From 28d5f541b451a05b362ec8c8dee9556d34eeefc8 Mon Sep 17 00:00:00 2001 From: Ilya Lesikov Date: Tue, 24 Mar 2026 06:45:05 +0300 Subject: [PATCH 01/85] Squashed 'pkg/helm/' content from commit f03cc04ca git-subtree-dir: pkg/helm git-subtree-split: f03cc04caaa8f6d7c3e67cf918929150cf6f3f12 --- .circleci/config.yml | 14 + .github/dependabot.yml | 21 + .github/issue_template.md | 9 + .github/pull_request_template.md | 12 + .github/workflows/build-test.yml | 26 + .github/workflows/codeql-analysis.yml | 67 + .github/workflows/golangci-lint.yml | 22 + .github/workflows/release.yml | 102 ++ .github/workflows/stale-issue-bot.yaml | 16 + .gitignore | 13 + .golangci.yml | 25 + ADOPTERS.md | 16 + CONTRIBUTING.md | 352 +++++ KEYS | 942 ++++++++++++ LICENSE | 202 +++ Makefile | 239 +++ OWNERS | 29 + README.md | 75 + SECURITY.md | 3 + cmd/helm/completion.go | 215 +++ cmd/helm/completion_test.go | 93 ++ cmd/helm/create.go | 113 ++ cmd/helm/create_test.go | 198 +++ cmd/helm/dependency.go | 122 ++ cmd/helm/dependency_build.go | 93 ++ cmd/helm/dependency_build_test.go | 164 +++ cmd/helm/dependency_test.go | 57 + cmd/helm/dependency_update.go | 84 ++ cmd/helm/dependency_update_test.go | 303 ++++ cmd/helm/docs.go | 104 ++ cmd/helm/docs_test.go | 38 + cmd/helm/env.go | 76 + cmd/helm/env_test.go | 35 + cmd/helm/flags.go | 252 ++++ cmd/helm/flags_test.go | 95 ++ cmd/helm/get.go | 55 + cmd/helm/get_all.go | 82 ++ cmd/helm/get_all_test.go | 56 + cmd/helm/get_hooks.go | 75 + cmd/helm/get_hooks_test.go | 51 + cmd/helm/get_manifest.go | 75 + cmd/helm/get_manifest_test.go | 51 + cmd/helm/get_metadata.go | 94 ++ cmd/helm/get_metadata_test.go | 66 + cmd/helm/get_notes.go | 74 + cmd/helm/get_notes_test.go | 51 + cmd/helm/get_test.go | 25 + cmd/helm/get_values.go | 98 ++ cmd/helm/get_values_test.go | 71 + cmd/helm/helm.go | 130 ++ cmd/helm/helm_test.go | 222 +++ cmd/helm/history.go | 200 +++ cmd/helm/history_test.go | 124 ++ cmd/helm/install.go | 350 +++++ cmd/helm/install_test.go | 303 ++++ cmd/helm/lint.go | 155 ++ cmd/helm/lint_test.go | 97 ++ cmd/helm/list.go | 251 ++++ cmd/helm/list_test.go | 246 ++++ cmd/helm/load_plugins.go | 377 +++++ cmd/helm/package.go | 124 ++ cmd/helm/package_test.go | 197 +++ cmd/helm/plugin.go | 72 + cmd/helm/plugin_install.go | 94 ++ cmd/helm/plugin_list.go | 88 ++ cmd/helm/plugin_test.go | 453 ++++++ cmd/helm/plugin_uninstall.go | 100 ++ cmd/helm/plugin_update.go | 114 ++ cmd/helm/printer.go | 30 + cmd/helm/pull.go | 105 ++ cmd/helm/pull_test.go | 396 +++++ cmd/helm/push.go | 101 ++ cmd/helm/push_test.go | 27 + cmd/helm/registry.go | 41 + cmd/helm/registry_login.go | 151 ++ cmd/helm/registry_login_test.go | 25 + cmd/helm/registry_logout.go | 44 + cmd/helm/registry_logout_test.go | 25 + cmd/helm/release_testing.go | 97 ++ cmd/helm/release_testing_test.go | 30 + cmd/helm/repo.go | 54 + cmd/helm/repo_add.go | 220 +++ cmd/helm/repo_add_test.go | 273 ++++ cmd/helm/repo_index.go | 118 ++ cmd/helm/repo_index_test.go | 194 +++ cmd/helm/repo_list.go | 137 ++ cmd/helm/repo_list_test.go | 29 + cmd/helm/repo_remove.go | 96 ++ cmd/helm/repo_remove_test.go | 216 +++ cmd/helm/repo_test.go | 25 + cmd/helm/repo_update.go | 167 +++ cmd/helm/repo_update_test.go | 238 +++ cmd/helm/require/args.go | 88 ++ cmd/helm/require/args_test.go | 91 ++ cmd/helm/rollback.go | 90 ++ cmd/helm/rollback_test.go | 166 +++ cmd/helm/root.go | 304 ++++ cmd/helm/root_test.go | 131 ++ cmd/helm/root_unix.go | 58 + cmd/helm/root_unix_test.go | 82 ++ cmd/helm/root_windows.go | 22 + cmd/helm/search.go | 43 + cmd/helm/search/search.go | 227 +++ cmd/helm/search/search_test.go | 311 ++++ cmd/helm/search_hub.go | 198 +++ cmd/helm/search_hub_test.go | 187 +++ cmd/helm/search_repo.go | 396 +++++ cmd/helm/search_repo_test.go | 107 ++ cmd/helm/search_test.go | 23 + cmd/helm/show.go | 236 +++ cmd/helm/show_test.go | 155 ++ cmd/helm/status.go | 240 +++ cmd/helm/status_test.go | 220 +++ cmd/helm/template.go | 262 ++++ cmd/helm/template_test.go | 203 +++ .../helm/plugins/fullenv/completion.yaml | 19 + .../helm/plugins/fullenv/fullenv.sh | 7 + .../helm/plugins/fullenv/plugin.yaml | 4 + .../helm/repositories.yaml | 6 + .../helm/repository/test-name-charts.txt | 0 .../helm/repository/test-name-index.yaml | 3 + .../helm/repository/testing-index.yaml | 66 + cmd/helm/testdata/helm-test-key.pub | Bin 0 -> 1243 bytes cmd/helm/testdata/helm-test-key.secret | Bin 0 -> 2545 bytes .../helmhome/helm/plugins/args/args.sh | 2 + .../helm/plugins/args/plugin.complete | 13 + .../helmhome/helm/plugins/args/plugin.yaml | 4 + .../helm/plugins/echo/completion.yaml | 0 .../helm/plugins/echo/plugin.complete | 13 + .../helmhome/helm/plugins/echo/plugin.yaml | 4 + .../helmhome/helm/plugins/env/completion.yaml | 13 + .../helmhome/helm/plugins/env/plugin.yaml | 4 + .../helm/plugins/exitwith/completion.yaml | 5 + .../helm/plugins/exitwith/exitwith.sh | 2 + .../helm/plugins/exitwith/plugin.yaml | 4 + .../helm/plugins/fullenv/completion.yaml | 19 + .../helmhome/helm/plugins/fullenv/fullenv.sh | 7 + .../helmhome/helm/plugins/fullenv/plugin.yaml | 4 + .../testdata/helmhome/helm/repositories.yaml | 6 + .../helm/repository/test-name-charts.txt | 0 .../helm/repository/test-name-index.yaml | 3 + .../helm/repository/testing-index.yaml | 66 + .../output/chart-with-subchart-update.txt | 8 + .../output/dependency-list-archive.txt | 5 + .../output/dependency-list-no-chart-linux.txt | 1 + .../dependency-list-no-requirements-linux.txt | 1 + cmd/helm/testdata/output/dependency-list.txt | 5 + cmd/helm/testdata/output/deprecated-chart.txt | 6 + cmd/helm/testdata/output/docs-type-comp.txt | 5 + .../testdata/output/empty_default_comp.txt | 2 + .../testdata/output/empty_nofile_comp.txt | 2 + cmd/helm/testdata/output/env-comp.txt | 23 + cmd/helm/testdata/output/get-all-no-args.txt | 3 + .../testdata/output/get-hooks-no-args.txt | 3 + cmd/helm/testdata/output/get-hooks.txt | 8 + .../testdata/output/get-manifest-no-args.txt | 3 + cmd/helm/testdata/output/get-manifest.txt | 5 + .../testdata/output/get-metadata-args.txt | 3 + cmd/helm/testdata/output/get-metadata.json | 1 + cmd/helm/testdata/output/get-metadata.txt | 8 + cmd/helm/testdata/output/get-metadata.yaml | 8 + .../testdata/output/get-notes-no-args.txt | 3 + cmd/helm/testdata/output/get-notes.txt | 2 + .../testdata/output/get-release-template.txt | 1 + cmd/helm/testdata/output/get-release.txt | 32 + cmd/helm/testdata/output/get-values-all.txt | 2 + cmd/helm/testdata/output/get-values-args.txt | 3 + cmd/helm/testdata/output/get-values.txt | 2 + cmd/helm/testdata/output/history-limit.txt | 3 + cmd/helm/testdata/output/history.json | 1 + cmd/helm/testdata/output/history.txt | 5 + cmd/helm/testdata/output/history.yaml | 12 + .../testdata/output/install-and-replace.txt | 6 + .../output/install-chart-bad-type.txt | 1 + .../testdata/output/install-lib-chart.txt | 1 + .../testdata/output/install-name-template.txt | 6 + cmd/helm/testdata/output/install-no-args.txt | 3 + cmd/helm/testdata/output/install-no-hooks.txt | 6 + .../install-with-multiple-values-files.txt | 6 + .../output/install-with-multiple-values.txt | 6 + .../testdata/output/install-with-timeout.txt | 6 + .../output/install-with-values-file.txt | 6 + .../testdata/output/install-with-values.txt | 6 + .../output/install-with-wait-for-jobs.txt | 6 + .../testdata/output/install-with-wait.txt | 6 + cmd/helm/testdata/output/install.txt | 6 + cmd/helm/testdata/output/issue-9027.txt | 32 + ...hart-with-bad-subcharts-with-subcharts.txt | 19 + .../output/lint-chart-with-bad-subcharts.txt | 7 + ...lint-chart-with-deprecated-api-old-k8s.txt | 4 + .../lint-chart-with-deprecated-api-strict.txt | 5 + .../output/lint-chart-with-deprecated-api.txt | 5 + .../testdata/output/lint-quiet-with-error.txt | 8 + .../output/lint-quiet-with-warning.txt | 0 cmd/helm/testdata/output/lint-quiet.txt | 0 cmd/helm/testdata/output/list-all.txt | 9 + .../testdata/output/list-date-reversed.txt | 5 + cmd/helm/testdata/output/list-date.txt | 5 + cmd/helm/testdata/output/list-failed.txt | 2 + cmd/helm/testdata/output/list-filter.txt | 5 + cmd/helm/testdata/output/list-max.txt | 2 + cmd/helm/testdata/output/list-namespace.txt | 2 + cmd/helm/testdata/output/list-no-headers.txt | 4 + cmd/helm/testdata/output/list-offset.txt | 4 + cmd/helm/testdata/output/list-pending.txt | 2 + cmd/helm/testdata/output/list-reverse.txt | 5 + cmd/helm/testdata/output/list-short-json.txt | 1 + cmd/helm/testdata/output/list-short-yaml.txt | 4 + cmd/helm/testdata/output/list-short.txt | 4 + cmd/helm/testdata/output/list-superseded.txt | 3 + cmd/helm/testdata/output/list-uninstalled.txt | 2 + .../testdata/output/list-uninstalling.txt | 2 + cmd/helm/testdata/output/list.txt | 5 + cmd/helm/testdata/output/object-order.txt | 191 +++ cmd/helm/testdata/output/output-comp.txt | 5 + cmd/helm/testdata/output/plugin_args_comp.txt | 6 + .../testdata/output/plugin_args_flag_comp.txt | 6 + .../output/plugin_args_many_args_comp.txt | 6 + .../testdata/output/plugin_args_ns_comp.txt | 6 + .../output/plugin_echo_no_directive.txt | 6 + cmd/helm/testdata/output/plugin_list_comp.txt | 7 + .../testdata/output/plugin_repeat_comp.txt | 6 + .../testdata/output/release_list_comp.txt | 5 + .../output/release_list_repeat_comp.txt | 4 + cmd/helm/testdata/output/repo-add.txt | 1 + cmd/helm/testdata/output/repo-add2.txt | 1 + cmd/helm/testdata/output/repo_list_comp.txt | 5 + cmd/helm/testdata/output/repo_repeat_comp.txt | 4 + cmd/helm/testdata/output/revision-comp.txt | 6 + .../output/revision-wrong-args-comp.txt | 2 + cmd/helm/testdata/output/rollback-comp.txt | 4 + cmd/helm/testdata/output/rollback-no-args.txt | 3 + .../testdata/output/rollback-no-revision.txt | 1 + .../output/rollback-non-existent-version.txt | 1 + cmd/helm/testdata/output/rollback-timeout.txt | 1 + .../output/rollback-wait-for-jobs.txt | 1 + cmd/helm/testdata/output/rollback-wait.txt | 1 + .../output/rollback-wrong-args-comp.txt | 2 + cmd/helm/testdata/output/rollback.txt | 1 + .../testdata/output/schema-negative-cli.txt | 4 + cmd/helm/testdata/output/schema-negative.txt | 5 + cmd/helm/testdata/output/schema.txt | 6 + .../output/search-constraint-single.txt | 2 + .../testdata/output/search-constraint.txt | 2 + .../output/search-multiple-devel-release.txt | 2 + .../output/search-multiple-stable-release.txt | 2 + .../search-multiple-versions-constraints.txt | 3 + .../output/search-multiple-versions.txt | 3 + .../output/search-not-found-error.txt | 1 + cmd/helm/testdata/output/search-not-found.txt | 1 + .../testdata/output/search-output-json.txt | 1 + .../testdata/output/search-output-yaml.txt | 4 + cmd/helm/testdata/output/search-regex.txt | 2 + .../output/search-versions-constraint.txt | 2 + cmd/helm/testdata/output/status-comp.txt | 5 + cmd/helm/testdata/output/status-with-desc.txt | 7 + .../testdata/output/status-with-notes.txt | 8 + .../output/status-with-resources.json | 1 + .../testdata/output/status-with-resources.txt | 6 + .../output/status-with-test-suite.txt | 13 + .../output/status-wrong-args-comp.txt | 2 + cmd/helm/testdata/output/status.json | 1 + cmd/helm/testdata/output/status.txt | 6 + .../output/subchart-schema-cli-negative.txt | 4 + .../testdata/output/subchart-schema-cli.txt | 6 + .../output/subchart-schema-negative.txt | 6 + .../output/template-chart-bad-type.txt | 1 + ...te-chart-with-template-lib-archive-dep.txt | 61 + .../template-chart-with-template-lib-dep.txt | 61 + .../testdata/output/template-lib-chart.txt | 1 + .../output/template-name-template.txt | 114 ++ cmd/helm/testdata/output/template-no-args.txt | 3 + cmd/helm/testdata/output/template-set.txt | 114 ++ .../output/template-show-only-glob.txt | 24 + .../output/template-show-only-multiple.txt | 38 + .../output/template-show-only-one.txt | 21 + .../testdata/output/template-skip-tests.txt | 85 ++ .../output/template-subchart-cm-set-file.txt | 122 ++ .../output/template-subchart-cm-set.txt | 122 ++ .../testdata/output/template-subchart-cm.txt | 122 ++ .../testdata/output/template-values-files.txt | 114 ++ .../output/template-with-api-version.txt | 115 ++ .../testdata/output/template-with-crds.txt | 131 ++ .../template-with-invalid-yaml-debug.txt | 13 + .../output/template-with-invalid-yaml.txt | 3 + .../output/template-with-kube-version.txt | 114 ++ cmd/helm/testdata/output/template.txt | 114 ++ .../output/uninstall-keep-history.txt | 1 + .../testdata/output/uninstall-multiple.txt | 2 + .../testdata/output/uninstall-no-args.txt | 3 + .../testdata/output/uninstall-no-hooks.txt | 1 + .../testdata/output/uninstall-timeout.txt | 1 + cmd/helm/testdata/output/uninstall-wait.txt | 1 + cmd/helm/testdata/output/uninstall.txt | 1 + .../output/upgrade-with-bad-dependencies.txt | 1 + ...e-with-bad-or-missing-existing-release.txt | 1 + .../output/upgrade-with-dependency-update.txt | 9 + .../output/upgrade-with-install-timeout.txt | 7 + .../testdata/output/upgrade-with-install.txt | 7 + .../upgrade-with-missing-dependencies.txt | 1 + .../output/upgrade-with-pending-install.txt | 1 + .../output/upgrade-with-reset-values.txt | 7 + .../output/upgrade-with-reset-values2.txt | 7 + .../testdata/output/upgrade-with-timeout.txt | 7 + .../output/upgrade-with-wait-for-jobs.txt | 7 + .../testdata/output/upgrade-with-wait.txt | 7 + cmd/helm/testdata/output/upgrade.txt | 7 + cmd/helm/testdata/output/values.json | 1 + cmd/helm/testdata/output/values.yaml | 1 + .../output/version-client-shorthand.txt | 1 + cmd/helm/testdata/output/version-client.txt | 1 + cmd/helm/testdata/output/version-comp.txt | 5 + .../testdata/output/version-invalid-comp.txt | 2 + cmd/helm/testdata/output/version-short.txt | 1 + cmd/helm/testdata/output/version-template.txt | 1 + cmd/helm/testdata/output/version.txt | 1 + cmd/helm/testdata/password | 1 + cmd/helm/testdata/plugins.yaml | 3 + cmd/helm/testdata/repositories.yaml | 9 + .../testdata/testcharts/alpine/Chart.yaml | 8 + cmd/helm/testdata/testcharts/alpine/README.md | 13 + .../testcharts/alpine/extra_values.yaml | 2 + .../testcharts/alpine/more_values.yaml | 2 + .../alpine/templates/alpine-pod.yaml | 27 + .../testdata/testcharts/alpine/values.yaml | 1 + .../chart-bad-requirements/.helmignore | 21 + .../chart-bad-requirements/Chart.yaml | 8 + .../charts/reqsubchart/.helmignore | 21 + .../charts/reqsubchart/Chart.yaml | 4 + .../charts/reqsubchart/values.yaml | 4 + .../chart-bad-requirements/values.yaml | 4 + .../testcharts/chart-bad-type/Chart.yaml | 8 + .../testcharts/chart-bad-type/README.md | 13 + .../chart-bad-type/extra_values.yaml | 2 + .../chart-bad-type/more_values.yaml | 2 + .../chart-bad-type/templates/alpine-pod.yaml | 25 + .../testcharts/chart-bad-type/values.yaml | 1 + .../testcharts/chart-missing-deps/.helmignore | 21 + .../testcharts/chart-missing-deps/Chart.yaml | 11 + .../charts/reqsubchart/.helmignore | 21 + .../charts/reqsubchart/Chart.yaml | 4 + .../charts/reqsubchart/values.yaml | 4 + .../testcharts/chart-missing-deps/values.yaml | 4 + .../chart-with-bad-subcharts/Chart.yaml | 4 + .../charts/bad-subchart/Chart.yaml | 1 + .../charts/bad-subchart/values.yaml | 0 .../charts/good-subchart/Chart.yaml | 4 + .../charts/good-subchart/values.yaml | 0 .../requirements.yaml | 5 + .../chart-with-bad-subcharts/values.yaml | 0 .../chart-with-deprecated-api/Chart.yaml | 6 + .../templates/horizontalpodautoscaler.yaml | 9 + .../chart-with-deprecated-api/values.yaml | 0 .../testcharts/chart-with-lib-dep/.helmignore | 21 + .../testcharts/chart-with-lib-dep/Chart.yaml | 6 + .../charts/common-0.0.5.tgz | Bin 0 -> 8347 bytes .../chart-with-lib-dep/templates/NOTES.txt | 19 + .../chart-with-lib-dep/templates/_helpers.tpl | 32 + .../templates/deployment.yaml | 51 + .../chart-with-lib-dep/templates/ingress.yaml | 38 + .../chart-with-lib-dep/templates/service.yaml | 10 + .../testcharts/chart-with-lib-dep/values.yaml | 48 + .../chart-with-only-crds/.helmignore | 23 + .../chart-with-only-crds/Chart.yaml | 21 + .../chart-with-only-crds/crds/test-crd.yaml | 19 + .../chart-with-schema-and-subchart/Chart.yaml | 6 + .../charts/subchart-with-schema/Chart.yaml | 6 + .../subchart-with-schema/templates/empty.yaml | 1 + .../subchart-with-schema/values.schema.json | 15 + .../charts/subchart-with-schema/values.yaml | 0 .../templates/empty.yaml | 1 + .../values.schema.json | 18 + .../values.yaml | 1 + .../chart-with-schema-negative/Chart.yaml | 7 + .../templates/empty.yaml | 1 + .../values.schema.json | 67 + .../chart-with-schema-negative/values.yaml | 14 + .../testcharts/chart-with-schema/Chart.yaml | 7 + .../chart-with-schema/extra-values.yaml | 2 + .../chart-with-schema/templates/empty.yaml | 1 + .../chart-with-schema/values.schema.json | 67 + .../testcharts/chart-with-schema/values.yaml | 17 + .../chart-with-subchart-notes/Chart.yaml | 7 + .../charts/subchart-with-notes/Chart.yaml | 4 + .../subchart-with-notes/templates/NOTES.txt | 1 + .../templates/NOTES.txt | 1 + .../chart-with-subchart-update/Chart.lock | 6 + .../chart-with-subchart-update/Chart.yaml | 8 + .../charts/subchart-with-notes/Chart.yaml | 4 + .../subchart-with-notes/templates/NOTES.txt | 1 + .../templates/NOTES.txt | 1 + .../.helmignore | 21 + .../Chart.yaml | 6 + .../charts/common-0.0.5.tgz | Bin 0 -> 8446 bytes .../templates/NOTES.txt | 19 + .../templates/_helpers.tpl | 32 + .../templates/deployment.yaml | 51 + .../templates/ingress.yaml | 38 + .../templates/service.yaml | 10 + .../values.yaml | 48 + .../chart-with-template-lib-dep/.helmignore | 21 + .../chart-with-template-lib-dep/Chart.yaml | 6 + .../charts/common/.helmignore | 21 + .../charts/common/Chart.yaml | 12 + .../charts/common/README.md | 831 +++++++++++ .../charts/common/templates/_chartref.tpl | 14 + .../charts/common/templates/_configmap.yaml | 9 + .../charts/common/templates/_container.yaml | 15 + .../charts/common/templates/_deployment.yaml | 18 + .../charts/common/templates/_envvar.tpl | 31 + .../charts/common/templates/_fullname.tpl | 39 + .../charts/common/templates/_ingress.yaml | 27 + .../charts/common/templates/_metadata.yaml | 10 + .../templates/_metadata_annotations.tpl | 18 + .../common/templates/_metadata_labels.tpl | 28 + .../charts/common/templates/_name.tpl | 29 + .../templates/_persistentvolumeclaim.yaml | 24 + .../charts/common/templates/_secret.yaml | 10 + .../charts/common/templates/_service.yaml | 17 + .../charts/common/templates/_util.tpl | 15 + .../charts/common/templates/_volume.tpl | 22 + .../charts/common/templates/configmap.yaml | 6 + .../charts/common/values.yaml | 4 + .../templates/NOTES.txt | 19 + .../templates/_helpers.tpl | 32 + .../templates/deployment.yaml | 51 + .../templates/ingress.yaml | 38 + .../templates/service.yaml | 10 + .../chart-with-template-lib-dep/values.yaml | 48 + .../Chart.yaml | 8 + .../README.md | 13 + .../templates/alpine-pod.yaml | 10 + .../values.yaml | 1 + .../testcharts/compressedchart-0.1.0.tar.gz | Bin 0 -> 477 bytes .../testcharts/compressedchart-0.1.0.tgz | Bin 0 -> 477 bytes .../testcharts/compressedchart-0.2.0.tgz | Bin 0 -> 477 bytes .../testcharts/compressedchart-0.3.0.tgz | Bin 0 -> 477 bytes .../compressedchart-with-hyphens-0.1.0.tgz | Bin 0 -> 548 bytes .../testdata/testcharts/deprecated/Chart.yaml | 8 + .../testdata/testcharts/deprecated/README.md | 3 + cmd/helm/testdata/testcharts/empty/Chart.yaml | 7 + cmd/helm/testdata/testcharts/empty/README.md | 3 + .../testcharts/empty/templates/empty.yaml | 1 + .../testdata/testcharts/empty/values.yaml | 1 + .../testcharts/issue-7233/.helmignore | 22 + .../testdata/testcharts/issue-7233/Chart.yaml | 5 + .../testcharts/issue-7233/requirements.lock | 6 + .../testcharts/issue-7233/requirements.yaml | 4 + .../issue-7233/templates/configmap.yaml | 7 + .../testcharts/issue-7233/values.yaml | 1 + .../testdata/testcharts/issue-9027/Chart.yaml | 6 + .../issue-9027/charts/subchart/Chart.yaml | 3 + .../charts/subchart/templates/values.yaml | 1 + .../issue-9027/charts/subchart/values.yaml | 17 + .../issue-9027/templates/values.yaml | 1 + .../testcharts/issue-9027/values.yaml | 11 + .../testdata/testcharts/issue1979/Chart.yaml | 7 + .../testdata/testcharts/issue1979/README.md | 13 + .../testcharts/issue1979/extra_values.yaml | 2 + .../testcharts/issue1979/more_values.yaml | 2 + .../issue1979/templates/alpine-pod.yaml | 26 + .../testdata/testcharts/issue1979/values.yaml | 2 + .../testdata/testcharts/lib-chart/.helmignore | 21 + .../testdata/testcharts/lib-chart/Chart.yaml | 12 + .../testdata/testcharts/lib-chart/README.md | 831 +++++++++++ .../lib-chart/templates/_chartref.tpl | 14 + .../lib-chart/templates/_configmap.yaml | 9 + .../lib-chart/templates/_container.yaml | 15 + .../lib-chart/templates/_deployment.yaml | 18 + .../lib-chart/templates/_envvar.tpl | 31 + .../lib-chart/templates/_fullname.tpl | 39 + .../lib-chart/templates/_ingress.yaml | 27 + .../lib-chart/templates/_metadata.yaml | 10 + .../templates/_metadata_annotations.tpl | 18 + .../lib-chart/templates/_metadata_labels.tpl | 28 + .../testcharts/lib-chart/templates/_name.tpl | 29 + .../templates/_persistentvolumeclaim.yaml | 24 + .../lib-chart/templates/_secret.yaml | 10 + .../lib-chart/templates/_service.yaml | 17 + .../testcharts/lib-chart/templates/_util.tpl | 15 + .../lib-chart/templates/_volume.tpl | 22 + .../testdata/testcharts/lib-chart/values.yaml | 4 + .../testcharts/object-order/Chart.yaml | 5 + .../object-order/templates/01-a.yml | 57 + .../object-order/templates/02-b.yml | 143 ++ .../testcharts/object-order/values.yaml | 0 .../testcharts/oci-dependent-chart-0.1.0.tgz | Bin 0 -> 3599 bytes .../pre-release-chart-0.1.0-alpha.tgz | Bin 0 -> 355 bytes .../testdata/testcharts/reqtest-0.1.0.tgz | Bin 0 -> 798 bytes .../testdata/testcharts/reqtest/.helmignore | 21 + .../testdata/testcharts/reqtest/Chart.lock | 3 + .../testdata/testcharts/reqtest/Chart.yaml | 14 + .../reqtest/charts/reqsubchart/.helmignore | 21 + .../reqtest/charts/reqsubchart/Chart.yaml | 4 + .../reqtest/charts/reqsubchart/values.yaml | 4 + .../reqtest/charts/reqsubchart2/.helmignore | 21 + .../reqtest/charts/reqsubchart2/Chart.yaml | 4 + .../reqtest/charts/reqsubchart2/values.yaml | 4 + .../reqtest/charts/reqsubchart3-0.2.0.tgz | Bin 0 -> 498 bytes .../testdata/testcharts/reqtest/values.yaml | 4 + .../testdata/testcharts/signtest-0.1.0.tgz | Bin 0 -> 973 bytes .../testcharts/signtest-0.1.0.tgz.prov | 21 + .../testdata/testcharts/signtest/.helmignore | 5 + .../testdata/testcharts/signtest/Chart.yaml | 4 + .../testcharts/signtest/alpine/Chart.yaml | 7 + .../testcharts/signtest/alpine/README.md | 9 + .../signtest/alpine/templates/alpine-pod.yaml | 14 + .../testcharts/signtest/alpine/values.yaml | 2 + .../testcharts/signtest/templates/pod.yaml | 10 + .../testdata/testcharts/signtest/values.yaml | 0 .../testdata/testcharts/subchart/Chart.yaml | 39 + .../subchart/charts/subchartA/Chart.yaml | 4 + .../charts/subchartA/templates/service.yaml | 15 + .../subchart/charts/subchartA/values.yaml | 17 + .../subchart/charts/subchartB/Chart.yaml | 4 + .../charts/subchartB/templates/service.yaml | 15 + .../subchart/charts/subchartB/values.yaml | 39 + .../testcharts/subchart/crds/crdA.yaml | 14 + .../testcharts/subchart/extra_values.yaml | 5 + .../testcharts/subchart/templates/NOTES.txt | 1 + .../subchart/templates/service.yaml | 22 + .../subchart/templates/subdir/configmap.yaml | 8 + .../subchart/templates/subdir/role.yaml | 8 + .../templates/subdir/rolebinding.yaml | 12 + .../templates/subdir/serviceaccount.yaml | 4 + .../subchart/templates/tests/test-config.yaml | 8 + .../templates/tests/test-nothing.yaml | 17 + .../testdata/testcharts/subchart/values.yaml | 59 + .../upgradetest/templates/configmap.yaml | 7 + .../testcharts/upgradetest/values.yaml | 1 + cmd/helm/testdata/testplugin/plugin.yaml | 4 + cmd/helm/testdata/testserver/index.yaml | 1 + .../testserver/repository/repositories.yaml | 6 + cmd/helm/uninstall.go | 92 ++ cmd/helm/uninstall_test.go | 83 ++ cmd/helm/upgrade.go | 287 ++++ cmd/helm/upgrade_test.go | 460 ++++++ cmd/helm/verify.go | 70 + cmd/helm/verify_test.go | 97 ++ cmd/helm/version.go | 103 ++ cmd/helm/version_test.go | 49 + code-of-conduct.md | 3 + go.mod | 169 +++ go.sum | 598 ++++++++ internal/fileutil/fileutil.go | 50 + internal/fileutil/fileutil_test.go | 57 + internal/monocular/client.go | 68 + internal/monocular/client_test.go | 31 + internal/monocular/doc.go | 22 + internal/monocular/search.go | 145 ++ internal/monocular/search_test.go | 49 + internal/resolver/resolver.go | 263 ++++ internal/resolver/resolver_test.go | 310 ++++ .../testdata/chartpath/base/Chart.yaml | 3 + .../charts/localdependency/Chart.yaml | 3 + .../repository/kubernetes-charts-index.yaml | 49 + internal/sympath/walk.go | 119 ++ internal/sympath/walk_test.go | 151 ++ internal/test/ensure/ensure.go | 55 + internal/test/test.go | 96 ++ internal/third_party/dep/fs/fs.go | 372 +++++ internal/third_party/dep/fs/fs_test.go | 642 ++++++++ internal/third_party/dep/fs/rename.go | 58 + internal/third_party/dep/fs/rename_windows.go | 69 + .../dep/fs/testdata/symlinks/file-symlink | 1 + .../dep/fs/testdata/symlinks/invalid-symlink | 1 + .../fs/testdata/symlinks/windows-file-symlink | 1 + .../third_party/dep/fs/testdata/test.file | 0 .../deployment/util/deploymentutil.go | 178 +++ internal/tlsutil/cfg.go | 58 + internal/tlsutil/tls.go | 78 + internal/tlsutil/tlsutil_test.go | 114 ++ internal/urlutil/urlutil.go | 73 + internal/urlutil/urlutil_test.go | 81 + internal/version/version.go | 81 + pkg/action/action.go | 424 ++++++ pkg/action/action_test.go | 283 ++++ pkg/action/dependency.go | 230 +++ pkg/action/dependency_test.go | 152 ++ pkg/action/doc.go | 22 + pkg/action/get.go | 47 + pkg/action/get_metadata.go | 69 + pkg/action/get_values.go | 60 + pkg/action/history.go | 58 + pkg/action/hooks.go | 159 ++ pkg/action/install.go | 815 +++++++++++ pkg/action/install_test.go | 763 ++++++++++ pkg/action/lazyclient.go | 197 +++ pkg/action/lint.go | 129 ++ pkg/action/lint_test.go | 159 ++ pkg/action/list.go | 324 ++++ pkg/action/list_test.go | 368 +++++ pkg/action/package.go | 181 +++ pkg/action/package_test.go | 121 ++ pkg/action/pull.go | 172 +++ pkg/action/push.go | 112 ++ pkg/action/registry_login.go | 88 ++ pkg/action/registry_logout.go | 38 + pkg/action/release_testing.go | 152 ++ pkg/action/resource_policy.go | 46 + pkg/action/rollback.go | 265 ++++ pkg/action/show.go | 165 +++ pkg/action/show_test.go | 153 ++ pkg/action/status.go | 95 ++ .../charts/chart-missing-deps/Chart.yaml | 2 + .../chart-missing-deps/requirements.lock | 6 + .../chart-missing-deps/requirements.yaml | 7 + ...art-with-compressed-dependencies-2.1.8.tgz | Bin 0 -> 10962 bytes .../Chart.yaml | 2 + .../charts/mariadb-4.3.1.tgz | Bin 0 -> 8401 bytes .../requirements.lock | 6 + .../requirements.yaml | 7 + .../chart-with-no-templates-dir/Chart.yaml | 5 + .../chart-with-schema-negative/Chart.yaml | 7 + .../templates/empty.yaml | 1 + .../values.schema.json | 67 + .../chart-with-schema-negative/values.yaml | 14 + .../charts/chart-with-schema/Chart.yaml | 7 + .../chart-with-schema/extra-values.yaml | 2 + .../chart-with-schema/templates/empty.yaml | 1 + .../chart-with-schema/values.schema.json | 67 + .../charts/chart-with-schema/values.yaml | 17 + ...t-with-uncompressed-dependencies-2.1.8.tgz | Bin 0 -> 10953 bytes .../.helmignore | 5 + .../Chart.yaml | 20 + .../README.md | 3 + .../charts/mariadb/.helmignore | 1 + .../charts/mariadb/Chart.yaml | 21 + .../charts/mariadb/README.md | 143 ++ .../docker-entrypoint-initdb.d/README.md | 3 + .../charts/mariadb/templates/NOTES.txt | 35 + .../charts/mariadb/templates/_helpers.tpl | 53 + .../templates/initialization-configmap.yaml | 12 + .../mariadb/templates/master-configmap.yaml | 15 + .../mariadb/templates/master-statefulset.yaml | 187 +++ .../charts/mariadb/templates/master-svc.yaml | 29 + .../charts/mariadb/templates/secrets.yaml | 38 + .../mariadb/templates/slave-configmap.yaml | 15 + .../mariadb/templates/slave-statefulset.yaml | 193 +++ .../charts/mariadb/templates/slave-svc.yaml | 31 + .../charts/mariadb/templates/test-runner.yaml | 44 + .../charts/mariadb/templates/tests.yaml | 9 + .../charts/mariadb/values.yaml | 233 +++ .../requirements.lock | 6 + .../requirements.yaml | 7 + .../templates/NOTES.txt | 1 + .../values.yaml | 254 ++++ .../charts/compressedchart-0.1.0.tar.gz | Bin 0 -> 477 bytes .../testdata/charts/compressedchart-0.1.0.tgz | Bin 0 -> 477 bytes .../testdata/charts/compressedchart-0.2.0.tgz | Bin 0 -> 477 bytes .../testdata/charts/compressedchart-0.3.0.tgz | Bin 0 -> 477 bytes .../compressedchart-with-hyphens-0.1.0.tgz | Bin 0 -> 548 bytes .../charts/corrupted-compressed-chart.tgz | 0 .../charts/decompressedchart/Chart.yaml | 4 + .../charts/decompressedchart/values.yaml | 4 + .../multiplecharts-lint-chart-1/Chart.yaml | 4 + .../templates/configmap.yaml | 6 + .../multiplecharts-lint-chart-1/values.yaml | 1 + .../multiplecharts-lint-chart-2/Chart.yaml | 4 + .../templates/configmap.yaml | 5 + .../multiplecharts-lint-chart-2/values.yaml | 2 + .../charts/pre-release-chart-0.1.0-alpha.tgz | Bin 0 -> 355 bytes .../output/list-compressed-deps-tgz.txt | 3 + .../testdata/output/list-compressed-deps.txt | 3 + .../testdata/output/list-missing-deps.txt | 3 + .../output/list-uncompressed-deps-tgz.txt | 3 + .../output/list-uncompressed-deps.txt | 3 + pkg/action/testdata/rbac.txt | 25 + pkg/action/uninstall.go | 251 ++++ pkg/action/uninstall_test.go | 140 ++ pkg/action/upgrade.go | 627 ++++++++ pkg/action/upgrade_test.go | 537 +++++++ pkg/action/validate.go | 184 +++ pkg/action/validate_test.go | 123 ++ pkg/action/verify.go | 59 + pkg/chart/chart.go | 173 +++ pkg/chart/chart_test.go | 211 +++ pkg/chart/dependency.go | 82 ++ pkg/chart/dependency_test.go | 44 + pkg/chart/errors.go | 30 + pkg/chart/file.go | 27 + pkg/chart/loader/archive.go | 205 +++ pkg/chart/loader/archive_test.go | 90 ++ pkg/chart/loader/directory.go | 119 ++ pkg/chart/loader/load.go | 200 +++ pkg/chart/loader/load_test.go | 648 ++++++++ pkg/chart/loader/testdata/LICENSE | 1 + .../loader/testdata/albatross/Chart.yaml | 4 + .../loader/testdata/albatross/values.yaml | 4 + pkg/chart/loader/testdata/frobnitz-1.2.3.tgz | Bin 0 -> 3482 bytes pkg/chart/loader/testdata/frobnitz.v1.tgz | Bin 0 -> 3525 bytes .../loader/testdata/frobnitz.v1/.helmignore | 1 + .../loader/testdata/frobnitz.v1/Chart.lock | 8 + .../loader/testdata/frobnitz.v1/Chart.yaml | 20 + .../loader/testdata/frobnitz.v1/INSTALL.txt | 1 + pkg/chart/loader/testdata/frobnitz.v1/LICENSE | 1 + .../loader/testdata/frobnitz.v1/README.md | 11 + .../testdata/frobnitz.v1/charts/_ignore_me | 1 + .../frobnitz.v1/charts/alpine/Chart.yaml | 5 + .../frobnitz.v1/charts/alpine/README.md | 9 + .../charts/alpine/charts/mast1/Chart.yaml | 5 + .../charts/alpine/charts/mast1/values.yaml | 4 + .../charts/alpine/charts/mast2-0.1.0.tgz | Bin 0 -> 252 bytes .../charts/alpine/templates/alpine-pod.yaml | 14 + .../frobnitz.v1/charts/alpine/values.yaml | 2 + .../frobnitz.v1/charts/mariner-4.3.2.tgz | Bin 0 -> 967 bytes .../testdata/frobnitz.v1/docs/README.md | 1 + .../loader/testdata/frobnitz.v1/icon.svg | 8 + .../loader/testdata/frobnitz.v1/ignore/me.txt | 0 .../testdata/frobnitz.v1/requirements.yaml | 7 + .../frobnitz.v1/templates/template.tpl | 1 + .../loader/testdata/frobnitz.v1/values.yaml | 6 + .../testdata/frobnitz.v2.reqs/.helmignore | 1 + .../testdata/frobnitz.v2.reqs/Chart.yaml | 20 + .../testdata/frobnitz.v2.reqs/INSTALL.txt | 1 + .../loader/testdata/frobnitz.v2.reqs/LICENSE | 1 + .../testdata/frobnitz.v2.reqs/README.md | 11 + .../frobnitz.v2.reqs/charts/_ignore_me | 1 + .../frobnitz.v2.reqs/charts/alpine/Chart.yaml | 5 + .../frobnitz.v2.reqs/charts/alpine/README.md | 9 + .../charts/alpine/charts/mast1/Chart.yaml | 5 + .../charts/alpine/charts/mast1/values.yaml | 4 + .../charts/alpine/charts/mast2-0.1.0.tgz | Bin 0 -> 252 bytes .../charts/alpine/templates/alpine-pod.yaml | 14 + .../charts/alpine/values.yaml | 2 + .../frobnitz.v2.reqs/charts/mariner-4.3.2.tgz | Bin 0 -> 967 bytes .../testdata/frobnitz.v2.reqs/docs/README.md | 1 + .../loader/testdata/frobnitz.v2.reqs/icon.svg | 8 + .../testdata/frobnitz.v2.reqs/ignore/me.txt | 0 .../frobnitz.v2.reqs/requirements.yaml | 7 + .../frobnitz.v2.reqs/templates/template.tpl | 1 + .../testdata/frobnitz.v2.reqs/values.yaml | 6 + .../loader/testdata/frobnitz/.helmignore | 1 + pkg/chart/loader/testdata/frobnitz/Chart.lock | 8 + pkg/chart/loader/testdata/frobnitz/Chart.yaml | 27 + .../loader/testdata/frobnitz/INSTALL.txt | 1 + pkg/chart/loader/testdata/frobnitz/LICENSE | 1 + pkg/chart/loader/testdata/frobnitz/README.md | 11 + .../testdata/frobnitz/charts/_ignore_me | 1 + .../frobnitz/charts/alpine/Chart.yaml | 5 + .../testdata/frobnitz/charts/alpine/README.md | 9 + .../charts/alpine/charts/mast1/Chart.yaml | 5 + .../charts/alpine/charts/mast1/values.yaml | 4 + .../charts/alpine/charts/mast2-0.1.0.tgz | Bin 0 -> 252 bytes .../charts/alpine/templates/alpine-pod.yaml | 14 + .../frobnitz/charts/alpine/values.yaml | 2 + .../frobnitz/charts/mariner-4.3.2.tgz | Bin 0 -> 967 bytes .../loader/testdata/frobnitz/docs/README.md | 1 + pkg/chart/loader/testdata/frobnitz/icon.svg | 8 + .../loader/testdata/frobnitz/ignore/me.txt | 0 .../testdata/frobnitz/templates/template.tpl | 1 + .../loader/testdata/frobnitz/values.yaml | 6 + .../testdata/frobnitz_backslash-1.2.3.tgz | Bin 0 -> 3490 bytes .../testdata/frobnitz_backslash/.helmignore | 1 + .../testdata/frobnitz_backslash/Chart.lock | 8 + .../testdata/frobnitz_backslash/Chart.yaml | 27 + .../testdata/frobnitz_backslash/INSTALL.txt | 1 + .../testdata/frobnitz_backslash/LICENSE | 1 + .../testdata/frobnitz_backslash/README.md | 11 + .../frobnitz_backslash/charts/_ignore_me | 1 + .../charts/alpine/Chart.yaml | 5 + .../charts/alpine/README.md | 9 + .../charts/alpine/charts/mast1/Chart.yaml | 5 + .../charts/alpine/charts/mast1/values.yaml | 4 + .../charts/alpine/charts/mast2-0.1.0.tgz | Bin 0 -> 252 bytes .../charts/alpine/templates/alpine-pod.yaml | 14 + .../charts/alpine/values.yaml | 2 + .../charts/mariner-4.3.2.tgz | Bin 0 -> 967 bytes .../frobnitz_backslash/docs/README.md | 1 + .../testdata/frobnitz_backslash/icon.svg | 8 + .../testdata/frobnitz_backslash/ignore/me.txt | 0 .../frobnitz_backslash/templates/template.tpl | 1 + .../testdata/frobnitz_backslash/values.yaml | 6 + .../loader/testdata/frobnitz_with_bom.tgz | Bin 0 -> 3523 bytes .../testdata/frobnitz_with_bom/.helmignore | 1 + .../testdata/frobnitz_with_bom/Chart.lock | 8 + .../testdata/frobnitz_with_bom/Chart.yaml | 27 + .../testdata/frobnitz_with_bom/INSTALL.txt | 1 + .../loader/testdata/frobnitz_with_bom/LICENSE | 1 + .../testdata/frobnitz_with_bom/README.md | 11 + .../frobnitz_with_bom/charts/_ignore_me | 1 + .../charts/alpine/Chart.yaml | 5 + .../frobnitz_with_bom/charts/alpine/README.md | 9 + .../charts/alpine/charts/mast1/Chart.yaml | 5 + .../charts/alpine/charts/mast1/values.yaml | 4 + .../charts/alpine/charts/mast2-0.1.0.tgz | Bin 0 -> 252 bytes .../charts/alpine/templates/alpine-pod.yaml | 14 + .../charts/alpine/values.yaml | 2 + .../charts/mariner-4.3.2.tgz | Bin 0 -> 967 bytes .../testdata/frobnitz_with_bom/docs/README.md | 1 + .../testdata/frobnitz_with_bom/icon.svg | 8 + .../testdata/frobnitz_with_bom/ignore/me.txt | 0 .../frobnitz_with_bom/templates/template.tpl | 1 + .../testdata/frobnitz_with_bom/values.yaml | 6 + .../frobnitz_with_dev_null/.helmignore | 1 + .../frobnitz_with_dev_null/Chart.lock | 8 + .../frobnitz_with_dev_null/Chart.yaml | 27 + .../frobnitz_with_dev_null/INSTALL.txt | 1 + .../testdata/frobnitz_with_dev_null/LICENSE | 1 + .../testdata/frobnitz_with_dev_null/README.md | 11 + .../frobnitz_with_dev_null/charts/_ignore_me | 1 + .../charts/alpine/Chart.yaml | 5 + .../charts/alpine/README.md | 9 + .../charts/alpine/charts/mast1/Chart.yaml | 5 + .../charts/alpine/charts/mast1/values.yaml | 4 + .../charts/alpine/charts/mast2-0.1.0.tgz | Bin 0 -> 252 bytes .../charts/alpine/templates/alpine-pod.yaml | 14 + .../charts/alpine/values.yaml | 2 + .../charts/mariner-4.3.2.tgz | Bin 0 -> 967 bytes .../frobnitz_with_dev_null/docs/README.md | 1 + .../testdata/frobnitz_with_dev_null/icon.svg | 8 + .../frobnitz_with_dev_null/ignore/me.txt | 0 .../testdata/frobnitz_with_dev_null/null | 1 + .../templates/template.tpl | 1 + .../frobnitz_with_dev_null/values.yaml | 6 + .../frobnitz_with_symlink/.helmignore | 1 + .../testdata/frobnitz_with_symlink/Chart.lock | 8 + .../testdata/frobnitz_with_symlink/Chart.yaml | 27 + .../frobnitz_with_symlink/INSTALL.txt | 1 + .../testdata/frobnitz_with_symlink/README.md | 11 + .../frobnitz_with_symlink/charts/_ignore_me | 1 + .../charts/alpine/Chart.yaml | 5 + .../charts/alpine/README.md | 9 + .../charts/alpine/charts/mast1/Chart.yaml | 5 + .../charts/alpine/charts/mast1/values.yaml | 4 + .../charts/alpine/charts/mast2-0.1.0.tgz | Bin 0 -> 252 bytes .../charts/alpine/templates/alpine-pod.yaml | 14 + .../charts/alpine/values.yaml | 2 + .../charts/mariner-4.3.2.tgz | Bin 0 -> 967 bytes .../frobnitz_with_symlink/docs/README.md | 1 + .../testdata/frobnitz_with_symlink/icon.svg | 8 + .../frobnitz_with_symlink/ignore/me.txt | 0 .../templates/template.tpl | 1 + .../frobnitz_with_symlink/values.yaml | 6 + pkg/chart/loader/testdata/genfrob.sh | 14 + pkg/chart/loader/testdata/mariner/Chart.yaml | 9 + .../mariner/charts/albatross-0.1.0.tgz | Bin 0 -> 306 bytes .../mariner/templates/placeholder.tpl | 1 + pkg/chart/loader/testdata/mariner/values.yaml | 7 + pkg/chart/metadata.go | 178 +++ pkg/chart/metadata_test.go | 201 +++ pkg/chartutil/capabilities.go | 126 ++ pkg/chartutil/capabilities_test.go | 84 ++ pkg/chartutil/chartfile.go | 92 ++ pkg/chartutil/chartfile_test.go | 121 ++ pkg/chartutil/coalesce.go | 293 ++++ pkg/chartutil/coalesce_test.go | 700 +++++++++ pkg/chartutil/compatible.go | 34 + pkg/chartutil/compatible_test.go | 43 + pkg/chartutil/create.go | 723 +++++++++ pkg/chartutil/create_test.go | 172 +++ pkg/chartutil/dependencies.go | 356 +++++ pkg/chartutil/dependencies_test.go | 504 +++++++ pkg/chartutil/doc.go | 45 + pkg/chartutil/errors.go | 43 + pkg/chartutil/errors_test.go | 37 + pkg/chartutil/expand.go | 90 ++ pkg/chartutil/expand_test.go | 124 ++ pkg/chartutil/jsonschema.go | 93 ++ pkg/chartutil/jsonschema_test.go | 167 +++ pkg/chartutil/save.go | 264 ++++ pkg/chartutil/save_test.go | 264 ++++ pkg/chartutil/testdata/chartfiletest.yaml | 20 + pkg/chartutil/testdata/coleridge.yaml | 12 + .../dependent-chart-alias/.helmignore | 1 + .../testdata/dependent-chart-alias/Chart.lock | 8 + .../testdata/dependent-chart-alias/Chart.yaml | 29 + .../dependent-chart-alias/INSTALL.txt | 1 + .../testdata/dependent-chart-alias/LICENSE | 1 + .../testdata/dependent-chart-alias/README.md | 11 + .../dependent-chart-alias/charts/_ignore_me | 1 + .../charts/alpine/Chart.yaml | 5 + .../charts/alpine/README.md | 9 + .../charts/alpine/charts/mast1/Chart.yaml | 5 + .../charts/alpine/charts/mast1/values.yaml | 4 + .../charts/alpine/charts/mast2-0.1.0.tgz | Bin 0 -> 252 bytes .../charts/alpine/templates/alpine-pod.yaml | 14 + .../charts/alpine/values.yaml | 2 + .../charts/mariner-4.3.2.tgz | Bin 0 -> 967 bytes .../dependent-chart-alias/docs/README.md | 1 + .../testdata/dependent-chart-alias/icon.svg | 8 + .../dependent-chart-alias/ignore/me.txt | 0 .../templates/template.tpl | 1 + .../dependent-chart-alias/values.yaml | 6 + .../dependent-chart-helmignore/.helmignore | 2 + .../dependent-chart-helmignore/Chart.yaml | 17 + .../charts/.ignore_me | 0 .../charts/_ignore_me | 1 + .../charts/alpine/Chart.yaml | 5 + .../charts/alpine/README.md | 9 + .../charts/alpine/charts/mast1/Chart.yaml | 5 + .../charts/alpine/charts/mast1/values.yaml | 4 + .../charts/alpine/charts/mast2-0.1.0.tgz | Bin 0 -> 252 bytes .../charts/alpine/templates/alpine-pod.yaml | 14 + .../charts/alpine/values.yaml | 2 + .../templates/template.tpl | 1 + .../dependent-chart-helmignore/values.yaml | 6 + .../.helmignore | 1 + .../Chart.yaml | 17 + .../INSTALL.txt | 1 + .../LICENSE | 1 + .../README.md | 11 + .../charts/_ignore_me | 1 + .../charts/alpine/Chart.yaml | 5 + .../charts/alpine/README.md | 9 + .../charts/alpine/charts/mast1/Chart.yaml | 5 + .../charts/alpine/charts/mast1/values.yaml | 4 + .../charts/alpine/charts/mast2-0.1.0.tgz | Bin 0 -> 252 bytes .../charts/alpine/templates/alpine-pod.yaml | 14 + .../charts/alpine/values.yaml | 2 + .../charts/mariner-4.3.2.tgz | Bin 0 -> 967 bytes .../docs/README.md | 1 + .../icon.svg | 8 + .../ignore/me.txt | 0 .../templates/template.tpl | 1 + .../values.yaml | 6 + .../.helmignore | 1 + .../Chart.yaml | 24 + .../INSTALL.txt | 1 + .../LICENSE | 1 + .../README.md | 11 + .../charts/_ignore_me | 1 + .../charts/alpine/Chart.yaml | 5 + .../charts/alpine/README.md | 9 + .../charts/alpine/charts/mast1/Chart.yaml | 5 + .../charts/alpine/charts/mast1/values.yaml | 4 + .../charts/alpine/charts/mast2-0.1.0.tgz | Bin 0 -> 252 bytes .../charts/alpine/templates/alpine-pod.yaml | 14 + .../charts/alpine/values.yaml | 2 + .../charts/mariner-4.3.2.tgz | Bin 0 -> 967 bytes .../docs/README.md | 1 + .../icon.svg | 8 + .../ignore/me.txt | 0 .../templates/template.tpl | 1 + .../values.yaml | 6 + .../.helmignore | 1 + .../Chart.yaml | 21 + .../INSTALL.txt | 1 + .../LICENSE | 1 + .../README.md | 11 + .../charts/_ignore_me | 1 + .../charts/alpine/Chart.yaml | 5 + .../charts/alpine/README.md | 9 + .../charts/alpine/charts/mast1/Chart.yaml | 5 + .../charts/alpine/charts/mast1/values.yaml | 4 + .../charts/alpine/charts/mast2-0.1.0.tgz | Bin 0 -> 252 bytes .../charts/alpine/templates/alpine-pod.yaml | 14 + .../charts/alpine/values.yaml | 2 + .../charts/mariner-4.3.2.tgz | Bin 0 -> 967 bytes .../docs/README.md | 1 + .../icon.svg | 8 + .../ignore/me.txt | 0 .../templates/template.tpl | 1 + .../values.yaml | 6 + pkg/chartutil/testdata/frobnitz-1.2.3.tgz | Bin 0 -> 3485 bytes pkg/chartutil/testdata/frobnitz/.helmignore | 1 + pkg/chartutil/testdata/frobnitz/Chart.lock | 8 + pkg/chartutil/testdata/frobnitz/Chart.yaml | 27 + pkg/chartutil/testdata/frobnitz/INSTALL.txt | 1 + pkg/chartutil/testdata/frobnitz/LICENSE | 1 + pkg/chartutil/testdata/frobnitz/README.md | 11 + .../testdata/frobnitz/charts/_ignore_me | 1 + .../frobnitz/charts/alpine/Chart.yaml | 5 + .../testdata/frobnitz/charts/alpine/README.md | 9 + .../charts/alpine/charts/mast1/Chart.yaml | 5 + .../charts/alpine/charts/mast1/values.yaml | 4 + .../charts/alpine/charts/mast2-0.1.0.tgz | Bin 0 -> 252 bytes .../charts/alpine/templates/alpine-pod.yaml | 14 + .../frobnitz/charts/alpine/values.yaml | 2 + .../frobnitz/charts/mariner/Chart.yaml | 9 + .../mariner/charts/albatross/Chart.yaml | 5 + .../mariner/charts/albatross/values.yaml | 4 + .../charts/mariner/templates/placeholder.tpl | 1 + .../frobnitz/charts/mariner/values.yaml | 7 + .../testdata/frobnitz/docs/README.md | 1 + pkg/chartutil/testdata/frobnitz/icon.svg | 8 + pkg/chartutil/testdata/frobnitz/ignore/me.txt | 0 .../testdata/frobnitz/templates/template.tpl | 1 + pkg/chartutil/testdata/frobnitz/values.yaml | 6 + .../testdata/frobnitz_backslash-1.2.3.tgz | Bin 0 -> 3496 bytes pkg/chartutil/testdata/genfrob.sh | 14 + .../parent-chart/Chart.lock | 9 + .../parent-chart/Chart.yaml | 22 + .../parent-chart/charts/dev-v0.1.0.tgz | Bin 0 -> 333 bytes .../parent-chart/charts/prod-v0.1.0.tgz | Bin 0 -> 336 bytes .../parent-chart/envs/dev/Chart.yaml | 4 + .../parent-chart/envs/dev/values.yaml | 9 + .../parent-chart/envs/prod/Chart.yaml | 4 + .../parent-chart/envs/prod/values.yaml | 9 + .../parent-chart/templates/autoscaler.yaml | 16 + .../parent-chart/values.yaml | 10 + pkg/chartutil/testdata/joonix/Chart.yaml | 4 + pkg/chartutil/testdata/joonix/charts/.gitkeep | 0 pkg/chartutil/testdata/subpop/Chart.yaml | 41 + pkg/chartutil/testdata/subpop/README.md | 18 + .../subpop/charts/subchart1/Chart.yaml | 36 + .../subchart1/charts/subchartA/Chart.yaml | 4 + .../charts/subchartA/templates/service.yaml | 15 + .../subchart1/charts/subchartA/values.yaml | 17 + .../subchart1/charts/subchartB/Chart.yaml | 4 + .../charts/subchartB/templates/service.yaml | 15 + .../subchart1/charts/subchartB/values.yaml | 35 + .../subpop/charts/subchart1/crds/crdA.yaml | 13 + .../charts/subchart1/templates/NOTES.txt | 1 + .../charts/subchart1/templates/service.yaml | 22 + .../subchart1/templates/subdir/role.yaml | 7 + .../templates/subdir/rolebinding.yaml | 12 + .../templates/subdir/serviceaccount.yaml | 4 + .../subpop/charts/subchart1/values.yaml | 55 + .../subpop/charts/subchart2/Chart.yaml | 19 + .../subchart2/charts/subchartB/Chart.yaml | 4 + .../charts/subchartB/templates/service.yaml | 15 + .../subchart2/charts/subchartB/values.yaml | 21 + .../subchart2/charts/subchartC/Chart.yaml | 4 + .../charts/subchartC/templates/service.yaml | 15 + .../subchart2/charts/subchartC/values.yaml | 21 + .../charts/subchart2/templates/service.yaml | 15 + .../subpop/charts/subchart2/values.yaml | 21 + .../testdata/subpop/noreqs/Chart.yaml | 4 + .../subpop/noreqs/templates/service.yaml | 15 + .../testdata/subpop/noreqs/values.yaml | 26 + pkg/chartutil/testdata/subpop/values.yaml | 45 + .../testdata/test-values-invalid.schema.json | 1 + .../testdata/test-values-negative.yaml | 14 + .../testdata/test-values.schema.json | 67 + pkg/chartutil/testdata/test-values.yaml | 17 + .../three-level-dependent-chart/README.md | 16 + .../umbrella/Chart.yaml | 19 + .../umbrella/charts/app1/Chart.yaml | 11 + .../charts/app1/charts/library/Chart.yaml | 5 + .../charts/library/templates/service.yaml | 9 + .../charts/app1/charts/library/values.yaml | 5 + .../charts/app1/templates/service.yaml | 1 + .../umbrella/charts/app1/values.yaml | 3 + .../umbrella/charts/app2/Chart.yaml | 11 + .../charts/app2/charts/library/Chart.yaml | 5 + .../charts/library/templates/service.yaml | 9 + .../charts/app2/charts/library/values.yaml | 5 + .../charts/app2/templates/service.yaml | 1 + .../umbrella/charts/app2/values.yaml | 3 + .../umbrella/charts/app3/Chart.yaml | 11 + .../charts/app3/charts/library/Chart.yaml | 5 + .../charts/library/templates/service.yaml | 9 + .../charts/app3/charts/library/values.yaml | 5 + .../charts/app3/templates/service.yaml | 1 + .../umbrella/charts/app3/values.yaml | 2 + .../umbrella/charts/app4/Chart.yaml | 9 + .../charts/app4/charts/library/Chart.yaml | 5 + .../charts/library/templates/service.yaml | 9 + .../charts/app4/charts/library/values.yaml | 5 + .../charts/app4/templates/service.yaml | 1 + .../umbrella/charts/app4/values.yaml | 3 + .../umbrella/values.yaml | 14 + pkg/chartutil/validate_name.go | 112 ++ pkg/chartutil/validate_name_test.go | 91 ++ pkg/chartutil/values.go | 212 +++ pkg/chartutil/values_test.go | 292 ++++ pkg/cli/environment.go | 258 ++++ pkg/cli/environment_test.go | 270 ++++ pkg/cli/output/output.go | 140 ++ pkg/cli/roundtripper.go | 80 + pkg/cli/values/options.go | 147 ++ pkg/cli/values/options_test.go | 88 ++ pkg/downloader/chart_downloader.go | 406 +++++ pkg/downloader/chart_downloader_test.go | 346 +++++ pkg/downloader/doc.go | 24 + pkg/downloader/manager.go | 905 ++++++++++++ pkg/downloader/manager_test.go | 600 ++++++++ pkg/downloader/testdata/helm-test-key.pub | Bin 0 -> 1243 bytes pkg/downloader/testdata/helm-test-key.secret | Bin 0 -> 2545 bytes .../testdata/local-subchart-0.1.0.tgz | Bin 0 -> 259 bytes .../testdata/local-subchart/Chart.yaml | 3 + pkg/downloader/testdata/repositories.yaml | 28 + .../repository/encoded-url-index.yaml | 15 + .../repository/kubernetes-charts-index.yaml | 49 + .../testdata/repository/malformed-index.yaml | 16 + .../repository/testing-basicauth-index.yaml | 15 + .../repository/testing-ca-file-index.yaml | 15 + .../repository/testing-https-index.yaml | 15 + ...g-https-insecureskip-tls-verify-index.yaml | 14 + .../testdata/repository/testing-index.yaml | 43 + .../repository/testing-querystring-index.yaml | 16 + .../repository/testing-relative-index.yaml | 28 + ...testing-relative-trailing-slash-index.yaml | 28 + pkg/downloader/testdata/signtest-0.1.0.tgz | Bin 0 -> 973 bytes .../testdata/signtest-0.1.0.tgz.prov | 21 + pkg/downloader/testdata/signtest/.helmignore | 5 + pkg/downloader/testdata/signtest/Chart.yaml | 4 + .../testdata/signtest/alpine/Chart.yaml | 7 + .../testdata/signtest/alpine/README.md | 9 + .../signtest/alpine/templates/alpine-pod.yaml | 14 + .../testdata/signtest/alpine/values.yaml | 2 + .../testdata/signtest/templates/pod.yaml | 10 + pkg/downloader/testdata/signtest/values.yaml | 0 pkg/engine/doc.go | 24 + pkg/engine/engine.go | 441 ++++++ pkg/engine/engine_test.go | 1302 +++++++++++++++++ pkg/engine/files.go | 165 +++ pkg/engine/files_test.go | 111 ++ pkg/engine/funcs.go | 176 +++ pkg/engine/funcs_test.go | 178 +++ pkg/engine/lookup_func.go | 143 ++ pkg/gates/doc.go | 21 + pkg/gates/gates.go | 38 + pkg/gates/gates_test.go | 56 + pkg/getter/doc.go | 22 + pkg/getter/getter.go | 212 +++ pkg/getter/getter_test.go | 80 + pkg/getter/httpgetter.go | 157 ++ pkg/getter/httpgetter_test.go | 532 +++++++ pkg/getter/ocigetter.go | 155 ++ pkg/getter/ocigetter_test.go | 151 ++ pkg/getter/plugingetter.go | 110 ++ pkg/getter/plugingetter_test.go | 101 ++ pkg/getter/testdata/ca.crt | 25 + pkg/getter/testdata/client.crt | 21 + pkg/getter/testdata/client.key | 27 + pkg/getter/testdata/empty-0.0.1.tgz | Bin 0 -> 130 bytes pkg/getter/testdata/plugins/testgetter/get.sh | 8 + .../testdata/plugins/testgetter/plugin.yaml | 15 + .../testdata/plugins/testgetter2/get.sh | 8 + .../testdata/plugins/testgetter2/plugin.yaml | 10 + .../testdata/repository/local/index.yaml | 3 + .../testdata/repository/repositories.yaml | 15 + pkg/helmpath/home.go | 44 + pkg/helmpath/home_unix_test.go | 46 + pkg/helmpath/home_windows_test.go | 43 + pkg/helmpath/lazypath.go | 72 + pkg/helmpath/lazypath_darwin.go | 34 + pkg/helmpath/lazypath_darwin_test.go | 86 ++ pkg/helmpath/lazypath_unix.go | 45 + pkg/helmpath/lazypath_unix_test.go | 86 ++ pkg/helmpath/lazypath_windows.go | 24 + pkg/helmpath/lazypath_windows_test.go | 89 ++ pkg/helmpath/xdg/xdg.go | 34 + pkg/ignore/doc.go | 68 + pkg/ignore/rules.go | 228 +++ pkg/ignore/rules_test.go | 155 ++ pkg/ignore/testdata/.helmignore | 3 + pkg/ignore/testdata/.joonix | 0 pkg/ignore/testdata/a.txt | 0 pkg/ignore/testdata/cargo/a.txt | 0 pkg/ignore/testdata/cargo/b.txt | 0 pkg/ignore/testdata/cargo/c.txt | 0 pkg/ignore/testdata/helm.txt | 0 pkg/ignore/testdata/mast/a.txt | 0 pkg/ignore/testdata/mast/b.txt | 0 pkg/ignore/testdata/mast/c.txt | 0 pkg/ignore/testdata/rudder.txt | 0 pkg/ignore/testdata/templates/.dotfile | 0 pkg/ignore/testdata/tiller.txt | 0 pkg/kube/client.go | 850 +++++++++++ pkg/kube/client_test.go | 560 +++++++ pkg/kube/config.go | 30 + pkg/kube/converter.go | 69 + pkg/kube/factory.go | 51 + pkg/kube/fake/fake.go | 160 ++ pkg/kube/fake/printer.go | 136 ++ pkg/kube/interface.go | 116 ++ pkg/kube/ready.go | 463 ++++++ pkg/kube/ready_test.go | 705 +++++++++ pkg/kube/resource.go | 85 ++ pkg/kube/resource_policy.go | 27 + pkg/kube/resource_test.go | 61 + pkg/kube/result.go | 28 + pkg/kube/wait.go | 128 ++ pkg/lint/lint.go | 43 + pkg/lint/lint_test.go | 173 +++ pkg/lint/rules/chartfile.go | 213 +++ pkg/lint/rules/chartfile_test.go | 255 ++++ pkg/lint/rules/dependencies.go | 103 ++ pkg/lint/rules/dependencies_test.go | 157 ++ pkg/lint/rules/deprecations.go | 106 ++ pkg/lint/rules/deprecations_test.go | 41 + pkg/lint/rules/template.go | 351 +++++ pkg/lint/rules/template_test.go | 460 ++++++ pkg/lint/rules/testdata/albatross/Chart.yaml | 5 + .../testdata/albatross/templates/_helpers.tpl | 16 + .../testdata/albatross/templates/fail.yaml | 1 + .../testdata/albatross/templates/svc.yaml | 19 + pkg/lint/rules/testdata/albatross/values.yaml | 1 + .../testdata/anotherbadchartfile/Chart.yaml | 15 + .../rules/testdata/badchartfile/Chart.yaml | 11 + .../rules/testdata/badchartfile/values.yaml | 1 + .../rules/testdata/badchartname/Chart.yaml | 5 + .../rules/testdata/badchartname/values.yaml | 1 + .../rules/testdata/badvaluesfile/Chart.yaml | 6 + .../templates/badvaluesfile.yaml | 2 + .../rules/testdata/badvaluesfile/values.yaml | 2 + pkg/lint/rules/testdata/goodone/Chart.yaml | 5 + .../testdata/goodone/templates/goodone.yaml | 2 + pkg/lint/rules/testdata/goodone/values.yaml | 1 + .../testdata/malformed-template/.helmignore | 23 + .../testdata/malformed-template/Chart.yaml | 25 + .../malformed-template/templates/bad.yaml | 1 + .../testdata/malformed-template/values.yaml | 82 ++ .../testdata/multi-template-fail/Chart.yaml | 21 + .../templates/multi-fail.yaml | 13 + pkg/lint/rules/testdata/v3-fail/Chart.yaml | 21 + .../testdata/v3-fail/templates/_helpers.tpl | 63 + .../v3-fail/templates/deployment.yaml | 56 + .../testdata/v3-fail/templates/ingress.yaml | 62 + .../testdata/v3-fail/templates/service.yaml | 17 + pkg/lint/rules/testdata/v3-fail/values.yaml | 66 + .../rules/testdata/withsubchart/Chart.yaml | 16 + .../withsubchart/charts/subchart/Chart.yaml | 6 + .../charts/subchart/templates/subchart.yaml | 2 + .../withsubchart/charts/subchart/values.yaml | 2 + .../withsubchart/templates/mainchart.yaml | 2 + .../rules/testdata/withsubchart/values.yaml | 0 pkg/lint/rules/values.go | 86 ++ pkg/lint/rules/values_test.go | 169 +++ pkg/lint/support/doc.go | 23 + pkg/lint/support/message.go | 76 + pkg/lint/support/message_test.go | 80 + pkg/plugin/cache/cache.go | 67 + pkg/plugin/hooks.go | 29 + pkg/plugin/installer/base.go | 45 + pkg/plugin/installer/base_test.go | 48 + pkg/plugin/installer/doc.go | 17 + pkg/plugin/installer/http_installer.go | 268 ++++ pkg/plugin/installer/http_installer_test.go | 350 +++++ pkg/plugin/installer/installer.go | 135 ++ pkg/plugin/installer/installer_test.go | 40 + pkg/plugin/installer/local_installer.go | 68 + pkg/plugin/installer/local_installer_test.go | 65 + pkg/plugin/installer/vcs_installer.go | 176 +++ pkg/plugin/installer/vcs_installer_test.go | 181 +++ pkg/plugin/plugin.go | 288 ++++ pkg/plugin/plugin_test.go | 407 ++++++ .../plugdir/bad/duplicate-entries/plugin.yaml | 11 + .../plugdir/good/downloader/plugin.yaml | 11 + .../testdata/plugdir/good/echo/plugin.yaml | 8 + .../testdata/plugdir/good/hello/hello.sh | 9 + .../testdata/plugdir/good/hello/plugin.yaml | 9 + pkg/postrender/exec.go | 109 ++ pkg/postrender/exec_test.go | 182 +++ pkg/postrender/postrender.go | 29 + pkg/provenance/doc.go | 38 + pkg/provenance/sign.go | 427 ++++++ pkg/provenance/sign_test.go | 344 +++++ pkg/provenance/testdata/hashtest-1.2.3.tgz | Bin 0 -> 399 bytes .../testdata/hashtest-1.2.3.tgz.prov | 21 + pkg/provenance/testdata/hashtest.sha256 | 1 + pkg/provenance/testdata/hashtest/.helmignore | 5 + pkg/provenance/testdata/hashtest/Chart.yaml | 4 + pkg/provenance/testdata/hashtest/values.yaml | 4 + .../testdata/helm-password-key.secret | Bin 0 -> 2562 bytes pkg/provenance/testdata/helm-test-key.pub | Bin 0 -> 1243 bytes pkg/provenance/testdata/helm-test-key.secret | Bin 0 -> 2545 bytes pkg/provenance/testdata/msgblock.yaml | 8 + pkg/provenance/testdata/msgblock.yaml.asc | 22 + .../testdata/msgblock.yaml.tampered | 21 + pkg/provenance/testdata/regen-hashtest.sh | 3 + pkg/pusher/doc.go | 21 + pkg/pusher/ocipusher.go | 152 ++ pkg/pusher/ocipusher_test.go | 96 ++ pkg/pusher/pusher.go | 122 ++ pkg/pusher/pusher_test.go | 68 + pkg/registry/client.go | 703 +++++++++ pkg/registry/client_http_test.go | 68 + pkg/registry/client_insecure_tls_test.go | 77 + pkg/registry/client_tls_test.go | 77 + pkg/registry/constants.go | 37 + pkg/registry/testdata/tls/ca.crt | 21 + pkg/registry/testdata/tls/client.crt | 20 + pkg/registry/testdata/tls/client.key | 28 + pkg/registry/testdata/tls/server.crt | 20 + pkg/registry/testdata/tls/server.key | 28 + pkg/registry/util.go | 247 ++++ pkg/registry/util_test.go | 240 +++ pkg/registry/utils_test.go | 393 +++++ pkg/release/hook.go | 106 ++ pkg/release/info.go | 40 + pkg/release/mock.go | 116 ++ pkg/release/release.go | 49 + pkg/release/responses.go | 24 + pkg/release/status.go | 49 + pkg/releaseutil/filter.go | 78 + pkg/releaseutil/filter_test.go | 59 + pkg/releaseutil/kind_sorter.go | 160 ++ pkg/releaseutil/kind_sorter_test.go | 339 +++++ pkg/releaseutil/manifest.go | 72 + pkg/releaseutil/manifest_sorter.go | 233 +++ pkg/releaseutil/manifest_sorter_test.go | 228 +++ pkg/releaseutil/manifest_test.go | 61 + pkg/releaseutil/sorter.go | 78 + pkg/releaseutil/sorter_test.go | 108 ++ pkg/repo/chartrepo.go | 317 ++++ pkg/repo/chartrepo_test.go | 402 +++++ pkg/repo/doc.go | 94 ++ pkg/repo/index.go | 414 ++++++ pkg/repo/index_test.go | 646 ++++++++ pkg/repo/repo.go | 125 ++ pkg/repo/repo_test.go | 257 ++++ pkg/repo/repotest/doc.go | 21 + pkg/repo/repotest/server.go | 426 ++++++ pkg/repo/repotest/server_test.go | 116 ++ .../repotest/testdata/examplechart-0.1.0.tgz | Bin 0 -> 500 bytes .../testdata/examplechart/.helmignore | 21 + .../repotest/testdata/examplechart/Chart.yaml | 4 + .../testdata/examplechart/values.yaml | 4 + pkg/repo/testdata/chartmuseum-index.yaml | 54 + .../testdata/local-index-annotations.yaml | 54 + pkg/repo/testdata/local-index-unordered.yaml | 52 + pkg/repo/testdata/local-index.json | 53 + pkg/repo/testdata/local-index.yaml | 52 + pkg/repo/testdata/old-repositories.yaml | 3 + pkg/repo/testdata/repositories.yaml | 8 + .../testdata/repository/frobnitz-1.2.3.tgz | Bin 0 -> 3485 bytes .../testdata/repository/sprocket-1.1.0.tgz | Bin 0 -> 414 bytes .../testdata/repository/sprocket-1.2.0.tgz | Bin 0 -> 413 bytes .../repository/universe/zarthal-1.0.0.tgz | Bin 0 -> 411 bytes pkg/repo/testdata/server/index.yaml | 39 + pkg/repo/testdata/server/test.txt | 1 + pkg/storage/driver/cfgmaps.go | 263 ++++ pkg/storage/driver/cfgmaps_test.go | 251 ++++ pkg/storage/driver/driver.go | 105 ++ pkg/storage/driver/labels.go | 48 + pkg/storage/driver/labels_test.go | 49 + pkg/storage/driver/memory.go | 240 +++ pkg/storage/driver/memory_test.go | 289 ++++ pkg/storage/driver/mock_test.go | 269 ++++ pkg/storage/driver/records.go | 124 ++ pkg/storage/driver/records_test.go | 240 +++ pkg/storage/driver/secrets.go | 256 ++++ pkg/storage/driver/secrets_test.go | 251 ++++ pkg/storage/driver/sql.go | 697 +++++++++ pkg/storage/driver/sql_test.go | 592 ++++++++ pkg/storage/driver/util.go | 122 ++ pkg/storage/driver/util_test.go | 108 ++ pkg/storage/storage.go | 266 ++++ pkg/storage/storage_test.go | 560 +++++++ pkg/strvals/doc.go | 33 + pkg/strvals/literal_parser.go | 244 +++ pkg/strvals/literal_parser_test.go | 480 ++++++ pkg/strvals/parser.go | 559 +++++++ pkg/strvals/parser_test.go | 818 +++++++++++ pkg/time/time.go | 91 ++ pkg/time/time_test.go | 83 ++ pkg/uploader/chart_uploader.go | 58 + pkg/uploader/doc.go | 21 + scripts/coverage.sh | 54 + scripts/get | 242 +++ scripts/get-helm-3 | 341 +++++ scripts/release-notes.sh | 105 ++ scripts/sync-repo.sh | 82 ++ scripts/util.sh | 58 + scripts/validate-license.sh | 44 + testdata/crt.pem | 73 + testdata/generate.sh | 4 + testdata/key.pem | 27 + testdata/openssl.conf | 42 + testdata/releases.yaml | 43 + testdata/rootca.crt | 21 + testdata/rootca.key | 27 + 1356 files changed, 81450 insertions(+) create mode 100644 .circleci/config.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/issue_template.md create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/build-test.yml create mode 100644 .github/workflows/codeql-analysis.yml create mode 100644 .github/workflows/golangci-lint.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/stale-issue-bot.yaml create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 ADOPTERS.md create mode 100644 CONTRIBUTING.md create mode 100644 KEYS create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 OWNERS create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 cmd/helm/completion.go create mode 100644 cmd/helm/completion_test.go create mode 100644 cmd/helm/create.go create mode 100644 cmd/helm/create_test.go create mode 100644 cmd/helm/dependency.go create mode 100644 cmd/helm/dependency_build.go create mode 100644 cmd/helm/dependency_build_test.go create mode 100644 cmd/helm/dependency_test.go create mode 100644 cmd/helm/dependency_update.go create mode 100644 cmd/helm/dependency_update_test.go create mode 100644 cmd/helm/docs.go create mode 100644 cmd/helm/docs_test.go create mode 100644 cmd/helm/env.go create mode 100644 cmd/helm/env_test.go create mode 100644 cmd/helm/flags.go create mode 100644 cmd/helm/flags_test.go create mode 100644 cmd/helm/get.go create mode 100644 cmd/helm/get_all.go create mode 100644 cmd/helm/get_all_test.go create mode 100644 cmd/helm/get_hooks.go create mode 100644 cmd/helm/get_hooks_test.go create mode 100644 cmd/helm/get_manifest.go create mode 100644 cmd/helm/get_manifest_test.go create mode 100644 cmd/helm/get_metadata.go create mode 100644 cmd/helm/get_metadata_test.go create mode 100644 cmd/helm/get_notes.go create mode 100644 cmd/helm/get_notes_test.go create mode 100644 cmd/helm/get_test.go create mode 100644 cmd/helm/get_values.go create mode 100644 cmd/helm/get_values_test.go create mode 100644 cmd/helm/helm.go create mode 100644 cmd/helm/helm_test.go create mode 100644 cmd/helm/history.go create mode 100644 cmd/helm/history_test.go create mode 100644 cmd/helm/install.go create mode 100644 cmd/helm/install_test.go create mode 100644 cmd/helm/lint.go create mode 100644 cmd/helm/lint_test.go create mode 100644 cmd/helm/list.go create mode 100644 cmd/helm/list_test.go create mode 100644 cmd/helm/load_plugins.go create mode 100644 cmd/helm/package.go create mode 100644 cmd/helm/package_test.go create mode 100644 cmd/helm/plugin.go create mode 100644 cmd/helm/plugin_install.go create mode 100644 cmd/helm/plugin_list.go create mode 100644 cmd/helm/plugin_test.go create mode 100644 cmd/helm/plugin_uninstall.go create mode 100644 cmd/helm/plugin_update.go create mode 100644 cmd/helm/printer.go create mode 100644 cmd/helm/pull.go create mode 100644 cmd/helm/pull_test.go create mode 100644 cmd/helm/push.go create mode 100644 cmd/helm/push_test.go create mode 100644 cmd/helm/registry.go create mode 100644 cmd/helm/registry_login.go create mode 100644 cmd/helm/registry_login_test.go create mode 100644 cmd/helm/registry_logout.go create mode 100644 cmd/helm/registry_logout_test.go create mode 100644 cmd/helm/release_testing.go create mode 100644 cmd/helm/release_testing_test.go create mode 100644 cmd/helm/repo.go create mode 100644 cmd/helm/repo_add.go create mode 100644 cmd/helm/repo_add_test.go create mode 100644 cmd/helm/repo_index.go create mode 100644 cmd/helm/repo_index_test.go create mode 100644 cmd/helm/repo_list.go create mode 100644 cmd/helm/repo_list_test.go create mode 100644 cmd/helm/repo_remove.go create mode 100644 cmd/helm/repo_remove_test.go create mode 100644 cmd/helm/repo_test.go create mode 100644 cmd/helm/repo_update.go create mode 100644 cmd/helm/repo_update_test.go create mode 100644 cmd/helm/require/args.go create mode 100644 cmd/helm/require/args_test.go create mode 100644 cmd/helm/rollback.go create mode 100644 cmd/helm/rollback_test.go create mode 100644 cmd/helm/root.go create mode 100644 cmd/helm/root_test.go create mode 100644 cmd/helm/root_unix.go create mode 100644 cmd/helm/root_unix_test.go create mode 100644 cmd/helm/root_windows.go create mode 100644 cmd/helm/search.go create mode 100644 cmd/helm/search/search.go create mode 100644 cmd/helm/search/search_test.go create mode 100644 cmd/helm/search_hub.go create mode 100644 cmd/helm/search_hub_test.go create mode 100644 cmd/helm/search_repo.go create mode 100644 cmd/helm/search_repo_test.go create mode 100644 cmd/helm/search_test.go create mode 100644 cmd/helm/show.go create mode 100644 cmd/helm/show_test.go create mode 100644 cmd/helm/status.go create mode 100644 cmd/helm/status_test.go create mode 100644 cmd/helm/template.go create mode 100644 cmd/helm/template_test.go create mode 100644 cmd/helm/testdata/helm home with space/helm/plugins/fullenv/completion.yaml create mode 100755 cmd/helm/testdata/helm home with space/helm/plugins/fullenv/fullenv.sh create mode 100644 cmd/helm/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml create mode 100644 cmd/helm/testdata/helm home with space/helm/repositories.yaml create mode 100644 cmd/helm/testdata/helm home with space/helm/repository/test-name-charts.txt create mode 100644 cmd/helm/testdata/helm home with space/helm/repository/test-name-index.yaml create mode 100644 cmd/helm/testdata/helm home with space/helm/repository/testing-index.yaml create mode 100644 cmd/helm/testdata/helm-test-key.pub create mode 100644 cmd/helm/testdata/helm-test-key.secret create mode 100755 cmd/helm/testdata/helmhome/helm/plugins/args/args.sh create mode 100755 cmd/helm/testdata/helmhome/helm/plugins/args/plugin.complete create mode 100644 cmd/helm/testdata/helmhome/helm/plugins/args/plugin.yaml create mode 100644 cmd/helm/testdata/helmhome/helm/plugins/echo/completion.yaml create mode 100755 cmd/helm/testdata/helmhome/helm/plugins/echo/plugin.complete create mode 100644 cmd/helm/testdata/helmhome/helm/plugins/echo/plugin.yaml create mode 100644 cmd/helm/testdata/helmhome/helm/plugins/env/completion.yaml create mode 100644 cmd/helm/testdata/helmhome/helm/plugins/env/plugin.yaml create mode 100644 cmd/helm/testdata/helmhome/helm/plugins/exitwith/completion.yaml create mode 100755 cmd/helm/testdata/helmhome/helm/plugins/exitwith/exitwith.sh create mode 100644 cmd/helm/testdata/helmhome/helm/plugins/exitwith/plugin.yaml create mode 100644 cmd/helm/testdata/helmhome/helm/plugins/fullenv/completion.yaml create mode 100755 cmd/helm/testdata/helmhome/helm/plugins/fullenv/fullenv.sh create mode 100644 cmd/helm/testdata/helmhome/helm/plugins/fullenv/plugin.yaml create mode 100644 cmd/helm/testdata/helmhome/helm/repositories.yaml create mode 100644 cmd/helm/testdata/helmhome/helm/repository/test-name-charts.txt create mode 100644 cmd/helm/testdata/helmhome/helm/repository/test-name-index.yaml create mode 100644 cmd/helm/testdata/helmhome/helm/repository/testing-index.yaml create mode 100644 cmd/helm/testdata/output/chart-with-subchart-update.txt create mode 100644 cmd/helm/testdata/output/dependency-list-archive.txt create mode 100644 cmd/helm/testdata/output/dependency-list-no-chart-linux.txt create mode 100644 cmd/helm/testdata/output/dependency-list-no-requirements-linux.txt create mode 100644 cmd/helm/testdata/output/dependency-list.txt create mode 100644 cmd/helm/testdata/output/deprecated-chart.txt create mode 100644 cmd/helm/testdata/output/docs-type-comp.txt create mode 100644 cmd/helm/testdata/output/empty_default_comp.txt create mode 100644 cmd/helm/testdata/output/empty_nofile_comp.txt create mode 100644 cmd/helm/testdata/output/env-comp.txt create mode 100644 cmd/helm/testdata/output/get-all-no-args.txt create mode 100644 cmd/helm/testdata/output/get-hooks-no-args.txt create mode 100644 cmd/helm/testdata/output/get-hooks.txt create mode 100644 cmd/helm/testdata/output/get-manifest-no-args.txt create mode 100644 cmd/helm/testdata/output/get-manifest.txt create mode 100644 cmd/helm/testdata/output/get-metadata-args.txt create mode 100644 cmd/helm/testdata/output/get-metadata.json create mode 100644 cmd/helm/testdata/output/get-metadata.txt create mode 100644 cmd/helm/testdata/output/get-metadata.yaml create mode 100644 cmd/helm/testdata/output/get-notes-no-args.txt create mode 100644 cmd/helm/testdata/output/get-notes.txt create mode 100644 cmd/helm/testdata/output/get-release-template.txt create mode 100644 cmd/helm/testdata/output/get-release.txt create mode 100644 cmd/helm/testdata/output/get-values-all.txt create mode 100644 cmd/helm/testdata/output/get-values-args.txt create mode 100644 cmd/helm/testdata/output/get-values.txt create mode 100644 cmd/helm/testdata/output/history-limit.txt create mode 100644 cmd/helm/testdata/output/history.json create mode 100644 cmd/helm/testdata/output/history.txt create mode 100644 cmd/helm/testdata/output/history.yaml create mode 100644 cmd/helm/testdata/output/install-and-replace.txt create mode 100644 cmd/helm/testdata/output/install-chart-bad-type.txt create mode 100644 cmd/helm/testdata/output/install-lib-chart.txt create mode 100644 cmd/helm/testdata/output/install-name-template.txt create mode 100644 cmd/helm/testdata/output/install-no-args.txt create mode 100644 cmd/helm/testdata/output/install-no-hooks.txt create mode 100644 cmd/helm/testdata/output/install-with-multiple-values-files.txt create mode 100644 cmd/helm/testdata/output/install-with-multiple-values.txt create mode 100644 cmd/helm/testdata/output/install-with-timeout.txt create mode 100644 cmd/helm/testdata/output/install-with-values-file.txt create mode 100644 cmd/helm/testdata/output/install-with-values.txt create mode 100644 cmd/helm/testdata/output/install-with-wait-for-jobs.txt create mode 100644 cmd/helm/testdata/output/install-with-wait.txt create mode 100644 cmd/helm/testdata/output/install.txt create mode 100644 cmd/helm/testdata/output/issue-9027.txt create mode 100644 cmd/helm/testdata/output/lint-chart-with-bad-subcharts-with-subcharts.txt create mode 100644 cmd/helm/testdata/output/lint-chart-with-bad-subcharts.txt create mode 100644 cmd/helm/testdata/output/lint-chart-with-deprecated-api-old-k8s.txt create mode 100644 cmd/helm/testdata/output/lint-chart-with-deprecated-api-strict.txt create mode 100644 cmd/helm/testdata/output/lint-chart-with-deprecated-api.txt create mode 100644 cmd/helm/testdata/output/lint-quiet-with-error.txt create mode 100644 cmd/helm/testdata/output/lint-quiet-with-warning.txt create mode 100644 cmd/helm/testdata/output/lint-quiet.txt create mode 100644 cmd/helm/testdata/output/list-all.txt create mode 100644 cmd/helm/testdata/output/list-date-reversed.txt create mode 100644 cmd/helm/testdata/output/list-date.txt create mode 100644 cmd/helm/testdata/output/list-failed.txt create mode 100644 cmd/helm/testdata/output/list-filter.txt create mode 100644 cmd/helm/testdata/output/list-max.txt create mode 100644 cmd/helm/testdata/output/list-namespace.txt create mode 100644 cmd/helm/testdata/output/list-no-headers.txt create mode 100644 cmd/helm/testdata/output/list-offset.txt create mode 100644 cmd/helm/testdata/output/list-pending.txt create mode 100644 cmd/helm/testdata/output/list-reverse.txt create mode 100644 cmd/helm/testdata/output/list-short-json.txt create mode 100644 cmd/helm/testdata/output/list-short-yaml.txt create mode 100644 cmd/helm/testdata/output/list-short.txt create mode 100644 cmd/helm/testdata/output/list-superseded.txt create mode 100644 cmd/helm/testdata/output/list-uninstalled.txt create mode 100644 cmd/helm/testdata/output/list-uninstalling.txt create mode 100644 cmd/helm/testdata/output/list.txt create mode 100644 cmd/helm/testdata/output/object-order.txt create mode 100644 cmd/helm/testdata/output/output-comp.txt create mode 100644 cmd/helm/testdata/output/plugin_args_comp.txt create mode 100644 cmd/helm/testdata/output/plugin_args_flag_comp.txt create mode 100644 cmd/helm/testdata/output/plugin_args_many_args_comp.txt create mode 100644 cmd/helm/testdata/output/plugin_args_ns_comp.txt create mode 100644 cmd/helm/testdata/output/plugin_echo_no_directive.txt create mode 100644 cmd/helm/testdata/output/plugin_list_comp.txt create mode 100644 cmd/helm/testdata/output/plugin_repeat_comp.txt create mode 100644 cmd/helm/testdata/output/release_list_comp.txt create mode 100644 cmd/helm/testdata/output/release_list_repeat_comp.txt create mode 100644 cmd/helm/testdata/output/repo-add.txt create mode 100644 cmd/helm/testdata/output/repo-add2.txt create mode 100644 cmd/helm/testdata/output/repo_list_comp.txt create mode 100644 cmd/helm/testdata/output/repo_repeat_comp.txt create mode 100644 cmd/helm/testdata/output/revision-comp.txt create mode 100644 cmd/helm/testdata/output/revision-wrong-args-comp.txt create mode 100644 cmd/helm/testdata/output/rollback-comp.txt create mode 100644 cmd/helm/testdata/output/rollback-no-args.txt create mode 100644 cmd/helm/testdata/output/rollback-no-revision.txt create mode 100644 cmd/helm/testdata/output/rollback-non-existent-version.txt create mode 100644 cmd/helm/testdata/output/rollback-timeout.txt create mode 100644 cmd/helm/testdata/output/rollback-wait-for-jobs.txt create mode 100644 cmd/helm/testdata/output/rollback-wait.txt create mode 100644 cmd/helm/testdata/output/rollback-wrong-args-comp.txt create mode 100644 cmd/helm/testdata/output/rollback.txt create mode 100644 cmd/helm/testdata/output/schema-negative-cli.txt create mode 100644 cmd/helm/testdata/output/schema-negative.txt create mode 100644 cmd/helm/testdata/output/schema.txt create mode 100644 cmd/helm/testdata/output/search-constraint-single.txt create mode 100644 cmd/helm/testdata/output/search-constraint.txt create mode 100644 cmd/helm/testdata/output/search-multiple-devel-release.txt create mode 100644 cmd/helm/testdata/output/search-multiple-stable-release.txt create mode 100644 cmd/helm/testdata/output/search-multiple-versions-constraints.txt create mode 100644 cmd/helm/testdata/output/search-multiple-versions.txt create mode 100644 cmd/helm/testdata/output/search-not-found-error.txt create mode 100644 cmd/helm/testdata/output/search-not-found.txt create mode 100644 cmd/helm/testdata/output/search-output-json.txt create mode 100644 cmd/helm/testdata/output/search-output-yaml.txt create mode 100644 cmd/helm/testdata/output/search-regex.txt create mode 100644 cmd/helm/testdata/output/search-versions-constraint.txt create mode 100644 cmd/helm/testdata/output/status-comp.txt create mode 100644 cmd/helm/testdata/output/status-with-desc.txt create mode 100644 cmd/helm/testdata/output/status-with-notes.txt create mode 100644 cmd/helm/testdata/output/status-with-resources.json create mode 100644 cmd/helm/testdata/output/status-with-resources.txt create mode 100644 cmd/helm/testdata/output/status-with-test-suite.txt create mode 100644 cmd/helm/testdata/output/status-wrong-args-comp.txt create mode 100644 cmd/helm/testdata/output/status.json create mode 100644 cmd/helm/testdata/output/status.txt create mode 100644 cmd/helm/testdata/output/subchart-schema-cli-negative.txt create mode 100644 cmd/helm/testdata/output/subchart-schema-cli.txt create mode 100644 cmd/helm/testdata/output/subchart-schema-negative.txt create mode 100644 cmd/helm/testdata/output/template-chart-bad-type.txt create mode 100644 cmd/helm/testdata/output/template-chart-with-template-lib-archive-dep.txt create mode 100644 cmd/helm/testdata/output/template-chart-with-template-lib-dep.txt create mode 100644 cmd/helm/testdata/output/template-lib-chart.txt create mode 100644 cmd/helm/testdata/output/template-name-template.txt create mode 100644 cmd/helm/testdata/output/template-no-args.txt create mode 100644 cmd/helm/testdata/output/template-set.txt create mode 100644 cmd/helm/testdata/output/template-show-only-glob.txt create mode 100644 cmd/helm/testdata/output/template-show-only-multiple.txt create mode 100644 cmd/helm/testdata/output/template-show-only-one.txt create mode 100644 cmd/helm/testdata/output/template-skip-tests.txt create mode 100644 cmd/helm/testdata/output/template-subchart-cm-set-file.txt create mode 100644 cmd/helm/testdata/output/template-subchart-cm-set.txt create mode 100644 cmd/helm/testdata/output/template-subchart-cm.txt create mode 100644 cmd/helm/testdata/output/template-values-files.txt create mode 100644 cmd/helm/testdata/output/template-with-api-version.txt create mode 100644 cmd/helm/testdata/output/template-with-crds.txt create mode 100644 cmd/helm/testdata/output/template-with-invalid-yaml-debug.txt create mode 100644 cmd/helm/testdata/output/template-with-invalid-yaml.txt create mode 100644 cmd/helm/testdata/output/template-with-kube-version.txt create mode 100644 cmd/helm/testdata/output/template.txt create mode 100644 cmd/helm/testdata/output/uninstall-keep-history.txt create mode 100644 cmd/helm/testdata/output/uninstall-multiple.txt create mode 100644 cmd/helm/testdata/output/uninstall-no-args.txt create mode 100644 cmd/helm/testdata/output/uninstall-no-hooks.txt create mode 100644 cmd/helm/testdata/output/uninstall-timeout.txt create mode 100644 cmd/helm/testdata/output/uninstall-wait.txt create mode 100644 cmd/helm/testdata/output/uninstall.txt create mode 100644 cmd/helm/testdata/output/upgrade-with-bad-dependencies.txt create mode 100644 cmd/helm/testdata/output/upgrade-with-bad-or-missing-existing-release.txt create mode 100644 cmd/helm/testdata/output/upgrade-with-dependency-update.txt create mode 100644 cmd/helm/testdata/output/upgrade-with-install-timeout.txt create mode 100644 cmd/helm/testdata/output/upgrade-with-install.txt create mode 100644 cmd/helm/testdata/output/upgrade-with-missing-dependencies.txt create mode 100644 cmd/helm/testdata/output/upgrade-with-pending-install.txt create mode 100644 cmd/helm/testdata/output/upgrade-with-reset-values.txt create mode 100644 cmd/helm/testdata/output/upgrade-with-reset-values2.txt create mode 100644 cmd/helm/testdata/output/upgrade-with-timeout.txt create mode 100644 cmd/helm/testdata/output/upgrade-with-wait-for-jobs.txt create mode 100644 cmd/helm/testdata/output/upgrade-with-wait.txt create mode 100644 cmd/helm/testdata/output/upgrade.txt create mode 100644 cmd/helm/testdata/output/values.json create mode 100644 cmd/helm/testdata/output/values.yaml create mode 100644 cmd/helm/testdata/output/version-client-shorthand.txt create mode 100644 cmd/helm/testdata/output/version-client.txt create mode 100644 cmd/helm/testdata/output/version-comp.txt create mode 100644 cmd/helm/testdata/output/version-invalid-comp.txt create mode 100644 cmd/helm/testdata/output/version-short.txt create mode 100644 cmd/helm/testdata/output/version-template.txt create mode 100644 cmd/helm/testdata/output/version.txt create mode 100644 cmd/helm/testdata/password create mode 100644 cmd/helm/testdata/plugins.yaml create mode 100644 cmd/helm/testdata/repositories.yaml create mode 100644 cmd/helm/testdata/testcharts/alpine/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/alpine/README.md create mode 100644 cmd/helm/testdata/testcharts/alpine/extra_values.yaml create mode 100644 cmd/helm/testdata/testcharts/alpine/more_values.yaml create mode 100644 cmd/helm/testdata/testcharts/alpine/templates/alpine-pod.yaml create mode 100644 cmd/helm/testdata/testcharts/alpine/values.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-bad-requirements/.helmignore create mode 100644 cmd/helm/testdata/testcharts/chart-bad-requirements/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/.helmignore create mode 100644 cmd/helm/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/values.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-bad-requirements/values.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-bad-type/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-bad-type/README.md create mode 100644 cmd/helm/testdata/testcharts/chart-bad-type/extra_values.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-bad-type/more_values.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-bad-type/templates/alpine-pod.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-bad-type/values.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-missing-deps/.helmignore create mode 100644 cmd/helm/testdata/testcharts/chart-missing-deps/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-missing-deps/charts/reqsubchart/.helmignore create mode 100644 cmd/helm/testdata/testcharts/chart-missing-deps/charts/reqsubchart/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-missing-deps/charts/reqsubchart/values.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-missing-deps/values.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-bad-subcharts/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart/values.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/values.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-bad-subcharts/requirements.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-bad-subcharts/values.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-deprecated-api/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-deprecated-api/templates/horizontalpodautoscaler.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-deprecated-api/values.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-lib-dep/.helmignore create mode 100644 cmd/helm/testdata/testcharts/chart-with-lib-dep/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-lib-dep/charts/common-0.0.5.tgz create mode 100644 cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/NOTES.txt create mode 100644 cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/_helpers.tpl create mode 100644 cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/deployment.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/ingress.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/service.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-lib-dep/values.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-only-crds/.helmignore create mode 100644 cmd/helm/testdata/testcharts/chart-with-only-crds/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-only-crds/crds/test-crd.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/templates/empty.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.schema.json create mode 100644 cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/templates/empty.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/values.schema.json create mode 100644 cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/values.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-schema-negative/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-schema-negative/templates/empty.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-schema-negative/values.schema.json create mode 100644 cmd/helm/testdata/testcharts/chart-with-schema-negative/values.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-schema/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-schema/extra-values.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-schema/templates/empty.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-schema/values.schema.json create mode 100644 cmd/helm/testdata/testcharts/chart-with-schema/values.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-subchart-notes/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/templates/NOTES.txt create mode 100644 cmd/helm/testdata/testcharts/chart-with-subchart-notes/templates/NOTES.txt create mode 100644 cmd/helm/testdata/testcharts/chart-with-subchart-update/Chart.lock create mode 100644 cmd/helm/testdata/testcharts/chart-with-subchart-update/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/templates/NOTES.txt create mode 100644 cmd/helm/testdata/testcharts/chart-with-subchart-update/templates/NOTES.txt create mode 100644 cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/.helmignore create mode 100644 cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/charts/common-0.0.5.tgz create mode 100644 cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/templates/NOTES.txt create mode 100644 cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/templates/_helpers.tpl create mode 100644 cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/templates/deployment.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/templates/ingress.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/templates/service.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/values.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/.helmignore create mode 100644 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/Chart.yaml create mode 100755 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/.helmignore create mode 100755 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/Chart.yaml create mode 100755 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/README.md create mode 100755 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_chartref.tpl create mode 100755 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_configmap.yaml create mode 100755 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_container.yaml create mode 100755 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_deployment.yaml create mode 100755 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_envvar.tpl create mode 100755 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_fullname.tpl create mode 100755 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_ingress.yaml create mode 100755 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata.yaml create mode 100755 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata_annotations.tpl create mode 100755 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata_labels.tpl create mode 100755 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_name.tpl create mode 100755 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_persistentvolumeclaim.yaml create mode 100755 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_secret.yaml create mode 100755 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_service.yaml create mode 100755 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_util.tpl create mode 100755 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_volume.tpl create mode 100644 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/configmap.yaml create mode 100755 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/values.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/templates/NOTES.txt create mode 100644 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/templates/_helpers.tpl create mode 100644 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/templates/deployment.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/templates/ingress.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/templates/service.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-template-lib-dep/values.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-template-with-invalid-yaml/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-template-with-invalid-yaml/README.md create mode 100644 cmd/helm/testdata/testcharts/chart-with-template-with-invalid-yaml/templates/alpine-pod.yaml create mode 100644 cmd/helm/testdata/testcharts/chart-with-template-with-invalid-yaml/values.yaml create mode 100644 cmd/helm/testdata/testcharts/compressedchart-0.1.0.tar.gz create mode 100644 cmd/helm/testdata/testcharts/compressedchart-0.1.0.tgz create mode 100644 cmd/helm/testdata/testcharts/compressedchart-0.2.0.tgz create mode 100644 cmd/helm/testdata/testcharts/compressedchart-0.3.0.tgz create mode 100644 cmd/helm/testdata/testcharts/compressedchart-with-hyphens-0.1.0.tgz create mode 100644 cmd/helm/testdata/testcharts/deprecated/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/deprecated/README.md create mode 100644 cmd/helm/testdata/testcharts/empty/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/empty/README.md create mode 100644 cmd/helm/testdata/testcharts/empty/templates/empty.yaml create mode 100644 cmd/helm/testdata/testcharts/empty/values.yaml create mode 100644 cmd/helm/testdata/testcharts/issue-7233/.helmignore create mode 100644 cmd/helm/testdata/testcharts/issue-7233/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/issue-7233/requirements.lock create mode 100644 cmd/helm/testdata/testcharts/issue-7233/requirements.yaml create mode 100644 cmd/helm/testdata/testcharts/issue-7233/templates/configmap.yaml create mode 100644 cmd/helm/testdata/testcharts/issue-7233/values.yaml create mode 100644 cmd/helm/testdata/testcharts/issue-9027/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/issue-9027/charts/subchart/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/issue-9027/charts/subchart/templates/values.yaml create mode 100644 cmd/helm/testdata/testcharts/issue-9027/charts/subchart/values.yaml create mode 100644 cmd/helm/testdata/testcharts/issue-9027/templates/values.yaml create mode 100644 cmd/helm/testdata/testcharts/issue-9027/values.yaml create mode 100644 cmd/helm/testdata/testcharts/issue1979/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/issue1979/README.md create mode 100644 cmd/helm/testdata/testcharts/issue1979/extra_values.yaml create mode 100644 cmd/helm/testdata/testcharts/issue1979/more_values.yaml create mode 100644 cmd/helm/testdata/testcharts/issue1979/templates/alpine-pod.yaml create mode 100644 cmd/helm/testdata/testcharts/issue1979/values.yaml create mode 100644 cmd/helm/testdata/testcharts/lib-chart/.helmignore create mode 100755 cmd/helm/testdata/testcharts/lib-chart/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/lib-chart/README.md create mode 100644 cmd/helm/testdata/testcharts/lib-chart/templates/_chartref.tpl create mode 100644 cmd/helm/testdata/testcharts/lib-chart/templates/_configmap.yaml create mode 100644 cmd/helm/testdata/testcharts/lib-chart/templates/_container.yaml create mode 100644 cmd/helm/testdata/testcharts/lib-chart/templates/_deployment.yaml create mode 100644 cmd/helm/testdata/testcharts/lib-chart/templates/_envvar.tpl create mode 100644 cmd/helm/testdata/testcharts/lib-chart/templates/_fullname.tpl create mode 100644 cmd/helm/testdata/testcharts/lib-chart/templates/_ingress.yaml create mode 100644 cmd/helm/testdata/testcharts/lib-chart/templates/_metadata.yaml create mode 100644 cmd/helm/testdata/testcharts/lib-chart/templates/_metadata_annotations.tpl create mode 100644 cmd/helm/testdata/testcharts/lib-chart/templates/_metadata_labels.tpl create mode 100644 cmd/helm/testdata/testcharts/lib-chart/templates/_name.tpl create mode 100644 cmd/helm/testdata/testcharts/lib-chart/templates/_persistentvolumeclaim.yaml create mode 100644 cmd/helm/testdata/testcharts/lib-chart/templates/_secret.yaml create mode 100644 cmd/helm/testdata/testcharts/lib-chart/templates/_service.yaml create mode 100644 cmd/helm/testdata/testcharts/lib-chart/templates/_util.tpl create mode 100644 cmd/helm/testdata/testcharts/lib-chart/templates/_volume.tpl create mode 100644 cmd/helm/testdata/testcharts/lib-chart/values.yaml create mode 100644 cmd/helm/testdata/testcharts/object-order/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/object-order/templates/01-a.yml create mode 100644 cmd/helm/testdata/testcharts/object-order/templates/02-b.yml create mode 100644 cmd/helm/testdata/testcharts/object-order/values.yaml create mode 100644 cmd/helm/testdata/testcharts/oci-dependent-chart-0.1.0.tgz create mode 100644 cmd/helm/testdata/testcharts/pre-release-chart-0.1.0-alpha.tgz create mode 100644 cmd/helm/testdata/testcharts/reqtest-0.1.0.tgz create mode 100644 cmd/helm/testdata/testcharts/reqtest/.helmignore create mode 100755 cmd/helm/testdata/testcharts/reqtest/Chart.lock create mode 100644 cmd/helm/testdata/testcharts/reqtest/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/.helmignore create mode 100644 cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/values.yaml create mode 100644 cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/.helmignore create mode 100644 cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/values.yaml create mode 100644 cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart3-0.2.0.tgz create mode 100644 cmd/helm/testdata/testcharts/reqtest/values.yaml create mode 100644 cmd/helm/testdata/testcharts/signtest-0.1.0.tgz create mode 100644 cmd/helm/testdata/testcharts/signtest-0.1.0.tgz.prov create mode 100644 cmd/helm/testdata/testcharts/signtest/.helmignore create mode 100644 cmd/helm/testdata/testcharts/signtest/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/signtest/alpine/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/signtest/alpine/README.md create mode 100644 cmd/helm/testdata/testcharts/signtest/alpine/templates/alpine-pod.yaml create mode 100644 cmd/helm/testdata/testcharts/signtest/alpine/values.yaml create mode 100644 cmd/helm/testdata/testcharts/signtest/templates/pod.yaml create mode 100644 cmd/helm/testdata/testcharts/signtest/values.yaml create mode 100644 cmd/helm/testdata/testcharts/subchart/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/subchart/charts/subchartA/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/subchart/charts/subchartA/templates/service.yaml create mode 100644 cmd/helm/testdata/testcharts/subchart/charts/subchartA/values.yaml create mode 100644 cmd/helm/testdata/testcharts/subchart/charts/subchartB/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/subchart/charts/subchartB/templates/service.yaml create mode 100644 cmd/helm/testdata/testcharts/subchart/charts/subchartB/values.yaml create mode 100644 cmd/helm/testdata/testcharts/subchart/crds/crdA.yaml create mode 100644 cmd/helm/testdata/testcharts/subchart/extra_values.yaml create mode 100644 cmd/helm/testdata/testcharts/subchart/templates/NOTES.txt create mode 100644 cmd/helm/testdata/testcharts/subchart/templates/service.yaml create mode 100644 cmd/helm/testdata/testcharts/subchart/templates/subdir/configmap.yaml create mode 100644 cmd/helm/testdata/testcharts/subchart/templates/subdir/role.yaml create mode 100644 cmd/helm/testdata/testcharts/subchart/templates/subdir/rolebinding.yaml create mode 100644 cmd/helm/testdata/testcharts/subchart/templates/subdir/serviceaccount.yaml create mode 100644 cmd/helm/testdata/testcharts/subchart/templates/tests/test-config.yaml create mode 100644 cmd/helm/testdata/testcharts/subchart/templates/tests/test-nothing.yaml create mode 100644 cmd/helm/testdata/testcharts/subchart/values.yaml create mode 100644 cmd/helm/testdata/testcharts/upgradetest/templates/configmap.yaml create mode 100644 cmd/helm/testdata/testcharts/upgradetest/values.yaml create mode 100644 cmd/helm/testdata/testplugin/plugin.yaml create mode 100644 cmd/helm/testdata/testserver/index.yaml create mode 100644 cmd/helm/testdata/testserver/repository/repositories.yaml create mode 100644 cmd/helm/uninstall.go create mode 100644 cmd/helm/uninstall_test.go create mode 100644 cmd/helm/upgrade.go create mode 100644 cmd/helm/upgrade_test.go create mode 100644 cmd/helm/verify.go create mode 100644 cmd/helm/verify_test.go create mode 100644 cmd/helm/version.go create mode 100644 cmd/helm/version_test.go create mode 100644 code-of-conduct.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/fileutil/fileutil.go create mode 100644 internal/fileutil/fileutil_test.go create mode 100644 internal/monocular/client.go create mode 100644 internal/monocular/client_test.go create mode 100644 internal/monocular/doc.go create mode 100644 internal/monocular/search.go create mode 100644 internal/monocular/search_test.go create mode 100644 internal/resolver/resolver.go create mode 100644 internal/resolver/resolver_test.go create mode 100644 internal/resolver/testdata/chartpath/base/Chart.yaml create mode 100644 internal/resolver/testdata/chartpath/charts/localdependency/Chart.yaml create mode 100644 internal/resolver/testdata/repository/kubernetes-charts-index.yaml create mode 100644 internal/sympath/walk.go create mode 100644 internal/sympath/walk_test.go create mode 100644 internal/test/ensure/ensure.go create mode 100644 internal/test/test.go create mode 100644 internal/third_party/dep/fs/fs.go create mode 100644 internal/third_party/dep/fs/fs_test.go create mode 100644 internal/third_party/dep/fs/rename.go create mode 100644 internal/third_party/dep/fs/rename_windows.go create mode 120000 internal/third_party/dep/fs/testdata/symlinks/file-symlink create mode 120000 internal/third_party/dep/fs/testdata/symlinks/invalid-symlink create mode 120000 internal/third_party/dep/fs/testdata/symlinks/windows-file-symlink create mode 100644 internal/third_party/dep/fs/testdata/test.file create mode 100644 internal/third_party/k8s.io/kubernetes/deployment/util/deploymentutil.go create mode 100644 internal/tlsutil/cfg.go create mode 100644 internal/tlsutil/tls.go create mode 100644 internal/tlsutil/tlsutil_test.go create mode 100644 internal/urlutil/urlutil.go create mode 100644 internal/urlutil/urlutil_test.go create mode 100644 internal/version/version.go create mode 100644 pkg/action/action.go create mode 100644 pkg/action/action_test.go create mode 100644 pkg/action/dependency.go create mode 100644 pkg/action/dependency_test.go create mode 100644 pkg/action/doc.go create mode 100644 pkg/action/get.go create mode 100644 pkg/action/get_metadata.go create mode 100644 pkg/action/get_values.go create mode 100644 pkg/action/history.go create mode 100644 pkg/action/hooks.go create mode 100644 pkg/action/install.go create mode 100644 pkg/action/install_test.go create mode 100644 pkg/action/lazyclient.go create mode 100644 pkg/action/lint.go create mode 100644 pkg/action/lint_test.go create mode 100644 pkg/action/list.go create mode 100644 pkg/action/list_test.go create mode 100644 pkg/action/package.go create mode 100644 pkg/action/package_test.go create mode 100644 pkg/action/pull.go create mode 100644 pkg/action/push.go create mode 100644 pkg/action/registry_login.go create mode 100644 pkg/action/registry_logout.go create mode 100644 pkg/action/release_testing.go create mode 100644 pkg/action/resource_policy.go create mode 100644 pkg/action/rollback.go create mode 100644 pkg/action/show.go create mode 100644 pkg/action/show_test.go create mode 100644 pkg/action/status.go create mode 100755 pkg/action/testdata/charts/chart-missing-deps/Chart.yaml create mode 100755 pkg/action/testdata/charts/chart-missing-deps/requirements.lock create mode 100755 pkg/action/testdata/charts/chart-missing-deps/requirements.yaml create mode 100644 pkg/action/testdata/charts/chart-with-compressed-dependencies-2.1.8.tgz create mode 100755 pkg/action/testdata/charts/chart-with-compressed-dependencies/Chart.yaml create mode 100644 pkg/action/testdata/charts/chart-with-compressed-dependencies/charts/mariadb-4.3.1.tgz create mode 100755 pkg/action/testdata/charts/chart-with-compressed-dependencies/requirements.lock create mode 100755 pkg/action/testdata/charts/chart-with-compressed-dependencies/requirements.yaml create mode 100644 pkg/action/testdata/charts/chart-with-no-templates-dir/Chart.yaml create mode 100644 pkg/action/testdata/charts/chart-with-schema-negative/Chart.yaml create mode 100644 pkg/action/testdata/charts/chart-with-schema-negative/templates/empty.yaml create mode 100644 pkg/action/testdata/charts/chart-with-schema-negative/values.schema.json create mode 100644 pkg/action/testdata/charts/chart-with-schema-negative/values.yaml create mode 100644 pkg/action/testdata/charts/chart-with-schema/Chart.yaml create mode 100644 pkg/action/testdata/charts/chart-with-schema/extra-values.yaml create mode 100644 pkg/action/testdata/charts/chart-with-schema/templates/empty.yaml create mode 100644 pkg/action/testdata/charts/chart-with-schema/values.schema.json create mode 100644 pkg/action/testdata/charts/chart-with-schema/values.yaml create mode 100644 pkg/action/testdata/charts/chart-with-uncompressed-dependencies-2.1.8.tgz create mode 100755 pkg/action/testdata/charts/chart-with-uncompressed-dependencies/.helmignore create mode 100755 pkg/action/testdata/charts/chart-with-uncompressed-dependencies/Chart.yaml create mode 100755 pkg/action/testdata/charts/chart-with-uncompressed-dependencies/README.md create mode 100755 pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/.helmignore create mode 100755 pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/Chart.yaml create mode 100755 pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/README.md create mode 100755 pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/files/docker-entrypoint-initdb.d/README.md create mode 100755 pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/NOTES.txt create mode 100755 pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/_helpers.tpl create mode 100755 pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/initialization-configmap.yaml create mode 100755 pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/master-configmap.yaml create mode 100755 pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/master-statefulset.yaml create mode 100755 pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/master-svc.yaml create mode 100755 pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/secrets.yaml create mode 100755 pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/slave-configmap.yaml create mode 100755 pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/slave-statefulset.yaml create mode 100755 pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/slave-svc.yaml create mode 100755 pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/test-runner.yaml create mode 100755 pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/tests.yaml create mode 100755 pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/values.yaml create mode 100755 pkg/action/testdata/charts/chart-with-uncompressed-dependencies/requirements.lock create mode 100755 pkg/action/testdata/charts/chart-with-uncompressed-dependencies/requirements.yaml create mode 100755 pkg/action/testdata/charts/chart-with-uncompressed-dependencies/templates/NOTES.txt create mode 100755 pkg/action/testdata/charts/chart-with-uncompressed-dependencies/values.yaml create mode 100644 pkg/action/testdata/charts/compressedchart-0.1.0.tar.gz create mode 100644 pkg/action/testdata/charts/compressedchart-0.1.0.tgz create mode 100644 pkg/action/testdata/charts/compressedchart-0.2.0.tgz create mode 100644 pkg/action/testdata/charts/compressedchart-0.3.0.tgz create mode 100644 pkg/action/testdata/charts/compressedchart-with-hyphens-0.1.0.tgz create mode 100644 pkg/action/testdata/charts/corrupted-compressed-chart.tgz create mode 100644 pkg/action/testdata/charts/decompressedchart/Chart.yaml create mode 100644 pkg/action/testdata/charts/decompressedchart/values.yaml create mode 100644 pkg/action/testdata/charts/multiplecharts-lint-chart-1/Chart.yaml create mode 100644 pkg/action/testdata/charts/multiplecharts-lint-chart-1/templates/configmap.yaml create mode 100644 pkg/action/testdata/charts/multiplecharts-lint-chart-1/values.yaml create mode 100644 pkg/action/testdata/charts/multiplecharts-lint-chart-2/Chart.yaml create mode 100644 pkg/action/testdata/charts/multiplecharts-lint-chart-2/templates/configmap.yaml create mode 100644 pkg/action/testdata/charts/multiplecharts-lint-chart-2/values.yaml create mode 100644 pkg/action/testdata/charts/pre-release-chart-0.1.0-alpha.tgz create mode 100644 pkg/action/testdata/output/list-compressed-deps-tgz.txt create mode 100644 pkg/action/testdata/output/list-compressed-deps.txt create mode 100644 pkg/action/testdata/output/list-missing-deps.txt create mode 100644 pkg/action/testdata/output/list-uncompressed-deps-tgz.txt create mode 100644 pkg/action/testdata/output/list-uncompressed-deps.txt create mode 100644 pkg/action/testdata/rbac.txt create mode 100644 pkg/action/uninstall.go create mode 100644 pkg/action/uninstall_test.go create mode 100644 pkg/action/upgrade.go create mode 100644 pkg/action/upgrade_test.go create mode 100644 pkg/action/validate.go create mode 100644 pkg/action/validate_test.go create mode 100644 pkg/action/verify.go create mode 100644 pkg/chart/chart.go create mode 100644 pkg/chart/chart_test.go create mode 100644 pkg/chart/dependency.go create mode 100644 pkg/chart/dependency_test.go create mode 100644 pkg/chart/errors.go create mode 100644 pkg/chart/file.go create mode 100644 pkg/chart/loader/archive.go create mode 100644 pkg/chart/loader/archive_test.go create mode 100644 pkg/chart/loader/directory.go create mode 100644 pkg/chart/loader/load.go create mode 100644 pkg/chart/loader/load_test.go create mode 100644 pkg/chart/loader/testdata/LICENSE create mode 100644 pkg/chart/loader/testdata/albatross/Chart.yaml create mode 100644 pkg/chart/loader/testdata/albatross/values.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz-1.2.3.tgz create mode 100644 pkg/chart/loader/testdata/frobnitz.v1.tgz create mode 100644 pkg/chart/loader/testdata/frobnitz.v1/.helmignore create mode 100644 pkg/chart/loader/testdata/frobnitz.v1/Chart.lock create mode 100644 pkg/chart/loader/testdata/frobnitz.v1/Chart.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz.v1/INSTALL.txt create mode 100644 pkg/chart/loader/testdata/frobnitz.v1/LICENSE create mode 100644 pkg/chart/loader/testdata/frobnitz.v1/README.md create mode 100644 pkg/chart/loader/testdata/frobnitz.v1/charts/_ignore_me create mode 100644 pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/Chart.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/README.md create mode 100644 pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/charts/mast1/Chart.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/charts/mast1/values.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/charts/mast2-0.1.0.tgz create mode 100644 pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/templates/alpine-pod.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/values.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz.v1/charts/mariner-4.3.2.tgz create mode 100644 pkg/chart/loader/testdata/frobnitz.v1/docs/README.md create mode 100644 pkg/chart/loader/testdata/frobnitz.v1/icon.svg create mode 100644 pkg/chart/loader/testdata/frobnitz.v1/ignore/me.txt create mode 100644 pkg/chart/loader/testdata/frobnitz.v1/requirements.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz.v1/templates/template.tpl create mode 100644 pkg/chart/loader/testdata/frobnitz.v1/values.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz.v2.reqs/.helmignore create mode 100644 pkg/chart/loader/testdata/frobnitz.v2.reqs/Chart.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz.v2.reqs/INSTALL.txt create mode 100644 pkg/chart/loader/testdata/frobnitz.v2.reqs/LICENSE create mode 100644 pkg/chart/loader/testdata/frobnitz.v2.reqs/README.md create mode 100644 pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/_ignore_me create mode 100644 pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/Chart.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/README.md create mode 100644 pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/Chart.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/values.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast2-0.1.0.tgz create mode 100644 pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/templates/alpine-pod.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/values.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/mariner-4.3.2.tgz create mode 100644 pkg/chart/loader/testdata/frobnitz.v2.reqs/docs/README.md create mode 100644 pkg/chart/loader/testdata/frobnitz.v2.reqs/icon.svg create mode 100644 pkg/chart/loader/testdata/frobnitz.v2.reqs/ignore/me.txt create mode 100644 pkg/chart/loader/testdata/frobnitz.v2.reqs/requirements.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz.v2.reqs/templates/template.tpl create mode 100644 pkg/chart/loader/testdata/frobnitz.v2.reqs/values.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz/.helmignore create mode 100644 pkg/chart/loader/testdata/frobnitz/Chart.lock create mode 100644 pkg/chart/loader/testdata/frobnitz/Chart.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz/INSTALL.txt create mode 100644 pkg/chart/loader/testdata/frobnitz/LICENSE create mode 100644 pkg/chart/loader/testdata/frobnitz/README.md create mode 100644 pkg/chart/loader/testdata/frobnitz/charts/_ignore_me create mode 100644 pkg/chart/loader/testdata/frobnitz/charts/alpine/Chart.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz/charts/alpine/README.md create mode 100644 pkg/chart/loader/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz create mode 100644 pkg/chart/loader/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz/charts/alpine/values.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz/charts/mariner-4.3.2.tgz create mode 100644 pkg/chart/loader/testdata/frobnitz/docs/README.md create mode 100644 pkg/chart/loader/testdata/frobnitz/icon.svg create mode 100644 pkg/chart/loader/testdata/frobnitz/ignore/me.txt create mode 100644 pkg/chart/loader/testdata/frobnitz/templates/template.tpl create mode 100644 pkg/chart/loader/testdata/frobnitz/values.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz_backslash-1.2.3.tgz create mode 100755 pkg/chart/loader/testdata/frobnitz_backslash/.helmignore create mode 100755 pkg/chart/loader/testdata/frobnitz_backslash/Chart.lock create mode 100755 pkg/chart/loader/testdata/frobnitz_backslash/Chart.yaml create mode 100755 pkg/chart/loader/testdata/frobnitz_backslash/INSTALL.txt create mode 100755 pkg/chart/loader/testdata/frobnitz_backslash/LICENSE create mode 100755 pkg/chart/loader/testdata/frobnitz_backslash/README.md create mode 100755 pkg/chart/loader/testdata/frobnitz_backslash/charts/_ignore_me create mode 100755 pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/Chart.yaml create mode 100755 pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/README.md create mode 100755 pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/Chart.yaml create mode 100755 pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/values.yaml create mode 100755 pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast2-0.1.0.tgz create mode 100755 pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/templates/alpine-pod.yaml create mode 100755 pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/values.yaml create mode 100755 pkg/chart/loader/testdata/frobnitz_backslash/charts/mariner-4.3.2.tgz create mode 100755 pkg/chart/loader/testdata/frobnitz_backslash/docs/README.md create mode 100755 pkg/chart/loader/testdata/frobnitz_backslash/icon.svg create mode 100755 pkg/chart/loader/testdata/frobnitz_backslash/ignore/me.txt create mode 100755 pkg/chart/loader/testdata/frobnitz_backslash/templates/template.tpl create mode 100755 pkg/chart/loader/testdata/frobnitz_backslash/values.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz_with_bom.tgz create mode 100644 pkg/chart/loader/testdata/frobnitz_with_bom/.helmignore create mode 100644 pkg/chart/loader/testdata/frobnitz_with_bom/Chart.lock create mode 100644 pkg/chart/loader/testdata/frobnitz_with_bom/Chart.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz_with_bom/INSTALL.txt create mode 100644 pkg/chart/loader/testdata/frobnitz_with_bom/LICENSE create mode 100644 pkg/chart/loader/testdata/frobnitz_with_bom/README.md create mode 100644 pkg/chart/loader/testdata/frobnitz_with_bom/charts/_ignore_me create mode 100644 pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/Chart.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/README.md create mode 100644 pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/Chart.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/values.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast2-0.1.0.tgz create mode 100644 pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/templates/alpine-pod.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/values.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz_with_bom/charts/mariner-4.3.2.tgz create mode 100644 pkg/chart/loader/testdata/frobnitz_with_bom/docs/README.md create mode 100644 pkg/chart/loader/testdata/frobnitz_with_bom/icon.svg create mode 100644 pkg/chart/loader/testdata/frobnitz_with_bom/ignore/me.txt create mode 100644 pkg/chart/loader/testdata/frobnitz_with_bom/templates/template.tpl create mode 100644 pkg/chart/loader/testdata/frobnitz_with_bom/values.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz_with_dev_null/.helmignore create mode 100644 pkg/chart/loader/testdata/frobnitz_with_dev_null/Chart.lock create mode 100644 pkg/chart/loader/testdata/frobnitz_with_dev_null/Chart.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz_with_dev_null/INSTALL.txt create mode 100644 pkg/chart/loader/testdata/frobnitz_with_dev_null/LICENSE create mode 100644 pkg/chart/loader/testdata/frobnitz_with_dev_null/README.md create mode 100644 pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/_ignore_me create mode 100644 pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/Chart.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/README.md create mode 100644 pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/Chart.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/values.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast2-0.1.0.tgz create mode 100644 pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/templates/alpine-pod.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/values.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/mariner-4.3.2.tgz create mode 100644 pkg/chart/loader/testdata/frobnitz_with_dev_null/docs/README.md create mode 100644 pkg/chart/loader/testdata/frobnitz_with_dev_null/icon.svg create mode 100644 pkg/chart/loader/testdata/frobnitz_with_dev_null/ignore/me.txt create mode 120000 pkg/chart/loader/testdata/frobnitz_with_dev_null/null create mode 100644 pkg/chart/loader/testdata/frobnitz_with_dev_null/templates/template.tpl create mode 100644 pkg/chart/loader/testdata/frobnitz_with_dev_null/values.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz_with_symlink/.helmignore create mode 100644 pkg/chart/loader/testdata/frobnitz_with_symlink/Chart.lock create mode 100644 pkg/chart/loader/testdata/frobnitz_with_symlink/Chart.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz_with_symlink/INSTALL.txt create mode 100644 pkg/chart/loader/testdata/frobnitz_with_symlink/README.md create mode 100644 pkg/chart/loader/testdata/frobnitz_with_symlink/charts/_ignore_me create mode 100644 pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/Chart.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/README.md create mode 100644 pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/Chart.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/values.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast2-0.1.0.tgz create mode 100644 pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/templates/alpine-pod.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/values.yaml create mode 100644 pkg/chart/loader/testdata/frobnitz_with_symlink/charts/mariner-4.3.2.tgz create mode 100644 pkg/chart/loader/testdata/frobnitz_with_symlink/docs/README.md create mode 100644 pkg/chart/loader/testdata/frobnitz_with_symlink/icon.svg create mode 100644 pkg/chart/loader/testdata/frobnitz_with_symlink/ignore/me.txt create mode 100644 pkg/chart/loader/testdata/frobnitz_with_symlink/templates/template.tpl create mode 100644 pkg/chart/loader/testdata/frobnitz_with_symlink/values.yaml create mode 100755 pkg/chart/loader/testdata/genfrob.sh create mode 100644 pkg/chart/loader/testdata/mariner/Chart.yaml create mode 100644 pkg/chart/loader/testdata/mariner/charts/albatross-0.1.0.tgz create mode 100644 pkg/chart/loader/testdata/mariner/templates/placeholder.tpl create mode 100644 pkg/chart/loader/testdata/mariner/values.yaml create mode 100644 pkg/chart/metadata.go create mode 100644 pkg/chart/metadata_test.go create mode 100644 pkg/chartutil/capabilities.go create mode 100644 pkg/chartutil/capabilities_test.go create mode 100644 pkg/chartutil/chartfile.go create mode 100644 pkg/chartutil/chartfile_test.go create mode 100644 pkg/chartutil/coalesce.go create mode 100644 pkg/chartutil/coalesce_test.go create mode 100644 pkg/chartutil/compatible.go create mode 100644 pkg/chartutil/compatible_test.go create mode 100644 pkg/chartutil/create.go create mode 100644 pkg/chartutil/create_test.go create mode 100644 pkg/chartutil/dependencies.go create mode 100644 pkg/chartutil/dependencies_test.go create mode 100644 pkg/chartutil/doc.go create mode 100644 pkg/chartutil/errors.go create mode 100644 pkg/chartutil/errors_test.go create mode 100644 pkg/chartutil/expand.go create mode 100644 pkg/chartutil/expand_test.go create mode 100644 pkg/chartutil/jsonschema.go create mode 100644 pkg/chartutil/jsonschema_test.go create mode 100644 pkg/chartutil/save.go create mode 100644 pkg/chartutil/save_test.go create mode 100644 pkg/chartutil/testdata/chartfiletest.yaml create mode 100644 pkg/chartutil/testdata/coleridge.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-alias/.helmignore create mode 100644 pkg/chartutil/testdata/dependent-chart-alias/Chart.lock create mode 100644 pkg/chartutil/testdata/dependent-chart-alias/Chart.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-alias/INSTALL.txt create mode 100644 pkg/chartutil/testdata/dependent-chart-alias/LICENSE create mode 100644 pkg/chartutil/testdata/dependent-chart-alias/README.md create mode 100644 pkg/chartutil/testdata/dependent-chart-alias/charts/_ignore_me create mode 100644 pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/Chart.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/README.md create mode 100644 pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/charts/mast1/Chart.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/charts/mast1/values.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/charts/mast2-0.1.0.tgz create mode 100644 pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/templates/alpine-pod.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/values.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-alias/charts/mariner-4.3.2.tgz create mode 100644 pkg/chartutil/testdata/dependent-chart-alias/docs/README.md create mode 100644 pkg/chartutil/testdata/dependent-chart-alias/icon.svg create mode 100644 pkg/chartutil/testdata/dependent-chart-alias/ignore/me.txt create mode 100644 pkg/chartutil/testdata/dependent-chart-alias/templates/template.tpl create mode 100644 pkg/chartutil/testdata/dependent-chart-alias/values.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-helmignore/.helmignore create mode 100644 pkg/chartutil/testdata/dependent-chart-helmignore/Chart.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-helmignore/charts/.ignore_me create mode 100644 pkg/chartutil/testdata/dependent-chart-helmignore/charts/_ignore_me create mode 100644 pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/Chart.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/README.md create mode 100644 pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/Chart.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/values.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/charts/mast2-0.1.0.tgz create mode 100644 pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/templates/alpine-pod.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/values.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-helmignore/templates/template.tpl create mode 100644 pkg/chartutil/testdata/dependent-chart-helmignore/values.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/.helmignore create mode 100644 pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/Chart.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/INSTALL.txt create mode 100644 pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/LICENSE create mode 100644 pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/README.md create mode 100644 pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/_ignore_me create mode 100644 pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/Chart.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/README.md create mode 100644 pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/values.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz create mode 100644 pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/templates/alpine-pod.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/values.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/mariner-4.3.2.tgz create mode 100644 pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/docs/README.md create mode 100644 pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/icon.svg create mode 100644 pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/ignore/me.txt create mode 100644 pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/templates/template.tpl create mode 100644 pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/values.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/.helmignore create mode 100644 pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/Chart.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/INSTALL.txt create mode 100644 pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/LICENSE create mode 100644 pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/README.md create mode 100644 pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/_ignore_me create mode 100644 pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/Chart.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/README.md create mode 100644 pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/values.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz create mode 100644 pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/templates/alpine-pod.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/values.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/mariner-4.3.2.tgz create mode 100644 pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/docs/README.md create mode 100644 pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/icon.svg create mode 100644 pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/ignore/me.txt create mode 100644 pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/templates/template.tpl create mode 100644 pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/values.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/.helmignore create mode 100644 pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/Chart.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/INSTALL.txt create mode 100644 pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/LICENSE create mode 100644 pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/README.md create mode 100644 pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/_ignore_me create mode 100644 pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/Chart.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/README.md create mode 100644 pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/values.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz create mode 100644 pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/templates/alpine-pod.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/values.yaml create mode 100644 pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/mariner-4.3.2.tgz create mode 100644 pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/docs/README.md create mode 100644 pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/icon.svg create mode 100644 pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/ignore/me.txt create mode 100644 pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/templates/template.tpl create mode 100644 pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/values.yaml create mode 100644 pkg/chartutil/testdata/frobnitz-1.2.3.tgz create mode 100644 pkg/chartutil/testdata/frobnitz/.helmignore create mode 100644 pkg/chartutil/testdata/frobnitz/Chart.lock create mode 100644 pkg/chartutil/testdata/frobnitz/Chart.yaml create mode 100644 pkg/chartutil/testdata/frobnitz/INSTALL.txt create mode 100644 pkg/chartutil/testdata/frobnitz/LICENSE create mode 100644 pkg/chartutil/testdata/frobnitz/README.md create mode 100644 pkg/chartutil/testdata/frobnitz/charts/_ignore_me create mode 100644 pkg/chartutil/testdata/frobnitz/charts/alpine/Chart.yaml create mode 100644 pkg/chartutil/testdata/frobnitz/charts/alpine/README.md create mode 100644 pkg/chartutil/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml create mode 100644 pkg/chartutil/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml create mode 100644 pkg/chartutil/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz create mode 100644 pkg/chartutil/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml create mode 100644 pkg/chartutil/testdata/frobnitz/charts/alpine/values.yaml create mode 100644 pkg/chartutil/testdata/frobnitz/charts/mariner/Chart.yaml create mode 100644 pkg/chartutil/testdata/frobnitz/charts/mariner/charts/albatross/Chart.yaml create mode 100644 pkg/chartutil/testdata/frobnitz/charts/mariner/charts/albatross/values.yaml create mode 100644 pkg/chartutil/testdata/frobnitz/charts/mariner/templates/placeholder.tpl create mode 100644 pkg/chartutil/testdata/frobnitz/charts/mariner/values.yaml create mode 100644 pkg/chartutil/testdata/frobnitz/docs/README.md create mode 100644 pkg/chartutil/testdata/frobnitz/icon.svg create mode 100644 pkg/chartutil/testdata/frobnitz/ignore/me.txt create mode 100644 pkg/chartutil/testdata/frobnitz/templates/template.tpl create mode 100644 pkg/chartutil/testdata/frobnitz/values.yaml create mode 100644 pkg/chartutil/testdata/frobnitz_backslash-1.2.3.tgz create mode 100755 pkg/chartutil/testdata/genfrob.sh create mode 100644 pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/Chart.lock create mode 100644 pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/Chart.yaml create mode 100644 pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/charts/dev-v0.1.0.tgz create mode 100644 pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/charts/prod-v0.1.0.tgz create mode 100644 pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/Chart.yaml create mode 100644 pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/values.yaml create mode 100644 pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/Chart.yaml create mode 100644 pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/values.yaml create mode 100644 pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/templates/autoscaler.yaml create mode 100644 pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/values.yaml create mode 100644 pkg/chartutil/testdata/joonix/Chart.yaml create mode 100644 pkg/chartutil/testdata/joonix/charts/.gitkeep create mode 100644 pkg/chartutil/testdata/subpop/Chart.yaml create mode 100644 pkg/chartutil/testdata/subpop/README.md create mode 100644 pkg/chartutil/testdata/subpop/charts/subchart1/Chart.yaml create mode 100644 pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA/Chart.yaml create mode 100644 pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA/templates/service.yaml create mode 100644 pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA/values.yaml create mode 100644 pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB/Chart.yaml create mode 100644 pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB/templates/service.yaml create mode 100644 pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB/values.yaml create mode 100644 pkg/chartutil/testdata/subpop/charts/subchart1/crds/crdA.yaml create mode 100644 pkg/chartutil/testdata/subpop/charts/subchart1/templates/NOTES.txt create mode 100644 pkg/chartutil/testdata/subpop/charts/subchart1/templates/service.yaml create mode 100644 pkg/chartutil/testdata/subpop/charts/subchart1/templates/subdir/role.yaml create mode 100644 pkg/chartutil/testdata/subpop/charts/subchart1/templates/subdir/rolebinding.yaml create mode 100644 pkg/chartutil/testdata/subpop/charts/subchart1/templates/subdir/serviceaccount.yaml create mode 100644 pkg/chartutil/testdata/subpop/charts/subchart1/values.yaml create mode 100644 pkg/chartutil/testdata/subpop/charts/subchart2/Chart.yaml create mode 100644 pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/Chart.yaml create mode 100644 pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/templates/service.yaml create mode 100644 pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/values.yaml create mode 100644 pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC/Chart.yaml create mode 100644 pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC/templates/service.yaml create mode 100644 pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC/values.yaml create mode 100644 pkg/chartutil/testdata/subpop/charts/subchart2/templates/service.yaml create mode 100644 pkg/chartutil/testdata/subpop/charts/subchart2/values.yaml create mode 100644 pkg/chartutil/testdata/subpop/noreqs/Chart.yaml create mode 100644 pkg/chartutil/testdata/subpop/noreqs/templates/service.yaml create mode 100644 pkg/chartutil/testdata/subpop/noreqs/values.yaml create mode 100644 pkg/chartutil/testdata/subpop/values.yaml create mode 100644 pkg/chartutil/testdata/test-values-invalid.schema.json create mode 100644 pkg/chartutil/testdata/test-values-negative.yaml create mode 100644 pkg/chartutil/testdata/test-values.schema.json create mode 100644 pkg/chartutil/testdata/test-values.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/README.md create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/Chart.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/Chart.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/Chart.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/templates/service.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/values.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/templates/service.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/values.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/Chart.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/Chart.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/templates/service.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/values.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/templates/service.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/values.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/Chart.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/Chart.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/templates/service.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/values.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/templates/service.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/values.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/Chart.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/Chart.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/templates/service.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/values.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/templates/service.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/values.yaml create mode 100644 pkg/chartutil/testdata/three-level-dependent-chart/umbrella/values.yaml create mode 100644 pkg/chartutil/validate_name.go create mode 100644 pkg/chartutil/validate_name_test.go create mode 100644 pkg/chartutil/values.go create mode 100644 pkg/chartutil/values_test.go create mode 100644 pkg/cli/environment.go create mode 100644 pkg/cli/environment_test.go create mode 100644 pkg/cli/output/output.go create mode 100644 pkg/cli/roundtripper.go create mode 100644 pkg/cli/values/options.go create mode 100644 pkg/cli/values/options_test.go create mode 100644 pkg/downloader/chart_downloader.go create mode 100644 pkg/downloader/chart_downloader_test.go create mode 100644 pkg/downloader/doc.go create mode 100644 pkg/downloader/manager.go create mode 100644 pkg/downloader/manager_test.go create mode 100644 pkg/downloader/testdata/helm-test-key.pub create mode 100644 pkg/downloader/testdata/helm-test-key.secret create mode 100644 pkg/downloader/testdata/local-subchart-0.1.0.tgz create mode 100644 pkg/downloader/testdata/local-subchart/Chart.yaml create mode 100644 pkg/downloader/testdata/repositories.yaml create mode 100644 pkg/downloader/testdata/repository/encoded-url-index.yaml create mode 100644 pkg/downloader/testdata/repository/kubernetes-charts-index.yaml create mode 100644 pkg/downloader/testdata/repository/malformed-index.yaml create mode 100644 pkg/downloader/testdata/repository/testing-basicauth-index.yaml create mode 100644 pkg/downloader/testdata/repository/testing-ca-file-index.yaml create mode 100644 pkg/downloader/testdata/repository/testing-https-index.yaml create mode 100644 pkg/downloader/testdata/repository/testing-https-insecureskip-tls-verify-index.yaml create mode 100644 pkg/downloader/testdata/repository/testing-index.yaml create mode 100644 pkg/downloader/testdata/repository/testing-querystring-index.yaml create mode 100644 pkg/downloader/testdata/repository/testing-relative-index.yaml create mode 100644 pkg/downloader/testdata/repository/testing-relative-trailing-slash-index.yaml create mode 100644 pkg/downloader/testdata/signtest-0.1.0.tgz create mode 100644 pkg/downloader/testdata/signtest-0.1.0.tgz.prov create mode 100644 pkg/downloader/testdata/signtest/.helmignore create mode 100644 pkg/downloader/testdata/signtest/Chart.yaml create mode 100644 pkg/downloader/testdata/signtest/alpine/Chart.yaml create mode 100644 pkg/downloader/testdata/signtest/alpine/README.md create mode 100644 pkg/downloader/testdata/signtest/alpine/templates/alpine-pod.yaml create mode 100644 pkg/downloader/testdata/signtest/alpine/values.yaml create mode 100644 pkg/downloader/testdata/signtest/templates/pod.yaml create mode 100644 pkg/downloader/testdata/signtest/values.yaml create mode 100644 pkg/engine/doc.go create mode 100644 pkg/engine/engine.go create mode 100644 pkg/engine/engine_test.go create mode 100644 pkg/engine/files.go create mode 100644 pkg/engine/files_test.go create mode 100644 pkg/engine/funcs.go create mode 100644 pkg/engine/funcs_test.go create mode 100644 pkg/engine/lookup_func.go create mode 100644 pkg/gates/doc.go create mode 100644 pkg/gates/gates.go create mode 100644 pkg/gates/gates_test.go create mode 100644 pkg/getter/doc.go create mode 100644 pkg/getter/getter.go create mode 100644 pkg/getter/getter_test.go create mode 100644 pkg/getter/httpgetter.go create mode 100644 pkg/getter/httpgetter_test.go create mode 100644 pkg/getter/ocigetter.go create mode 100644 pkg/getter/ocigetter_test.go create mode 100644 pkg/getter/plugingetter.go create mode 100644 pkg/getter/plugingetter_test.go create mode 100644 pkg/getter/testdata/ca.crt create mode 100644 pkg/getter/testdata/client.crt create mode 100644 pkg/getter/testdata/client.key create mode 100644 pkg/getter/testdata/empty-0.0.1.tgz create mode 100755 pkg/getter/testdata/plugins/testgetter/get.sh create mode 100644 pkg/getter/testdata/plugins/testgetter/plugin.yaml create mode 100755 pkg/getter/testdata/plugins/testgetter2/get.sh create mode 100644 pkg/getter/testdata/plugins/testgetter2/plugin.yaml create mode 100644 pkg/getter/testdata/repository/local/index.yaml create mode 100644 pkg/getter/testdata/repository/repositories.yaml create mode 100644 pkg/helmpath/home.go create mode 100644 pkg/helmpath/home_unix_test.go create mode 100644 pkg/helmpath/home_windows_test.go create mode 100644 pkg/helmpath/lazypath.go create mode 100644 pkg/helmpath/lazypath_darwin.go create mode 100644 pkg/helmpath/lazypath_darwin_test.go create mode 100644 pkg/helmpath/lazypath_unix.go create mode 100644 pkg/helmpath/lazypath_unix_test.go create mode 100644 pkg/helmpath/lazypath_windows.go create mode 100644 pkg/helmpath/lazypath_windows_test.go create mode 100644 pkg/helmpath/xdg/xdg.go create mode 100644 pkg/ignore/doc.go create mode 100644 pkg/ignore/rules.go create mode 100644 pkg/ignore/rules_test.go create mode 100644 pkg/ignore/testdata/.helmignore create mode 100644 pkg/ignore/testdata/.joonix create mode 100644 pkg/ignore/testdata/a.txt create mode 100644 pkg/ignore/testdata/cargo/a.txt create mode 100644 pkg/ignore/testdata/cargo/b.txt create mode 100644 pkg/ignore/testdata/cargo/c.txt create mode 100644 pkg/ignore/testdata/helm.txt create mode 100644 pkg/ignore/testdata/mast/a.txt create mode 100644 pkg/ignore/testdata/mast/b.txt create mode 100644 pkg/ignore/testdata/mast/c.txt create mode 100644 pkg/ignore/testdata/rudder.txt create mode 100644 pkg/ignore/testdata/templates/.dotfile create mode 100644 pkg/ignore/testdata/tiller.txt create mode 100644 pkg/kube/client.go create mode 100644 pkg/kube/client_test.go create mode 100644 pkg/kube/config.go create mode 100644 pkg/kube/converter.go create mode 100644 pkg/kube/factory.go create mode 100644 pkg/kube/fake/fake.go create mode 100644 pkg/kube/fake/printer.go create mode 100644 pkg/kube/interface.go create mode 100644 pkg/kube/ready.go create mode 100644 pkg/kube/ready_test.go create mode 100644 pkg/kube/resource.go create mode 100644 pkg/kube/resource_policy.go create mode 100644 pkg/kube/resource_test.go create mode 100644 pkg/kube/result.go create mode 100644 pkg/kube/wait.go create mode 100644 pkg/lint/lint.go create mode 100644 pkg/lint/lint_test.go create mode 100644 pkg/lint/rules/chartfile.go create mode 100644 pkg/lint/rules/chartfile_test.go create mode 100644 pkg/lint/rules/dependencies.go create mode 100644 pkg/lint/rules/dependencies_test.go create mode 100644 pkg/lint/rules/deprecations.go create mode 100644 pkg/lint/rules/deprecations_test.go create mode 100644 pkg/lint/rules/template.go create mode 100644 pkg/lint/rules/template_test.go create mode 100644 pkg/lint/rules/testdata/albatross/Chart.yaml create mode 100644 pkg/lint/rules/testdata/albatross/templates/_helpers.tpl create mode 100644 pkg/lint/rules/testdata/albatross/templates/fail.yaml create mode 100644 pkg/lint/rules/testdata/albatross/templates/svc.yaml create mode 100644 pkg/lint/rules/testdata/albatross/values.yaml create mode 100644 pkg/lint/rules/testdata/anotherbadchartfile/Chart.yaml create mode 100644 pkg/lint/rules/testdata/badchartfile/Chart.yaml create mode 100644 pkg/lint/rules/testdata/badchartfile/values.yaml create mode 100644 pkg/lint/rules/testdata/badchartname/Chart.yaml create mode 100644 pkg/lint/rules/testdata/badchartname/values.yaml create mode 100644 pkg/lint/rules/testdata/badvaluesfile/Chart.yaml create mode 100644 pkg/lint/rules/testdata/badvaluesfile/templates/badvaluesfile.yaml create mode 100644 pkg/lint/rules/testdata/badvaluesfile/values.yaml create mode 100644 pkg/lint/rules/testdata/goodone/Chart.yaml create mode 100644 pkg/lint/rules/testdata/goodone/templates/goodone.yaml create mode 100644 pkg/lint/rules/testdata/goodone/values.yaml create mode 100644 pkg/lint/rules/testdata/malformed-template/.helmignore create mode 100644 pkg/lint/rules/testdata/malformed-template/Chart.yaml create mode 100644 pkg/lint/rules/testdata/malformed-template/templates/bad.yaml create mode 100644 pkg/lint/rules/testdata/malformed-template/values.yaml create mode 100644 pkg/lint/rules/testdata/multi-template-fail/Chart.yaml create mode 100644 pkg/lint/rules/testdata/multi-template-fail/templates/multi-fail.yaml create mode 100644 pkg/lint/rules/testdata/v3-fail/Chart.yaml create mode 100644 pkg/lint/rules/testdata/v3-fail/templates/_helpers.tpl create mode 100644 pkg/lint/rules/testdata/v3-fail/templates/deployment.yaml create mode 100644 pkg/lint/rules/testdata/v3-fail/templates/ingress.yaml create mode 100644 pkg/lint/rules/testdata/v3-fail/templates/service.yaml create mode 100644 pkg/lint/rules/testdata/v3-fail/values.yaml create mode 100644 pkg/lint/rules/testdata/withsubchart/Chart.yaml create mode 100644 pkg/lint/rules/testdata/withsubchart/charts/subchart/Chart.yaml create mode 100644 pkg/lint/rules/testdata/withsubchart/charts/subchart/templates/subchart.yaml create mode 100644 pkg/lint/rules/testdata/withsubchart/charts/subchart/values.yaml create mode 100644 pkg/lint/rules/testdata/withsubchart/templates/mainchart.yaml create mode 100644 pkg/lint/rules/testdata/withsubchart/values.yaml create mode 100644 pkg/lint/rules/values.go create mode 100644 pkg/lint/rules/values_test.go create mode 100644 pkg/lint/support/doc.go create mode 100644 pkg/lint/support/message.go create mode 100644 pkg/lint/support/message_test.go create mode 100644 pkg/plugin/cache/cache.go create mode 100644 pkg/plugin/hooks.go create mode 100644 pkg/plugin/installer/base.go create mode 100644 pkg/plugin/installer/base_test.go create mode 100644 pkg/plugin/installer/doc.go create mode 100644 pkg/plugin/installer/http_installer.go create mode 100644 pkg/plugin/installer/http_installer_test.go create mode 100644 pkg/plugin/installer/installer.go create mode 100644 pkg/plugin/installer/installer_test.go create mode 100644 pkg/plugin/installer/local_installer.go create mode 100644 pkg/plugin/installer/local_installer_test.go create mode 100644 pkg/plugin/installer/vcs_installer.go create mode 100644 pkg/plugin/installer/vcs_installer_test.go create mode 100644 pkg/plugin/plugin.go create mode 100644 pkg/plugin/plugin_test.go create mode 100644 pkg/plugin/testdata/plugdir/bad/duplicate-entries/plugin.yaml create mode 100644 pkg/plugin/testdata/plugdir/good/downloader/plugin.yaml create mode 100644 pkg/plugin/testdata/plugdir/good/echo/plugin.yaml create mode 100755 pkg/plugin/testdata/plugdir/good/hello/hello.sh create mode 100644 pkg/plugin/testdata/plugdir/good/hello/plugin.yaml create mode 100644 pkg/postrender/exec.go create mode 100644 pkg/postrender/exec_test.go create mode 100644 pkg/postrender/postrender.go create mode 100644 pkg/provenance/doc.go create mode 100644 pkg/provenance/sign.go create mode 100644 pkg/provenance/sign_test.go create mode 100644 pkg/provenance/testdata/hashtest-1.2.3.tgz create mode 100755 pkg/provenance/testdata/hashtest-1.2.3.tgz.prov create mode 100644 pkg/provenance/testdata/hashtest.sha256 create mode 100644 pkg/provenance/testdata/hashtest/.helmignore create mode 100644 pkg/provenance/testdata/hashtest/Chart.yaml create mode 100644 pkg/provenance/testdata/hashtest/values.yaml create mode 100644 pkg/provenance/testdata/helm-password-key.secret create mode 100644 pkg/provenance/testdata/helm-test-key.pub create mode 100644 pkg/provenance/testdata/helm-test-key.secret create mode 100644 pkg/provenance/testdata/msgblock.yaml create mode 100644 pkg/provenance/testdata/msgblock.yaml.asc create mode 100644 pkg/provenance/testdata/msgblock.yaml.tampered create mode 100755 pkg/provenance/testdata/regen-hashtest.sh create mode 100644 pkg/pusher/doc.go create mode 100644 pkg/pusher/ocipusher.go create mode 100644 pkg/pusher/ocipusher_test.go create mode 100644 pkg/pusher/pusher.go create mode 100644 pkg/pusher/pusher_test.go create mode 100644 pkg/registry/client.go create mode 100644 pkg/registry/client_http_test.go create mode 100644 pkg/registry/client_insecure_tls_test.go create mode 100644 pkg/registry/client_tls_test.go create mode 100644 pkg/registry/constants.go create mode 100644 pkg/registry/testdata/tls/ca.crt create mode 100644 pkg/registry/testdata/tls/client.crt create mode 100644 pkg/registry/testdata/tls/client.key create mode 100644 pkg/registry/testdata/tls/server.crt create mode 100644 pkg/registry/testdata/tls/server.key create mode 100644 pkg/registry/util.go create mode 100644 pkg/registry/util_test.go create mode 100644 pkg/registry/utils_test.go create mode 100644 pkg/release/hook.go create mode 100644 pkg/release/info.go create mode 100644 pkg/release/mock.go create mode 100644 pkg/release/release.go create mode 100644 pkg/release/responses.go create mode 100644 pkg/release/status.go create mode 100644 pkg/releaseutil/filter.go create mode 100644 pkg/releaseutil/filter_test.go create mode 100644 pkg/releaseutil/kind_sorter.go create mode 100644 pkg/releaseutil/kind_sorter_test.go create mode 100644 pkg/releaseutil/manifest.go create mode 100644 pkg/releaseutil/manifest_sorter.go create mode 100644 pkg/releaseutil/manifest_sorter_test.go create mode 100644 pkg/releaseutil/manifest_test.go create mode 100644 pkg/releaseutil/sorter.go create mode 100644 pkg/releaseutil/sorter_test.go create mode 100644 pkg/repo/chartrepo.go create mode 100644 pkg/repo/chartrepo_test.go create mode 100644 pkg/repo/doc.go create mode 100644 pkg/repo/index.go create mode 100644 pkg/repo/index_test.go create mode 100644 pkg/repo/repo.go create mode 100644 pkg/repo/repo_test.go create mode 100644 pkg/repo/repotest/doc.go create mode 100644 pkg/repo/repotest/server.go create mode 100644 pkg/repo/repotest/server_test.go create mode 100644 pkg/repo/repotest/testdata/examplechart-0.1.0.tgz create mode 100644 pkg/repo/repotest/testdata/examplechart/.helmignore create mode 100644 pkg/repo/repotest/testdata/examplechart/Chart.yaml create mode 100644 pkg/repo/repotest/testdata/examplechart/values.yaml create mode 100644 pkg/repo/testdata/chartmuseum-index.yaml create mode 100644 pkg/repo/testdata/local-index-annotations.yaml create mode 100644 pkg/repo/testdata/local-index-unordered.yaml create mode 100644 pkg/repo/testdata/local-index.json create mode 100644 pkg/repo/testdata/local-index.yaml create mode 100644 pkg/repo/testdata/old-repositories.yaml create mode 100644 pkg/repo/testdata/repositories.yaml create mode 100644 pkg/repo/testdata/repository/frobnitz-1.2.3.tgz create mode 100644 pkg/repo/testdata/repository/sprocket-1.1.0.tgz create mode 100644 pkg/repo/testdata/repository/sprocket-1.2.0.tgz create mode 100644 pkg/repo/testdata/repository/universe/zarthal-1.0.0.tgz create mode 100644 pkg/repo/testdata/server/index.yaml create mode 100644 pkg/repo/testdata/server/test.txt create mode 100644 pkg/storage/driver/cfgmaps.go create mode 100644 pkg/storage/driver/cfgmaps_test.go create mode 100644 pkg/storage/driver/driver.go create mode 100644 pkg/storage/driver/labels.go create mode 100644 pkg/storage/driver/labels_test.go create mode 100644 pkg/storage/driver/memory.go create mode 100644 pkg/storage/driver/memory_test.go create mode 100644 pkg/storage/driver/mock_test.go create mode 100644 pkg/storage/driver/records.go create mode 100644 pkg/storage/driver/records_test.go create mode 100644 pkg/storage/driver/secrets.go create mode 100644 pkg/storage/driver/secrets_test.go create mode 100644 pkg/storage/driver/sql.go create mode 100644 pkg/storage/driver/sql_test.go create mode 100644 pkg/storage/driver/util.go create mode 100644 pkg/storage/driver/util_test.go create mode 100644 pkg/storage/storage.go create mode 100644 pkg/storage/storage_test.go create mode 100644 pkg/strvals/doc.go create mode 100644 pkg/strvals/literal_parser.go create mode 100644 pkg/strvals/literal_parser_test.go create mode 100644 pkg/strvals/parser.go create mode 100644 pkg/strvals/parser_test.go create mode 100644 pkg/time/time.go create mode 100644 pkg/time/time_test.go create mode 100644 pkg/uploader/chart_uploader.go create mode 100644 pkg/uploader/doc.go create mode 100755 scripts/coverage.sh create mode 100755 scripts/get create mode 100755 scripts/get-helm-3 create mode 100755 scripts/release-notes.sh create mode 100755 scripts/sync-repo.sh create mode 100644 scripts/util.sh create mode 100755 scripts/validate-license.sh create mode 100644 testdata/crt.pem create mode 100755 testdata/generate.sh create mode 100644 testdata/key.pem create mode 100644 testdata/openssl.conf create mode 100644 testdata/releases.yaml create mode 100644 testdata/rootca.crt create mode 100644 testdata/rootca.key diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000..b377a086 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,14 @@ +--- + +# This file can be removed when Helm no longer uses CircleCI on any release +# branches. Once CircleCI is turned off this file can be removed. +version: 2 + +jobs: + build: + docker: + - image: cimg/go:1.18 + + steps: + - checkout + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..c9702f2c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 + +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "daily" + groups: + k8s.io: + patterns: + - "k8s.io/api" + - "k8s.io/apiextensions-apiserver" + - "k8s.io/apimachinery" + - "k8s.io/apiserver" + - "k8s.io/cli-runtime" + - "k8s.io/client-go" + - "k8s.io/kubectl" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/issue_template.md b/.github/issue_template.md new file mode 100644 index 00000000..48f48e5b --- /dev/null +++ b/.github/issue_template.md @@ -0,0 +1,9 @@ + + +Output of `helm version`: + +Output of `kubectl version`: + +Cloud Provider/Platform (AKS, GKE, Minikube etc.): + + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..cda9086d --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,12 @@ + + +**What this PR does / why we need it**: + +**Special notes for your reviewer**: + +**If applicable**: +- [ ] this PR contains documentation +- [ ] this PR contains unit tests +- [ ] this PR has been tested for backwards compatibility diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml new file mode 100644 index 00000000..759da782 --- /dev/null +++ b/.github/workflows/build-test.yml @@ -0,0 +1,26 @@ +name: build-test +on: + push: + branches: + - 'main' + - 'release-**' + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout source code + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1 + - name: Setup Go + uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # pin@4.1.0 + with: + go-version: '1.21' + - name: Test source headers are present + run: make test-source-headers + - name: Run unit tests + run: make test-coverage + - name: Test build + run: make build diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 00000000..45fc2128 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,67 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ main ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ main ] + schedule: + - cron: '29 6 * * 6' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + language: [ 'go' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] + # Learn more: + # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed + + steps: + - name: Checkout repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@b374143c1149a9115d881581d29b8390bbcbb59c # pinv3.22.11 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@b374143c1149a9115d881581d29b8390bbcbb59c # pinv3.22.11 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@b374143c1149a9115d881581d29b8390bbcbb59c # pinv3.22.11 diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml new file mode 100644 index 00000000..dcc982dc --- /dev/null +++ b/.github/workflows/golangci-lint.yml @@ -0,0 +1,22 @@ +name: golangci-lint + +on: + push: + pull_request: + +jobs: + golangci: + name: golangci-lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1 + + - name: Setup Go + uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # pin@4.1.0 + with: + go-version: "1.21" + - name: golangci-lint + uses: golangci/golangci-lint-action@3a919529898de77ec3da873e3063ca4b10e7f5cc #pin@3.7.0 + with: + version: v1.55 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..81938d3d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,102 @@ +name: release +on: + create: + tags: + - v* + push: + branches: + - main + +# Note the only differences between release and canary-release jobs are: +# - only canary passes --overwrite flag +# - the VERSION make variable passed to 'make dist checksum' is expected to +# be "canary" if the job is triggered by a push to "main" branch. If the +# job is triggered by a tag push, VERSION should be the tag ref. +jobs: + release: + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + steps: + - name: Checkout source code + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1 + with: + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # pin@4.1.0 + with: + go-version: '1.21' + + - name: Run unit tests + run: make test-coverage + + - name: Build Helm Binaries + run: | + set -eu -o pipefail + + make build-cross + make dist checksum VERSION="${{ github.ref_name }}" + + - name: Set latest version + run: | + set -eu -o pipefail + + mkdir -p _dist_versions + + # Push the latest semver tag, excluding prerelease tags + LATEST_VERSION="$(git tag | sort -r --version-sort | grep '^v[0-9]' | grep -v '-' | head -n1)" + echo "LATEST_VERSION=${LATEST_VERSION}" + echo "${LATEST_VERSION}" > _dist_versions/helm-latest-version + echo "${LATEST_VERSION}" > _dist_versions/helm3-latest-version + + - name: Upload Binaries + uses: bacongobbler/azure-blob-storage-upload@50f7d898b7697e864130ea04c303ca38b5751c50 # pin@3.0.0 + env: + AZURE_STORAGE_CONNECTION_STRING: "${{ secrets.AZURE_STORAGE_CONNECTION_STRING }}" + AZURE_STORAGE_CONTAINER_NAME: "${{ secrets.AZURE_STORAGE_CONTAINER_NAME }}" + with: + source_dir: _dist + container_name: ${{ secrets.AZURE_STORAGE_CONTAINER_NAME }} + connection_string: ${{ secrets.AZURE_STORAGE_CONNECTION_STRING }} + extra_args: '--pattern helm-*' + + - name: Upload Version tag files + uses: bacongobbler/azure-blob-storage-upload@50f7d898b7697e864130ea04c303ca38b5751c50 # pin@3.0.0 + env: + AZURE_STORAGE_CONNECTION_STRING: "${{ secrets.AZURE_STORAGE_CONNECTION_STRING }}" + AZURE_STORAGE_CONTAINER_NAME: "${{ secrets.AZURE_STORAGE_CONTAINER_NAME }}" + with: + overwrite: 'true' + source_dir: _dist_versions + container_name: ${{ secrets.AZURE_STORAGE_CONTAINER_NAME }} + connection_string: ${{ secrets.AZURE_STORAGE_CONNECTION_STRING }} + + canary-release: + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + steps: + - name: Checkout source code + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1 + + - name: Setup Go + uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # pin@4.1.0 + with: + go-version: '1.21' + + - name: Run unit tests + run: make test-coverage + + - name: Build Helm Binaries + run: | + make build-cross + make dist checksum VERSION="canary" + + - name: Upload Binaries + uses: bacongobbler/azure-blob-storage-upload@50f7d898b7697e864130ea04c303ca38b5751c50 # pin@3.0.0 + with: + source_dir: _dist + container_name: ${{ secrets.AZURE_STORAGE_CONTAINER_NAME }} + connection_string: ${{ secrets.AZURE_STORAGE_CONNECTION_STRING }} + extra_args: '--pattern helm-*' + # WARNING: this will overwrite existing blobs in your blob storage + overwrite: 'true' diff --git a/.github/workflows/stale-issue-bot.yaml b/.github/workflows/stale-issue-bot.yaml new file mode 100644 index 00000000..85160634 --- /dev/null +++ b/.github/workflows/stale-issue-bot.yaml @@ -0,0 +1,16 @@ +name: "Close stale issues" +on: + schedule: + - cron: "0 0 * * *" +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v3.0.14 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + stale-issue-message: 'This issue has been marked as stale because it has been open for 90 days with no activity. This thread will be automatically closed in 30 days if no further activity occurs.' + exempt-issue-labels: 'keep open,v4.x,in progress' + days-before-stale: 90 + days-before-close: 30 + operations-per-run: 100 diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..ef980661 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +*.exe +*.swp +.DS_Store +.coverage/ +.idea/ +.vimrc +.vscode/ +_dist/ +_dist_versions/ +bin/ +vendor/ +# Ignores charts pulled for dependency build tests +cmd/helm/testdata/testcharts/issue-7233/charts/* diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..3cf50a0d --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,25 @@ +run: + timeout: 10m + +linters: + disable-all: true + enable: + - dupl + - gofmt + - goimports + - gosimple + - govet + - ineffassign + - misspell + - nakedret + - revive + - unused + - staticcheck + +linters-settings: + gofmt: + simplify: true + goimports: + local-prefixes: helm.sh/helm/v3 + dupl: + threshold: 400 diff --git a/ADOPTERS.md b/ADOPTERS.md new file mode 100644 index 00000000..9d5365b7 --- /dev/null +++ b/ADOPTERS.md @@ -0,0 +1,16 @@ + To add your organization to this list, open a pull request that adds your + organization's name, optionally with a link. The list is in alphabetical order. + + (Remember to use `git commit --signoff` to comply with the DCO) + +# Organizations Using Helm + +- [Blood Orange](https://bloodorange.io) +- [IBM](https://www.ibm.com) +- [Microsoft](https://microsoft.com) +- [Qovery](https://www.qovery.com/) +- [Samsung SDS](https://www.samsungsds.com/) +- [Softonic](https://hello.softonic.com/) +- [Ville de Montreal](https://montreal.ca) + +_This file is part of the CNCF official documentation for projects._ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..5bbe2ec6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,352 @@ +# Contributing Guidelines + +The Helm project accepts contributions via GitHub pull requests. This document outlines the process +to help get your contribution accepted. + +## Reporting a Security Issue + +Most of the time, when you find a bug in Helm, it should be reported using [GitHub +issues](https://github.com/helm/helm/issues). However, if you are reporting a _security +vulnerability_, please email a report to +[cncf-helm-security@lists.cncf.io](mailto:cncf-helm-security@lists.cncf.io). This will give us a +chance to try to fix the issue before it is exploited in the wild. + +## Sign Your Work + +The sign-off is a simple line at the end of the explanation for a commit. All commits need to be +signed. Your signature certifies that you wrote the patch or otherwise have the right to contribute +the material. The rules are pretty simple, if you can certify the below (from +[developercertificate.org](https://developercertificate.org/)): + +``` +Developer Certificate of Origin +Version 1.1 + +Copyright (C) 2004, 2006 The Linux Foundation and its contributors. +1 Letterman Drive +Suite D4700 +San Francisco, CA, 94129 + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +(b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + +(c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +(d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. +``` + +Then you just add a line to every git commit message: + + Signed-off-by: Joe Smith + +Use your real name (sorry, no pseudonyms or anonymous contributions.) + +If you set your `user.name` and `user.email` git configs, you can sign your commit automatically +with `git commit -s`. + +Note: If your git config information is set properly then viewing the `git log` information for your + commit will look something like this: + +``` +Author: Joe Smith +Date: Thu Feb 2 11:41:15 2018 -0800 + + Update README + + Signed-off-by: Joe Smith +``` + +Notice the `Author` and `Signed-off-by` lines match. If they don't your PR will be rejected by the +automated DCO check. + +## Support Channels + +Whether you are a user or contributor, official support channels include: + +- [Issues](https://github.com/helm/helm/issues) +- Slack: + - User: [#helm-users](https://kubernetes.slack.com/messages/C0NH30761/details/) + - Contributor: [#helm-dev](https://kubernetes.slack.com/messages/C51E88VDG/) + +Before opening a new issue or submitting a new pull request, it's helpful to search the project - +it's likely that another user has already reported the issue you're facing, or it's a known issue +that we're already aware of. It is also worth asking on the Slack channels. + +## Milestones + +We use milestones to track progress of specific planned releases. + +For example, if the latest currently-released version is `3.2.1`, an issue/PR which pertains to a +specific upcoming bugfix or feature release could fall into one of two different active milestones: +`3.2.2` or `3.3.0`. + +Issues and PRs which are deemed backwards-incompatible may be added to the discussion items for +Helm 4 with [label:v4.x](https://github.com/helm/helm/labels/v4.x). An issue or PR that we are not +sure we will be addressing will not be added to any milestone. + +A milestone (and hence release) can be closed when all outstanding issues/PRs have been closed +or moved to another milestone and the associated release has been published. + +## Semantic Versioning + +Helm maintains a strong commitment to backward compatibility. All of our changes to protocols and +formats are backward compatible from one major release to the next. No features, flags, or commands +are removed or substantially modified (unless we need to fix a security issue). + +We also try very hard to not change publicly accessible Go library definitions inside of the `pkg/` +directory of our source code. + +For a quick summary of our backward compatibility guidelines for releases between 3.0 and 4.0: + +- Command line commands, flags, and arguments MUST be backward compatible +- File formats (such as Chart.yaml) MUST be backward compatible +- Any chart that worked on a previous version of Helm 3 MUST work on a new version of Helm 3 + (barring the cases where (a) Kubernetes itself changed, and (b) the chart worked because it + exploited a bug) +- Chart repository functionality MUST be backward compatible +- Go libraries inside of `pkg/` SHOULD remain backward compatible, though code inside of `cmd/` and + `internal/` may be changed from release to release without notice. + +## Support Contract for Helm 2 + +With Helm 2's current release schedule, we want to take into account any migration issues for users +due to the upcoming holiday shopping season and tax season. We also want to clarify what actions may +occur after the support contract ends for Helm 2, so that users will not be surprised or caught off +guard. + +After Helm 2.15.0 is released, Helm 2 will go into "maintenance mode". We will continue to accept +bug fixes and fix any security issues that arise, but no new features will be accepted for Helm 2. +All feature development will be moved over to Helm 3. + +6 months after Helm 3.0.0's public release, Helm 2 will stop accepting bug fixes. Only security +issues will be accepted. + +12 months after Helm 3.0.0's public release, support for Helm 2 will formally end. Download links +for the Helm 2 client through Google Cloud Storage, the Docker image for Tiller stored in Google +Container Registry, and the Google Cloud buckets for the stable and incubator chart repositories may +no longer work at any point. Client downloads through `get.helm.sh` will continue to work, and we +will distribute a Tiller image that will be made available at an alternative location which can be +updated with `helm init --tiller-image`. + +## Issues + +Issues are used as the primary method for tracking anything to do with the Helm project. + +### Issue Types + +There are 5 types of issues (each with their own corresponding [label](#labels)): + +- `question/support`: These are support or functionality inquiries that we want to have a record of + for future reference. Generally these are questions that are too complex or large to store in the + Slack channel or have particular interest to the community as a whole. Depending on the + discussion, these can turn into `feature` or `bug` issues. +- `proposal`: Used for items (like this one) that propose a new ideas or functionality that require + a larger community discussion. This allows for feedback from others in the community before a + feature is actually developed. This is not needed for small additions. Final word on whether or + not a feature needs a proposal is up to the core maintainers. All issues that are proposals should + both have a label and an issue title of "Proposal: [the rest of the title]." A proposal can become + a `feature` and does not require a milestone. +- `feature`: These track specific feature requests and ideas until they are complete. They can + evolve from a `proposal` or can be submitted individually depending on the size. +- `bug`: These track bugs with the code +- `docs`: These track problems with the documentation (i.e. missing or incomplete) + +### Issue Lifecycle + +The issue lifecycle is mainly driven by the core maintainers, but is good information for those +contributing to Helm. All issue types follow the same general lifecycle. Differences are noted +below. + +1. Issue creation +2. Triage + - The maintainer in charge of triaging will apply the proper labels for the issue. This includes + labels for priority, type, and metadata (such as `good first issue`). The only issue priority + we will be tracking is whether or not the issue is "critical." If additional levels are needed + in the future, we will add them. + - (If needed) Clean up the title to succinctly and clearly state the issue. Also ensure that + proposals are prefaced with "Proposal: [the rest of the title]". + - Add the issue to the correct milestone. If any questions come up, don't worry about adding the + issue to a milestone until the questions are answered. + - We attempt to do this process at least once per work day. +3. Discussion + - Issues that are labeled `feature` or `proposal` must write a Helm Improvement Proposal (HIP). + See [Proposing an Idea](#proposing-an-idea). Smaller quality-of-life enhancements are exempt. + - Issues that are labeled as `feature` or `bug` should be connected to the PR that resolves it. + - Whoever is working on a `feature` or `bug` issue (whether a maintainer or someone from the + community), should either assign the issue to themselves or make a comment in the issue saying + that they are taking it. + - `proposal` and `support/question` issues should stay open until resolved or if they have not + been active for more than 30 days. This will help keep the issue queue to a manageable size + and reduce noise. Should the issue need to stay open, the `keep open` label can be added. +4. Issue closure + +## Proposing an Idea + +Before proposing a new idea to the Helm project, please make sure to write up a [Helm Improvement +Proposal](https://github.com/helm/community/tree/master/hips). A Helm Improvement Proposal is a +design document that describes a new feature for the Helm project. The proposal should provide a +concise technical specification and rationale for the feature. + +It is also worth considering vetting your idea with the community via the +[cncf-helm](mailto:cncf-helm@lists.cncf.io) mailing list. Vetting an idea publicly before going as +far as writing a proposal is meant to save the potential author time. Many ideas have been proposed; +it's quite likely there are others in the community who may be working on a similar proposal, or a +similar proposal may have already been written. + +HIPs are submitted to the [helm/community repository](https://github.com/helm/community). [HIP +1](https://github.com/helm/community/blob/master/hips/hip-0001.md) describes the process to write a +HIP as well as the review process. + +After your proposal has been approved, follow the [developer's +guide](https://helm.sh/docs/community/developers/) to get started. + +## How to Contribute a Patch + +1. Identify or create the related issue. If you're proposing a larger change to + Helm, see [Proposing an Idea](#proposing-an-idea). +2. Fork the desired repo; develop and test your code changes. +3. Submit a pull request, making sure to sign your work and link the related issue. + +Coding conventions and standards are explained in the [official developer +docs](https://helm.sh/docs/developers/). + +## Pull Requests + +Like any good open source project, we use Pull Requests (PRs) to track code changes. + +### PR Lifecycle + +1. PR creation + - PRs are usually created to fix or else be a subset of other PRs that fix a particular issue. + - We more than welcome PRs that are currently in progress. They are a great way to keep track of + important work that is in-flight, but useful for others to see. If a PR is a work in progress, + it **must** be prefaced with "WIP: [title]". Once the PR is ready for review, remove "WIP" + from the title. + - It is preferred, but not required, to have a PR tied to a specific issue. There can be + circumstances where if it is a quick fix then an issue might be overkill. The details provided + in the PR description would suffice in this case. +2. Triage + - The maintainer in charge of triaging will apply the proper labels for the issue. This should + include at least a size label, `bug` or `feature`, and `awaiting review` once all labels are + applied. See the [Labels section](#labels) for full details on the definitions of labels. + - Add the PR to the correct milestone. This should be the same as the issue the PR closes. +3. Assigning reviews + - Once a review has the `awaiting review` label, maintainers will review them as schedule + permits. The maintainer who takes the issue should self-request a review. + - PRs from a community member with the label `size/S` or larger requires 2 review approvals from + maintainers before it can be merged. Those with `size/XS` are per the judgement of the + maintainers. For more detail see the [Size Labels](#size-labels) section. +4. Reviewing/Discussion + - All reviews will be completed using GitHub review tool. + - A "Comment" review should be used when there are questions about the code that should be + answered, but that don't involve code changes. This type of review does not count as approval. + - A "Changes Requested" review indicates that changes to the code need to be made before they + will be merged. + - Reviewers should update labels as needed (such as `needs rebase`) +5. Address comments by answering questions or changing code +6. LGTM (Looks good to me) + - Once a Reviewer has completed a review and the code looks ready to merge, an "Approve" review + is used to signal to the contributor and to other maintainers that you have reviewed the code + and feel that it is ready to be merged. +7. Merge or close + - PRs should stay open until merged or if they have not been active for more than 30 days. This + will help keep the PR queue to a manageable size and reduce noise. Should the PR need to stay + open (like in the case of a WIP), the `keep open` label can be added. + - Before merging a PR, refer to the topic on [Size Labels](#size-labels) below to determine if + the PR requires more than one LGTM to merge. + - If the owner of the PR is listed in the `OWNERS` file, that user **must** merge their own PRs + or explicitly request another OWNER do that for them. + - If the owner of a PR is _not_ listed in `OWNERS`, any core maintainer may merge the PR. + +#### Documentation PRs + +Documentation PRs will follow the same lifecycle as other PRs. They will also be labeled with the +`docs` label. For documentation, special attention will be paid to spelling, grammar, and clarity +(whereas those things don't matter *as* much for comments in code). + +## The Triager + +Each week, one of the core maintainers will serve as the designated "triager" starting after the +public stand-up meetings on Thursday. This person will be in charge triaging new PRs and issues +throughout the work week. + +## Labels + +The following tables define all label types used for Helm. It is split up by category. + +### Common + +| Label | Description | +| ----- | ----------- | +| `bug` | Marks an issue as a bug or a PR as a bugfix | +| `critical` | Marks an issue or PR as critical. This means that addressing the PR or issue is top priority and must be addressed as soon as possible | +| `docs` | Indicates the issue or PR is a documentation change | +| `feature` | Marks the issue as a feature request or a PR as a feature implementation | +| `keep open` | Denotes that the issue or PR should be kept open past 30 days of inactivity | +| `refactor` | Indicates that the issue is a code refactor and is not fixing a bug or adding additional functionality | + +### Issue Specific + +| Label | Description | +| ----- | ----------- | +| `help wanted` | Marks an issue needs help from the community to solve | +| `proposal` | Marks an issue as a proposal | +| `question/support` | Marks an issue as a support request or question | +| `good first issue` | Marks an issue as a good starter issue for someone new to Helm | +| `wont fix` | Marks an issue as discussed and will not be implemented (or accepted in the case of a proposal) | + +### PR Specific + +| Label | Description | +| ----- | ----------- | +| `awaiting review` | Indicates a PR has been triaged and is ready for someone to review | +| `breaking` | Indicates a PR has breaking changes (such as API changes) | +| `in progress` | Indicates that a maintainer is looking at the PR, even if no review has been posted yet | +| `needs rebase` | Indicates a PR needs to be rebased before it can be merged | +| `needs pick` | Indicates a PR needs to be cherry-picked into a feature branch (generally bugfix branches). Once it has been, the `picked` label should be applied and this one removed | +| `picked` | This PR has been cherry-picked into a feature branch | + +#### Size labels + +Size labels are used to indicate how "dangerous" a PR is. The guidelines below are used to assign +the labels, but ultimately this can be changed by the maintainers. For example, even if a PR only +makes 30 lines of changes in 1 file, but it changes key functionality, it will likely be labeled as +`size/L` because it requires sign off from multiple people. Conversely, a PR that adds a small +feature, but requires another 150 lines of tests to cover all cases, could be labeled as `size/S` +even though the number of lines is greater than defined below. + +Any changes from the community labeled as `size/S` or larger should be thoroughly tested before +merging and always requires approval from 2 core maintainers. PRs submitted by a core maintainer, +regardless of size, only requires approval from one additional maintainer. This ensures there are at +least two maintainers who are aware of any significant PRs introduced to the codebase. + +| Label | Description | +| ----- | ----------- | +| `size/XS` | Denotes a PR that changes 0-9 lines, ignoring generated files. Very little testing may be required depending on the change. | +| `size/S` | Denotes a PR that changes 10-29 lines, ignoring generated files. Only small amounts of manual testing may be required. | +| `size/M` | Denotes a PR that changes 30-99 lines, ignoring generated files. Manual validation should be required. | +| `size/L` | Denotes a PR that changes 100-499 lines, ignoring generated files. | +| `size/XL` | Denotes a PR that changes 500-999 lines, ignoring generated files. | +| `size/XXL` | Denotes a PR that changes 1000+ lines, ignoring generated files. | diff --git a/KEYS b/KEYS new file mode 100644 index 00000000..89ef930f --- /dev/null +++ b/KEYS @@ -0,0 +1,942 @@ +This file contains the PGP keys of developers who have signed releases of Helm. + +For your convenience, commands are provided for those who use pgp and gpg. + +For users to import keys: + pgp < KEYS + or + gpg --import KEYS + +Developers to add their keys: + pgp -kxa and append it to this file. + or + (pgpk -ll && pgpk -xa ) >> KEYS + or + (gpg --list-sigs + && gpg --armor --export ) >> KEYS + +pub rsa4096/0x461449C25E36B98E 2017-11-10 [SC] + 672C657BE06B4B30969C4A57461449C25E36B98E +uid [ultimate] Matthew Farina +sig 3 0x461449C25E36B98E 2017-11-10 Matthew Farina +sig 0x2CDBBFBB37AE822A 2018-12-12 Adnan Abdulhussein +sig 0x1EF612347F8A9958 2018-12-12 Adam Reese +sig 0x62F49E747D911B60 2018-12-12 Matt Butcher +sub rsa4096/0xCCCE67689DF05738 2017-11-10 [E] +sig 0x461449C25E36B98E 2017-11-10 Matthew Farina +sub rsa4096/0x9436E80BFBA46909 2017-11-10 [S] [expires: 2022-11-09] +sig 0x461449C25E36B98E 2017-11-10 Matthew Farina + +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 ++YMbPW0/IQ== +=g11H +-----END PGP PUBLIC KEY BLOCK----- + +pub rsa4096 2019-05-15 [SC] + F1261BDE929012C8FF2E501D6EA5D7598529A53E +uid [ultimate] Martin Hickey +sig 3 6EA5D7598529A53E 2019-05-15 Martin Hickey +sub rsa4096 2019-05-15 [E] +sig 6EA5D7598529A53E 2019-05-15 Martin Hickey + +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBFzcLlgBEACsmjtsbfMuKiKBl3yV5FsQBxvmNyhIwUJMtjgm5CMFcOLD+jDw +mExfsE8sM5fqfS5P7NFHn3V6NY/GyKNH3DZHGhYwDw/vG6JfHo1s9IzhjySuWEtL +7GUCJBKXk2cDfk4p0lHRgEtoYjG/sRMgk3y7WTR/W0McxllcrQQBB3RREbz8y7r7 +atJCeec36SSZgXqsyXAESx5dx7qRTdIwObPTCGxBdj2ZkgzT3D35EExdi9I8oM6L +bYOyUPy0aEj/FX6HVBOIWNGB0z8TYXjwY6/3gJG1JhaFZK1zvYogJ3p8jO07bTwo +/AzYAG4NoV4TqTyFPmb0d0+wE+lZOWA3FfF0YtYnNe3KPmPJZ/TXdTO6kle24UTy +Q9GK2s8QB3V9NA09/YoSF1qdjRfL5jo7XnRJztfFgIqW118I4EKSF+kz3hCMxH1Y +iCvHIHFQs+WX6g1bXHDI8JWe7VDiCVYwMxap8o/vtEKoETH9fjOEO/f/YF68hqpX +7eYTacDEV72qikHz/O0hNyeS1m/AnavPrd5RQi53vOT/KhwM+wC4a1bAywQUDZDW +KkSEkTqjzcSryj3DJR6EZ9y4F11Kt4TZoxHvh59UCcVyaTZPl/YdcRWom6eGo/5U +K1MFeF7fTK9ZVuJnvG6av2/W7Sbz9KaJxLHhUNAQ+ytdVkN9xfXrx1HP7QARAQAB +tChNYXJ0aW4gSGlja2V5IDxtYXJ0aW4uaGlja2V5QGllLmlibS5jb20+iQJOBBMB +CgA4FiEE8SYb3pKQEsj/LlAdbqXXWYUppT4FAlzcLlgCGwMFCwkIBwIGFQoJCAsC +BBYCAwECHgECF4AACgkQbqXXWYUppT5IFA//b64QqKN/ookqqeKEUMUOMoZUTi2t +4HPtzX/nqOXDb0zyIyaJaJlgxz+LuoN8CrSrwnmTY/ibKsFS7xkFRIeKYSb9b2no +NPb8F0SVtxYFQJ8d4WU1snAWFJd8aMe3+z8w15Mqz1Sd1lS/sN5s101rbh8jtFZD +NnAZqyfUgIhVq243XfhP4/mHPinpXjjF+APlMbdsOqnWgxzp8E9hpCd/YLb6KY0j +JbwryzH52ha9ZDMdMipH557+Xutcl4Wyn8RsJy38J0qBvy2p8AMZIYotw6pSCedi +7Iva+EitGSXXgRWbR6O68JvUgrFDOjcPKSQy7AlwhTase+b4OA9c3DgSxR5SMBR6 +OLYaIuDeVY2Zjr0ydFdxrfQzlHget7axRH0aaMimyCNfRa3HJea8ffF/Ssv2meUF +IPIhYLn7SBrVoTISu38S6WkhBBkDiHAW7nqV+mWR3cnVjIzIjW56bI06NZ4kqtvk +D9TX7b+KV20cSjjbSGI70023oHFoJSpLsj9+otvPwNrYC2oD0qTLBfNMkpcktnnw +I2uynQrPNbQVeA+cKrECJeyl2yAC4WXvP4ZefvFZX6RnL9HiiZ+pDyBt6Yq3A9AA +NhRd8zEAKNwH88tFmWMinTzCZz04bKvql+E7A3MAaR8WS3BG3JfLXMqOKiMfCHr5 +4Gn3rD4UGtFfxoy5Ag0EXNwuWAEQAKuxVJDOjG+xuaaO2Z/6BQfTaz6/zgzql/pR +UHInKSt5ts2LGdRhfvsNBzGBhoneLWZ8PivHRGSZFsFj5Nzy9/DIkopdHSZhP/zB +aqihHgFJTKxKBfrhP60bYQGBkHNMVwqbFuck24DUCzrMyJXG15f252aY7ByCIIem +SHbmPww5q6HPEPS+hHE4ka4N4s+vqL+oK8ktq7lnZCX+AZ4jIuMAoh/C851hLcr5 +EK+a6tXa2yRJtJfj44GX6+nBVm2w+3eHqOpD7JM7NqWmo41+qg3t2J3zHQf/0ejP +ej+OcVdEBD5zlJL+CNZ9PCMBUOrb+IbqY3ybmJieipOJtOCY8nwUyCueyTmq1tso +OwUsGB9hIsVY11wNgoNgrA6PhExGxcM5S/0Rt4+y/pwFjnqYLXBXyBSjXzzmpjhn +zERjmANlI8QLKHDdShgboDUt3Ynw+D/peTS9iJMIPuUTrcGcKgw4+6FNKACnJ5l7 +Wvz7apgD8QmxnSZMquul23bGihhbQMITWvdF5KEHE06Ah1bOzB3KXBEVx00Y0tO/ +hsY8XH4T/pEKv9FsIF6R4o2k/xm6jR9eZutABVIrizMHkZzjjo1ZC8b15olrZvLa +/DtNHzV5nPPSvGZPcey9BYk6b5GGCfT/EiWtJz8Nxm7/cCYRvuuZnGCxriH6XPww +v8kPNihfABEBAAGJAjYEGAEKACAWIQTxJhvekpASyP8uUB1upddZhSmlPgUCXNwu +WAIbDAAKCRBupddZhSmlPikmD/9UrspSeSjwaXSj2vCpO1pWm6ryVQc2ZzyMnXvq +j5HLwzaVsN8HM/YADK5FL6qqhxrROOZdSHjS92sxk2Rab23gGRKbwDUJmerheZ4B +ZXG40fDOPv45PZ8V0Kn9bzliNpPBFPjoaI8X1AKoIXyUqEy98Y/zhnLDhW/+yPrO +gznPfO5ds75+u4xOx9pTfGpdwt6qhfCdNHUoZWsAw/6pafqrCIvbHjGvmMJyYENS +dl6sPYBeiDkJkH67sGvJghjedhNznnXJ8+sm701eTqZkmpxzc0jvzwgnnYb0rAzS +uU3QNj9w5HcGQd/pk29Ui8A4VWLJOUcDCVa/CIQMQqQDPYJKxaj7XgE+dQ9MxQ3a +O0wgpEo2+4BaZ4I/qP8CgaE9q4IopMhNKPR1IeEFUmTsIzLVAktS/InshFWWUp5e +mEss8kiqxU9bAGZvWopllCaPJQTDZElQpW84Z0afyVLPp47CoKcXBSMsITFt3mRf +ZXAA6h8UlSgC7FV1YT4p6qsHqQ3cLERdTSrQFLmaCb2yRCR2V9d0RiMaIwUmnbld +g1jeR4weO3LLghuWpfZHruDrDU2ZvOAObQIQdHBFmCHejA/gilf0MUdJ1h2gApuJ +m3MUub704EDCTSqz9LJc+4/NbA2esZj7mExCtsMEqaoHW7BU4ws6BRHTyeHgi+Le +1qneNQ== +=oCPv +-----END PGP PUBLIC KEY BLOCK----- + +pub rsa4096 2018-03-14 [SC] + 967F8AC5E2216F9F4FD270AD92AA783CBAAE8E3B +uid [ultimate] Matthew Fisher +sig 3 92AA783CBAAE8E3B 2018-03-14 Matthew Fisher +sub rsa4096 2018-03-14 [E] +sig 92AA783CBAAE8E3B 2018-03-14 Matthew Fisher + +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +Qknjx8fW +=jz9T +-----END PGP PUBLIC KEY BLOCK----- + +pub rsa4096 2018-08-06 [SC] [expires: 2022-08-06] + 76939899B137D575D3274E756DCCB9D752D35BA8 +uid [ultimate] Taylor Thomas +sig 3 6DCCB9D752D35BA8 2018-08-06 Taylor Thomas +sub rsa4096 2018-08-06 [E] [expires: 2022-08-06] +sig 6DCCB9D752D35BA8 2018-08-06 Taylor Thomas + +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBFto3pMBEADAO8mWocOAqBUHtiLBnht3+vLnjLv1LNs2GBdMCDRza51/SzFN +NN5pAETGbFl11zxpm9rBkyjI2xVO4OqI8TNIn6vYPTh2YVBs9UB+qRqjJt94fm9C +tWdQ3/27I4PPrCIw5CxjLKst/GO0BjS/J228wP1JtUeyf/QH9K8hDFeov0y94IMM +s7NFRkqZJ6tXjlDCJnDkPm3wERgY3S2I8bgr/BlGFEWCmjqD75PqHuJYjh4mmXhk +KTeYcJh42INPzCXd3bnvF0NwfmAE70fsSOZz7H3Ox14Gs+Tn+jDC8+Or4CCaqtyE +276d8yyyDXBlDN9IjwhjlJPfx/zMtvD+lAkGV89NwbZ+YnyUNenK2V6H86Efe36t +MxvFCH7rOKjCNjKUE0NUbxXfYig5u6xuZKcBcJmjXbmL2dFUIaMzm8jf1NlLuzjw +k7IVAw2Y9ZcVO1eNgeVxI+NdRsdz8qgBmDTvRhxh2n/ppc+5DDVhiffGqqlIZmYN +NJ2bUhW0x0R2OHgOedMyKnYDGgXPI3hnmY/t48ErDwxTqVNoo2tVU1YWS3eP63oa +8ZAiNsvYVWFWIAUi1Q+ADAhj1GdISg4VU5N97joFZA/POZrRtS43OBDIvCpaKjF/ +bu2EltTVBdGZj6fW8xndZIum9cIHMlMi+gyq/o9kipFyZ7zVEM0SB8aI6QARAQAB +tCtUYXlsb3IgVGhvbWFzIDx0YXlsb3IudGhvbWFzQG1pY3Jvc29mdC5jb20+iQJU +BBMBCAA+FiEEdpOYmbE31XXTJ051bcy511LTW6gFAlto3pMCGwMFCQeGH4AFCwkI +BwIGFQoJCAsCBBYCAwECHgECF4AACgkQbcy511LTW6gkqw/9E/DZMckYjml9gN6f +Z7jyZSzO9zP2pVKvcPvaXU+kcyKPR6r6seYt4uSOdosSsZs/xF7aSPoMezDyNli+ +W0t27DCXtnbk+LYptw6AaevkUF9+Cxe/gfXSQDxU6jtOV00KM4WkJtJ7Zty1dvk3 +PsnpPhbxUAWwULy0wF9Ab9RAXMyz/7TrgWP70EY1G/KkETUHTdSkxaoUPs67F9Y8 +c5qVQjgFVqSeN90h58w/4SF7KkS4EOy7RRyfzaBuyQPPi3fOtvsfAY/cSOVn2PBF +Pj1RPoTREKEa0nnp9TtrlwP7v+ooIvwDeemjL1c6tlTBW67T6UM+W5hcvjegQg1h +uLOdRtiN1HlTvOZngtegvbegGviwpdXahrNxN2mtYCAAYNELNyQOAWERGF9TUKeb +OC1HLbZwXdmPiUlUfPN3aAnMH46qe7eSMAZK203ciZlUxowFuE01X+M3WmLESdP3 +dxv3TACiC55mGBgZm/d/1CK83KBWMlzbgfmop65xbxi/tmpJbYdqoTeidYtUDo+L +IzJVjagvfED49o/U86C5DBr7u0mhZqnAxaWEWRBRgFi1Bnl7w3zSYYhdwGjiYTJ9 +/hejac8iqWc+RC9AJh4HW6itB3jPoEI90aVb1y8hm3UOBQTMEnI+dpvZEQPWSBnd +tWzzQS7et8Tlq0J4/wRVcEXAlmq5Ag0EW2jekwEQAK3KxoH8N7Qc0vSkMQmo/NfO +lEE89/KobYLDvyQMfXQJGF143eaaW2IHcE6OIT6E9IX9vnt00Lfzm0Jwdd3ur5xf +l3GJ9r0riYVNzQ/9kMx4JkoXJ+kgaL2kVTykKERkHUvgRcLkgqVZWMMGz2sUNqYE +XUBtEnYVsZmxQNE8X41NI26XP/1e9jctn/FgAPXMtkLrXRfRIlKzyQTz7zCX1WnK +xeMjFjHkdBD4dP2ohoEkk2y5lDI1hlPginVPZHPShawxd+TE0Vre5i0Wp6Y+lKWL +QtsZKefTbqBDDJlwXmUZc+eu5jEuB7qKDcwH1s8vtZhRVaKFr0kPHEZ3Ka+ODWmt +/v6Zhh/tFkxq0mgh5OHycN3GvwMKp/fEBkRl9pM4RuHrf41XB4+/Joi+CottP5WC +1So8ydbJnG2lYknHxPMrISvACDMLPwWp9IFpr1u4nPkkuAlCuzUkev9r3dJQNq3G +hsTqpQgkHQTd5+QBabE1543gWOCjz9ap80RnIlSfylskR5fWy8U2XnOo7kb5TtnC +hZtJfjpCxHW894VEx3oe8tHaUUdmwrcK3g1DSu5KrtWn976g9tOlcpmfTjqNYEJq +93jD1aCb8yx+LHk5wypQhLpn76AE6YCJebQVxmc0AsqFOlTTRU6yPQF46qYOZD/+ +spUs4bXbxmLghLTO/VmrABEBAAGJAjwEGAEIACYWIQR2k5iZsTfVddMnTnVtzLnX +UtNbqAUCW2jekwIbDAUJB4YfgAAKCRBtzLnXUtNbqE4lEAC+uIwA9vkHHpucTLBq +UiwI4agcY9D0iOGohO7qJQ44MitIsiIqG3Qn1Wps1sdGdoxmFtTE8W0tMhZ+XpTd +ZkL3G8EIhB9gyuel3H1L2vD/6YX3P9Vv4JlcpNDjc/c2i/U+/05kBMwtrgmjB/3T +W1368I9uzfAS3SPYDUsx6nNv6iHhDYDEGOOuBWv5VDvrnYcBbysxkevB6SDZrs5d +7fpmALMsUt8le/y9sn5TFH2CB3aKHGJHMv0RxV5iEXwq7jHPeRJCamzKTCx8/et+ +8wn8Wudk+FiqrSH72BaRb9j7n7KoBuBQB30IbbocRNwGHJHsmuyThGBBZh9Z37Qm +r1qoSNRl+ZJy6QoAO6DVPS6FERDDXYPwrHiC8EbomblcVMYjfI9/Ln+rSB30/OtC +4t+v83v1TPerc1FCXJc2lISs1KLlJnPh5Ykq6IffH9nUALmo5tK5FUDUUhOAxFhe +wCE4fJI+yNIcMHotk5XSxbeSUVFaXDb4Pue/9DjQjnF5iSQGnbveEmGUaXxncjf2 +cJgcNZjd9P4XKqb1hNKpFwgm47dr3TH1/KmkFlfeBK4S/GpVsipWiB9vX4RC28EB +QP4bc5To+ohqwuOLw6hRo0YLf15jTJknCDtfsgKQ6uiR7ai+z6fqoH3kycCCcsPc +Y2/8LdVLydI6o8cZJDEpEexPaA== +=vtJm +-----END PGP PUBLIC KEY BLOCK----- + +pub rsa4096/0x1EF612347F8A9958 2016-07-25 [SC] + Key fingerprint = 49D0 9C86 C3DC 8DA3 F0A0 7622 1EF6 1234 7F8A 9958 +uid [ultimate] Adam Reese +sig 3 0x1EF612347F8A9958 2018-01-02 Adam Reese +sig 3 0x1EF612347F8A9958 2016-07-25 Adam Reese +sig 0x62F49E747D911B60 2018-12-12 Matt Butcher +sig 0x461449C25E36B98E 2018-12-12 Matthew Farina +sig 0x2CDBBFBB37AE822A 2018-12-12 Adnan Abdulhussein +uid [ultimate] Adam Reese +sig 3 0x1EF612347F8A9958 2018-01-02 Adam Reese +sig 3 0x1EF612347F8A9958 2016-07-25 Adam Reese +sig 0x62F49E747D911B60 2018-12-12 Matt Butcher +sig 0x461449C25E36B98E 2018-12-12 Matthew Farina +sig 0x2CDBBFBB37AE822A 2018-12-12 Adnan Abdulhussein +uid [ultimate] Adam Reese +sig 3 0x1EF612347F8A9958 2018-01-02 Adam Reese +sig 3 0x1EF612347F8A9958 2016-07-25 Adam Reese +sig 0x62F49E747D911B60 2018-12-12 Matt Butcher +sig 0x461449C25E36B98E 2018-12-12 Matthew Farina +sig 0x2CDBBFBB37AE822A 2018-12-12 Adnan Abdulhussein +sub rsa2048/0x21DD8DC880EBB474 2016-07-25 [E] [expires: 2024-07-23] +sig 0x1EF612347F8A9958 2016-07-25 Adam Reese +sub rsa2048/0x06F35E60A7A18DD6 2016-07-25 [SA] [expires: 2024-07-23] +sig 0x1EF612347F8A9958 2016-07-25 Adam Reese +sub rsa4096/0x2970B7F911395FDE 2018-01-09 [A] +sig 0x1EF612347F8A9958 2018-01-09 Adam Reese + +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +uuqLDN3Ydqkoa++5HoqIdeh9uW4oiiGnMAkvH24012GpiXN37T45cH8LnNj0XooN +tYuiY/ZoCTw4gawtMwSWSl/htDrkLQKiUSiKdZqBLVXO2wRSjLjFPJEkq+eIBBcc +hvzj2C4a0sZhk4W1gHESeeB8D6IblgMm4oLa8Fn+4YhiwvbK9Dgja/7iiICP8bbO +3/9smCWOnnsixho5Jkd5IXWF0+tcfHVR/l+M8bCf+c02IGE/mD7RMFcxv3jdFYU7 +/HsbwU9fCDSCAgXszwM+232kALGeur0riRJ42X1RNOzh82cF1wzYyxHR8JZMtcIt +x2MWc/n2wJO3swXnItKI1Vy4fjRu2PcJiQIzBBABCAAdFiEEURHac98S2OgSykYv +LNu/uzeugioFAlwRQeYACgkQLNu/uzeugiotag//YwAVNMHNLmOeAzSOzEf6Z6yK +2WDgEhsUt9Ykhy2pSc2vUD7jIXSAPTJYI7yY1flDmOe3kEVXXeVcPYAli9Ii5Eq4 +DYJBC0FGboMbzdwh8P8RZGnhusB9MSlXYi2DnWH+oKGS9dQFnhpzn/lm0nl8tpL3 +FrnwhlshNpgYYqIa29yO1EHskiFVLD6pL1W/DM6lMFlmTMjRb+y8eyZtbpCIdrY7 +uhDRVwvJPYegj34KR+8OMo1iDvbckee5AR1Dc8L44KmB3Nm9AgW9o+bEz/kYz5pz +Eyv9ibthtagBpxU8kyfSuwH1Z3X3qzgpq8QVNVH0Y/5+sWAOEi2NkCHR2z4W757f +HO16Xz5SZQ5jBrqNFm6u7CLy0X96Sr5FRcefZl/gjqlgNqNTt/iJP48nfKvrEkn2 +1OlFKRFitEC3QOWDTeI4uDFgS/OIUQq7AgqAaxERR3/kbAaVh2R71AsYuXyD57dW +Eo9PJkle9gSMvWUqRC8/0eSR4zgrwirkmXNQVEj55l2Z5y1kWv95NB1kozJb4aJW +TboHAHzklAMK1Gw8AMkjsA5PZyXQGkM+kXzUvE4TLC3qnsr5w05yJT2teOaWGK3s +3ZOtu2WuvuOXo0qd7oBzkF9850LQ83wEuwIfc7XcbaB0pyb6B3EtyZ+pMlRrzREg +EiX+bB98Q3qhNopjIDy0I0FkYW0gUmVlc2UgPGFyZWVzZWRlc2lnbkBnbWFpbC5j +b20+iQJOBBMBCgA4AhsDAh4BAheAFiEESdCchsPcjaPwoHYiHvYSNH+KmVgFAlpL ++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 +=HZO9 +-----END PGP PUBLIC KEY BLOCK----- +pub rsa4096 2014-05-13 [SCEA] + ABA2529598F6626C420D335B62F49E747D911B60 +uid [ unknown] Matt Butcher +sig 3 62F49E747D911B60 2017-08-11 Matt Butcher +sig 461449C25E36B98E 2018-12-12 Matthew Farina +sig 1EF612347F8A9958 2018-12-12 Adam Reese +sig 2CDBBFBB37AE822A 2018-12-12 Adnan Abdulhussein +uid [ unknown] technosophos (keybase.io/technosophos) +sig 3 62F49E747D911B60 2016-10-24 Matt Butcher +sig 461449C25E36B98E 2018-12-12 Matthew Farina +sig 1EF612347F8A9958 2018-12-12 Adam Reese +sig 2CDBBFBB37AE822A 2018-12-12 Adnan Abdulhussein +uid [ unknown] keybase.io/technosophos +sig 3 62F49E747D911B60 2014-05-13 Matt Butcher +sig 461449C25E36B98E 2018-12-12 Matthew Farina +sig 1EF612347F8A9958 2018-12-12 Adam Reese +sig 2CDBBFBB37AE822A 2018-12-12 Adnan Abdulhussein +sub rsa2048 2014-05-13 [S] [expires: 2022-05-11] +sig 62F49E747D911B60 2014-05-13 Matt Butcher +sub rsa2048 2014-05-13 [E] [expires: 2022-05-11] +sig 62F49E747D911B60 2014-05-13 Matt Butcher + +-----BEGIN PGP PUBLIC KEY BLOCK----- +Comment: GPGTools - https://gpgtools.org + +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 +=nEJu +-----END PGP PUBLIC KEY BLOCK----- +pub rsa4096 2021-03-12 [SC] [expires: 2037-03-08] + 4AB45F1CB0D292975C6371436E2A23D806B6E6DD +uid [ unknown] Matt Butcher +sig 3 6E2A23D806B6E6DD 2021-03-12 Matt Butcher +uid [ unknown] Matt Butcher +sig 3 6E2A23D806B6E6DD 2021-03-12 Matt Butcher +uid [ unknown] Matt Butcher +sig 3 6E2A23D806B6E6DD 2021-03-12 Matt Butcher +sub rsa4096 2021-03-12 [E] [expires: 2037-03-08] +sig 6E2A23D806B6E6DD 2021-03-12 Matt Butcher + +-----BEGIN PGP PUBLIC KEY BLOCK----- +Comment: GPGTools - https://gpgtools.org + +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 +CQMFFQgKAgMEFgABAgAA3TIP+wSoWwwicctBVV0Mu3zX+9TOC/QT3pf95la5PgIV +fu6S97h7ePphk0ORRFe4qW5f7IM0iXWTN455h1ngnZGXn5tG3JtkUY616AnmK1fJ +MHRZRCJmeD8u5SzCCZGBlL+n3Hp6gOR7q14hhgkeg4oPiFKSF75LJos4JYEeCIYN +WyUa2yjz/glnzrA/zMeRQ+acRXj/Aa1MlwiDukxpIaHzB8U0xm+V6AgWdNzP7T8P +Daxidjgkjk3GGAK741z37avP9MFYUTd/Pq6Z2uB5xFuaB2xD5gJcvVYMBJQtYmtt +AmbzEZwYsROmkfCmS9jmlUFaMbKdAl2do/0feX7Hw29fhVT23tYD2d9Zm39CFXOm +tIb4SDcteyqeIOhQkLZgKLwJiwXkaLsHPVZlQljzvkQlW4qRGvzxyCWWr4PZovQG +ZSyFcO3XJk2hswijbhM3rQOxtOL9GJ9U+khnghLfmet5otSl0Gm1yW+ub7AynXi9 +JT+kMv2QZfPP+jZjIeBLC3yItI6K/+0qI53JMswKDvQ8qnmeVj++dquSSnSozXpa +npqxrjxAhZ905UrPKqzxd9lJUegfB4khUBC/IuE7HTkFnZz/I+r6IfJ031YZK/lr +eeCQm6DMvoehR+4vgo+APdvclMmmCWd4TBTFBhtOZvLX5HfMU++YZC13AeDUmzOp +edRWuQINBGBLvGsBEADtGQcj2nLThgu9QBKN7Q4TCwywd/RTyJCZm2aq6NVs2iYP +NGd49RmHdzYbiSgOaSSIYODevDB0KFK0/D3YMjEE5oBpf94MxGDOfq/tVEVOjiOR +rwW7YaKGpxoD0q9QB+CI4+w3Dhu5Yiaiun+carXPfhxaOvoYq26heLipZ/cztgRK +16bqoAn/Kl2/yY3kfN2YRBgHFaLwkKFAKD39QxbxrCTB6YuGLhGOI+BLv47WlECi +TnSM//k80jQVEjuvoXZaFQO0/A8O7vIXF2TarVKO2I2HPlCt4q09ub6rmmqn2MGj +2gwYR1lv1vQZMVevJOe+4gwGKPCicIbp+JX2CN8n9lorS/PlYkUSNZehNhEaBKUK +yl5WFY00oGtjYKwRwStN9m3JwNPAQES9EYipGi4YGdsrTa+MtsIZQdnbaMVA9wlU +sNMyoTBjaGr79Gu4cPLISy3mNy6LRivlEeE3pxcziUj3k/6dLEUFgTfgmH3dGJ2o +c1fqF7RPJ0hvzqh6pG9lx5nkUtpG+s8FC7hDDnuqVXCS+4rPe13sEFRlM6l1YAiC +hXeApBhvpqB71ydiVR/yHua1H9b49+1eVeWzfF6XPtUSSCkwH7W1ZWx+8yUBi6zz +GUgmGNJ4m0GglCDPXsP3w7WNJoPAU15LNsi5z59bjGou3OkI5czPTKF7Q73znwAR +AQABiQI1BBgBCAApBQJgS7xrCRBuKiPYBrbm3QIbDAUJHhM4AAQLBwkDBRUICgID +BBYAAQIAACVcEACY7aIw03LMedYRsWogFn6IkpdbqRVEYP5Zjglky8MFIOQv81j7 +Zg99BB4V0lyvSMSlFmom4BE+Sq6EO3uuqC7WR+7GL3p92AyIF9EJIOAg9FFH8eRn +jk1jA12Zdx40V6okWpy3C/OY6D6du17G6AJ1NExfSWtbxXknFAbsv2azQpJ0ATdK +xEPun0PGlOhsg+Bu33k7tQ2P6/4dJT8c2e8QBy/kedj3mGhrb9Ymy0VdOn12P7kA +oVl9TvvQV64f9YSToQzDjHTSP8dxiEV7a8SMD4cm/7sTLF1a7LW8lD405jxqll8a +dtj4+yY/rfSN/rDVoTDBkc6habYL0G97j70o02nZYJtukkIQvSYdYARE0OUdwb+y +SZWuTxT340LDJHUwmDpFyk6L6MTaCwlFPoi4+0FDpjdOngEMjMHe92vWT1gGhk6B +uOKbA/wFozjv87y8T6bCJ+dA1/TqhUT7UJBKJozXpOpcYapI59ZmTVu5V7WwFJvK +JlWm8DSDpOI75JRRy3DTX4UmYg/nRX5pfLPsxq2JQW/QnjPLPJ/y+5Y++b92wWrP +AirPev6SluPhLJ2mswaK3THlhOZulKO/VIEJ6g50m5Vj3hdYf6sR603yK9rP+3iu +IagTQt2SGfW3Ap0RO3Yt+w29BpZ1CZ5Ml4gAYkXz0hiiMnVRhlcLIOHoFw== +=h3+3 +-----END PGP PUBLIC KEY BLOCK----- diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..21c57fae --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2016 The Kubernetes Authors All Rights Reserved + + 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. diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..c8ced67a --- /dev/null +++ b/Makefile @@ -0,0 +1,239 @@ +BINDIR := $(CURDIR)/bin +INSTALL_PATH ?= /usr/local/bin +DIST_DIRS := find * -type d -exec +TARGETS := darwin/amd64 darwin/arm64 linux/amd64 linux/386 linux/arm linux/arm64 linux/ppc64le linux/s390x linux/riscv64 windows/amd64 +TARGET_OBJS ?= darwin-amd64.tar.gz darwin-amd64.tar.gz.sha256 darwin-amd64.tar.gz.sha256sum darwin-arm64.tar.gz darwin-arm64.tar.gz.sha256 darwin-arm64.tar.gz.sha256sum linux-amd64.tar.gz linux-amd64.tar.gz.sha256 linux-amd64.tar.gz.sha256sum linux-386.tar.gz linux-386.tar.gz.sha256 linux-386.tar.gz.sha256sum linux-arm.tar.gz linux-arm.tar.gz.sha256 linux-arm.tar.gz.sha256sum linux-arm64.tar.gz linux-arm64.tar.gz.sha256 linux-arm64.tar.gz.sha256sum linux-ppc64le.tar.gz linux-ppc64le.tar.gz.sha256 linux-ppc64le.tar.gz.sha256sum linux-s390x.tar.gz linux-s390x.tar.gz.sha256 linux-s390x.tar.gz.sha256sum linux-riscv64.tar.gz linux-riscv64.tar.gz.sha256 linux-riscv64.tar.gz.sha256sum windows-amd64.zip windows-amd64.zip.sha256 windows-amd64.zip.sha256sum +BINNAME ?= helm + +GOBIN = $(shell go env GOBIN) +ifeq ($(GOBIN),) +GOBIN = $(shell go env GOPATH)/bin +endif +GOX = $(GOBIN)/gox +GOIMPORTS = $(GOBIN)/goimports +ARCH = $(shell go env GOARCH) + +ACCEPTANCE_DIR:=../acceptance-testing +# To specify the subset of acceptance tests to run. '.' means all tests +ACCEPTANCE_RUN_TESTS=. + +# go option +PKG := ./... +TAGS := +TESTS := . +TESTFLAGS := +LDFLAGS := -w -s +GOFLAGS := +CGO_ENABLED ?= 0 + +# Rebuild the binary if any of these files change +SRC := $(shell find . -type f -name '*.go' -print) go.mod go.sum + +# Required for globs to work correctly +SHELL = /usr/bin/env bash + +GIT_COMMIT = $(shell git rev-parse HEAD) +GIT_SHA = $(shell git rev-parse --short HEAD) +GIT_TAG = $(shell git describe --tags --abbrev=0 --exact-match 2>/dev/null) +GIT_DIRTY = $(shell test -n "`git status --porcelain`" && echo "dirty" || echo "clean") + +ifdef VERSION + BINARY_VERSION = $(VERSION) +endif +BINARY_VERSION ?= ${GIT_TAG} + +# Only set Version if building a tag or VERSION is set +ifneq ($(BINARY_VERSION),) + LDFLAGS += -X helm.sh/helm/v3/internal/version.version=${BINARY_VERSION} +endif + +VERSION_METADATA = unreleased +# Clear the "unreleased" string in BuildMetadata +ifneq ($(GIT_TAG),) + VERSION_METADATA = +endif + +LDFLAGS += -X helm.sh/helm/v3/internal/version.metadata=${VERSION_METADATA} +LDFLAGS += -X helm.sh/helm/v3/internal/version.gitCommit=${GIT_COMMIT} +LDFLAGS += -X helm.sh/helm/v3/internal/version.gitTreeState=${GIT_DIRTY} +LDFLAGS += $(EXT_LDFLAGS) + +# Define constants based on the client-go version +K8S_MODULES_VER=$(subst ., ,$(subst v,,$(shell go list -f '{{.Version}}' -m k8s.io/client-go))) +K8S_MODULES_MAJOR_VER=$(shell echo $$(($(firstword $(K8S_MODULES_VER)) + 1))) +K8S_MODULES_MINOR_VER=$(word 2,$(K8S_MODULES_VER)) + +LDFLAGS += -X helm.sh/helm/v3/pkg/lint/rules.k8sVersionMajor=$(K8S_MODULES_MAJOR_VER) +LDFLAGS += -X helm.sh/helm/v3/pkg/lint/rules.k8sVersionMinor=$(K8S_MODULES_MINOR_VER) +LDFLAGS += -X helm.sh/helm/v3/pkg/chartutil.k8sVersionMajor=$(K8S_MODULES_MAJOR_VER) +LDFLAGS += -X helm.sh/helm/v3/pkg/chartutil.k8sVersionMinor=$(K8S_MODULES_MINOR_VER) + +.PHONY: all +all: build + +# ------------------------------------------------------------------------------ +# build + +.PHONY: build +build: $(BINDIR)/$(BINNAME) + +$(BINDIR)/$(BINNAME): $(SRC) + GO111MODULE=on CGO_ENABLED=$(CGO_ENABLED) go build $(GOFLAGS) -trimpath -tags '$(TAGS)' -ldflags '$(LDFLAGS)' -o '$(BINDIR)'/$(BINNAME) ./cmd/helm + +# ------------------------------------------------------------------------------ +# install + +.PHONY: install +install: build + @install "$(BINDIR)/$(BINNAME)" "$(INSTALL_PATH)/$(BINNAME)" + +# ------------------------------------------------------------------------------ +# test + +.PHONY: test +test: build +ifeq ($(ARCH),s390x) +test: TESTFLAGS += -v +else +test: TESTFLAGS += -race -v +endif +test: test-style +test: test-unit + +.PHONY: test-unit +test-unit: + @echo + @echo "==> Running unit tests <==" + GO111MODULE=on go test $(GOFLAGS) -run $(TESTS) $(PKG) $(TESTFLAGS) + +.PHONY: test-coverage +test-coverage: + @echo + @echo "==> Running unit tests with coverage <==" + @ ./scripts/coverage.sh + +.PHONY: test-style +test-style: + golangci-lint run ./... + @scripts/validate-license.sh + +.PHONY: test-source-headers +test-source-headers: + @scripts/validate-license.sh + +.PHONY: test-acceptance +test-acceptance: TARGETS = linux/amd64 +test-acceptance: build build-cross + @if [ -d "${ACCEPTANCE_DIR}" ]; then \ + cd ${ACCEPTANCE_DIR} && \ + ROBOT_RUN_TESTS=$(ACCEPTANCE_RUN_TESTS) ROBOT_HELM_PATH='$(BINDIR)' make acceptance; \ + else \ + echo "You must clone the acceptance_testing repo under $(ACCEPTANCE_DIR)"; \ + echo "You can find the acceptance_testing repo at https://github.com/helm/acceptance-testing"; \ + fi + +.PHONY: test-acceptance-completion +test-acceptance-completion: ACCEPTANCE_RUN_TESTS = shells.robot +test-acceptance-completion: test-acceptance + +.PHONY: coverage +coverage: + @scripts/coverage.sh + +.PHONY: format +format: $(GOIMPORTS) + GO111MODULE=on go list -f '{{.Dir}}' ./... | xargs $(GOIMPORTS) -w -local helm.sh/helm + +# Generate golden files used in unit tests +.PHONY: gen-test-golden +gen-test-golden: +gen-test-golden: PKG = ./cmd/helm ./pkg/action +gen-test-golden: TESTFLAGS = -update +gen-test-golden: test-unit + +# ------------------------------------------------------------------------------ +# dependencies + +# If go install is run from inside the project directory it will add the +# dependencies to the go.mod file. To avoid that we change to a directory +# without a go.mod file when downloading the following dependencies + +$(GOX): + (cd /; GO111MODULE=on go install github.com/mitchellh/gox@v1.0.2-0.20220701044238-9f712387e2d2) + +$(GOIMPORTS): + (cd /; GO111MODULE=on go install golang.org/x/tools/cmd/goimports@latest) + +# ------------------------------------------------------------------------------ +# release + +.PHONY: build-cross +build-cross: LDFLAGS += -extldflags "-static" +build-cross: $(GOX) + GOFLAGS="-trimpath" GO111MODULE=on CGO_ENABLED=0 $(GOX) -parallel=3 -output="_dist/{{.OS}}-{{.Arch}}/$(BINNAME)" -osarch='$(TARGETS)' $(GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)' ./cmd/helm + +.PHONY: dist +dist: + ( \ + cd _dist && \ + $(DIST_DIRS) cp ../LICENSE {} \; && \ + $(DIST_DIRS) cp ../README.md {} \; && \ + $(DIST_DIRS) tar -zcf helm-${VERSION}-{}.tar.gz {} \; && \ + $(DIST_DIRS) zip -r helm-${VERSION}-{}.zip {} \; \ + ) + +.PHONY: fetch-dist +fetch-dist: + mkdir -p _dist + cd _dist && \ + for obj in ${TARGET_OBJS} ; do \ + curl -sSL -o helm-${VERSION}-$${obj} https://get.helm.sh/helm-${VERSION}-$${obj} ; \ + done + +.PHONY: sign +sign: + for f in $$(ls _dist/*.{gz,zip,sha256,sha256sum} 2>/dev/null) ; do \ + gpg --armor --detach-sign $${f} ; \ + done + +# The contents of the .sha256sum file are compatible with tools like +# shasum. For example, using the following command will verify +# the file helm-3.1.0-rc.1-darwin-amd64.tar.gz: +# shasum -a 256 -c helm-3.1.0-rc.1-darwin-amd64.tar.gz.sha256sum +# The .sha256 files hold only the hash and are not compatible with +# verification tools like shasum or sha256sum. This method and file can be +# removed in Helm v4. +.PHONY: checksum +checksum: + for f in $$(ls _dist/*.{gz,zip} 2>/dev/null) ; do \ + shasum -a 256 "$${f}" | sed 's/_dist\///' > "$${f}.sha256sum" ; \ + shasum -a 256 "$${f}" | awk '{print $$1}' > "$${f}.sha256" ; \ + done + +# ------------------------------------------------------------------------------ + +.PHONY: clean +clean: + @rm -rf '$(BINDIR)' ./_dist + +.PHONY: release-notes +release-notes: + @if [ ! -d "./_dist" ]; then \ + echo "please run 'make fetch-dist' first" && \ + exit 1; \ + fi + @if [ -z "${PREVIOUS_RELEASE}" ]; then \ + echo "please set PREVIOUS_RELEASE environment variable" \ + && exit 1; \ + fi + + @./scripts/release-notes.sh ${PREVIOUS_RELEASE} ${VERSION} + + + +.PHONY: info +info: + @echo "Version: ${VERSION}" + @echo "Git Tag: ${GIT_TAG}" + @echo "Git Commit: ${GIT_COMMIT}" + @echo "Git Tree State: ${GIT_DIRTY}" diff --git a/OWNERS b/OWNERS new file mode 100644 index 00000000..cc18ea52 --- /dev/null +++ b/OWNERS @@ -0,0 +1,29 @@ +maintainers: + - hickeyma + - joejulian + - jdolitsky + - marckhouzam + - mattfarina + - sabre1041 + - scottrigby + - technosophos +triage: + - yxxhero + - zonggen + - gjenkins8 + - z4ce +emeritus: + - adamreese + - bacongobbler + - fibonacci1729 + - jascott1 + - michelleN + - migmartri + - nebril + - prydonius + - rimusz + - seh + - SlickNik + - thomastaylor312 + - vaikas-google + - viglesiasce diff --git a/README.md b/README.md new file mode 100644 index 00000000..b279d6af --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# Helm + +[![Build Status](https://github.com/helm/helm/workflows/release/badge.svg)](https://github.com/helm/helm/actions?workflow=release) +[![Go Report Card](https://goreportcard.com/badge/github.com/helm/helm)](https://goreportcard.com/report/github.com/helm/helm) +[![GoDoc](https://img.shields.io/static/v1?label=godoc&message=reference&color=blue)](https://pkg.go.dev/helm.sh/helm/v3) +[![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/3131/badge)](https://bestpractices.coreinfrastructure.org/projects/3131) + +Helm is a tool for managing Charts. Charts are packages of pre-configured Kubernetes resources. + +Use Helm to: + +- Find and use [popular software packaged as Helm Charts](https://artifacthub.io/packages/search?kind=0) to run in Kubernetes +- Share your own applications as Helm Charts +- Create reproducible builds of your Kubernetes applications +- Intelligently manage your Kubernetes manifest files +- Manage releases of Helm packages + +## Helm in a Handbasket + +Helm is a tool that streamlines installing and managing Kubernetes applications. +Think of it like apt/yum/homebrew for Kubernetes. + +- Helm renders your templates and communicates with the Kubernetes API +- Helm runs on your laptop, CI/CD, or wherever you want it to run. +- Charts are Helm packages that contain at least two things: + - A description of the package (`Chart.yaml`) + - One or more templates, which contain Kubernetes manifest files +- Charts can be stored on disk, or fetched from remote chart repositories + (like Debian or RedHat packages) + +## Install + +Binary downloads of the Helm client can be found on [the Releases page](https://github.com/helm/helm/releases/latest). + +Unpack the `helm` binary and add it to your PATH and you are good to go! + +If you want to use a package manager: + +- [Homebrew](https://brew.sh/) users can use `brew install helm`. +- [Chocolatey](https://chocolatey.org/) users can use `choco install kubernetes-helm`. +- [Scoop](https://scoop.sh/) users can use `scoop install helm`. +- [Snapcraft](https://snapcraft.io/) users can use `snap install helm --classic` + +To rapidly get Helm up and running, start with the [Quick Start Guide](https://helm.sh/docs/intro/quickstart/). + +See the [installation guide](https://helm.sh/docs/intro/install/) for more options, +including installing pre-releases. + +## Docs + +Get started with the [Quick Start guide](https://helm.sh/docs/intro/quickstart/) or plunge into the [complete documentation](https://helm.sh/docs) + +## Roadmap + +The [Helm roadmap uses GitHub milestones](https://github.com/helm/helm/milestones) to track the progress of the project. + +## Community, discussion, contribution, and support + +You can reach the Helm community and developers via the following channels: + +- [Kubernetes Slack](https://kubernetes.slack.com): + - [#helm-users](https://kubernetes.slack.com/messages/helm-users) + - [#helm-dev](https://kubernetes.slack.com/messages/helm-dev) + - [#charts](https://kubernetes.slack.com/messages/charts) +- Mailing List: + - [Helm Mailing List](https://lists.cncf.io/g/cncf-helm) +- Developer Call: Thursdays at 9:30-10:00 Pacific ([meeting details](https://github.com/helm/community/blob/master/communication.md#meetings)) + +### Contribution + +If you're interested in contributing, please refer to the [Contributing Guide](CONTRIBUTING.md) **before submitting a pull request**. + +### Code of conduct + +Participation in the Helm community is governed by the [Code of Conduct](code-of-conduct.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..c84a6f86 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,3 @@ +# Helm Security Reporting and Policy + +The Helm project has [a common process and policy that can be found here](https://github.com/helm/community/blob/master/SECURITY.md). \ No newline at end of file diff --git a/cmd/helm/completion.go b/cmd/helm/completion.go new file mode 100644 index 00000000..93b9e8ea --- /dev/null +++ b/cmd/helm/completion.go @@ -0,0 +1,215 @@ +/* +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 ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" +) + +const completionDesc = ` +Generate autocompletion scripts for Helm for the specified shell. +` +const bashCompDesc = ` +Generate the autocompletion script for Helm for the bash shell. + +To load completions in your current shell session: + + source <(helm completion bash) + +To load completions for every new session, execute once: +- Linux: + + helm completion bash > /etc/bash_completion.d/helm + +- MacOS: + + helm completion bash > /usr/local/etc/bash_completion.d/helm +` + +const zshCompDesc = ` +Generate the autocompletion script for Helm for the zsh shell. + +To load completions in your current shell session: + + source <(helm completion zsh) + +To load completions for every new session, execute once: + + helm completion zsh > "${fpath[1]}/_helm" +` + +const fishCompDesc = ` +Generate the autocompletion script for Helm for the fish shell. + +To load completions in your current shell session: + + helm completion fish | source + +To load completions for every new session, execute once: + + helm completion fish > ~/.config/fish/completions/helm.fish + +You will need to start a new shell for this setup to take effect. +` + +const powershellCompDesc = ` +Generate the autocompletion script for powershell. + +To load completions in your current shell session: +PS C:\> helm completion powershell | Out-String | Invoke-Expression + +To load completions for every new session, add the output of the above command +to your powershell profile. +` + +const ( + noDescFlagName = "no-descriptions" + noDescFlagText = "disable completion descriptions" +) + +var disableCompDescriptions bool + +func newCompletionCmd(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "completion", + Short: "generate autocompletion scripts for the specified shell", + Long: completionDesc, + Args: require.NoArgs, + } + + bash := &cobra.Command{ + Use: "bash", + Short: "generate autocompletion script for bash", + Long: bashCompDesc, + Args: require.NoArgs, + ValidArgsFunction: noCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + return runCompletionBash(out, cmd) + }, + } + bash.Flags().BoolVar(&disableCompDescriptions, noDescFlagName, false, noDescFlagText) + + zsh := &cobra.Command{ + Use: "zsh", + Short: "generate autocompletion script for zsh", + Long: zshCompDesc, + Args: require.NoArgs, + ValidArgsFunction: noCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + return runCompletionZsh(out, cmd) + }, + } + zsh.Flags().BoolVar(&disableCompDescriptions, noDescFlagName, false, noDescFlagText) + + fish := &cobra.Command{ + Use: "fish", + Short: "generate autocompletion script for fish", + Long: fishCompDesc, + Args: require.NoArgs, + ValidArgsFunction: noCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + return runCompletionFish(out, cmd) + }, + } + fish.Flags().BoolVar(&disableCompDescriptions, noDescFlagName, false, noDescFlagText) + + powershell := &cobra.Command{ + Use: "powershell", + Short: "generate autocompletion script for powershell", + Long: powershellCompDesc, + Args: require.NoArgs, + ValidArgsFunction: noCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + return runCompletionPowershell(out, cmd) + }, + } + powershell.Flags().BoolVar(&disableCompDescriptions, noDescFlagName, false, noDescFlagText) + + cmd.AddCommand(bash, zsh, fish, powershell) + + return cmd +} + +func runCompletionBash(out io.Writer, cmd *cobra.Command) error { + err := cmd.Root().GenBashCompletionV2(out, !disableCompDescriptions) + + // In case the user renamed the helm binary (e.g., to be able to run + // both helm2 and helm3), we hook the new binary name to the completion function + if binary := filepath.Base(os.Args[0]); binary != "helm" { + renamedBinaryHook := ` +# Hook the command used to generate the completion script +# to the helm completion function to handle the case where +# the user renamed the helm binary +if [[ $(type -t compopt) = "builtin" ]]; then + complete -o default -F __start_helm %[1]s +else + complete -o default -o nospace -F __start_helm %[1]s +fi +` + fmt.Fprintf(out, renamedBinaryHook, binary) + } + + return err +} + +func runCompletionZsh(out io.Writer, cmd *cobra.Command) error { + var err error + if disableCompDescriptions { + err = cmd.Root().GenZshCompletionNoDesc(out) + } else { + err = cmd.Root().GenZshCompletion(out) + } + + // In case the user renamed the helm binary (e.g., to be able to run + // both helm2 and helm3), we hook the new binary name to the completion function + if binary := filepath.Base(os.Args[0]); binary != "helm" { + renamedBinaryHook := ` +# Hook the command used to generate the completion script +# to the helm completion function to handle the case where +# the user renamed the helm binary +compdef _helm %[1]s +` + fmt.Fprintf(out, renamedBinaryHook, binary) + } + + // Cobra doesn't source zsh completion file, explicitly doing it here + fmt.Fprintf(out, "compdef _helm helm") + + return err +} + +func runCompletionFish(out io.Writer, cmd *cobra.Command) error { + return cmd.Root().GenFishCompletion(out, !disableCompDescriptions) +} + +func runCompletionPowershell(out io.Writer, cmd *cobra.Command) error { + if disableCompDescriptions { + return cmd.Root().GenPowerShellCompletion(out) + } + return cmd.Root().GenPowerShellCompletionWithDesc(out) +} + +// Function to disable file completion +func noCompletions(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return nil, cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/helm/completion_test.go b/cmd/helm/completion_test.go new file mode 100644 index 00000000..1143d644 --- /dev/null +++ b/cmd/helm/completion_test.go @@ -0,0 +1,93 @@ +/* +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 ( + "fmt" + "strings" + "testing" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/release" +) + +// Check if file completion should be performed according to parameter 'shouldBePerformed' +func checkFileCompletion(t *testing.T, cmdName string, shouldBePerformed bool) { + storage := storageFixture() + storage.Create(&release.Release{ + Name: "myrelease", + Info: &release.Info{Status: release.StatusDeployed}, + Chart: &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "Myrelease-Chart", + Version: "1.2.3", + }, + }, + Version: 1, + }) + + testcmd := fmt.Sprintf("__complete %s ''", cmdName) + _, out, err := executeActionCommandC(storage, testcmd) + if err != nil { + t.Errorf("unexpected error, %s", err) + } + if !strings.Contains(out, "ShellCompDirectiveNoFileComp") != shouldBePerformed { + if shouldBePerformed { + t.Errorf("Unexpected directive ShellCompDirectiveNoFileComp when completing '%s'", cmdName) + } else { + + t.Errorf("Did not receive directive ShellCompDirectiveNoFileComp when completing '%s'", cmdName) + } + t.Log(out) + } +} + +func TestCompletionFileCompletion(t *testing.T) { + checkFileCompletion(t, "completion", false) + checkFileCompletion(t, "completion bash", false) + checkFileCompletion(t, "completion zsh", false) + checkFileCompletion(t, "completion fish", false) +} + +func checkReleaseCompletion(t *testing.T, cmdName string, multiReleasesAllowed bool) { + multiReleaseTestGolden := "output/empty_nofile_comp.txt" + if multiReleasesAllowed { + multiReleaseTestGolden = "output/release_list_repeat_comp.txt" + } + tests := []cmdTestCase{{ + name: "completion for uninstall", + cmd: fmt.Sprintf("__complete %s ''", cmdName), + golden: "output/release_list_comp.txt", + rels: []*release.Release{ + release.Mock(&release.MockReleaseOptions{Name: "athos"}), + release.Mock(&release.MockReleaseOptions{Name: "porthos"}), + release.Mock(&release.MockReleaseOptions{Name: "aramis"}), + }, + }, { + name: "completion for uninstall repetition", + cmd: fmt.Sprintf("__complete %s porthos ''", cmdName), + golden: multiReleaseTestGolden, + rels: []*release.Release{ + release.Mock(&release.MockReleaseOptions{Name: "athos"}), + release.Mock(&release.MockReleaseOptions{Name: "porthos"}), + release.Mock(&release.MockReleaseOptions{Name: "aramis"}), + }, + }} + for _, test := range tests { + runTestCmd(t, []cmdTestCase{test}) + } +} diff --git a/cmd/helm/create.go b/cmd/helm/create.go new file mode 100644 index 00000000..fe5cc540 --- /dev/null +++ b/cmd/helm/create.go @@ -0,0 +1,113 @@ +/* +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 ( + "fmt" + "io" + "path/filepath" + + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/helmpath" +) + +const createDesc = ` +This command creates a chart directory along with the common files and +directories used in a chart. + +For example, 'helm create foo' will create a directory structure that looks +something like this: + + foo/ + ├── .helmignore # Contains patterns to ignore when packaging Helm charts. + ├── Chart.yaml # Information about your chart + ├── values.yaml # The default values for your templates + ├── charts/ # Charts that this chart depends on + └── templates/ # The template files + └── tests/ # The test files + +'helm create' takes a path for an argument. If directories in the given path +do not exist, Helm will attempt to create them as it goes. If the given +destination exists and there are files in that directory, conflicting files +will be overwritten, but other files will be left alone. +` + +type createOptions struct { + starter string // --starter + name string + starterDir string +} + +func newCreateCmd(out io.Writer) *cobra.Command { + o := &createOptions{} + + cmd := &cobra.Command{ + Use: "create NAME", + Short: "create a new chart with the given name", + Long: createDesc, + Args: require.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + // Allow file completion when completing the argument for the name + // which could be a path + return nil, cobra.ShellCompDirectiveDefault + } + // No more completions, so disable file completion + return nil, cobra.ShellCompDirectiveNoFileComp + }, + RunE: func(cmd *cobra.Command, args []string) error { + o.name = args[0] + o.starterDir = helmpath.DataPath("starters") + return o.run(out) + }, + } + + cmd.Flags().StringVarP(&o.starter, "starter", "p", "", "the name or absolute path to Helm starter scaffold") + return cmd +} + +func (o *createOptions) run(out io.Writer) error { + fmt.Fprintf(out, "Creating %s\n", o.name) + + chartname := filepath.Base(o.name) + cfile := &chart.Metadata{ + Name: chartname, + Description: "A Helm chart for Kubernetes", + Type: "application", + Version: "0.1.0", + AppVersion: "0.1.0", + APIVersion: chart.APIVersionV2, + } + + if o.starter != "" { + // Create from the starter + lstarter := filepath.Join(o.starterDir, o.starter) + // If path is absolute, we don't want to prefix it with helm starters folder + if filepath.IsAbs(o.starter) { + lstarter = o.starter + } + return chartutil.CreateFrom(cfile, filepath.Dir(o.name), lstarter) + } + + chartutil.Stderr = out + _, err := chartutil.Create(chartname, filepath.Dir(o.name)) + return err +} diff --git a/cmd/helm/create_test.go b/cmd/helm/create_test.go new file mode 100644 index 00000000..1a22d058 --- /dev/null +++ b/cmd/helm/create_test.go @@ -0,0 +1,198 @@ +/* +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 ( + "fmt" + "os" + "path/filepath" + "testing" + + "helm.sh/helm/v3/internal/test/ensure" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/helmpath" +) + +func TestCreateCmd(t *testing.T) { + ensure.HelmHome(t) + cname := "testchart" + dir := t.TempDir() + defer testChdir(t, dir)() + + // Run a create + if _, _, err := executeActionCommand("create " + cname); err != nil { + t.Fatalf("Failed to run create: %s", err) + } + + // Test that the chart is there + if fi, err := os.Stat(cname); err != nil { + t.Fatalf("no chart directory: %s", err) + } else if !fi.IsDir() { + t.Fatalf("chart is not directory") + } + + c, err := loader.LoadDir(cname) + if err != nil { + t.Fatal(err) + } + + if c.Name() != cname { + t.Errorf("Expected %q name, got %q", cname, c.Name()) + } + if c.Metadata.APIVersion != chart.APIVersionV2 { + t.Errorf("Wrong API version: %q", c.Metadata.APIVersion) + } +} + +func TestCreateStarterCmd(t *testing.T) { + ensure.HelmHome(t) + cname := "testchart" + defer resetEnv()() + os.MkdirAll(helmpath.CachePath(), 0755) + defer testChdir(t, helmpath.CachePath())() + + // Create a starter. + starterchart := helmpath.DataPath("starters") + os.MkdirAll(starterchart, 0755) + if dest, err := chartutil.Create("starterchart", starterchart); err != nil { + t.Fatalf("Could not create chart: %s", err) + } else { + t.Logf("Created %s", dest) + } + tplpath := filepath.Join(starterchart, "starterchart", "templates", "foo.tpl") + if err := os.WriteFile(tplpath, []byte("test"), 0644); err != nil { + t.Fatalf("Could not write template: %s", err) + } + + // Run a create + if _, _, err := executeActionCommand(fmt.Sprintf("create --starter=starterchart %s", cname)); err != nil { + t.Errorf("Failed to run create: %s", err) + return + } + + // Test that the chart is there + if fi, err := os.Stat(cname); err != nil { + t.Fatalf("no chart directory: %s", err) + } else if !fi.IsDir() { + t.Fatalf("chart is not directory") + } + + c, err := loader.LoadDir(cname) + if err != nil { + t.Fatal(err) + } + + if c.Name() != cname { + t.Errorf("Expected %q name, got %q", cname, c.Name()) + } + if c.Metadata.APIVersion != chart.APIVersionV2 { + t.Errorf("Wrong API version: %q", c.Metadata.APIVersion) + } + + expectedNumberOfTemplates := 9 + if l := len(c.Templates); l != expectedNumberOfTemplates { + t.Errorf("Expected %d templates, got %d", expectedNumberOfTemplates, l) + } + + found := false + for _, tpl := range c.Templates { + if tpl.Name == "templates/foo.tpl" { + found = true + if data := string(tpl.Data); data != "test" { + t.Errorf("Expected template 'test', got %q", data) + } + } + } + if !found { + t.Error("Did not find foo.tpl") + } + +} + +func TestCreateStarterAbsoluteCmd(t *testing.T) { + defer resetEnv()() + ensure.HelmHome(t) + cname := "testchart" + + // Create a starter. + starterchart := helmpath.DataPath("starters") + os.MkdirAll(starterchart, 0755) + if dest, err := chartutil.Create("starterchart", starterchart); err != nil { + t.Fatalf("Could not create chart: %s", err) + } else { + t.Logf("Created %s", dest) + } + tplpath := filepath.Join(starterchart, "starterchart", "templates", "foo.tpl") + if err := os.WriteFile(tplpath, []byte("test"), 0644); err != nil { + t.Fatalf("Could not write template: %s", err) + } + + os.MkdirAll(helmpath.CachePath(), 0755) + defer testChdir(t, helmpath.CachePath())() + + starterChartPath := filepath.Join(starterchart, "starterchart") + + // Run a create + if _, _, err := executeActionCommand(fmt.Sprintf("create --starter=%s %s", starterChartPath, cname)); err != nil { + t.Errorf("Failed to run create: %s", err) + return + } + + // Test that the chart is there + if fi, err := os.Stat(cname); err != nil { + t.Fatalf("no chart directory: %s", err) + } else if !fi.IsDir() { + t.Fatalf("chart is not directory") + } + + c, err := loader.LoadDir(cname) + if err != nil { + t.Fatal(err) + } + + if c.Name() != cname { + t.Errorf("Expected %q name, got %q", cname, c.Name()) + } + if c.Metadata.APIVersion != chart.APIVersionV2 { + t.Errorf("Wrong API version: %q", c.Metadata.APIVersion) + } + + expectedNumberOfTemplates := 9 + if l := len(c.Templates); l != expectedNumberOfTemplates { + t.Errorf("Expected %d templates, got %d", expectedNumberOfTemplates, l) + } + + found := false + for _, tpl := range c.Templates { + if tpl.Name == "templates/foo.tpl" { + found = true + if data := string(tpl.Data); data != "test" { + t.Errorf("Expected template 'test', got %q", data) + } + } + } + if !found { + t.Error("Did not find foo.tpl") + } +} + +func TestCreateFileCompletion(t *testing.T) { + checkFileCompletion(t, "create", true) + checkFileCompletion(t, "create myname", false) +} diff --git a/cmd/helm/dependency.go b/cmd/helm/dependency.go new file mode 100644 index 00000000..03874742 --- /dev/null +++ b/cmd/helm/dependency.go @@ -0,0 +1,122 @@ +/* +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" + "path/filepath" + + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/action" +) + +const dependencyDesc = ` +Manage the dependencies of a chart. + +Helm charts store their dependencies in 'charts/'. For chart developers, it is +often easier to manage dependencies in 'Chart.yaml' which declares all +dependencies. + +The dependency commands operate on that file, making it easy to synchronize +between the desired dependencies and the actual dependencies stored in the +'charts/' directory. + +For example, this Chart.yaml declares two dependencies: + + # Chart.yaml + dependencies: + - name: nginx + version: "1.2.3" + repository: "https://example.com/charts" + - name: memcached + version: "3.2.1" + repository: "https://another.example.com/charts" + + +The 'name' should be the name of a chart, where that name must match the name +in that chart's 'Chart.yaml' file. + +The 'version' field should contain a semantic version or version range. + +The 'repository' URL should point to a Chart Repository. Helm expects that by +appending '/index.yaml' to the URL, it should be able to retrieve the chart +repository's index. Note: 'repository' can be an alias. The alias must start +with 'alias:' or '@'. + +Starting from 2.2.0, repository can be defined as the path to the directory of +the dependency charts stored locally. The path should start with a prefix of +"file://". For example, + + # Chart.yaml + dependencies: + - name: nginx + version: "1.2.3" + repository: "file://../dependency_chart/nginx" + +If the dependency chart is retrieved locally, it is not required to have the +repository added to helm by "helm add repo". Version matching is also supported +for this case. +` + +const dependencyListDesc = ` +List all of the dependencies declared in a chart. + +This can take chart archives and chart directories as input. It will not alter +the contents of a chart. + +This will produce an error if the chart cannot be loaded. +` + +func newDependencyCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "dependency update|build|list", + Aliases: []string{"dep", "dependencies"}, + Short: "manage a chart's dependencies", + Long: dependencyDesc, + Args: require.NoArgs, + } + + cmd.AddCommand(newDependencyListCmd(out)) + cmd.AddCommand(newDependencyUpdateCmd(cfg, out)) + cmd.AddCommand(newDependencyBuildCmd(cfg, out)) + + return cmd +} + +func newDependencyListCmd(out io.Writer) *cobra.Command { + client := action.NewDependency() + cmd := &cobra.Command{ + Use: "list CHART", + Aliases: []string{"ls"}, + Short: "list the dependencies for the given chart", + Long: dependencyListDesc, + Args: require.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + chartpath := "." + if len(args) > 0 { + chartpath = filepath.Clean(args[0]) + } + return client.List(chartpath, out) + }, + } + + f := cmd.Flags() + + f.UintVar(&client.ColumnWidth, "max-col-width", 80, "maximum column width for output table") + return cmd +} diff --git a/cmd/helm/dependency_build.go b/cmd/helm/dependency_build.go new file mode 100644 index 00000000..1ee46d3d --- /dev/null +++ b/cmd/helm/dependency_build.go @@ -0,0 +1,93 @@ +/* +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 ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "k8s.io/client-go/util/homedir" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/downloader" + "helm.sh/helm/v3/pkg/getter" +) + +const dependencyBuildDesc = ` +Build out the charts/ directory from the Chart.lock file. + +Build is used to reconstruct a chart's dependencies to the state specified in +the lock file. This will not re-negotiate dependencies, as 'helm dependency update' +does. + +If no lock file is found, 'helm dependency build' will mirror the behavior +of 'helm dependency update'. +` + +func newDependencyBuildCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + client := action.NewDependency() + + cmd := &cobra.Command{ + Use: "build CHART", + Short: "rebuild the charts/ directory based on the Chart.lock file", + Long: dependencyBuildDesc, + Args: require.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + chartpath := "." + if len(args) > 0 { + chartpath = filepath.Clean(args[0]) + } + man := &downloader.Manager{ + Out: out, + ChartPath: chartpath, + Keyring: client.Keyring, + SkipUpdate: client.SkipRefresh, + Getters: getter.All(settings), + RegistryClient: cfg.RegistryClient, + RepositoryConfig: settings.RepositoryConfig, + RepositoryCache: settings.RepositoryCache, + Debug: settings.Debug, + } + if client.Verify { + man.Verify = downloader.VerifyIfPossible + } + err := man.Build() + if e, ok := err.(downloader.ErrRepoNotFound); ok { + return fmt.Errorf("%s. Please add the missing repos via 'helm repo add'", e.Error()) + } + return err + }, + } + + f := cmd.Flags() + f.BoolVar(&client.Verify, "verify", false, "verify the packages against signatures") + f.StringVar(&client.Keyring, "keyring", defaultKeyring(), "keyring containing public keys") + f.BoolVar(&client.SkipRefresh, "skip-refresh", false, "do not refresh the local repository cache") + + return cmd +} + +// defaultKeyring returns the expanded path to the default keyring. +func defaultKeyring() string { + if v, ok := os.LookupEnv("GNUPGHOME"); ok { + return filepath.Join(v, "pubring.gpg") + } + return filepath.Join(homedir.HomeDir(), ".gnupg", "pubring.gpg") +} diff --git a/cmd/helm/dependency_build_test.go b/cmd/helm/dependency_build_test.go new file mode 100644 index 00000000..37e3242c --- /dev/null +++ b/cmd/helm/dependency_build_test.go @@ -0,0 +1,164 @@ +/* +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 ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/provenance" + "helm.sh/helm/v3/pkg/repo" + "helm.sh/helm/v3/pkg/repo/repotest" +) + +func TestDependencyBuildCmd(t *testing.T) { + srv, err := repotest.NewTempServerWithCleanup(t, "testdata/testcharts/*.tgz") + defer srv.Stop() + if err != nil { + t.Fatal(err) + } + + rootDir := srv.Root() + srv.LinkIndices() + + ociSrv, err := repotest.NewOCIServer(t, srv.Root()) + if err != nil { + t.Fatal(err) + } + + ociChartName := "oci-depending-chart" + c := createTestingMetadataForOCI(ociChartName, ociSrv.RegistryURL) + if _, err := chartutil.Save(c, ociSrv.Dir); err != nil { + t.Fatal(err) + } + ociSrv.Run(t, repotest.WithDependingChart(c)) + + dir := func(p ...string) string { + return filepath.Join(append([]string{srv.Root()}, p...)...) + } + + chartname := "depbuild" + createTestingChart(t, rootDir, chartname, srv.URL()) + 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) + + // In the first pass, we basically want the same results as an update. + if err != nil { + t.Logf("Output: %s", out) + t.Fatal(err) + } + + if !strings.Contains(out, `update from the "test" chart repository`) { + t.Errorf("Repo did not get updated\n%s", out) + } + + // Make sure the actual file got downloaded. + expect := filepath.Join(rootDir, chartname, "charts/reqtest-0.1.0.tgz") + if _, err := os.Stat(expect); err != nil { + t.Fatal(err) + } + + // In the second pass, we want to remove the chart's request dependency, + // then see if it restores from the lock. + lockfile := filepath.Join(rootDir, chartname, "Chart.lock") + if _, err := os.Stat(lockfile); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(expect); err != nil { + t.Fatal(err) + } + + _, out, err = executeActionCommand(cmd) + if err != nil { + t.Logf("Output: %s", out) + t.Fatal(err) + } + + // Now repeat the test that the dependency exists. + if _, err := os.Stat(expect); err != nil { + t.Fatal(err) + } + + // Make sure that build is also fetching the correct version. + hash, err := provenance.DigestFile(expect) + if err != nil { + t.Fatal(err) + } + + i, err := repo.LoadIndexFile(filepath.Join(rootDir, "index.yaml")) + if err != nil { + t.Fatal(err) + } + + reqver := i.Entries["reqtest"][0] + if h := reqver.Digest; h != hash { + t.Errorf("Failed hash match: expected %s, got %s", hash, h) + } + if v := reqver.Version; v != "0.1.0" { + t.Errorf("mismatched versions. Expected %q, got %q", "0.1.0", v) + } + + skipRefreshCmd := fmt.Sprintf("dependency build '%s' --skip-refresh --repository-config %s --repository-cache %s", filepath.Join(rootDir, chartname), repoFile, rootDir) + _, out, err = executeActionCommand(skipRefreshCmd) + + // In this pass, we check --skip-refresh option becomes effective. + if err != nil { + t.Logf("Output: %s", out) + t.Fatal(err) + } + + if strings.Contains(out, `update from the "test" chart repository`) { + t.Errorf("Repo did get updated\n%s", out) + } + + // OCI dependencies + if err := chartutil.SaveDir(c, dir()); err != nil { + t.Fatal(err) + } + cmd = fmt.Sprintf("dependency build '%s' --repository-config %s --repository-cache %s --registry-config %s/config.json", + dir(ociChartName), + dir("repositories.yaml"), + dir(), + dir()) + _, out, err = executeActionCommand(cmd) + if err != nil { + t.Logf("Output: %s", out) + t.Fatal(err) + } + expect = dir(ociChartName, "charts/oci-dependent-chart-0.1.0.tgz") + if _, err := os.Stat(expect); err != nil { + t.Fatal(err) + } +} + +func TestDependencyBuildCmdWithHelmV2Hash(t *testing.T) { + chartName := "testdata/testcharts/issue-7233" + + cmd := fmt.Sprintf("dependency build '%s'", chartName) + _, out, err := executeActionCommand(cmd) + + // Want to make sure the build can verify Helm v2 hash + if err != nil { + t.Logf("Output: %s", out) + t.Fatal(err) + } +} diff --git a/cmd/helm/dependency_test.go b/cmd/helm/dependency_test.go new file mode 100644 index 00000000..34c6a25e --- /dev/null +++ b/cmd/helm/dependency_test.go @@ -0,0 +1,57 @@ +/* +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 ( + "runtime" + "testing" +) + +func TestDependencyListCmd(t *testing.T) { + noSuchChart := cmdTestCase{ + name: "No such chart", + cmd: "dependency list /no/such/chart", + golden: "output/dependency-list-no-chart-linux.txt", + wantError: true, + } + + noDependencies := cmdTestCase{ + name: "No dependencies", + cmd: "dependency list testdata/testcharts/alpine", + golden: "output/dependency-list-no-requirements-linux.txt", + } + + if runtime.GOOS == "windows" { + noSuchChart.golden = "output/dependency-list-no-chart-windows.txt" + noDependencies.golden = "output/dependency-list-no-requirements-windows.txt" + } + + tests := []cmdTestCase{noSuchChart, + noDependencies, { + name: "Dependencies in chart dir", + cmd: "dependency list testdata/testcharts/reqtest", + golden: "output/dependency-list.txt", + }, { + name: "Dependencies in chart archive", + cmd: "dependency list testdata/testcharts/reqtest-0.1.0.tgz", + golden: "output/dependency-list-archive.txt", + }} + runTestCmd(t, tests) +} + +func TestDependencyFileCompletion(t *testing.T) { + checkFileCompletion(t, "dependency", false) +} diff --git a/cmd/helm/dependency_update.go b/cmd/helm/dependency_update.go new file mode 100644 index 00000000..ad0188f1 --- /dev/null +++ b/cmd/helm/dependency_update.go @@ -0,0 +1,84 @@ +/* +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" + "path/filepath" + + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/downloader" + "helm.sh/helm/v3/pkg/getter" +) + +const dependencyUpDesc = ` +Update the on-disk dependencies to mirror Chart.yaml. + +This command verifies that the required charts, as expressed in 'Chart.yaml', +are present in 'charts/' and are at an acceptable version. It will pull down +the latest charts that satisfy the dependencies, and clean up old dependencies. + +On successful update, this will generate a lock file that can be used to +rebuild the dependencies to an exact version. + +Dependencies are not required to be represented in 'Chart.yaml'. For that +reason, an update command will not remove charts unless they are (a) present +in the Chart.yaml file, but (b) at the wrong version. +` + +// newDependencyUpdateCmd creates a new dependency update command. +func newDependencyUpdateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + client := action.NewDependency() + + cmd := &cobra.Command{ + Use: "update CHART", + Aliases: []string{"up"}, + Short: "update charts/ based on the contents of Chart.yaml", + Long: dependencyUpDesc, + Args: require.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + chartpath := "." + if len(args) > 0 { + chartpath = filepath.Clean(args[0]) + } + man := &downloader.Manager{ + Out: out, + ChartPath: chartpath, + Keyring: client.Keyring, + SkipUpdate: client.SkipRefresh, + Getters: getter.All(settings), + RegistryClient: cfg.RegistryClient, + RepositoryConfig: settings.RepositoryConfig, + RepositoryCache: settings.RepositoryCache, + Debug: settings.Debug, + } + if client.Verify { + man.Verify = downloader.VerifyAlways + } + return man.Update() + }, + } + + f := cmd.Flags() + f.BoolVar(&client.Verify, "verify", false, "verify the packages against signatures") + f.StringVar(&client.Keyring, "keyring", defaultKeyring(), "keyring containing public keys") + f.BoolVar(&client.SkipRefresh, "skip-refresh", false, "do not refresh the local repository cache") + + return cmd +} diff --git a/cmd/helm/dependency_update_test.go b/cmd/helm/dependency_update_test.go new file mode 100644 index 00000000..967786b9 --- /dev/null +++ b/cmd/helm/dependency_update_test.go @@ -0,0 +1,303 @@ +/* +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 ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "helm.sh/helm/v3/internal/test/ensure" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/helmpath" + "helm.sh/helm/v3/pkg/provenance" + "helm.sh/helm/v3/pkg/repo" + "helm.sh/helm/v3/pkg/repo/repotest" +) + +func TestDependencyUpdateCmd(t *testing.T) { + srv, err := repotest.NewTempServerWithCleanup(t, "testdata/testcharts/*.tgz") + if err != nil { + t.Fatal(err) + } + defer srv.Stop() + t.Logf("Listening on directory %s", srv.Root()) + + ociSrv, err := repotest.NewOCIServer(t, srv.Root()) + if err != nil { + t.Fatal(err) + } + + ociChartName := "oci-depending-chart" + c := createTestingMetadataForOCI(ociChartName, ociSrv.RegistryURL) + if _, err := chartutil.Save(c, ociSrv.Dir); err != nil { + t.Fatal(err) + } + ociSrv.Run(t, repotest.WithDependingChart(c)) + + if err := srv.LinkIndices(); err != nil { + t.Fatal(err) + } + + dir := func(p ...string) string { + return filepath.Join(append([]string{srv.Root()}, p...)...) + } + + chartname := "depup" + ch := createTestingMetadata(chartname, srv.URL()) + md := ch.Metadata + if err := chartutil.SaveDir(ch, dir()); err != nil { + t.Fatal(err) + } + + _, out, err := executeActionCommand( + fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s", dir(chartname), dir("repositories.yaml"), dir()), + ) + if err != nil { + t.Logf("Output: %s", out) + t.Fatal(err) + } + + // This is written directly to stdout, so we have to capture as is. + if !strings.Contains(out, `update from the "test" chart repository`) { + t.Errorf("Repo did not get updated\n%s", out) + } + + // Make sure the actual file got downloaded. + expect := dir(chartname, "charts/reqtest-0.1.0.tgz") + if _, err := os.Stat(expect); err != nil { + t.Fatal(err) + } + + hash, err := provenance.DigestFile(expect) + if err != nil { + t.Fatal(err) + } + + i, err := repo.LoadIndexFile(dir(helmpath.CacheIndexFile("test"))) + if err != nil { + t.Fatal(err) + } + + reqver := i.Entries["reqtest"][0] + if h := reqver.Digest; h != hash { + t.Errorf("Failed hash match: expected %s, got %s", hash, h) + } + + // Now change the dependencies and update. This verifies that on update, + // old dependencies are cleansed and new dependencies are added. + md.Dependencies = []*chart.Dependency{ + {Name: "reqtest", Version: "0.1.0", Repository: srv.URL()}, + {Name: "compressedchart", Version: "0.3.0", Repository: srv.URL()}, + } + if err := chartutil.SaveChartfile(dir(chartname, "Chart.yaml"), md); err != nil { + t.Fatal(err) + } + + _, out, err = executeActionCommand(fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s", dir(chartname), dir("repositories.yaml"), dir())) + if err != nil { + t.Logf("Output: %s", out) + t.Fatal(err) + } + + // In this second run, we should see compressedchart-0.3.0.tgz, and not + // the 0.1.0 version. + expect = dir(chartname, "charts/compressedchart-0.3.0.tgz") + if _, err := os.Stat(expect); err != nil { + t.Fatalf("Expected %q: %s", expect, err) + } + unexpected := dir(chartname, "charts/compressedchart-0.1.0.tgz") + if _, err := os.Stat(unexpected); err == nil { + t.Fatalf("Unexpected %q", unexpected) + } + + // test for OCI charts + if err := chartutil.SaveDir(c, dir()); err != nil { + t.Fatal(err) + } + cmd := fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s --registry-config %s/config.json", + dir(ociChartName), + dir("repositories.yaml"), + dir(), + dir()) + _, out, err = executeActionCommand(cmd) + if err != nil { + t.Logf("Output: %s", out) + t.Fatal(err) + } + expect = dir(ociChartName, "charts/oci-dependent-chart-0.1.0.tgz") + if _, err := os.Stat(expect); err != nil { + t.Fatal(err) + } +} + +func TestDependencyUpdateCmd_DoNotDeleteOldChartsOnError(t *testing.T) { + defer resetEnv()() + ensure.HelmHome(t) + + srv, err := repotest.NewTempServerWithCleanup(t, "testdata/testcharts/*.tgz") + if err != nil { + t.Fatal(err) + } + defer srv.Stop() + t.Logf("Listening on directory %s", srv.Root()) + + if err := srv.LinkIndices(); err != nil { + t.Fatal(err) + } + + chartname := "depupdelete" + + dir := func(p ...string) string { + return filepath.Join(append([]string{srv.Root()}, p...)...) + } + 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())) + if err != nil { + t.Logf("Output: %s", output) + t.Fatal(err) + } + + // 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())) + if err == nil { + t.Logf("Output: %s", output) + t.Fatal("Expected error, got nil") + } + + // Make sure charts dir still has dependencies + files, err := os.ReadDir(filepath.Join(dir(chartname), "charts")) + if err != nil { + t.Fatal(err) + } + dependencies := []string{"compressedchart-0.1.0.tgz", "reqtest-0.1.0.tgz"} + + if len(dependencies) != len(files) { + t.Fatalf("Expected %d chart dependencies, got %d", len(dependencies), len(files)) + } + for index, file := range files { + if dependencies[index] != file.Name() { + t.Fatalf("Chart dependency %s not matching %s", dependencies[index], file.Name()) + } + } + + // Make sure tmpcharts is deleted + if _, err := os.Stat(filepath.Join(dir(chartname), "tmpcharts")); !os.IsNotExist(err) { + t.Fatalf("tmpcharts dir still exists") + } +} + +func TestDependencyUpdateCmd_WithRepoThatWasNotAdded(t *testing.T) { + srv := setupMockRepoServer(t) + srvForUnmanagedRepo := setupMockRepoServer(t) + defer srv.Stop() + defer srvForUnmanagedRepo.Stop() + + dir := func(p ...string) string { + return filepath.Join(append([]string{srv.Root()}, p...)...) + } + + chartname := "depup" + ch := createTestingMetadata(chartname, srv.URL()) + chartDependency := &chart.Dependency{ + Name: "signtest", + Version: "0.1.0", + Repository: srvForUnmanagedRepo.URL(), + } + ch.Metadata.Dependencies = append(ch.Metadata.Dependencies, chartDependency) + + if err := chartutil.SaveDir(ch, dir()); err != nil { + t.Fatal(err) + } + + _, out, err := executeActionCommand( + fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s", dir(chartname), + dir("repositories.yaml"), dir()), + ) + + if err != nil { + t.Logf("Output: %s", out) + t.Fatal(err) + } + + // This is written directly to stdout, so we have to capture as is + if !strings.Contains(out, `Getting updates for unmanaged Helm repositories...`) { + t.Errorf("No ‘unmanaged’ Helm repo used in test chartdependency or it doesn’t cause the creation "+ + "of an ‘ad hoc’ repo index cache file\n%s", out) + } +} + +func setupMockRepoServer(t *testing.T) *repotest.Server { + srv, err := repotest.NewTempServerWithCleanup(t, "testdata/testcharts/*.tgz") + if err != nil { + t.Fatal(err) + } + + t.Logf("Listening on directory %s", srv.Root()) + + if err := srv.LinkIndices(); err != nil { + t.Fatal(err) + } + + return srv +} + +// createTestingMetadata creates a basic chart that depends on reqtest-0.1.0 +// +// The baseURL can be used to point to a particular repository server. +func createTestingMetadata(name, baseURL string) *chart.Chart { + return &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV2, + Name: name, + Version: "1.2.3", + Dependencies: []*chart.Dependency{ + {Name: "reqtest", Version: "0.1.0", Repository: baseURL}, + {Name: "compressedchart", Version: "0.1.0", Repository: baseURL}, + }, + }, + } +} + +func createTestingMetadataForOCI(name, registryURL string) *chart.Chart { + return &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV2, + Name: name, + Version: "1.2.3", + Dependencies: []*chart.Dependency{ + {Name: "oci-dependent-chart", Version: "0.1.0", Repository: fmt.Sprintf("oci://%s/u/ocitestuser", registryURL)}, + }, + }, + } +} + +// createTestingChart creates a basic chart that depends on reqtest-0.1.0 +// +// The baseURL can be used to point to a particular repository server. +func createTestingChart(t *testing.T, dest, name, baseURL string) { + t.Helper() + cfile := createTestingMetadata(name, baseURL) + if err := chartutil.SaveDir(cfile, dest); err != nil { + t.Fatal(err) + } +} diff --git a/cmd/helm/docs.go b/cmd/helm/docs.go new file mode 100644 index 00000000..6e9788f2 --- /dev/null +++ b/cmd/helm/docs.go @@ -0,0 +1,104 @@ +/* +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 ( + "fmt" + "io" + "path" + "path/filepath" + "strings" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + "github.com/spf13/cobra/doc" + "golang.org/x/text/cases" + "golang.org/x/text/language" + + "helm.sh/helm/v3/cmd/helm/require" +) + +const docsDesc = ` +Generate documentation files for Helm. + +This command can generate documentation for Helm in the following formats: + +- Markdown +- Man pages + +It can also generate bash autocompletions. +` + +type docsOptions struct { + dest string + docTypeString string + topCmd *cobra.Command + generateHeaders bool +} + +func newDocsCmd(out io.Writer) *cobra.Command { + o := &docsOptions{} + + cmd := &cobra.Command{ + Use: "docs", + Short: "generate documentation as markdown or man pages", + Long: docsDesc, + Hidden: true, + Args: require.NoArgs, + ValidArgsFunction: noCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + o.topCmd = cmd.Root() + return o.run(out) + }, + } + + f := cmd.Flags() + f.StringVar(&o.dest, "dir", "./", "directory to which documentation is written") + f.StringVar(&o.docTypeString, "type", "markdown", "the type of documentation to generate (markdown, man, bash)") + f.BoolVar(&o.generateHeaders, "generate-headers", false, "generate standard headers for markdown files") + + cmd.RegisterFlagCompletionFunc("type", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"bash", "man", "markdown"}, cobra.ShellCompDirectiveNoFileComp + }) + + return cmd +} + +func (o *docsOptions) run(_ io.Writer) error { + switch o.docTypeString { + case "markdown", "mdown", "md": + if o.generateHeaders { + standardLinks := func(s string) string { return s } + + hdrFunc := func(filename string) string { + base := filepath.Base(filename) + name := strings.TrimSuffix(base, path.Ext(base)) + title := cases.Title(language.Und, cases.NoLower).String(strings.Replace(name, "_", " ", -1)) + return fmt.Sprintf("---\ntitle: \"%s\"\n---\n\n", title) + } + + return doc.GenMarkdownTreeCustom(o.topCmd, o.dest, hdrFunc, standardLinks) + } + return doc.GenMarkdownTree(o.topCmd, o.dest) + case "man": + manHdr := &doc.GenManHeader{Title: "HELM", Section: "1"} + return doc.GenManTree(o.topCmd, manHdr, o.dest) + case "bash": + return o.topCmd.GenBashCompletionFile(filepath.Join(o.dest, "completions.bash")) + default: + return errors.Errorf("unknown doc type %q. Try 'markdown' or 'man'", o.docTypeString) + } +} diff --git a/cmd/helm/docs_test.go b/cmd/helm/docs_test.go new file mode 100644 index 00000000..fe5864d5 --- /dev/null +++ b/cmd/helm/docs_test.go @@ -0,0 +1,38 @@ +/* +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 ( + "testing" +) + +func TestDocsTypeFlagCompletion(t *testing.T) { + tests := []cmdTestCase{{ + name: "completion for docs --type", + cmd: "__complete docs --type ''", + golden: "output/docs-type-comp.txt", + }, { + name: "completion for docs --type, no filter", + cmd: "__complete docs --type mar", + golden: "output/docs-type-comp.txt", + }} + runTestCmd(t, tests) +} + +func TestDocsFileCompletion(t *testing.T) { + checkFileCompletion(t, "docs", false) +} diff --git a/cmd/helm/env.go b/cmd/helm/env.go new file mode 100644 index 00000000..3754b748 --- /dev/null +++ b/cmd/helm/env.go @@ -0,0 +1,76 @@ +/* +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 ( + "fmt" + "io" + "sort" + + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" +) + +var envHelp = ` +Env prints out all the environment information in use by Helm. +` + +func newEnvCmd(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "env", + Short: "helm client environment information", + Long: envHelp, + Args: require.MaximumNArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + keys := getSortedEnvVarKeys() + return keys, cobra.ShellCompDirectiveNoFileComp + } + + return nil, cobra.ShellCompDirectiveNoFileComp + }, + Run: func(cmd *cobra.Command, args []string) { + envVars := settings.EnvVars() + + if len(args) == 0 { + // Sort the variables by alphabetical order. + // This allows for a constant output across calls to 'helm env'. + keys := getSortedEnvVarKeys() + + for _, k := range keys { + fmt.Fprintf(out, "%s=\"%s\"\n", k, envVars[k]) + } + } else { + fmt.Fprintf(out, "%s\n", envVars[args[0]]) + } + }, + } + return cmd +} + +func getSortedEnvVarKeys() []string { + envVars := settings.EnvVars() + + var keys []string + for k := range envVars { + keys = append(keys, k) + } + sort.Strings(keys) + + return keys +} diff --git a/cmd/helm/env_test.go b/cmd/helm/env_test.go new file mode 100644 index 00000000..01ef2593 --- /dev/null +++ b/cmd/helm/env_test.go @@ -0,0 +1,35 @@ +/* +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 ( + "testing" +) + +func TestEnv(t *testing.T) { + tests := []cmdTestCase{{ + name: "completion for env", + cmd: "__complete env ''", + golden: "output/env-comp.txt", + }} + runTestCmd(t, tests) +} + +func TestEnvFileCompletion(t *testing.T) { + checkFileCompletion(t, "env", false) + checkFileCompletion(t, "env HELM_BIN", false) +} diff --git a/cmd/helm/flags.go b/cmd/helm/flags.go new file mode 100644 index 00000000..4fcd8a0e --- /dev/null +++ b/cmd/helm/flags.go @@ -0,0 +1,252 @@ +/* +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 ( + "flag" + "fmt" + "log" + "path/filepath" + "sort" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "k8s.io/klog/v2" + + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/cli/output" + "helm.sh/helm/v3/pkg/cli/values" + "helm.sh/helm/v3/pkg/helmpath" + "helm.sh/helm/v3/pkg/postrender" + "helm.sh/helm/v3/pkg/repo" +) + +const ( + outputFlag = "output" + postRenderFlag = "post-renderer" + postRenderArgsFlag = "post-renderer-args" +) + +func addValueOptionsFlags(f *pflag.FlagSet, v *values.Options) { + f.StringSliceVarP(&v.ValueFiles, "values", "f", []string{}, "specify values in a YAML file or a URL (can specify multiple)") + f.StringArrayVar(&v.Values, "set", []string{}, "set values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2)") + f.StringArrayVar(&v.StringValues, "set-string", []string{}, "set STRING values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2)") + f.StringArrayVar(&v.FileValues, "set-file", []string{}, "set values from respective files specified via the command line (can specify multiple or separate values with commas: key1=path1,key2=path2)") + f.StringArrayVar(&v.JSONValues, "set-json", []string{}, "set JSON values on the command line (can specify multiple or separate values with commas: key1=jsonval1,key2=jsonval2)") + f.StringArrayVar(&v.LiteralValues, "set-literal", []string{}, "set a literal STRING value on the command line") +} + +func addChartPathOptionsFlags(f *pflag.FlagSet, c *action.ChartPathOptions) { + f.StringVar(&c.Version, "version", "", "specify a version constraint for the chart version to use. This constraint can be a specific tag (e.g. 1.1.1) or it may reference a valid range (e.g. ^2.0.0). If this is not specified, the latest version is used") + f.BoolVar(&c.Verify, "verify", false, "verify the package before using it") + f.StringVar(&c.Keyring, "keyring", defaultKeyring(), "location of public keys used for verification") + f.StringVar(&c.RepoURL, "repo", "", "chart repository url where to locate the requested chart") + f.StringVar(&c.Username, "username", "", "chart repository username where to locate the requested chart") + f.StringVar(&c.Password, "password", "", "chart repository password where to locate the requested chart") + f.StringVar(&c.CertFile, "cert-file", "", "identify HTTPS client using this SSL certificate file") + f.StringVar(&c.KeyFile, "key-file", "", "identify HTTPS client using this SSL key file") + f.BoolVar(&c.InsecureSkipTLSverify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the chart download") + f.BoolVar(&c.PlainHTTP, "plain-http", false, "use insecure HTTP connections for the chart download") + f.StringVar(&c.CaFile, "ca-file", "", "verify certificates of HTTPS-enabled servers using this CA bundle") + f.BoolVar(&c.PassCredentialsAll, "pass-credentials", false, "pass credentials to all domains") +} + +// bindOutputFlag will add the output flag to the given command and bind the +// value to the given format pointer +func bindOutputFlag(cmd *cobra.Command, varRef *output.Format) { + cmd.Flags().VarP(newOutputValue(output.Table, varRef), outputFlag, "o", + fmt.Sprintf("prints the output in the specified format. Allowed values: %s", strings.Join(output.Formats(), ", "))) + + err := cmd.RegisterFlagCompletionFunc(outputFlag, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + var formatNames []string + for format, desc := range output.FormatsWithDesc() { + formatNames = append(formatNames, fmt.Sprintf("%s\t%s", format, desc)) + } + + // Sort the results to get a deterministic order for the tests + sort.Strings(formatNames) + return formatNames, cobra.ShellCompDirectiveNoFileComp + }) + + if err != nil { + log.Fatal(err) + } +} + +type outputValue output.Format + +func newOutputValue(defaultValue output.Format, p *output.Format) *outputValue { + *p = defaultValue + return (*outputValue)(p) +} + +func (o *outputValue) String() string { + // It is much cleaner looking (and technically less allocations) to just + // convert to a string rather than type asserting to the underlying + // output.Format + return string(*o) +} + +func (o *outputValue) Type() string { + return "format" +} + +func (o *outputValue) Set(s string) error { + outfmt, err := output.ParseFormat(s) + if err != nil { + return err + } + *o = outputValue(outfmt) + return nil +} + +func bindPostRenderFlag(cmd *cobra.Command, varRef *postrender.PostRenderer) { + p := &postRendererOptions{varRef, "", []string{}} + cmd.Flags().Var(&postRendererString{p}, postRenderFlag, "the path to an executable to be used for post rendering. If it exists in $PATH, the binary will be used, otherwise it will try to look for the executable at the given path") + cmd.Flags().Var(&postRendererArgsSlice{p}, postRenderArgsFlag, "an argument to the post-renderer (can specify multiple)") +} + +type postRendererOptions struct { + renderer *postrender.PostRenderer + binaryPath string + args []string +} + +type postRendererString struct { + options *postRendererOptions +} + +func (p *postRendererString) String() string { + return p.options.binaryPath +} + +func (p *postRendererString) Type() string { + return "postRendererString" +} + +func (p *postRendererString) Set(val string) error { + if val == "" { + return nil + } + p.options.binaryPath = val + pr, err := postrender.NewExec(p.options.binaryPath, p.options.args...) + if err != nil { + return err + } + *p.options.renderer = pr + return nil +} + +type postRendererArgsSlice struct { + options *postRendererOptions +} + +func (p *postRendererArgsSlice) String() string { + return "[" + strings.Join(p.options.args, ",") + "]" +} + +func (p *postRendererArgsSlice) Type() string { + return "postRendererArgsSlice" +} + +func (p *postRendererArgsSlice) Set(val string) error { + + // a post-renderer defined by a user may accept empty arguments + p.options.args = append(p.options.args, val) + + if p.options.binaryPath == "" { + return nil + } + // overwrite if already create PostRenderer by `post-renderer` flags + pr, err := postrender.NewExec(p.options.binaryPath, p.options.args...) + if err != nil { + return err + } + *p.options.renderer = pr + return nil +} + +func (p *postRendererArgsSlice) Append(val string) error { + p.options.args = append(p.options.args, val) + return nil +} + +func (p *postRendererArgsSlice) Replace(val []string) error { + p.options.args = val + return nil +} + +func (p *postRendererArgsSlice) GetSlice() []string { + return p.options.args +} + +func compVersionFlag(chartRef string, _ string) ([]string, cobra.ShellCompDirective) { + chartInfo := strings.Split(chartRef, "/") + if len(chartInfo) != 2 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + repoName := chartInfo[0] + chartName := chartInfo[1] + + path := filepath.Join(settings.RepositoryCache, helmpath.CacheIndexFile(repoName)) + + var versions []string + if indexFile, err := repo.LoadIndexFile(path); err == nil { + for _, details := range indexFile.Entries[chartName] { + appVersion := details.Metadata.AppVersion + appVersionDesc := "" + if appVersion != "" { + appVersionDesc = fmt.Sprintf("App: %s, ", appVersion) + } + created := details.Created.Format("January 2, 2006") + createdDesc := "" + if created != "" { + createdDesc = fmt.Sprintf("Created: %s ", created) + } + deprecated := "" + if details.Metadata.Deprecated { + deprecated = "(deprecated)" + } + versions = append(versions, fmt.Sprintf("%s\t%s%s%s", details.Metadata.Version, appVersionDesc, createdDesc, deprecated)) + } + } + + return versions, cobra.ShellCompDirectiveNoFileComp +} + +// addKlogFlags adds flags from k8s.io/klog +// marks the flags as hidden to avoid polluting the help text +func addKlogFlags(fs *pflag.FlagSet) { + local := flag.NewFlagSet("klog", flag.ExitOnError) + klog.InitFlags(local) + local.VisitAll(func(fl *flag.Flag) { + fl.Name = normalize(fl.Name) + if fs.Lookup(fl.Name) != nil { + return + } + newflag := pflag.PFlagFromGoFlag(fl) + newflag.Hidden = true + fs.AddFlag(newflag) + }) +} + +// normalize replaces underscores with hyphens +func normalize(s string) string { + return strings.ReplaceAll(s, "_", "-") +} diff --git a/cmd/helm/flags_test.go b/cmd/helm/flags_test.go new file mode 100644 index 00000000..07d28c46 --- /dev/null +++ b/cmd/helm/flags_test.go @@ -0,0 +1,95 @@ +/* +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 ( + "fmt" + "testing" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/release" + helmtime "helm.sh/helm/v3/pkg/time" +) + +func outputFlagCompletionTest(t *testing.T, cmdName string) { + releasesMockWithStatus := func(info *release.Info, hooks ...*release.Hook) []*release.Release { + info.LastDeployed = helmtime.Unix(1452902400, 0).UTC() + return []*release.Release{{ + Name: "athos", + Namespace: "default", + Info: info, + Chart: &chart.Chart{}, + Hooks: hooks, + }, { + Name: "porthos", + Namespace: "default", + Info: info, + Chart: &chart.Chart{}, + Hooks: hooks, + }, { + Name: "aramis", + Namespace: "default", + Info: info, + Chart: &chart.Chart{}, + Hooks: hooks, + }, { + Name: "dartagnan", + Namespace: "gascony", + Info: info, + Chart: &chart.Chart{}, + Hooks: hooks, + }} + } + + tests := []cmdTestCase{{ + name: "completion for output flag long and before arg", + cmd: fmt.Sprintf("__complete %s --output ''", cmdName), + golden: "output/output-comp.txt", + rels: releasesMockWithStatus(&release.Info{ + Status: release.StatusDeployed, + }), + }, { + name: "completion for output flag long and after arg", + cmd: fmt.Sprintf("__complete %s aramis --output ''", cmdName), + golden: "output/output-comp.txt", + rels: releasesMockWithStatus(&release.Info{ + Status: release.StatusDeployed, + }), + }, { + name: "completion for output flag short and before arg", + cmd: fmt.Sprintf("__complete %s -o ''", cmdName), + golden: "output/output-comp.txt", + rels: releasesMockWithStatus(&release.Info{ + Status: release.StatusDeployed, + }), + }, { + name: "completion for output flag short and after arg", + cmd: fmt.Sprintf("__complete %s aramis -o ''", cmdName), + golden: "output/output-comp.txt", + rels: releasesMockWithStatus(&release.Info{ + Status: release.StatusDeployed, + }), + }, { + name: "completion for output flag, no filter", + cmd: fmt.Sprintf("__complete %s --output jso", cmdName), + golden: "output/output-comp.txt", + rels: releasesMockWithStatus(&release.Info{ + Status: release.StatusDeployed, + }), + }} + runTestCmd(t, tests) +} diff --git a/cmd/helm/get.go b/cmd/helm/get.go new file mode 100644 index 00000000..727cdaf8 --- /dev/null +++ b/cmd/helm/get.go @@ -0,0 +1,55 @@ +/* +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" +) + +var getHelp = ` +This command consists of multiple subcommands which can be used to +get extended information about the release, including: + +- The values used to generate the release +- The generated manifest file +- The notes provided by the chart of the release +- The hooks associated with the release +- The metadata of the release +` + +func newGetCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "get", + Short: "download extended information of a named release", + Long: getHelp, + Args: require.NoArgs, + } + + cmd.AddCommand(newGetAllCmd(cfg, out)) + cmd.AddCommand(newGetValuesCmd(cfg, out)) + cmd.AddCommand(newGetManifestCmd(cfg, out)) + cmd.AddCommand(newGetHooksCmd(cfg, out)) + cmd.AddCommand(newGetNotesCmd(cfg, out)) + cmd.AddCommand(newGetMetadataCmd(cfg, out)) + + return cmd +} diff --git a/cmd/helm/get_all.go b/cmd/helm/get_all.go new file mode 100644 index 00000000..e51d5053 --- /dev/null +++ b/cmd/helm/get_all.go @@ -0,0 +1,82 @@ +/* +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" + "log" + + "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 getAllHelp = ` +This command prints a human readable collection of information about the +notes, hooks, supplied values, and generated manifest file of the given release. +` + +func newGetAllCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + var template string + client := action.NewGet(cfg) + + cmd := &cobra.Command{ + Use: "all RELEASE_NAME", + Short: "download all information for a named release", + Long: getAllHelp, + Args: require.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return compListReleases(toComplete, args, cfg) + }, + RunE: func(cmd *cobra.Command, args []string) error { + res, err := client.Run(args[0]) + if err != nil { + return err + } + if template != "" { + data := map[string]interface{}{ + "Release": res, + } + return tpl(template, data, out) + } + + return output.Table.Write(out, &statusPrinter{res, true, false, false, true}) + }, + } + + f := cmd.Flags() + f.IntVar(&client.Version, "revision", 0, "get the named release with revision") + err := cmd.RegisterFlagCompletionFunc("revision", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 1 { + return compListRevisions(toComplete, cfg, args[0]) + } + return nil, cobra.ShellCompDirectiveNoFileComp + }) + + if err != nil { + log.Fatal(err) + } + + f.StringVar(&template, "template", "", "go template for formatting the output, eg: {{.Release.Name}}") + + return cmd +} diff --git a/cmd/helm/get_all_test.go b/cmd/helm/get_all_test.go new file mode 100644 index 00000000..948f0aa7 --- /dev/null +++ b/cmd/helm/get_all_test.go @@ -0,0 +1,56 @@ +/* +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 ( + "testing" + + "helm.sh/helm/v3/pkg/release" +) + +func TestGetCmd(t *testing.T) { + tests := []cmdTestCase{{ + name: "get all with a release", + cmd: "get all thomas-guide", + golden: "output/get-release.txt", + rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "thomas-guide"})}, + }, { + name: "get all with a formatted release", + cmd: "get all elevated-turkey --template {{.Release.Chart.Metadata.Version}}", + golden: "output/get-release-template.txt", + rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "elevated-turkey"})}, + }, { + name: "get all requires release name arg", + cmd: "get all", + golden: "output/get-all-no-args.txt", + wantError: true, + }} + runTestCmd(t, tests) +} + +func TestGetAllCompletion(t *testing.T) { + checkReleaseCompletion(t, "get all", false) +} + +func TestGetAllRevisionCompletion(t *testing.T) { + revisionFlagCompletionTest(t, "get all") +} + +func TestGetAllFileCompletion(t *testing.T) { + checkFileCompletion(t, "get all", false) + checkFileCompletion(t, "get all myrelease", false) +} diff --git a/cmd/helm/get_hooks.go b/cmd/helm/get_hooks.go new file mode 100644 index 00000000..913e2c58 --- /dev/null +++ b/cmd/helm/get_hooks.go @@ -0,0 +1,75 @@ +/* +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 ( + "fmt" + "io" + "log" + + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/action" +) + +const getHooksHelp = ` +This command downloads hooks for a given release. + +Hooks are formatted in YAML and separated by the YAML '---\n' separator. +` + +func newGetHooksCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + client := action.NewGet(cfg) + + cmd := &cobra.Command{ + Use: "hooks RELEASE_NAME", + Short: "download all hooks for a named release", + Long: getHooksHelp, + Args: require.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return compListReleases(toComplete, args, cfg) + }, + RunE: func(cmd *cobra.Command, args []string) error { + res, err := client.Run(args[0]) + if err != nil { + return err + } + for _, hook := range res.Hooks { + fmt.Fprintf(out, "---\n# Source: %s\n%s\n", hook.Path, hook.Manifest) + } + return nil + }, + } + + cmd.Flags().IntVar(&client.Version, "revision", 0, "get the named release with revision") + err := cmd.RegisterFlagCompletionFunc("revision", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 1 { + return compListRevisions(toComplete, cfg, args[0]) + } + return nil, cobra.ShellCompDirectiveNoFileComp + }) + + if err != nil { + log.Fatal(err) + } + + return cmd +} diff --git a/cmd/helm/get_hooks_test.go b/cmd/helm/get_hooks_test.go new file mode 100644 index 00000000..251d5c73 --- /dev/null +++ b/cmd/helm/get_hooks_test.go @@ -0,0 +1,51 @@ +/* +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 ( + "testing" + + "helm.sh/helm/v3/pkg/release" +) + +func TestGetHooks(t *testing.T) { + tests := []cmdTestCase{{ + name: "get hooks with release", + cmd: "get hooks aeneas", + golden: "output/get-hooks.txt", + rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "aeneas"})}, + }, { + name: "get hooks without args", + cmd: "get hooks", + golden: "output/get-hooks-no-args.txt", + wantError: true, + }} + runTestCmd(t, tests) +} + +func TestGetHooksCompletion(t *testing.T) { + checkReleaseCompletion(t, "get hooks", false) +} + +func TestGetHooksRevisionCompletion(t *testing.T) { + revisionFlagCompletionTest(t, "get hooks") +} + +func TestGetHooksFileCompletion(t *testing.T) { + checkFileCompletion(t, "get hooks", false) + checkFileCompletion(t, "get hooks myrelease", false) +} diff --git a/cmd/helm/get_manifest.go b/cmd/helm/get_manifest.go new file mode 100644 index 00000000..baeaf8d7 --- /dev/null +++ b/cmd/helm/get_manifest.go @@ -0,0 +1,75 @@ +/* +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 ( + "fmt" + "io" + "log" + + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/action" +) + +var getManifestHelp = ` +This command fetches the generated manifest for a given release. + +A manifest is a YAML-encoded representation of the Kubernetes resources that +were generated from this release's chart(s). If a chart is dependent on other +charts, those resources will also be included in the manifest. +` + +func newGetManifestCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + client := action.NewGet(cfg) + + cmd := &cobra.Command{ + Use: "manifest RELEASE_NAME", + Short: "download the manifest for a named release", + Long: getManifestHelp, + Args: require.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return compListReleases(toComplete, args, cfg) + }, + RunE: func(cmd *cobra.Command, args []string) error { + res, err := client.Run(args[0]) + if err != nil { + return err + } + fmt.Fprintln(out, res.Manifest) + return nil + }, + } + + cmd.Flags().IntVar(&client.Version, "revision", 0, "get the named release with revision") + err := cmd.RegisterFlagCompletionFunc("revision", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 1 { + return compListRevisions(toComplete, cfg, args[0]) + } + return nil, cobra.ShellCompDirectiveNoFileComp + }) + + if err != nil { + log.Fatal(err) + } + + return cmd +} diff --git a/cmd/helm/get_manifest_test.go b/cmd/helm/get_manifest_test.go new file mode 100644 index 00000000..2f27476b --- /dev/null +++ b/cmd/helm/get_manifest_test.go @@ -0,0 +1,51 @@ +/* +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 ( + "testing" + + "helm.sh/helm/v3/pkg/release" +) + +func TestGetManifest(t *testing.T) { + tests := []cmdTestCase{{ + name: "get manifest with release", + cmd: "get manifest juno", + golden: "output/get-manifest.txt", + rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "juno"})}, + }, { + name: "get manifest without args", + cmd: "get manifest", + golden: "output/get-manifest-no-args.txt", + wantError: true, + }} + runTestCmd(t, tests) +} + +func TestGetManifestCompletion(t *testing.T) { + checkReleaseCompletion(t, "get manifest", false) +} + +func TestGetManifestRevisionCompletion(t *testing.T) { + revisionFlagCompletionTest(t, "get manifest") +} + +func TestGetManifestFileCompletion(t *testing.T) { + checkFileCompletion(t, "get manifest", false) + checkFileCompletion(t, "get manifest myrelease", false) +} diff --git a/cmd/helm/get_metadata.go b/cmd/helm/get_metadata.go new file mode 100644 index 00000000..adab891b --- /dev/null +++ b/cmd/helm/get_metadata.go @@ -0,0 +1,94 @@ +/* +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 ( + "fmt" + "io" + "log" + + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/cli/output" +) + +type metadataWriter struct { + metadata *action.Metadata +} + +func newGetMetadataCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + var outfmt output.Format + client := action.NewGetMetadata(cfg) + + cmd := &cobra.Command{ + Use: "metadata RELEASE_NAME", + Short: "This command fetches metadata for a given release", + Args: require.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return compListReleases(toComplete, args, cfg) + }, + RunE: func(cmd *cobra.Command, args []string) error { + releaseMetadata, err := client.Run(args[0]) + if err != nil { + return err + } + return outfmt.Write(out, &metadataWriter{releaseMetadata}) + }, + } + + f := cmd.Flags() + f.IntVar(&client.Version, "revision", 0, "specify release revision") + err := cmd.RegisterFlagCompletionFunc("revision", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 1 { + return compListRevisions(toComplete, cfg, args[0]) + } + return nil, cobra.ShellCompDirectiveNoFileComp + }) + + if err != nil { + log.Fatal(err) + } + + bindOutputFlag(cmd, &outfmt) + + return cmd +} + +func (w metadataWriter) WriteTable(out io.Writer) error { + _, _ = fmt.Fprintf(out, "NAME: %v\n", w.metadata.Name) + _, _ = fmt.Fprintf(out, "CHART: %v\n", w.metadata.Chart) + _, _ = fmt.Fprintf(out, "VERSION: %v\n", w.metadata.Version) + _, _ = fmt.Fprintf(out, "APP_VERSION: %v\n", w.metadata.AppVersion) + _, _ = fmt.Fprintf(out, "NAMESPACE: %v\n", w.metadata.Namespace) + _, _ = fmt.Fprintf(out, "REVISION: %v\n", w.metadata.Revision) + _, _ = fmt.Fprintf(out, "STATUS: %v\n", w.metadata.Status) + _, _ = fmt.Fprintf(out, "DEPLOYED_AT: %v\n", w.metadata.DeployedAt) + return nil +} + +func (w metadataWriter) WriteJSON(out io.Writer) error { + return output.EncodeJSON(out, w.metadata) +} + +func (w metadataWriter) WriteYAML(out io.Writer) error { + return output.EncodeYAML(out, w.metadata) +} diff --git a/cmd/helm/get_metadata_test.go b/cmd/helm/get_metadata_test.go new file mode 100644 index 00000000..b6f0ab9f --- /dev/null +++ b/cmd/helm/get_metadata_test.go @@ -0,0 +1,66 @@ +/* +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 ( + "testing" + + "helm.sh/helm/v3/pkg/release" +) + +func TestGetMetadataCmd(t *testing.T) { + tests := []cmdTestCase{{ + name: "get metadata with a release", + cmd: "get metadata thomas-guide", + golden: "output/get-metadata.txt", + rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "thomas-guide"})}, + }, { + name: "get metadata requires release name arg", + cmd: "get metadata", + golden: "output/get-metadata-args.txt", + rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "thomas-guide"})}, + wantError: true, + }, { + name: "get metadata to json", + cmd: "get metadata thomas-guide --output json", + golden: "output/get-metadata.json", + rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "thomas-guide"})}, + }, { + name: "get metadata to yaml", + cmd: "get metadata thomas-guide --output yaml", + golden: "output/get-metadata.yaml", + rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "thomas-guide"})}, + }} + runTestCmd(t, tests) +} + +func TestGetMetadataCompletion(t *testing.T) { + checkReleaseCompletion(t, "get metadata", false) +} + +func TestGetMetadataRevisionCompletion(t *testing.T) { + revisionFlagCompletionTest(t, "get metadata") +} + +func TestGetMetadataOutputCompletion(t *testing.T) { + outputFlagCompletionTest(t, "get metadata") +} + +func TestGetMetadataFileCompletion(t *testing.T) { + checkFileCompletion(t, "get metadata", false) + checkFileCompletion(t, "get metadata myrelease", false) +} diff --git a/cmd/helm/get_notes.go b/cmd/helm/get_notes.go new file mode 100644 index 00000000..b71bcbdf --- /dev/null +++ b/cmd/helm/get_notes.go @@ -0,0 +1,74 @@ +/* +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 ( + "fmt" + "io" + "log" + + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/action" +) + +var getNotesHelp = ` +This command shows notes provided by the chart of a named release. +` + +func newGetNotesCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + client := action.NewGet(cfg) + + cmd := &cobra.Command{ + Use: "notes RELEASE_NAME", + Short: "download the notes for a named release", + Long: getNotesHelp, + Args: require.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return compListReleases(toComplete, args, cfg) + }, + RunE: func(cmd *cobra.Command, args []string) error { + res, err := client.Run(args[0]) + if err != nil { + return err + } + if len(res.Info.Notes) > 0 { + fmt.Fprintf(out, "NOTES:\n%s\n", res.Info.Notes) + } + return nil + }, + } + + f := cmd.Flags() + f.IntVar(&client.Version, "revision", 0, "get the named release with revision") + err := cmd.RegisterFlagCompletionFunc("revision", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 1 { + return compListRevisions(toComplete, cfg, args[0]) + } + return nil, cobra.ShellCompDirectiveNoFileComp + }) + + if err != nil { + log.Fatal(err) + } + + return cmd +} diff --git a/cmd/helm/get_notes_test.go b/cmd/helm/get_notes_test.go new file mode 100644 index 00000000..8be9a3f7 --- /dev/null +++ b/cmd/helm/get_notes_test.go @@ -0,0 +1,51 @@ +/* +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 ( + "testing" + + "helm.sh/helm/v3/pkg/release" +) + +func TestGetNotesCmd(t *testing.T) { + tests := []cmdTestCase{{ + name: "get notes of a deployed release", + cmd: "get notes the-limerick", + golden: "output/get-notes.txt", + rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "the-limerick"})}, + }, { + name: "get notes without args", + cmd: "get notes", + golden: "output/get-notes-no-args.txt", + wantError: true, + }} + runTestCmd(t, tests) +} + +func TestGetNotesCompletion(t *testing.T) { + checkReleaseCompletion(t, "get notes", false) +} + +func TestGetNotesRevisionCompletion(t *testing.T) { + revisionFlagCompletionTest(t, "get notes") +} + +func TestGetNotesFileCompletion(t *testing.T) { + checkFileCompletion(t, "get notes", false) + checkFileCompletion(t, "get notes myrelease", false) +} diff --git a/cmd/helm/get_test.go b/cmd/helm/get_test.go new file mode 100644 index 00000000..79f914be --- /dev/null +++ b/cmd/helm/get_test.go @@ -0,0 +1,25 @@ +/* +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 ( + "testing" +) + +func TestGetFileCompletion(t *testing.T) { + checkFileCompletion(t, "get", false) +} diff --git a/cmd/helm/get_values.go b/cmd/helm/get_values.go new file mode 100644 index 00000000..6124e1b3 --- /dev/null +++ b/cmd/helm/get_values.go @@ -0,0 +1,98 @@ +/* +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 ( + "fmt" + "io" + "log" + + "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 getValuesHelp = ` +This command downloads a values file for a given release. +` + +type valuesWriter struct { + vals map[string]interface{} + allValues bool +} + +func newGetValuesCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + var outfmt output.Format + client := action.NewGetValues(cfg) + + cmd := &cobra.Command{ + Use: "values RELEASE_NAME", + Short: "download the values file for a named release", + Long: getValuesHelp, + Args: require.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return compListReleases(toComplete, args, cfg) + }, + RunE: func(cmd *cobra.Command, args []string) error { + vals, err := client.Run(args[0]) + if err != nil { + return err + } + return outfmt.Write(out, &valuesWriter{vals, client.AllValues}) + }, + } + + f := cmd.Flags() + f.IntVar(&client.Version, "revision", 0, "get the named release with revision") + err := cmd.RegisterFlagCompletionFunc("revision", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 1 { + return compListRevisions(toComplete, cfg, args[0]) + } + return nil, cobra.ShellCompDirectiveNoFileComp + }) + + if err != nil { + log.Fatal(err) + } + + f.BoolVarP(&client.AllValues, "all", "a", false, "dump all (computed) values") + bindOutputFlag(cmd, &outfmt) + + return cmd +} + +func (v valuesWriter) WriteTable(out io.Writer) error { + if v.allValues { + fmt.Fprintln(out, "COMPUTED VALUES:") + } else { + fmt.Fprintln(out, "USER-SUPPLIED VALUES:") + } + return output.EncodeYAML(out, v.vals) +} + +func (v valuesWriter) WriteJSON(out io.Writer) error { + return output.EncodeJSON(out, v.vals) +} + +func (v valuesWriter) WriteYAML(out io.Writer) error { + return output.EncodeYAML(out, v.vals) +} diff --git a/cmd/helm/get_values_test.go b/cmd/helm/get_values_test.go new file mode 100644 index 00000000..423c3285 --- /dev/null +++ b/cmd/helm/get_values_test.go @@ -0,0 +1,71 @@ +/* +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 ( + "testing" + + "helm.sh/helm/v3/pkg/release" +) + +func TestGetValuesCmd(t *testing.T) { + tests := []cmdTestCase{{ + name: "get values with a release", + cmd: "get values thomas-guide", + golden: "output/get-values.txt", + rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "thomas-guide"})}, + }, { + name: "get values requires release name arg", + cmd: "get values", + golden: "output/get-values-args.txt", + rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "thomas-guide"})}, + wantError: true, + }, { + name: "get values thomas-guide (all)", + cmd: "get values thomas-guide --all", + golden: "output/get-values-all.txt", + rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "thomas-guide"})}, + }, { + name: "get values to json", + cmd: "get values thomas-guide --output json", + golden: "output/values.json", + rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "thomas-guide"})}, + }, { + name: "get values to yaml", + cmd: "get values thomas-guide --output yaml", + golden: "output/values.yaml", + rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "thomas-guide"})}, + }} + runTestCmd(t, tests) +} + +func TestGetValuesCompletion(t *testing.T) { + checkReleaseCompletion(t, "get values", false) +} + +func TestGetValuesRevisionCompletion(t *testing.T) { + revisionFlagCompletionTest(t, "get values") +} + +func TestGetValuesOutputCompletion(t *testing.T) { + outputFlagCompletionTest(t, "get values") +} + +func TestGetValuesFileCompletion(t *testing.T) { + checkFileCompletion(t, "get values", false) + checkFileCompletion(t, "get values myrelease", false) +} diff --git a/cmd/helm/helm.go b/cmd/helm/helm.go new file mode 100644 index 00000000..553da509 --- /dev/null +++ b/cmd/helm/helm.go @@ -0,0 +1,130 @@ +/* +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 "helm.sh/helm/v3/cmd/helm" + +import ( + "fmt" + "io" + "log" + "os" + "strings" + + "github.com/spf13/cobra" + "sigs.k8s.io/yaml" + + // Import to initialize client auth plugins. + _ "k8s.io/client-go/plugin/pkg/client/auth" + + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/kube" + kubefake "helm.sh/helm/v3/pkg/kube/fake" + "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/storage/driver" +) + +var settings = cli.New() + +func init() { + log.SetFlags(log.Lshortfile) +} + +func debug(format string, v ...interface{}) { + if settings.Debug { + format = fmt.Sprintf("[debug] %s\n", format) + log.Output(2, fmt.Sprintf(format, v...)) + } +} + +func warning(format string, v ...interface{}) { + format = fmt.Sprintf("WARNING: %s\n", format) + fmt.Fprintf(os.Stderr, format, v...) +} + +func main() { + // Setting the name of the app for managedFields in the Kubernetes client. + // It is set here to the full name of "helm" so that renaming of helm to + // another name (e.g., helm2 or helm3) does not change the name of the + // manager as picked up by the automated name detection. + kube.ManagedFieldsManager = "helm" + + actionConfig := new(action.Configuration) + cmd, err := newRootCmd(actionConfig, os.Stdout, os.Args[1:]) + if err != nil { + warning("%+v", err) + os.Exit(1) + } + + // run when each command's execute method is called + cobra.OnInitialize(func() { + helmDriver := os.Getenv("HELM_DRIVER") + if err := actionConfig.Init(settings.RESTClientGetter(), settings.Namespace(), helmDriver, debug); err != nil { + log.Fatal(err) + } + if helmDriver == "memory" { + loadReleasesInMemory(actionConfig) + } + }) + + if err := cmd.Execute(); err != nil { + debug("%+v", err) + switch e := err.(type) { + case pluginError: + os.Exit(e.code) + default: + os.Exit(1) + } + } +} + +// This function loads releases into the memory storage if the +// environment variable is properly set. +func loadReleasesInMemory(actionConfig *action.Configuration) { + filePaths := strings.Split(os.Getenv("HELM_MEMORY_DRIVER_DATA"), ":") + if len(filePaths) == 0 { + return + } + + store := actionConfig.Releases + mem, ok := store.Driver.(*driver.Memory) + if !ok { + // For an unexpected reason we are not dealing with the memory storage driver. + return + } + + actionConfig.KubeClient = &kubefake.PrintingKubeClient{Out: io.Discard} + + for _, path := range filePaths { + b, err := os.ReadFile(path) + if err != nil { + log.Fatal("Unable to read memory driver data", err) + } + + releases := []*release.Release{} + if err := yaml.Unmarshal(b, &releases); err != nil { + log.Fatal("Unable to unmarshal memory driver data: ", err) + } + + for _, rel := range releases { + if err := store.Create(rel); err != nil { + log.Fatal(err) + } + } + } + // Must reset namespace to the proper one + mem.SetNamespace(settings.Namespace()) +} diff --git a/cmd/helm/helm_test.go b/cmd/helm/helm_test.go new file mode 100644 index 00000000..b20b1a24 --- /dev/null +++ b/cmd/helm/helm_test.go @@ -0,0 +1,222 @@ +/* +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" + "io" + "os" + "os/exec" + "runtime" + "strings" + "testing" + + shellwords "github.com/mattn/go-shellwords" + "github.com/spf13/cobra" + + "helm.sh/helm/v3/internal/test" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/cli" + kubefake "helm.sh/helm/v3/pkg/kube/fake" + "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/storage" + "helm.sh/helm/v3/pkg/storage/driver" + "helm.sh/helm/v3/pkg/time" +) + +func testTimestamper() time.Time { return time.Unix(242085845, 0).UTC() } + +func init() { + action.Timestamper = testTimestamper +} + +func runTestCmd(t *testing.T, tests []cmdTestCase) { + t.Helper() + for _, tt := range tests { + for i := 0; i <= tt.repeat; i++ { + t.Run(tt.name, func(t *testing.T) { + defer resetEnv()() + + storage := storageFixture() + for _, rel := range tt.rels { + if err := storage.Create(rel); err != nil { + t.Fatal(err) + } + } + t.Logf("running cmd (attempt %d): %s", i+1, tt.cmd) + _, out, err := executeActionCommandC(storage, tt.cmd) + if tt.wantError && err == nil { + t.Errorf("expected error, got success with the following output:\n%s", out) + } + if !tt.wantError && err != nil { + t.Errorf("expected no error, got: '%v'", err) + } + if tt.golden != "" { + test.AssertGoldenString(t, out, tt.golden) + } + }) + } + } +} + +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 executeActionCommandStdinC(store *storage.Storage, in *os.File, cmd string) (*cobra.Command, string, error) { + args, err := shellwords.Parse(cmd) + if err != nil { + return nil, "", err + } + + buf := new(bytes.Buffer) + + actionConfig := &action.Configuration{ + Releases: store, + KubeClient: &kubefake.PrintingKubeClient{Out: io.Discard}, + Capabilities: chartutil.DefaultCapabilities, + Log: func(format string, v ...interface{}) {}, + } + + root, err := newRootCmd(actionConfig, buf, args) + if err != nil { + return nil, "", err + } + + root.SetOut(buf) + root.SetErr(buf) + root.SetArgs(args) + + oldStdin := os.Stdin + if in != nil { + root.SetIn(in) + os.Stdin = in + } + + if mem, ok := store.Driver.(*driver.Memory); ok { + mem.SetNamespace(settings.Namespace()) + } + c, err := root.ExecuteC() + + result := buf.String() + + os.Stdin = oldStdin + + return c, result, err +} + +// cmdTestCase describes a test case that works with releases. +type cmdTestCase struct { + name string + cmd string + golden string + wantError bool + // Rels are the available releases at the start of the test. + rels []*release.Release + // 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 +} + +func executeActionCommand(cmd string) (*cobra.Command, string, error) { + return executeActionCommandC(storageFixture(), cmd) +} + +func resetEnv() func() { + origEnv := os.Environ() + return func() { + os.Clearenv() + for _, pair := range origEnv { + kv := strings.SplitN(pair, "=", 2) + os.Setenv(kv[0], kv[1]) + } + settings = cli.New() + } +} + +func testChdir(t *testing.T, dir string) func() { + t.Helper() + old, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + return func() { os.Chdir(old) } +} + +func TestPluginExitCode(t *testing.T) { + if os.Getenv("RUN_MAIN_FOR_TESTING") == "1" { + os.Args = []string{"helm", "exitwith", "2"} + + // We DO call helm's main() here. So this looks like a normal `helm` process. + main() + + // As main calls os.Exit, we never reach this line. + // But the test called this block of code catches and verifies the exit code. + return + } + + // Currently, plugins assume a Linux subsystem. Skip the execution + // tests until this is fixed + if runtime.GOOS != "windows" { + // Do a second run of this specific test(TestPluginExitCode) with RUN_MAIN_FOR_TESTING=1 set, + // So that the second run is able to run main() and this first run can verify the exit status returned by that. + // + // This technique originates from https://talks.golang.org/2014/testing.slide#23. + cmd := exec.Command(os.Args[0], "-test.run=TestPluginExitCode") + cmd.Env = append( + os.Environ(), + "RUN_MAIN_FOR_TESTING=1", + // See pkg/cli/environment.go for which envvars can be used for configuring these passes + // and also see plugin_test.go for how a plugin env can be set up. + // We just does the same setup as plugin_test.go via envvars + "HELM_PLUGINS=testdata/helmhome/helm/plugins", + "HELM_REPOSITORY_CONFIG=testdata/helmhome/helm/repositories.yaml", + "HELM_REPOSITORY_CACHE=testdata/helmhome/helm/repository", + ) + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + cmd.Stdout = stdout + cmd.Stderr = stderr + err := cmd.Run() + exiterr, ok := err.(*exec.ExitError) + + if !ok { + t.Fatalf("Unexpected error returned by os.Exit: %T", err) + } + + if stdout.String() != "" { + t.Errorf("Expected no write to stdout: Got %q", stdout.String()) + } + + expectedStderr := "Error: plugin \"exitwith\" exited with error\n" + if stderr.String() != expectedStderr { + t.Errorf("Expected %q written to stderr: Got %q", expectedStderr, stderr.String()) + } + + if exiterr.ExitCode() != 2 { + t.Errorf("Expected exit code 2: Got %d", exiterr.ExitCode()) + } + } +} diff --git a/cmd/helm/history.go b/cmd/helm/history.go new file mode 100644 index 00000000..de8b13a9 --- /dev/null +++ b/cmd/helm/history.go @@ -0,0 +1,200 @@ +/* +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 ( + "fmt" + "io" + "strconv" + "time" + + "github.com/gosuri/uitable" + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/cli/output" + "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/releaseutil" + helmtime "helm.sh/helm/v3/pkg/time" +) + +var historyHelp = ` +History prints historical revisions for a given release. + +A default maximum of 256 revisions will be returned. Setting '--max' +configures the maximum length of the revision list returned. + +The historical release set is printed as a formatted table, e.g: + + $ helm history angry-bird + REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION + 1 Mon Oct 3 10:15:13 2016 superseded alpine-0.1.0 1.0 Initial install + 2 Mon Oct 3 10:15:13 2016 superseded alpine-0.1.0 1.0 Upgraded successfully + 3 Mon Oct 3 10:15:13 2016 superseded alpine-0.1.0 1.0 Rolled back to 2 + 4 Mon Oct 3 10:15:13 2016 deployed alpine-0.1.0 1.0 Upgraded successfully +` + +func newHistoryCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + client := action.NewHistory(cfg) + var outfmt output.Format + + cmd := &cobra.Command{ + Use: "history RELEASE_NAME", + Long: historyHelp, + Short: "fetch release history", + Aliases: []string{"hist"}, + Args: require.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return compListReleases(toComplete, args, cfg) + }, + RunE: func(cmd *cobra.Command, args []string) error { + history, err := getHistory(client, args[0]) + if err != nil { + return err + } + + return outfmt.Write(out, history) + }, + } + + f := cmd.Flags() + f.IntVar(&client.Max, "max", 256, "maximum number of revision to include in history") + bindOutputFlag(cmd, &outfmt) + + return cmd +} + +type releaseInfo struct { + Revision int `json:"revision"` + Updated helmtime.Time `json:"updated"` + Status string `json:"status"` + Chart string `json:"chart"` + AppVersion string `json:"app_version"` + Description string `json:"description"` +} + +type releaseHistory []releaseInfo + +func (r releaseHistory) WriteJSON(out io.Writer) error { + return output.EncodeJSON(out, r) +} + +func (r releaseHistory) WriteYAML(out io.Writer) error { + return output.EncodeYAML(out, r) +} + +func (r releaseHistory) WriteTable(out io.Writer) error { + tbl := uitable.New() + tbl.AddRow("REVISION", "UPDATED", "STATUS", "CHART", "APP VERSION", "DESCRIPTION") + for _, item := range r { + tbl.AddRow(item.Revision, item.Updated.Format(time.ANSIC), item.Status, item.Chart, item.AppVersion, item.Description) + } + return output.EncodeTable(out, tbl) +} + +func getHistory(client *action.History, name string) (releaseHistory, error) { + hist, err := client.Run(name) + if err != nil { + return nil, err + } + + releaseutil.Reverse(hist, releaseutil.SortByRevision) + + var rels []*release.Release + for i := 0; i < min(len(hist), client.Max); i++ { + rels = append(rels, hist[i]) + } + + if len(rels) == 0 { + return releaseHistory{}, nil + } + + releaseHistory := getReleaseHistory(rels) + + return releaseHistory, nil +} + +func getReleaseHistory(rls []*release.Release) (history releaseHistory) { + for i := len(rls) - 1; i >= 0; i-- { + r := rls[i] + c := formatChartname(r.Chart) + s := r.Info.Status.String() + v := r.Version + d := r.Info.Description + a := formatAppVersion(r.Chart) + + rInfo := releaseInfo{ + Revision: v, + Status: s, + Chart: c, + AppVersion: a, + Description: d, + } + if !r.Info.LastDeployed.IsZero() { + rInfo.Updated = r.Info.LastDeployed + + } + history = append(history, rInfo) + } + + return history +} + +func formatChartname(c *chart.Chart) string { + if c == nil || c.Metadata == nil { + // This is an edge case that has happened in prod, though we don't + // know how: https://github.com/helm/helm/issues/1347 + return "MISSING" + } + return fmt.Sprintf("%s-%s", c.Name(), c.Metadata.Version) +} + +func formatAppVersion(c *chart.Chart) string { + if c == nil || c.Metadata == nil { + // This is an edge case that has happened in prod, though we don't + // know how: https://github.com/helm/helm/issues/1347 + return "MISSING" + } + return c.AppVersion() +} + +func min(x, y int) int { + if x < y { + return x + } + return y +} + +func compListRevisions(_ string, cfg *action.Configuration, releaseName string) ([]string, cobra.ShellCompDirective) { + client := action.NewHistory(cfg) + + var revisions []string + if hist, err := client.Run(releaseName); err == nil { + for _, release := range hist { + appVersion := fmt.Sprintf("App: %s", release.Chart.Metadata.AppVersion) + chartDesc := fmt.Sprintf("Chart: %s-%s", release.Chart.Metadata.Name, release.Chart.Metadata.Version) + revisions = append(revisions, fmt.Sprintf("%s\t%s, %s", strconv.Itoa(release.Version), appVersion, chartDesc)) + } + return revisions, cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveError +} diff --git a/cmd/helm/history_test.go b/cmd/helm/history_test.go new file mode 100644 index 00000000..07f2d85d --- /dev/null +++ b/cmd/helm/history_test.go @@ -0,0 +1,124 @@ +/* +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 ( + "fmt" + "testing" + + "helm.sh/helm/v3/pkg/release" +) + +func TestHistoryCmd(t *testing.T) { + mk := func(name string, vers int, status release.Status) *release.Release { + return release.Mock(&release.MockReleaseOptions{ + Name: name, + Version: vers, + Status: status, + }) + } + + tests := []cmdTestCase{{ + name: "get history for release", + cmd: "history angry-bird", + rels: []*release.Release{ + mk("angry-bird", 4, release.StatusDeployed), + mk("angry-bird", 3, release.StatusSuperseded), + mk("angry-bird", 2, release.StatusSuperseded), + mk("angry-bird", 1, release.StatusSuperseded), + }, + golden: "output/history.txt", + }, { + name: "get history with max limit set", + cmd: "history angry-bird --max 2", + rels: []*release.Release{ + mk("angry-bird", 4, release.StatusDeployed), + mk("angry-bird", 3, release.StatusSuperseded), + }, + golden: "output/history-limit.txt", + }, { + name: "get history with yaml output format", + cmd: "history angry-bird --output yaml", + rels: []*release.Release{ + mk("angry-bird", 4, release.StatusDeployed), + mk("angry-bird", 3, release.StatusSuperseded), + }, + golden: "output/history.yaml", + }, { + name: "get history with json output format", + cmd: "history angry-bird --output json", + rels: []*release.Release{ + mk("angry-bird", 4, release.StatusDeployed), + mk("angry-bird", 3, release.StatusSuperseded), + }, + golden: "output/history.json", + }} + runTestCmd(t, tests) +} + +func TestHistoryOutputCompletion(t *testing.T) { + outputFlagCompletionTest(t, "history") +} + +func revisionFlagCompletionTest(t *testing.T, cmdName string) { + mk := func(name string, vers int, status release.Status) *release.Release { + return release.Mock(&release.MockReleaseOptions{ + Name: name, + Version: vers, + Status: status, + }) + } + + releases := []*release.Release{ + mk("musketeers", 11, release.StatusDeployed), + mk("musketeers", 10, release.StatusSuperseded), + mk("musketeers", 9, release.StatusSuperseded), + mk("musketeers", 8, release.StatusSuperseded), + } + + tests := []cmdTestCase{{ + name: "completion for revision flag", + cmd: fmt.Sprintf("__complete %s musketeers --revision ''", cmdName), + rels: releases, + golden: "output/revision-comp.txt", + }, { + name: "completion for revision flag, no filter", + cmd: fmt.Sprintf("__complete %s musketeers --revision 1", cmdName), + rels: releases, + golden: "output/revision-comp.txt", + }, { + name: "completion for revision flag with too few args", + cmd: fmt.Sprintf("__complete %s --revision ''", cmdName), + rels: releases, + golden: "output/revision-wrong-args-comp.txt", + }, { + name: "completion for revision flag with too many args", + cmd: fmt.Sprintf("__complete %s three musketeers --revision ''", cmdName), + rels: releases, + golden: "output/revision-wrong-args-comp.txt", + }} + runTestCmd(t, tests) +} + +func TestHistoryCompletion(t *testing.T) { + checkReleaseCompletion(t, "history", false) +} + +func TestHistoryFileCompletion(t *testing.T) { + checkFileCompletion(t, "history", false) + checkFileCompletion(t, "history myrelease", false) +} diff --git a/cmd/helm/install.go b/cmd/helm/install.go new file mode 100644 index 00000000..84ecc406 --- /dev/null +++ b/cmd/helm/install.go @@ -0,0 +1,350 @@ +/* +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 ( + "context" + "fmt" + "io" + "log" + "os" + "os/signal" + "syscall" + "time" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/cli/output" + "helm.sh/helm/v3/pkg/cli/values" + "helm.sh/helm/v3/pkg/downloader" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/release" +) + +const installDesc = ` +This command installs a chart archive. + +The install argument must be a chart reference, a path to a packaged chart, +a path to an unpacked chart directory or a URL. + +To override values in a chart, use either the '--values' flag and pass in a file +or use the '--set' flag and pass configuration from the command line, to force +a string value use '--set-string'. You can use '--set-file' to set individual +values from a file when the value itself is too long for the command line +or is dynamically generated. You can also use '--set-json' to set json values +(scalars/objects/arrays) from the command line. + + $ helm install -f myvalues.yaml myredis ./redis + +or + + $ helm install --set name=prod myredis ./redis + +or + + $ helm install --set-string long_int=1234567890 myredis ./redis + +or + + $ helm install --set-file my_script=dothings.sh myredis ./redis + +or + + $ helm install --set-json 'master.sidecars=[{"name":"sidecar","image":"myImage","imagePullPolicy":"Always","ports":[{"name":"portname","containerPort":1234}]}]' myredis ./redis + + +You can specify the '--values'/'-f' flag multiple times. The priority will be given to the +last (right-most) file specified. For example, if both myvalues.yaml and override.yaml +contained a key called 'Test', the value set in override.yaml would take precedence: + + $ helm install -f myvalues.yaml -f override.yaml myredis ./redis + +You can specify the '--set' flag multiple times. The priority will be given to the +last (right-most) set specified. For example, if both 'bar' and 'newbar' values are +set for a key called 'foo', the 'newbar' value would take precedence: + + $ helm install --set foo=bar --set foo=newbar myredis ./redis + +Similarly, in the following example 'foo' is set to '["four"]': + + $ helm install --set-json='foo=["one", "two", "three"]' --set-json='foo=["four"]' myredis ./redis + +And in the following example, 'foo' is set to '{"key1":"value1","key2":"bar"}': + + $ helm install --set-json='foo={"key1":"value1","key2":"value2"}' --set-json='foo.key2="bar"' myredis ./redis + +To check the generated manifests of a release without installing the chart, +the --debug and --dry-run flags can be combined. + +The --dry-run flag will output all generated chart manifests, including Secrets +which can contain sensitive values. Please carefully consider how and when this +flag is used. + +If --verify is set, the chart MUST have a provenance file, and the provenance +file MUST pass all verification steps. + +There are six different ways you can express the chart you want to install: + +1. By chart reference: helm install mymaria example/mariadb +2. By path to a packaged chart: helm install mynginx ./nginx-1.2.3.tgz +3. By path to an unpacked chart directory: helm install mynginx ./nginx +4. By absolute URL: helm install mynginx https://example.com/charts/nginx-1.2.3.tgz +5. By chart reference and repo url: helm install --repo https://example.com/charts/ mynginx nginx +6. By OCI registries: helm install mynginx --version 1.2.3 oci://example.com/charts/nginx + +CHART REFERENCES + +A chart reference is a convenient way of referencing a chart in a chart repository. + +When you use a chart reference with a repo prefix ('example/mariadb'), Helm will look in the local +configuration for a chart repository named 'example', and will then look for a +chart in that repository whose name is 'mariadb'. It will install the latest stable version of that chart +until you specify '--devel' flag to also include development version (alpha, beta, and release candidate releases), or +supply a version number with the '--version' flag. + +To see the list of chart repositories, use 'helm repo list'. To search for +charts in a repository, use 'helm search'. +` + +func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + client := action.NewInstall(cfg) + valueOpts := &values.Options{} + var outfmt output.Format + + cmd := &cobra.Command{ + Use: "install [NAME] [CHART]", + Short: "install a chart", + Long: installDesc, + Args: require.MinimumNArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return compInstall(args, toComplete, client) + }, + RunE: func(_ *cobra.Command, args []string) error { + registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile, + client.InsecureSkipTLSverify, client.PlainHTTP) + if err != nil { + return fmt.Errorf("missing registry client: %w", err) + } + client.SetRegistryClient(registryClient) + + // This is for the case where "" is specifically passed in as a + // value. When there is no value passed in NoOptDefVal will be used + // and it is set to client. See addInstallFlags. + if client.DryRunOption == "" { + client.DryRunOption = "none" + } + rel, err := runInstall(args, client, valueOpts, out) + if err != nil { + return errors.Wrap(err, "INSTALLATION FAILED") + } + + return outfmt.Write(out, &statusPrinter{rel, settings.Debug, false, false, false}) + }, + } + + addInstallFlags(cmd, cmd.Flags(), client, valueOpts) + bindOutputFlag(cmd, &outfmt) + bindPostRenderFlag(cmd, &client.PostRenderer) + + return cmd +} + +func addInstallFlags(cmd *cobra.Command, f *pflag.FlagSet, client *action.Install, valueOpts *values.Options) { + f.BoolVar(&client.CreateNamespace, "create-namespace", false, "create the release namespace if not present") + // --dry-run options with expected outcome: + // - Not set means no dry run and server is contacted. + // - Set with no value, a value of client, or a value of true and the server is not contacted + // - Set with a value of false, none, or false and the server is contacted + // The true/false part is meant to reflect some legacy behavior while none is equal to "". + f.StringVar(&client.DryRunOption, "dry-run", "", "simulate an install. If --dry-run is set with no option being specified or as '--dry-run=client', it will not attempt cluster connections. Setting '--dry-run=server' allows attempting cluster connections.") + f.Lookup("dry-run").NoOptDefVal = "client" + f.BoolVar(&client.Force, "force", false, "force resource updates through a replacement strategy") + f.BoolVar(&client.DisableHooks, "no-hooks", false, "prevent hooks from running during install") + f.BoolVar(&client.Replace, "replace", false, "re-use the given name, only if that name is a deleted release which remains in the history. This is unsafe in production") + f.DurationVar(&client.Timeout, "timeout", 300*time.Second, "time to wait for any individual Kubernetes operation (like Jobs for hooks)") + f.BoolVar(&client.Wait, "wait", false, "if set, will wait until all Pods, PVCs, Services, and minimum number of Pods of a Deployment, StatefulSet, or ReplicaSet are in a ready state before marking the release as successful. It will wait for as long as --timeout") + f.BoolVar(&client.WaitForJobs, "wait-for-jobs", false, "if set and --wait enabled, will wait until all Jobs have been completed before marking the release as successful. It will wait for as long as --timeout") + f.BoolVarP(&client.GenerateName, "generate-name", "g", false, "generate the name (and omit the NAME parameter)") + f.StringVar(&client.NameTemplate, "name-template", "", "specify template used to name the release") + f.StringVar(&client.Description, "description", "", "add a custom description") + f.BoolVar(&client.Devel, "devel", false, "use development versions, too. Equivalent to version '>0.0.0-0'. If --version is set, this is ignored") + f.BoolVar(&client.DependencyUpdate, "dependency-update", false, "update dependencies if they are missing before installing the chart") + f.BoolVar(&client.DisableOpenAPIValidation, "disable-openapi-validation", false, "if set, the installation process will not validate rendered templates against the Kubernetes OpenAPI Schema") + f.BoolVar(&client.Atomic, "atomic", false, "if set, the installation process deletes the installation on failure. The --wait flag will be set automatically if --atomic is used") + f.BoolVar(&client.SkipCRDs, "skip-crds", false, "if set, no CRDs will be installed. By default, CRDs are installed if not already present") + f.BoolVar(&client.SubNotes, "render-subchart-notes", false, "if set, render subchart notes along with the parent") + f.StringToStringVarP(&client.Labels, "labels", "l", nil, "Labels that would be added to release metadata. Should be divided by comma.") + f.BoolVar(&client.EnableDNS, "enable-dns", false, "enable DNS lookups when rendering templates") + addValueOptionsFlags(f, valueOpts) + addChartPathOptionsFlags(f, &client.ChartPathOptions) + + err := cmd.RegisterFlagCompletionFunc("version", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + requiredArgs := 2 + if client.GenerateName { + requiredArgs = 1 + } + if len(args) != requiredArgs { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return compVersionFlag(args[requiredArgs-1], toComplete) + }) + + if err != nil { + log.Fatal(err) + } +} + +func runInstall(args []string, client *action.Install, valueOpts *values.Options, out io.Writer) (*release.Release, error) { + debug("Original chart version: %q", client.Version) + if client.Version == "" && client.Devel { + debug("setting version to >0.0.0-0") + client.Version = ">0.0.0-0" + } + + name, chart, err := client.NameAndChart(args) + if err != nil { + return nil, err + } + client.ReleaseName = name + + cp, err := client.ChartPathOptions.LocateChart(chart, settings) + if err != nil { + return nil, err + } + + debug("CHART PATH: %s\n", cp) + + p := getter.All(settings) + vals, err := valueOpts.MergeValues(p) + if err != nil { + return nil, err + } + + // Check chart dependencies to make sure all are present in /charts + chartRequested, err := loader.Load(cp) + if err != nil { + return nil, err + } + + if err := checkIfInstallable(chartRequested); err != nil { + return nil, err + } + + if chartRequested.Metadata.Deprecated { + warning("This chart is deprecated") + } + + if req := chartRequested.Metadata.Dependencies; req != nil { + // If CheckDependencies returns an error, we have unfulfilled dependencies. + // As of Helm 2.4.0, this is treated as a stopping condition: + // https://github.com/helm/helm/issues/2209 + if err := action.CheckDependencies(chartRequested, req); err != nil { + err = errors.Wrap(err, "An error occurred while checking for chart dependencies. You may need to run `helm dependency build` to fetch missing dependencies") + if client.DependencyUpdate { + man := &downloader.Manager{ + Out: out, + ChartPath: cp, + Keyring: client.ChartPathOptions.Keyring, + SkipUpdate: false, + Getters: p, + RepositoryConfig: settings.RepositoryConfig, + RepositoryCache: settings.RepositoryCache, + Debug: settings.Debug, + RegistryClient: client.GetRegistryClient(), + } + if err := man.Update(); err != nil { + return nil, err + } + // Reload the chart with the updated Chart.lock file. + if chartRequested, err = loader.Load(cp); err != nil { + return nil, errors.Wrap(err, "failed reloading chart after repo update") + } + } else { + return nil, err + } + } + } + + client.Namespace = settings.Namespace() + + // Validate DryRunOption member is one of the allowed values + if err := validateDryRunOptionFlag(client.DryRunOption); err != nil { + return nil, err + } + + // Create context and prepare the handle of SIGTERM + ctx := context.Background() + ctx, cancel := context.WithCancel(ctx) + + // Set up channel on which to send signal notifications. + // We must use a buffered channel or risk missing the signal + // if we're not ready to receive when the signal is sent. + cSignal := make(chan os.Signal, 2) + signal.Notify(cSignal, os.Interrupt, syscall.SIGTERM) + go func() { + <-cSignal + fmt.Fprintf(out, "Release %s has been cancelled.\n", args[0]) + cancel() + }() + + return client.RunWithContext(ctx, chartRequested, vals) +} + +// checkIfInstallable validates if a chart can be installed +// +// Application chart type is only installable +func checkIfInstallable(ch *chart.Chart) error { + switch ch.Metadata.Type { + case "", "application": + return nil + } + return errors.Errorf("%s charts are not installable", ch.Metadata.Type) +} + +// Provide dynamic auto-completion for the install and template commands +func compInstall(args []string, toComplete string, client *action.Install) ([]string, cobra.ShellCompDirective) { + requiredArgs := 1 + if client.GenerateName { + requiredArgs = 0 + } + if len(args) == requiredArgs { + return compListCharts(toComplete, true) + } + return nil, cobra.ShellCompDirectiveNoFileComp +} + +func validateDryRunOptionFlag(dryRunOptionFlagValue string) error { + // Validate dry-run flag value with a set of allowed value + allowedDryRunValues := []string{"false", "true", "none", "client", "server"} + isAllowed := false + for _, v := range allowedDryRunValues { + if dryRunOptionFlagValue == v { + isAllowed = true + break + } + } + if !isAllowed { + return errors.New("Invalid dry-run flag. Flag must one of the following: false, true, none, client, server") + } + return nil +} diff --git a/cmd/helm/install_test.go b/cmd/helm/install_test.go new file mode 100644 index 00000000..b34d1455 --- /dev/null +++ b/cmd/helm/install_test.go @@ -0,0 +1,303 @@ +/* +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 ( + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "helm.sh/helm/v3/pkg/repo/repotest" +) + +func TestInstall(t *testing.T) { + srv, err := repotest.NewTempServerWithCleanup(t, "testdata/testcharts/*.tgz*") + if err != nil { + t.Fatal(err) + } + defer srv.Stop() + + srv.WithMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != "username" || password != "password" { + t.Errorf("Expected request to use basic auth and for username == 'username' and password == 'password', got '%v', '%s', '%s'", ok, username, password) + } + })) + + srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.FileServer(http.Dir(srv.Root())).ServeHTTP(w, r) + })) + defer srv2.Close() + + if err := srv.LinkIndices(); err != nil { + t.Fatal(err) + } + + repoFile := filepath.Join(srv.Root(), "repositories.yaml") + + tests := []cmdTestCase{ + // Install, base case + { + name: "basic install", + cmd: "install aeneas testdata/testcharts/empty --namespace default", + golden: "output/install.txt", + }, + + // Install, values from cli + { + name: "install with values", + cmd: "install virgil testdata/testcharts/alpine --set test.Name=bar", + golden: "output/install-with-values.txt", + }, + // Install, values from cli via multiple --set + { + name: "install with multiple values", + cmd: "install virgil testdata/testcharts/alpine --set test.Color=yellow --set test.Name=banana", + golden: "output/install-with-multiple-values.txt", + }, + // Install, values from yaml + { + name: "install with values file", + cmd: "install virgil testdata/testcharts/alpine -f testdata/testcharts/alpine/extra_values.yaml", + golden: "output/install-with-values-file.txt", + }, + // Install, no hooks + { + name: "install without hooks", + cmd: "install aeneas testdata/testcharts/alpine --no-hooks --set test.Name=hello", + golden: "output/install-no-hooks.txt", + }, + // Install, values from multiple yaml + { + name: "install with values", + cmd: "install virgil testdata/testcharts/alpine -f testdata/testcharts/alpine/extra_values.yaml -f testdata/testcharts/alpine/more_values.yaml", + golden: "output/install-with-multiple-values-files.txt", + }, + // Install, no charts + { + name: "install with no chart specified", + cmd: "install", + golden: "output/install-no-args.txt", + wantError: true, + }, + // Install, re-use name + { + name: "install and replace release", + cmd: "install aeneas testdata/testcharts/empty --replace", + golden: "output/install-and-replace.txt", + }, + // Install, with timeout + { + name: "install with a timeout", + cmd: "install foobar testdata/testcharts/empty --timeout 120s", + golden: "output/install-with-timeout.txt", + }, + // Install, with wait + { + name: "install with a wait", + cmd: "install apollo testdata/testcharts/empty --wait", + golden: "output/install-with-wait.txt", + }, + // Install, with wait-for-jobs + { + name: "install with wait-for-jobs", + cmd: "install apollo testdata/testcharts/empty --wait --wait-for-jobs", + golden: "output/install-with-wait-for-jobs.txt", + }, + // Install, using the name-template + { + name: "install with name-template", + cmd: "install testdata/testcharts/empty --name-template '{{ \"foobar\"}}'", + golden: "output/install-name-template.txt", + }, + // Install, perform chart verification along the way. + { + name: "install with verification, missing provenance", + cmd: "install bogus testdata/testcharts/compressedchart-0.1.0.tgz --verify --keyring testdata/helm-test-key.pub", + wantError: true, + }, + { + name: "install with verification, directory instead of file", + cmd: "install bogus testdata/testcharts/signtest --verify --keyring testdata/helm-test-key.pub", + wantError: true, + }, + { + name: "install with verification, valid", + cmd: "install signtest testdata/testcharts/signtest-0.1.0.tgz --verify --keyring testdata/helm-test-key.pub", + }, + // Install, chart with missing dependencies in /charts + { + name: "install chart with missing dependencies", + cmd: "install nodeps testdata/testcharts/chart-missing-deps", + wantError: true, + }, + // Install chart with update-dependency + { + name: "install chart with missing dependencies", + cmd: "install --dependency-update updeps testdata/testcharts/chart-with-subchart-update", + golden: "output/chart-with-subchart-update.txt", + }, + // Install, chart with bad dependencies in Chart.yaml in /charts + { + name: "install chart with bad dependencies in Chart.yaml", + cmd: "install badreq testdata/testcharts/chart-bad-requirements", + wantError: true, + }, + // Install, chart with library chart dependency + { + name: "install chart with library chart dependency", + cmd: "install withlibchartp testdata/testcharts/chart-with-lib-dep", + }, + // Install, library chart + { + name: "install library chart", + cmd: "install libchart testdata/testcharts/lib-chart", + wantError: true, + golden: "output/install-lib-chart.txt", + }, + // Install, chart with bad type + { + name: "install chart with bad type", + cmd: "install badtype testdata/testcharts/chart-bad-type", + wantError: true, + golden: "output/install-chart-bad-type.txt", + }, + // Install, values from yaml, schematized + { + name: "install with schema file", + cmd: "install schema testdata/testcharts/chart-with-schema", + golden: "output/schema.txt", + }, + // Install, values from yaml, schematized with errors + { + name: "install with schema file, with errors", + cmd: "install schema testdata/testcharts/chart-with-schema-negative", + wantError: true, + golden: "output/schema-negative.txt", + }, + // Install, values from yaml, extra values from yaml, schematized with errors + { + name: "install with schema file, extra values from yaml, with errors", + cmd: "install schema testdata/testcharts/chart-with-schema -f testdata/testcharts/chart-with-schema/extra-values.yaml", + wantError: true, + golden: "output/schema-negative.txt", + }, + // Install, values from yaml, extra values from cli, schematized with errors + { + name: "install with schema file, extra values from cli, with errors", + cmd: "install schema testdata/testcharts/chart-with-schema --set age=-5", + wantError: true, + golden: "output/schema-negative-cli.txt", + }, + // Install with subchart, values from yaml, schematized with errors + { + name: "install with schema file and schematized subchart, with errors", + cmd: "install schema testdata/testcharts/chart-with-schema-and-subchart", + wantError: true, + golden: "output/subchart-schema-negative.txt", + }, + // Install with subchart, values from yaml, extra values from cli, schematized with errors + { + name: "install with schema file and schematized subchart, extra values from cli", + cmd: "install schema testdata/testcharts/chart-with-schema-and-subchart --set lastname=doe --set subchart-with-schema.age=25", + golden: "output/subchart-schema-cli.txt", + }, + // Install with subchart, values from yaml, extra values from cli, schematized with errors + { + name: "install with schema file and schematized subchart, extra values from cli, with errors", + cmd: "install schema testdata/testcharts/chart-with-schema-and-subchart --set lastname=doe --set subchart-with-schema.age=-25", + wantError: true, + golden: "output/subchart-schema-cli-negative.txt", + }, + // Install deprecated chart + { + name: "install with warning about deprecated chart", + cmd: "install aeneas testdata/testcharts/deprecated --namespace default", + golden: "output/deprecated-chart.txt", + }, + // Install chart with only crds + { + name: "install chart with only crds", + cmd: "install crd-test testdata/testcharts/chart-with-only-crds --namespace default", + }, + // Verify the user/pass works + { + name: "basic install with credentials", + cmd: "install aeneas reqtest --namespace default --repo " + srv.URL() + " --username username --password password", + golden: "output/install.txt", + }, + { + name: "basic install with credentials", + cmd: "install aeneas reqtest --namespace default --repo " + srv2.URL + " --username username --password password --pass-credentials", + golden: "output/install.txt", + }, + { + name: "basic install with credentials and no repo", + cmd: fmt.Sprintf("install aeneas test/reqtest --username username --password password --repository-config %s --repository-cache %s", repoFile, srv.Root()), + golden: "output/install.txt", + }, + } + + runTestCmd(t, tests) +} + +func TestInstallOutputCompletion(t *testing.T) { + outputFlagCompletionTest(t, "install") +} + +func TestInstallVersionCompletion(t *testing.T) { + repoFile := "testdata/helmhome/helm/repositories.yaml" + repoCache := "testdata/helmhome/helm/repository" + + repoSetup := fmt.Sprintf("--repository-config %s --repository-cache %s", repoFile, repoCache) + + tests := []cmdTestCase{{ + name: "completion for install version flag with release name", + cmd: fmt.Sprintf("%s __complete install releasename testing/alpine --version ''", repoSetup), + golden: "output/version-comp.txt", + }, { + name: "completion for install version flag with generate-name", + cmd: fmt.Sprintf("%s __complete install --generate-name testing/alpine --version ''", repoSetup), + golden: "output/version-comp.txt", + }, { + name: "completion for install version flag, no filter", + cmd: fmt.Sprintf("%s __complete install releasename testing/alpine --version 0.3", repoSetup), + golden: "output/version-comp.txt", + }, { + name: "completion for install version flag too few args", + cmd: fmt.Sprintf("%s __complete install testing/alpine --version ''", repoSetup), + golden: "output/version-invalid-comp.txt", + }, { + name: "completion for install version flag too many args", + cmd: fmt.Sprintf("%s __complete install releasename testing/alpine badarg --version ''", repoSetup), + golden: "output/version-invalid-comp.txt", + }, { + name: "completion for install version flag invalid chart", + cmd: fmt.Sprintf("%s __complete install releasename invalid/invalid --version ''", repoSetup), + golden: "output/version-invalid-comp.txt", + }} + runTestCmd(t, tests) +} + +func TestInstallFileCompletion(t *testing.T) { + checkFileCompletion(t, "install", false) + checkFileCompletion(t, "install --generate-name", true) + checkFileCompletion(t, "install myname", true) + checkFileCompletion(t, "install myname mychart", false) +} diff --git a/cmd/helm/lint.go b/cmd/helm/lint.go new file mode 100644 index 00000000..46f3c0ef --- /dev/null +++ b/cmd/helm/lint.go @@ -0,0 +1,155 @@ +/* +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 ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/cli/values" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/lint/support" +) + +var longLintHelp = ` +This command takes a path to a chart and runs a series of tests to verify that +the chart is well-formed. + +If the linter encounters things that will cause the chart to fail installation, +it will emit [ERROR] messages. If it encounters issues that break with convention +or recommendation, it will emit [WARNING] messages. +` + +func newLintCmd(out io.Writer) *cobra.Command { + client := action.NewLint() + valueOpts := &values.Options{} + var kubeVersion string + + cmd := &cobra.Command{ + Use: "lint PATH", + Short: "examine a chart for possible issues", + Long: longLintHelp, + RunE: func(cmd *cobra.Command, args []string) error { + paths := []string{"."} + if len(args) > 0 { + paths = args + } + + if kubeVersion != "" { + parsedKubeVersion, err := chartutil.ParseKubeVersion(kubeVersion) + if err != nil { + return fmt.Errorf("invalid kube version '%s': %s", kubeVersion, err) + } + client.KubeVersion = parsedKubeVersion + } + + if client.WithSubcharts { + for _, p := range paths { + filepath.Walk(filepath.Join(p, "charts"), func(path string, info os.FileInfo, err error) error { + if info != nil { + if info.Name() == "Chart.yaml" { + paths = append(paths, filepath.Dir(path)) + } else if strings.HasSuffix(path, ".tgz") || strings.HasSuffix(path, ".tar.gz") { + paths = append(paths, path) + } + } + return nil + }) + } + } + + client.Namespace = settings.Namespace() + vals, err := valueOpts.MergeValues(getter.All(settings)) + if err != nil { + return err + } + + var message strings.Builder + failed := 0 + errorsOrWarnings := 0 + + for _, path := range paths { + result := client.Run([]string{path}, vals) + + // If there is no errors/warnings and quiet flag is set + // go to the next chart + hasWarningsOrErrors := action.HasWarningsOrErrors(result) + if hasWarningsOrErrors { + errorsOrWarnings++ + } + if client.Quiet && !hasWarningsOrErrors { + continue + } + + fmt.Fprintf(&message, "==> Linting %s\n", path) + + // All the Errors that are generated by a chart + // that failed a lint will be included in the + // results.Messages so we only need to print + // the Errors if there are no Messages. + if len(result.Messages) == 0 { + for _, err := range result.Errors { + fmt.Fprintf(&message, "Error %s\n", err) + } + } + + for _, msg := range result.Messages { + if !client.Quiet || msg.Severity > support.InfoSev { + fmt.Fprintf(&message, "%s\n", msg) + } + } + + if len(result.Errors) != 0 { + failed++ + } + + // Adding extra new line here to break up the + // results, stops this from being a big wall of + // text and makes it easier to follow. + fmt.Fprint(&message, "\n") + } + + fmt.Fprint(out, message.String()) + + summary := fmt.Sprintf("%d chart(s) linted, %d chart(s) failed", len(paths), failed) + if failed > 0 { + return errors.New(summary) + } + if !client.Quiet || errorsOrWarnings > 0 { + fmt.Fprintln(out, summary) + } + return nil + }, + } + + f := cmd.Flags() + f.BoolVar(&client.Strict, "strict", false, "fail on lint warnings") + f.BoolVar(&client.WithSubcharts, "with-subcharts", false, "lint dependent charts") + f.BoolVar(&client.Quiet, "quiet", false, "print only warnings and errors") + f.StringVar(&kubeVersion, "kube-version", "", "Kubernetes version used for capabilities and deprecation checks") + addValueOptionsFlags(f, valueOpts) + + return cmd +} diff --git a/cmd/helm/lint_test.go b/cmd/helm/lint_test.go new file mode 100644 index 00000000..166b69ba --- /dev/null +++ b/cmd/helm/lint_test.go @@ -0,0 +1,97 @@ +/* +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 ( + "fmt" + "testing" +) + +func TestLintCmdWithSubchartsFlag(t *testing.T) { + testChart := "testdata/testcharts/chart-with-bad-subcharts" + tests := []cmdTestCase{{ + name: "lint good chart with bad subcharts", + cmd: fmt.Sprintf("lint %s", testChart), + golden: "output/lint-chart-with-bad-subcharts.txt", + wantError: true, + }, { + name: "lint good chart with bad subcharts using --with-subcharts flag", + cmd: fmt.Sprintf("lint --with-subcharts %s", testChart), + golden: "output/lint-chart-with-bad-subcharts-with-subcharts.txt", + wantError: true, + }} + runTestCmd(t, tests) +} + +func TestLintCmdWithQuietFlag(t *testing.T) { + testChart1 := "testdata/testcharts/alpine" + testChart2 := "testdata/testcharts/chart-bad-requirements" + tests := []cmdTestCase{{ + name: "lint good chart using --quiet flag", + cmd: fmt.Sprintf("lint --quiet %s", testChart1), + golden: "output/lint-quiet.txt", + }, { + name: "lint two charts, one with error using --quiet flag", + cmd: fmt.Sprintf("lint --quiet %s %s", testChart1, testChart2), + golden: "output/lint-quiet-with-error.txt", + wantError: true, + }, { + name: "lint chart with warning using --quiet flag", + cmd: "lint --quiet testdata/testcharts/chart-with-only-crds", + golden: "output/lint-quiet-with-warning.txt", + }, { + name: "lint non-existent chart using --quiet flag", + cmd: "lint --quiet thischartdoesntexist/", + golden: "", + wantError: true, + }} + runTestCmd(t, tests) + +} + +func TestLintCmdWithKubeVersionFlag(t *testing.T) { + testChart := "testdata/testcharts/chart-with-deprecated-api" + tests := []cmdTestCase{{ + name: "lint chart with deprecated api version using kube version flag", + cmd: fmt.Sprintf("lint --kube-version 1.22.0 %s", testChart), + golden: "output/lint-chart-with-deprecated-api.txt", + wantError: false, + }, { + name: "lint chart with deprecated api version using kube version and strict flag", + cmd: fmt.Sprintf("lint --kube-version 1.22.0 --strict %s", testChart), + golden: "output/lint-chart-with-deprecated-api-strict.txt", + wantError: true, + }, { + // the test builds will use the default k8sVersionMinor const in deprecations.go and capabilities.go + // which is "20" + name: "lint chart with deprecated api version without kube version", + cmd: fmt.Sprintf("lint %s", testChart), + golden: "output/lint-chart-with-deprecated-api-old-k8s.txt", + wantError: false, + }, { + name: "lint chart with deprecated api version with older kube version", + cmd: fmt.Sprintf("lint --kube-version 1.21.0 --strict %s", testChart), + golden: "output/lint-chart-with-deprecated-api-old-k8s.txt", + wantError: false, + }} + runTestCmd(t, tests) +} + +func TestLintFileCompletion(t *testing.T) { + checkFileCompletion(t, "lint", true) + checkFileCompletion(t, "lint mypath", true) // Multiple paths can be given +} diff --git a/cmd/helm/list.go b/cmd/helm/list.go new file mode 100644 index 00000000..5ca3de18 --- /dev/null +++ b/cmd/helm/list.go @@ -0,0 +1,251 @@ +/* +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 ( + "fmt" + "io" + "os" + "strconv" + + "github.com/gosuri/uitable" + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/cli/output" + "helm.sh/helm/v3/pkg/release" +) + +var listHelp = ` +This command lists all of the releases for a specified namespace (uses current namespace context if namespace not specified). + +By default, it lists only releases that are deployed or failed. Flags like +'--uninstalled' and '--all' will alter this behavior. Such flags can be combined: +'--uninstalled --failed'. + +By default, items are sorted alphabetically. Use the '-d' flag to sort by +release date. + +If the --filter flag is provided, it will be treated as a filter. Filters are +regular expressions (Perl compatible) that are applied to the list of releases. +Only items that match the filter will be returned. + + $ helm list --filter 'ara[a-z]+' + NAME UPDATED CHART + maudlin-arachnid 2020-06-18 14:17:46.125134977 +0000 UTC alpine-0.1.0 + +If no results are found, 'helm list' will exit 0, but with no output (or in +the case of no '-q' flag, only headers). + +By default, up to 256 items may be returned. To limit this, use the '--max' flag. +Setting '--max' to 0 will not return all results. Rather, it will return the +server's default, which may be much higher than 256. Pairing the '--max' +flag with the '--offset' flag allows you to page through results. +` + +func newListCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + client := action.NewList(cfg) + var outfmt output.Format + + cmd := &cobra.Command{ + Use: "list", + Short: "list releases", + Long: listHelp, + Aliases: []string{"ls"}, + Args: require.NoArgs, + ValidArgsFunction: noCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + if client.AllNamespaces { + if err := cfg.Init(settings.RESTClientGetter(), "", os.Getenv("HELM_DRIVER"), debug); err != nil { + return err + } + } + client.SetStateMask() + + results, err := client.Run() + if err != nil { + return err + } + + if client.Short { + names := make([]string, 0, len(results)) + for _, res := range results { + names = append(names, res.Name) + } + + outputFlag := cmd.Flag("output") + + switch outputFlag.Value.String() { + case "json": + output.EncodeJSON(out, names) + return nil + case "yaml": + output.EncodeYAML(out, names) + return nil + case "table": + for _, res := range results { + fmt.Fprintln(out, res.Name) + } + return nil + } + } + + return outfmt.Write(out, newReleaseListWriter(results, client.TimeFormat, client.NoHeaders)) + }, + } + + f := cmd.Flags() + f.BoolVarP(&client.Short, "short", "q", false, "output short (quiet) listing format") + f.BoolVarP(&client.NoHeaders, "no-headers", "", false, "don't print headers when using the default output format") + f.StringVar(&client.TimeFormat, "time-format", "", `format time using golang time formatter. Example: --time-format "2006-01-02 15:04:05Z0700"`) + f.BoolVarP(&client.ByDate, "date", "d", false, "sort by release date") + f.BoolVarP(&client.SortReverse, "reverse", "r", false, "reverse the sort order") + f.BoolVarP(&client.All, "all", "a", false, "show all releases without any filter applied") + f.BoolVar(&client.Uninstalled, "uninstalled", false, "show uninstalled releases (if 'helm uninstall --keep-history' was used)") + f.BoolVar(&client.Superseded, "superseded", false, "show superseded releases") + f.BoolVar(&client.Uninstalling, "uninstalling", false, "show releases that are currently being uninstalled") + f.BoolVar(&client.Deployed, "deployed", false, "show deployed releases. If no other is specified, this will be automatically enabled") + f.BoolVar(&client.Failed, "failed", false, "show failed releases") + f.BoolVar(&client.Pending, "pending", false, "show pending releases") + f.BoolVarP(&client.AllNamespaces, "all-namespaces", "A", false, "list releases across all namespaces") + f.IntVarP(&client.Limit, "max", "m", 256, "maximum number of releases to fetch") + f.IntVar(&client.Offset, "offset", 0, "next release index in the list, used to offset from start value") + f.StringVarP(&client.Filter, "filter", "f", "", "a regular expression (Perl compatible). Any releases that match the expression will be included in the results") + f.StringVarP(&client.Selector, "selector", "l", "", "Selector (label query) to filter on, supports '=', '==', and '!='.(e.g. -l key1=value1,key2=value2). Works only for secret(default) and configmap storage backends.") + bindOutputFlag(cmd, &outfmt) + + return cmd +} + +type releaseElement struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Revision string `json:"revision"` + Updated string `json:"updated"` + Status string `json:"status"` + Chart string `json:"chart"` + AppVersion string `json:"app_version"` +} + +type releaseListWriter struct { + releases []releaseElement + noHeaders bool +} + +func newReleaseListWriter(releases []*release.Release, timeFormat string, noHeaders bool) *releaseListWriter { + // Initialize the array so no results returns an empty array instead of null + elements := make([]releaseElement, 0, len(releases)) + for _, r := range releases { + element := releaseElement{ + Name: r.Name, + Namespace: r.Namespace, + Revision: strconv.Itoa(r.Version), + Status: r.Info.Status.String(), + Chart: formatChartname(r.Chart), + AppVersion: formatAppVersion(r.Chart), + } + + t := "-" + if tspb := r.Info.LastDeployed; !tspb.IsZero() { + if timeFormat != "" { + t = tspb.Format(timeFormat) + } else { + t = tspb.String() + } + } + element.Updated = t + + elements = append(elements, element) + } + return &releaseListWriter{elements, noHeaders} +} + +func (r *releaseListWriter) WriteTable(out io.Writer) error { + table := uitable.New() + if !r.noHeaders { + table.AddRow("NAME", "NAMESPACE", "REVISION", "UPDATED", "STATUS", "CHART", "APP VERSION") + } + for _, r := range r.releases { + table.AddRow(r.Name, r.Namespace, r.Revision, r.Updated, r.Status, r.Chart, r.AppVersion) + } + return output.EncodeTable(out, table) +} + +func (r *releaseListWriter) WriteJSON(out io.Writer) error { + return output.EncodeJSON(out, r.releases) +} + +func (r *releaseListWriter) WriteYAML(out io.Writer) error { + return output.EncodeYAML(out, r.releases) +} + +// Returns all releases from 'releases', except those with names matching 'ignoredReleases' +func filterReleases(releases []*release.Release, ignoredReleaseNames []string) []*release.Release { + // if ignoredReleaseNames is nil, just return releases + if ignoredReleaseNames == nil { + return releases + } + + var filteredReleases []*release.Release + for _, rel := range releases { + found := false + for _, ignoredName := range ignoredReleaseNames { + if rel.Name == ignoredName { + found = true + break + } + } + if !found { + filteredReleases = append(filteredReleases, rel) + } + } + + return filteredReleases +} + +// Provide dynamic auto-completion for release names +func compListReleases(toComplete string, ignoredReleaseNames []string, cfg *action.Configuration) ([]string, cobra.ShellCompDirective) { + cobra.CompDebugln(fmt.Sprintf("compListReleases with toComplete %s", toComplete), settings.Debug) + + client := action.NewList(cfg) + client.All = true + client.Limit = 0 + // Do not filter so as to get the entire list of releases. + // This will allow zsh and fish to match completion choices + // on other criteria then prefix. For example: + // helm status ingress + // can match + // helm status nginx-ingress + // + // client.Filter = fmt.Sprintf("^%s", toComplete) + + client.SetStateMask() + releases, err := client.Run() + if err != nil { + return nil, cobra.ShellCompDirectiveDefault + } + + var choices []string + filteredReleases := filterReleases(releases, ignoredReleaseNames) + for _, rel := range filteredReleases { + choices = append(choices, + fmt.Sprintf("%s\t%s-%s -> %s", rel.Name, rel.Chart.Metadata.Name, rel.Chart.Metadata.Version, rel.Info.Status.String())) + } + + return choices, cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/helm/list_test.go b/cmd/helm/list_test.go new file mode 100644 index 00000000..97a1e284 --- /dev/null +++ b/cmd/helm/list_test.go @@ -0,0 +1,246 @@ +/* +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 ( + "testing" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/time" +) + +func TestListCmd(t *testing.T) { + defaultNamespace := "default" + + sampleTimeSeconds := int64(1452902400) + timestamp1 := time.Unix(sampleTimeSeconds+1, 0).UTC() + timestamp2 := time.Unix(sampleTimeSeconds+2, 0).UTC() + timestamp3 := time.Unix(sampleTimeSeconds+3, 0).UTC() + timestamp4 := time.Unix(sampleTimeSeconds+4, 0).UTC() + chartInfo := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "chickadee", + Version: "1.0.0", + AppVersion: "0.0.1", + }, + } + + releaseFixture := []*release.Release{ + { + Name: "starlord", + Version: 1, + Namespace: defaultNamespace, + Info: &release.Info{ + LastDeployed: timestamp1, + Status: release.StatusSuperseded, + }, + Chart: chartInfo, + }, + { + Name: "starlord", + Version: 2, + Namespace: defaultNamespace, + Info: &release.Info{ + LastDeployed: timestamp1, + Status: release.StatusDeployed, + }, + Chart: chartInfo, + }, + { + Name: "groot", + Version: 1, + Namespace: defaultNamespace, + Info: &release.Info{ + LastDeployed: timestamp1, + Status: release.StatusUninstalled, + }, + Chart: chartInfo, + }, + { + Name: "gamora", + Version: 1, + Namespace: defaultNamespace, + Info: &release.Info{ + LastDeployed: timestamp1, + Status: release.StatusSuperseded, + }, + Chart: chartInfo, + }, + { + Name: "rocket", + Version: 1, + Namespace: defaultNamespace, + Info: &release.Info{ + LastDeployed: timestamp2, + Status: release.StatusFailed, + }, + Chart: chartInfo, + }, + { + Name: "drax", + Version: 1, + Namespace: defaultNamespace, + Info: &release.Info{ + LastDeployed: timestamp1, + Status: release.StatusUninstalling, + }, + Chart: chartInfo, + }, + { + Name: "thanos", + Version: 1, + Namespace: defaultNamespace, + Info: &release.Info{ + LastDeployed: timestamp1, + Status: release.StatusPendingInstall, + }, + Chart: chartInfo, + }, + { + Name: "hummingbird", + Version: 1, + Namespace: defaultNamespace, + Info: &release.Info{ + LastDeployed: timestamp3, + Status: release.StatusDeployed, + }, + Chart: chartInfo, + }, + { + Name: "iguana", + Version: 2, + Namespace: defaultNamespace, + Info: &release.Info{ + LastDeployed: timestamp4, + Status: release.StatusDeployed, + }, + Chart: chartInfo, + }, + { + Name: "starlord", + Version: 2, + Namespace: "milano", + Info: &release.Info{ + LastDeployed: timestamp1, + Status: release.StatusDeployed, + }, + Chart: chartInfo, + }, + } + + tests := []cmdTestCase{{ + name: "list releases", + cmd: "list", + golden: "output/list.txt", + rels: releaseFixture, + }, { + name: "list without headers", + cmd: "list --no-headers", + golden: "output/list-no-headers.txt", + rels: releaseFixture, + }, { + name: "list all releases", + cmd: "list --all", + golden: "output/list-all.txt", + rels: releaseFixture, + }, { + name: "list releases sorted by release date", + cmd: "list --date", + golden: "output/list-date.txt", + rels: releaseFixture, + }, { + name: "list failed releases", + cmd: "list --failed", + golden: "output/list-failed.txt", + rels: releaseFixture, + }, { + name: "list filtered releases", + cmd: "list --filter='.*'", + golden: "output/list-filter.txt", + rels: releaseFixture, + }, { + name: "list releases, limited to one release", + cmd: "list --max 1", + golden: "output/list-max.txt", + rels: releaseFixture, + }, { + name: "list releases, offset by one", + cmd: "list --offset 1", + golden: "output/list-offset.txt", + rels: releaseFixture, + }, { + name: "list pending releases", + cmd: "list --pending", + golden: "output/list-pending.txt", + rels: releaseFixture, + }, { + name: "list releases in reverse order", + cmd: "list --reverse", + golden: "output/list-reverse.txt", + rels: releaseFixture, + }, { + name: "list releases sorted by reversed release date", + cmd: "list --date --reverse", + golden: "output/list-date-reversed.txt", + rels: releaseFixture, + }, { + name: "list releases in short output format", + cmd: "list --short", + golden: "output/list-short.txt", + rels: releaseFixture, + }, { + name: "list releases in short output format", + cmd: "list --short --output yaml", + golden: "output/list-short-yaml.txt", + rels: releaseFixture, + }, { + name: "list releases in short output format", + cmd: "list --short --output json", + golden: "output/list-short-json.txt", + rels: releaseFixture, + }, { + name: "list superseded releases", + cmd: "list --superseded", + golden: "output/list-superseded.txt", + rels: releaseFixture, + }, { + name: "list uninstalled releases", + cmd: "list --uninstalled", + golden: "output/list-uninstalled.txt", + rels: releaseFixture, + }, { + name: "list releases currently uninstalling", + cmd: "list --uninstalling", + golden: "output/list-uninstalling.txt", + rels: releaseFixture, + }, { + name: "list releases in another namespace", + cmd: "list -n milano", + golden: "output/list-namespace.txt", + rels: releaseFixture, + }} + runTestCmd(t, tests) +} + +func TestListOutputCompletion(t *testing.T) { + outputFlagCompletionTest(t, "list") +} + +func TestListFileCompletion(t *testing.T) { + checkFileCompletion(t, "list", false) +} diff --git a/cmd/helm/load_plugins.go b/cmd/helm/load_plugins.go new file mode 100644 index 00000000..001a084e --- /dev/null +++ b/cmd/helm/load_plugins.go @@ -0,0 +1,377 @@ +/* +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" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/pkg/plugin" +) + +const ( + pluginStaticCompletionFile = "completion.yaml" + pluginDynamicCompletionExecutable = "plugin.complete" +) + +type pluginError struct { + error + code int +} + +// loadPlugins loads plugins into the command list. +// +// This follows a different pattern than the other commands because it has +// to inspect its environment and then add commands to the base command +// as it finds them. +func loadPlugins(baseCmd *cobra.Command, out io.Writer) { + + // If HELM_NO_PLUGINS is set to 1, do not load plugins. + if os.Getenv("HELM_NO_PLUGINS") == "1" { + return + } + + found, err := plugin.FindPlugins(settings.PluginsDirectory) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to load plugins: %s\n", err) + return + } + + // Now we create commands for all of these. + for _, plug := range found { + plug := plug + md := plug.Metadata + if md.Usage == "" { + md.Usage = fmt.Sprintf("the %q plugin", md.Name) + } + + c := &cobra.Command{ + Use: md.Name, + Short: md.Usage, + Long: md.Description, + RunE: func(cmd *cobra.Command, args []string) error { + u, err := processParent(cmd, args) + if err != nil { + return err + } + + // Call setupEnv before PrepareCommand because + // PrepareCommand uses os.ExpandEnv and expects the + // setupEnv vars. + plugin.SetupPluginEnv(settings, md.Name, plug.Dir) + main, argv, prepCmdErr := plug.PrepareCommand(u) + if prepCmdErr != nil { + os.Stderr.WriteString(prepCmdErr.Error()) + return errors.Errorf("plugin %q exited with error", md.Name) + } + + return callPluginExecutable(md.Name, main, argv, out) + }, + // This passes all the flags to the subcommand. + DisableFlagParsing: true, + } + + // TODO: Make sure a command with this name does not already exist. + baseCmd.AddCommand(c) + + // For completion, we try to load more details about the plugins so as to allow for command and + // flag completion of the plugin itself. + // We only do this when necessary (for the "completion" and "__complete" commands) to avoid the + // risk of a rogue plugin affecting Helm's normal behavior. + subCmd, _, err := baseCmd.Find(os.Args[1:]) + if (err == nil && + ((subCmd.HasParent() && subCmd.Parent().Name() == "completion") || subCmd.Name() == cobra.ShellCompRequestCmd)) || + /* for the tests */ subCmd == baseCmd.Root() { + loadCompletionForPlugin(c, plug) + } + } +} + +func processParent(cmd *cobra.Command, args []string) ([]string, error) { + k, u := manuallyProcessArgs(args) + if err := cmd.Parent().ParseFlags(k); err != nil { + return nil, err + } + return u, nil +} + +// This function is used to setup the environment for the plugin and then +// call the executable specified by the parameter 'main' +func callPluginExecutable(pluginName string, main string, argv []string, out io.Writer) error { + env := os.Environ() + for k, v := range settings.EnvVars() { + env = append(env, fmt.Sprintf("%s=%s", k, v)) + } + + mainCmdExp := os.ExpandEnv(main) + prog := exec.Command(mainCmdExp, argv...) + prog.Env = env + prog.Stdin = os.Stdin + prog.Stdout = out + prog.Stderr = os.Stderr + if err := prog.Run(); err != nil { + if eerr, ok := err.(*exec.ExitError); ok { + os.Stderr.Write(eerr.Stderr) + status := eerr.Sys().(syscall.WaitStatus) + return pluginError{ + error: errors.Errorf("plugin %q exited with error", pluginName), + code: status.ExitStatus(), + } + } + return err + } + return nil +} + +// manuallyProcessArgs processes an arg array, removing special args. +// +// Returns two sets of args: known and unknown (in that order) +func manuallyProcessArgs(args []string) ([]string, []string) { + known := []string{} + unknown := []string{} + kvargs := []string{"--kube-context", "--namespace", "-n", "--kubeconfig", "--kube-apiserver", "--kube-token", "--kube-as-user", "--kube-as-group", "--kube-ca-file", "--registry-config", "--repository-cache", "--repository-config", "--insecure-skip-tls-verify", "--tls-server-name"} + knownArg := func(a string) bool { + for _, pre := range kvargs { + if strings.HasPrefix(a, pre+"=") { + return true + } + } + return false + } + + isKnown := func(v string) string { + for _, i := range kvargs { + if i == v { + return v + } + } + return "" + } + + for i := 0; i < len(args); i++ { + switch a := args[i]; a { + case "--debug": + known = append(known, a) + case isKnown(a): + known = append(known, a) + i++ + if i < len(args) { + known = append(known, args[i]) + } + default: + if knownArg(a) { + known = append(known, a) + continue + } + unknown = append(unknown, a) + } + } + return known, unknown +} + +// pluginCommand represents the optional completion.yaml file of a plugin +type pluginCommand struct { + Name string `json:"name"` + ValidArgs []string `json:"validArgs"` + Flags []string `json:"flags"` + Commands []pluginCommand `json:"commands"` +} + +// loadCompletionForPlugin will load and parse any completion.yaml provided by the plugin +// and add the dynamic completion hook to call the optional plugin.complete +func loadCompletionForPlugin(pluginCmd *cobra.Command, plugin *plugin.Plugin) { + // Parse the yaml file providing the plugin's sub-commands and flags + cmds, err := loadFile(strings.Join( + []string{plugin.Dir, pluginStaticCompletionFile}, string(filepath.Separator))) + + if err != nil { + // The file could be missing or invalid. No static completion for this plugin. + if settings.Debug { + log.Output(2, fmt.Sprintf("[info] %s\n", err.Error())) + } + // Continue to setup dynamic completion. + cmds = &pluginCommand{} + } + + // Preserve the Usage string specified for the plugin + cmds.Name = pluginCmd.Use + + addPluginCommands(plugin, pluginCmd, cmds) +} + +// addPluginCommands is a recursive method that adds each different level +// of sub-commands and flags for the plugins that have provided such information +func addPluginCommands(plugin *plugin.Plugin, baseCmd *cobra.Command, cmds *pluginCommand) { + if cmds == nil { + return + } + + if len(cmds.Name) == 0 { + // Missing name for a command + if settings.Debug { + log.Output(2, fmt.Sprintf("[info] sub-command name field missing for %s", baseCmd.CommandPath())) + } + return + } + + baseCmd.Use = cmds.Name + baseCmd.ValidArgs = cmds.ValidArgs + // Setup the same dynamic completion for each plugin sub-command. + // This is because if dynamic completion is triggered, there is a single executable + // to call (plugin.complete), so every sub-commands calls it in the same fashion. + if cmds.Commands == nil { + // Only setup dynamic completion if there are no sub-commands. This avoids + // calling plugin.complete at every completion, which greatly simplifies + // development of plugin.complete for plugin developers. + baseCmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return pluginDynamicComp(plugin, cmd, args, toComplete) + } + } + + // Create fake flags. + if len(cmds.Flags) > 0 { + // The flags can be created with any type, since we only need them for completion. + // pflag does not allow to create short flags without a corresponding long form + // so we look for all short flags and match them to any long flag. This will allow + // plugins to provide short flags without a long form. + // If there are more short-flags than long ones, we'll create an extra long flag with + // the same single letter as the short form. + shorts := []string{} + longs := []string{} + for _, flag := range cmds.Flags { + if len(flag) == 1 { + shorts = append(shorts, flag) + } else { + longs = append(longs, flag) + } + } + + f := baseCmd.Flags() + if len(longs) >= len(shorts) { + for i := range longs { + if i < len(shorts) { + f.BoolP(longs[i], shorts[i], false, "") + } else { + f.Bool(longs[i], false, "") + } + } + } else { + for i := range shorts { + if i < len(longs) { + f.BoolP(longs[i], shorts[i], false, "") + } else { + // Create a long flag with the same name as the short flag. + // Not a perfect solution, but its better than ignoring the extra short flags. + f.BoolP(shorts[i], shorts[i], false, "") + } + } + } + } + + // Recursively add any sub-commands + for _, cmd := range cmds.Commands { + // Create a fake command so that completion can be done for the sub-commands of the plugin + subCmd := &cobra.Command{ + // This prevents Cobra from removing the flags. We want to keep the flags to pass them + // to the dynamic completion script of the plugin. + DisableFlagParsing: true, + // A Run is required for it to be a valid command without subcommands + Run: func(cmd *cobra.Command, args []string) {}, + } + baseCmd.AddCommand(subCmd) + addPluginCommands(plugin, subCmd, &cmd) + } +} + +// loadFile takes a yaml file at the given path, parses it and returns a pluginCommand object +func loadFile(path string) (*pluginCommand, error) { + cmds := new(pluginCommand) + b, err := os.ReadFile(path) + if err != nil { + return cmds, fmt.Errorf("file (%s) not provided by plugin. No plugin auto-completion possible", path) + } + + err = yaml.Unmarshal(b, cmds) + return cmds, err +} + +// pluginDynamicComp call the plugin.complete script of the plugin (if available) +// to obtain the dynamic completion choices. It must pass all the flags and sub-commands +// specified in the command-line to the plugin.complete executable (except helm's global flags) +func pluginDynamicComp(plug *plugin.Plugin, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + md := plug.Metadata + + u, err := processParent(cmd, args) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + // We will call the dynamic completion script of the plugin + main := strings.Join([]string{plug.Dir, pluginDynamicCompletionExecutable}, string(filepath.Separator)) + + // We must include all sub-commands passed on the command-line. + // To do that, we pass-in the entire CommandPath, except the first two elements + // which are 'helm' and 'pluginName'. + argv := strings.Split(cmd.CommandPath(), " ")[2:] + if !md.IgnoreFlags { + argv = append(argv, u...) + argv = append(argv, toComplete) + } + plugin.SetupPluginEnv(settings, md.Name, plug.Dir) + + cobra.CompDebugln(fmt.Sprintf("calling %s with args %v", main, argv), settings.Debug) + buf := new(bytes.Buffer) + if err := callPluginExecutable(md.Name, main, argv, buf); err != nil { + // The dynamic completion file is optional for a plugin, so this error is ok. + cobra.CompDebugln(fmt.Sprintf("Unable to call %s: %v", main, err.Error()), settings.Debug) + return nil, cobra.ShellCompDirectiveDefault + } + + var completions []string + for _, comp := range strings.Split(buf.String(), "\n") { + // Remove any empty lines + if len(comp) > 0 { + completions = append(completions, comp) + } + } + + // Check if the last line of output is of the form :, which + // indicates the BashCompletionDirective. + directive := cobra.ShellCompDirectiveDefault + if len(completions) > 0 { + lastLine := completions[len(completions)-1] + if len(lastLine) > 1 && lastLine[0] == ':' { + if strInt, err := strconv.Atoi(lastLine[1:]); err == nil { + directive = cobra.ShellCompDirective(strInt) + completions = completions[:len(completions)-1] + } + } + } + + return completions, directive +} diff --git a/cmd/helm/package.go b/cmd/helm/package.go new file mode 100644 index 00000000..822d3d56 --- /dev/null +++ b/cmd/helm/package.go @@ -0,0 +1,124 @@ +/* +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 ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/cli/values" + "helm.sh/helm/v3/pkg/downloader" + "helm.sh/helm/v3/pkg/getter" +) + +const packageDesc = ` +This command packages a chart into a versioned chart archive file. If a path +is given, this will look at that path for a chart (which must contain a +Chart.yaml file) and then package that directory. + +Versioned chart archives are used by Helm package repositories. + +To sign a chart, use the '--sign' flag. In most cases, you should also +provide '--keyring path/to/secret/keys' and '--key keyname'. + + $ helm package --sign ./mychart --key mykey --keyring ~/.gnupg/secring.gpg + +If '--keyring' is not specified, Helm usually defaults to the public keyring +unless your environment is otherwise configured. +` + +func newPackageCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + client := action.NewPackage() + valueOpts := &values.Options{} + + cmd := &cobra.Command{ + Use: "package [CHART_PATH] [...]", + Short: "package a chart directory into a chart archive", + Long: packageDesc, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return errors.Errorf("need at least one argument, the path to the chart") + } + if client.Sign { + if client.Key == "" { + return errors.New("--key is required for signing a package") + } + if client.Keyring == "" { + return errors.New("--keyring is required for signing a package") + } + } + client.RepositoryConfig = settings.RepositoryConfig + client.RepositoryCache = settings.RepositoryCache + p := getter.All(settings) + vals, err := valueOpts.MergeValues(p) + if err != nil { + return err + } + + for i := 0; i < len(args); i++ { + path, err := filepath.Abs(args[i]) + if err != nil { + return err + } + if _, err := os.Stat(args[i]); err != nil { + return err + } + + if client.DependencyUpdate { + downloadManager := &downloader.Manager{ + Out: io.Discard, + ChartPath: path, + Keyring: client.Keyring, + Getters: p, + Debug: settings.Debug, + RegistryClient: cfg.RegistryClient, + RepositoryConfig: settings.RepositoryConfig, + RepositoryCache: settings.RepositoryCache, + } + + if err := downloadManager.Update(); err != nil { + return err + } + } + p, err := client.Run(path, vals) + if err != nil { + return err + } + fmt.Fprintf(out, "Successfully packaged chart and saved it to: %s\n", p) + } + return nil + }, + } + + f := cmd.Flags() + f.BoolVar(&client.Sign, "sign", false, "use a PGP private key to sign this package") + f.StringVar(&client.Key, "key", "", "name of the key to use when signing. Used if --sign is true") + f.StringVar(&client.Keyring, "keyring", defaultKeyring(), "location of a public keyring") + f.StringVar(&client.PassphraseFile, "passphrase-file", "", `location of a file which contains the passphrase for the signing key. Use "-" in order to read from stdin.`) + f.StringVar(&client.Version, "version", "", "set the version on the chart to this semver version") + f.StringVar(&client.AppVersion, "app-version", "", "set the appVersion on the chart to this version") + f.StringVarP(&client.Destination, "destination", "d", ".", "location to write the chart.") + f.BoolVarP(&client.DependencyUpdate, "dependency-update", "u", false, `update dependencies from "Chart.yaml" to dir "charts/" before packaging`) + + return cmd +} diff --git a/cmd/helm/package_test.go b/cmd/helm/package_test.go new file mode 100644 index 00000000..9093b510 --- /dev/null +++ b/cmd/helm/package_test.go @@ -0,0 +1,197 @@ +/* +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 ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" +) + +func TestPackage(t *testing.T) { + tests := []struct { + name string + flags map[string]string + args []string + expect string + hasfile string + err bool + }{ + { + name: "package without chart path", + args: []string{}, + flags: map[string]string{}, + expect: "need at least one argument, the path to the chart", + err: true, + }, + { + name: "package --sign, no --key", + args: []string{"testdata/testcharts/alpine"}, + flags: map[string]string{"sign": "1"}, + expect: "key is required for signing a package", + err: true, + }, + { + name: "package --sign, no --keyring", + args: []string{"testdata/testcharts/alpine"}, + flags: map[string]string{"sign": "1", "key": "nosuchkey", "keyring": ""}, + expect: "keyring is required for signing a package", + err: true, + }, + { + name: "package testdata/testcharts/alpine, no save", + args: []string{"testdata/testcharts/alpine"}, + flags: map[string]string{"save": "0"}, + expect: "", + hasfile: "alpine-0.1.0.tgz", + }, + { + name: "package testdata/testcharts/alpine", + args: []string{"testdata/testcharts/alpine"}, + expect: "", + hasfile: "alpine-0.1.0.tgz", + }, + { + name: "package testdata/testcharts/issue1979", + args: []string{"testdata/testcharts/issue1979"}, + expect: "", + hasfile: "alpine-0.1.0.tgz", + }, + { + name: "package --destination toot", + args: []string{"testdata/testcharts/alpine"}, + flags: map[string]string{"destination": "toot"}, + expect: "", + hasfile: "toot/alpine-0.1.0.tgz", + }, + { + name: "package --sign --key=KEY --keyring=KEYRING testdata/testcharts/alpine", + args: []string{"testdata/testcharts/alpine"}, + flags: map[string]string{"sign": "1", "keyring": "testdata/helm-test-key.secret", "key": "helm-test"}, + expect: "", + hasfile: "alpine-0.1.0.tgz", + }, + { + name: "package testdata/testcharts/chart-missing-deps", + args: []string{"testdata/testcharts/chart-missing-deps"}, + hasfile: "chart-missing-deps-0.1.0.tgz", + err: true, + }, + { + name: "package testdata/testcharts/chart-bad-type", + args: []string{"testdata/testcharts/chart-bad-type"}, + err: true, + }, + } + + origDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cachePath := t.TempDir() + defer testChdir(t, cachePath)() + + if err := os.MkdirAll("toot", 0777); err != nil { + t.Fatal(err) + } + + // This is an unfortunate byproduct of the tmpdir + if v, ok := tt.flags["keyring"]; ok && len(v) > 0 { + tt.flags["keyring"] = filepath.Join(origDir, v) + } + + re := regexp.MustCompile(tt.expect) + + adjustedArgs := make([]string, len(tt.args)) + for i, f := range tt.args { + adjustedArgs[i] = filepath.Join(origDir, f) + } + + cmd := []string{"package"} + if len(adjustedArgs) > 0 { + cmd = append(cmd, adjustedArgs...) + } + for k, v := range tt.flags { + if v != "0" { + cmd = append(cmd, fmt.Sprintf("--%s=%s", k, v)) + } + } + _, _, err = executeActionCommand(strings.Join(cmd, " ")) + if err != nil { + if tt.err && re.MatchString(err.Error()) { + return + } + t.Fatalf("%q: expected error %q, got %q", tt.name, tt.expect, err) + } + + if len(tt.hasfile) > 0 { + if fi, err := os.Stat(tt.hasfile); err != nil { + t.Errorf("%q: expected file %q, got err %q", tt.name, tt.hasfile, err) + } else if fi.Size() == 0 { + t.Errorf("%q: file %q has zero bytes.", tt.name, tt.hasfile) + } + } + + if v, ok := tt.flags["sign"]; ok && v == "1" { + if fi, err := os.Stat(tt.hasfile + ".prov"); err != nil { + t.Errorf("%q: expected provenance file", tt.name) + } else if fi.Size() == 0 { + t.Errorf("%q: provenance file is empty", tt.name) + } + } + }) + } +} + +func TestSetAppVersion(t *testing.T) { + var ch *chart.Chart + expectedAppVersion := "app-version-foo" + chartToPackage := "testdata/testcharts/alpine" + dir := t.TempDir() + cmd := fmt.Sprintf("package %s --destination=%s --app-version=%s", chartToPackage, dir, expectedAppVersion) + _, output, err := executeActionCommand(cmd) + if err != nil { + t.Logf("Output: %s", output) + t.Fatal(err) + } + chartPath := filepath.Join(dir, "alpine-0.1.0.tgz") + if fi, err := os.Stat(chartPath); err != nil { + t.Errorf("expected file %q, got err %q", chartPath, err) + } else if fi.Size() == 0 { + t.Errorf("file %q has zero bytes.", chartPath) + } + ch, err = loader.Load(chartPath) + if err != nil { + t.Fatalf("unexpected error loading packaged chart: %v", err) + } + if ch.Metadata.AppVersion != expectedAppVersion { + t.Errorf("expected app-version %q, found %q", expectedAppVersion, ch.Metadata.AppVersion) + } +} + +func TestPackageFileCompletion(t *testing.T) { + checkFileCompletion(t, "package", true) + checkFileCompletion(t, "package mypath", true) // Multiple paths can be given +} diff --git a/cmd/helm/plugin.go b/cmd/helm/plugin.go new file mode 100644 index 00000000..8e1044f5 --- /dev/null +++ b/cmd/helm/plugin.go @@ -0,0 +1,72 @@ +/* +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" + "os" + "os/exec" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "helm.sh/helm/v3/pkg/plugin" +) + +const pluginHelp = ` +Manage client-side Helm plugins. +` + +func newPluginCmd(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "plugin", + Short: "install, list, or uninstall Helm plugins", + Long: pluginHelp, + } + cmd.AddCommand( + newPluginInstallCmd(out), + newPluginListCmd(out), + newPluginUninstallCmd(out), + newPluginUpdateCmd(out), + ) + return cmd +} + +// runHook will execute a plugin hook. +func runHook(p *plugin.Plugin, event string) error { + hook := p.Metadata.Hooks[event] + if hook == "" { + return nil + } + + prog := exec.Command("sh", "-c", hook) + // TODO make this work on windows + // I think its ... ¯\_(ツ)_/¯ + // prog := exec.Command("cmd", "/C", p.Metadata.Hooks.Install()) + + debug("running %s hook: %s", event, prog) + + plugin.SetupPluginEnv(settings, p.Metadata.Name, p.Dir) + prog.Stdout, prog.Stderr = os.Stdout, os.Stderr + if err := prog.Run(); err != nil { + if eerr, ok := err.(*exec.ExitError); ok { + os.Stderr.Write(eerr.Stderr) + return errors.Errorf("plugin %s hook for %q exited with error", event, p.Metadata.Name) + } + return err + } + return nil +} diff --git a/cmd/helm/plugin_install.go b/cmd/helm/plugin_install.go new file mode 100644 index 00000000..4e8ee327 --- /dev/null +++ b/cmd/helm/plugin_install.go @@ -0,0 +1,94 @@ +/* +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 ( + "fmt" + "io" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/plugin" + "helm.sh/helm/v3/pkg/plugin/installer" +) + +type pluginInstallOptions struct { + source string + version string +} + +const pluginInstallDesc = ` +This command allows you to install a plugin from a url to a VCS repo or a local path. +` + +func newPluginInstallCmd(out io.Writer) *cobra.Command { + o := &pluginInstallOptions{} + cmd := &cobra.Command{ + Use: "install [options] ...", + Short: "install one or more Helm plugins", + Long: pluginInstallDesc, + Aliases: []string{"add"}, + Args: require.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + // We do file completion, in case the plugin is local + return nil, cobra.ShellCompDirectiveDefault + } + // No more completion once the plugin path has been specified + return nil, cobra.ShellCompDirectiveNoFileComp + }, + PreRunE: func(cmd *cobra.Command, args []string) error { + return o.complete(args) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(out) + }, + } + cmd.Flags().StringVar(&o.version, "version", "", "specify a version constraint. If this is not specified, the latest version is installed") + return cmd +} + +func (o *pluginInstallOptions) complete(args []string) error { + o.source = args[0] + return nil +} + +func (o *pluginInstallOptions) run(out io.Writer) error { + installer.Debug = settings.Debug + + i, err := installer.NewForSource(o.source, o.version) + if err != nil { + return err + } + if err := installer.Install(i); err != nil { + return err + } + + debug("loading plugin from %s", i.Path()) + p, err := plugin.LoadDir(i.Path()) + if err != nil { + return errors.Wrap(err, "plugin is installed but unusable") + } + + if err := runHook(p, plugin.Install); err != nil { + return err + } + + fmt.Fprintf(out, "Installed plugin: %s\n", p.Metadata.Name) + return nil +} diff --git a/cmd/helm/plugin_list.go b/cmd/helm/plugin_list.go new file mode 100644 index 00000000..fcd41519 --- /dev/null +++ b/cmd/helm/plugin_list.go @@ -0,0 +1,88 @@ +/* +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 ( + "fmt" + "io" + + "github.com/gosuri/uitable" + "github.com/spf13/cobra" + + "helm.sh/helm/v3/pkg/plugin" +) + +func newPluginListCmd(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "list installed Helm plugins", + ValidArgsFunction: noCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + debug("pluginDirs: %s", settings.PluginsDirectory) + plugins, err := plugin.FindPlugins(settings.PluginsDirectory) + if err != nil { + return err + } + + table := uitable.New() + table.AddRow("NAME", "VERSION", "DESCRIPTION") + for _, p := range plugins { + table.AddRow(p.Metadata.Name, p.Metadata.Version, p.Metadata.Description) + } + fmt.Fprintln(out, table) + return nil + }, + } + return cmd +} + +// Returns all plugins from plugins, except those with names matching ignoredPluginNames +func filterPlugins(plugins []*plugin.Plugin, ignoredPluginNames []string) []*plugin.Plugin { + // if ignoredPluginNames is nil, just return plugins + if ignoredPluginNames == nil { + return plugins + } + + var filteredPlugins []*plugin.Plugin + for _, plugin := range plugins { + found := false + for _, ignoredName := range ignoredPluginNames { + if plugin.Metadata.Name == ignoredName { + found = true + break + } + } + if !found { + filteredPlugins = append(filteredPlugins, plugin) + } + } + + return filteredPlugins +} + +// Provide dynamic auto-completion for plugin names +func compListPlugins(_ string, ignoredPluginNames []string) []string { + var pNames []string + plugins, err := plugin.FindPlugins(settings.PluginsDirectory) + if err == nil && len(plugins) > 0 { + filteredPlugins := filterPlugins(plugins, ignoredPluginNames) + for _, p := range filteredPlugins { + pNames = append(pNames, fmt.Sprintf("%s\t%s", p.Metadata.Name, p.Metadata.Usage)) + } + } + return pNames +} diff --git a/cmd/helm/plugin_test.go b/cmd/helm/plugin_test.go new file mode 100644 index 00000000..e13ad26f --- /dev/null +++ b/cmd/helm/plugin_test.go @@ -0,0 +1,453 @@ +/* +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" + "os" + "runtime" + "sort" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "helm.sh/helm/v3/pkg/release" +) + +func TestManuallyProcessArgs(t *testing.T) { + input := []string{ + "--debug", + "--foo", "bar", + "--kubeconfig=/home/foo", + "--kubeconfig", "/home/foo", + "--kube-context=test1", + "--kube-context", "test1", + "--kube-as-user", "pikachu", + "--kube-as-group", "teatime", + "--kube-as-group", "admins", + "-n=test2", + "-n", "test2", + "--namespace=test2", + "--namespace", "test2", + "--home=/tmp", + "command", + } + + expectKnown := []string{ + "--debug", + "--kubeconfig=/home/foo", + "--kubeconfig", "/home/foo", + "--kube-context=test1", + "--kube-context", "test1", + "--kube-as-user", "pikachu", + "--kube-as-group", "teatime", + "--kube-as-group", "admins", + "-n=test2", + "-n", "test2", + "--namespace=test2", + "--namespace", "test2", + } + + expectUnknown := []string{ + "--foo", "bar", "--home=/tmp", "command", + } + + known, unknown := manuallyProcessArgs(input) + + for i, k := range known { + if k != expectKnown[i] { + t.Errorf("expected known flag %d to be %q, got %q", i, expectKnown[i], k) + } + } + for i, k := range unknown { + if k != expectUnknown[i] { + t.Errorf("expected unknown flag %d to be %q, got %q", i, expectUnknown[i], k) + } + } + +} + +func TestLoadPlugins(t *testing.T) { + settings.PluginsDirectory = "testdata/helmhome/helm/plugins" + settings.RepositoryConfig = "testdata/helmhome/helm/repositories.yaml" + settings.RepositoryCache = "testdata/helmhome/helm/repository" + + var ( + out bytes.Buffer + cmd cobra.Command + ) + loadPlugins(&cmd, &out) + + envs := strings.Join([]string{ + "fullenv", + "testdata/helmhome/helm/plugins/fullenv", + "testdata/helmhome/helm/plugins", + "testdata/helmhome/helm/repositories.yaml", + "testdata/helmhome/helm/repository", + os.Args[0], + }, "\n") + + // Test that the YAML file was correctly converted to a command. + tests := []struct { + use string + short string + long string + expect string + args []string + code int + }{ + {"args", "echo args", "This echos args", "-a -b -c\n", []string{"-a", "-b", "-c"}, 0}, + {"echo", "echo stuff", "This echos stuff", "hello\n", []string{}, 0}, + {"env", "env stuff", "show the env", "env\n", []string{}, 0}, + {"exitwith", "exitwith code", "This exits with the specified exit code", "", []string{"2"}, 2}, + {"fullenv", "show env vars", "show all env vars", envs + "\n", []string{}, 0}, + } + + plugins := cmd.Commands() + + if len(plugins) != len(tests) { + t.Fatalf("Expected %d plugins, got %d", len(tests), len(plugins)) + } + + for i := 0; i < len(plugins); i++ { + out.Reset() + tt := tests[i] + pp := plugins[i] + if pp.Use != tt.use { + t.Errorf("%d: Expected Use=%q, got %q", i, tt.use, pp.Use) + } + if pp.Short != tt.short { + t.Errorf("%d: Expected Use=%q, got %q", i, tt.short, pp.Short) + } + if pp.Long != tt.long { + t.Errorf("%d: Expected Use=%q, got %q", i, tt.long, pp.Long) + } + + // Currently, plugins assume a Linux subsystem. Skip the execution + // tests until this is fixed + if runtime.GOOS != "windows" { + if err := pp.RunE(pp, tt.args); err != nil { + if tt.code > 0 { + perr, ok := err.(pluginError) + if !ok { + t.Errorf("Expected %s to return pluginError: got %v(%T)", tt.use, err, err) + } + if perr.code != tt.code { + t.Errorf("Expected %s to return %d: got %d", tt.use, tt.code, perr.code) + } + } else { + t.Errorf("Error running %s: %+v", tt.use, err) + } + } + if out.String() != tt.expect { + t.Errorf("Expected %s to output:\n%s\ngot\n%s", tt.use, tt.expect, out.String()) + } + } + } +} + +func TestLoadPluginsWithSpace(t *testing.T) { + settings.PluginsDirectory = "testdata/helm home with space/helm/plugins" + settings.RepositoryConfig = "testdata/helm home with space/helm/repositories.yaml" + settings.RepositoryCache = "testdata/helm home with space/helm/repository" + + var ( + out bytes.Buffer + cmd cobra.Command + ) + loadPlugins(&cmd, &out) + + envs := strings.Join([]string{ + "fullenv", + "testdata/helm home with space/helm/plugins/fullenv", + "testdata/helm home with space/helm/plugins", + "testdata/helm home with space/helm/repositories.yaml", + "testdata/helm home with space/helm/repository", + os.Args[0], + }, "\n") + + // Test that the YAML file was correctly converted to a command. + tests := []struct { + use string + short string + long string + expect string + args []string + code int + }{ + {"fullenv", "show env vars", "show all env vars", envs + "\n", []string{}, 0}, + } + + plugins := cmd.Commands() + + if len(plugins) != len(tests) { + t.Fatalf("Expected %d plugins, got %d", len(tests), len(plugins)) + } + + for i := 0; i < len(plugins); i++ { + out.Reset() + tt := tests[i] + pp := plugins[i] + if pp.Use != tt.use { + t.Errorf("%d: Expected Use=%q, got %q", i, tt.use, pp.Use) + } + if pp.Short != tt.short { + t.Errorf("%d: Expected Use=%q, got %q", i, tt.short, pp.Short) + } + if pp.Long != tt.long { + t.Errorf("%d: Expected Use=%q, got %q", i, tt.long, pp.Long) + } + + // Currently, plugins assume a Linux subsystem. Skip the execution + // tests until this is fixed + if runtime.GOOS != "windows" { + if err := pp.RunE(pp, tt.args); err != nil { + if tt.code > 0 { + perr, ok := err.(pluginError) + if !ok { + t.Errorf("Expected %s to return pluginError: got %v(%T)", tt.use, err, err) + } + if perr.code != tt.code { + t.Errorf("Expected %s to return %d: got %d", tt.use, tt.code, perr.code) + } + } else { + t.Errorf("Error running %s: %+v", tt.use, err) + } + } + if out.String() != tt.expect { + t.Errorf("Expected %s to output:\n%s\ngot\n%s", tt.use, tt.expect, out.String()) + } + } + } +} + +type staticCompletionDetails struct { + use string + validArgs []string + flags []string + next []staticCompletionDetails +} + +func TestLoadPluginsForCompletion(t *testing.T) { + settings.PluginsDirectory = "testdata/helmhome/helm/plugins" + + var out bytes.Buffer + + cmd := &cobra.Command{ + Use: "completion", + } + + loadPlugins(cmd, &out) + + tests := []staticCompletionDetails{ + {"args", []string{}, []string{}, []staticCompletionDetails{}}, + {"echo", []string{}, []string{}, []staticCompletionDetails{}}, + {"env", []string{}, []string{"global"}, []staticCompletionDetails{ + {"list", []string{}, []string{"a", "all", "log"}, []staticCompletionDetails{}}, + {"remove", []string{"all", "one"}, []string{}, []staticCompletionDetails{}}, + }}, + {"exitwith", []string{}, []string{}, []staticCompletionDetails{ + {"code", []string{}, []string{"a", "b"}, []staticCompletionDetails{}}, + }}, + {"fullenv", []string{}, []string{"q", "z"}, []staticCompletionDetails{ + {"empty", []string{}, []string{}, []staticCompletionDetails{}}, + {"full", []string{}, []string{}, []staticCompletionDetails{ + {"less", []string{}, []string{"a", "all"}, []staticCompletionDetails{}}, + {"more", []string{"one", "two"}, []string{"b", "ball"}, []staticCompletionDetails{}}, + }}, + }}, + } + checkCommand(t, cmd.Commands(), tests) +} + +func checkCommand(t *testing.T, plugins []*cobra.Command, tests []staticCompletionDetails) { + if len(plugins) != len(tests) { + t.Fatalf("Expected commands %v, got %v", tests, plugins) + } + + for i := 0; i < len(plugins); i++ { + pp := plugins[i] + tt := tests[i] + if pp.Use != tt.use { + t.Errorf("%s: Expected Use=%q, got %q", pp.Name(), tt.use, pp.Use) + } + + targs := tt.validArgs + pargs := pp.ValidArgs + if len(targs) != len(pargs) { + t.Fatalf("%s: expected args %v, got %v", pp.Name(), targs, pargs) + } + + sort.Strings(targs) + sort.Strings(pargs) + for j := range targs { + if targs[j] != pargs[j] { + t.Errorf("%s: expected validArg=%q, got %q", pp.Name(), targs[j], pargs[j]) + } + } + + tflags := tt.flags + var pflags []string + pp.LocalFlags().VisitAll(func(flag *pflag.Flag) { + pflags = append(pflags, flag.Name) + if len(flag.Shorthand) > 0 && flag.Shorthand != flag.Name { + pflags = append(pflags, flag.Shorthand) + } + }) + if len(tflags) != len(pflags) { + t.Fatalf("%s: expected flags %v, got %v", pp.Name(), tflags, pflags) + } + + sort.Strings(tflags) + sort.Strings(pflags) + for j := range tflags { + if tflags[j] != pflags[j] { + t.Errorf("%s: expected flag=%q, got %q", pp.Name(), tflags[j], pflags[j]) + } + } + // Check the next level + checkCommand(t, pp.Commands(), tt.next) + } +} + +func TestPluginDynamicCompletion(t *testing.T) { + + tests := []cmdTestCase{{ + name: "completion for plugin", + cmd: "__complete args ''", + golden: "output/plugin_args_comp.txt", + rels: []*release.Release{}, + }, { + name: "completion for plugin with flag", + cmd: "__complete args --myflag ''", + golden: "output/plugin_args_flag_comp.txt", + rels: []*release.Release{}, + }, { + name: "completion for plugin with global flag", + cmd: "__complete args --namespace mynamespace ''", + golden: "output/plugin_args_ns_comp.txt", + rels: []*release.Release{}, + }, { + name: "completion for plugin with multiple args", + cmd: "__complete args --myflag --namespace mynamespace start", + golden: "output/plugin_args_many_args_comp.txt", + rels: []*release.Release{}, + }, { + name: "completion for plugin no directive", + cmd: "__complete echo -n mynamespace ''", + golden: "output/plugin_echo_no_directive.txt", + rels: []*release.Release{}, + }} + for _, test := range tests { + settings.PluginsDirectory = "testdata/helmhome/helm/plugins" + runTestCmd(t, []cmdTestCase{test}) + } +} + +func TestLoadPlugins_HelmNoPlugins(t *testing.T) { + settings.PluginsDirectory = "testdata/helmhome/helm/plugins" + settings.RepositoryConfig = "testdata/helmhome/helm/repository" + + os.Setenv("HELM_NO_PLUGINS", "1") + + out := bytes.NewBuffer(nil) + cmd := &cobra.Command{} + loadPlugins(cmd, out) + plugins := cmd.Commands() + + if len(plugins) != 0 { + t.Fatalf("Expected 0 plugins, got %d", len(plugins)) + } +} + +func TestPluginCmdsCompletion(t *testing.T) { + + tests := []cmdTestCase{{ + name: "completion for plugin update", + cmd: "__complete plugin update ''", + golden: "output/plugin_list_comp.txt", + rels: []*release.Release{}, + }, { + name: "completion for plugin update, no filter", + cmd: "__complete plugin update full", + golden: "output/plugin_list_comp.txt", + rels: []*release.Release{}, + }, { + name: "completion for plugin update repetition", + cmd: "__complete plugin update args ''", + golden: "output/plugin_repeat_comp.txt", + rels: []*release.Release{}, + }, { + name: "completion for plugin uninstall", + cmd: "__complete plugin uninstall ''", + golden: "output/plugin_list_comp.txt", + rels: []*release.Release{}, + }, { + name: "completion for plugin uninstall, no filter", + cmd: "__complete plugin uninstall full", + golden: "output/plugin_list_comp.txt", + rels: []*release.Release{}, + }, { + name: "completion for plugin uninstall repetition", + cmd: "__complete plugin uninstall args ''", + golden: "output/plugin_repeat_comp.txt", + rels: []*release.Release{}, + }, { + name: "completion for plugin list", + cmd: "__complete plugin list ''", + golden: "output/empty_nofile_comp.txt", + rels: []*release.Release{}, + }, { + name: "completion for plugin install no args", + cmd: "__complete plugin install ''", + golden: "output/empty_default_comp.txt", + rels: []*release.Release{}, + }, { + name: "completion for plugin install one arg", + cmd: "__complete plugin list /tmp ''", + golden: "output/empty_nofile_comp.txt", + rels: []*release.Release{}, + }, {}} + for _, test := range tests { + settings.PluginsDirectory = "testdata/helmhome/helm/plugins" + runTestCmd(t, []cmdTestCase{test}) + } +} + +func TestPluginFileCompletion(t *testing.T) { + checkFileCompletion(t, "plugin", false) +} + +func TestPluginInstallFileCompletion(t *testing.T) { + checkFileCompletion(t, "plugin install", true) + checkFileCompletion(t, "plugin install mypath", false) +} + +func TestPluginListFileCompletion(t *testing.T) { + checkFileCompletion(t, "plugin list", false) +} + +func TestPluginUninstallFileCompletion(t *testing.T) { + checkFileCompletion(t, "plugin uninstall", false) + checkFileCompletion(t, "plugin uninstall myplugin", false) +} + +func TestPluginUpdateFileCompletion(t *testing.T) { + checkFileCompletion(t, "plugin update", false) + checkFileCompletion(t, "plugin update myplugin", false) +} diff --git a/cmd/helm/plugin_uninstall.go b/cmd/helm/plugin_uninstall.go new file mode 100644 index 00000000..ee4a47be --- /dev/null +++ b/cmd/helm/plugin_uninstall.go @@ -0,0 +1,100 @@ +/* +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 ( + "fmt" + "io" + "os" + "strings" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "helm.sh/helm/v3/pkg/plugin" +) + +type pluginUninstallOptions struct { + names []string +} + +func newPluginUninstallCmd(out io.Writer) *cobra.Command { + o := &pluginUninstallOptions{} + + cmd := &cobra.Command{ + Use: "uninstall ...", + Aliases: []string{"rm", "remove"}, + Short: "uninstall one or more Helm plugins", + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return compListPlugins(toComplete, args), cobra.ShellCompDirectiveNoFileComp + }, + PreRunE: func(cmd *cobra.Command, args []string) error { + return o.complete(args) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(out) + }, + } + return cmd +} + +func (o *pluginUninstallOptions) complete(args []string) error { + if len(args) == 0 { + return errors.New("please provide plugin name to uninstall") + } + o.names = args + return nil +} + +func (o *pluginUninstallOptions) run(out io.Writer) error { + debug("loading installed plugins from %s", settings.PluginsDirectory) + plugins, err := plugin.FindPlugins(settings.PluginsDirectory) + if err != nil { + return err + } + var errorPlugins []string + for _, name := range o.names { + if found := findPlugin(plugins, name); found != nil { + if err := uninstallPlugin(found); err != nil { + errorPlugins = append(errorPlugins, fmt.Sprintf("Failed to uninstall plugin %s, got error (%v)", name, err)) + } else { + fmt.Fprintf(out, "Uninstalled plugin: %s\n", name) + } + } else { + errorPlugins = append(errorPlugins, fmt.Sprintf("Plugin: %s not found", name)) + } + } + if len(errorPlugins) > 0 { + return errors.Errorf(strings.Join(errorPlugins, "\n")) + } + return nil +} + +func uninstallPlugin(p *plugin.Plugin) error { + if err := os.RemoveAll(p.Dir); err != nil { + return err + } + return runHook(p, plugin.Delete) +} + +func findPlugin(plugins []*plugin.Plugin, name string) *plugin.Plugin { + for _, p := range plugins { + if p.Metadata.Name == name { + return p + } + } + return nil +} diff --git a/cmd/helm/plugin_update.go b/cmd/helm/plugin_update.go new file mode 100644 index 00000000..4515acdb --- /dev/null +++ b/cmd/helm/plugin_update.go @@ -0,0 +1,114 @@ +/* +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 ( + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "helm.sh/helm/v3/pkg/plugin" + "helm.sh/helm/v3/pkg/plugin/installer" +) + +type pluginUpdateOptions struct { + names []string +} + +func newPluginUpdateCmd(out io.Writer) *cobra.Command { + o := &pluginUpdateOptions{} + + cmd := &cobra.Command{ + Use: "update ...", + Aliases: []string{"up"}, + Short: "update one or more Helm plugins", + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return compListPlugins(toComplete, args), cobra.ShellCompDirectiveNoFileComp + }, + PreRunE: func(cmd *cobra.Command, args []string) error { + return o.complete(args) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(out) + }, + } + return cmd +} + +func (o *pluginUpdateOptions) complete(args []string) error { + if len(args) == 0 { + return errors.New("please provide plugin name to update") + } + o.names = args + return nil +} + +func (o *pluginUpdateOptions) run(out io.Writer) error { + installer.Debug = settings.Debug + debug("loading installed plugins from %s", settings.PluginsDirectory) + plugins, err := plugin.FindPlugins(settings.PluginsDirectory) + if err != nil { + return err + } + var errorPlugins []string + + for _, name := range o.names { + if found := findPlugin(plugins, name); found != nil { + if err := updatePlugin(found); err != nil { + errorPlugins = append(errorPlugins, fmt.Sprintf("Failed to update plugin %s, got error (%v)", name, err)) + } else { + fmt.Fprintf(out, "Updated plugin: %s\n", name) + } + } else { + errorPlugins = append(errorPlugins, fmt.Sprintf("Plugin: %s not found", name)) + } + } + if len(errorPlugins) > 0 { + return errors.Errorf(strings.Join(errorPlugins, "\n")) + } + return nil +} + +func updatePlugin(p *plugin.Plugin) error { + exactLocation, err := filepath.EvalSymlinks(p.Dir) + if err != nil { + return err + } + absExactLocation, err := filepath.Abs(exactLocation) + if err != nil { + return err + } + + i, err := installer.FindSource(absExactLocation) + if err != nil { + return err + } + if err := installer.Update(i); err != nil { + return err + } + + debug("loading plugin from %s", i.Path()) + updatedPlugin, err := plugin.LoadDir(i.Path()) + if err != nil { + return err + } + + return runHook(updatedPlugin, plugin.Update) +} diff --git a/cmd/helm/printer.go b/cmd/helm/printer.go new file mode 100644 index 00000000..7cf7bf99 --- /dev/null +++ b/cmd/helm/printer.go @@ -0,0 +1,30 @@ +/* +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" + "text/template" +) + +func tpl(t string, vals map[string]interface{}, out io.Writer) error { + tt, err := template.New("_").Parse(t) + if err != nil { + return err + } + return tt.Execute(out, vals) +} diff --git a/cmd/helm/pull.go b/cmd/helm/pull.go new file mode 100644 index 00000000..af3092af --- /dev/null +++ b/cmd/helm/pull.go @@ -0,0 +1,105 @@ +/* +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 ( + "fmt" + "io" + "log" + + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/action" +) + +const pullDesc = ` +Retrieve a package from a package repository, and download it locally. + +This is useful for fetching packages to inspect, modify, or repackage. It can +also be used to perform cryptographic verification of a chart without installing +the chart. + +There are options for unpacking the chart after download. This will create a +directory for the chart and uncompress into that directory. + +If the --verify flag is specified, the requested chart MUST have a provenance +file, and MUST pass the verification process. Failure in any part of this will +result in an error, and the chart will not be saved locally. +` + +func newPullCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + client := action.NewPullWithOpts(action.WithConfig(cfg)) + + cmd := &cobra.Command{ + Use: "pull [chart URL | repo/chartname] [...]", + Short: "download a chart from a repository and (optionally) unpack it in local directory", + Aliases: []string{"fetch"}, + Long: pullDesc, + Args: require.MinimumNArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return compListCharts(toComplete, false) + }, + RunE: func(cmd *cobra.Command, args []string) error { + client.Settings = settings + if client.Version == "" && client.Devel { + debug("setting version to >0.0.0-0") + client.Version = ">0.0.0-0" + } + + registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile, + client.InsecureSkipTLSverify, client.PlainHTTP) + if err != nil { + return fmt.Errorf("missing registry client: %w", err) + } + client.SetRegistryClient(registryClient) + + for i := 0; i < len(args); i++ { + output, err := client.Run(args[i]) + if err != nil { + return err + } + fmt.Fprint(out, output) + } + return nil + }, + } + + f := cmd.Flags() + f.BoolVar(&client.Devel, "devel", false, "use development versions, too. Equivalent to version '>0.0.0-0'. If --version is set, this is ignored.") + f.BoolVar(&client.Untar, "untar", false, "if set to true, will untar the chart after downloading it") + f.BoolVar(&client.VerifyLater, "prov", false, "fetch the provenance file, but don't perform verification") + f.StringVar(&client.UntarDir, "untardir", ".", "if untar is specified, this flag specifies the name of the directory into which the chart is expanded") + f.StringVarP(&client.DestDir, "destination", "d", ".", "location to write the chart. If this and untardir are specified, untardir is appended to this") + addChartPathOptionsFlags(f, &client.ChartPathOptions) + + err := cmd.RegisterFlagCompletionFunc("version", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 1 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return compVersionFlag(args[0], toComplete) + }) + + if err != nil { + log.Fatal(err) + } + + return cmd +} diff --git a/cmd/helm/pull_test.go b/cmd/helm/pull_test.go new file mode 100644 index 00000000..41ac237f --- /dev/null +++ b/cmd/helm/pull_test.go @@ -0,0 +1,396 @@ +/* +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 ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "helm.sh/helm/v3/pkg/repo/repotest" +) + +func TestPullCmd(t *testing.T) { + srv, err := repotest.NewTempServerWithCleanup(t, "testdata/testcharts/*.tgz*") + if err != nil { + t.Fatal(err) + } + defer srv.Stop() + + ociSrv, err := repotest.NewOCIServer(t, srv.Root()) + if err != nil { + t.Fatal(err) + } + ociSrv.Run(t) + + if err := srv.LinkIndices(); err != nil { + t.Fatal(err) + } + + helmTestKeyOut := "Signed by: Helm Testing (This key should only be used for testing. DO NOT TRUST.) \n" + + "Using Key With Fingerprint: 5E615389B53CA37F0EE60BD3843BBF981FC18762\n" + + "Chart Hash Verified: " + + // all flags will get "-d outdir" appended. + tests := []struct { + name string + args string + existFile string + existDir string + wantError bool + wantErrorMsg string + failExpect string + expectFile string + expectDir bool + expectVerify bool + expectSha string + }{ + { + name: "Basic chart fetch", + args: "test/signtest", + expectFile: "./signtest-0.1.0.tgz", + }, + { + name: "Chart fetch with version", + args: "test/signtest --version=0.1.0", + expectFile: "./signtest-0.1.0.tgz", + }, + { + name: "Fail chart fetch with non-existent version", + args: "test/signtest --version=99.1.0", + wantError: true, + failExpect: "no such chart", + }, + { + name: "Fail fetching non-existent chart", + args: "test/nosuchthing", + failExpect: "Failed to fetch", + wantError: true, + }, + { + name: "Fetch and verify", + args: "test/signtest --verify --keyring testdata/helm-test-key.pub", + expectFile: "./signtest-0.1.0.tgz", + expectVerify: true, + expectSha: "sha256:e5ef611620fb97704d8751c16bab17fedb68883bfb0edc76f78a70e9173f9b55", + }, + { + name: "Fetch and fail verify", + args: "test/reqtest --verify --keyring testdata/helm-test-key.pub", + failExpect: "Failed to fetch provenance", + wantError: true, + }, + { + name: "Fetch and untar", + args: "test/signtest --untar --untardir signtest", + expectFile: "./signtest", + expectDir: true, + }, + { + name: "Fetch untar when file with same name existed", + args: "test/test1 --untar --untardir test1", + existFile: "test1", + wantError: true, + wantErrorMsg: fmt.Sprintf("failed to untar: a file or directory with the name %s already exists", filepath.Join(srv.Root(), "test1")), + }, + { + name: "Fetch untar when dir with same name existed", + args: "test/test2 --untar --untardir test2", + existDir: "test2", + wantError: true, + wantErrorMsg: fmt.Sprintf("failed to untar: a file or directory with the name %s already exists", filepath.Join(srv.Root(), "test2")), + }, + { + name: "Fetch, verify, untar", + args: "test/signtest --verify --keyring=testdata/helm-test-key.pub --untar --untardir signtest2", + expectFile: "./signtest2", + expectDir: true, + expectVerify: true, + expectSha: "sha256:e5ef611620fb97704d8751c16bab17fedb68883bfb0edc76f78a70e9173f9b55", + }, + { + name: "Chart fetch using repo URL", + expectFile: "./signtest-0.1.0.tgz", + args: "signtest --repo " + srv.URL(), + }, + { + name: "Fail fetching non-existent chart on repo URL", + args: "someChart --repo " + srv.URL(), + failExpect: "Failed to fetch chart", + wantError: true, + }, + { + name: "Specific version chart fetch using repo URL", + expectFile: "./signtest-0.1.0.tgz", + args: "signtest --version=0.1.0 --repo " + srv.URL(), + }, + { + name: "Specific version chart fetch using repo URL", + args: "signtest --version=0.2.0 --repo " + srv.URL(), + failExpect: "Failed to fetch chart version", + wantError: true, + }, + { + name: "Fetch OCI Chart", + args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart --version 0.1.0", ociSrv.RegistryURL), + expectFile: "./oci-dependent-chart-0.1.0.tgz", + }, + { + name: "Fetch OCI Chart with untar", + args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart --version 0.1.0 --untar", ociSrv.RegistryURL), + expectFile: "./oci-dependent-chart", + expectDir: true, + }, + { + name: "Fetch OCI Chart with untar and untardir", + args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart --version 0.1.0 --untar --untardir ocitest2", ociSrv.RegistryURL), + expectFile: "./ocitest2", + expectDir: true, + }, + { + name: "OCI Fetch untar when dir with same name existed", + args: fmt.Sprintf("oci-test-chart oci://%s/u/ocitestuser/oci-dependent-chart --version 0.1.0 --untar --untardir ocitest2 --untar --untardir ocitest2", ociSrv.RegistryURL), + wantError: true, + wantErrorMsg: fmt.Sprintf("failed to untar: a file or directory with the name %s already exists", filepath.Join(srv.Root(), "ocitest2")), + }, + { + name: "Fail fetching non-existent OCI chart", + args: fmt.Sprintf("oci://%s/u/ocitestuser/nosuchthing --version 0.1.0", ociSrv.RegistryURL), + failExpect: "Failed to fetch", + wantError: true, + }, + { + name: "Fail fetching OCI chart without version specified", + args: fmt.Sprintf("oci://%s/u/ocitestuser/nosuchthing", ociSrv.RegistryURL), + wantErrorMsg: "Error: --version flag is explicitly required for OCI registries", + wantError: true, + }, + { + name: "Fail fetching OCI chart without version specified", + args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart:0.1.0", ociSrv.RegistryURL), + wantErrorMsg: "Error: --version flag is explicitly required for OCI registries", + wantError: true, + }, + { + name: "Fail fetching OCI chart without version specified", + args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart:0.1.0 --version 0.1.0", ociSrv.RegistryURL), + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + outdir := srv.Root() + cmd := fmt.Sprintf("fetch %s -d '%s' --repository-config %s --repository-cache %s --registry-config %s", + tt.args, + outdir, + filepath.Join(outdir, "repositories.yaml"), + outdir, + filepath.Join(outdir, "config.json"), + ) + // Create file or Dir before helm pull --untar, see: https://github.com/helm/helm/issues/7182 + if tt.existFile != "" { + file := filepath.Join(outdir, tt.existFile) + _, err := os.Create(file) + if err != nil { + t.Fatal(err) + } + } + if tt.existDir != "" { + file := filepath.Join(outdir, tt.existDir) + err := os.Mkdir(file, 0755) + if err != nil { + t.Fatal(err) + } + } + _, out, err := executeActionCommand(cmd) + if err != nil { + if tt.wantError { + if tt.wantErrorMsg != "" && tt.wantErrorMsg == err.Error() { + t.Fatalf("Actual error %s, not equal to expected error %s", err, tt.wantErrorMsg) + } + return + } + t.Fatalf("%q reported error: %s", tt.name, err) + } + + if tt.expectVerify { + outString := helmTestKeyOut + tt.expectSha + "\n" + if out != outString { + t.Errorf("%q: expected verification output %q, got %q", tt.name, outString, out) + } + + } + + ef := filepath.Join(outdir, tt.expectFile) + fi, err := os.Stat(ef) + if err != nil { + t.Errorf("%q: expected a file at %s. %s", tt.name, ef, err) + } + if fi.IsDir() != tt.expectDir { + t.Errorf("%q: expected directory=%t, but it's not.", tt.name, tt.expectDir) + } + }) + } +} + +func TestPullWithCredentialsCmd(t *testing.T) { + srv, err := repotest.NewTempServerWithCleanup(t, "testdata/testcharts/*.tgz*") + if err != nil { + t.Fatal(err) + } + defer srv.Stop() + + srv.WithMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != "username" || password != "password" { + t.Errorf("Expected request to use basic auth and for username == 'username' and password == 'password', got '%v', '%s', '%s'", ok, username, password) + } + })) + + srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.FileServer(http.Dir(srv.Root())).ServeHTTP(w, r) + })) + defer srv2.Close() + + if err := srv.LinkIndices(); err != nil { + t.Fatal(err) + } + + // all flags will get "-d outdir" appended. + tests := []struct { + name string + args string + existFile string + existDir string + wantError bool + wantErrorMsg string + expectFile string + expectDir bool + }{ + { + name: "Chart fetch using repo URL", + expectFile: "./signtest-0.1.0.tgz", + args: "signtest --repo " + srv.URL() + " --username username --password password", + }, + { + name: "Fail fetching non-existent chart on repo URL", + args: "someChart --repo " + srv.URL() + " --username username --password password", + wantError: true, + }, + { + name: "Specific version chart fetch using repo URL", + expectFile: "./signtest-0.1.0.tgz", + args: "signtest --version=0.1.0 --repo " + srv.URL() + " --username username --password password", + }, + { + name: "Specific version chart fetch using repo URL", + args: "signtest --version=0.2.0 --repo " + srv.URL() + " --username username --password password", + wantError: true, + }, + { + name: "Chart located on different domain with credentials passed", + args: "reqtest --repo " + srv2.URL + " --username username --password password --pass-credentials", + expectFile: "./reqtest-0.1.0.tgz", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + outdir := srv.Root() + cmd := fmt.Sprintf("pull %s -d '%s' --repository-config %s --repository-cache %s --registry-config %s", + tt.args, + outdir, + filepath.Join(outdir, "repositories.yaml"), + outdir, + filepath.Join(outdir, "config.json"), + ) + // Create file or Dir before helm pull --untar, see: https://github.com/helm/helm/issues/7182 + if tt.existFile != "" { + file := filepath.Join(outdir, tt.existFile) + _, err := os.Create(file) + if err != nil { + t.Fatal(err) + } + } + if tt.existDir != "" { + file := filepath.Join(outdir, tt.existDir) + err := os.Mkdir(file, 0755) + if err != nil { + t.Fatal(err) + } + } + _, _, err := executeActionCommand(cmd) + if err != nil { + if tt.wantError { + if tt.wantErrorMsg != "" && tt.wantErrorMsg == err.Error() { + t.Fatalf("Actual error %s, not equal to expected error %s", err, tt.wantErrorMsg) + } + return + } + t.Fatalf("%q reported error: %s", tt.name, err) + } + + ef := filepath.Join(outdir, tt.expectFile) + fi, err := os.Stat(ef) + if err != nil { + t.Errorf("%q: expected a file at %s. %s", tt.name, ef, err) + } + if fi.IsDir() != tt.expectDir { + t.Errorf("%q: expected directory=%t, but it's not.", tt.name, tt.expectDir) + } + }) + } +} + +func TestPullVersionCompletion(t *testing.T) { + repoFile := "testdata/helmhome/helm/repositories.yaml" + repoCache := "testdata/helmhome/helm/repository" + + repoSetup := fmt.Sprintf("--repository-config %s --repository-cache %s", repoFile, repoCache) + + tests := []cmdTestCase{{ + name: "completion for pull version flag", + cmd: fmt.Sprintf("%s __complete pull testing/alpine --version ''", repoSetup), + golden: "output/version-comp.txt", + }, { + name: "completion for pull version flag, no filter", + cmd: fmt.Sprintf("%s __complete pull testing/alpine --version 0.3", repoSetup), + golden: "output/version-comp.txt", + }, { + name: "completion for pull version flag too few args", + cmd: fmt.Sprintf("%s __complete pull --version ''", repoSetup), + golden: "output/version-invalid-comp.txt", + }, { + name: "completion for pull version flag too many args", + cmd: fmt.Sprintf("%s __complete pull testing/alpine badarg --version ''", repoSetup), + golden: "output/version-invalid-comp.txt", + }, { + name: "completion for pull version flag invalid chart", + cmd: fmt.Sprintf("%s __complete pull invalid/invalid --version ''", repoSetup), + golden: "output/version-invalid-comp.txt", + }} + runTestCmd(t, tests) +} + +func TestPullFileCompletion(t *testing.T) { + checkFileCompletion(t, "pull", false) + checkFileCompletion(t, "pull repo/chart", false) +} diff --git a/cmd/helm/push.go b/cmd/helm/push.go new file mode 100644 index 00000000..3375155e --- /dev/null +++ b/cmd/helm/push.go @@ -0,0 +1,101 @@ +/* +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 ( + "fmt" + "io" + + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/pusher" +) + +const pushDesc = ` +Upload a chart to a registry. + +If the chart has an associated provenance file, +it will also be uploaded. +` + +type registryPushOptions struct { + certFile string + keyFile string + caFile string + insecureSkipTLSverify bool + plainHTTP bool +} + +func newPushCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + o := ®istryPushOptions{} + + cmd := &cobra.Command{ + Use: "push [chart] [remote]", + Short: "push a chart to remote", + Long: pushDesc, + Args: require.MinimumNArgs(2), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + // Do file completion for the chart file to push + return nil, cobra.ShellCompDirectiveDefault + } + if len(args) == 1 { + providers := []pusher.Provider(pusher.All(settings)) + var comps []string + for _, p := range providers { + for _, scheme := range p.Schemes { + comps = append(comps, fmt.Sprintf("%s://", scheme)) + } + } + return comps, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + RunE: func(cmd *cobra.Command, args []string) error { + registryClient, err := newRegistryClient(o.certFile, o.keyFile, o.caFile, o.insecureSkipTLSverify, o.plainHTTP) + if err != nil { + return fmt.Errorf("missing registry client: %w", err) + } + cfg.RegistryClient = registryClient + chartRef := args[0] + remote := args[1] + client := action.NewPushWithOpts(action.WithPushConfig(cfg), + action.WithTLSClientConfig(o.certFile, o.keyFile, o.caFile), + action.WithInsecureSkipTLSVerify(o.insecureSkipTLSverify), + action.WithPlainHTTP(o.plainHTTP), + action.WithPushOptWriter(out)) + client.Settings = settings + output, err := client.Run(chartRef, remote) + if err != nil { + return err + } + fmt.Fprint(out, output) + return nil + }, + } + + f := cmd.Flags() + f.StringVar(&o.certFile, "cert-file", "", "identify registry client using this SSL certificate file") + f.StringVar(&o.keyFile, "key-file", "", "identify registry client using this SSL key file") + f.StringVar(&o.caFile, "ca-file", "", "verify certificates of HTTPS-enabled servers using this CA bundle") + f.BoolVar(&o.insecureSkipTLSverify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the chart upload") + f.BoolVar(&o.plainHTTP, "plain-http", false, "use insecure HTTP connections for the chart upload") + + return cmd +} diff --git a/cmd/helm/push_test.go b/cmd/helm/push_test.go new file mode 100644 index 00000000..8e56d99d --- /dev/null +++ b/cmd/helm/push_test.go @@ -0,0 +1,27 @@ +/* +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 ( + "testing" +) + +func TestPushFileCompletion(t *testing.T) { + checkFileCompletion(t, "push", true) + checkFileCompletion(t, "push package.tgz", false) + checkFileCompletion(t, "push package.tgz oci://localhost:5000", false) +} diff --git a/cmd/helm/registry.go b/cmd/helm/registry.go new file mode 100644 index 00000000..b2b24cd1 --- /dev/null +++ b/cmd/helm/registry.go @@ -0,0 +1,41 @@ +/* +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/pkg/action" +) + +const registryHelp = ` +This command consists of multiple subcommands to interact with registries. +` + +func newRegistryCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "registry", + Short: "login to or logout from a registry", + Long: registryHelp, + } + cmd.AddCommand( + newRegistryLoginCmd(cfg, out), + newRegistryLogoutCmd(cfg, out), + ) + return cmd +} diff --git a/cmd/helm/registry_login.go b/cmd/helm/registry_login.go new file mode 100644 index 00000000..112e06a9 --- /dev/null +++ b/cmd/helm/registry_login.go @@ -0,0 +1,151 @@ +/* +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 ( + "bufio" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/moby/term" + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/action" +) + +const registryLoginDesc = ` +Authenticate to a remote registry. +` + +type registryLoginOptions struct { + username string + password string + passwordFromStdinOpt bool + certFile string + keyFile string + caFile string + insecure bool +} + +func newRegistryLoginCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + o := ®istryLoginOptions{} + + cmd := &cobra.Command{ + Use: "login [host]", + Short: "login to a registry", + Long: registryLoginDesc, + Args: require.MinimumNArgs(1), + ValidArgsFunction: noCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + hostname := args[0] + + username, password, err := getUsernamePassword(o.username, o.password, o.passwordFromStdinOpt) + if err != nil { + return err + } + + return action.NewRegistryLogin(cfg).Run(out, hostname, username, password, + action.WithCertFile(o.certFile), + action.WithKeyFile(o.keyFile), + action.WithCAFile(o.caFile), + action.WithInsecure(o.insecure)) + }, + } + + f := cmd.Flags() + f.StringVarP(&o.username, "username", "u", "", "registry username") + f.StringVarP(&o.password, "password", "p", "", "registry password or identity token") + f.BoolVarP(&o.passwordFromStdinOpt, "password-stdin", "", false, "read password or identity token from stdin") + f.BoolVarP(&o.insecure, "insecure", "", false, "allow connections to TLS registry without certs") + f.StringVar(&o.certFile, "cert-file", "", "identify registry client using this SSL certificate file") + f.StringVar(&o.keyFile, "key-file", "", "identify registry client using this SSL key file") + f.StringVar(&o.caFile, "ca-file", "", "verify certificates of HTTPS-enabled servers using this CA bundle") + + return cmd +} + +// Adapted from https://github.com/oras-project/oras +func getUsernamePassword(usernameOpt string, passwordOpt string, passwordFromStdinOpt bool) (string, string, error) { + var err error + username := usernameOpt + password := passwordOpt + + if passwordFromStdinOpt { + passwordFromStdin, err := io.ReadAll(os.Stdin) + if err != nil { + return "", "", err + } + password = strings.TrimSuffix(string(passwordFromStdin), "\n") + password = strings.TrimSuffix(password, "\r") + } else if password == "" { + if username == "" { + username, err = readLine("Username: ", false) + if err != nil { + return "", "", err + } + username = strings.TrimSpace(username) + } + if username == "" { + password, err = readLine("Token: ", true) + if err != nil { + return "", "", err + } else if password == "" { + return "", "", errors.New("token required") + } + } else { + password, err = readLine("Password: ", true) + if err != nil { + return "", "", err + } else if password == "" { + return "", "", errors.New("password required") + } + } + } else { + warning("Using --password via the CLI is insecure. Use --password-stdin.") + } + + return username, password, nil +} + +// Copied/adapted from https://github.com/oras-project/oras +func readLine(prompt string, silent bool) (string, error) { + fmt.Print(prompt) + if silent { + fd := os.Stdin.Fd() + state, err := term.SaveState(fd) + if err != nil { + return "", err + } + term.DisableEcho(fd, state) + defer term.RestoreTerminal(fd, state) + } + + reader := bufio.NewReader(os.Stdin) + line, _, err := reader.ReadLine() + if err != nil { + return "", err + } + if silent { + fmt.Println() + } + + return string(line), nil +} diff --git a/cmd/helm/registry_login_test.go b/cmd/helm/registry_login_test.go new file mode 100644 index 00000000..517fe08e --- /dev/null +++ b/cmd/helm/registry_login_test.go @@ -0,0 +1,25 @@ +/* +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 ( + "testing" +) + +func TestRegistryLoginFileCompletion(t *testing.T) { + checkFileCompletion(t, "registry login", false) +} diff --git a/cmd/helm/registry_logout.go b/cmd/helm/registry_logout.go new file mode 100644 index 00000000..0084f8c0 --- /dev/null +++ b/cmd/helm/registry_logout.go @@ -0,0 +1,44 @@ +/* +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" +) + +const registryLogoutDesc = ` +Remove credentials stored for a remote registry. +` + +func newRegistryLogoutCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + return &cobra.Command{ + Use: "logout [host]", + Short: "logout from a registry", + Long: registryLogoutDesc, + Args: require.MinimumNArgs(1), + ValidArgsFunction: noCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + hostname := args[0] + return action.NewRegistryLogout(cfg).Run(out, hostname) + }, + } +} diff --git a/cmd/helm/registry_logout_test.go b/cmd/helm/registry_logout_test.go new file mode 100644 index 00000000..31f71672 --- /dev/null +++ b/cmd/helm/registry_logout_test.go @@ -0,0 +1,25 @@ +/* +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 ( + "testing" +) + +func TestRegistryLogoutFileCompletion(t *testing.T) { + checkFileCompletion(t, "registry logout", false) +} diff --git a/cmd/helm/release_testing.go b/cmd/helm/release_testing.go new file mode 100644 index 00000000..548ae2b8 --- /dev/null +++ b/cmd/helm/release_testing.go @@ -0,0 +1,97 @@ +/* +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 ( + "fmt" + "io" + "regexp" + "strings" + "time" + + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/cli/output" +) + +const releaseTestHelp = ` +The test command runs the tests for a release. + +The argument this command takes is the name of a deployed release. +The tests to be run are defined in the chart that was installed. +` + +func newReleaseTestCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + client := action.NewReleaseTesting(cfg) + var outfmt = output.Table + var outputLogs bool + var filter []string + + cmd := &cobra.Command{ + Use: "test [RELEASE]", + Short: "run tests for a release", + Long: releaseTestHelp, + Args: require.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return compListReleases(toComplete, args, cfg) + }, + RunE: func(cmd *cobra.Command, args []string) error { + client.Namespace = settings.Namespace() + notName := regexp.MustCompile(`^!\s?name=`) + for _, f := range filter { + if strings.HasPrefix(f, "name=") { + client.Filters[action.IncludeNameFilter] = append(client.Filters[action.IncludeNameFilter], strings.TrimPrefix(f, "name=")) + } else if notName.MatchString(f) { + client.Filters[action.ExcludeNameFilter] = append(client.Filters[action.ExcludeNameFilter], notName.ReplaceAllLiteralString(f, "")) + } + } + rel, runErr := client.Run(args[0]) + // We only return an error if we weren't even able to get the + // release, otherwise we keep going so we can print status and logs + // if requested + if runErr != nil && rel == nil { + return runErr + } + + if err := outfmt.Write(out, &statusPrinter{rel, settings.Debug, false, false, false}); err != nil { + return err + } + + if outputLogs { + // Print a newline to stdout to separate the output + fmt.Fprintln(out) + if err := client.GetPodLogs(out, rel); err != nil { + return err + } + } + + return runErr + }, + } + + f := cmd.Flags() + f.DurationVar(&client.Timeout, "timeout", 300*time.Second, "time to wait for any individual Kubernetes operation (like Jobs for hooks)") + f.BoolVar(&outputLogs, "logs", false, "dump the logs from test pods (this runs after all tests are complete, but before any cleanup)") + f.StringSliceVar(&filter, "filter", []string{}, "specify tests by attribute (currently \"name\") using attribute=value syntax or '!attribute=value' to exclude a test (can specify multiple or separate values with commas: name=test1,name=test2)") + + return cmd +} diff --git a/cmd/helm/release_testing_test.go b/cmd/helm/release_testing_test.go new file mode 100644 index 00000000..680a9bd3 --- /dev/null +++ b/cmd/helm/release_testing_test.go @@ -0,0 +1,30 @@ +/* +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 ( + "testing" +) + +func TestReleaseTestingCompletion(t *testing.T) { + checkReleaseCompletion(t, "test", false) +} + +func TestReleaseTestingFileCompletion(t *testing.T) { + checkFileCompletion(t, "test", false) + checkFileCompletion(t, "test myrelease", false) +} diff --git a/cmd/helm/repo.go b/cmd/helm/repo.go new file mode 100644 index 00000000..ad6ceaa8 --- /dev/null +++ b/cmd/helm/repo.go @@ -0,0 +1,54 @@ +/* +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" + "os" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" +) + +var repoHelm = ` +This command consists of multiple subcommands to interact with chart repositories. + +It can be used to add, remove, list, and index chart repositories. +` + +func newRepoCmd(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "repo add|remove|list|index|update [ARGS]", + Short: "add, list, remove, update, and index chart repositories", + Long: repoHelm, + Args: require.NoArgs, + } + + cmd.AddCommand(newRepoAddCmd(out)) + cmd.AddCommand(newRepoListCmd(out)) + cmd.AddCommand(newRepoRemoveCmd(out)) + cmd.AddCommand(newRepoIndexCmd(out)) + cmd.AddCommand(newRepoUpdateCmd(out)) + + return cmd +} + +func isNotExist(err error) bool { + return os.IsNotExist(errors.Cause(err)) +} diff --git a/cmd/helm/repo_add.go b/cmd/helm/repo_add.go new file mode 100644 index 00000000..2deda3f4 --- /dev/null +++ b/cmd/helm/repo_add.go @@ -0,0 +1,220 @@ +/* +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 ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/gofrs/flock" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "golang.org/x/term" + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/repo" +) + +// Repositories that have been permanently deleted and no longer work +var deprecatedRepos = map[string]string{ + "//kubernetes-charts.storage.googleapis.com": "https://charts.helm.sh/stable", + "//kubernetes-charts-incubator.storage.googleapis.com": "https://charts.helm.sh/incubator", +} + +type repoAddOptions struct { + name string + url string + username string + password string + passwordFromStdinOpt bool + passCredentialsAll bool + forceUpdate bool + allowDeprecatedRepos bool + + certFile string + keyFile string + caFile string + insecureSkipTLSverify bool + + repoFile string + repoCache string + + // Deprecated, but cannot be removed until Helm 4 + deprecatedNoUpdate bool +} + +func newRepoAddCmd(out io.Writer) *cobra.Command { + o := &repoAddOptions{} + + cmd := &cobra.Command{ + Use: "add [NAME] [URL]", + Short: "add a chart repository", + Args: require.ExactArgs(2), + ValidArgsFunction: noCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + o.name = args[0] + o.url = args[1] + o.repoFile = settings.RepositoryConfig + o.repoCache = settings.RepositoryCache + + return o.run(out) + }, + } + + f := cmd.Flags() + f.StringVar(&o.username, "username", "", "chart repository username") + f.StringVar(&o.password, "password", "", "chart repository password") + f.BoolVarP(&o.passwordFromStdinOpt, "password-stdin", "", false, "read chart repository password from stdin") + f.BoolVar(&o.forceUpdate, "force-update", false, "replace (overwrite) the repo if it already exists") + f.BoolVar(&o.deprecatedNoUpdate, "no-update", false, "Ignored. Formerly, it would disabled forced updates. It is deprecated by force-update.") + f.StringVar(&o.certFile, "cert-file", "", "identify HTTPS client using this SSL certificate file") + f.StringVar(&o.keyFile, "key-file", "", "identify HTTPS client using this SSL key file") + f.StringVar(&o.caFile, "ca-file", "", "verify certificates of HTTPS-enabled servers using this CA bundle") + f.BoolVar(&o.insecureSkipTLSverify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the repository") + f.BoolVar(&o.allowDeprecatedRepos, "allow-deprecated-repos", false, "by default, this command will not allow adding official repos that have been permanently deleted. This disables that behavior") + f.BoolVar(&o.passCredentialsAll, "pass-credentials", false, "pass credentials to all domains") + + return cmd +} + +func (o *repoAddOptions) run(out io.Writer) error { + // Block deprecated repos + if !o.allowDeprecatedRepos { + for oldURL, newURL := range deprecatedRepos { + if strings.Contains(o.url, oldURL) { + return fmt.Errorf("repo %q is no longer available; try %q instead", o.url, newURL) + } + } + } + + // Ensure the file directory exists as it is required for file locking + err := os.MkdirAll(filepath.Dir(o.repoFile), os.ModePerm) + if err != nil && !os.IsExist(err) { + return err + } + + // Acquire a file lock for process synchronization + repoFileExt := filepath.Ext(o.repoFile) + var lockPath string + if len(repoFileExt) > 0 && len(repoFileExt) < len(o.repoFile) { + lockPath = strings.TrimSuffix(o.repoFile, repoFileExt) + ".lock" + } else { + lockPath = o.repoFile + ".lock" + } + fileLock := flock.New(lockPath) + lockCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + locked, err := fileLock.TryLockContext(lockCtx, time.Second) + if err == nil && locked { + defer fileLock.Unlock() + } + if err != nil { + return err + } + + b, err := os.ReadFile(o.repoFile) + if err != nil && !os.IsNotExist(err) { + return err + } + + var f repo.File + if err := yaml.Unmarshal(b, &f); err != nil { + return err + } + + if o.username != "" && o.password == "" { + if o.passwordFromStdinOpt { + passwordFromStdin, err := io.ReadAll(os.Stdin) + if err != nil { + return err + } + password := strings.TrimSuffix(string(passwordFromStdin), "\n") + password = strings.TrimSuffix(password, "\r") + o.password = password + } else { + fd := int(os.Stdin.Fd()) + fmt.Fprint(out, "Password: ") + password, err := term.ReadPassword(fd) + fmt.Fprintln(out) + if err != nil { + return err + } + o.password = string(password) + } + } + + c := repo.Entry{ + Name: o.name, + URL: o.url, + Username: o.username, + Password: o.password, + PassCredentialsAll: o.passCredentialsAll, + CertFile: o.certFile, + KeyFile: o.keyFile, + CAFile: o.caFile, + InsecureSkipTLSverify: o.insecureSkipTLSverify, + } + + // Check if the repo name is legal + if strings.Contains(o.name, "/") { + return errors.Errorf("repository name (%s) contains '/', please specify a different name without '/'", o.name) + } + + // If the repo exists do one of two things: + // 1. If the configuration for the name is the same continue without error + // 2. When the config is different require --force-update + if !o.forceUpdate && f.Has(o.name) { + existing := f.Get(o.name) + if c != *existing { + + // The input coming in for the name is different from what is already + // configured. Return an error. + return errors.Errorf("repository name (%s) already exists, please specify a different name", o.name) + } + + // The add is idempotent so do nothing + fmt.Fprintf(out, "%q already exists with the same configuration, skipping\n", o.name) + return nil + } + + r, err := repo.NewChartRepository(&c, getter.All(settings)) + if err != nil { + return err + } + + if o.repoCache != "" { + r.CachePath = o.repoCache + } + if _, err := r.DownloadIndexFile(); err != nil { + return errors.Wrapf(err, "looks like %q is not a valid chart repository or cannot be reached", o.url) + } + + f.Update(&c) + + if err := f.WriteFile(o.repoFile, 0600); err != nil { + return err + } + fmt.Fprintf(out, "%q has been added to your repositories\n", o.name) + return nil +} diff --git a/cmd/helm/repo_add_test.go b/cmd/helm/repo_add_test.go new file mode 100644 index 00000000..2386bb01 --- /dev/null +++ b/cmd/helm/repo_add_test.go @@ -0,0 +1,273 @@ +/* +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 ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/pkg/helmpath" + "helm.sh/helm/v3/pkg/helmpath/xdg" + "helm.sh/helm/v3/pkg/repo" + "helm.sh/helm/v3/pkg/repo/repotest" +) + +func TestRepoAddCmd(t *testing.T) { + srv, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") + if err != nil { + t.Fatal(err) + } + defer srv.Stop() + + // A second test server is setup to verify URL changing + srv2, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") + if err != nil { + t.Fatal(err) + } + defer srv2.Stop() + + tmpdir := filepath.Join(t.TempDir(), "path-component.yaml/data") + err = os.MkdirAll(tmpdir, 0777) + if err != nil { + t.Fatal(err) + } + repoFile := filepath.Join(tmpdir, "repositories.yaml") + + tests := []cmdTestCase{ + { + name: "add a repository", + cmd: fmt.Sprintf("repo add test-name %s --repository-config %s --repository-cache %s", srv.URL(), repoFile, tmpdir), + golden: "output/repo-add.txt", + }, + { + name: "add repository second time", + cmd: fmt.Sprintf("repo add test-name %s --repository-config %s --repository-cache %s", srv.URL(), repoFile, tmpdir), + golden: "output/repo-add2.txt", + }, + { + name: "add repository different url", + cmd: fmt.Sprintf("repo add test-name %s --repository-config %s --repository-cache %s", srv2.URL(), repoFile, tmpdir), + wantError: true, + }, + { + name: "add repository second time", + cmd: fmt.Sprintf("repo add test-name %s --repository-config %s --repository-cache %s --force-update", srv2.URL(), repoFile, tmpdir), + golden: "output/repo-add.txt", + }, + } + + runTestCmd(t, tests) +} + +func TestRepoAdd(t *testing.T) { + ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") + if err != nil { + t.Fatal(err) + } + defer ts.Stop() + + rootDir := t.TempDir() + repoFile := filepath.Join(rootDir, "repositories.yaml") + + const testRepoName = "test-name" + + o := &repoAddOptions{ + name: testRepoName, + url: ts.URL(), + forceUpdate: false, + deprecatedNoUpdate: true, + repoFile: repoFile, + } + os.Setenv(xdg.CacheHomeEnvVar, rootDir) + + if err := o.run(io.Discard); err != nil { + t.Error(err) + } + + f, err := repo.LoadFile(repoFile) + if err != nil { + t.Fatal(err) + } + + if !f.Has(testRepoName) { + t.Errorf("%s was not successfully inserted into %s", testRepoName, repoFile) + } + + idx := filepath.Join(helmpath.CachePath("repository"), helmpath.CacheIndexFile(testRepoName)) + if _, err := os.Stat(idx); os.IsNotExist(err) { + t.Errorf("Error cache index file was not created for repository %s", testRepoName) + } + idx = filepath.Join(helmpath.CachePath("repository"), helmpath.CacheChartsFile(testRepoName)) + if _, err := os.Stat(idx); os.IsNotExist(err) { + t.Errorf("Error cache charts file was not created for repository %s", testRepoName) + } + + o.forceUpdate = true + + if err := o.run(io.Discard); err != nil { + t.Errorf("Repository was not updated: %s", err) + } + + if err := o.run(io.Discard); err != nil { + t.Errorf("Duplicate repository name was added") + } +} + +func TestRepoAddCheckLegalName(t *testing.T) { + ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") + if err != nil { + t.Fatal(err) + } + defer ts.Stop() + defer resetEnv()() + + const testRepoName = "test-hub/test-name" + + rootDir := t.TempDir() + repoFile := filepath.Join(t.TempDir(), "repositories.yaml") + + o := &repoAddOptions{ + name: testRepoName, + url: ts.URL(), + forceUpdate: false, + deprecatedNoUpdate: true, + repoFile: repoFile, + } + os.Setenv(xdg.CacheHomeEnvVar, rootDir) + + wantErrorMsg := fmt.Sprintf("repository name (%s) contains '/', please specify a different name without '/'", testRepoName) + + if err := o.run(io.Discard); err != nil { + if wantErrorMsg != err.Error() { + t.Fatalf("Actual error %s, not equal to expected error %s", err, wantErrorMsg) + } + } else { + t.Fatalf("expect reported an error.") + } +} + +func TestRepoAddConcurrentGoRoutines(t *testing.T) { + const testName = "test-name" + repoFile := filepath.Join(t.TempDir(), "repositories.yaml") + repoAddConcurrent(t, testName, repoFile) +} + +func TestRepoAddConcurrentDirNotExist(t *testing.T) { + const testName = "test-name-2" + repoFile := filepath.Join(t.TempDir(), "foo", "repositories.yaml") + repoAddConcurrent(t, testName, repoFile) +} + +func TestRepoAddConcurrentNoFileExtension(t *testing.T) { + const testName = "test-name-3" + repoFile := filepath.Join(t.TempDir(), "repositories") + repoAddConcurrent(t, testName, repoFile) +} + +func TestRepoAddConcurrentHiddenFile(t *testing.T) { + const testName = "test-name-4" + repoFile := filepath.Join(t.TempDir(), ".repositories") + repoAddConcurrent(t, testName, repoFile) +} + +func repoAddConcurrent(t *testing.T, testName, repoFile string) { + ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") + if err != nil { + t.Fatal(err) + } + defer ts.Stop() + + var wg sync.WaitGroup + wg.Add(3) + for i := 0; i < 3; i++ { + go func(name string) { + defer wg.Done() + o := &repoAddOptions{ + name: name, + url: ts.URL(), + deprecatedNoUpdate: true, + forceUpdate: false, + repoFile: repoFile, + } + if err := o.run(io.Discard); err != nil { + t.Error(err) + } + }(fmt.Sprintf("%s-%d", testName, i)) + } + wg.Wait() + + b, err := os.ReadFile(repoFile) + if err != nil { + t.Error(err) + } + + var f repo.File + if err := yaml.Unmarshal(b, &f); err != nil { + t.Error(err) + } + + var name string + for i := 0; i < 3; i++ { + name = fmt.Sprintf("%s-%d", testName, i) + if !f.Has(name) { + t.Errorf("%s was not successfully inserted into %s: %s", name, repoFile, f.Repositories[0]) + } + } +} + +func TestRepoAddFileCompletion(t *testing.T) { + checkFileCompletion(t, "repo add", false) + checkFileCompletion(t, "repo add reponame", false) + checkFileCompletion(t, "repo add reponame https://example.com", false) +} + +func TestRepoAddWithPasswordFromStdin(t *testing.T) { + srv := repotest.NewTempServerWithCleanupAndBasicAuth(t, "testdata/testserver/*.*") + defer srv.Stop() + + defer resetEnv()() + + in, err := os.Open("testdata/password") + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + + tmpdir := t.TempDir() + repoFile := filepath.Join(tmpdir, "repositories.yaml") + + store := storageFixture() + + const testName = "test-name" + 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) + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + + if !strings.Contains(result, fmt.Sprintf("\"%s\" has been added to your repositories", testName)) { + t.Errorf("Repo was not successfully added. Output: %s", result) + } +} diff --git a/cmd/helm/repo_index.go b/cmd/helm/repo_index.go new file mode 100644 index 00000000..d947aca6 --- /dev/null +++ b/cmd/helm/repo_index.go @@ -0,0 +1,118 @@ +/* +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" + "os" + "path/filepath" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/repo" +) + +const repoIndexDesc = ` +Read the current directory and generate an index file based on the charts found. + +This tool is used for creating an 'index.yaml' file for a chart repository. To +set an absolute URL to the charts, use '--url' flag. + +To merge the generated index with an existing index file, use the '--merge' +flag. In this case, the charts found in the current directory will be merged +into the existing index, with local charts taking priority over existing charts. +` + +type repoIndexOptions struct { + dir string + url string + merge string + json bool +} + +func newRepoIndexCmd(out io.Writer) *cobra.Command { + o := &repoIndexOptions{} + + cmd := &cobra.Command{ + Use: "index [DIR]", + Short: "generate an index file given a directory containing packaged charts", + Long: repoIndexDesc, + Args: require.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + // Allow file completion when completing the argument for the directory + return nil, cobra.ShellCompDirectiveDefault + } + // No more completions, so disable file completion + return nil, cobra.ShellCompDirectiveNoFileComp + }, + RunE: func(cmd *cobra.Command, args []string) error { + o.dir = args[0] + return o.run(out) + }, + } + + f := cmd.Flags() + f.StringVar(&o.url, "url", "", "url of chart repository") + f.StringVar(&o.merge, "merge", "", "merge the generated index into the given index") + f.BoolVar(&o.json, "json", false, "output in JSON format") + + return cmd +} + +func (i *repoIndexOptions) run(_ io.Writer) error { + path, err := filepath.Abs(i.dir) + if err != nil { + return err + } + + return index(path, i.url, i.merge, i.json) +} + +func index(dir, url, mergeTo string, json bool) error { + out := filepath.Join(dir, "index.yaml") + + i, err := repo.IndexDirectory(dir, url) + if err != nil { + return err + } + if mergeTo != "" { + // if index.yaml is missing then create an empty one to merge into + var i2 *repo.IndexFile + if _, err := os.Stat(mergeTo); os.IsNotExist(err) { + i2 = repo.NewIndexFile() + writeIndexFile(i2, mergeTo, json) + } else { + i2, err = repo.LoadIndexFile(mergeTo) + if err != nil { + return errors.Wrap(err, "merge failed") + } + } + i.Merge(i2) + } + i.SortEntries() + return writeIndexFile(i, out, json) +} + +func writeIndexFile(i *repo.IndexFile, out string, json bool) error { + if json { + return i.WriteJSONFile(out, 0644) + } + return i.WriteFile(out, 0644) +} diff --git a/cmd/helm/repo_index_test.go b/cmd/helm/repo_index_test.go new file mode 100644 index 00000000..554a3dad --- /dev/null +++ b/cmd/helm/repo_index_test.go @@ -0,0 +1,194 @@ +/* +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" + "encoding/json" + "io" + "os" + "path/filepath" + "testing" + + "helm.sh/helm/v3/pkg/repo" +) + +func TestRepoIndexCmd(t *testing.T) { + + dir := t.TempDir() + + comp := filepath.Join(dir, "compressedchart-0.1.0.tgz") + if err := linkOrCopy("testdata/testcharts/compressedchart-0.1.0.tgz", comp); err != nil { + t.Fatal(err) + } + comp2 := filepath.Join(dir, "compressedchart-0.2.0.tgz") + if err := linkOrCopy("testdata/testcharts/compressedchart-0.2.0.tgz", comp2); err != nil { + t.Fatal(err) + } + + buf := bytes.NewBuffer(nil) + c := newRepoIndexCmd(buf) + + if err := c.RunE(c, []string{dir}); err != nil { + t.Error(err) + } + + destIndex := filepath.Join(dir, "index.yaml") + + index, err := repo.LoadIndexFile(destIndex) + if err != nil { + t.Fatal(err) + } + + if len(index.Entries) != 1 { + t.Errorf("expected 1 entry, got %d: %#v", len(index.Entries), index.Entries) + } + + vs := index.Entries["compressedchart"] + if len(vs) != 2 { + t.Errorf("expected 2 versions, got %d: %#v", len(vs), vs) + } + + expectedVersion := "0.2.0" + if vs[0].Version != expectedVersion { + t.Errorf("expected %q, got %q", expectedVersion, vs[0].Version) + } + + b, err := os.ReadFile(destIndex) + if err != nil { + t.Fatal(err) + } + if json.Valid(b) { + t.Error("did not expect index file to be valid json") + } + + // Test with `--json` + + c.ParseFlags([]string{"--json", "true"}) + if err := c.RunE(c, []string{dir}); err != nil { + t.Error(err) + } + + if b, err = os.ReadFile(destIndex); err != nil { + t.Fatal(err) + } + if !json.Valid(b) { + t.Error("index file is not valid json") + } + + // Test with `--merge` + + // Remove first two charts. + if err := os.Remove(comp); err != nil { + t.Fatal(err) + } + if err := os.Remove(comp2); err != nil { + t.Fatal(err) + } + // Add a new chart and a new version of an existing chart + if err := linkOrCopy("testdata/testcharts/reqtest-0.1.0.tgz", filepath.Join(dir, "reqtest-0.1.0.tgz")); err != nil { + t.Fatal(err) + } + if err := linkOrCopy("testdata/testcharts/compressedchart-0.3.0.tgz", filepath.Join(dir, "compressedchart-0.3.0.tgz")); err != nil { + t.Fatal(err) + } + + c.ParseFlags([]string{"--merge", destIndex}) + if err := c.RunE(c, []string{dir}); err != nil { + t.Error(err) + } + + index, err = repo.LoadIndexFile(destIndex) + if err != nil { + t.Fatal(err) + } + + if len(index.Entries) != 2 { + t.Errorf("expected 2 entries, got %d: %#v", len(index.Entries), index.Entries) + } + + vs = index.Entries["compressedchart"] + if len(vs) != 3 { + t.Errorf("expected 3 versions, got %d: %#v", len(vs), vs) + } + + expectedVersion = "0.3.0" + if vs[0].Version != expectedVersion { + t.Errorf("expected %q, got %q", expectedVersion, vs[0].Version) + } + + // test that index.yaml gets generated on merge even when it doesn't exist + if err := os.Remove(destIndex); err != nil { + t.Fatal(err) + } + + c.ParseFlags([]string{"--merge", destIndex}) + if err := c.RunE(c, []string{dir}); err != nil { + t.Error(err) + } + + index, err = repo.LoadIndexFile(destIndex) + if err != nil { + t.Fatal(err) + } + + // verify it didn't create an empty index.yaml and the merged happened + if len(index.Entries) != 2 { + t.Errorf("expected 2 entries, got %d: %#v", len(index.Entries), index.Entries) + } + + vs = index.Entries["compressedchart"] + if len(vs) != 1 { + t.Errorf("expected 1 versions, got %d: %#v", len(vs), vs) + } + + expectedVersion = "0.3.0" + if vs[0].Version != expectedVersion { + t.Errorf("expected %q, got %q", expectedVersion, vs[0].Version) + } +} + +func linkOrCopy(old, new string) error { + if err := os.Link(old, new); err != nil { + return copyFile(old, new) + } + + return nil +} + +func copyFile(dst, src string) error { + i, err := os.Open(dst) + if err != nil { + return err + } + defer i.Close() + + o, err := os.Create(src) + if err != nil { + return err + } + defer o.Close() + + _, err = io.Copy(o, i) + + return err +} + +func TestRepoIndexFileCompletion(t *testing.T) { + checkFileCompletion(t, "repo index", true) + checkFileCompletion(t, "repo index mydir", false) +} diff --git a/cmd/helm/repo_list.go b/cmd/helm/repo_list.go new file mode 100644 index 00000000..334f859a --- /dev/null +++ b/cmd/helm/repo_list.go @@ -0,0 +1,137 @@ +/* +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 ( + "fmt" + "io" + + "github.com/gosuri/uitable" + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/cli/output" + "helm.sh/helm/v3/pkg/repo" +) + +func newRepoListCmd(out io.Writer) *cobra.Command { + var outfmt output.Format + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "list chart repositories", + Args: require.NoArgs, + ValidArgsFunction: noCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + f, _ := repo.LoadFile(settings.RepositoryConfig) + if len(f.Repositories) == 0 && !(outfmt == output.JSON || outfmt == output.YAML) { + return errors.New("no repositories to show") + } + + return outfmt.Write(out, &repoListWriter{f.Repositories}) + }, + } + + bindOutputFlag(cmd, &outfmt) + + return cmd +} + +type repositoryElement struct { + Name string `json:"name"` + URL string `json:"url"` +} + +type repoListWriter struct { + repos []*repo.Entry +} + +func (r *repoListWriter) WriteTable(out io.Writer) error { + table := uitable.New() + table.AddRow("NAME", "URL") + for _, re := range r.repos { + table.AddRow(re.Name, re.URL) + } + return output.EncodeTable(out, table) +} + +func (r *repoListWriter) WriteJSON(out io.Writer) error { + return r.encodeByFormat(out, output.JSON) +} + +func (r *repoListWriter) WriteYAML(out io.Writer) error { + return r.encodeByFormat(out, output.YAML) +} + +func (r *repoListWriter) encodeByFormat(out io.Writer, format output.Format) error { + // Initialize the array so no results returns an empty array instead of null + repolist := make([]repositoryElement, 0, len(r.repos)) + + for _, re := range r.repos { + repolist = append(repolist, repositoryElement{Name: re.Name, URL: re.URL}) + } + + switch format { + case output.JSON: + return output.EncodeJSON(out, repolist) + case output.YAML: + return output.EncodeYAML(out, repolist) + } + + // Because this is a non-exported function and only called internally by + // WriteJSON and WriteYAML, we shouldn't get invalid types + return nil +} + +// Returns all repos from repos, except those with names matching ignoredRepoNames +// Inspired by https://stackoverflow.com/a/28701031/893211 +func filterRepos(repos []*repo.Entry, ignoredRepoNames []string) []*repo.Entry { + // if ignoredRepoNames is nil, just return repo + if ignoredRepoNames == nil { + return repos + } + + filteredRepos := make([]*repo.Entry, 0) + + ignored := make(map[string]bool, len(ignoredRepoNames)) + for _, repoName := range ignoredRepoNames { + ignored[repoName] = true + } + + for _, repo := range repos { + if _, removed := ignored[repo.Name]; !removed { + filteredRepos = append(filteredRepos, repo) + } + } + + return filteredRepos +} + +// Provide dynamic auto-completion for repo names +func compListRepos(_ string, ignoredRepoNames []string) []string { + var rNames []string + + f, err := repo.LoadFile(settings.RepositoryConfig) + if err == nil && len(f.Repositories) > 0 { + filteredRepos := filterRepos(f.Repositories, ignoredRepoNames) + for _, repo := range filteredRepos { + rNames = append(rNames, fmt.Sprintf("%s\t%s", repo.Name, repo.URL)) + } + } + return rNames +} diff --git a/cmd/helm/repo_list_test.go b/cmd/helm/repo_list_test.go new file mode 100644 index 00000000..90149ebd --- /dev/null +++ b/cmd/helm/repo_list_test.go @@ -0,0 +1,29 @@ +/* +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 ( + "testing" +) + +func TestRepoListOutputCompletion(t *testing.T) { + outputFlagCompletionTest(t, "repo list") +} + +func TestRepoListFileCompletion(t *testing.T) { + checkFileCompletion(t, "repo list", false) +} diff --git a/cmd/helm/repo_remove.go b/cmd/helm/repo_remove.go new file mode 100644 index 00000000..0c1ad2cd --- /dev/null +++ b/cmd/helm/repo_remove.go @@ -0,0 +1,96 @@ +/* +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 ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/helmpath" + "helm.sh/helm/v3/pkg/repo" +) + +type repoRemoveOptions struct { + names []string + repoFile string + repoCache string +} + +func newRepoRemoveCmd(out io.Writer) *cobra.Command { + o := &repoRemoveOptions{} + + cmd := &cobra.Command{ + Use: "remove [REPO1 [REPO2 ...]]", + Aliases: []string{"rm"}, + Short: "remove one or more chart repositories", + Args: require.MinimumNArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return compListRepos(toComplete, args), cobra.ShellCompDirectiveNoFileComp + }, + RunE: func(cmd *cobra.Command, args []string) error { + o.repoFile = settings.RepositoryConfig + o.repoCache = settings.RepositoryCache + o.names = args + return o.run(out) + }, + } + return cmd +} + +func (o *repoRemoveOptions) run(out io.Writer) error { + r, err := repo.LoadFile(o.repoFile) + if isNotExist(err) || len(r.Repositories) == 0 { + return errors.New("no repositories configured") + } + + for _, name := range o.names { + if !r.Remove(name) { + return errors.Errorf("no repo named %q found", name) + } + if err := r.WriteFile(o.repoFile, 0600); err != nil { + return err + } + + if err := removeRepoCache(o.repoCache, name); err != nil { + return err + } + fmt.Fprintf(out, "%q has been removed from your repositories\n", name) + } + + return nil +} + +func removeRepoCache(root, name string) error { + idx := filepath.Join(root, helmpath.CacheChartsFile(name)) + if _, err := os.Stat(idx); err == nil { + os.Remove(idx) + } + + idx = filepath.Join(root, helmpath.CacheIndexFile(name)) + if _, err := os.Stat(idx); os.IsNotExist(err) { + return nil + } else if err != nil { + return errors.Wrapf(err, "can't remove index file %s", idx) + } + return os.Remove(idx) +} diff --git a/cmd/helm/repo_remove_test.go b/cmd/helm/repo_remove_test.go new file mode 100644 index 00000000..e2795e73 --- /dev/null +++ b/cmd/helm/repo_remove_test.go @@ -0,0 +1,216 @@ +/* +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" + "os" + "path/filepath" + "strings" + "testing" + + "helm.sh/helm/v3/pkg/helmpath" + "helm.sh/helm/v3/pkg/repo" + "helm.sh/helm/v3/pkg/repo/repotest" +) + +func TestRepoRemove(t *testing.T) { + ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") + if err != nil { + t.Fatal(err) + } + defer ts.Stop() + + rootDir := t.TempDir() + repoFile := filepath.Join(rootDir, "repositories.yaml") + + const testRepoName = "test-name" + + b := bytes.NewBuffer(nil) + + rmOpts := repoRemoveOptions{ + names: []string{testRepoName}, + repoFile: repoFile, + repoCache: rootDir, + } + + if err := rmOpts.run(os.Stderr); err == nil { + t.Errorf("Expected error removing %s, but did not get one.", testRepoName) + } + o := &repoAddOptions{ + name: testRepoName, + url: ts.URL(), + repoFile: repoFile, + } + + if err := o.run(os.Stderr); err != nil { + t.Error(err) + } + + cacheIndexFile, cacheChartsFile := createCacheFiles(rootDir, testRepoName) + + // Reset the buffer before running repo remove + b.Reset() + + if err := rmOpts.run(b); err != nil { + t.Errorf("Error removing %s from repositories", testRepoName) + } + if !strings.Contains(b.String(), "has been removed") { + t.Errorf("Unexpected output: %s", b.String()) + } + + testCacheFiles(t, cacheIndexFile, cacheChartsFile, testRepoName) + + f, err := repo.LoadFile(repoFile) + if err != nil { + t.Error(err) + } + + if f.Has(testRepoName) { + t.Errorf("%s was not successfully removed from repositories list", testRepoName) + } + + // Test removal of multiple repos in one go + var testRepoNames = []string{"foo", "bar", "baz"} + cacheFiles := make(map[string][]string, len(testRepoNames)) + + // Add test repos + for _, repoName := range testRepoNames { + o := &repoAddOptions{ + name: repoName, + url: ts.URL(), + repoFile: repoFile, + } + + if err := o.run(os.Stderr); err != nil { + t.Error(err) + } + + cacheIndex, cacheChart := createCacheFiles(rootDir, repoName) + cacheFiles[repoName] = []string{cacheIndex, cacheChart} + + } + + // Create repo remove command + multiRmOpts := repoRemoveOptions{ + names: testRepoNames, + repoFile: repoFile, + repoCache: rootDir, + } + + // Reset the buffer before running repo remove + b.Reset() + + // Run repo remove command + if err := multiRmOpts.run(b); err != nil { + t.Errorf("Error removing list of repos from repositories: %q", testRepoNames) + } + + // Check that stuff were removed + if !strings.Contains(b.String(), "has been removed") { + t.Errorf("Unexpected output: %s", b.String()) + } + + for _, repoName := range testRepoNames { + f, err := repo.LoadFile(repoFile) + if err != nil { + t.Error(err) + } + if f.Has(repoName) { + t.Errorf("%s was not successfully removed from repositories list", repoName) + } + cacheIndex := cacheFiles[repoName][0] + cacheChart := cacheFiles[repoName][1] + testCacheFiles(t, cacheIndex, cacheChart, repoName) + } +} + +func createCacheFiles(rootDir string, repoName string) (cacheIndexFile string, cacheChartsFile string) { + cacheIndexFile = filepath.Join(rootDir, helmpath.CacheIndexFile(repoName)) + mf, _ := os.Create(cacheIndexFile) + mf.Close() + + cacheChartsFile = filepath.Join(rootDir, helmpath.CacheChartsFile(repoName)) + mf, _ = os.Create(cacheChartsFile) + mf.Close() + + return cacheIndexFile, cacheChartsFile +} + +func testCacheFiles(t *testing.T, cacheIndexFile string, cacheChartsFile string, repoName string) { + if _, err := os.Stat(cacheIndexFile); err == nil { + t.Errorf("Error cache index file was not removed for repository %s", repoName) + } + if _, err := os.Stat(cacheChartsFile); err == nil { + t.Errorf("Error cache chart file was not removed for repository %s", repoName) + } +} + +func TestRepoRemoveCompletion(t *testing.T) { + ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") + if err != nil { + t.Fatal(err) + } + defer ts.Stop() + + rootDir := t.TempDir() + repoFile := filepath.Join(rootDir, "repositories.yaml") + repoCache := filepath.Join(rootDir, "cache/") + + var testRepoNames = []string{"foo", "bar", "baz"} + + // Add test repos + for _, repoName := range testRepoNames { + o := &repoAddOptions{ + name: repoName, + url: ts.URL(), + repoFile: repoFile, + } + + if err := o.run(os.Stderr); err != nil { + t.Error(err) + } + } + + repoSetup := fmt.Sprintf("--repository-config %s --repository-cache %s", repoFile, repoCache) + + // In the following tests, we turn off descriptions for completions by using __completeNoDesc. + // We have to do this because the description will contain the port used by the webserver, + // and that port changes each time we run the test. + tests := []cmdTestCase{{ + name: "completion for repo remove", + cmd: fmt.Sprintf("%s __completeNoDesc repo remove ''", repoSetup), + golden: "output/repo_list_comp.txt", + }, { + name: "completion for repo remove, no filter", + cmd: fmt.Sprintf("%s __completeNoDesc repo remove fo", repoSetup), + golden: "output/repo_list_comp.txt", + }, { + name: "completion for repo remove repetition", + cmd: fmt.Sprintf("%s __completeNoDesc repo remove foo ''", repoSetup), + golden: "output/repo_repeat_comp.txt", + }} + for _, test := range tests { + runTestCmd(t, []cmdTestCase{test}) + } +} + +func TestRepoRemoveFileCompletion(t *testing.T) { + checkFileCompletion(t, "repo remove", false) + checkFileCompletion(t, "repo remove repo1", false) +} diff --git a/cmd/helm/repo_test.go b/cmd/helm/repo_test.go new file mode 100644 index 00000000..2b0df7c4 --- /dev/null +++ b/cmd/helm/repo_test.go @@ -0,0 +1,25 @@ +/* +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 ( + "testing" +) + +func TestRepoFileCompletion(t *testing.T) { + checkFileCompletion(t, "repo", false) +} diff --git a/cmd/helm/repo_update.go b/cmd/helm/repo_update.go new file mode 100644 index 00000000..27661674 --- /dev/null +++ b/cmd/helm/repo_update.go @@ -0,0 +1,167 @@ +/* +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 ( + "fmt" + "io" + "sync" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/repo" +) + +const updateDesc = ` +Update gets the latest information about charts from the respective chart repositories. +Information is cached locally, where it is used by commands like 'helm search'. + +You can optionally specify a list of repositories you want to update. + $ helm repo update ... +To update all the repositories, use 'helm repo update'. +` + +var errNoRepositories = errors.New("no repositories found. You must add one before updating") + +type repoUpdateOptions struct { + update func([]*repo.ChartRepository, io.Writer, bool) error + repoFile string + repoCache string + names []string + failOnRepoUpdateFail bool +} + +func newRepoUpdateCmd(out io.Writer) *cobra.Command { + o := &repoUpdateOptions{update: updateCharts} + + cmd := &cobra.Command{ + Use: "update [REPO1 [REPO2 ...]]", + Aliases: []string{"up"}, + Short: "update information of available charts locally from chart repositories", + Long: updateDesc, + Args: require.MinimumNArgs(0), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return compListRepos(toComplete, args), cobra.ShellCompDirectiveNoFileComp + }, + RunE: func(cmd *cobra.Command, args []string) error { + o.repoFile = settings.RepositoryConfig + o.repoCache = settings.RepositoryCache + o.names = args + return o.run(out) + }, + } + + f := cmd.Flags() + + // Adding this flag for Helm 3 as stop gap functionality for https://github.com/helm/helm/issues/10016. + // This should be deprecated in Helm 4 by update to the behaviour of `helm repo update` command. + f.BoolVar(&o.failOnRepoUpdateFail, "fail-on-repo-update-fail", false, "update fails if any of the repository updates fail") + + return cmd +} + +func (o *repoUpdateOptions) run(out io.Writer) error { + f, err := repo.LoadFile(o.repoFile) + switch { + case isNotExist(err): + return errNoRepositories + case err != nil: + return errors.Wrapf(err, "failed loading file: %s", o.repoFile) + case len(f.Repositories) == 0: + return errNoRepositories + } + + var repos []*repo.ChartRepository + updateAllRepos := len(o.names) == 0 + + if !updateAllRepos { + // Fail early if the user specified an invalid repo to update + if err := checkRequestedRepos(o.names, f.Repositories); err != nil { + return err + } + } + + for _, cfg := range f.Repositories { + if updateAllRepos || isRepoRequested(cfg.Name, o.names) { + r, err := repo.NewChartRepository(cfg, getter.All(settings)) + if err != nil { + return err + } + if o.repoCache != "" { + r.CachePath = o.repoCache + } + repos = append(repos, r) + } + } + + return o.update(repos, out, o.failOnRepoUpdateFail) +} + +func updateCharts(repos []*repo.ChartRepository, out io.Writer, failOnRepoUpdateFail bool) error { + fmt.Fprintln(out, "Hang tight while we grab the latest from your chart repositories...") + var wg sync.WaitGroup + var repoFailList []string + for _, re := range repos { + wg.Add(1) + go func(re *repo.ChartRepository) { + defer wg.Done() + if _, err := re.DownloadIndexFile(); err != nil { + fmt.Fprintf(out, "...Unable to get an update from the %q chart repository (%s):\n\t%s\n", re.Config.Name, re.Config.URL, err) + repoFailList = append(repoFailList, re.Config.URL) + } else { + fmt.Fprintf(out, "...Successfully got an update from the %q chart repository\n", re.Config.Name) + } + }(re) + } + wg.Wait() + + if len(repoFailList) > 0 && failOnRepoUpdateFail { + return fmt.Errorf("Failed to update the following repositories: %s", + repoFailList) + } + + fmt.Fprintln(out, "Update Complete. ⎈Happy Helming!⎈") + return nil +} + +func checkRequestedRepos(requestedRepos []string, validRepos []*repo.Entry) error { + for _, requestedRepo := range requestedRepos { + found := false + for _, repo := range validRepos { + if requestedRepo == repo.Name { + found = true + break + } + } + if !found { + return errors.Errorf("no repositories found matching '%s'. Nothing will be updated", requestedRepo) + } + } + return nil +} + +func isRepoRequested(repoName string, requestedRepos []string) bool { + for _, requestedRepo := range requestedRepos { + if repoName == requestedRepo { + return true + } + } + return false +} diff --git a/cmd/helm/repo_update_test.go b/cmd/helm/repo_update_test.go new file mode 100644 index 00000000..645c68cf --- /dev/null +++ b/cmd/helm/repo_update_test.go @@ -0,0 +1,238 @@ +/* +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" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "helm.sh/helm/v3/internal/test/ensure" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/repo" + "helm.sh/helm/v3/pkg/repo/repotest" +) + +func TestUpdateCmd(t *testing.T) { + var out bytes.Buffer + // Instead of using the HTTP updater, we provide our own for this test. + // The TestUpdateCharts test verifies the HTTP behavior independently. + updater := func(repos []*repo.ChartRepository, out io.Writer, failOnRepoUpdateFail bool) error { + for _, re := range repos { + fmt.Fprintln(out, re.Config.Name) + } + return nil + } + o := &repoUpdateOptions{ + update: updater, + repoFile: "testdata/repositories.yaml", + } + if err := o.run(&out); err != nil { + t.Fatal(err) + } + + if got := out.String(); !strings.Contains(got, "charts") || + !strings.Contains(got, "firstexample") || + !strings.Contains(got, "secondexample") { + t.Errorf("Expected 'charts', 'firstexample' and 'secondexample' but got %q", got) + } +} + +func TestUpdateCmdMultiple(t *testing.T) { + var out bytes.Buffer + // Instead of using the HTTP updater, we provide our own for this test. + // The TestUpdateCharts test verifies the HTTP behavior independently. + updater := func(repos []*repo.ChartRepository, out io.Writer, failOnRepoUpdateFail bool) error { + for _, re := range repos { + fmt.Fprintln(out, re.Config.Name) + } + return nil + } + o := &repoUpdateOptions{ + update: updater, + repoFile: "testdata/repositories.yaml", + names: []string{"firstexample", "charts"}, + } + if err := o.run(&out); err != nil { + t.Fatal(err) + } + + if got := out.String(); !strings.Contains(got, "charts") || + !strings.Contains(got, "firstexample") || + strings.Contains(got, "secondexample") { + t.Errorf("Expected 'charts' and 'firstexample' but not 'secondexample' but got %q", got) + } +} + +func TestUpdateCmdInvalid(t *testing.T) { + var out bytes.Buffer + // Instead of using the HTTP updater, we provide our own for this test. + // The TestUpdateCharts test verifies the HTTP behavior independently. + updater := func(repos []*repo.ChartRepository, out io.Writer, failOnRepoUpdateFail bool) error { + for _, re := range repos { + fmt.Fprintln(out, re.Config.Name) + } + return nil + } + o := &repoUpdateOptions{ + update: updater, + repoFile: "testdata/repositories.yaml", + names: []string{"firstexample", "invalid"}, + } + if err := o.run(&out); err == nil { + t.Fatal("expected error but did not get one") + } +} + +func TestUpdateCustomCacheCmd(t *testing.T) { + rootDir := t.TempDir() + cachePath := filepath.Join(rootDir, "updcustomcache") + os.Mkdir(cachePath, os.ModePerm) + + ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") + if err != nil { + t.Fatal(err) + } + defer ts.Stop() + + o := &repoUpdateOptions{ + update: updateCharts, + repoFile: filepath.Join(ts.Root(), "repositories.yaml"), + repoCache: cachePath, + } + b := io.Discard + if err := o.run(b); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(cachePath, "test-index.yaml")); err != nil { + t.Fatalf("error finding created index file in custom cache: %v", err) + } +} + +func TestUpdateCharts(t *testing.T) { + defer resetEnv()() + ensure.HelmHome(t) + + ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") + if err != nil { + t.Fatal(err) + } + defer ts.Stop() + + r, err := repo.NewChartRepository(&repo.Entry{ + Name: "charts", + URL: ts.URL(), + }, getter.All(settings)) + if err != nil { + t.Error(err) + } + + b := bytes.NewBuffer(nil) + updateCharts([]*repo.ChartRepository{r}, b, false) + + got := b.String() + if strings.Contains(got, "Unable to get an update") { + t.Errorf("Failed to get a repo: %q", got) + } + if !strings.Contains(got, "Update Complete.") { + t.Error("Update was not successful") + } +} + +func TestRepoUpdateFileCompletion(t *testing.T) { + checkFileCompletion(t, "repo update", false) + checkFileCompletion(t, "repo update repo1", false) +} + +func TestUpdateChartsFail(t *testing.T) { + defer resetEnv()() + ensure.HelmHome(t) + + ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") + if err != nil { + t.Fatal(err) + } + defer ts.Stop() + + var invalidURL = ts.URL() + "55" + r, err := repo.NewChartRepository(&repo.Entry{ + Name: "charts", + URL: invalidURL, + }, getter.All(settings)) + if err != nil { + t.Error(err) + } + + b := bytes.NewBuffer(nil) + if err := updateCharts([]*repo.ChartRepository{r}, b, false); err != nil { + t.Error("Repo update should not return error if update of repository fails") + } + + got := b.String() + if !strings.Contains(got, "Unable to get an update") { + t.Errorf("Repo should have failed update but instead got: %q", got) + } + if !strings.Contains(got, "Update Complete.") { + t.Error("Update was not successful") + } +} + +func TestUpdateChartsFailWithError(t *testing.T) { + defer resetEnv()() + ensure.HelmHome(t) + + ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") + if err != nil { + t.Fatal(err) + } + defer ts.Stop() + + var invalidURL = ts.URL() + "55" + r, err := repo.NewChartRepository(&repo.Entry{ + Name: "charts", + URL: invalidURL, + }, getter.All(settings)) + if err != nil { + t.Error(err) + } + + b := bytes.NewBuffer(nil) + err = updateCharts([]*repo.ChartRepository{r}, b, true) + if err == nil { + t.Error("Repo update should return error because update of repository fails and 'fail-on-repo-update-fail' flag set") + return + } + var expectedErr = "Failed to update the following repositories" + var receivedErr = err.Error() + if !strings.Contains(receivedErr, expectedErr) { + t.Errorf("Expected error (%s) but got (%s) instead", expectedErr, receivedErr) + } + if !strings.Contains(receivedErr, invalidURL) { + t.Errorf("Expected invalid URL (%s) in error message but got (%s) instead", invalidURL, receivedErr) + } + + got := b.String() + if !strings.Contains(got, "Unable to get an update") { + t.Errorf("Repo should have failed update but instead got: %q", got) + } + if strings.Contains(got, "Update Complete.") { + t.Error("Update was not successful and should return error message because 'fail-on-repo-update-fail' flag set") + } +} diff --git a/cmd/helm/require/args.go b/cmd/helm/require/args.go new file mode 100644 index 00000000..cfa8a016 --- /dev/null +++ b/cmd/helm/require/args.go @@ -0,0 +1,88 @@ +/* +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 require + +import ( + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +// NoArgs returns an error if any args are included. +func NoArgs(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + return errors.Errorf( + "%q accepts no arguments\n\nUsage: %s", + cmd.CommandPath(), + cmd.UseLine(), + ) + } + return nil +} + +// ExactArgs returns an error if there are not exactly n args. +func ExactArgs(n int) cobra.PositionalArgs { + return func(cmd *cobra.Command, args []string) error { + if len(args) != n { + return errors.Errorf( + "%q requires %d %s\n\nUsage: %s", + cmd.CommandPath(), + n, + pluralize("argument", n), + cmd.UseLine(), + ) + } + return nil + } +} + +// MaximumNArgs returns an error if there are more than N args. +func MaximumNArgs(n int) cobra.PositionalArgs { + return func(cmd *cobra.Command, args []string) error { + if len(args) > n { + return errors.Errorf( + "%q accepts at most %d %s\n\nUsage: %s", + cmd.CommandPath(), + n, + pluralize("argument", n), + cmd.UseLine(), + ) + } + return nil + } +} + +// MinimumNArgs returns an error if there is not at least N args. +func MinimumNArgs(n int) cobra.PositionalArgs { + return func(cmd *cobra.Command, args []string) error { + if len(args) < n { + return errors.Errorf( + "%q requires at least %d %s\n\nUsage: %s", + cmd.CommandPath(), + n, + pluralize("argument", n), + cmd.UseLine(), + ) + } + return nil + } +} + +func pluralize(word string, n int) string { + if n == 1 { + return word + } + return word + "s" +} diff --git a/cmd/helm/require/args_test.go b/cmd/helm/require/args_test.go new file mode 100644 index 00000000..5a84a42d --- /dev/null +++ b/cmd/helm/require/args_test.go @@ -0,0 +1,91 @@ +/* +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 require + +import ( + "fmt" + "io" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestArgs(t *testing.T) { + runTestCases(t, []testCase{{ + validateFunc: NoArgs, + }, { + args: []string{"one"}, + validateFunc: NoArgs, + wantError: `"root" accepts no arguments`, + }, { + args: []string{"one"}, + validateFunc: ExactArgs(1), + }, { + validateFunc: ExactArgs(1), + wantError: `"root" requires 1 argument`, + }, { + validateFunc: ExactArgs(2), + wantError: `"root" requires 2 arguments`, + }, { + args: []string{"one"}, + validateFunc: MaximumNArgs(1), + }, { + args: []string{"one", "two"}, + validateFunc: MaximumNArgs(1), + wantError: `"root" accepts at most 1 argument`, + }, { + validateFunc: MinimumNArgs(1), + wantError: `"root" requires at least 1 argument`, + }, { + args: []string{"one", "two"}, + validateFunc: MinimumNArgs(1), + }}) +} + +type testCase struct { + args []string + validateFunc cobra.PositionalArgs + wantError string +} + +func runTestCases(t *testing.T, testCases []testCase) { + for i, tc := range testCases { + t.Run(fmt.Sprint(i), func(t *testing.T) { + cmd := &cobra.Command{ + Use: "root", + Run: func(*cobra.Command, []string) {}, + Args: tc.validateFunc, + } + cmd.SetArgs(tc.args) + cmd.SetOutput(io.Discard) + + err := cmd.Execute() + if tc.wantError == "" { + if err != nil { + t.Fatalf("unexpected error, got '%v'", err) + } + return + } + if !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("unexpected error \n\nWANT:\n%q\n\nGOT:\n%q\n", tc.wantError, err) + } + if !strings.Contains(err.Error(), "Usage:") { + t.Fatalf("unexpected error: want Usage string\n\nGOT:\n%q\n", err) + } + }) + } +} diff --git a/cmd/helm/rollback.go b/cmd/helm/rollback.go new file mode 100644 index 00000000..7de98e40 --- /dev/null +++ b/cmd/helm/rollback.go @@ -0,0 +1,90 @@ +/* +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 ( + "fmt" + "io" + "strconv" + "time" + + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/action" +) + +const rollbackDesc = ` +This command rolls back a release to a previous revision. + +The first argument of the rollback command is the name of a release, and the +second is a revision (version) number. If this argument is omitted or set to +0, it will roll back to the previous release. + +To see revision numbers, run 'helm history RELEASE'. +` + +func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + client := action.NewRollback(cfg) + + cmd := &cobra.Command{ + Use: "rollback [REVISION]", + Short: "roll back a release to a previous revision", + Long: rollbackDesc, + Args: require.MinimumNArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return compListReleases(toComplete, args, cfg) + } + + if len(args) == 1 { + return compListRevisions(toComplete, cfg, args[0]) + } + + return nil, cobra.ShellCompDirectiveNoFileComp + }, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 1 { + ver, err := strconv.Atoi(args[1]) + if err != nil { + return fmt.Errorf("could not convert revision to a number: %v", err) + } + client.Version = ver + } + + if err := client.Run(args[0]); err != nil { + return err + } + + fmt.Fprintf(out, "Rollback was a success! Happy Helming!\n") + return nil + }, + } + + f := cmd.Flags() + f.BoolVar(&client.DryRun, "dry-run", false, "simulate a rollback") + f.BoolVar(&client.Recreate, "recreate-pods", false, "performs pods restart for the resource if applicable") + f.BoolVar(&client.Force, "force", false, "force resource update through delete/recreate if needed") + f.BoolVar(&client.DisableHooks, "no-hooks", false, "prevent hooks from running during rollback") + f.DurationVar(&client.Timeout, "timeout", 300*time.Second, "time to wait for any individual Kubernetes operation (like Jobs for hooks)") + f.BoolVar(&client.Wait, "wait", false, "if set, will wait until all Pods, PVCs, Services, and minimum number of Pods of a Deployment, StatefulSet, or ReplicaSet are in a ready state before marking the release as successful. It will wait for as long as --timeout") + f.BoolVar(&client.WaitForJobs, "wait-for-jobs", false, "if set and --wait enabled, will wait until all Jobs have been completed before marking the release as successful. It will wait for as long as --timeout") + f.BoolVar(&client.CleanupOnFail, "cleanup-on-fail", false, "allow deletion of new resources created in this rollback when rollback fails") + f.IntVar(&client.MaxHistory, "history-max", settings.MaxHistory, "limit the maximum number of revisions saved per release. Use 0 for no limit") + + return cmd +} diff --git a/cmd/helm/rollback_test.go b/cmd/helm/rollback_test.go new file mode 100644 index 00000000..b58e4c16 --- /dev/null +++ b/cmd/helm/rollback_test.go @@ -0,0 +1,166 @@ +/* +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 ( + "fmt" + "reflect" + "testing" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/release" +) + +func TestRollbackCmd(t *testing.T) { + rels := []*release.Release{ + { + Name: "funny-honey", + Info: &release.Info{Status: release.StatusSuperseded}, + Chart: &chart.Chart{}, + Version: 1, + }, + { + Name: "funny-honey", + Info: &release.Info{Status: release.StatusDeployed}, + Chart: &chart.Chart{}, + Version: 2, + }, + } + + tests := []cmdTestCase{{ + name: "rollback a release", + cmd: "rollback funny-honey 1", + golden: "output/rollback.txt", + rels: rels, + }, { + name: "rollback a release with timeout", + cmd: "rollback funny-honey 1 --timeout 120s", + golden: "output/rollback-timeout.txt", + rels: rels, + }, { + name: "rollback a release with wait", + cmd: "rollback funny-honey 1 --wait", + golden: "output/rollback-wait.txt", + rels: rels, + }, { + name: "rollback a release with wait-for-jobs", + cmd: "rollback funny-honey 1 --wait --wait-for-jobs", + golden: "output/rollback-wait-for-jobs.txt", + rels: rels, + }, { + name: "rollback a release without revision", + cmd: "rollback funny-honey", + golden: "output/rollback-no-revision.txt", + rels: rels, + }, { + name: "rollback a release with non-existent version", + cmd: "rollback funny-honey 3", + golden: "output/rollback-non-existent-version.txt", + rels: rels, + wantError: true, + }, { + name: "rollback a release without release name", + cmd: "rollback", + golden: "output/rollback-no-args.txt", + rels: rels, + wantError: true, + }} + runTestCmd(t, tests) +} + +func TestRollbackRevisionCompletion(t *testing.T) { + mk := func(name string, vers int, status release.Status) *release.Release { + return release.Mock(&release.MockReleaseOptions{ + Name: name, + Version: vers, + Status: status, + }) + } + + releases := []*release.Release{ + mk("musketeers", 11, release.StatusDeployed), + mk("musketeers", 10, release.StatusSuperseded), + mk("musketeers", 9, release.StatusSuperseded), + mk("musketeers", 8, release.StatusSuperseded), + mk("carabins", 1, release.StatusSuperseded), + } + + tests := []cmdTestCase{{ + name: "completion for release parameter", + cmd: "__complete rollback ''", + rels: releases, + golden: "output/rollback-comp.txt", + }, { + name: "completion for revision parameter", + cmd: "__complete rollback musketeers ''", + rels: releases, + golden: "output/revision-comp.txt", + }, { + name: "completion for with too many args", + cmd: "__complete rollback musketeers 11 ''", + rels: releases, + golden: "output/rollback-wrong-args-comp.txt", + }} + runTestCmd(t, tests) +} + +func TestRollbackFileCompletion(t *testing.T) { + checkFileCompletion(t, "rollback", false) + checkFileCompletion(t, "rollback myrelease", false) + checkFileCompletion(t, "rollback myrelease 1", false) +} + +func TestRollbackWithLabels(t *testing.T) { + labels1 := map[string]string{"operation": "install", "firstLabel": "firstValue"} + labels2 := map[string]string{"operation": "upgrade", "secondLabel": "secondValue"} + + releaseName := "funny-bunny-labels" + rels := []*release.Release{ + { + Name: releaseName, + Info: &release.Info{Status: release.StatusSuperseded}, + Chart: &chart.Chart{}, + Version: 1, + Labels: labels1, + }, + { + Name: releaseName, + Info: &release.Info{Status: release.StatusDeployed}, + Chart: &chart.Chart{}, + Version: 2, + Labels: labels2, + }, + } + storage := storageFixture() + for _, rel := range rels { + if err := storage.Create(rel); err != nil { + t.Fatal(err) + } + } + _, _, err := executeActionCommandC(storage, fmt.Sprintf("rollback %s 1", releaseName)) + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + updatedRel, err := storage.Get(releaseName, 3) + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + + if !reflect.DeepEqual(updatedRel.Labels, labels1) { + t.Errorf("Expected {%v}, got {%v}", labels1, updatedRel.Labels) + } +} diff --git a/cmd/helm/root.go b/cmd/helm/root.go new file mode 100644 index 00000000..55b7e889 --- /dev/null +++ b/cmd/helm/root.go @@ -0,0 +1,304 @@ +/* +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 "helm.sh/helm/v3/cmd/helm" + +import ( + "context" + "fmt" + "io" + "log" + "os" + "strings" + + "github.com/spf13/cobra" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/clientcmd" + + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/registry" + "helm.sh/helm/v3/pkg/repo" +) + +var globalUsage = `The Kubernetes package manager + +Common actions for Helm: + +- helm search: search for charts +- helm pull: download a chart to your local directory to view +- helm install: upload the chart to Kubernetes +- helm list: list releases of charts + +Environment variables: + +| Name | Description | +|------------------------------------|------------------------------------------------------------------------------------------------------------| +| $HELM_CACHE_HOME | set an alternative location for storing cached files. | +| $HELM_CONFIG_HOME | set an alternative location for storing Helm configuration. | +| $HELM_DATA_HOME | set an alternative location for storing Helm data. | +| $HELM_DEBUG | indicate whether or not Helm is running in Debug mode | +| $HELM_DRIVER | set the backend storage driver. Values are: configmap, secret, memory, sql. | +| $HELM_DRIVER_SQL_CONNECTION_STRING | set the connection string the SQL storage driver should use. | +| $HELM_MAX_HISTORY | set the maximum number of helm release history. | +| $HELM_NAMESPACE | set the namespace used for the helm operations. | +| $HELM_NO_PLUGINS | disable plugins. Set HELM_NO_PLUGINS=1 to disable plugins. | +| $HELM_PLUGINS | set the path to the plugins directory | +| $HELM_REGISTRY_CONFIG | set the path to the registry config file. | +| $HELM_REPOSITORY_CACHE | set the path to the repository cache directory | +| $HELM_REPOSITORY_CONFIG | set the path to the repositories file. | +| $KUBECONFIG | set an alternative Kubernetes configuration file (default "~/.kube/config") | +| $HELM_KUBEAPISERVER | set the Kubernetes API Server Endpoint for authentication | +| $HELM_KUBECAFILE | set the Kubernetes certificate authority file. | +| $HELM_KUBEASGROUPS | set the Groups to use for impersonation using a comma-separated list. | +| $HELM_KUBEASUSER | set the Username to impersonate for the operation. | +| $HELM_KUBECONTEXT | set the name of the kubeconfig context. | +| $HELM_KUBETOKEN | set the Bearer KubeToken used for authentication. | +| $HELM_KUBEINSECURE_SKIP_TLS_VERIFY | indicate if the Kubernetes API server's certificate validation should be skipped (insecure) | +| $HELM_KUBETLS_SERVER_NAME | set the server name used to validate the Kubernetes API server certificate | +| $HELM_BURST_LIMIT | set the default burst limit in the case the server contains many CRDs (default 100, -1 to disable) | +| $HELM_QPS | set the Queries Per Second in cases where a high number of calls exceed the option for higher burst values | + +Helm stores cache, configuration, and data based on the following configuration order: + +- If a HELM_*_HOME environment variable is set, it will be used +- Otherwise, on systems supporting the XDG base directory specification, the XDG variables will be used +- When no other location is set a default location will be used based on the operating system + +By default, the default directories depend on the Operating System. The defaults are listed below: + +| Operating System | Cache Path | Configuration Path | Data Path | +|------------------|---------------------------|--------------------------------|-------------------------| +| Linux | $HOME/.cache/helm | $HOME/.config/helm | $HOME/.local/share/helm | +| macOS | $HOME/Library/Caches/helm | $HOME/Library/Preferences/helm | $HOME/Library/helm | +| Windows | %TEMP%\helm | %APPDATA%\helm | %APPDATA%\helm | +` + +func newRootCmd(actionConfig *action.Configuration, out io.Writer, args []string) (*cobra.Command, error) { + cmd := &cobra.Command{ + Use: "helm", + Short: "The Helm package manager for Kubernetes.", + Long: globalUsage, + SilenceUsage: true, + } + flags := cmd.PersistentFlags() + + settings.AddFlags(flags) + addKlogFlags(flags) + + // Setup shell completion for the namespace flag + err := cmd.RegisterFlagCompletionFunc("namespace", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if client, err := actionConfig.KubernetesClientSet(); err == nil { + // Choose a long enough timeout that the user notices something is not working + // but short enough that the user is not made to wait very long + to := int64(3) + cobra.CompDebugln(fmt.Sprintf("About to call kube client for namespaces with timeout of: %d", to), settings.Debug) + + nsNames := []string{} + if namespaces, err := client.CoreV1().Namespaces().List(context.Background(), metav1.ListOptions{TimeoutSeconds: &to}); err == nil { + for _, ns := range namespaces.Items { + nsNames = append(nsNames, ns.Name) + } + return nsNames, cobra.ShellCompDirectiveNoFileComp + } + } + return nil, cobra.ShellCompDirectiveDefault + }) + + if err != nil { + log.Fatal(err) + } + + // Setup shell completion for the kube-context flag + err = cmd.RegisterFlagCompletionFunc("kube-context", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + cobra.CompDebugln("About to get the different kube-contexts", settings.Debug) + + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + if len(settings.KubeConfig) > 0 { + loadingRules = &clientcmd.ClientConfigLoadingRules{ExplicitPath: settings.KubeConfig} + } + if config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + loadingRules, + &clientcmd.ConfigOverrides{}).RawConfig(); err == nil { + comps := []string{} + for name, context := range config.Contexts { + comps = append(comps, fmt.Sprintf("%s\t%s", name, context.Cluster)) + } + return comps, cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp + }) + + if err != nil { + log.Fatal(err) + } + + // We can safely ignore any errors that flags.Parse encounters since + // those errors will be caught later during the call to cmd.Execution. + // This call is required to gather configuration information prior to + // execution. + flags.ParseErrorsWhitelist.UnknownFlags = true + flags.Parse(args) + + registryClient, err := newDefaultRegistryClient(false) + if err != nil { + return nil, err + } + actionConfig.RegistryClient = registryClient + + // Add subcommands + cmd.AddCommand( + // chart commands + newCreateCmd(out), + newDependencyCmd(actionConfig, out), + newPullCmd(actionConfig, out), + newShowCmd(actionConfig, out), + newLintCmd(out), + newPackageCmd(actionConfig, out), + newRepoCmd(out), + newSearchCmd(out), + newVerifyCmd(out), + + // release commands + newGetCmd(actionConfig, out), + newHistoryCmd(actionConfig, out), + newInstallCmd(actionConfig, out), + newListCmd(actionConfig, out), + newReleaseTestCmd(actionConfig, out), + newRollbackCmd(actionConfig, out), + newStatusCmd(actionConfig, out), + newTemplateCmd(actionConfig, out), + newUninstallCmd(actionConfig, out), + newUpgradeCmd(actionConfig, out), + + newCompletionCmd(out), + newEnvCmd(out), + newPluginCmd(out), + newVersionCmd(out), + + // Hidden documentation generator command: 'helm docs' + newDocsCmd(out), + ) + + cmd.AddCommand( + newRegistryCmd(actionConfig, out), + newPushCmd(actionConfig, out), + ) + + // Find and add plugins + loadPlugins(cmd, out) + + // Check permissions on critical files + checkPerms() + + // Check for expired repositories + checkForExpiredRepos(settings.RepositoryConfig) + + return cmd, nil +} + +func checkForExpiredRepos(repofile string) { + + expiredRepos := []struct { + name string + old string + new string + }{ + { + name: "stable", + old: "kubernetes-charts.storage.googleapis.com", + new: "https://charts.helm.sh/stable", + }, + { + name: "incubator", + old: "kubernetes-charts-incubator.storage.googleapis.com", + new: "https://charts.helm.sh/incubator", + }, + } + + // parse repo file. + // Ignore the error because it is okay for a repo file to be unparseable at this + // stage. Later checks will trap the error and respond accordingly. + repoFile, err := repo.LoadFile(repofile) + if err != nil { + return + } + + for _, exp := range expiredRepos { + r := repoFile.Get(exp.name) + if r == nil { + return + } + + if url := r.URL; strings.Contains(url, exp.old) { + fmt.Fprintf( + os.Stderr, + "WARNING: %q is deprecated for %q and will be deleted Nov. 13, 2020.\nWARNING: You should switch to %q via:\nWARNING: helm repo add %q %q --force-update\n", + exp.old, + exp.name, + exp.new, + exp.name, + exp.new, + ) + } + } + +} + +func newRegistryClient(certFile, keyFile, caFile string, insecureSkipTLSverify, plainHTTP bool) (*registry.Client, error) { + if certFile != "" && keyFile != "" || caFile != "" || insecureSkipTLSverify { + registryClient, err := newRegistryClientWithTLS(certFile, keyFile, caFile, insecureSkipTLSverify) + if err != nil { + return nil, err + } + return registryClient, nil + } + registryClient, err := newDefaultRegistryClient(plainHTTP) + if err != nil { + return nil, err + } + return registryClient, nil +} + +func newDefaultRegistryClient(plainHTTP bool) (*registry.Client, error) { + opts := []registry.ClientOption{ + registry.ClientOptDebug(settings.Debug), + registry.ClientOptEnableCache(true), + registry.ClientOptWriter(os.Stderr), + registry.ClientOptCredentialsFile(settings.RegistryConfig), + } + if plainHTTP { + opts = append(opts, registry.ClientOptPlainHTTP()) + } + + // Create a new registry client + registryClient, err := registry.NewClient(opts...) + if err != nil { + return nil, err + } + return registryClient, nil +} + +func newRegistryClientWithTLS(certFile, keyFile, caFile string, insecureSkipTLSverify bool) (*registry.Client, error) { + // Create a new registry client + registryClient, err := registry.NewRegistryClientWithTLS(os.Stderr, certFile, keyFile, caFile, insecureSkipTLSverify, + settings.RegistryConfig, settings.Debug, + ) + if err != nil { + return nil, err + } + return registryClient, nil +} diff --git a/cmd/helm/root_test.go b/cmd/helm/root_test.go new file mode 100644 index 00000000..65e6d66c --- /dev/null +++ b/cmd/helm/root_test.go @@ -0,0 +1,131 @@ +/* +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 ( + "os" + "path/filepath" + "testing" + + "helm.sh/helm/v3/internal/test/ensure" + "helm.sh/helm/v3/pkg/helmpath" + "helm.sh/helm/v3/pkg/helmpath/xdg" +) + +func TestRootCmd(t *testing.T) { + defer resetEnv()() + + tests := []struct { + name, args, cachePath, configPath, dataPath string + envvars map[string]string + }{ + { + name: "defaults", + args: "env", + }, + { + name: "with $XDG_CACHE_HOME set", + args: "env", + envvars: map[string]string{xdg.CacheHomeEnvVar: "/bar"}, + cachePath: "/bar/helm", + }, + { + name: "with $XDG_CONFIG_HOME set", + args: "env", + envvars: map[string]string{xdg.ConfigHomeEnvVar: "/bar"}, + configPath: "/bar/helm", + }, + { + name: "with $XDG_DATA_HOME set", + args: "env", + envvars: map[string]string{xdg.DataHomeEnvVar: "/bar"}, + dataPath: "/bar/helm", + }, + { + name: "with $HELM_CACHE_HOME set", + args: "env", + envvars: map[string]string{helmpath.CacheHomeEnvVar: "/foo/helm"}, + cachePath: "/foo/helm", + }, + { + name: "with $HELM_CONFIG_HOME set", + args: "env", + envvars: map[string]string{helmpath.ConfigHomeEnvVar: "/foo/helm"}, + configPath: "/foo/helm", + }, + { + name: "with $HELM_DATA_HOME set", + args: "env", + envvars: map[string]string{helmpath.DataHomeEnvVar: "/foo/helm"}, + dataPath: "/foo/helm", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ensure.HelmHome(t) + + for k, v := range tt.envvars { + os.Setenv(k, v) + } + + if _, _, err := executeActionCommand(tt.args); err != nil { + t.Fatalf("unexpected error: %s", err) + } + + // NOTE(bacongobbler): we need to check here after calling ensure.HelmHome so we + // load the proper paths after XDG_*_HOME is set + if tt.cachePath == "" { + tt.cachePath = filepath.Join(os.Getenv(xdg.CacheHomeEnvVar), "helm") + } + + if tt.configPath == "" { + tt.configPath = filepath.Join(os.Getenv(xdg.ConfigHomeEnvVar), "helm") + } + + if tt.dataPath == "" { + tt.dataPath = filepath.Join(os.Getenv(xdg.DataHomeEnvVar), "helm") + } + + if helmpath.CachePath() != tt.cachePath { + t.Errorf("expected cache path %q, got %q", tt.cachePath, helmpath.CachePath()) + } + if helmpath.ConfigPath() != tt.configPath { + t.Errorf("expected config path %q, got %q", tt.configPath, helmpath.ConfigPath()) + } + if helmpath.DataPath() != tt.dataPath { + t.Errorf("expected data path %q, got %q", tt.dataPath, helmpath.DataPath()) + } + }) + } +} + +func TestUnknownSubCmd(t *testing.T) { + _, _, err := executeActionCommand("foobar") + + if err == nil || err.Error() != `unknown command "foobar" for "helm"` { + t.Errorf("Expect unknown command error, got %q", err) + } +} + +// Need the release of Cobra following 1.0 to be able to disable +// file completion on the root command. Until then, we cannot +// because it would break 'helm help ' +// +// func TestRootFileCompletion(t *testing.T) { +// checkFileCompletion(t, "", false) +// } diff --git a/cmd/helm/root_unix.go b/cmd/helm/root_unix.go new file mode 100644 index 00000000..92fa1b59 --- /dev/null +++ b/cmd/helm/root_unix.go @@ -0,0 +1,58 @@ +//go:build !windows + +/* +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 ( + "os" + "os/user" + "path/filepath" +) + +func checkPerms() { + // This function MUST NOT FAIL, as it is just a check for a common permissions problem. + // If for some reason the function hits a stopping condition, it may panic. But only if + // we can be sure that it is panicking because Helm cannot proceed. + + kc := settings.KubeConfig + if kc == "" { + kc = os.Getenv("KUBECONFIG") + } + if kc == "" { + u, err := user.Current() + if err != nil { + // No idea where to find KubeConfig, so return silently. Many helm commands + // can proceed happily without a KUBECONFIG, so this is not a fatal error. + return + } + kc = filepath.Join(u.HomeDir, ".kube", "config") + } + fi, err := os.Stat(kc) + if err != nil { + // DO NOT error if no KubeConfig is found. Not all commands require one. + return + } + + perm := fi.Mode().Perm() + if perm&0040 > 0 { + warning("Kubernetes configuration file is group-readable. This is insecure. Location: %s", kc) + } + if perm&0004 > 0 { + warning("Kubernetes configuration file is world-readable. This is insecure. Location: %s", kc) + } +} diff --git a/cmd/helm/root_unix_test.go b/cmd/helm/root_unix_test.go new file mode 100644 index 00000000..f7466a93 --- /dev/null +++ b/cmd/helm/root_unix_test.go @@ -0,0 +1,82 @@ +//go:build !windows + +/* +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" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +func checkPermsStderr() (string, error) { + r, w, err := os.Pipe() + if err != nil { + return "", err + } + + stderr := os.Stderr + os.Stderr = w + defer func() { + os.Stderr = stderr + }() + + checkPerms() + w.Close() + + var text bytes.Buffer + io.Copy(&text, r) + return text.String(), nil +} + +func TestCheckPerms(t *testing.T) { + tdir := t.TempDir() + tfile := filepath.Join(tdir, "testconfig") + fh, err := os.OpenFile(tfile, os.O_CREATE|os.O_APPEND|os.O_RDWR, 0440) + if err != nil { + t.Errorf("Failed to create temp file: %s", err) + } + + tconfig := settings.KubeConfig + settings.KubeConfig = tfile + defer func() { settings.KubeConfig = tconfig }() + + text, err := checkPermsStderr() + if err != nil { + t.Fatalf("could not read from stderr: %s", err) + } + expectPrefix := "WARNING: Kubernetes configuration file is group-readable. This is insecure. Location:" + if !strings.HasPrefix(text, expectPrefix) { + t.Errorf("Expected to get a warning for group perms. Got %q", text) + } + + if err := fh.Chmod(0404); err != nil { + t.Errorf("Could not change mode on file: %s", err) + } + text, err = checkPermsStderr() + if err != nil { + t.Fatalf("could not read from stderr: %s", err) + } + expectPrefix = "WARNING: Kubernetes configuration file is world-readable. This is insecure. Location:" + if !strings.HasPrefix(text, expectPrefix) { + t.Errorf("Expected to get a warning for world perms. Got %q", text) + } +} diff --git a/cmd/helm/root_windows.go b/cmd/helm/root_windows.go new file mode 100644 index 00000000..7b5000f4 --- /dev/null +++ b/cmd/helm/root_windows.go @@ -0,0 +1,22 @@ +/* +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 + +func checkPerms() { + // Not yet implemented on Windows. If you know how to do a comprehensive perms + // check on Windows, contributions welcomed! +} diff --git a/cmd/helm/search.go b/cmd/helm/search.go new file mode 100644 index 00000000..6c62d5d2 --- /dev/null +++ b/cmd/helm/search.go @@ -0,0 +1,43 @@ +/* +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" +) + +const searchDesc = ` +Search provides the ability to search for Helm charts in the various places +they can be stored including the Artifact Hub and repositories you have added. +Use search subcommands to search different locations for charts. +` + +func newSearchCmd(out io.Writer) *cobra.Command { + + cmd := &cobra.Command{ + Use: "search [keyword]", + Short: "search for a keyword in charts", + Long: searchDesc, + } + + cmd.AddCommand(newSearchHubCmd(out)) + cmd.AddCommand(newSearchRepoCmd(out)) + + return cmd +} diff --git a/cmd/helm/search/search.go b/cmd/helm/search/search.go new file mode 100644 index 00000000..ac29b27c --- /dev/null +++ b/cmd/helm/search/search.go @@ -0,0 +1,227 @@ +/* +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 search provides client-side repository searching. + +This supports building an in-memory search index based on the contents of +multiple repositories, and then using string matching or regular expressions +to find matches. +*/ +package search + +import ( + "path" + "regexp" + "sort" + "strings" + + "github.com/Masterminds/semver/v3" + + "helm.sh/helm/v3/pkg/repo" +) + +// Result is a search result. +// +// Score indicates how close it is to match. The higher the score, the longer +// the distance. +type Result struct { + Name string + Score int + Chart *repo.ChartVersion +} + +// Index is a searchable index of chart information. +type Index struct { + lines map[string]string + charts map[string]*repo.ChartVersion +} + +const sep = "\v" + +// NewIndex creates a new Index. +func NewIndex() *Index { + return &Index{lines: map[string]string{}, charts: map[string]*repo.ChartVersion{}} +} + +// verSep is a separator for version fields in map keys. +const verSep = "$$" + +// AddRepo adds a repository index to the search index. +func (i *Index) AddRepo(rname string, ind *repo.IndexFile, all bool) { + ind.SortEntries() + for name, ref := range ind.Entries { + if len(ref) == 0 { + // Skip chart names that have zero releases. + continue + } + // By convention, an index file is supposed to have the newest at the + // 0 slot, so our best bet is to grab the 0 entry and build the index + // entry off of that. + // Note: Do not use filePath.Join since on Windows it will return \ + // which results in a repo name that cannot be understood. + fname := path.Join(rname, name) + if !all { + i.lines[fname] = indstr(rname, ref[0]) + i.charts[fname] = ref[0] + continue + } + + // If 'all' is set, then we go through all of the refs, and add them all + // to the index. This will generate a lot of near-duplicate entries. + for _, rr := range ref { + versionedName := fname + verSep + rr.Version + i.lines[versionedName] = indstr(rname, rr) + i.charts[versionedName] = rr + } + } +} + +// All returns all charts in the index as if they were search results. +// +// Each will be given a score of 0. +func (i *Index) All() []*Result { + res := make([]*Result, len(i.charts)) + j := 0 + for name, ch := range i.charts { + parts := strings.Split(name, verSep) + res[j] = &Result{ + Name: parts[0], + Chart: ch, + } + j++ + } + return res +} + +// Search searches an index for the given term. +// +// Threshold indicates the maximum score a term may have before being marked +// irrelevant. (Low score means higher relevance. Golf, not bowling.) +// +// If regexp is true, the term is treated as a regular expression. Otherwise, +// term is treated as a literal string. +func (i *Index) Search(term string, threshold int, regexp bool) ([]*Result, error) { + if regexp { + return i.SearchRegexp(term, threshold) + } + return i.SearchLiteral(term, threshold), nil +} + +// calcScore calculates a score for a match. +func (i *Index) calcScore(index int, matchline string) int { + + // This is currently tied to the fact that sep is a single char. + splits := []int{} + s := rune(sep[0]) + for i, ch := range matchline { + if ch == s { + splits = append(splits, i) + } + } + + for i, pos := range splits { + if index > pos { + continue + } + return i + } + return len(splits) +} + +// SearchLiteral does a literal string search (no regexp). +func (i *Index) SearchLiteral(term string, threshold int) []*Result { + term = strings.ToLower(term) + buf := []*Result{} + for k, v := range i.lines { + lv := strings.ToLower(v) + res := strings.Index(lv, term) + if score := i.calcScore(res, lv); res != -1 && score < threshold { + parts := strings.Split(k, verSep) // Remove version, if it is there. + buf = append(buf, &Result{Name: parts[0], Score: score, Chart: i.charts[k]}) + } + } + return buf +} + +// SearchRegexp searches using a regular expression. +func (i *Index) SearchRegexp(re string, threshold int) ([]*Result, error) { + matcher, err := regexp.Compile(re) + if err != nil { + return []*Result{}, err + } + buf := []*Result{} + for k, v := range i.lines { + ind := matcher.FindStringIndex(v) + if len(ind) == 0 { + continue + } + if score := i.calcScore(ind[0], v); ind[0] >= 0 && score < threshold { + parts := strings.Split(k, verSep) // Remove version, if it is there. + buf = append(buf, &Result{Name: parts[0], Score: score, Chart: i.charts[k]}) + } + } + return buf, nil +} + +// SortScore does an in-place sort of the results. +// +// Lowest scores are highest on the list. Matching scores are subsorted alphabetically. +func SortScore(r []*Result) { + sort.Sort(scoreSorter(r)) +} + +// scoreSorter sorts results by score, and subsorts by alpha Name. +type scoreSorter []*Result + +// Len returns the length of this scoreSorter. +func (s scoreSorter) Len() int { return len(s) } + +// Swap performs an in-place swap. +func (s scoreSorter) Swap(i, j int) { s[i], s[j] = s[j], s[i] } + +// Less compares a to b, and returns true if a is less than b. +func (s scoreSorter) Less(a, b int) bool { + first := s[a] + second := s[b] + + if first.Score > second.Score { + return false + } + if first.Score < second.Score { + return true + } + if first.Name == second.Name { + v1, err := semver.NewVersion(first.Chart.Version) + if err != nil { + return true + } + v2, err := semver.NewVersion(second.Chart.Version) + if err != nil { + return true + } + // Sort so that the newest chart is higher than the oldest chart. This is + // the opposite of what you'd expect in a function called Less. + return v1.GreaterThan(v2) + } + return first.Name < second.Name +} + +func indstr(name string, ref *repo.ChartVersion) string { + i := ref.Name + sep + name + "/" + ref.Name + sep + + ref.Description + sep + strings.Join(ref.Keywords, " ") + return i +} diff --git a/cmd/helm/search/search_test.go b/cmd/helm/search/search_test.go new file mode 100644 index 00000000..415c085b --- /dev/null +++ b/cmd/helm/search/search_test.go @@ -0,0 +1,311 @@ +/* +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 search + +import ( + "strings" + "testing" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/repo" +) + +func TestSortScore(t *testing.T) { + in := []*Result{ + {Name: "bbb", Score: 0, Chart: &repo.ChartVersion{Metadata: &chart.Metadata{Version: "1.2.3"}}}, + {Name: "aaa", Score: 5}, + {Name: "abb", Score: 5}, + {Name: "aab", Score: 0}, + {Name: "bab", Score: 5}, + {Name: "ver", Score: 5, Chart: &repo.ChartVersion{Metadata: &chart.Metadata{Version: "1.2.4"}}}, + {Name: "ver", Score: 5, Chart: &repo.ChartVersion{Metadata: &chart.Metadata{Version: "1.2.3"}}}, + } + expect := []string{"aab", "bbb", "aaa", "abb", "bab", "ver", "ver"} + expectScore := []int{0, 0, 5, 5, 5, 5, 5} + SortScore(in) + + // Test Score + for i := 0; i < len(expectScore); i++ { + if expectScore[i] != in[i].Score { + t.Errorf("Sort error on index %d: expected %d, got %d", i, expectScore[i], in[i].Score) + } + } + // Test Name + for i := 0; i < len(expect); i++ { + if expect[i] != in[i].Name { + t.Errorf("Sort error: expected %s, got %s", expect[i], in[i].Name) + } + } + + // Test version of last two items + if in[5].Chart.Version != "1.2.4" { + t.Errorf("Expected 1.2.4, got %s", in[5].Chart.Version) + } + if in[6].Chart.Version != "1.2.3" { + t.Error("Expected 1.2.3 to be last") + } +} + +var indexfileEntries = map[string]repo.ChartVersions{ + "niña": { + { + URLs: []string{"http://example.com/charts/nina-0.1.0.tgz"}, + Metadata: &chart.Metadata{ + Name: "niña", + Version: "0.1.0", + Description: "One boat", + }, + }, + }, + "pinta": { + { + URLs: []string{"http://example.com/charts/pinta-0.1.0.tgz"}, + Metadata: &chart.Metadata{ + Name: "pinta", + Version: "0.1.0", + Description: "Two ship", + }, + }, + }, + "santa-maria": { + { + URLs: []string{"http://example.com/charts/santa-maria-1.2.3.tgz"}, + Metadata: &chart.Metadata{ + Name: "santa-maria", + Version: "1.2.3", + Description: "Three boat", + }, + }, + { + URLs: []string{"http://example.com/charts/santa-maria-1.2.2-rc-1.tgz"}, + Metadata: &chart.Metadata{ + Name: "santa-maria", + Version: "1.2.2-RC-1", + Description: "Three boat", + }, + }, + }, +} + +func loadTestIndex(_ *testing.T, all bool) *Index { + i := NewIndex() + i.AddRepo("testing", &repo.IndexFile{Entries: indexfileEntries}, all) + i.AddRepo("ztesting", &repo.IndexFile{Entries: map[string]repo.ChartVersions{ + "Pinta": { + { + URLs: []string{"http://example.com/charts/pinta-2.0.0.tgz"}, + Metadata: &chart.Metadata{ + Name: "Pinta", + Version: "2.0.0", + Description: "Two ship, version two", + }, + }, + }, + }}, all) + return i +} + +func TestAll(t *testing.T) { + i := loadTestIndex(t, false) + all := i.All() + if len(all) != 4 { + t.Errorf("Expected 4 entries, got %d", len(all)) + } + + i = loadTestIndex(t, true) + all = i.All() + if len(all) != 5 { + t.Errorf("Expected 5 entries, got %d", len(all)) + } +} + +func TestAddRepo_Sort(t *testing.T) { + i := loadTestIndex(t, true) + sr, err := i.Search("TESTING/SANTA-MARIA", 100, false) + if err != nil { + t.Fatal(err) + } + SortScore(sr) + + ch := sr[0] + expect := "1.2.3" + if ch.Chart.Version != expect { + t.Errorf("Expected %q, got %q", expect, ch.Chart.Version) + } +} + +func TestSearchByName(t *testing.T) { + + tests := []struct { + name string + query string + expect []*Result + regexp bool + fail bool + failMsg string + }{ + { + name: "basic search for one result", + query: "santa-maria", + expect: []*Result{ + {Name: "testing/santa-maria"}, + }, + }, + { + name: "basic search for two results", + query: "pinta", + expect: []*Result{ + {Name: "testing/pinta"}, + {Name: "ztesting/Pinta"}, + }, + }, + { + name: "repo-specific search for one result", + query: "ztesting/pinta", + expect: []*Result{ + {Name: "ztesting/Pinta"}, + }, + }, + { + name: "partial name search", + query: "santa", + expect: []*Result{ + {Name: "testing/santa-maria"}, + }, + }, + { + name: "description search, one result", + query: "Three", + expect: []*Result{ + {Name: "testing/santa-maria"}, + }, + }, + { + name: "description search, two results", + query: "two", + expect: []*Result{ + {Name: "testing/pinta"}, + {Name: "ztesting/Pinta"}, + }, + }, + { + name: "search mixedCase and result should be mixedCase too", + query: "pinta", + expect: []*Result{ + {Name: "testing/pinta"}, + {Name: "ztesting/Pinta"}, + }, + }, + { + name: "description upper search, two results", + query: "TWO", + expect: []*Result{ + {Name: "testing/pinta"}, + {Name: "ztesting/Pinta"}, + }, + }, + { + name: "nothing found", + query: "mayflower", + expect: []*Result{}, + }, + { + name: "regexp, one result", + query: "Th[ref]*", + expect: []*Result{ + {Name: "testing/santa-maria"}, + }, + regexp: true, + }, + { + name: "regexp, fail compile", + query: "th[", + expect: []*Result{}, + regexp: true, + fail: true, + failMsg: "error parsing regexp:", + }, + } + + i := loadTestIndex(t, false) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + charts, err := i.Search(tt.query, 100, tt.regexp) + if err != nil { + if tt.fail { + if !strings.Contains(err.Error(), tt.failMsg) { + t.Fatalf("Unexpected error message: %s", err) + } + return + } + t.Fatalf("%s: %s", tt.name, err) + } + // Give us predictably ordered results. + SortScore(charts) + + l := len(charts) + if l != len(tt.expect) { + t.Fatalf("Expected %d result, got %d", len(tt.expect), l) + } + // For empty result sets, just keep going. + if l == 0 { + return + } + + for i, got := range charts { + ex := tt.expect[i] + if got.Name != ex.Name { + t.Errorf("[%d]: Expected name %q, got %q", i, ex.Name, got.Name) + } + } + + }) + } +} + +func TestSearchByNameAll(t *testing.T) { + // Test with the All bit turned on. + i := loadTestIndex(t, true) + cs, err := i.Search("santa-maria", 100, false) + if err != nil { + t.Fatal(err) + } + if len(cs) != 2 { + t.Errorf("expected 2 charts, got %d", len(cs)) + } +} + +func TestCalcScore(t *testing.T) { + i := NewIndex() + + fields := []string{"aaa", "bbb", "ccc", "ddd"} + matchline := strings.Join(fields, sep) + if r := i.calcScore(2, matchline); r != 0 { + t.Errorf("Expected 0, got %d", r) + } + if r := i.calcScore(5, matchline); r != 1 { + t.Errorf("Expected 1, got %d", r) + } + if r := i.calcScore(10, matchline); r != 2 { + t.Errorf("Expected 2, got %d", r) + } + if r := i.calcScore(14, matchline); r != 3 { + t.Errorf("Expected 3, got %d", r) + } +} diff --git a/cmd/helm/search_hub.go b/cmd/helm/search_hub.go new file mode 100644 index 00000000..1618a4c9 --- /dev/null +++ b/cmd/helm/search_hub.go @@ -0,0 +1,198 @@ +/* +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 ( + "fmt" + "io" + "strings" + + "github.com/gosuri/uitable" + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "helm.sh/helm/v3/internal/monocular" + "helm.sh/helm/v3/pkg/cli/output" +) + +const searchHubDesc = ` +Search for Helm charts in the Artifact Hub or your own hub instance. + +Artifact Hub is a web-based application that enables finding, installing, and +publishing packages and configurations for CNCF projects, including publicly +available distributed charts Helm charts. It is a Cloud Native Computing +Foundation sandbox project. You can browse the hub at https://artifacthub.io/ + +The [KEYWORD] argument accepts either a keyword string, or quoted string of rich +query options. For rich query options documentation, see +https://artifacthub.github.io/hub/api/?urls.primaryName=Monocular%20compatible%20search%20API#/Monocular/get_api_chartsvc_v1_charts_search + +Previous versions of Helm used an instance of Monocular as the default +'endpoint', so for backwards compatibility Artifact Hub is compatible with the +Monocular search API. Similarly, when setting the 'endpoint' flag, the specified +endpoint must also be implement a Monocular compatible search API endpoint. +Note that when specifying a Monocular instance as the 'endpoint', rich queries +are not supported. For API details, see https://github.com/helm/monocular +` + +type searchHubOptions struct { + searchEndpoint string + maxColWidth uint + outputFormat output.Format + listRepoURL bool + failOnNoResult bool +} + +func newSearchHubCmd(out io.Writer) *cobra.Command { + o := &searchHubOptions{} + + cmd := &cobra.Command{ + Use: "hub [KEYWORD]", + Short: "search for charts in the Artifact Hub or your own hub instance", + Long: searchHubDesc, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(out, args) + }, + } + + f := cmd.Flags() + f.StringVar(&o.searchEndpoint, "endpoint", "https://hub.helm.sh", "Hub instance to query for charts") + f.UintVar(&o.maxColWidth, "max-col-width", 50, "maximum column width for output table") + f.BoolVar(&o.listRepoURL, "list-repo-url", false, "print charts repository URL") + f.BoolVar(&o.failOnNoResult, "fail-on-no-result", false, "search fails if no results are found") + + bindOutputFlag(cmd, &o.outputFormat) + + return cmd +} + +func (o *searchHubOptions) run(out io.Writer, args []string) error { + c, err := monocular.New(o.searchEndpoint) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("unable to create connection to %q", o.searchEndpoint)) + } + + q := strings.Join(args, " ") + results, err := c.Search(q) + if err != nil { + debug("%s", err) + return fmt.Errorf("unable to perform search against %q", o.searchEndpoint) + } + + return o.outputFormat.Write(out, newHubSearchWriter(results, o.searchEndpoint, o.maxColWidth, o.listRepoURL, o.failOnNoResult)) +} + +type hubChartRepo struct { + URL string `json:"url"` + Name string `json:"name"` +} + +type hubChartElement struct { + URL string `json:"url"` + Version string `json:"version"` + AppVersion string `json:"app_version"` + Description string `json:"description"` + Repository hubChartRepo `json:"repository"` +} + +type hubSearchWriter struct { + elements []hubChartElement + columnWidth uint + listRepoURL bool + failOnNoResult bool +} + +func newHubSearchWriter(results []monocular.SearchResult, endpoint string, columnWidth uint, listRepoURL, failOnNoResult bool) *hubSearchWriter { + var elements []hubChartElement + for _, r := range results { + // Backwards compatibility for Monocular + url := endpoint + "/charts/" + r.ID + + // Check for artifactHub compatibility + if r.ArtifactHub.PackageURL != "" { + url = r.ArtifactHub.PackageURL + } + + elements = append(elements, hubChartElement{url, r.Relationships.LatestChartVersion.Data.Version, r.Relationships.LatestChartVersion.Data.AppVersion, r.Attributes.Description, hubChartRepo{URL: r.Attributes.Repo.URL, Name: r.Attributes.Repo.Name}}) + } + return &hubSearchWriter{elements, columnWidth, listRepoURL, failOnNoResult} +} + +func (h *hubSearchWriter) WriteTable(out io.Writer) error { + if len(h.elements) == 0 { + // Fail if no results found and --fail-on-no-result is enabled + if h.failOnNoResult { + return fmt.Errorf("no results found") + } + + _, err := out.Write([]byte("No results found\n")) + if err != nil { + return fmt.Errorf("unable to write results: %s", err) + } + return nil + } + table := uitable.New() + table.MaxColWidth = h.columnWidth + + if h.listRepoURL { + table.AddRow("URL", "CHART VERSION", "APP VERSION", "DESCRIPTION", "REPO URL") + } else { + table.AddRow("URL", "CHART VERSION", "APP VERSION", "DESCRIPTION") + } + + for _, r := range h.elements { + if h.listRepoURL { + table.AddRow(r.URL, r.Version, r.AppVersion, r.Description, r.Repository.URL) + } else { + table.AddRow(r.URL, r.Version, r.AppVersion, r.Description) + } + } + return output.EncodeTable(out, table) +} + +func (h *hubSearchWriter) WriteJSON(out io.Writer) error { + return h.encodeByFormat(out, output.JSON) +} + +func (h *hubSearchWriter) WriteYAML(out io.Writer) error { + return h.encodeByFormat(out, output.YAML) +} + +func (h *hubSearchWriter) encodeByFormat(out io.Writer, format output.Format) error { + // Fail if no results found and --fail-on-no-result is enabled + if len(h.elements) == 0 && h.failOnNoResult { + return fmt.Errorf("no results found") + } + + // Initialize the array so no results returns an empty array instead of null + chartList := make([]hubChartElement, 0, len(h.elements)) + + for _, r := range h.elements { + chartList = append(chartList, hubChartElement{r.URL, r.Version, r.AppVersion, r.Description, r.Repository}) + } + + switch format { + case output.JSON: + return output.EncodeJSON(out, chartList) + case output.YAML: + return output.EncodeYAML(out, chartList) + } + + // Because this is a non-exported function and only called internally by + // WriteJSON and WriteYAML, we shouldn't get invalid types + return nil +} diff --git a/cmd/helm/search_hub_test.go b/cmd/helm/search_hub_test.go new file mode 100644 index 00000000..89ce2b3e --- /dev/null +++ b/cmd/helm/search_hub_test.go @@ -0,0 +1,187 @@ +/* +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 ( + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSearchHubCmd(t *testing.T) { + + // Setup a mock search service + var searchResult = `{"data":[{"id":"stable/phpmyadmin","type":"chart","attributes":{"name":"phpmyadmin","repo":{"name":"stable","url":"https://charts.helm.sh/stable"},"description":"phpMyAdmin is an mysql administration frontend","home":"https://www.phpmyadmin.net/","keywords":["mariadb","mysql","phpmyadmin"],"maintainers":[{"name":"Bitnami","email":"containers@bitnami.com"}],"sources":["https://github.com/bitnami/bitnami-docker-phpmyadmin"],"icon":""},"links":{"self":"/v1/charts/stable/phpmyadmin"},"relationships":{"latestChartVersion":{"data":{"version":"3.0.0","app_version":"4.9.0-1","created":"2019-08-08T17:57:31.38Z","digest":"119c499251bffd4b06ff0cd5ac98c2ce32231f84899fb4825be6c2d90971c742","urls":["https://charts.helm.sh/stable/phpmyadmin-3.0.0.tgz"],"readme":"/v1/assets/stable/phpmyadmin/versions/3.0.0/README.md","values":"/v1/assets/stable/phpmyadmin/versions/3.0.0/values.yaml"},"links":{"self":"/v1/charts/stable/phpmyadmin/versions/3.0.0"}}}},{"id":"bitnami/phpmyadmin","type":"chart","attributes":{"name":"phpmyadmin","repo":{"name":"bitnami","url":"https://charts.bitnami.com"},"description":"phpMyAdmin is an mysql administration frontend","home":"https://www.phpmyadmin.net/","keywords":["mariadb","mysql","phpmyadmin"],"maintainers":[{"name":"Bitnami","email":"containers@bitnami.com"}],"sources":["https://github.com/bitnami/bitnami-docker-phpmyadmin"],"icon":""},"links":{"self":"/v1/charts/bitnami/phpmyadmin"},"relationships":{"latestChartVersion":{"data":{"version":"3.0.0","app_version":"4.9.0-1","created":"2019-08-08T18:34:13.341Z","digest":"66d77cf6d8c2b52c488d0a294cd4996bd5bad8dc41d3829c394498fb401c008a","urls":["https://charts.bitnami.com/bitnami/phpmyadmin-3.0.0.tgz"],"readme":"/v1/assets/bitnami/phpmyadmin/versions/3.0.0/README.md","values":"/v1/assets/bitnami/phpmyadmin/versions/3.0.0/values.yaml"},"links":{"self":"/v1/charts/bitnami/phpmyadmin/versions/3.0.0"}}}}]}` + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, searchResult) + })) + defer ts.Close() + + // The expected output has the URL to the mocked search service in it + // Trailing spaces are necessary to preserve in "expected" as the uitable package adds + // them during printing. + var expected = fmt.Sprintf(`URL CHART VERSION APP VERSION DESCRIPTION +%s/charts/stable/phpmyadmin 3.0.0 4.9.0-1 phpMyAdmin is an mysql administration frontend +%s/charts/bitnami/phpmyadmin 3.0.0 4.9.0-1 phpMyAdmin is an mysql administration frontend +`, ts.URL, ts.URL) + + testcmd := "search hub --endpoint " + ts.URL + " maria" + storage := storageFixture() + _, out, err := executeActionCommandC(storage, testcmd) + if err != nil { + t.Errorf("unexpected error, %s", err) + } + if out != expected { + t.Error("expected and actual output did not match") + t.Log(out) + t.Log(expected) + } +} + +func TestSearchHubListRepoCmd(t *testing.T) { + + // Setup a mock search service + var searchResult = `{"data":[{"id":"stable/phpmyadmin","type":"chart","attributes":{"name":"phpmyadmin","repo":{"name":"stable","url":"https://charts.helm.sh/stable"},"description":"phpMyAdmin is an mysql administration frontend","home":"https://www.phpmyadmin.net/","keywords":["mariadb","mysql","phpmyadmin"],"maintainers":[{"name":"Bitnami","email":"containers@bitnami.com"}],"sources":["https://github.com/bitnami/bitnami-docker-phpmyadmin"],"icon":""},"links":{"self":"/v1/charts/stable/phpmyadmin"},"relationships":{"latestChartVersion":{"data":{"version":"3.0.0","app_version":"4.9.0-1","created":"2019-08-08T17:57:31.38Z","digest":"119c499251bffd4b06ff0cd5ac98c2ce32231f84899fb4825be6c2d90971c742","urls":["https://charts.helm.sh/stable/phpmyadmin-3.0.0.tgz"],"readme":"/v1/assets/stable/phpmyadmin/versions/3.0.0/README.md","values":"/v1/assets/stable/phpmyadmin/versions/3.0.0/values.yaml"},"links":{"self":"/v1/charts/stable/phpmyadmin/versions/3.0.0"}}}},{"id":"bitnami/phpmyadmin","type":"chart","attributes":{"name":"phpmyadmin","repo":{"name":"bitnami","url":"https://charts.bitnami.com"},"description":"phpMyAdmin is an mysql administration frontend","home":"https://www.phpmyadmin.net/","keywords":["mariadb","mysql","phpmyadmin"],"maintainers":[{"name":"Bitnami","email":"containers@bitnami.com"}],"sources":["https://github.com/bitnami/bitnami-docker-phpmyadmin"],"icon":""},"links":{"self":"/v1/charts/bitnami/phpmyadmin"},"relationships":{"latestChartVersion":{"data":{"version":"3.0.0","app_version":"4.9.0-1","created":"2019-08-08T18:34:13.341Z","digest":"66d77cf6d8c2b52c488d0a294cd4996bd5bad8dc41d3829c394498fb401c008a","urls":["https://charts.bitnami.com/bitnami/phpmyadmin-3.0.0.tgz"],"readme":"/v1/assets/bitnami/phpmyadmin/versions/3.0.0/README.md","values":"/v1/assets/bitnami/phpmyadmin/versions/3.0.0/values.yaml"},"links":{"self":"/v1/charts/bitnami/phpmyadmin/versions/3.0.0"}}}}]}` + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, searchResult) + })) + defer ts.Close() + + // The expected output has the URL to the mocked search service in it + // Trailing spaces are necessary to preserve in "expected" as the uitable package adds + // them during printing. + var expected = fmt.Sprintf(`URL CHART VERSION APP VERSION DESCRIPTION REPO URL +%s/charts/stable/phpmyadmin 3.0.0 4.9.0-1 phpMyAdmin is an mysql administration frontend https://charts.helm.sh/stable +%s/charts/bitnami/phpmyadmin 3.0.0 4.9.0-1 phpMyAdmin is an mysql administration frontend https://charts.bitnami.com +`, ts.URL, ts.URL) + + testcmd := "search hub --list-repo-url --endpoint " + ts.URL + " maria" + storage := storageFixture() + _, out, err := executeActionCommandC(storage, testcmd) + if err != nil { + t.Errorf("unexpected error, %s", err) + } + if out != expected { + t.Error("expected and actual output did not match") + t.Log(out) + t.Log(expected) + } +} + +func TestSearchHubOutputCompletion(t *testing.T) { + outputFlagCompletionTest(t, "search hub") +} + +func TestSearchHubFileCompletion(t *testing.T) { + checkFileCompletion(t, "search hub", true) // File completion may be useful when inputting a keyword +} + +func TestSearchHubCmd_FailOnNoResponseTests(t *testing.T) { + var ( + searchResult = `{"data":[]}` + noResultFoundErr = "Error: no results found\n" + noResultFoundWarn = "No results found\n" + noResultFoundWarnInList = "[]\n" + ) + + type testCase struct { + name string + cmd string + response string + expected string + wantErr bool + } + + var tests = []testCase{ + { + name: "Search hub with no results in response", + cmd: `search hub maria`, + response: searchResult, + expected: noResultFoundWarn, + wantErr: false, + }, + { + name: "Search hub with no results in response and output JSON", + cmd: `search hub maria --output json`, + response: searchResult, + expected: noResultFoundWarnInList, + wantErr: false, + }, + { + name: "Search hub with no results in response and output YAML", + cmd: `search hub maria --output yaml`, + response: searchResult, + expected: noResultFoundWarnInList, + wantErr: false, + }, + { + name: "Search hub with no results in response and --fail-on-no-result enabled, expected failure", + cmd: `search hub maria --fail-on-no-result`, + response: searchResult, + expected: noResultFoundErr, + wantErr: true, + }, + { + name: "Search hub with no results in response, output JSON and --fail-on-no-result enabled, expected failure", + cmd: `search hub maria --fail-on-no-result --output json`, + response: searchResult, + expected: noResultFoundErr, + wantErr: true, + }, + { + name: "Search hub with no results in response, output YAML and --fail-on-no-result enabled, expected failure", + cmd: `search hub maria --fail-on-no-result --output yaml`, + response: searchResult, + expected: noResultFoundErr, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup a mock search service + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, tt.response) + })) + defer ts.Close() + + // Add mock server URL to command + tt.cmd += " --endpoint " + ts.URL + + storage := storageFixture() + + _, out, err := executeActionCommandC(storage, tt.cmd) + if tt.wantErr { + if err == nil { + t.Errorf("expected error due to no record in response, got nil") + } + } else { + if err != nil { + t.Errorf("unexpected error, got %q", err) + } + } + + if out != tt.expected { + t.Errorf("expected and actual output did not match\n"+ + "expected: %q\n"+ + "actual : %q", + tt.expected, out) + } + }) + } +} diff --git a/cmd/helm/search_repo.go b/cmd/helm/search_repo.go new file mode 100644 index 00000000..2c6f1709 --- /dev/null +++ b/cmd/helm/search_repo.go @@ -0,0 +1,396 @@ +/* +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 ( + "bufio" + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/gosuri/uitable" + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/search" + "helm.sh/helm/v3/pkg/cli/output" + "helm.sh/helm/v3/pkg/helmpath" + "helm.sh/helm/v3/pkg/repo" +) + +const searchRepoDesc = ` +Search reads through all of the repositories configured on the system, and +looks for matches. Search of these repositories uses the metadata stored on +the system. + +It will display the latest stable versions of the charts found. If you +specify the --devel flag, the output will include pre-release versions. +If you want to search using a version constraint, use --version. + +Examples: + + # Search for stable release versions matching the keyword "nginx" + $ helm search repo nginx + + # Search for release versions matching the keyword "nginx", including pre-release versions + $ helm search repo nginx --devel + + # Search for the latest stable release for nginx-ingress with a major version of 1 + $ helm search repo nginx-ingress --version ^1.0.0 + +Repositories are managed with 'helm repo' commands. +` + +// searchMaxScore suggests that any score higher than this is not considered a match. +const searchMaxScore = 25 + +type searchRepoOptions struct { + versions bool + regexp bool + devel bool + version string + maxColWidth uint + repoFile string + repoCacheDir string + outputFormat output.Format + failOnNoResult bool +} + +func newSearchRepoCmd(out io.Writer) *cobra.Command { + o := &searchRepoOptions{} + + cmd := &cobra.Command{ + Use: "repo [keyword]", + Short: "search repositories for a keyword in charts", + Long: searchRepoDesc, + RunE: func(cmd *cobra.Command, args []string) error { + o.repoFile = settings.RepositoryConfig + o.repoCacheDir = settings.RepositoryCache + return o.run(out, args) + }, + } + + f := cmd.Flags() + f.BoolVarP(&o.regexp, "regexp", "r", false, "use regular expressions for searching repositories you have added") + f.BoolVarP(&o.versions, "versions", "l", false, "show the long listing, with each version of each chart on its own line, for repositories you have added") + f.BoolVar(&o.devel, "devel", false, "use development versions (alpha, beta, and release candidate releases), too. Equivalent to version '>0.0.0-0'. If --version is set, this is ignored") + f.StringVar(&o.version, "version", "", "search using semantic versioning constraints on repositories you have added") + f.UintVar(&o.maxColWidth, "max-col-width", 50, "maximum column width for output table") + f.BoolVar(&o.failOnNoResult, "fail-on-no-result", false, "search fails if no results are found") + + bindOutputFlag(cmd, &o.outputFormat) + + return cmd +} + +func (o *searchRepoOptions) run(out io.Writer, args []string) error { + o.setupSearchedVersion() + + index, err := o.buildIndex() + if err != nil { + return err + } + + var res []*search.Result + if len(args) == 0 { + res = index.All() + } else { + q := strings.Join(args, " ") + res, err = index.Search(q, searchMaxScore, o.regexp) + if err != nil { + return err + } + } + + search.SortScore(res) + data, err := o.applyConstraint(res) + if err != nil { + return err + } + + return o.outputFormat.Write(out, &repoSearchWriter{data, o.maxColWidth, o.failOnNoResult}) +} + +func (o *searchRepoOptions) setupSearchedVersion() { + debug("Original chart version: %q", o.version) + + if o.version != "" { + return + } + + if o.devel { // search for releases and prereleases (alpha, beta, and release candidate releases). + debug("setting version to >0.0.0-0") + o.version = ">0.0.0-0" + } else { // search only for stable releases, prerelease versions will be skip + debug("setting version to >0.0.0") + o.version = ">0.0.0" + } +} + +func (o *searchRepoOptions) applyConstraint(res []*search.Result) ([]*search.Result, error) { + if o.version == "" { + return res, nil + } + + constraint, err := semver.NewConstraint(o.version) + if err != nil { + return res, errors.Wrap(err, "an invalid version/constraint format") + } + + data := res[:0] + foundNames := map[string]bool{} + for _, r := range res { + // if not returning all versions and already have found a result, + // you're done! + if !o.versions && foundNames[r.Name] { + continue + } + v, err := semver.NewVersion(r.Chart.Version) + if err != nil { + continue + } + if constraint.Check(v) { + data = append(data, r) + foundNames[r.Name] = true + } + } + + return data, nil +} + +func (o *searchRepoOptions) buildIndex() (*search.Index, error) { + // Load the repositories.yaml + rf, err := repo.LoadFile(o.repoFile) + if isNotExist(err) || len(rf.Repositories) == 0 { + return nil, errors.New("no repositories configured") + } + + i := search.NewIndex() + for _, re := range rf.Repositories { + n := re.Name + f := filepath.Join(o.repoCacheDir, helmpath.CacheIndexFile(n)) + ind, err := repo.LoadIndexFile(f) + if err != nil { + warning("Repo %q is corrupt or missing. Try 'helm repo update'.", n) + warning("%s", err) + continue + } + + i.AddRepo(n, ind, o.versions || len(o.version) > 0) + } + return i, nil +} + +type repoChartElement struct { + Name string `json:"name"` + Version string `json:"version"` + AppVersion string `json:"app_version"` + Description string `json:"description"` +} + +type repoSearchWriter struct { + results []*search.Result + columnWidth uint + failOnNoResult bool +} + +func (r *repoSearchWriter) WriteTable(out io.Writer) error { + if len(r.results) == 0 { + // Fail if no results found and --fail-on-no-result is enabled + if r.failOnNoResult { + return fmt.Errorf("no results found") + } + + _, err := out.Write([]byte("No results found\n")) + if err != nil { + return fmt.Errorf("unable to write results: %s", err) + } + return nil + } + table := uitable.New() + table.MaxColWidth = r.columnWidth + table.AddRow("NAME", "CHART VERSION", "APP VERSION", "DESCRIPTION") + for _, r := range r.results { + table.AddRow(r.Name, r.Chart.Version, r.Chart.AppVersion, r.Chart.Description) + } + return output.EncodeTable(out, table) +} + +func (r *repoSearchWriter) WriteJSON(out io.Writer) error { + return r.encodeByFormat(out, output.JSON) +} + +func (r *repoSearchWriter) WriteYAML(out io.Writer) error { + return r.encodeByFormat(out, output.YAML) +} + +func (r *repoSearchWriter) encodeByFormat(out io.Writer, format output.Format) error { + // Fail if no results found and --fail-on-no-result is enabled + if len(r.results) == 0 && r.failOnNoResult { + return fmt.Errorf("no results found") + } + + // Initialize the array so no results returns an empty array instead of null + chartList := make([]repoChartElement, 0, len(r.results)) + + for _, r := range r.results { + chartList = append(chartList, repoChartElement{r.Name, r.Chart.Version, r.Chart.AppVersion, r.Chart.Description}) + } + + switch format { + case output.JSON: + return output.EncodeJSON(out, chartList) + case output.YAML: + return output.EncodeYAML(out, chartList) + } + + // Because this is a non-exported function and only called internally by + // WriteJSON and WriteYAML, we shouldn't get invalid types + return nil +} + +// Provides the list of charts that are part of the specified repo, and that starts with 'prefix'. +func compListChartsOfRepo(repoName string, prefix string) []string { + var charts []string + + path := filepath.Join(settings.RepositoryCache, helmpath.CacheChartsFile(repoName)) + content, err := os.ReadFile(path) + if err == nil { + scanner := bufio.NewScanner(bytes.NewReader(content)) + for scanner.Scan() { + fullName := fmt.Sprintf("%s/%s", repoName, scanner.Text()) + if strings.HasPrefix(fullName, prefix) { + charts = append(charts, fullName) + } + } + return charts + } + + if isNotExist(err) { + // If there is no cached charts file, fallback to the full index file. + // This is much slower but can happen after the caching feature is first + // installed but before the user does a 'helm repo update' to generate the + // first cached charts file. + path = filepath.Join(settings.RepositoryCache, helmpath.CacheIndexFile(repoName)) + if indexFile, err := repo.LoadIndexFile(path); err == nil { + for name := range indexFile.Entries { + fullName := fmt.Sprintf("%s/%s", repoName, name) + if strings.HasPrefix(fullName, prefix) { + charts = append(charts, fullName) + } + } + return charts + } + } + + return []string{} +} + +// Provide dynamic auto-completion for commands that operate on charts (e.g., helm show) +// When true, the includeFiles argument indicates that completion should include local files (e.g., local charts) +func compListCharts(toComplete string, includeFiles bool) ([]string, cobra.ShellCompDirective) { + cobra.CompDebugln(fmt.Sprintf("compListCharts with toComplete %s", toComplete), settings.Debug) + + noSpace := false + noFile := false + var completions []string + + // First check completions for repos + repos := compListRepos("", nil) + for _, repoInfo := range repos { + // Split name from description + repoInfo := strings.Split(repoInfo, "\t") + repo := repoInfo[0] + repoDesc := "" + if len(repoInfo) > 1 { + repoDesc = repoInfo[1] + } + repoWithSlash := fmt.Sprintf("%s/", repo) + if strings.HasPrefix(toComplete, repoWithSlash) { + // Must complete with charts within the specified repo. + // Don't filter on toComplete to allow for shell fuzzy matching + completions = append(completions, compListChartsOfRepo(repo, "")...) + noSpace = false + break + } else if strings.HasPrefix(repo, toComplete) { + // Must complete the repo name with the slash, followed by the description + completions = append(completions, fmt.Sprintf("%s\t%s", repoWithSlash, repoDesc)) + noSpace = true + } + } + cobra.CompDebugln(fmt.Sprintf("Completions after repos: %v", completions), settings.Debug) + + // Now handle completions for url prefixes + for _, url := range []string{"oci://\tChart OCI prefix", "https://\tChart URL prefix", "http://\tChart URL prefix", "file://\tChart local URL prefix"} { + if strings.HasPrefix(toComplete, url) { + // The user already put in the full url prefix; we don't have + // anything to add, but make sure the shell does not default + // to file completion since we could be returning an empty array. + noFile = true + noSpace = true + } else if strings.HasPrefix(url, toComplete) { + // We are completing a url prefix + completions = append(completions, url) + noSpace = true + } + } + cobra.CompDebugln(fmt.Sprintf("Completions after urls: %v", completions), settings.Debug) + + // Finally, provide file completion if we need to. + // We only do this if: + // 1- There are other completions found (if there are no completions, + // the shell will do file completion itself) + // 2- If there is some input from the user (or else we will end up + // listing the entire content of the current directory which will + // be too many choices for the user to find the real repos) + if includeFiles && len(completions) > 0 && len(toComplete) > 0 { + if files, err := os.ReadDir("."); err == nil { + for _, file := range files { + if strings.HasPrefix(file.Name(), toComplete) { + // We are completing a file prefix + completions = append(completions, file.Name()) + } + } + } + } + cobra.CompDebugln(fmt.Sprintf("Completions after files: %v", completions), settings.Debug) + + // If the user didn't provide any input to completion, + // we provide a hint that a path can also be used + if includeFiles && len(toComplete) == 0 { + completions = append(completions, "./\tRelative path prefix to local chart", "/\tAbsolute path prefix to local chart") + } + cobra.CompDebugln(fmt.Sprintf("Completions after checking empty input: %v", completions), settings.Debug) + + directive := cobra.ShellCompDirectiveDefault + if noFile { + directive = directive | cobra.ShellCompDirectiveNoFileComp + } + if noSpace { + directive = directive | cobra.ShellCompDirectiveNoSpace + } + if !includeFiles { + // If we should not include files in the completions, + // we should disable file completion + directive = directive | cobra.ShellCompDirectiveNoFileComp + } + return completions, directive +} diff --git a/cmd/helm/search_repo_test.go b/cmd/helm/search_repo_test.go new file mode 100644 index 00000000..9039842f --- /dev/null +++ b/cmd/helm/search_repo_test.go @@ -0,0 +1,107 @@ +/* +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 ( + "testing" +) + +func TestSearchRepositoriesCmd(t *testing.T) { + repoFile := "testdata/helmhome/helm/repositories.yaml" + repoCache := "testdata/helmhome/helm/repository" + + tests := []cmdTestCase{{ + name: "search for 'alpine', expect one match with latest stable version", + cmd: "search repo alpine", + golden: "output/search-multiple-stable-release.txt", + }, { + name: "search for 'alpine', expect one match with newest development version", + cmd: "search repo alpine --devel", + golden: "output/search-multiple-devel-release.txt", + }, { + name: "search for 'alpine' with versions, expect three matches", + cmd: "search repo alpine --versions", + golden: "output/search-multiple-versions.txt", + }, { + name: "search for 'alpine' with version constraint, expect one match with version 0.1.0", + cmd: "search repo alpine --version '>= 0.1, < 0.2'", + golden: "output/search-constraint.txt", + }, { + name: "search for 'alpine' with version constraint, expect one match with version 0.1.0", + cmd: "search repo alpine --versions --version '>= 0.1, < 0.2'", + golden: "output/search-versions-constraint.txt", + }, { + name: "search for 'alpine' with version constraint, expect one match with version 0.2.0", + cmd: "search repo alpine --version '>= 0.1'", + golden: "output/search-constraint-single.txt", + }, { + name: "search for 'alpine' with version constraint and --versions, expect two matches", + cmd: "search repo alpine --versions --version '>= 0.1'", + golden: "output/search-multiple-versions-constraints.txt", + }, { + name: "search for 'syzygy', expect no matches", + cmd: "search repo syzygy", + golden: "output/search-not-found.txt", + }, { + name: "search for 'syzygy' with --fail-on-no-result, expect failure for no results", + cmd: "search repo syzygy --fail-on-no-result", + golden: "output/search-not-found-error.txt", + wantError: true, + }, {name: "search for 'syzygy' with json output and --fail-on-no-result, expect failure for no results", + cmd: "search repo syzygy --output json --fail-on-no-result", + golden: "output/search-not-found-error.txt", + wantError: true, + }, { + name: "search for 'syzygy' with yaml output --fail-on-no-result, expect failure for no results", + cmd: "search repo syzygy --output yaml --fail-on-no-result", + golden: "output/search-not-found-error.txt", + wantError: true, + }, { + name: "search for 'alp[a-z]+', expect two matches", + cmd: "search repo alp[a-z]+ --regexp", + golden: "output/search-regex.txt", + }, { + name: "search for 'alp[', expect failure to compile regexp", + cmd: "search repo alp[ --regexp", + wantError: true, + }, { + name: "search for 'maria', expect valid json output", + cmd: "search repo maria --output json", + golden: "output/search-output-json.txt", + }, { + name: "search for 'alpine', expect valid yaml output", + cmd: "search repo alpine --output yaml", + golden: "output/search-output-yaml.txt", + }} + + settings.Debug = true + defer func() { settings.Debug = false }() + + for i := range tests { + tests[i].cmd += " --repository-config " + repoFile + tests[i].cmd += " --repository-cache " + repoCache + } + runTestCmd(t, tests) +} + +func TestSearchRepoOutputCompletion(t *testing.T) { + outputFlagCompletionTest(t, "search repo") +} + +func TestSearchRepoFileCompletion(t *testing.T) { + checkFileCompletion(t, "search repo", true) // File completion may be useful when inputting a keyword +} diff --git a/cmd/helm/search_test.go b/cmd/helm/search_test.go new file mode 100644 index 00000000..6cf845b0 --- /dev/null +++ b/cmd/helm/search_test.go @@ -0,0 +1,23 @@ +/* +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 "testing" + +func TestSearchFileCompletion(t *testing.T) { + checkFileCompletion(t, "search", false) +} diff --git a/cmd/helm/show.go b/cmd/helm/show.go new file mode 100644 index 00000000..28eb9756 --- /dev/null +++ b/cmd/helm/show.go @@ -0,0 +1,236 @@ +/* +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 ( + "fmt" + "io" + "log" + + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/action" +) + +const showDesc = ` +This command consists of multiple subcommands to display information about a chart +` + +const showAllDesc = ` +This command inspects a chart (directory, file, or URL) and displays all its content +(values.yaml, Chart.yaml, README) +` + +const showValuesDesc = ` +This command inspects a chart (directory, file, or URL) and displays the contents +of the values.yaml file +` + +const showChartDesc = ` +This command inspects a chart (directory, file, or URL) and displays the contents +of the Chart.yaml file +` + +const readmeChartDesc = ` +This command inspects a chart (directory, file, or URL) and displays the contents +of the README file +` + +const showCRDsDesc = ` +This command inspects a chart (directory, file, or URL) and displays the contents +of the CustomResourceDefinition files +` + +func newShowCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + client := action.NewShowWithConfig(action.ShowAll, cfg) + + showCommand := &cobra.Command{ + Use: "show", + Short: "show information of a chart", + Aliases: []string{"inspect"}, + Long: showDesc, + Args: require.NoArgs, + ValidArgsFunction: noCompletions, // Disable file completion + } + + // Function providing dynamic auto-completion + validArgsFunc := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return compListCharts(toComplete, true) + } + + all := &cobra.Command{ + Use: "all [CHART]", + Short: "show all information of the chart", + Long: showAllDesc, + Args: require.ExactArgs(1), + ValidArgsFunction: validArgsFunc, + RunE: func(cmd *cobra.Command, args []string) error { + client.OutputFormat = action.ShowAll + err := addRegistryClient(client) + if err != nil { + return err + } + output, err := runShow(args, client) + if err != nil { + return err + } + fmt.Fprint(out, output) + return nil + }, + } + + valuesSubCmd := &cobra.Command{ + Use: "values [CHART]", + Short: "show the chart's values", + Long: showValuesDesc, + Args: require.ExactArgs(1), + ValidArgsFunction: validArgsFunc, + RunE: func(cmd *cobra.Command, args []string) error { + client.OutputFormat = action.ShowValues + err := addRegistryClient(client) + if err != nil { + return err + } + output, err := runShow(args, client) + if err != nil { + return err + } + fmt.Fprint(out, output) + return nil + }, + } + + chartSubCmd := &cobra.Command{ + Use: "chart [CHART]", + Short: "show the chart's definition", + Long: showChartDesc, + Args: require.ExactArgs(1), + ValidArgsFunction: validArgsFunc, + RunE: func(cmd *cobra.Command, args []string) error { + client.OutputFormat = action.ShowChart + err := addRegistryClient(client) + if err != nil { + return err + } + output, err := runShow(args, client) + if err != nil { + return err + } + fmt.Fprint(out, output) + return nil + }, + } + + readmeSubCmd := &cobra.Command{ + Use: "readme [CHART]", + Short: "show the chart's README", + Long: readmeChartDesc, + Args: require.ExactArgs(1), + ValidArgsFunction: validArgsFunc, + RunE: func(cmd *cobra.Command, args []string) error { + client.OutputFormat = action.ShowReadme + err := addRegistryClient(client) + if err != nil { + return err + } + output, err := runShow(args, client) + if err != nil { + return err + } + fmt.Fprint(out, output) + return nil + }, + } + + crdsSubCmd := &cobra.Command{ + Use: "crds [CHART]", + Short: "show the chart's CRDs", + Long: showCRDsDesc, + Args: require.ExactArgs(1), + ValidArgsFunction: validArgsFunc, + RunE: func(cmd *cobra.Command, args []string) error { + client.OutputFormat = action.ShowCRDs + err := addRegistryClient(client) + if err != nil { + return err + } + output, err := runShow(args, client) + if err != nil { + return err + } + fmt.Fprint(out, output) + return nil + }, + } + + cmds := []*cobra.Command{all, readmeSubCmd, valuesSubCmd, chartSubCmd, crdsSubCmd} + for _, subCmd := range cmds { + addShowFlags(subCmd, client) + showCommand.AddCommand(subCmd) + } + + return showCommand +} + +func addShowFlags(subCmd *cobra.Command, client *action.Show) { + f := subCmd.Flags() + + f.BoolVar(&client.Devel, "devel", false, "use development versions, too. Equivalent to version '>0.0.0-0'. If --version is set, this is ignored") + if subCmd.Name() == "values" { + f.StringVar(&client.JSONPathTemplate, "jsonpath", "", "supply a JSONPath expression to filter the output") + } + addChartPathOptionsFlags(f, &client.ChartPathOptions) + + err := subCmd.RegisterFlagCompletionFunc("version", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 1 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return compVersionFlag(args[0], toComplete) + }) + + if err != nil { + log.Fatal(err) + } +} + +func runShow(args []string, client *action.Show) (string, error) { + debug("Original chart version: %q", client.Version) + if client.Version == "" && client.Devel { + debug("setting version to >0.0.0-0") + client.Version = ">0.0.0-0" + } + + cp, err := client.ChartPathOptions.LocateChart(args[0], settings) + if err != nil { + return "", err + } + return client.Run(cp) +} + +func addRegistryClient(client *action.Show) error { + registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile, + client.InsecureSkipTLSverify, client.PlainHTTP) + if err != nil { + return fmt.Errorf("missing registry client: %w", err) + } + client.SetRegistryClient(registryClient) + return nil +} diff --git a/cmd/helm/show_test.go b/cmd/helm/show_test.go new file mode 100644 index 00000000..93ec08d0 --- /dev/null +++ b/cmd/helm/show_test.go @@ -0,0 +1,155 @@ +/* +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 ( + "fmt" + "path/filepath" + "strings" + "testing" + + "helm.sh/helm/v3/pkg/repo/repotest" +) + +func TestShowPreReleaseChart(t *testing.T) { + srv, err := repotest.NewTempServerWithCleanup(t, "testdata/testcharts/*.tgz*") + if err != nil { + t.Fatal(err) + } + defer srv.Stop() + + if err := srv.LinkIndices(); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + args string + flags string + fail bool + expectedErr string + }{ + { + name: "show pre-release chart", + args: "test/pre-release-chart", + fail: true, + expectedErr: "chart \"pre-release-chart\" matching not found in test index. (try 'helm repo update'): no chart version found for pre-release-chart-", + }, + { + name: "show pre-release chart", + args: "test/pre-release-chart", + fail: true, + flags: "--version 1.0.0", + expectedErr: "chart \"pre-release-chart\" matching 1.0.0 not found in test index. (try 'helm repo update'): no chart version found for pre-release-chart-1.0.0", + }, + { + name: "show pre-release chart with 'devel' flag", + args: "test/pre-release-chart", + flags: "--devel", + fail: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + outdir := srv.Root() + cmd := fmt.Sprintf("show all '%s' %s --repository-config %s --repository-cache %s", + tt.args, + tt.flags, + filepath.Join(outdir, "repositories.yaml"), + outdir, + ) + //_, out, err := executeActionCommand(cmd) + _, _, err := executeActionCommand(cmd) + if err != nil { + if tt.fail { + if !strings.Contains(err.Error(), tt.expectedErr) { + t.Errorf("%q expected error: %s, got: %s", tt.name, tt.expectedErr, err.Error()) + } + return + } + t.Errorf("%q reported error: %s", tt.name, err) + } + }) + } +} + +func TestShowVersionCompletion(t *testing.T) { + repoFile := "testdata/helmhome/helm/repositories.yaml" + repoCache := "testdata/helmhome/helm/repository" + + repoSetup := fmt.Sprintf("--repository-config %s --repository-cache %s", repoFile, repoCache) + + tests := []cmdTestCase{{ + name: "completion for show version flag", + cmd: fmt.Sprintf("%s __complete show chart testing/alpine --version ''", repoSetup), + golden: "output/version-comp.txt", + }, { + name: "completion for show version flag, no filter", + cmd: fmt.Sprintf("%s __complete show chart testing/alpine --version 0.3", repoSetup), + golden: "output/version-comp.txt", + }, { + name: "completion for show version flag too few args", + cmd: fmt.Sprintf("%s __complete show chart --version ''", repoSetup), + golden: "output/version-invalid-comp.txt", + }, { + name: "completion for show version flag too many args", + cmd: fmt.Sprintf("%s __complete show chart testing/alpine badarg --version ''", repoSetup), + golden: "output/version-invalid-comp.txt", + }, { + name: "completion for show version flag invalid chart", + cmd: fmt.Sprintf("%s __complete show chart invalid/invalid --version ''", repoSetup), + golden: "output/version-invalid-comp.txt", + }, { + name: "completion for show version flag with all", + cmd: fmt.Sprintf("%s __complete show all testing/alpine --version ''", repoSetup), + golden: "output/version-comp.txt", + }, { + name: "completion for show version flag with readme", + cmd: fmt.Sprintf("%s __complete show readme testing/alpine --version ''", repoSetup), + golden: "output/version-comp.txt", + }, { + name: "completion for show version flag with values", + cmd: fmt.Sprintf("%s __complete show values testing/alpine --version ''", repoSetup), + golden: "output/version-comp.txt", + }} + runTestCmd(t, tests) +} + +func TestShowFileCompletion(t *testing.T) { + checkFileCompletion(t, "show", false) +} + +func TestShowAllFileCompletion(t *testing.T) { + checkFileCompletion(t, "show all", true) +} + +func TestShowChartFileCompletion(t *testing.T) { + checkFileCompletion(t, "show chart", true) +} + +func TestShowReadmeFileCompletion(t *testing.T) { + checkFileCompletion(t, "show readme", true) +} + +func TestShowValuesFileCompletion(t *testing.T) { + checkFileCompletion(t, "show values", true) +} + +func TestShowCRDsFileCompletion(t *testing.T) { + checkFileCompletion(t, "show crds", true) +} diff --git a/cmd/helm/status.go b/cmd/helm/status.go new file mode 100644 index 00000000..850862cd --- /dev/null +++ b/cmd/helm/status.go @@ -0,0 +1,240 @@ +/* +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" + "io" + "log" + "strings" + "time" + + "github.com/spf13/cobra" + + "k8s.io/kubectl/pkg/cmd/get" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/cli/output" + "helm.sh/helm/v3/pkg/release" +) + +// NOTE: Keep the list of statuses up-to-date with pkg/release/status.go. +var statusHelp = ` +This command shows the status of a named release. +The status consists of: +- last deployment time +- k8s namespace in which the release lives +- state of the release (can be: unknown, deployed, uninstalled, superseded, failed, uninstalling, pending-install, pending-upgrade or pending-rollback) +- revision of the release +- description of the release (can be completion message or error message, need to enable --show-desc) +- list of resources that this release consists of (need to enable --show-resources) +- details on last test suite run, if applicable +- additional notes provided by the chart +` + +func newStatusCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + client := action.NewStatus(cfg) + var outfmt output.Format + + cmd := &cobra.Command{ + Use: "status RELEASE_NAME", + Short: "display the status of the named release", + Long: statusHelp, + Args: require.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return compListReleases(toComplete, args, cfg) + }, + RunE: func(cmd *cobra.Command, args []string) error { + + // When the output format is a table the resources should be fetched + // and displayed as a table. When YAML or JSON the resources will be + // returned. This mirrors the handling in kubectl. + if outfmt == output.Table { + client.ShowResourcesTable = true + } + rel, err := client.Run(args[0]) + if err != nil { + return err + } + + // strip chart metadata from the output + rel.Chart = nil + + return outfmt.Write(out, &statusPrinter{rel, false, client.ShowDescription, client.ShowResources, false}) + }, + } + + f := cmd.Flags() + + f.IntVar(&client.Version, "revision", 0, "if set, display the status of the named release with revision") + + err := cmd.RegisterFlagCompletionFunc("revision", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 1 { + return compListRevisions(toComplete, cfg, args[0]) + } + return nil, cobra.ShellCompDirectiveNoFileComp + }) + + if err != nil { + log.Fatal(err) + } + + bindOutputFlag(cmd, &outfmt) + f.BoolVar(&client.ShowDescription, "show-desc", false, "if set, display the description message of the named release") + + f.BoolVar(&client.ShowResources, "show-resources", false, "if set, display the resources of the named release") + + return cmd +} + +type statusPrinter struct { + release *release.Release + debug bool + showDescription bool + showResources bool + showMetadata bool +} + +func (s statusPrinter) WriteJSON(out io.Writer) error { + return output.EncodeJSON(out, s.release) +} + +func (s statusPrinter) WriteYAML(out io.Writer) error { + return output.EncodeYAML(out, s.release) +} + +func (s statusPrinter) WriteTable(out io.Writer) error { + if s.release == nil { + return nil + } + _, _ = fmt.Fprintf(out, "NAME: %s\n", s.release.Name) + if !s.release.Info.LastDeployed.IsZero() { + _, _ = fmt.Fprintf(out, "LAST DEPLOYED: %s\n", s.release.Info.LastDeployed.Format(time.ANSIC)) + } + _, _ = fmt.Fprintf(out, "NAMESPACE: %s\n", s.release.Namespace) + _, _ = fmt.Fprintf(out, "STATUS: %s\n", s.release.Info.Status.String()) + _, _ = fmt.Fprintf(out, "REVISION: %d\n", s.release.Version) + if s.showMetadata { + _, _ = fmt.Fprintf(out, "CHART: %s\n", s.release.Chart.Metadata.Name) + _, _ = fmt.Fprintf(out, "VERSION: %s\n", s.release.Chart.Metadata.Version) + _, _ = fmt.Fprintf(out, "APP_VERSION: %s\n", s.release.Chart.Metadata.AppVersion) + } + if s.showDescription { + _, _ = fmt.Fprintf(out, "DESCRIPTION: %s\n", s.release.Info.Description) + } + + if s.showResources && s.release.Info.Resources != nil && len(s.release.Info.Resources) > 0 { + buf := new(bytes.Buffer) + printFlags := get.NewHumanPrintFlags() + typePrinter, _ := printFlags.ToPrinter("") + printer := &get.TablePrinter{Delegate: typePrinter} + + var keys []string + for key := range s.release.Info.Resources { + keys = append(keys, key) + } + + for _, t := range keys { + _, _ = fmt.Fprintf(buf, "==> %s\n", t) + + vk := s.release.Info.Resources[t] + for _, resource := range vk { + if err := printer.PrintObj(resource, buf); err != nil { + _, _ = fmt.Fprintf(buf, "failed to print object type %s: %v\n", t, err) + } + } + + buf.WriteString("\n") + } + + _, _ = fmt.Fprintf(out, "RESOURCES:\n%s\n", buf.String()) + } + + executions := executionsByHookEvent(s.release) + if tests, ok := executions[release.HookTest]; !ok || len(tests) == 0 { + _, _ = fmt.Fprintln(out, "TEST SUITE: None") + } else { + for _, h := range tests { + // Don't print anything if hook has not been initiated + if h.LastRun.StartedAt.IsZero() { + continue + } + _, _ = fmt.Fprintf(out, "TEST SUITE: %s\n%s\n%s\n%s\n", + h.Name, + fmt.Sprintf("Last Started: %s", h.LastRun.StartedAt.Format(time.ANSIC)), + fmt.Sprintf("Last Completed: %s", h.LastRun.CompletedAt.Format(time.ANSIC)), + fmt.Sprintf("Phase: %s", h.LastRun.Phase), + ) + } + } + + if s.debug { + _, _ = fmt.Fprintln(out, "USER-SUPPLIED VALUES:") + err := output.EncodeYAML(out, s.release.Config) + if err != nil { + return err + } + // Print an extra newline + _, _ = fmt.Fprintln(out) + + cfg, err := chartutil.CoalesceValues(s.release.Chart, s.release.Config) + if err != nil { + return err + } + + _, _ = fmt.Fprintln(out, "COMPUTED VALUES:") + err = output.EncodeYAML(out, cfg.AsMap()) + if err != nil { + return err + } + // Print an extra newline + _, _ = fmt.Fprintln(out) + } + + if strings.EqualFold(s.release.Info.Description, "Dry run complete") || s.debug { + _, _ = fmt.Fprintln(out, "HOOKS:") + for _, h := range s.release.Hooks { + _, _ = fmt.Fprintf(out, "---\n# Source: %s\n%s\n", h.Path, h.Manifest) + } + _, _ = fmt.Fprintf(out, "MANIFEST:\n%s\n", s.release.Manifest) + } + + if len(s.release.Info.Notes) > 0 { + _, _ = fmt.Fprintf(out, "NOTES:\n%s\n", strings.TrimSpace(s.release.Info.Notes)) + } + return nil +} + +func executionsByHookEvent(rel *release.Release) map[release.HookEvent][]*release.Hook { + result := make(map[release.HookEvent][]*release.Hook) + for _, h := range rel.Hooks { + for _, e := range h.Events { + executions, ok := result[e] + if !ok { + executions = []*release.Hook{} + } + result[e] = append(executions, h) + } + } + return result +} diff --git a/cmd/helm/status_test.go b/cmd/helm/status_test.go new file mode 100644 index 00000000..6722bf94 --- /dev/null +++ b/cmd/helm/status_test.go @@ -0,0 +1,220 @@ +/* +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 ( + "testing" + "time" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/release" + helmtime "helm.sh/helm/v3/pkg/time" +) + +func TestStatusCmd(t *testing.T) { + releasesMockWithStatus := func(info *release.Info, hooks ...*release.Hook) []*release.Release { + info.LastDeployed = helmtime.Unix(1452902400, 0).UTC() + return []*release.Release{{ + Name: "flummoxed-chickadee", + Namespace: "default", + Info: info, + Chart: &chart.Chart{Metadata: &chart.Metadata{Name: "name", Version: "1.2.3", AppVersion: "3.2.1"}}, + Hooks: hooks, + }} + } + + tests := []cmdTestCase{{ + name: "get status of a deployed release", + cmd: "status flummoxed-chickadee", + golden: "output/status.txt", + rels: releasesMockWithStatus(&release.Info{ + Status: release.StatusDeployed, + }), + }, { + name: "get status of a deployed release, with desc", + cmd: "status --show-desc flummoxed-chickadee", + golden: "output/status-with-desc.txt", + rels: releasesMockWithStatus(&release.Info{ + Status: release.StatusDeployed, + Description: "Mock description", + }), + }, { + name: "get status of a deployed release with notes", + cmd: "status flummoxed-chickadee", + golden: "output/status-with-notes.txt", + rels: releasesMockWithStatus(&release.Info{ + Status: release.StatusDeployed, + Notes: "release notes", + }), + }, { + name: "get status of a deployed release with notes in json", + cmd: "status flummoxed-chickadee -o json", + golden: "output/status.json", + rels: releasesMockWithStatus(&release.Info{ + Status: release.StatusDeployed, + Notes: "release notes", + }), + }, { + name: "get status of a deployed release with resources", + cmd: "status --show-resources flummoxed-chickadee", + golden: "output/status-with-resources.txt", + rels: releasesMockWithStatus( + &release.Info{ + Status: release.StatusDeployed, + }, + ), + }, { + name: "get status of a deployed release with resources in json", + cmd: "status --show-resources flummoxed-chickadee -o json", + golden: "output/status-with-resources.json", + rels: releasesMockWithStatus( + &release.Info{ + Status: release.StatusDeployed, + }, + ), + }, { + name: "get status of a deployed release with test suite", + cmd: "status flummoxed-chickadee", + golden: "output/status-with-test-suite.txt", + rels: releasesMockWithStatus( + &release.Info{ + Status: release.StatusDeployed, + }, + &release.Hook{ + Name: "never-run-test", + Events: []release.HookEvent{release.HookTest}, + }, + &release.Hook{ + Name: "passing-test", + Events: []release.HookEvent{release.HookTest}, + LastRun: release.HookExecution{ + StartedAt: mustParseTime("2006-01-02T15:04:05Z"), + CompletedAt: mustParseTime("2006-01-02T15:04:07Z"), + Phase: release.HookPhaseSucceeded, + }, + }, + &release.Hook{ + Name: "failing-test", + Events: []release.HookEvent{release.HookTest}, + LastRun: release.HookExecution{ + StartedAt: mustParseTime("2006-01-02T15:10:05Z"), + CompletedAt: mustParseTime("2006-01-02T15:10:07Z"), + Phase: release.HookPhaseFailed, + }, + }, + &release.Hook{ + Name: "passing-pre-install", + Events: []release.HookEvent{release.HookPreInstall}, + LastRun: release.HookExecution{ + StartedAt: mustParseTime("2006-01-02T15:00:05Z"), + CompletedAt: mustParseTime("2006-01-02T15:00:07Z"), + Phase: release.HookPhaseSucceeded, + }, + }, + ), + }} + runTestCmd(t, tests) +} + +func mustParseTime(t string) helmtime.Time { + res, _ := helmtime.Parse(time.RFC3339, t) + return res +} + +func TestStatusCompletion(t *testing.T) { + rels := []*release.Release{ + { + Name: "athos", + Namespace: "default", + Info: &release.Info{ + Status: release.StatusDeployed, + }, + Chart: &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "Athos-chart", + Version: "1.2.3", + }, + }, + }, { + Name: "porthos", + Namespace: "default", + Info: &release.Info{ + Status: release.StatusFailed, + }, + Chart: &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "Porthos-chart", + Version: "111.222.333", + }, + }, + }, { + Name: "aramis", + Namespace: "default", + Info: &release.Info{ + Status: release.StatusUninstalled, + }, + Chart: &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "Aramis-chart", + Version: "0.0.0", + }, + }, + }, { + Name: "dartagnan", + Namespace: "gascony", + Info: &release.Info{ + Status: release.StatusUnknown, + }, + Chart: &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "Dartagnan-chart", + Version: "1.2.3-prerelease", + }, + }, + }} + + tests := []cmdTestCase{{ + name: "completion for status", + cmd: "__complete status a", + golden: "output/status-comp.txt", + rels: rels, + }, { + name: "completion for status with too many arguments", + cmd: "__complete status dartagnan ''", + golden: "output/status-wrong-args-comp.txt", + rels: rels, + }, { + name: "completion for status with global flag", + cmd: "__complete status --debug a", + golden: "output/status-comp.txt", + rels: rels, + }} + runTestCmd(t, tests) +} + +func TestStatusRevisionCompletion(t *testing.T) { + revisionFlagCompletionTest(t, "status") +} + +func TestStatusOutputCompletion(t *testing.T) { + outputFlagCompletionTest(t, "status") +} + +func TestStatusFileCompletion(t *testing.T) { + checkFileCompletion(t, "status", false) + checkFileCompletion(t, "status myrelease", false) +} diff --git a/cmd/helm/template.go b/cmd/helm/template.go new file mode 100644 index 00000000..a16cbc76 --- /dev/null +++ b/cmd/helm/template.go @@ -0,0 +1,262 @@ +/* +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" + "io" + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strings" + + "helm.sh/helm/v3/pkg/release" + + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/cli/values" + "helm.sh/helm/v3/pkg/releaseutil" +) + +const templateDesc = ` +Render chart templates locally and display the output. + +Any values that would normally be looked up or retrieved in-cluster will be +faked locally. Additionally, none of the server-side testing of chart validity +(e.g. whether an API is supported) is done. +` + +func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + var validate bool + var includeCrds bool + var skipTests bool + client := action.NewInstall(cfg) + valueOpts := &values.Options{} + var kubeVersion string + var extraAPIs []string + var showFiles []string + + cmd := &cobra.Command{ + Use: "template [NAME] [CHART]", + Short: "locally render templates", + Long: templateDesc, + Args: require.MinimumNArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return compInstall(args, toComplete, client) + }, + RunE: func(_ *cobra.Command, args []string) error { + if kubeVersion != "" { + parsedKubeVersion, err := chartutil.ParseKubeVersion(kubeVersion) + if err != nil { + return fmt.Errorf("invalid kube version '%s': %s", kubeVersion, err) + } + client.KubeVersion = parsedKubeVersion + } + + registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile, + client.InsecureSkipTLSverify, client.PlainHTTP) + if err != nil { + return fmt.Errorf("missing registry client: %w", err) + } + client.SetRegistryClient(registryClient) + + // This is for the case where "" is specifically passed in as a + // value. When there is no value passed in NoOptDefVal will be used + // and it is set to client. See addInstallFlags. + if client.DryRunOption == "" { + client.DryRunOption = "true" + } + client.DryRun = true + client.ReleaseName = "release-name" + client.Replace = true // Skip the name check + client.ClientOnly = !validate + client.APIVersions = chartutil.VersionSet(extraAPIs) + client.IncludeCRDs = includeCrds + rel, err := runInstall(args, client, valueOpts, out) + + if err != nil && !settings.Debug { + if rel != nil { + return fmt.Errorf("%w\n\nUse --debug flag to render out invalid YAML", err) + } + return err + } + + // We ignore a potential error here because, when the --debug flag was specified, + // we always want to print the YAML, even if it is not valid. The error is still returned afterwards. + if rel != nil { + var manifests bytes.Buffer + fmt.Fprintln(&manifests, strings.TrimSpace(rel.Manifest)) + if !client.DisableHooks { + fileWritten := make(map[string]bool) + for _, m := range rel.Hooks { + if skipTests && isTestHook(m) { + continue + } + if client.OutputDir == "" { + fmt.Fprintf(&manifests, "---\n# Source: %s\n%s\n", m.Path, m.Manifest) + } else { + newDir := client.OutputDir + if client.UseReleaseName { + newDir = filepath.Join(client.OutputDir, client.ReleaseName) + } + _, err := os.Stat(filepath.Join(newDir, m.Path)) + if err == nil { + fileWritten[m.Path] = true + } + + err = writeToFile(newDir, m.Path, m.Manifest, fileWritten[m.Path]) + if err != nil { + return err + } + } + + } + } + + // if we have a list of files to render, then check that each of the + // provided files exists in the chart. + if len(showFiles) > 0 { + // This is necessary to ensure consistent manifest ordering when using --show-only + // with globs or directory names. + splitManifests := releaseutil.SplitManifests(manifests.String()) + manifestsKeys := make([]string, 0, len(splitManifests)) + for k := range splitManifests { + manifestsKeys = append(manifestsKeys, k) + } + sort.Sort(releaseutil.BySplitManifestsOrder(manifestsKeys)) + + manifestNameRegex := regexp.MustCompile("# Source: [^/]+/(.+)") + var manifestsToRender []string + for _, f := range showFiles { + missing := true + // Use linux-style filepath separators to unify user's input path + f = filepath.ToSlash(f) + for _, manifestKey := range manifestsKeys { + manifest := splitManifests[manifestKey] + submatch := manifestNameRegex.FindStringSubmatch(manifest) + if len(submatch) == 0 { + continue + } + manifestName := submatch[1] + // manifest.Name is rendered using linux-style filepath separators on Windows as + // well as macOS/linux. + manifestPathSplit := strings.Split(manifestName, "/") + // manifest.Path is connected using linux-style filepath separators on Windows as + // well as macOS/linux + manifestPath := strings.Join(manifestPathSplit, "/") + + // if the filepath provided matches a manifest path in the + // chart, render that manifest + if matched, _ := filepath.Match(f, manifestPath); !matched { + continue + } + manifestsToRender = append(manifestsToRender, manifest) + missing = false + } + if missing { + return fmt.Errorf("could not find template %s in chart", f) + } + } + for _, m := range manifestsToRender { + fmt.Fprintf(out, "---\n%s\n", m) + } + } else { + fmt.Fprintf(out, "%s", manifests.String()) + } + } + + return err + }, + } + + f := cmd.Flags() + addInstallFlags(cmd, f, client, valueOpts) + f.StringArrayVarP(&showFiles, "show-only", "s", []string{}, "only show manifests rendered from the given templates") + f.StringVar(&client.OutputDir, "output-dir", "", "writes the executed templates to files in output-dir instead of stdout") + f.BoolVar(&validate, "validate", false, "validate your manifests against the Kubernetes cluster you are currently pointing at. This is the same validation performed on an install") + f.BoolVar(&includeCrds, "include-crds", false, "include CRDs in the templated output") + f.BoolVar(&skipTests, "skip-tests", false, "skip tests from templated output") + f.BoolVar(&client.IsUpgrade, "is-upgrade", false, "set .Release.IsUpgrade instead of .Release.IsInstall") + f.StringVar(&kubeVersion, "kube-version", "", "Kubernetes version used for Capabilities.KubeVersion") + f.StringSliceVarP(&extraAPIs, "api-versions", "a", []string{}, "Kubernetes api versions used for Capabilities.APIVersions") + f.BoolVar(&client.UseReleaseName, "release-name", false, "use release name in the output-dir path.") + bindPostRenderFlag(cmd, &client.PostRenderer) + + return cmd +} + +func isTestHook(h *release.Hook) bool { + for _, e := range h.Events { + if e == release.HookTest { + return true + } + } + return false +} + +// The following functions (writeToFile, createOrOpenFile, and ensureDirectoryForFile) +// are copied from the actions package. This is part of a change to correct a +// bug introduced by #8156. As part of the todo to refactor renderResources +// this duplicate code should be removed. It is added here so that the API +// surface area is as minimally impacted as possible in fixing the issue. +func writeToFile(outputDir string, name string, data string, append bool) error { + outfileName := strings.Join([]string{outputDir, name}, string(filepath.Separator)) + + err := ensureDirectoryForFile(outfileName) + if err != nil { + return err + } + + f, err := createOrOpenFile(outfileName, append) + if err != nil { + return err + } + + defer f.Close() + + _, err = f.WriteString(fmt.Sprintf("---\n# Source: %s\n%s\n", name, data)) + + if err != nil { + return err + } + + fmt.Printf("wrote %s\n", outfileName) + return nil +} + +func createOrOpenFile(filename string, append bool) (*os.File, error) { + if append { + return os.OpenFile(filename, os.O_APPEND|os.O_WRONLY, 0600) + } + return os.Create(filename) +} + +func ensureDirectoryForFile(file string) error { + baseDir := path.Dir(file) + _, err := os.Stat(baseDir) + if err != nil && !os.IsNotExist(err) { + return err + } + + return os.MkdirAll(baseDir, 0755) +} diff --git a/cmd/helm/template_test.go b/cmd/helm/template_test.go new file mode 100644 index 00000000..e5b93987 --- /dev/null +++ b/cmd/helm/template_test.go @@ -0,0 +1,203 @@ +/* +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 ( + "fmt" + "path/filepath" + "testing" +) + +var chartPath = "testdata/testcharts/subchart" + +func TestTemplateCmd(t *testing.T) { + deletevalchart := "testdata/testcharts/issue-9027" + + tests := []cmdTestCase{ + { + name: "check name", + cmd: fmt.Sprintf("template '%s'", chartPath), + golden: "output/template.txt", + }, + { + name: "check set name", + cmd: fmt.Sprintf("template '%s' --set service.name=apache", chartPath), + golden: "output/template-set.txt", + }, + { + name: "check values files", + cmd: fmt.Sprintf("template '%s' --values '%s'", chartPath, filepath.Join(chartPath, "/charts/subchartA/values.yaml")), + golden: "output/template-values-files.txt", + }, + { + name: "check name template", + cmd: fmt.Sprintf(`template '%s' --name-template='foobar-{{ b64enc "abc" | lower }}-baz'`, chartPath), + golden: "output/template-name-template.txt", + }, + { + name: "check no args", + cmd: "template", + wantError: true, + golden: "output/template-no-args.txt", + }, + { + name: "check library chart", + cmd: fmt.Sprintf("template '%s'", "testdata/testcharts/lib-chart"), + wantError: true, + golden: "output/template-lib-chart.txt", + }, + { + name: "check chart bad type", + cmd: fmt.Sprintf("template '%s'", "testdata/testcharts/chart-bad-type"), + wantError: true, + golden: "output/template-chart-bad-type.txt", + }, + { + name: "check chart with dependency which is an app chart acting as a library chart", + cmd: fmt.Sprintf("template '%s'", "testdata/testcharts/chart-with-template-lib-dep"), + golden: "output/template-chart-with-template-lib-dep.txt", + }, + { + name: "check chart with dependency which is an app chart archive acting as a library chart", + cmd: fmt.Sprintf("template '%s'", "testdata/testcharts/chart-with-template-lib-archive-dep"), + golden: "output/template-chart-with-template-lib-archive-dep.txt", + }, + { + name: "check kube version", + cmd: fmt.Sprintf("template --kube-version 1.16.0 '%s'", chartPath), + golden: "output/template-with-kube-version.txt", + }, + { + name: "check kube api versions", + cmd: fmt.Sprintf("template --api-versions helm.k8s.io/test '%s'", chartPath), + golden: "output/template-with-api-version.txt", + }, + { + name: "template with CRDs", + cmd: fmt.Sprintf("template '%s' --include-crds", chartPath), + golden: "output/template-with-crds.txt", + }, + { + name: "template with show-only one", + cmd: fmt.Sprintf("template '%s' --show-only templates/service.yaml", chartPath), + golden: "output/template-show-only-one.txt", + }, + { + name: "template with show-only multiple", + cmd: fmt.Sprintf("template '%s' --show-only templates/service.yaml --show-only charts/subcharta/templates/service.yaml", chartPath), + golden: "output/template-show-only-multiple.txt", + }, + { + name: "template with show-only glob", + cmd: fmt.Sprintf("template '%s' --show-only templates/subdir/role*", chartPath), + golden: "output/template-show-only-glob.txt", + // Repeat to ensure manifest ordering regressions are caught + repeat: 10, + }, + { + name: "sorted output of manifests (order of filenames, then order of objects within each YAML file)", + cmd: fmt.Sprintf("template '%s'", "testdata/testcharts/object-order"), + golden: "output/object-order.txt", + // Helm previously used random file order. Repeat the test so we + // don't accidentally get the expected result. + repeat: 10, + }, + { + name: "chart with template with invalid yaml", + cmd: fmt.Sprintf("template '%s'", "testdata/testcharts/chart-with-template-with-invalid-yaml"), + wantError: true, + golden: "output/template-with-invalid-yaml.txt", + }, + { + name: "chart with template with invalid yaml (--debug)", + cmd: fmt.Sprintf("template '%s' --debug", "testdata/testcharts/chart-with-template-with-invalid-yaml"), + wantError: true, + golden: "output/template-with-invalid-yaml-debug.txt", + }, + { + name: "template skip-tests", + cmd: fmt.Sprintf(`template '%s' --skip-tests`, chartPath), + golden: "output/template-skip-tests.txt", + }, + { + // This test case is to ensure the case where specified dependencies + // in the Chart.yaml and those where the Chart.yaml don't have them + // specified are the same. + name: "ensure nil/null values pass to subcharts delete values", + cmd: fmt.Sprintf("template '%s'", deletevalchart), + golden: "output/issue-9027.txt", + }, + { + // Ensure that parent chart values take precedence over imported values + name: "template with imported subchart values ensuring import", + cmd: fmt.Sprintf("template '%s' --set configmap.enabled=true --set subchartb.enabled=true", chartPath), + golden: "output/template-subchart-cm.txt", + }, + { + // Ensure that user input values take precedence over imported + // values from sub-charts. + name: "template with imported subchart values set with --set", + cmd: fmt.Sprintf("template '%s' --set configmap.enabled=true --set subchartb.enabled=true --set configmap.value=baz", chartPath), + golden: "output/template-subchart-cm-set.txt", + }, + { + // Ensure that user input values take precedence over imported + // values from sub-charts when passed by file + name: "template with imported subchart values set with --set", + cmd: fmt.Sprintf("template '%s' -f %s/extra_values.yaml", chartPath, chartPath), + golden: "output/template-subchart-cm-set-file.txt", + }, + } + runTestCmd(t, tests) +} + +func TestTemplateVersionCompletion(t *testing.T) { + repoFile := "testdata/helmhome/helm/repositories.yaml" + repoCache := "testdata/helmhome/helm/repository" + + repoSetup := fmt.Sprintf("--repository-config %s --repository-cache %s", repoFile, repoCache) + + tests := []cmdTestCase{{ + name: "completion for template version flag with release name", + cmd: fmt.Sprintf("%s __complete template releasename testing/alpine --version ''", repoSetup), + golden: "output/version-comp.txt", + }, { + name: "completion for template version flag with generate-name", + cmd: fmt.Sprintf("%s __complete template --generate-name testing/alpine --version ''", repoSetup), + golden: "output/version-comp.txt", + }, { + name: "completion for template version flag too few args", + cmd: fmt.Sprintf("%s __complete template testing/alpine --version ''", repoSetup), + golden: "output/version-invalid-comp.txt", + }, { + name: "completion for template version flag too many args", + cmd: fmt.Sprintf("%s __complete template releasename testing/alpine badarg --version ''", repoSetup), + golden: "output/version-invalid-comp.txt", + }, { + name: "completion for template version flag invalid chart", + cmd: fmt.Sprintf("%s __complete template releasename invalid/invalid --version ''", repoSetup), + golden: "output/version-invalid-comp.txt", + }} + runTestCmd(t, tests) +} + +func TestTemplateFileCompletion(t *testing.T) { + checkFileCompletion(t, "template", false) + checkFileCompletion(t, "template --generate-name", true) + checkFileCompletion(t, "template myname", true) + checkFileCompletion(t, "template myname mychart", false) +} diff --git a/cmd/helm/testdata/helm home with space/helm/plugins/fullenv/completion.yaml b/cmd/helm/testdata/helm home with space/helm/plugins/fullenv/completion.yaml new file mode 100644 index 00000000..e0b161c6 --- /dev/null +++ b/cmd/helm/testdata/helm home with space/helm/plugins/fullenv/completion.yaml @@ -0,0 +1,19 @@ +name: wrongname +commands: + - name: empty + - name: full + commands: + - name: more + validArgs: + - one + - two + flags: + - b + - ball + - name: less + flags: + - a + - all +flags: +- z +- q diff --git a/cmd/helm/testdata/helm home with space/helm/plugins/fullenv/fullenv.sh b/cmd/helm/testdata/helm home with space/helm/plugins/fullenv/fullenv.sh new file mode 100755 index 00000000..2efad9b3 --- /dev/null +++ b/cmd/helm/testdata/helm home with space/helm/plugins/fullenv/fullenv.sh @@ -0,0 +1,7 @@ +#!/bin/sh +echo $HELM_PLUGIN_NAME +echo $HELM_PLUGIN_DIR +echo $HELM_PLUGINS +echo $HELM_REPOSITORY_CONFIG +echo $HELM_REPOSITORY_CACHE +echo $HELM_BIN diff --git a/cmd/helm/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml b/cmd/helm/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml new file mode 100644 index 00000000..63f2f12d --- /dev/null +++ b/cmd/helm/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml @@ -0,0 +1,4 @@ +name: fullenv +usage: "show env vars" +description: "show all env vars" +command: "$HELM_PLUGIN_DIR/fullenv.sh" diff --git a/cmd/helm/testdata/helm home with space/helm/repositories.yaml b/cmd/helm/testdata/helm home with space/helm/repositories.yaml new file mode 100644 index 00000000..e9de487d --- /dev/null +++ b/cmd/helm/testdata/helm home with space/helm/repositories.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +generated: 2016-10-03T16:03:10.640376913-06:00 +repositories: + - cache: testing-index.yaml + name: testing + url: http://example.com/charts diff --git a/cmd/helm/testdata/helm home with space/helm/repository/test-name-charts.txt b/cmd/helm/testdata/helm home with space/helm/repository/test-name-charts.txt new file mode 100644 index 00000000..e69de29b diff --git a/cmd/helm/testdata/helm home with space/helm/repository/test-name-index.yaml b/cmd/helm/testdata/helm home with space/helm/repository/test-name-index.yaml new file mode 100644 index 00000000..d5ab620a --- /dev/null +++ b/cmd/helm/testdata/helm home with space/helm/repository/test-name-index.yaml @@ -0,0 +1,3 @@ +apiVersion: v1 +entries: {} +generated: "2020-09-09T19:50:50.198347916-04:00" diff --git a/cmd/helm/testdata/helm home with space/helm/repository/testing-index.yaml b/cmd/helm/testdata/helm home with space/helm/repository/testing-index.yaml new file mode 100644 index 00000000..91e4d463 --- /dev/null +++ b/cmd/helm/testdata/helm home with space/helm/repository/testing-index.yaml @@ -0,0 +1,66 @@ +apiVersion: v1 +entries: + alpine: + - name: alpine + url: https://charts.helm.sh/stable/alpine-0.1.0.tgz + checksum: 0e6661f193211d7a5206918d42f5c2a9470b737d + created: "2018-06-27T10:00:18.230700509Z" + deprecated: true + home: https://helm.sh/helm + sources: + - https://github.com/helm/helm + version: 0.1.0 + appVersion: 1.2.3 + description: Deploy a basic Alpine Linux pod + keywords: [] + maintainers: [] + icon: "" + apiVersion: v2 + - name: alpine + url: https://charts.helm.sh/stable/alpine-0.2.0.tgz + checksum: 0e6661f193211d7a5206918d42f5c2a9470b737d + created: "2018-07-09T11:34:37.797864902Z" + home: https://helm.sh/helm + sources: + - https://github.com/helm/helm + version: 0.2.0 + appVersion: 2.3.4 + description: Deploy a basic Alpine Linux pod + keywords: [] + maintainers: [] + icon: "" + apiVersion: v2 + - name: alpine + url: https://charts.helm.sh/stable/alpine-0.3.0-rc.1.tgz + checksum: 0e6661f193211d7a5206918d42f5c2a9470b737d + created: "2020-11-12T08:44:58.872726222Z" + home: https://helm.sh/helm + sources: + - https://github.com/helm/helm + version: 0.3.0-rc.1 + appVersion: 3.0.0 + description: Deploy a basic Alpine Linux pod + keywords: [] + maintainers: [] + icon: "" + apiVersion: v2 + mariadb: + - name: mariadb + url: https://charts.helm.sh/stable/mariadb-0.3.0.tgz + checksum: 65229f6de44a2be9f215d11dbff311673fc8ba56 + created: "2018-04-23T08:20:27.160959131Z" + home: https://mariadb.org + sources: + - https://github.com/bitnami/bitnami-docker-mariadb + version: 0.3.0 + description: Chart for MariaDB + keywords: + - mariadb + - mysql + - database + - sql + maintainers: + - name: Bitnami + email: containers@bitnami.com + icon: "" + apiVersion: v2 diff --git a/cmd/helm/testdata/helm-test-key.pub b/cmd/helm/testdata/helm-test-key.pub new file mode 100644 index 0000000000000000000000000000000000000000..38714f25adaf701b08e11fd559a587074bbde0e4 GIT binary patch literal 1243 zcmV<11SI>J0SyFKmTjH^2mr{k15wFPQdpTAAEclY4oV^-7nTy5x$&xF;PC4il}o-Pk4@#$knlp#(|J0GE?qli_lr;7-o zyY8vBsN_GJe;#w<`JdR7riNL&RJlcS)FG+W=91;dYS6NZ2tY?kZ8Sw9{r=e4|L3E{ zRod|EPC!PWgW&pe&4qiqKQAijj;G~fyjcC^m%0p54Tn{h%YFKd0VC4n=#~SRc@BVd znj66*)Om%*SEQfX{1*Tb0RRECT}WkYZ6H)-b98BLXCNq4XlZjGYh`&Lb7*gMY-AvB zZftoVVr3w8b7f>8W^ZyJbY*jNX>MmOAVg0fPES-IR8mz_R4yqXJZNQXZ7p6uVakV zT7GGV$jaKjyjfI_a~N1!Hk?5C$0wa&4)R=i$v7t&ZMycW#RkavpF%A?>MTT2anNDzOQUm<++zEOykJ9-@&c2QXq3owqf7fek`=L@+7iF zv;IW2Q>Q&r+V@cWDF&hAUUsCKlDinerKgvJUJCl$5gjb7NhM{mBP%!M^mX-iS8xFf zuLB{@MDqvtZzF#Bxd9CXSC(y_0SExW>8~h=U8|!do4*OJj2u#!KDe3v+1T+aVzU5di=Ji2)x37y$|Z2?YXImTjH_8w>yn2@r%kznCAv zhhp0`2mpxVj5j%o&5i)?`r7iES|8dA@p2kk@+XS(tjBGN)6>tm^=gayCn`gTEC*K74Y~{I_PREk) z)PstIMx1RxB@cK8%Mey%;nVnKriAKUk2Ky?dBMG3uXItKL$3N(#3P^pQa*K$l)wUy F^>pMLK0g2e literal 0 HcmV?d00001 diff --git a/cmd/helm/testdata/helm-test-key.secret b/cmd/helm/testdata/helm-test-key.secret new file mode 100644 index 0000000000000000000000000000000000000000..a966aef93ed97d01d764f29940738df6df2d9d24 GIT binary patch literal 2545 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 +if [ "$HELM_NAMESPACE" = "default" ]; then + echo ":4" +else + echo ":2" +fi diff --git a/cmd/helm/testdata/helmhome/helm/plugins/args/plugin.yaml b/cmd/helm/testdata/helmhome/helm/plugins/args/plugin.yaml new file mode 100644 index 00000000..21e28a7c --- /dev/null +++ b/cmd/helm/testdata/helmhome/helm/plugins/args/plugin.yaml @@ -0,0 +1,4 @@ +name: args +usage: "echo args" +description: "This echos args" +command: "$HELM_PLUGIN_DIR/args.sh" diff --git a/cmd/helm/testdata/helmhome/helm/plugins/echo/completion.yaml b/cmd/helm/testdata/helmhome/helm/plugins/echo/completion.yaml new file mode 100644 index 00000000..e69de29b diff --git a/cmd/helm/testdata/helmhome/helm/plugins/echo/plugin.complete b/cmd/helm/testdata/helmhome/helm/plugins/echo/plugin.complete new file mode 100755 index 00000000..63569aad --- /dev/null +++ b/cmd/helm/testdata/helmhome/helm/plugins/echo/plugin.complete @@ -0,0 +1,13 @@ +#!/usr/bin/env sh + +echo "echo plugin.complete was called" +echo "Namespace: ${HELM_NAMESPACE:-NO_NS}" +echo "Num args received: ${#}" +echo "Args received: ${@}" + +# Final printout is the optional completion directive of the form : +if [ "$HELM_NAMESPACE" = "default" ]; then + echo ":0" +# else + # Don't include the directive, to test it is really optional +fi diff --git a/cmd/helm/testdata/helmhome/helm/plugins/echo/plugin.yaml b/cmd/helm/testdata/helmhome/helm/plugins/echo/plugin.yaml new file mode 100644 index 00000000..7b9362a0 --- /dev/null +++ b/cmd/helm/testdata/helmhome/helm/plugins/echo/plugin.yaml @@ -0,0 +1,4 @@ +name: echo +usage: "echo stuff" +description: "This echos stuff" +command: "echo hello" diff --git a/cmd/helm/testdata/helmhome/helm/plugins/env/completion.yaml b/cmd/helm/testdata/helmhome/helm/plugins/env/completion.yaml new file mode 100644 index 00000000..e479a050 --- /dev/null +++ b/cmd/helm/testdata/helmhome/helm/plugins/env/completion.yaml @@ -0,0 +1,13 @@ +name: env +commands: + - name: list + flags: + - a + - all + - log + - name: remove + validArgs: + - all + - one +flags: +- global diff --git a/cmd/helm/testdata/helmhome/helm/plugins/env/plugin.yaml b/cmd/helm/testdata/helmhome/helm/plugins/env/plugin.yaml new file mode 100644 index 00000000..52cb7a84 --- /dev/null +++ b/cmd/helm/testdata/helmhome/helm/plugins/env/plugin.yaml @@ -0,0 +1,4 @@ +name: env +usage: "env stuff" +description: "show the env" +command: "echo $HELM_PLUGIN_NAME" diff --git a/cmd/helm/testdata/helmhome/helm/plugins/exitwith/completion.yaml b/cmd/helm/testdata/helmhome/helm/plugins/exitwith/completion.yaml new file mode 100644 index 00000000..e5bf440f --- /dev/null +++ b/cmd/helm/testdata/helmhome/helm/plugins/exitwith/completion.yaml @@ -0,0 +1,5 @@ +commands: + - name: code + flags: + - a + - b diff --git a/cmd/helm/testdata/helmhome/helm/plugins/exitwith/exitwith.sh b/cmd/helm/testdata/helmhome/helm/plugins/exitwith/exitwith.sh new file mode 100755 index 00000000..ec846965 --- /dev/null +++ b/cmd/helm/testdata/helmhome/helm/plugins/exitwith/exitwith.sh @@ -0,0 +1,2 @@ +#!/bin/bash +exit $* diff --git a/cmd/helm/testdata/helmhome/helm/plugins/exitwith/plugin.yaml b/cmd/helm/testdata/helmhome/helm/plugins/exitwith/plugin.yaml new file mode 100644 index 00000000..5691d171 --- /dev/null +++ b/cmd/helm/testdata/helmhome/helm/plugins/exitwith/plugin.yaml @@ -0,0 +1,4 @@ +name: exitwith +usage: "exitwith code" +description: "This exits with the specified exit code" +command: "$HELM_PLUGIN_DIR/exitwith.sh" diff --git a/cmd/helm/testdata/helmhome/helm/plugins/fullenv/completion.yaml b/cmd/helm/testdata/helmhome/helm/plugins/fullenv/completion.yaml new file mode 100644 index 00000000..e0b161c6 --- /dev/null +++ b/cmd/helm/testdata/helmhome/helm/plugins/fullenv/completion.yaml @@ -0,0 +1,19 @@ +name: wrongname +commands: + - name: empty + - name: full + commands: + - name: more + validArgs: + - one + - two + flags: + - b + - ball + - name: less + flags: + - a + - all +flags: +- z +- q diff --git a/cmd/helm/testdata/helmhome/helm/plugins/fullenv/fullenv.sh b/cmd/helm/testdata/helmhome/helm/plugins/fullenv/fullenv.sh new file mode 100755 index 00000000..2efad9b3 --- /dev/null +++ b/cmd/helm/testdata/helmhome/helm/plugins/fullenv/fullenv.sh @@ -0,0 +1,7 @@ +#!/bin/sh +echo $HELM_PLUGIN_NAME +echo $HELM_PLUGIN_DIR +echo $HELM_PLUGINS +echo $HELM_REPOSITORY_CONFIG +echo $HELM_REPOSITORY_CACHE +echo $HELM_BIN diff --git a/cmd/helm/testdata/helmhome/helm/plugins/fullenv/plugin.yaml b/cmd/helm/testdata/helmhome/helm/plugins/fullenv/plugin.yaml new file mode 100644 index 00000000..63f2f12d --- /dev/null +++ b/cmd/helm/testdata/helmhome/helm/plugins/fullenv/plugin.yaml @@ -0,0 +1,4 @@ +name: fullenv +usage: "show env vars" +description: "show all env vars" +command: "$HELM_PLUGIN_DIR/fullenv.sh" diff --git a/cmd/helm/testdata/helmhome/helm/repositories.yaml b/cmd/helm/testdata/helmhome/helm/repositories.yaml new file mode 100644 index 00000000..3835aaa5 --- /dev/null +++ b/cmd/helm/testdata/helmhome/helm/repositories.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +generated: 2016-10-03T16:03:10.640376913-06:00 +repositories: +- cache: testing-index.yaml + name: testing + url: http://example.com/charts diff --git a/cmd/helm/testdata/helmhome/helm/repository/test-name-charts.txt b/cmd/helm/testdata/helmhome/helm/repository/test-name-charts.txt new file mode 100644 index 00000000..e69de29b diff --git a/cmd/helm/testdata/helmhome/helm/repository/test-name-index.yaml b/cmd/helm/testdata/helmhome/helm/repository/test-name-index.yaml new file mode 100644 index 00000000..d5ab620a --- /dev/null +++ b/cmd/helm/testdata/helmhome/helm/repository/test-name-index.yaml @@ -0,0 +1,3 @@ +apiVersion: v1 +entries: {} +generated: "2020-09-09T19:50:50.198347916-04:00" diff --git a/cmd/helm/testdata/helmhome/helm/repository/testing-index.yaml b/cmd/helm/testdata/helmhome/helm/repository/testing-index.yaml new file mode 100644 index 00000000..91e4d463 --- /dev/null +++ b/cmd/helm/testdata/helmhome/helm/repository/testing-index.yaml @@ -0,0 +1,66 @@ +apiVersion: v1 +entries: + alpine: + - name: alpine + url: https://charts.helm.sh/stable/alpine-0.1.0.tgz + checksum: 0e6661f193211d7a5206918d42f5c2a9470b737d + created: "2018-06-27T10:00:18.230700509Z" + deprecated: true + home: https://helm.sh/helm + sources: + - https://github.com/helm/helm + version: 0.1.0 + appVersion: 1.2.3 + description: Deploy a basic Alpine Linux pod + keywords: [] + maintainers: [] + icon: "" + apiVersion: v2 + - name: alpine + url: https://charts.helm.sh/stable/alpine-0.2.0.tgz + checksum: 0e6661f193211d7a5206918d42f5c2a9470b737d + created: "2018-07-09T11:34:37.797864902Z" + home: https://helm.sh/helm + sources: + - https://github.com/helm/helm + version: 0.2.0 + appVersion: 2.3.4 + description: Deploy a basic Alpine Linux pod + keywords: [] + maintainers: [] + icon: "" + apiVersion: v2 + - name: alpine + url: https://charts.helm.sh/stable/alpine-0.3.0-rc.1.tgz + checksum: 0e6661f193211d7a5206918d42f5c2a9470b737d + created: "2020-11-12T08:44:58.872726222Z" + home: https://helm.sh/helm + sources: + - https://github.com/helm/helm + version: 0.3.0-rc.1 + appVersion: 3.0.0 + description: Deploy a basic Alpine Linux pod + keywords: [] + maintainers: [] + icon: "" + apiVersion: v2 + mariadb: + - name: mariadb + url: https://charts.helm.sh/stable/mariadb-0.3.0.tgz + checksum: 65229f6de44a2be9f215d11dbff311673fc8ba56 + created: "2018-04-23T08:20:27.160959131Z" + home: https://mariadb.org + sources: + - https://github.com/bitnami/bitnami-docker-mariadb + version: 0.3.0 + description: Chart for MariaDB + keywords: + - mariadb + - mysql + - database + - sql + maintainers: + - name: Bitnami + email: containers@bitnami.com + icon: "" + apiVersion: v2 diff --git a/cmd/helm/testdata/output/chart-with-subchart-update.txt b/cmd/helm/testdata/output/chart-with-subchart-update.txt new file mode 100644 index 00000000..a4135c78 --- /dev/null +++ b/cmd/helm/testdata/output/chart-with-subchart-update.txt @@ -0,0 +1,8 @@ +NAME: updeps +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 1 +TEST SUITE: None +NOTES: +PARENT NOTES diff --git a/cmd/helm/testdata/output/dependency-list-archive.txt b/cmd/helm/testdata/output/dependency-list-archive.txt new file mode 100644 index 00000000..ffd4542b --- /dev/null +++ b/cmd/helm/testdata/output/dependency-list-archive.txt @@ -0,0 +1,5 @@ +NAME VERSION REPOSITORY STATUS +reqsubchart 0.1.0 https://example.com/charts unpacked +reqsubchart2 0.2.0 https://example.com/charts unpacked +reqsubchart3 >=0.1.0 https://example.com/charts unpacked + diff --git a/cmd/helm/testdata/output/dependency-list-no-chart-linux.txt b/cmd/helm/testdata/output/dependency-list-no-chart-linux.txt new file mode 100644 index 00000000..8fab8f8e --- /dev/null +++ b/cmd/helm/testdata/output/dependency-list-no-chart-linux.txt @@ -0,0 +1 @@ +Error: stat /no/such/chart: no such file or directory diff --git a/cmd/helm/testdata/output/dependency-list-no-requirements-linux.txt b/cmd/helm/testdata/output/dependency-list-no-requirements-linux.txt new file mode 100644 index 00000000..35fe1d2e --- /dev/null +++ b/cmd/helm/testdata/output/dependency-list-no-requirements-linux.txt @@ -0,0 +1 @@ +WARNING: no dependencies at testdata/testcharts/alpine/charts diff --git a/cmd/helm/testdata/output/dependency-list.txt b/cmd/helm/testdata/output/dependency-list.txt new file mode 100644 index 00000000..b57c21a2 --- /dev/null +++ b/cmd/helm/testdata/output/dependency-list.txt @@ -0,0 +1,5 @@ +NAME VERSION REPOSITORY STATUS +reqsubchart 0.1.0 https://example.com/charts unpacked +reqsubchart2 0.2.0 https://example.com/charts unpacked +reqsubchart3 >=0.1.0 https://example.com/charts ok + diff --git a/cmd/helm/testdata/output/deprecated-chart.txt b/cmd/helm/testdata/output/deprecated-chart.txt new file mode 100644 index 00000000..039d6aef --- /dev/null +++ b/cmd/helm/testdata/output/deprecated-chart.txt @@ -0,0 +1,6 @@ +NAME: aeneas +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 1 +TEST SUITE: None diff --git a/cmd/helm/testdata/output/docs-type-comp.txt b/cmd/helm/testdata/output/docs-type-comp.txt new file mode 100644 index 00000000..69494f87 --- /dev/null +++ b/cmd/helm/testdata/output/docs-type-comp.txt @@ -0,0 +1,5 @@ +bash +man +markdown +:4 +Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/cmd/helm/testdata/output/empty_default_comp.txt b/cmd/helm/testdata/output/empty_default_comp.txt new file mode 100644 index 00000000..879d50d0 --- /dev/null +++ b/cmd/helm/testdata/output/empty_default_comp.txt @@ -0,0 +1,2 @@ +:0 +Completion ended with directive: ShellCompDirectiveDefault diff --git a/cmd/helm/testdata/output/empty_nofile_comp.txt b/cmd/helm/testdata/output/empty_nofile_comp.txt new file mode 100644 index 00000000..8d9fad57 --- /dev/null +++ b/cmd/helm/testdata/output/empty_nofile_comp.txt @@ -0,0 +1,2 @@ +:4 +Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/cmd/helm/testdata/output/env-comp.txt b/cmd/helm/testdata/output/env-comp.txt new file mode 100644 index 00000000..8f9c53fc --- /dev/null +++ b/cmd/helm/testdata/output/env-comp.txt @@ -0,0 +1,23 @@ +HELM_BIN +HELM_BURST_LIMIT +HELM_CACHE_HOME +HELM_CONFIG_HOME +HELM_DATA_HOME +HELM_DEBUG +HELM_KUBEAPISERVER +HELM_KUBEASGROUPS +HELM_KUBEASUSER +HELM_KUBECAFILE +HELM_KUBECONTEXT +HELM_KUBEINSECURE_SKIP_TLS_VERIFY +HELM_KUBETLS_SERVER_NAME +HELM_KUBETOKEN +HELM_MAX_HISTORY +HELM_NAMESPACE +HELM_PLUGINS +HELM_QPS +HELM_REGISTRY_CONFIG +HELM_REPOSITORY_CACHE +HELM_REPOSITORY_CONFIG +:4 +Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/cmd/helm/testdata/output/get-all-no-args.txt b/cmd/helm/testdata/output/get-all-no-args.txt new file mode 100644 index 00000000..cc3fc2ad --- /dev/null +++ b/cmd/helm/testdata/output/get-all-no-args.txt @@ -0,0 +1,3 @@ +Error: "helm get all" requires 1 argument + +Usage: helm get all RELEASE_NAME [flags] diff --git a/cmd/helm/testdata/output/get-hooks-no-args.txt b/cmd/helm/testdata/output/get-hooks-no-args.txt new file mode 100644 index 00000000..2911fdb8 --- /dev/null +++ b/cmd/helm/testdata/output/get-hooks-no-args.txt @@ -0,0 +1,3 @@ +Error: "helm get hooks" requires 1 argument + +Usage: helm get hooks RELEASE_NAME [flags] diff --git a/cmd/helm/testdata/output/get-hooks.txt b/cmd/helm/testdata/output/get-hooks.txt new file mode 100644 index 00000000..81e87b1f --- /dev/null +++ b/cmd/helm/testdata/output/get-hooks.txt @@ -0,0 +1,8 @@ +--- +# Source: pre-install-hook.yaml +apiVersion: v1 +kind: Job +metadata: + annotations: + "helm.sh/hook": pre-install + diff --git a/cmd/helm/testdata/output/get-manifest-no-args.txt b/cmd/helm/testdata/output/get-manifest-no-args.txt new file mode 100644 index 00000000..df7aa5b0 --- /dev/null +++ b/cmd/helm/testdata/output/get-manifest-no-args.txt @@ -0,0 +1,3 @@ +Error: "helm get manifest" requires 1 argument + +Usage: helm get manifest RELEASE_NAME [flags] diff --git a/cmd/helm/testdata/output/get-manifest.txt b/cmd/helm/testdata/output/get-manifest.txt new file mode 100644 index 00000000..88937e08 --- /dev/null +++ b/cmd/helm/testdata/output/get-manifest.txt @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: Secret +metadata: + name: fixture + diff --git a/cmd/helm/testdata/output/get-metadata-args.txt b/cmd/helm/testdata/output/get-metadata-args.txt new file mode 100644 index 00000000..acd3f4c1 --- /dev/null +++ b/cmd/helm/testdata/output/get-metadata-args.txt @@ -0,0 +1,3 @@ +Error: "helm get metadata" requires 1 argument + +Usage: helm get metadata RELEASE_NAME [flags] diff --git a/cmd/helm/testdata/output/get-metadata.json b/cmd/helm/testdata/output/get-metadata.json new file mode 100644 index 00000000..1d5152b2 --- /dev/null +++ b/cmd/helm/testdata/output/get-metadata.json @@ -0,0 +1 @@ +{"name":"thomas-guide","chart":"foo","version":"0.1.0-beta.1","appVersion":"1.0","namespace":"default","revision":1,"status":"deployed","deployedAt":"1977-09-02T22:04:05Z"} diff --git a/cmd/helm/testdata/output/get-metadata.txt b/cmd/helm/testdata/output/get-metadata.txt new file mode 100644 index 00000000..b91f1b86 --- /dev/null +++ b/cmd/helm/testdata/output/get-metadata.txt @@ -0,0 +1,8 @@ +NAME: thomas-guide +CHART: foo +VERSION: 0.1.0-beta.1 +APP_VERSION: 1.0 +NAMESPACE: default +REVISION: 1 +STATUS: deployed +DEPLOYED_AT: 1977-09-02T22:04:05Z diff --git a/cmd/helm/testdata/output/get-metadata.yaml b/cmd/helm/testdata/output/get-metadata.yaml new file mode 100644 index 00000000..b6d49b03 --- /dev/null +++ b/cmd/helm/testdata/output/get-metadata.yaml @@ -0,0 +1,8 @@ +appVersion: "1.0" +chart: foo +deployedAt: "1977-09-02T22:04:05Z" +name: thomas-guide +namespace: default +revision: 1 +status: deployed +version: 0.1.0-beta.1 diff --git a/cmd/helm/testdata/output/get-notes-no-args.txt b/cmd/helm/testdata/output/get-notes-no-args.txt new file mode 100644 index 00000000..1a0c20ca --- /dev/null +++ b/cmd/helm/testdata/output/get-notes-no-args.txt @@ -0,0 +1,3 @@ +Error: "helm get notes" requires 1 argument + +Usage: helm get notes RELEASE_NAME [flags] diff --git a/cmd/helm/testdata/output/get-notes.txt b/cmd/helm/testdata/output/get-notes.txt new file mode 100644 index 00000000..e710c780 --- /dev/null +++ b/cmd/helm/testdata/output/get-notes.txt @@ -0,0 +1,2 @@ +NOTES: +Some mock release notes! diff --git a/cmd/helm/testdata/output/get-release-template.txt b/cmd/helm/testdata/output/get-release-template.txt new file mode 100644 index 00000000..02d44fb0 --- /dev/null +++ b/cmd/helm/testdata/output/get-release-template.txt @@ -0,0 +1 @@ +0.1.0-beta.1 \ No newline at end of file diff --git a/cmd/helm/testdata/output/get-release.txt b/cmd/helm/testdata/output/get-release.txt new file mode 100644 index 00000000..12b4a407 --- /dev/null +++ b/cmd/helm/testdata/output/get-release.txt @@ -0,0 +1,32 @@ +NAME: thomas-guide +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 1 +CHART: foo +VERSION: 0.1.0-beta.1 +APP_VERSION: 1.0 +TEST SUITE: None +USER-SUPPLIED VALUES: +name: value + +COMPUTED VALUES: +name: value + +HOOKS: +--- +# Source: pre-install-hook.yaml +apiVersion: v1 +kind: Job +metadata: + annotations: + "helm.sh/hook": pre-install + +MANIFEST: +apiVersion: v1 +kind: Secret +metadata: + name: fixture + +NOTES: +Some mock release notes! diff --git a/cmd/helm/testdata/output/get-values-all.txt b/cmd/helm/testdata/output/get-values-all.txt new file mode 100644 index 00000000..b7e9696b --- /dev/null +++ b/cmd/helm/testdata/output/get-values-all.txt @@ -0,0 +1,2 @@ +COMPUTED VALUES: +name: value diff --git a/cmd/helm/testdata/output/get-values-args.txt b/cmd/helm/testdata/output/get-values-args.txt new file mode 100644 index 00000000..c8a65e7f --- /dev/null +++ b/cmd/helm/testdata/output/get-values-args.txt @@ -0,0 +1,3 @@ +Error: "helm get values" requires 1 argument + +Usage: helm get values RELEASE_NAME [flags] diff --git a/cmd/helm/testdata/output/get-values.txt b/cmd/helm/testdata/output/get-values.txt new file mode 100644 index 00000000..b7d146b1 --- /dev/null +++ b/cmd/helm/testdata/output/get-values.txt @@ -0,0 +1,2 @@ +USER-SUPPLIED VALUES: +name: value diff --git a/cmd/helm/testdata/output/history-limit.txt b/cmd/helm/testdata/output/history-limit.txt new file mode 100644 index 00000000..aee0fadb --- /dev/null +++ b/cmd/helm/testdata/output/history-limit.txt @@ -0,0 +1,3 @@ +REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION +3 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Release mock +4 Fri Sep 2 22:04:05 1977 deployed foo-0.1.0-beta.1 1.0 Release mock diff --git a/cmd/helm/testdata/output/history.json b/cmd/helm/testdata/output/history.json new file mode 100644 index 00000000..35311d3c --- /dev/null +++ b/cmd/helm/testdata/output/history.json @@ -0,0 +1 @@ +[{"revision":3,"updated":"1977-09-02T22:04:05Z","status":"superseded","chart":"foo-0.1.0-beta.1","app_version":"1.0","description":"Release mock"},{"revision":4,"updated":"1977-09-02T22:04:05Z","status":"deployed","chart":"foo-0.1.0-beta.1","app_version":"1.0","description":"Release mock"}] diff --git a/cmd/helm/testdata/output/history.txt b/cmd/helm/testdata/output/history.txt new file mode 100644 index 00000000..2a5d69c1 --- /dev/null +++ b/cmd/helm/testdata/output/history.txt @@ -0,0 +1,5 @@ +REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION +1 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Release mock +2 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Release mock +3 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Release mock +4 Fri Sep 2 22:04:05 1977 deployed foo-0.1.0-beta.1 1.0 Release mock diff --git a/cmd/helm/testdata/output/history.yaml b/cmd/helm/testdata/output/history.yaml new file mode 100644 index 00000000..b7ae03be --- /dev/null +++ b/cmd/helm/testdata/output/history.yaml @@ -0,0 +1,12 @@ +- app_version: "1.0" + chart: foo-0.1.0-beta.1 + description: Release mock + revision: 3 + status: superseded + updated: "1977-09-02T22:04:05Z" +- app_version: "1.0" + chart: foo-0.1.0-beta.1 + description: Release mock + revision: 4 + status: deployed + updated: "1977-09-02T22:04:05Z" diff --git a/cmd/helm/testdata/output/install-and-replace.txt b/cmd/helm/testdata/output/install-and-replace.txt new file mode 100644 index 00000000..039d6aef --- /dev/null +++ b/cmd/helm/testdata/output/install-and-replace.txt @@ -0,0 +1,6 @@ +NAME: aeneas +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 1 +TEST SUITE: None diff --git a/cmd/helm/testdata/output/install-chart-bad-type.txt b/cmd/helm/testdata/output/install-chart-bad-type.txt new file mode 100644 index 00000000..c482a793 --- /dev/null +++ b/cmd/helm/testdata/output/install-chart-bad-type.txt @@ -0,0 +1 @@ +Error: INSTALLATION FAILED: validation: chart.metadata.type must be application or library diff --git a/cmd/helm/testdata/output/install-lib-chart.txt b/cmd/helm/testdata/output/install-lib-chart.txt new file mode 100644 index 00000000..c482a793 --- /dev/null +++ b/cmd/helm/testdata/output/install-lib-chart.txt @@ -0,0 +1 @@ +Error: INSTALLATION FAILED: validation: chart.metadata.type must be application or library diff --git a/cmd/helm/testdata/output/install-name-template.txt b/cmd/helm/testdata/output/install-name-template.txt new file mode 100644 index 00000000..19952e3c --- /dev/null +++ b/cmd/helm/testdata/output/install-name-template.txt @@ -0,0 +1,6 @@ +NAME: foobar +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 1 +TEST SUITE: None diff --git a/cmd/helm/testdata/output/install-no-args.txt b/cmd/helm/testdata/output/install-no-args.txt new file mode 100644 index 00000000..47f010ab --- /dev/null +++ b/cmd/helm/testdata/output/install-no-args.txt @@ -0,0 +1,3 @@ +Error: "helm install" requires at least 1 argument + +Usage: helm install [NAME] [CHART] [flags] diff --git a/cmd/helm/testdata/output/install-no-hooks.txt b/cmd/helm/testdata/output/install-no-hooks.txt new file mode 100644 index 00000000..039d6aef --- /dev/null +++ b/cmd/helm/testdata/output/install-no-hooks.txt @@ -0,0 +1,6 @@ +NAME: aeneas +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 1 +TEST SUITE: None diff --git a/cmd/helm/testdata/output/install-with-multiple-values-files.txt b/cmd/helm/testdata/output/install-with-multiple-values-files.txt new file mode 100644 index 00000000..406e522a --- /dev/null +++ b/cmd/helm/testdata/output/install-with-multiple-values-files.txt @@ -0,0 +1,6 @@ +NAME: virgil +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 1 +TEST SUITE: None diff --git a/cmd/helm/testdata/output/install-with-multiple-values.txt b/cmd/helm/testdata/output/install-with-multiple-values.txt new file mode 100644 index 00000000..406e522a --- /dev/null +++ b/cmd/helm/testdata/output/install-with-multiple-values.txt @@ -0,0 +1,6 @@ +NAME: virgil +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 1 +TEST SUITE: None diff --git a/cmd/helm/testdata/output/install-with-timeout.txt b/cmd/helm/testdata/output/install-with-timeout.txt new file mode 100644 index 00000000..19952e3c --- /dev/null +++ b/cmd/helm/testdata/output/install-with-timeout.txt @@ -0,0 +1,6 @@ +NAME: foobar +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 1 +TEST SUITE: None diff --git a/cmd/helm/testdata/output/install-with-values-file.txt b/cmd/helm/testdata/output/install-with-values-file.txt new file mode 100644 index 00000000..406e522a --- /dev/null +++ b/cmd/helm/testdata/output/install-with-values-file.txt @@ -0,0 +1,6 @@ +NAME: virgil +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 1 +TEST SUITE: None diff --git a/cmd/helm/testdata/output/install-with-values.txt b/cmd/helm/testdata/output/install-with-values.txt new file mode 100644 index 00000000..406e522a --- /dev/null +++ b/cmd/helm/testdata/output/install-with-values.txt @@ -0,0 +1,6 @@ +NAME: virgil +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 1 +TEST SUITE: None diff --git a/cmd/helm/testdata/output/install-with-wait-for-jobs.txt b/cmd/helm/testdata/output/install-with-wait-for-jobs.txt new file mode 100644 index 00000000..7ce22d4e --- /dev/null +++ b/cmd/helm/testdata/output/install-with-wait-for-jobs.txt @@ -0,0 +1,6 @@ +NAME: apollo +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 1 +TEST SUITE: None diff --git a/cmd/helm/testdata/output/install-with-wait.txt b/cmd/helm/testdata/output/install-with-wait.txt new file mode 100644 index 00000000..7ce22d4e --- /dev/null +++ b/cmd/helm/testdata/output/install-with-wait.txt @@ -0,0 +1,6 @@ +NAME: apollo +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 1 +TEST SUITE: None diff --git a/cmd/helm/testdata/output/install.txt b/cmd/helm/testdata/output/install.txt new file mode 100644 index 00000000..039d6aef --- /dev/null +++ b/cmd/helm/testdata/output/install.txt @@ -0,0 +1,6 @@ +NAME: aeneas +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 1 +TEST SUITE: None diff --git a/cmd/helm/testdata/output/issue-9027.txt b/cmd/helm/testdata/output/issue-9027.txt new file mode 100644 index 00000000..eb19fc38 --- /dev/null +++ b/cmd/helm/testdata/output/issue-9027.txt @@ -0,0 +1,32 @@ +--- +# Source: issue-9027/charts/subchart/templates/values.yaml +global: + hash: + key3: 13 + key4: 4 + key5: 5 + key6: 6 +hash: + key3: 13 + key4: 4 + key5: 5 + key6: 6 +--- +# Source: issue-9027/templates/values.yaml +global: + hash: + key1: null + key2: null + key3: 13 +subchart: + global: + hash: + key3: 13 + key4: 4 + key5: 5 + key6: 6 + hash: + key3: 13 + key4: 4 + key5: 5 + key6: 6 diff --git a/cmd/helm/testdata/output/lint-chart-with-bad-subcharts-with-subcharts.txt b/cmd/helm/testdata/output/lint-chart-with-bad-subcharts-with-subcharts.txt new file mode 100644 index 00000000..d43c7c36 --- /dev/null +++ b/cmd/helm/testdata/output/lint-chart-with-bad-subcharts-with-subcharts.txt @@ -0,0 +1,19 @@ +==> Linting testdata/testcharts/chart-with-bad-subcharts +[INFO] Chart.yaml: icon is recommended +[ERROR] templates/: error unpacking bad-subchart in chart-with-bad-subcharts: validation: chart.metadata.name is required +[ERROR] : unable to load chart + error unpacking bad-subchart in chart-with-bad-subcharts: validation: chart.metadata.name is required + +==> Linting testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart +[ERROR] Chart.yaml: name is required +[ERROR] Chart.yaml: apiVersion is required. The value must be either "v1" or "v2" +[ERROR] Chart.yaml: version is required +[INFO] Chart.yaml: icon is recommended +[ERROR] templates/: validation: chart.metadata.name is required +[ERROR] : unable to load chart + validation: chart.metadata.name is required + +==> Linting testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart +[INFO] Chart.yaml: icon is recommended + +Error: 3 chart(s) linted, 2 chart(s) failed diff --git a/cmd/helm/testdata/output/lint-chart-with-bad-subcharts.txt b/cmd/helm/testdata/output/lint-chart-with-bad-subcharts.txt new file mode 100644 index 00000000..7c898b89 --- /dev/null +++ b/cmd/helm/testdata/output/lint-chart-with-bad-subcharts.txt @@ -0,0 +1,7 @@ +==> Linting testdata/testcharts/chart-with-bad-subcharts +[INFO] Chart.yaml: icon is recommended +[ERROR] templates/: error unpacking bad-subchart in chart-with-bad-subcharts: validation: chart.metadata.name is required +[ERROR] : unable to load chart + error unpacking bad-subchart in chart-with-bad-subcharts: validation: chart.metadata.name is required + +Error: 1 chart(s) linted, 1 chart(s) failed diff --git a/cmd/helm/testdata/output/lint-chart-with-deprecated-api-old-k8s.txt b/cmd/helm/testdata/output/lint-chart-with-deprecated-api-old-k8s.txt new file mode 100644 index 00000000..bd0d7000 --- /dev/null +++ b/cmd/helm/testdata/output/lint-chart-with-deprecated-api-old-k8s.txt @@ -0,0 +1,4 @@ +==> Linting testdata/testcharts/chart-with-deprecated-api +[INFO] Chart.yaml: icon is recommended + +1 chart(s) linted, 0 chart(s) failed diff --git a/cmd/helm/testdata/output/lint-chart-with-deprecated-api-strict.txt b/cmd/helm/testdata/output/lint-chart-with-deprecated-api-strict.txt new file mode 100644 index 00000000..a1ec4394 --- /dev/null +++ b/cmd/helm/testdata/output/lint-chart-with-deprecated-api-strict.txt @@ -0,0 +1,5 @@ +==> Linting testdata/testcharts/chart-with-deprecated-api +[INFO] Chart.yaml: icon is recommended +[WARNING] templates/horizontalpodautoscaler.yaml: autoscaling/v2beta1 HorizontalPodAutoscaler is deprecated in v1.22+, unavailable in v1.25+; use autoscaling/v2 HorizontalPodAutoscaler + +Error: 1 chart(s) linted, 1 chart(s) failed diff --git a/cmd/helm/testdata/output/lint-chart-with-deprecated-api.txt b/cmd/helm/testdata/output/lint-chart-with-deprecated-api.txt new file mode 100644 index 00000000..dac54620 --- /dev/null +++ b/cmd/helm/testdata/output/lint-chart-with-deprecated-api.txt @@ -0,0 +1,5 @@ +==> Linting testdata/testcharts/chart-with-deprecated-api +[INFO] Chart.yaml: icon is recommended +[WARNING] templates/horizontalpodautoscaler.yaml: autoscaling/v2beta1 HorizontalPodAutoscaler is deprecated in v1.22+, unavailable in v1.25+; use autoscaling/v2 HorizontalPodAutoscaler + +1 chart(s) linted, 0 chart(s) failed diff --git a/cmd/helm/testdata/output/lint-quiet-with-error.txt b/cmd/helm/testdata/output/lint-quiet-with-error.txt new file mode 100644 index 00000000..e3d29a5a --- /dev/null +++ b/cmd/helm/testdata/output/lint-quiet-with-error.txt @@ -0,0 +1,8 @@ +==> Linting testdata/testcharts/chart-bad-requirements +[ERROR] Chart.yaml: unable to parse YAML + error converting YAML to JSON: yaml: line 6: did not find expected '-' indicator +[ERROR] templates/: cannot load Chart.yaml: error converting YAML to JSON: yaml: line 6: did not find expected '-' indicator +[ERROR] : unable to load chart + cannot load Chart.yaml: error converting YAML to JSON: yaml: line 6: did not find expected '-' indicator + +Error: 2 chart(s) linted, 1 chart(s) failed diff --git a/cmd/helm/testdata/output/lint-quiet-with-warning.txt b/cmd/helm/testdata/output/lint-quiet-with-warning.txt new file mode 100644 index 00000000..e69de29b diff --git a/cmd/helm/testdata/output/lint-quiet.txt b/cmd/helm/testdata/output/lint-quiet.txt new file mode 100644 index 00000000..e69de29b diff --git a/cmd/helm/testdata/output/list-all.txt b/cmd/helm/testdata/output/list-all.txt new file mode 100644 index 00000000..ef6d44cd --- /dev/null +++ b/cmd/helm/testdata/output/list-all.txt @@ -0,0 +1,9 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +drax default 1 2016-01-16 00:00:01 +0000 UTC uninstalling chickadee-1.0.0 0.0.1 +gamora default 1 2016-01-16 00:00:01 +0000 UTC superseded chickadee-1.0.0 0.0.1 +groot default 1 2016-01-16 00:00:01 +0000 UTC uninstalled chickadee-1.0.0 0.0.1 +hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 +iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 +rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 +starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 +thanos default 1 2016-01-16 00:00:01 +0000 UTC pending-install chickadee-1.0.0 0.0.1 diff --git a/cmd/helm/testdata/output/list-date-reversed.txt b/cmd/helm/testdata/output/list-date-reversed.txt new file mode 100644 index 00000000..8b4e71a3 --- /dev/null +++ b/cmd/helm/testdata/output/list-date-reversed.txt @@ -0,0 +1,5 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 +hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 +rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 +starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 diff --git a/cmd/helm/testdata/output/list-date.txt b/cmd/helm/testdata/output/list-date.txt new file mode 100644 index 00000000..3d2b27ad --- /dev/null +++ b/cmd/helm/testdata/output/list-date.txt @@ -0,0 +1,5 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 +rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 +hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 +iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 diff --git a/cmd/helm/testdata/output/list-failed.txt b/cmd/helm/testdata/output/list-failed.txt new file mode 100644 index 00000000..a8ec3e13 --- /dev/null +++ b/cmd/helm/testdata/output/list-failed.txt @@ -0,0 +1,2 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 diff --git a/cmd/helm/testdata/output/list-filter.txt b/cmd/helm/testdata/output/list-filter.txt new file mode 100644 index 00000000..0a820922 --- /dev/null +++ b/cmd/helm/testdata/output/list-filter.txt @@ -0,0 +1,5 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 +iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 +rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 +starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 diff --git a/cmd/helm/testdata/output/list-max.txt b/cmd/helm/testdata/output/list-max.txt new file mode 100644 index 00000000..a909322b --- /dev/null +++ b/cmd/helm/testdata/output/list-max.txt @@ -0,0 +1,2 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 diff --git a/cmd/helm/testdata/output/list-namespace.txt b/cmd/helm/testdata/output/list-namespace.txt new file mode 100644 index 00000000..9382327d --- /dev/null +++ b/cmd/helm/testdata/output/list-namespace.txt @@ -0,0 +1,2 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +starlord milano 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 diff --git a/cmd/helm/testdata/output/list-no-headers.txt b/cmd/helm/testdata/output/list-no-headers.txt new file mode 100644 index 00000000..9d11d0ca --- /dev/null +++ b/cmd/helm/testdata/output/list-no-headers.txt @@ -0,0 +1,4 @@ +hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 +iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 +rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 +starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 diff --git a/cmd/helm/testdata/output/list-offset.txt b/cmd/helm/testdata/output/list-offset.txt new file mode 100644 index 00000000..36e963ca --- /dev/null +++ b/cmd/helm/testdata/output/list-offset.txt @@ -0,0 +1,4 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 +rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 +starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 diff --git a/cmd/helm/testdata/output/list-pending.txt b/cmd/helm/testdata/output/list-pending.txt new file mode 100644 index 00000000..f3d7aa03 --- /dev/null +++ b/cmd/helm/testdata/output/list-pending.txt @@ -0,0 +1,2 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +thanos default 1 2016-01-16 00:00:01 +0000 UTC pending-install chickadee-1.0.0 0.0.1 diff --git a/cmd/helm/testdata/output/list-reverse.txt b/cmd/helm/testdata/output/list-reverse.txt new file mode 100644 index 00000000..da178b2c --- /dev/null +++ b/cmd/helm/testdata/output/list-reverse.txt @@ -0,0 +1,5 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 +rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 +iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 +hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 diff --git a/cmd/helm/testdata/output/list-short-json.txt b/cmd/helm/testdata/output/list-short-json.txt new file mode 100644 index 00000000..acbf1e44 --- /dev/null +++ b/cmd/helm/testdata/output/list-short-json.txt @@ -0,0 +1 @@ +["hummingbird","iguana","rocket","starlord"] diff --git a/cmd/helm/testdata/output/list-short-yaml.txt b/cmd/helm/testdata/output/list-short-yaml.txt new file mode 100644 index 00000000..86fb3d67 --- /dev/null +++ b/cmd/helm/testdata/output/list-short-yaml.txt @@ -0,0 +1,4 @@ +- hummingbird +- iguana +- rocket +- starlord diff --git a/cmd/helm/testdata/output/list-short.txt b/cmd/helm/testdata/output/list-short.txt new file mode 100644 index 00000000..0a63be99 --- /dev/null +++ b/cmd/helm/testdata/output/list-short.txt @@ -0,0 +1,4 @@ +hummingbird +iguana +rocket +starlord diff --git a/cmd/helm/testdata/output/list-superseded.txt b/cmd/helm/testdata/output/list-superseded.txt new file mode 100644 index 00000000..50b43587 --- /dev/null +++ b/cmd/helm/testdata/output/list-superseded.txt @@ -0,0 +1,3 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +gamora default 1 2016-01-16 00:00:01 +0000 UTC superseded chickadee-1.0.0 0.0.1 +starlord default 1 2016-01-16 00:00:01 +0000 UTC superseded chickadee-1.0.0 0.0.1 diff --git a/cmd/helm/testdata/output/list-uninstalled.txt b/cmd/helm/testdata/output/list-uninstalled.txt new file mode 100644 index 00000000..430cf32f --- /dev/null +++ b/cmd/helm/testdata/output/list-uninstalled.txt @@ -0,0 +1,2 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +groot default 1 2016-01-16 00:00:01 +0000 UTC uninstalled chickadee-1.0.0 0.0.1 diff --git a/cmd/helm/testdata/output/list-uninstalling.txt b/cmd/helm/testdata/output/list-uninstalling.txt new file mode 100644 index 00000000..92289639 --- /dev/null +++ b/cmd/helm/testdata/output/list-uninstalling.txt @@ -0,0 +1,2 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +drax default 1 2016-01-16 00:00:01 +0000 UTC uninstalling chickadee-1.0.0 0.0.1 diff --git a/cmd/helm/testdata/output/list.txt b/cmd/helm/testdata/output/list.txt new file mode 100644 index 00000000..0a820922 --- /dev/null +++ b/cmd/helm/testdata/output/list.txt @@ -0,0 +1,5 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 +iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 +rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 +starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 diff --git a/cmd/helm/testdata/output/object-order.txt b/cmd/helm/testdata/output/object-order.txt new file mode 100644 index 00000000..307f928f --- /dev/null +++ b/cmd/helm/testdata/output/object-order.txt @@ -0,0 +1,191 @@ +--- +# Source: object-order/templates/01-a.yml +# 1 +kind: NetworkPolicy +apiVersion: networking.k8s.io/v1 +metadata: + name: first +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress +--- +# Source: object-order/templates/01-a.yml +# 2 +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: second +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress +--- +# Source: object-order/templates/01-a.yml +# 3 +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: third +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress +--- +# Source: object-order/templates/02-b.yml +# 5 +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: fifth +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress +--- +# Source: object-order/templates/02-b.yml +# 7 +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: seventh +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress +--- +# Source: object-order/templates/02-b.yml +# 8 +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: eighth +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress +--- +# Source: object-order/templates/02-b.yml +# 9 +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: ninth +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress +--- +# Source: object-order/templates/02-b.yml +# 10 +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: tenth +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress +--- +# Source: object-order/templates/02-b.yml +# 11 +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: eleventh +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress +--- +# Source: object-order/templates/02-b.yml +# 12 +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: twelfth +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress +--- +# Source: object-order/templates/02-b.yml +# 13 +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: thirteenth +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress +--- +# Source: object-order/templates/02-b.yml +# 14 +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: fourteenth +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress +--- +# Source: object-order/templates/02-b.yml +# 15 (11th object within 02-b.yml, in order to test `SplitManifests` which assigns `manifest-10` +# to this object which should then come *after* `manifest-9`) +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: fifteenth +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress +--- +# Source: object-order/templates/01-a.yml +# 4 (Deployment should come after all NetworkPolicy manifests, since 'helm template' outputs in install order) +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fourth +spec: + selector: + matchLabels: + pod: fourth + replicas: 1 + template: + metadata: + labels: + pod: fourth + spec: + containers: + - name: hello-world + image: gcr.io/google-samples/node-hello:1.0 +--- +# Source: object-order/templates/02-b.yml +# 6 (implementation detail: currently, 'helm template' outputs hook manifests last; and yes, NetworkPolicy won't make a reasonable hook, this is just a dummy unit test manifest) +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + annotations: + "helm.sh/hook": pre-install + name: sixth +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress diff --git a/cmd/helm/testdata/output/output-comp.txt b/cmd/helm/testdata/output/output-comp.txt new file mode 100644 index 00000000..6232b292 --- /dev/null +++ b/cmd/helm/testdata/output/output-comp.txt @@ -0,0 +1,5 @@ +json Output result in JSON format +table Output result in human-readable format +yaml Output result in YAML format +:4 +Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/cmd/helm/testdata/output/plugin_args_comp.txt b/cmd/helm/testdata/output/plugin_args_comp.txt new file mode 100644 index 00000000..4070cb1e --- /dev/null +++ b/cmd/helm/testdata/output/plugin_args_comp.txt @@ -0,0 +1,6 @@ +plugin.complete was called +Namespace: default +Num args received: 1 +Args received: +:4 +Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/cmd/helm/testdata/output/plugin_args_flag_comp.txt b/cmd/helm/testdata/output/plugin_args_flag_comp.txt new file mode 100644 index 00000000..87300fa9 --- /dev/null +++ b/cmd/helm/testdata/output/plugin_args_flag_comp.txt @@ -0,0 +1,6 @@ +plugin.complete was called +Namespace: default +Num args received: 2 +Args received: --myflag +:4 +Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/cmd/helm/testdata/output/plugin_args_many_args_comp.txt b/cmd/helm/testdata/output/plugin_args_many_args_comp.txt new file mode 100644 index 00000000..f3c386b6 --- /dev/null +++ b/cmd/helm/testdata/output/plugin_args_many_args_comp.txt @@ -0,0 +1,6 @@ +plugin.complete was called +Namespace: mynamespace +Num args received: 2 +Args received: --myflag start +:2 +Completion ended with directive: ShellCompDirectiveNoSpace diff --git a/cmd/helm/testdata/output/plugin_args_ns_comp.txt b/cmd/helm/testdata/output/plugin_args_ns_comp.txt new file mode 100644 index 00000000..13bfcd3f --- /dev/null +++ b/cmd/helm/testdata/output/plugin_args_ns_comp.txt @@ -0,0 +1,6 @@ +plugin.complete was called +Namespace: mynamespace +Num args received: 1 +Args received: +:2 +Completion ended with directive: ShellCompDirectiveNoSpace diff --git a/cmd/helm/testdata/output/plugin_echo_no_directive.txt b/cmd/helm/testdata/output/plugin_echo_no_directive.txt new file mode 100644 index 00000000..99cc47c1 --- /dev/null +++ b/cmd/helm/testdata/output/plugin_echo_no_directive.txt @@ -0,0 +1,6 @@ +echo plugin.complete was called +Namespace: mynamespace +Num args received: 1 +Args received: +:0 +Completion ended with directive: ShellCompDirectiveDefault diff --git a/cmd/helm/testdata/output/plugin_list_comp.txt b/cmd/helm/testdata/output/plugin_list_comp.txt new file mode 100644 index 00000000..833efc5e --- /dev/null +++ b/cmd/helm/testdata/output/plugin_list_comp.txt @@ -0,0 +1,7 @@ +args echo args +echo echo stuff +env env stuff +exitwith exitwith code +fullenv show env vars +:4 +Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/cmd/helm/testdata/output/plugin_repeat_comp.txt b/cmd/helm/testdata/output/plugin_repeat_comp.txt new file mode 100644 index 00000000..3fa05f0b --- /dev/null +++ b/cmd/helm/testdata/output/plugin_repeat_comp.txt @@ -0,0 +1,6 @@ +echo echo stuff +env env stuff +exitwith exitwith code +fullenv show env vars +:4 +Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/cmd/helm/testdata/output/release_list_comp.txt b/cmd/helm/testdata/output/release_list_comp.txt new file mode 100644 index 00000000..226c378a --- /dev/null +++ b/cmd/helm/testdata/output/release_list_comp.txt @@ -0,0 +1,5 @@ +aramis foo-0.1.0-beta.1 -> deployed +athos foo-0.1.0-beta.1 -> deployed +porthos foo-0.1.0-beta.1 -> deployed +:4 +Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/cmd/helm/testdata/output/release_list_repeat_comp.txt b/cmd/helm/testdata/output/release_list_repeat_comp.txt new file mode 100644 index 00000000..aa330f47 --- /dev/null +++ b/cmd/helm/testdata/output/release_list_repeat_comp.txt @@ -0,0 +1,4 @@ +aramis foo-0.1.0-beta.1 -> deployed +athos foo-0.1.0-beta.1 -> deployed +:4 +Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/cmd/helm/testdata/output/repo-add.txt b/cmd/helm/testdata/output/repo-add.txt new file mode 100644 index 00000000..e8882321 --- /dev/null +++ b/cmd/helm/testdata/output/repo-add.txt @@ -0,0 +1 @@ +"test-name" has been added to your repositories diff --git a/cmd/helm/testdata/output/repo-add2.txt b/cmd/helm/testdata/output/repo-add2.txt new file mode 100644 index 00000000..263ffa9e --- /dev/null +++ b/cmd/helm/testdata/output/repo-add2.txt @@ -0,0 +1 @@ +"test-name" already exists with the same configuration, skipping diff --git a/cmd/helm/testdata/output/repo_list_comp.txt b/cmd/helm/testdata/output/repo_list_comp.txt new file mode 100644 index 00000000..289e0d2e --- /dev/null +++ b/cmd/helm/testdata/output/repo_list_comp.txt @@ -0,0 +1,5 @@ +foo +bar +baz +:4 +Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/cmd/helm/testdata/output/repo_repeat_comp.txt b/cmd/helm/testdata/output/repo_repeat_comp.txt new file mode 100644 index 00000000..ed8ed89f --- /dev/null +++ b/cmd/helm/testdata/output/repo_repeat_comp.txt @@ -0,0 +1,4 @@ +bar +baz +:4 +Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/cmd/helm/testdata/output/revision-comp.txt b/cmd/helm/testdata/output/revision-comp.txt new file mode 100644 index 00000000..fe9faf1f --- /dev/null +++ b/cmd/helm/testdata/output/revision-comp.txt @@ -0,0 +1,6 @@ +8 App: 1.0, Chart: foo-0.1.0-beta.1 +9 App: 1.0, Chart: foo-0.1.0-beta.1 +10 App: 1.0, Chart: foo-0.1.0-beta.1 +11 App: 1.0, Chart: foo-0.1.0-beta.1 +:4 +Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/cmd/helm/testdata/output/revision-wrong-args-comp.txt b/cmd/helm/testdata/output/revision-wrong-args-comp.txt new file mode 100644 index 00000000..8d9fad57 --- /dev/null +++ b/cmd/helm/testdata/output/revision-wrong-args-comp.txt @@ -0,0 +1,2 @@ +:4 +Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/cmd/helm/testdata/output/rollback-comp.txt b/cmd/helm/testdata/output/rollback-comp.txt new file mode 100644 index 00000000..2cfeed1f --- /dev/null +++ b/cmd/helm/testdata/output/rollback-comp.txt @@ -0,0 +1,4 @@ +carabins foo-0.1.0-beta.1 -> superseded +musketeers foo-0.1.0-beta.1 -> deployed +:4 +Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/cmd/helm/testdata/output/rollback-no-args.txt b/cmd/helm/testdata/output/rollback-no-args.txt new file mode 100644 index 00000000..a1bc30b7 --- /dev/null +++ b/cmd/helm/testdata/output/rollback-no-args.txt @@ -0,0 +1,3 @@ +Error: "helm rollback" requires at least 1 argument + +Usage: helm rollback [REVISION] [flags] diff --git a/cmd/helm/testdata/output/rollback-no-revision.txt b/cmd/helm/testdata/output/rollback-no-revision.txt new file mode 100644 index 00000000..ae3c6f1c --- /dev/null +++ b/cmd/helm/testdata/output/rollback-no-revision.txt @@ -0,0 +1 @@ +Rollback was a success! Happy Helming! diff --git a/cmd/helm/testdata/output/rollback-non-existent-version.txt b/cmd/helm/testdata/output/rollback-non-existent-version.txt new file mode 100644 index 00000000..9c2e10e1 --- /dev/null +++ b/cmd/helm/testdata/output/rollback-non-existent-version.txt @@ -0,0 +1 @@ +Error: release has no 3 version diff --git a/cmd/helm/testdata/output/rollback-timeout.txt b/cmd/helm/testdata/output/rollback-timeout.txt new file mode 100644 index 00000000..ae3c6f1c --- /dev/null +++ b/cmd/helm/testdata/output/rollback-timeout.txt @@ -0,0 +1 @@ +Rollback was a success! Happy Helming! diff --git a/cmd/helm/testdata/output/rollback-wait-for-jobs.txt b/cmd/helm/testdata/output/rollback-wait-for-jobs.txt new file mode 100644 index 00000000..ae3c6f1c --- /dev/null +++ b/cmd/helm/testdata/output/rollback-wait-for-jobs.txt @@ -0,0 +1 @@ +Rollback was a success! Happy Helming! diff --git a/cmd/helm/testdata/output/rollback-wait.txt b/cmd/helm/testdata/output/rollback-wait.txt new file mode 100644 index 00000000..ae3c6f1c --- /dev/null +++ b/cmd/helm/testdata/output/rollback-wait.txt @@ -0,0 +1 @@ +Rollback was a success! Happy Helming! diff --git a/cmd/helm/testdata/output/rollback-wrong-args-comp.txt b/cmd/helm/testdata/output/rollback-wrong-args-comp.txt new file mode 100644 index 00000000..8d9fad57 --- /dev/null +++ b/cmd/helm/testdata/output/rollback-wrong-args-comp.txt @@ -0,0 +1,2 @@ +:4 +Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/cmd/helm/testdata/output/rollback.txt b/cmd/helm/testdata/output/rollback.txt new file mode 100644 index 00000000..ae3c6f1c --- /dev/null +++ b/cmd/helm/testdata/output/rollback.txt @@ -0,0 +1 @@ +Rollback was a success! Happy Helming! diff --git a/cmd/helm/testdata/output/schema-negative-cli.txt b/cmd/helm/testdata/output/schema-negative-cli.txt new file mode 100644 index 00000000..c4a5cc51 --- /dev/null +++ b/cmd/helm/testdata/output/schema-negative-cli.txt @@ -0,0 +1,4 @@ +Error: INSTALLATION FAILED: values don't meet the specifications of the schema(s) in the following chart(s): +empty: +- age: Must be greater than or equal to 0 + diff --git a/cmd/helm/testdata/output/schema-negative.txt b/cmd/helm/testdata/output/schema-negative.txt new file mode 100644 index 00000000..929af551 --- /dev/null +++ b/cmd/helm/testdata/output/schema-negative.txt @@ -0,0 +1,5 @@ +Error: INSTALLATION FAILED: values don't meet the specifications of the schema(s) in the following chart(s): +empty: +- (root): employmentInfo is required +- age: Must be greater than or equal to 0 + diff --git a/cmd/helm/testdata/output/schema.txt b/cmd/helm/testdata/output/schema.txt new file mode 100644 index 00000000..22a94b3f --- /dev/null +++ b/cmd/helm/testdata/output/schema.txt @@ -0,0 +1,6 @@ +NAME: schema +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 1 +TEST SUITE: None diff --git a/cmd/helm/testdata/output/search-constraint-single.txt b/cmd/helm/testdata/output/search-constraint-single.txt new file mode 100644 index 00000000..a1f75099 --- /dev/null +++ b/cmd/helm/testdata/output/search-constraint-single.txt @@ -0,0 +1,2 @@ +NAME CHART VERSION APP VERSION DESCRIPTION +testing/alpine 0.2.0 2.3.4 Deploy a basic Alpine Linux pod diff --git a/cmd/helm/testdata/output/search-constraint.txt b/cmd/helm/testdata/output/search-constraint.txt new file mode 100644 index 00000000..9fb22fe7 --- /dev/null +++ b/cmd/helm/testdata/output/search-constraint.txt @@ -0,0 +1,2 @@ +NAME CHART VERSION APP VERSION DESCRIPTION +testing/alpine 0.1.0 1.2.3 Deploy a basic Alpine Linux pod diff --git a/cmd/helm/testdata/output/search-multiple-devel-release.txt b/cmd/helm/testdata/output/search-multiple-devel-release.txt new file mode 100644 index 00000000..7e29a8f7 --- /dev/null +++ b/cmd/helm/testdata/output/search-multiple-devel-release.txt @@ -0,0 +1,2 @@ +NAME CHART VERSION APP VERSION DESCRIPTION +testing/alpine 0.3.0-rc.1 3.0.0 Deploy a basic Alpine Linux pod diff --git a/cmd/helm/testdata/output/search-multiple-stable-release.txt b/cmd/helm/testdata/output/search-multiple-stable-release.txt new file mode 100644 index 00000000..a1f75099 --- /dev/null +++ b/cmd/helm/testdata/output/search-multiple-stable-release.txt @@ -0,0 +1,2 @@ +NAME CHART VERSION APP VERSION DESCRIPTION +testing/alpine 0.2.0 2.3.4 Deploy a basic Alpine Linux pod diff --git a/cmd/helm/testdata/output/search-multiple-versions-constraints.txt b/cmd/helm/testdata/output/search-multiple-versions-constraints.txt new file mode 100644 index 00000000..a6a38885 --- /dev/null +++ b/cmd/helm/testdata/output/search-multiple-versions-constraints.txt @@ -0,0 +1,3 @@ +NAME CHART VERSION APP VERSION DESCRIPTION +testing/alpine 0.2.0 2.3.4 Deploy a basic Alpine Linux pod +testing/alpine 0.1.0 1.2.3 Deploy a basic Alpine Linux pod diff --git a/cmd/helm/testdata/output/search-multiple-versions.txt b/cmd/helm/testdata/output/search-multiple-versions.txt new file mode 100644 index 00000000..a6a38885 --- /dev/null +++ b/cmd/helm/testdata/output/search-multiple-versions.txt @@ -0,0 +1,3 @@ +NAME CHART VERSION APP VERSION DESCRIPTION +testing/alpine 0.2.0 2.3.4 Deploy a basic Alpine Linux pod +testing/alpine 0.1.0 1.2.3 Deploy a basic Alpine Linux pod diff --git a/cmd/helm/testdata/output/search-not-found-error.txt b/cmd/helm/testdata/output/search-not-found-error.txt new file mode 100644 index 00000000..8b586bea --- /dev/null +++ b/cmd/helm/testdata/output/search-not-found-error.txt @@ -0,0 +1 @@ +Error: no results found diff --git a/cmd/helm/testdata/output/search-not-found.txt b/cmd/helm/testdata/output/search-not-found.txt new file mode 100644 index 00000000..4f2a9fd0 --- /dev/null +++ b/cmd/helm/testdata/output/search-not-found.txt @@ -0,0 +1 @@ +No results found diff --git a/cmd/helm/testdata/output/search-output-json.txt b/cmd/helm/testdata/output/search-output-json.txt new file mode 100644 index 00000000..9b211e1b --- /dev/null +++ b/cmd/helm/testdata/output/search-output-json.txt @@ -0,0 +1 @@ +[{"name":"testing/mariadb","version":"0.3.0","app_version":"","description":"Chart for MariaDB"}] diff --git a/cmd/helm/testdata/output/search-output-yaml.txt b/cmd/helm/testdata/output/search-output-yaml.txt new file mode 100644 index 00000000..122b7f34 --- /dev/null +++ b/cmd/helm/testdata/output/search-output-yaml.txt @@ -0,0 +1,4 @@ +- app_version: 2.3.4 + description: Deploy a basic Alpine Linux pod + name: testing/alpine + version: 0.2.0 diff --git a/cmd/helm/testdata/output/search-regex.txt b/cmd/helm/testdata/output/search-regex.txt new file mode 100644 index 00000000..a1f75099 --- /dev/null +++ b/cmd/helm/testdata/output/search-regex.txt @@ -0,0 +1,2 @@ +NAME CHART VERSION APP VERSION DESCRIPTION +testing/alpine 0.2.0 2.3.4 Deploy a basic Alpine Linux pod diff --git a/cmd/helm/testdata/output/search-versions-constraint.txt b/cmd/helm/testdata/output/search-versions-constraint.txt new file mode 100644 index 00000000..9fb22fe7 --- /dev/null +++ b/cmd/helm/testdata/output/search-versions-constraint.txt @@ -0,0 +1,2 @@ +NAME CHART VERSION APP VERSION DESCRIPTION +testing/alpine 0.1.0 1.2.3 Deploy a basic Alpine Linux pod diff --git a/cmd/helm/testdata/output/status-comp.txt b/cmd/helm/testdata/output/status-comp.txt new file mode 100644 index 00000000..4c408c97 --- /dev/null +++ b/cmd/helm/testdata/output/status-comp.txt @@ -0,0 +1,5 @@ +aramis Aramis-chart-0.0.0 -> uninstalled +athos Athos-chart-1.2.3 -> deployed +porthos Porthos-chart-111.222.333 -> failed +:4 +Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/cmd/helm/testdata/output/status-with-desc.txt b/cmd/helm/testdata/output/status-with-desc.txt new file mode 100644 index 00000000..c681fe3e --- /dev/null +++ b/cmd/helm/testdata/output/status-with-desc.txt @@ -0,0 +1,7 @@ +NAME: flummoxed-chickadee +LAST DEPLOYED: Sat Jan 16 00:00:00 2016 +NAMESPACE: default +STATUS: deployed +REVISION: 0 +DESCRIPTION: Mock description +TEST SUITE: None diff --git a/cmd/helm/testdata/output/status-with-notes.txt b/cmd/helm/testdata/output/status-with-notes.txt new file mode 100644 index 00000000..e992ce91 --- /dev/null +++ b/cmd/helm/testdata/output/status-with-notes.txt @@ -0,0 +1,8 @@ +NAME: flummoxed-chickadee +LAST DEPLOYED: Sat Jan 16 00:00:00 2016 +NAMESPACE: default +STATUS: deployed +REVISION: 0 +TEST SUITE: None +NOTES: +release notes diff --git a/cmd/helm/testdata/output/status-with-resources.json b/cmd/helm/testdata/output/status-with-resources.json new file mode 100644 index 00000000..275e0cfc --- /dev/null +++ b/cmd/helm/testdata/output/status-with-resources.json @@ -0,0 +1 @@ +{"name":"flummoxed-chickadee","info":{"first_deployed":"","last_deployed":"2016-01-16T00:00:00Z","deleted":"","status":"deployed"},"namespace":"default"} diff --git a/cmd/helm/testdata/output/status-with-resources.txt b/cmd/helm/testdata/output/status-with-resources.txt new file mode 100644 index 00000000..a326c3db --- /dev/null +++ b/cmd/helm/testdata/output/status-with-resources.txt @@ -0,0 +1,6 @@ +NAME: flummoxed-chickadee +LAST DEPLOYED: Sat Jan 16 00:00:00 2016 +NAMESPACE: default +STATUS: deployed +REVISION: 0 +TEST SUITE: None diff --git a/cmd/helm/testdata/output/status-with-test-suite.txt b/cmd/helm/testdata/output/status-with-test-suite.txt new file mode 100644 index 00000000..58c67e10 --- /dev/null +++ b/cmd/helm/testdata/output/status-with-test-suite.txt @@ -0,0 +1,13 @@ +NAME: flummoxed-chickadee +LAST DEPLOYED: Sat Jan 16 00:00:00 2016 +NAMESPACE: default +STATUS: deployed +REVISION: 0 +TEST SUITE: passing-test +Last Started: Mon Jan 2 15:04:05 2006 +Last Completed: Mon Jan 2 15:04:07 2006 +Phase: Succeeded +TEST SUITE: failing-test +Last Started: Mon Jan 2 15:10:05 2006 +Last Completed: Mon Jan 2 15:10:07 2006 +Phase: Failed diff --git a/cmd/helm/testdata/output/status-wrong-args-comp.txt b/cmd/helm/testdata/output/status-wrong-args-comp.txt new file mode 100644 index 00000000..8d9fad57 --- /dev/null +++ b/cmd/helm/testdata/output/status-wrong-args-comp.txt @@ -0,0 +1,2 @@ +:4 +Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/cmd/helm/testdata/output/status.json b/cmd/helm/testdata/output/status.json new file mode 100644 index 00000000..4b499c93 --- /dev/null +++ b/cmd/helm/testdata/output/status.json @@ -0,0 +1 @@ +{"name":"flummoxed-chickadee","info":{"first_deployed":"","last_deployed":"2016-01-16T00:00:00Z","deleted":"","status":"deployed","notes":"release notes"},"namespace":"default"} diff --git a/cmd/helm/testdata/output/status.txt b/cmd/helm/testdata/output/status.txt new file mode 100644 index 00000000..a326c3db --- /dev/null +++ b/cmd/helm/testdata/output/status.txt @@ -0,0 +1,6 @@ +NAME: flummoxed-chickadee +LAST DEPLOYED: Sat Jan 16 00:00:00 2016 +NAMESPACE: default +STATUS: deployed +REVISION: 0 +TEST SUITE: None diff --git a/cmd/helm/testdata/output/subchart-schema-cli-negative.txt b/cmd/helm/testdata/output/subchart-schema-cli-negative.txt new file mode 100644 index 00000000..7396b4bf --- /dev/null +++ b/cmd/helm/testdata/output/subchart-schema-cli-negative.txt @@ -0,0 +1,4 @@ +Error: INSTALLATION FAILED: values don't meet the specifications of the schema(s) in the following chart(s): +subchart-with-schema: +- age: Must be greater than or equal to 0 + diff --git a/cmd/helm/testdata/output/subchart-schema-cli.txt b/cmd/helm/testdata/output/subchart-schema-cli.txt new file mode 100644 index 00000000..22a94b3f --- /dev/null +++ b/cmd/helm/testdata/output/subchart-schema-cli.txt @@ -0,0 +1,6 @@ +NAME: schema +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 1 +TEST SUITE: None diff --git a/cmd/helm/testdata/output/subchart-schema-negative.txt b/cmd/helm/testdata/output/subchart-schema-negative.txt new file mode 100644 index 00000000..7b1f654a --- /dev/null +++ b/cmd/helm/testdata/output/subchart-schema-negative.txt @@ -0,0 +1,6 @@ +Error: INSTALLATION FAILED: values don't meet the specifications of the schema(s) in the following chart(s): +chart-without-schema: +- (root): lastname is required +subchart-with-schema: +- (root): age is required + diff --git a/cmd/helm/testdata/output/template-chart-bad-type.txt b/cmd/helm/testdata/output/template-chart-bad-type.txt new file mode 100644 index 00000000..d8a3bf27 --- /dev/null +++ b/cmd/helm/testdata/output/template-chart-bad-type.txt @@ -0,0 +1 @@ +Error: validation: chart.metadata.type must be application or library diff --git a/cmd/helm/testdata/output/template-chart-with-template-lib-archive-dep.txt b/cmd/helm/testdata/output/template-chart-with-template-lib-archive-dep.txt new file mode 100644 index 00000000..c954b8e1 --- /dev/null +++ b/cmd/helm/testdata/output/template-chart-with-template-lib-archive-dep.txt @@ -0,0 +1,61 @@ +--- +# Source: chart-with-template-lib-archive-dep/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + labels: + app: chart-with-template-lib-archive-dep + chart: chart-with-template-lib-archive-dep-0.1.0 + heritage: Helm + release: release-name + name: release-name-chart-with-template-lib-archive-dep +spec: + ports: + - name: http + port: 80 + targetPort: http + selector: + app: chart-with-template-lib-archive-dep + release: release-name + type: ClusterIP +--- +# Source: chart-with-template-lib-archive-dep/templates/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: release-name-chart-with-template-lib-archive-dep + labels: + app: chart-with-template-lib-archive-dep + chart: chart-with-template-lib-archive-dep-0.1.0 + release: release-name + heritage: Helm +spec: + replicas: 1 + selector: + matchLabels: + app: chart-with-template-lib-archive-dep + release: release-name + template: + metadata: + labels: + app: chart-with-template-lib-archive-dep + release: release-name + spec: + containers: + - name: chart-with-template-lib-archive-dep + image: "nginx:stable" + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 80 + protocol: TCP + livenessProbe: + httpGet: + path: / + port: http + readinessProbe: + httpGet: + path: / + port: http + resources: + {} diff --git a/cmd/helm/testdata/output/template-chart-with-template-lib-dep.txt b/cmd/helm/testdata/output/template-chart-with-template-lib-dep.txt new file mode 100644 index 00000000..74a2a2df --- /dev/null +++ b/cmd/helm/testdata/output/template-chart-with-template-lib-dep.txt @@ -0,0 +1,61 @@ +--- +# Source: chart-with-template-lib-dep/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + labels: + app: chart-with-template-lib-dep + chart: chart-with-template-lib-dep-0.1.0 + heritage: Helm + release: release-name + name: release-name-chart-with-template-lib-dep +spec: + ports: + - name: http + port: 80 + targetPort: http + selector: + app: chart-with-template-lib-dep + release: release-name + type: ClusterIP +--- +# Source: chart-with-template-lib-dep/templates/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: release-name-chart-with-template-lib-dep + labels: + app: chart-with-template-lib-dep + chart: chart-with-template-lib-dep-0.1.0 + release: release-name + heritage: Helm +spec: + replicas: 1 + selector: + matchLabels: + app: chart-with-template-lib-dep + release: release-name + template: + metadata: + labels: + app: chart-with-template-lib-dep + release: release-name + spec: + containers: + - name: chart-with-template-lib-dep + image: "nginx:stable" + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 80 + protocol: TCP + livenessProbe: + httpGet: + path: / + port: http + readinessProbe: + httpGet: + path: / + port: http + resources: + {} diff --git a/cmd/helm/testdata/output/template-lib-chart.txt b/cmd/helm/testdata/output/template-lib-chart.txt new file mode 100644 index 00000000..d8a3bf27 --- /dev/null +++ b/cmd/helm/testdata/output/template-lib-chart.txt @@ -0,0 +1 @@ +Error: validation: chart.metadata.type must be application or library diff --git a/cmd/helm/testdata/output/template-name-template.txt b/cmd/helm/testdata/output/template-name-template.txt new file mode 100644 index 00000000..9406048d --- /dev/null +++ b/cmd/helm/testdata/output/template-name-template.txt @@ -0,0 +1,114 @@ +--- +# Source: subchart/templates/subdir/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: subchart-sa +--- +# Source: subchart/templates/subdir/role.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: subchart-role +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["get","list","watch"] +--- +# Source: subchart/templates/subdir/rolebinding.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: subchart-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: subchart-role +subjects: +- kind: ServiceAccount + name: subchart-sa + namespace: default +--- +# Source: subchart/charts/subcharta/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subcharta + labels: + helm.sh/chart: "subcharta-0.1.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: apache + selector: + app.kubernetes.io/name: subcharta +--- +# Source: subchart/charts/subchartb/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subchartb + labels: + helm.sh/chart: "subchartb-0.1.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: nginx + selector: + app.kubernetes.io/name: subchartb +--- +# Source: subchart/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subchart + labels: + helm.sh/chart: "subchart-0.1.0" + app.kubernetes.io/instance: "foobar-ywjj-baz" + kube-version/major: "1" + kube-version/minor: "20" + kube-version/version: "v1.20.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: nginx + selector: + app.kubernetes.io/name: subchart +--- +# Source: subchart/templates/tests/test-config.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: "foobar-ywjj-baz-testconfig" + annotations: + "helm.sh/hook": test +data: + message: Hello World +--- +# Source: subchart/templates/tests/test-nothing.yaml +apiVersion: v1 +kind: Pod +metadata: + name: "foobar-ywjj-baz-test" + annotations: + "helm.sh/hook": test +spec: + containers: + - name: test + image: "alpine:latest" + envFrom: + - configMapRef: + name: "foobar-ywjj-baz-testconfig" + command: + - echo + - "$message" + restartPolicy: Never diff --git a/cmd/helm/testdata/output/template-no-args.txt b/cmd/helm/testdata/output/template-no-args.txt new file mode 100644 index 00000000..f72f2b8c --- /dev/null +++ b/cmd/helm/testdata/output/template-no-args.txt @@ -0,0 +1,3 @@ +Error: "helm template" requires at least 1 argument + +Usage: helm template [NAME] [CHART] [flags] diff --git a/cmd/helm/testdata/output/template-set.txt b/cmd/helm/testdata/output/template-set.txt new file mode 100644 index 00000000..4040991c --- /dev/null +++ b/cmd/helm/testdata/output/template-set.txt @@ -0,0 +1,114 @@ +--- +# Source: subchart/templates/subdir/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: subchart-sa +--- +# Source: subchart/templates/subdir/role.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: subchart-role +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["get","list","watch"] +--- +# Source: subchart/templates/subdir/rolebinding.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: subchart-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: subchart-role +subjects: +- kind: ServiceAccount + name: subchart-sa + namespace: default +--- +# Source: subchart/charts/subcharta/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subcharta + labels: + helm.sh/chart: "subcharta-0.1.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: apache + selector: + app.kubernetes.io/name: subcharta +--- +# Source: subchart/charts/subchartb/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subchartb + labels: + helm.sh/chart: "subchartb-0.1.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: nginx + selector: + app.kubernetes.io/name: subchartb +--- +# Source: subchart/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subchart + labels: + helm.sh/chart: "subchart-0.1.0" + app.kubernetes.io/instance: "release-name" + kube-version/major: "1" + kube-version/minor: "20" + kube-version/version: "v1.20.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: apache + selector: + app.kubernetes.io/name: subchart +--- +# Source: subchart/templates/tests/test-config.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: "release-name-testconfig" + annotations: + "helm.sh/hook": test +data: + message: Hello World +--- +# Source: subchart/templates/tests/test-nothing.yaml +apiVersion: v1 +kind: Pod +metadata: + name: "release-name-test" + annotations: + "helm.sh/hook": test +spec: + containers: + - name: test + image: "alpine:latest" + envFrom: + - configMapRef: + name: "release-name-testconfig" + command: + - echo + - "$message" + restartPolicy: Never diff --git a/cmd/helm/testdata/output/template-show-only-glob.txt b/cmd/helm/testdata/output/template-show-only-glob.txt new file mode 100644 index 00000000..b2d2b1c2 --- /dev/null +++ b/cmd/helm/testdata/output/template-show-only-glob.txt @@ -0,0 +1,24 @@ +--- +# Source: subchart/templates/subdir/role.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: subchart-role +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["get","list","watch"] +--- +# Source: subchart/templates/subdir/rolebinding.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: subchart-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: subchart-role +subjects: +- kind: ServiceAccount + name: subchart-sa + namespace: default diff --git a/cmd/helm/testdata/output/template-show-only-multiple.txt b/cmd/helm/testdata/output/template-show-only-multiple.txt new file mode 100644 index 00000000..1aac3081 --- /dev/null +++ b/cmd/helm/testdata/output/template-show-only-multiple.txt @@ -0,0 +1,38 @@ +--- +# Source: subchart/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subchart + labels: + helm.sh/chart: "subchart-0.1.0" + app.kubernetes.io/instance: "release-name" + kube-version/major: "1" + kube-version/minor: "20" + kube-version/version: "v1.20.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: nginx + selector: + app.kubernetes.io/name: subchart +--- +# Source: subchart/charts/subcharta/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subcharta + labels: + helm.sh/chart: "subcharta-0.1.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: apache + selector: + app.kubernetes.io/name: subcharta diff --git a/cmd/helm/testdata/output/template-show-only-one.txt b/cmd/helm/testdata/output/template-show-only-one.txt new file mode 100644 index 00000000..9cc34f51 --- /dev/null +++ b/cmd/helm/testdata/output/template-show-only-one.txt @@ -0,0 +1,21 @@ +--- +# Source: subchart/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subchart + labels: + helm.sh/chart: "subchart-0.1.0" + app.kubernetes.io/instance: "release-name" + kube-version/major: "1" + kube-version/minor: "20" + kube-version/version: "v1.20.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: nginx + selector: + app.kubernetes.io/name: subchart diff --git a/cmd/helm/testdata/output/template-skip-tests.txt b/cmd/helm/testdata/output/template-skip-tests.txt new file mode 100644 index 00000000..5c907b56 --- /dev/null +++ b/cmd/helm/testdata/output/template-skip-tests.txt @@ -0,0 +1,85 @@ +--- +# Source: subchart/templates/subdir/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: subchart-sa +--- +# Source: subchart/templates/subdir/role.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: subchart-role +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["get","list","watch"] +--- +# Source: subchart/templates/subdir/rolebinding.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: subchart-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: subchart-role +subjects: +- kind: ServiceAccount + name: subchart-sa + namespace: default +--- +# Source: subchart/charts/subcharta/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subcharta + labels: + helm.sh/chart: "subcharta-0.1.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: apache + selector: + app.kubernetes.io/name: subcharta +--- +# Source: subchart/charts/subchartb/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subchartb + labels: + helm.sh/chart: "subchartb-0.1.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: nginx + selector: + app.kubernetes.io/name: subchartb +--- +# Source: subchart/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subchart + labels: + helm.sh/chart: "subchart-0.1.0" + app.kubernetes.io/instance: "release-name" + kube-version/major: "1" + kube-version/minor: "20" + kube-version/version: "v1.20.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: nginx + selector: + app.kubernetes.io/name: subchart diff --git a/cmd/helm/testdata/output/template-subchart-cm-set-file.txt b/cmd/helm/testdata/output/template-subchart-cm-set-file.txt new file mode 100644 index 00000000..56844e29 --- /dev/null +++ b/cmd/helm/testdata/output/template-subchart-cm-set-file.txt @@ -0,0 +1,122 @@ +--- +# Source: subchart/templates/subdir/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: subchart-sa +--- +# Source: subchart/templates/subdir/configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: subchart-cm +data: + value: qux +--- +# Source: subchart/templates/subdir/role.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: subchart-role +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["get","list","watch"] +--- +# Source: subchart/templates/subdir/rolebinding.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: subchart-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: subchart-role +subjects: +- kind: ServiceAccount + name: subchart-sa + namespace: default +--- +# Source: subchart/charts/subcharta/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subcharta + labels: + helm.sh/chart: "subcharta-0.1.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: apache + selector: + app.kubernetes.io/name: subcharta +--- +# Source: subchart/charts/subchartb/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subchartb + labels: + helm.sh/chart: "subchartb-0.1.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: nginx + selector: + app.kubernetes.io/name: subchartb +--- +# Source: subchart/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subchart + labels: + helm.sh/chart: "subchart-0.1.0" + app.kubernetes.io/instance: "release-name" + kube-version/major: "1" + kube-version/minor: "20" + kube-version/version: "v1.20.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: nginx + selector: + app.kubernetes.io/name: subchart +--- +# Source: subchart/templates/tests/test-config.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: "release-name-testconfig" + annotations: + "helm.sh/hook": test +data: + message: Hello World +--- +# Source: subchart/templates/tests/test-nothing.yaml +apiVersion: v1 +kind: Pod +metadata: + name: "release-name-test" + annotations: + "helm.sh/hook": test +spec: + containers: + - name: test + image: "alpine:latest" + envFrom: + - configMapRef: + name: "release-name-testconfig" + command: + - echo + - "$message" + restartPolicy: Never diff --git a/cmd/helm/testdata/output/template-subchart-cm-set.txt b/cmd/helm/testdata/output/template-subchart-cm-set.txt new file mode 100644 index 00000000..e52f7c23 --- /dev/null +++ b/cmd/helm/testdata/output/template-subchart-cm-set.txt @@ -0,0 +1,122 @@ +--- +# Source: subchart/templates/subdir/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: subchart-sa +--- +# Source: subchart/templates/subdir/configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: subchart-cm +data: + value: baz +--- +# Source: subchart/templates/subdir/role.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: subchart-role +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["get","list","watch"] +--- +# Source: subchart/templates/subdir/rolebinding.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: subchart-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: subchart-role +subjects: +- kind: ServiceAccount + name: subchart-sa + namespace: default +--- +# Source: subchart/charts/subcharta/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subcharta + labels: + helm.sh/chart: "subcharta-0.1.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: apache + selector: + app.kubernetes.io/name: subcharta +--- +# Source: subchart/charts/subchartb/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subchartb + labels: + helm.sh/chart: "subchartb-0.1.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: nginx + selector: + app.kubernetes.io/name: subchartb +--- +# Source: subchart/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subchart + labels: + helm.sh/chart: "subchart-0.1.0" + app.kubernetes.io/instance: "release-name" + kube-version/major: "1" + kube-version/minor: "20" + kube-version/version: "v1.20.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: nginx + selector: + app.kubernetes.io/name: subchart +--- +# Source: subchart/templates/tests/test-config.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: "release-name-testconfig" + annotations: + "helm.sh/hook": test +data: + message: Hello World +--- +# Source: subchart/templates/tests/test-nothing.yaml +apiVersion: v1 +kind: Pod +metadata: + name: "release-name-test" + annotations: + "helm.sh/hook": test +spec: + containers: + - name: test + image: "alpine:latest" + envFrom: + - configMapRef: + name: "release-name-testconfig" + command: + - echo + - "$message" + restartPolicy: Never diff --git a/cmd/helm/testdata/output/template-subchart-cm.txt b/cmd/helm/testdata/output/template-subchart-cm.txt new file mode 100644 index 00000000..9cc9e229 --- /dev/null +++ b/cmd/helm/testdata/output/template-subchart-cm.txt @@ -0,0 +1,122 @@ +--- +# Source: subchart/templates/subdir/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: subchart-sa +--- +# Source: subchart/templates/subdir/configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: subchart-cm +data: + value: foo +--- +# Source: subchart/templates/subdir/role.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: subchart-role +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["get","list","watch"] +--- +# Source: subchart/templates/subdir/rolebinding.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: subchart-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: subchart-role +subjects: +- kind: ServiceAccount + name: subchart-sa + namespace: default +--- +# Source: subchart/charts/subcharta/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subcharta + labels: + helm.sh/chart: "subcharta-0.1.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: apache + selector: + app.kubernetes.io/name: subcharta +--- +# Source: subchart/charts/subchartb/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subchartb + labels: + helm.sh/chart: "subchartb-0.1.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: nginx + selector: + app.kubernetes.io/name: subchartb +--- +# Source: subchart/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subchart + labels: + helm.sh/chart: "subchart-0.1.0" + app.kubernetes.io/instance: "release-name" + kube-version/major: "1" + kube-version/minor: "20" + kube-version/version: "v1.20.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: nginx + selector: + app.kubernetes.io/name: subchart +--- +# Source: subchart/templates/tests/test-config.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: "release-name-testconfig" + annotations: + "helm.sh/hook": test +data: + message: Hello World +--- +# Source: subchart/templates/tests/test-nothing.yaml +apiVersion: v1 +kind: Pod +metadata: + name: "release-name-test" + annotations: + "helm.sh/hook": test +spec: + containers: + - name: test + image: "alpine:latest" + envFrom: + - configMapRef: + name: "release-name-testconfig" + command: + - echo + - "$message" + restartPolicy: Never diff --git a/cmd/helm/testdata/output/template-values-files.txt b/cmd/helm/testdata/output/template-values-files.txt new file mode 100644 index 00000000..4040991c --- /dev/null +++ b/cmd/helm/testdata/output/template-values-files.txt @@ -0,0 +1,114 @@ +--- +# Source: subchart/templates/subdir/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: subchart-sa +--- +# Source: subchart/templates/subdir/role.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: subchart-role +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["get","list","watch"] +--- +# Source: subchart/templates/subdir/rolebinding.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: subchart-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: subchart-role +subjects: +- kind: ServiceAccount + name: subchart-sa + namespace: default +--- +# Source: subchart/charts/subcharta/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subcharta + labels: + helm.sh/chart: "subcharta-0.1.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: apache + selector: + app.kubernetes.io/name: subcharta +--- +# Source: subchart/charts/subchartb/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subchartb + labels: + helm.sh/chart: "subchartb-0.1.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: nginx + selector: + app.kubernetes.io/name: subchartb +--- +# Source: subchart/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subchart + labels: + helm.sh/chart: "subchart-0.1.0" + app.kubernetes.io/instance: "release-name" + kube-version/major: "1" + kube-version/minor: "20" + kube-version/version: "v1.20.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: apache + selector: + app.kubernetes.io/name: subchart +--- +# Source: subchart/templates/tests/test-config.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: "release-name-testconfig" + annotations: + "helm.sh/hook": test +data: + message: Hello World +--- +# Source: subchart/templates/tests/test-nothing.yaml +apiVersion: v1 +kind: Pod +metadata: + name: "release-name-test" + annotations: + "helm.sh/hook": test +spec: + containers: + - name: test + image: "alpine:latest" + envFrom: + - configMapRef: + name: "release-name-testconfig" + command: + - echo + - "$message" + restartPolicy: Never diff --git a/cmd/helm/testdata/output/template-with-api-version.txt b/cmd/helm/testdata/output/template-with-api-version.txt new file mode 100644 index 00000000..7e1c3500 --- /dev/null +++ b/cmd/helm/testdata/output/template-with-api-version.txt @@ -0,0 +1,115 @@ +--- +# Source: subchart/templates/subdir/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: subchart-sa +--- +# Source: subchart/templates/subdir/role.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: subchart-role +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["get","list","watch"] +--- +# Source: subchart/templates/subdir/rolebinding.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: subchart-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: subchart-role +subjects: +- kind: ServiceAccount + name: subchart-sa + namespace: default +--- +# Source: subchart/charts/subcharta/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subcharta + labels: + helm.sh/chart: "subcharta-0.1.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: apache + selector: + app.kubernetes.io/name: subcharta +--- +# Source: subchart/charts/subchartb/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subchartb + labels: + helm.sh/chart: "subchartb-0.1.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: nginx + selector: + app.kubernetes.io/name: subchartb +--- +# Source: subchart/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subchart + labels: + helm.sh/chart: "subchart-0.1.0" + app.kubernetes.io/instance: "release-name" + kube-version/major: "1" + kube-version/minor: "20" + kube-version/version: "v1.20.0" + kube-api-version/test: v1 +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: nginx + selector: + app.kubernetes.io/name: subchart +--- +# Source: subchart/templates/tests/test-config.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: "release-name-testconfig" + annotations: + "helm.sh/hook": test +data: + message: Hello World +--- +# Source: subchart/templates/tests/test-nothing.yaml +apiVersion: v1 +kind: Pod +metadata: + name: "release-name-test" + annotations: + "helm.sh/hook": test +spec: + containers: + - name: test + image: "alpine:latest" + envFrom: + - configMapRef: + name: "release-name-testconfig" + command: + - echo + - "$message" + restartPolicy: Never diff --git a/cmd/helm/testdata/output/template-with-crds.txt b/cmd/helm/testdata/output/template-with-crds.txt new file mode 100644 index 00000000..256fc7c3 --- /dev/null +++ b/cmd/helm/testdata/output/template-with-crds.txt @@ -0,0 +1,131 @@ +--- +# Source: subchart/crds/crdA.yaml +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: testcrds.testcrdgroups.example.com +spec: + group: testcrdgroups.example.com + version: v1alpha1 + names: + kind: TestCRD + listKind: TestCRDList + plural: testcrds + shortNames: + - tc + singular: authconfig + +--- +# Source: subchart/templates/subdir/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: subchart-sa +--- +# Source: subchart/templates/subdir/role.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: subchart-role +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["get","list","watch"] +--- +# Source: subchart/templates/subdir/rolebinding.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: subchart-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: subchart-role +subjects: +- kind: ServiceAccount + name: subchart-sa + namespace: default +--- +# Source: subchart/charts/subcharta/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subcharta + labels: + helm.sh/chart: "subcharta-0.1.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: apache + selector: + app.kubernetes.io/name: subcharta +--- +# Source: subchart/charts/subchartb/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subchartb + labels: + helm.sh/chart: "subchartb-0.1.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: nginx + selector: + app.kubernetes.io/name: subchartb +--- +# Source: subchart/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subchart + labels: + helm.sh/chart: "subchart-0.1.0" + app.kubernetes.io/instance: "release-name" + kube-version/major: "1" + kube-version/minor: "20" + kube-version/version: "v1.20.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: nginx + selector: + app.kubernetes.io/name: subchart +--- +# Source: subchart/templates/tests/test-config.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: "release-name-testconfig" + annotations: + "helm.sh/hook": test +data: + message: Hello World +--- +# Source: subchart/templates/tests/test-nothing.yaml +apiVersion: v1 +kind: Pod +metadata: + name: "release-name-test" + annotations: + "helm.sh/hook": test +spec: + containers: + - name: test + image: "alpine:latest" + envFrom: + - configMapRef: + name: "release-name-testconfig" + command: + - echo + - "$message" + restartPolicy: Never diff --git a/cmd/helm/testdata/output/template-with-invalid-yaml-debug.txt b/cmd/helm/testdata/output/template-with-invalid-yaml-debug.txt new file mode 100644 index 00000000..909c543d --- /dev/null +++ b/cmd/helm/testdata/output/template-with-invalid-yaml-debug.txt @@ -0,0 +1,13 @@ +--- +# Source: chart-with-template-with-invalid-yaml/templates/alpine-pod.yaml +apiVersion: v1 +kind: Pod +metadata: + name: "release-name-my-alpine" +spec: + containers: + - name: waiter + image: "alpine:3.9" + command: ["/bin/sleep","9000"] +invalid +Error: YAML parse error on chart-with-template-with-invalid-yaml/templates/alpine-pod.yaml: error converting YAML to JSON: yaml: line 11: could not find expected ':' diff --git a/cmd/helm/testdata/output/template-with-invalid-yaml.txt b/cmd/helm/testdata/output/template-with-invalid-yaml.txt new file mode 100644 index 00000000..687227b9 --- /dev/null +++ b/cmd/helm/testdata/output/template-with-invalid-yaml.txt @@ -0,0 +1,3 @@ +Error: YAML parse error on chart-with-template-with-invalid-yaml/templates/alpine-pod.yaml: error converting YAML to JSON: yaml: line 11: could not find expected ':' + +Use --debug flag to render out invalid YAML diff --git a/cmd/helm/testdata/output/template-with-kube-version.txt b/cmd/helm/testdata/output/template-with-kube-version.txt new file mode 100644 index 00000000..9d326f32 --- /dev/null +++ b/cmd/helm/testdata/output/template-with-kube-version.txt @@ -0,0 +1,114 @@ +--- +# Source: subchart/templates/subdir/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: subchart-sa +--- +# Source: subchart/templates/subdir/role.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: subchart-role +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["get","list","watch"] +--- +# Source: subchart/templates/subdir/rolebinding.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: subchart-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: subchart-role +subjects: +- kind: ServiceAccount + name: subchart-sa + namespace: default +--- +# Source: subchart/charts/subcharta/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subcharta + labels: + helm.sh/chart: "subcharta-0.1.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: apache + selector: + app.kubernetes.io/name: subcharta +--- +# Source: subchart/charts/subchartb/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subchartb + labels: + helm.sh/chart: "subchartb-0.1.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: nginx + selector: + app.kubernetes.io/name: subchartb +--- +# Source: subchart/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subchart + labels: + helm.sh/chart: "subchart-0.1.0" + app.kubernetes.io/instance: "release-name" + kube-version/major: "1" + kube-version/minor: "16" + kube-version/version: "v1.16.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: nginx + selector: + app.kubernetes.io/name: subchart +--- +# Source: subchart/templates/tests/test-config.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: "release-name-testconfig" + annotations: + "helm.sh/hook": test +data: + message: Hello World +--- +# Source: subchart/templates/tests/test-nothing.yaml +apiVersion: v1 +kind: Pod +metadata: + name: "release-name-test" + annotations: + "helm.sh/hook": test +spec: + containers: + - name: test + image: "alpine:latest" + envFrom: + - configMapRef: + name: "release-name-testconfig" + command: + - echo + - "$message" + restartPolicy: Never diff --git a/cmd/helm/testdata/output/template.txt b/cmd/helm/testdata/output/template.txt new file mode 100644 index 00000000..58c480b4 --- /dev/null +++ b/cmd/helm/testdata/output/template.txt @@ -0,0 +1,114 @@ +--- +# Source: subchart/templates/subdir/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: subchart-sa +--- +# Source: subchart/templates/subdir/role.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: subchart-role +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["get","list","watch"] +--- +# Source: subchart/templates/subdir/rolebinding.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: subchart-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: subchart-role +subjects: +- kind: ServiceAccount + name: subchart-sa + namespace: default +--- +# Source: subchart/charts/subcharta/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subcharta + labels: + helm.sh/chart: "subcharta-0.1.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: apache + selector: + app.kubernetes.io/name: subcharta +--- +# Source: subchart/charts/subchartb/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subchartb + labels: + helm.sh/chart: "subchartb-0.1.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: nginx + selector: + app.kubernetes.io/name: subchartb +--- +# Source: subchart/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: subchart + labels: + helm.sh/chart: "subchart-0.1.0" + app.kubernetes.io/instance: "release-name" + kube-version/major: "1" + kube-version/minor: "20" + kube-version/version: "v1.20.0" +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 80 + protocol: TCP + name: nginx + selector: + app.kubernetes.io/name: subchart +--- +# Source: subchart/templates/tests/test-config.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: "release-name-testconfig" + annotations: + "helm.sh/hook": test +data: + message: Hello World +--- +# Source: subchart/templates/tests/test-nothing.yaml +apiVersion: v1 +kind: Pod +metadata: + name: "release-name-test" + annotations: + "helm.sh/hook": test +spec: + containers: + - name: test + image: "alpine:latest" + envFrom: + - configMapRef: + name: "release-name-testconfig" + command: + - echo + - "$message" + restartPolicy: Never diff --git a/cmd/helm/testdata/output/uninstall-keep-history.txt b/cmd/helm/testdata/output/uninstall-keep-history.txt new file mode 100644 index 00000000..f5454b88 --- /dev/null +++ b/cmd/helm/testdata/output/uninstall-keep-history.txt @@ -0,0 +1 @@ +release "aeneas" uninstalled diff --git a/cmd/helm/testdata/output/uninstall-multiple.txt b/cmd/helm/testdata/output/uninstall-multiple.txt new file mode 100644 index 00000000..ee1c67d2 --- /dev/null +++ b/cmd/helm/testdata/output/uninstall-multiple.txt @@ -0,0 +1,2 @@ +release "aeneas" uninstalled +release "aeneas2" uninstalled diff --git a/cmd/helm/testdata/output/uninstall-no-args.txt b/cmd/helm/testdata/output/uninstall-no-args.txt new file mode 100644 index 00000000..fc01a75b --- /dev/null +++ b/cmd/helm/testdata/output/uninstall-no-args.txt @@ -0,0 +1,3 @@ +Error: "helm uninstall" requires at least 1 argument + +Usage: helm uninstall RELEASE_NAME [...] [flags] diff --git a/cmd/helm/testdata/output/uninstall-no-hooks.txt b/cmd/helm/testdata/output/uninstall-no-hooks.txt new file mode 100644 index 00000000..f5454b88 --- /dev/null +++ b/cmd/helm/testdata/output/uninstall-no-hooks.txt @@ -0,0 +1 @@ +release "aeneas" uninstalled diff --git a/cmd/helm/testdata/output/uninstall-timeout.txt b/cmd/helm/testdata/output/uninstall-timeout.txt new file mode 100644 index 00000000..f5454b88 --- /dev/null +++ b/cmd/helm/testdata/output/uninstall-timeout.txt @@ -0,0 +1 @@ +release "aeneas" uninstalled diff --git a/cmd/helm/testdata/output/uninstall-wait.txt b/cmd/helm/testdata/output/uninstall-wait.txt new file mode 100644 index 00000000..f5454b88 --- /dev/null +++ b/cmd/helm/testdata/output/uninstall-wait.txt @@ -0,0 +1 @@ +release "aeneas" uninstalled diff --git a/cmd/helm/testdata/output/uninstall.txt b/cmd/helm/testdata/output/uninstall.txt new file mode 100644 index 00000000..f5454b88 --- /dev/null +++ b/cmd/helm/testdata/output/uninstall.txt @@ -0,0 +1 @@ +release "aeneas" uninstalled diff --git a/cmd/helm/testdata/output/upgrade-with-bad-dependencies.txt b/cmd/helm/testdata/output/upgrade-with-bad-dependencies.txt new file mode 100644 index 00000000..6dddc734 --- /dev/null +++ b/cmd/helm/testdata/output/upgrade-with-bad-dependencies.txt @@ -0,0 +1 @@ +Error: cannot load Chart.yaml: error converting YAML to JSON: yaml: line 6: did not find expected '-' indicator diff --git a/cmd/helm/testdata/output/upgrade-with-bad-or-missing-existing-release.txt b/cmd/helm/testdata/output/upgrade-with-bad-or-missing-existing-release.txt new file mode 100644 index 00000000..8f24574a --- /dev/null +++ b/cmd/helm/testdata/output/upgrade-with-bad-or-missing-existing-release.txt @@ -0,0 +1 @@ +Error: UPGRADE FAILED: "funny-bunny" has no deployed releases diff --git a/cmd/helm/testdata/output/upgrade-with-dependency-update.txt b/cmd/helm/testdata/output/upgrade-with-dependency-update.txt new file mode 100644 index 00000000..0e7e5842 --- /dev/null +++ b/cmd/helm/testdata/output/upgrade-with-dependency-update.txt @@ -0,0 +1,9 @@ +Release "funny-bunny" has been upgraded. Happy Helming! +NAME: funny-bunny +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 3 +TEST SUITE: None +NOTES: +PARENT NOTES diff --git a/cmd/helm/testdata/output/upgrade-with-install-timeout.txt b/cmd/helm/testdata/output/upgrade-with-install-timeout.txt new file mode 100644 index 00000000..5d8d3a4e --- /dev/null +++ b/cmd/helm/testdata/output/upgrade-with-install-timeout.txt @@ -0,0 +1,7 @@ +Release "crazy-bunny" has been upgraded. Happy Helming! +NAME: crazy-bunny +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 2 +TEST SUITE: None diff --git a/cmd/helm/testdata/output/upgrade-with-install.txt b/cmd/helm/testdata/output/upgrade-with-install.txt new file mode 100644 index 00000000..af61212b --- /dev/null +++ b/cmd/helm/testdata/output/upgrade-with-install.txt @@ -0,0 +1,7 @@ +Release "zany-bunny" has been upgraded. Happy Helming! +NAME: zany-bunny +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 2 +TEST SUITE: None diff --git a/cmd/helm/testdata/output/upgrade-with-missing-dependencies.txt b/cmd/helm/testdata/output/upgrade-with-missing-dependencies.txt new file mode 100644 index 00000000..adf2ae89 --- /dev/null +++ b/cmd/helm/testdata/output/upgrade-with-missing-dependencies.txt @@ -0,0 +1 @@ +Error: An error occurred while checking for chart dependencies. You may need to run `helm dependency build` to fetch missing dependencies: found in Chart.yaml, but missing in charts/ directory: reqsubchart2 diff --git a/cmd/helm/testdata/output/upgrade-with-pending-install.txt b/cmd/helm/testdata/output/upgrade-with-pending-install.txt new file mode 100644 index 00000000..57a8e787 --- /dev/null +++ b/cmd/helm/testdata/output/upgrade-with-pending-install.txt @@ -0,0 +1 @@ +Error: UPGRADE FAILED: another operation (install/upgrade/rollback) is in progress diff --git a/cmd/helm/testdata/output/upgrade-with-reset-values.txt b/cmd/helm/testdata/output/upgrade-with-reset-values.txt new file mode 100644 index 00000000..01f1c0ac --- /dev/null +++ b/cmd/helm/testdata/output/upgrade-with-reset-values.txt @@ -0,0 +1,7 @@ +Release "funny-bunny" has been upgraded. Happy Helming! +NAME: funny-bunny +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 5 +TEST SUITE: None diff --git a/cmd/helm/testdata/output/upgrade-with-reset-values2.txt b/cmd/helm/testdata/output/upgrade-with-reset-values2.txt new file mode 100644 index 00000000..fdd1d2db --- /dev/null +++ b/cmd/helm/testdata/output/upgrade-with-reset-values2.txt @@ -0,0 +1,7 @@ +Release "funny-bunny" has been upgraded. Happy Helming! +NAME: funny-bunny +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 6 +TEST SUITE: None diff --git a/cmd/helm/testdata/output/upgrade-with-timeout.txt b/cmd/helm/testdata/output/upgrade-with-timeout.txt new file mode 100644 index 00000000..be3a4236 --- /dev/null +++ b/cmd/helm/testdata/output/upgrade-with-timeout.txt @@ -0,0 +1,7 @@ +Release "funny-bunny" has been upgraded. Happy Helming! +NAME: funny-bunny +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 4 +TEST SUITE: None diff --git a/cmd/helm/testdata/output/upgrade-with-wait-for-jobs.txt b/cmd/helm/testdata/output/upgrade-with-wait-for-jobs.txt new file mode 100644 index 00000000..500d07a1 --- /dev/null +++ b/cmd/helm/testdata/output/upgrade-with-wait-for-jobs.txt @@ -0,0 +1,7 @@ +Release "crazy-bunny" has been upgraded. Happy Helming! +NAME: crazy-bunny +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 3 +TEST SUITE: None diff --git a/cmd/helm/testdata/output/upgrade-with-wait.txt b/cmd/helm/testdata/output/upgrade-with-wait.txt new file mode 100644 index 00000000..500d07a1 --- /dev/null +++ b/cmd/helm/testdata/output/upgrade-with-wait.txt @@ -0,0 +1,7 @@ +Release "crazy-bunny" has been upgraded. Happy Helming! +NAME: crazy-bunny +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 3 +TEST SUITE: None diff --git a/cmd/helm/testdata/output/upgrade.txt b/cmd/helm/testdata/output/upgrade.txt new file mode 100644 index 00000000..bea42db5 --- /dev/null +++ b/cmd/helm/testdata/output/upgrade.txt @@ -0,0 +1,7 @@ +Release "funny-bunny" has been upgraded. Happy Helming! +NAME: funny-bunny +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 3 +TEST SUITE: None diff --git a/cmd/helm/testdata/output/values.json b/cmd/helm/testdata/output/values.json new file mode 100644 index 00000000..ea830862 --- /dev/null +++ b/cmd/helm/testdata/output/values.json @@ -0,0 +1 @@ +{"name":"value"} diff --git a/cmd/helm/testdata/output/values.yaml b/cmd/helm/testdata/output/values.yaml new file mode 100644 index 00000000..54ab03c9 --- /dev/null +++ b/cmd/helm/testdata/output/values.yaml @@ -0,0 +1 @@ +name: value diff --git a/cmd/helm/testdata/output/version-client-shorthand.txt b/cmd/helm/testdata/output/version-client-shorthand.txt new file mode 100644 index 00000000..e204f7a4 --- /dev/null +++ b/cmd/helm/testdata/output/version-client-shorthand.txt @@ -0,0 +1 @@ +version.BuildInfo{Version:"v3.14", GitCommit:"", GitTreeState:"", GoVersion:""} diff --git a/cmd/helm/testdata/output/version-client.txt b/cmd/helm/testdata/output/version-client.txt new file mode 100644 index 00000000..e204f7a4 --- /dev/null +++ b/cmd/helm/testdata/output/version-client.txt @@ -0,0 +1 @@ +version.BuildInfo{Version:"v3.14", GitCommit:"", GitTreeState:"", GoVersion:""} diff --git a/cmd/helm/testdata/output/version-comp.txt b/cmd/helm/testdata/output/version-comp.txt new file mode 100644 index 00000000..5b0556cf --- /dev/null +++ b/cmd/helm/testdata/output/version-comp.txt @@ -0,0 +1,5 @@ +0.3.0-rc.1 App: 3.0.0, Created: November 12, 2020 +0.2.0 App: 2.3.4, Created: July 9, 2018 +0.1.0 App: 1.2.3, Created: June 27, 2018 (deprecated) +:4 +Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/cmd/helm/testdata/output/version-invalid-comp.txt b/cmd/helm/testdata/output/version-invalid-comp.txt new file mode 100644 index 00000000..8d9fad57 --- /dev/null +++ b/cmd/helm/testdata/output/version-invalid-comp.txt @@ -0,0 +1,2 @@ +:4 +Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/cmd/helm/testdata/output/version-short.txt b/cmd/helm/testdata/output/version-short.txt new file mode 100644 index 00000000..3ef02b86 --- /dev/null +++ b/cmd/helm/testdata/output/version-short.txt @@ -0,0 +1 @@ +v3.14 diff --git a/cmd/helm/testdata/output/version-template.txt b/cmd/helm/testdata/output/version-template.txt new file mode 100644 index 00000000..d33c5a92 --- /dev/null +++ b/cmd/helm/testdata/output/version-template.txt @@ -0,0 +1 @@ +Version: v3.14 \ No newline at end of file diff --git a/cmd/helm/testdata/output/version.txt b/cmd/helm/testdata/output/version.txt new file mode 100644 index 00000000..e204f7a4 --- /dev/null +++ b/cmd/helm/testdata/output/version.txt @@ -0,0 +1 @@ +version.BuildInfo{Version:"v3.14", GitCommit:"", GitTreeState:"", GoVersion:""} diff --git a/cmd/helm/testdata/password b/cmd/helm/testdata/password new file mode 100644 index 00000000..f3097ab1 --- /dev/null +++ b/cmd/helm/testdata/password @@ -0,0 +1 @@ +password diff --git a/cmd/helm/testdata/plugins.yaml b/cmd/helm/testdata/plugins.yaml new file mode 100644 index 00000000..69086973 --- /dev/null +++ b/cmd/helm/testdata/plugins.yaml @@ -0,0 +1,3 @@ +plugins: +- name: testplugin + url: testdata/testplugin diff --git a/cmd/helm/testdata/repositories.yaml b/cmd/helm/testdata/repositories.yaml new file mode 100644 index 00000000..6be26b77 --- /dev/null +++ b/cmd/helm/testdata/repositories.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +repositories: + - name: charts + url: "https://charts.helm.sh/stable" + - name: firstexample + url: "http://firstexample.com" + - name: secondexample + url: "http://secondexample.com" + diff --git a/cmd/helm/testdata/testcharts/alpine/Chart.yaml b/cmd/helm/testdata/testcharts/alpine/Chart.yaml new file mode 100644 index 00000000..1d6bad82 --- /dev/null +++ b/cmd/helm/testdata/testcharts/alpine/Chart.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +appVersion: "3.9" +description: Deploy a basic Alpine Linux pod +home: https://helm.sh/helm +name: alpine +sources: +- https://github.com/helm/helm +version: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/alpine/README.md b/cmd/helm/testdata/testcharts/alpine/README.md new file mode 100644 index 00000000..05d39dbb --- /dev/null +++ b/cmd/helm/testdata/testcharts/alpine/README.md @@ -0,0 +1,13 @@ +# Alpine: A simple Helm chart + +Run a single pod of Alpine Linux. + +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.yaml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/cmd/helm/testdata/testcharts/alpine/extra_values.yaml b/cmd/helm/testdata/testcharts/alpine/extra_values.yaml new file mode 100644 index 00000000..468bbacb --- /dev/null +++ b/cmd/helm/testdata/testcharts/alpine/extra_values.yaml @@ -0,0 +1,2 @@ +test: + Name: extra-values diff --git a/cmd/helm/testdata/testcharts/alpine/more_values.yaml b/cmd/helm/testdata/testcharts/alpine/more_values.yaml new file mode 100644 index 00000000..3d21e1fe --- /dev/null +++ b/cmd/helm/testdata/testcharts/alpine/more_values.yaml @@ -0,0 +1,2 @@ +test: + Name: more-values diff --git a/cmd/helm/testdata/testcharts/alpine/templates/alpine-pod.yaml b/cmd/helm/testdata/testcharts/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..a1a44e53 --- /dev/null +++ b/cmd/helm/testdata/testcharts/alpine/templates/alpine-pod.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{.Release.Name}}-{{.Values.Name}}" + labels: + # The "app.kubernetes.io/managed-by" label is used to track which tool + # deployed a given chart. It is useful for admins who want to see what + # releases a particular tool is responsible for. + app.kubernetes.io/managed-by: {{.Release.Service | quote }} + # The "app.kubernetes.io/instance" convention makes it easy to tie a release + # to all of the Kubernetes resources that were created as part of that + # release. + app.kubernetes.io/instance: {{.Release.Name | quote }} + app.kubernetes.io/version: {{ .Chart.AppVersion }} + # This makes it easy to audit chart usage. + helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}" + values: {{.Values.Name}} +spec: + # This shows how to use a simple value. This will look for a passed-in value + # called restartPolicy. If it is not found, it will use the default value. + # {{default "Never" .restartPolicy}} is a slightly optimized version of the + # more conventional syntax: {{.restartPolicy | default "Never"}} + restartPolicy: {{default "Never" .Values.restartPolicy}} + containers: + - name: waiter + image: "alpine:{{ .Chart.AppVersion }}" + command: ["/bin/sleep","9000"] diff --git a/cmd/helm/testdata/testcharts/alpine/values.yaml b/cmd/helm/testdata/testcharts/alpine/values.yaml new file mode 100644 index 00000000..807e12ae --- /dev/null +++ b/cmd/helm/testdata/testcharts/alpine/values.yaml @@ -0,0 +1 @@ +Name: my-alpine diff --git a/cmd/helm/testdata/testcharts/chart-bad-requirements/.helmignore b/cmd/helm/testdata/testcharts/chart-bad-requirements/.helmignore new file mode 100644 index 00000000..f0c13194 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-bad-requirements/.helmignore @@ -0,0 +1,21 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj diff --git a/cmd/helm/testdata/testcharts/chart-bad-requirements/Chart.yaml b/cmd/helm/testdata/testcharts/chart-bad-requirements/Chart.yaml new file mode 100644 index 00000000..1f445ee1 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-bad-requirements/Chart.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +description: A Helm chart for Kubernetes +name: chart-missing-deps +version: 0.1.0 +dependencies: + - name: reqsubchart + version: 0.1.0 + repository: "https://example.com/charts" diff --git a/cmd/helm/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/.helmignore b/cmd/helm/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/.helmignore new file mode 100644 index 00000000..f0c13194 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/.helmignore @@ -0,0 +1,21 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj diff --git a/cmd/helm/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/Chart.yaml b/cmd/helm/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/Chart.yaml new file mode 100644 index 00000000..35613553 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: A Helm chart for Kubernetes +name: reqsubchart +version: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/values.yaml b/cmd/helm/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/values.yaml new file mode 100644 index 00000000..0f0b63f2 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/values.yaml @@ -0,0 +1,4 @@ +# Default values for reqsubchart. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name: value diff --git a/cmd/helm/testdata/testcharts/chart-bad-requirements/values.yaml b/cmd/helm/testdata/testcharts/chart-bad-requirements/values.yaml new file mode 100644 index 00000000..d57f76b0 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-bad-requirements/values.yaml @@ -0,0 +1,4 @@ +# Default values for reqtest. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name: value diff --git a/cmd/helm/testdata/testcharts/chart-bad-type/Chart.yaml b/cmd/helm/testdata/testcharts/chart-bad-type/Chart.yaml new file mode 100644 index 00000000..e77b5afa --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-bad-type/Chart.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +description: Deploy a basic Alpine Linux pod +home: https://helm.sh/helm +name: chart-bad-type +sources: + - https://github.com/helm/helm +version: 0.1.0 +type: foobar diff --git a/cmd/helm/testdata/testcharts/chart-bad-type/README.md b/cmd/helm/testdata/testcharts/chart-bad-type/README.md new file mode 100644 index 00000000..fcf7ee01 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-bad-type/README.md @@ -0,0 +1,13 @@ +#Alpine: A simple Helm chart + +Run a single pod of Alpine Linux. + +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.yaml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/cmd/helm/testdata/testcharts/chart-bad-type/extra_values.yaml b/cmd/helm/testdata/testcharts/chart-bad-type/extra_values.yaml new file mode 100644 index 00000000..468bbacb --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-bad-type/extra_values.yaml @@ -0,0 +1,2 @@ +test: + Name: extra-values diff --git a/cmd/helm/testdata/testcharts/chart-bad-type/more_values.yaml b/cmd/helm/testdata/testcharts/chart-bad-type/more_values.yaml new file mode 100644 index 00000000..3d21e1fe --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-bad-type/more_values.yaml @@ -0,0 +1,2 @@ +test: + Name: more-values diff --git a/cmd/helm/testdata/testcharts/chart-bad-type/templates/alpine-pod.yaml b/cmd/helm/testdata/testcharts/chart-bad-type/templates/alpine-pod.yaml new file mode 100644 index 00000000..a40ae32d --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-bad-type/templates/alpine-pod.yaml @@ -0,0 +1,25 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{.Release.Name}}-{{.Values.Name}}" + labels: + # The "app.kubernetes.io/managed-by" label is used to track which tool + # deployed a given chart. It is useful for admins who want to see what + # releases a particular tool is responsible for. + app.kubernetes.io/managed-by: {{.Release.Service | quote }} + # The "release" convention makes it easy to tie a release to all of the + # Kubernetes resources that were created as part of that release. + app.kubernetes.io/instance: {{.Release.Name | quote }} + # This makes it easy to audit chart usage. + helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}" + values: {{.Values.test.Name}} +spec: + # This shows how to use a simple value. This will look for a passed-in value + # called restartPolicy. If it is not found, it will use the default value. + # {{default "Never" .restartPolicy}} is a slightly optimized version of the + # more conventional syntax: {{.restartPolicy | default "Never"}} + restartPolicy: {{default "Never" .Values.restartPolicy}} + containers: + - name: waiter + image: "alpine:3.9" + command: ["/bin/sleep","9000"] diff --git a/cmd/helm/testdata/testcharts/chart-bad-type/values.yaml b/cmd/helm/testdata/testcharts/chart-bad-type/values.yaml new file mode 100644 index 00000000..807e12ae --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-bad-type/values.yaml @@ -0,0 +1 @@ +Name: my-alpine diff --git a/cmd/helm/testdata/testcharts/chart-missing-deps/.helmignore b/cmd/helm/testdata/testcharts/chart-missing-deps/.helmignore new file mode 100644 index 00000000..f0c13194 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-missing-deps/.helmignore @@ -0,0 +1,21 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj diff --git a/cmd/helm/testdata/testcharts/chart-missing-deps/Chart.yaml b/cmd/helm/testdata/testcharts/chart-missing-deps/Chart.yaml new file mode 100644 index 00000000..9605636d --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-missing-deps/Chart.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +description: A Helm chart for Kubernetes +name: chart-missing-deps +version: 0.1.0 +dependencies: + - name: reqsubchart + version: 0.1.0 + repository: "https://example.com/charts" + - name: reqsubchart2 + version: 0.2.0 + repository: "https://example.com/charts" diff --git a/cmd/helm/testdata/testcharts/chart-missing-deps/charts/reqsubchart/.helmignore b/cmd/helm/testdata/testcharts/chart-missing-deps/charts/reqsubchart/.helmignore new file mode 100644 index 00000000..f0c13194 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-missing-deps/charts/reqsubchart/.helmignore @@ -0,0 +1,21 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj diff --git a/cmd/helm/testdata/testcharts/chart-missing-deps/charts/reqsubchart/Chart.yaml b/cmd/helm/testdata/testcharts/chart-missing-deps/charts/reqsubchart/Chart.yaml new file mode 100644 index 00000000..35613553 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-missing-deps/charts/reqsubchart/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: A Helm chart for Kubernetes +name: reqsubchart +version: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/chart-missing-deps/charts/reqsubchart/values.yaml b/cmd/helm/testdata/testcharts/chart-missing-deps/charts/reqsubchart/values.yaml new file mode 100644 index 00000000..0f0b63f2 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-missing-deps/charts/reqsubchart/values.yaml @@ -0,0 +1,4 @@ +# Default values for reqsubchart. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name: value diff --git a/cmd/helm/testdata/testcharts/chart-missing-deps/values.yaml b/cmd/helm/testdata/testcharts/chart-missing-deps/values.yaml new file mode 100644 index 00000000..d57f76b0 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-missing-deps/values.yaml @@ -0,0 +1,4 @@ +# Default values for reqtest. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name: value diff --git a/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/Chart.yaml b/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/Chart.yaml new file mode 100644 index 00000000..a575aa9f --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: Chart with bad subcharts +name: chart-with-bad-subcharts +version: 0.0.1 diff --git a/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart/Chart.yaml b/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart/Chart.yaml new file mode 100644 index 00000000..a6754b24 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart/Chart.yaml @@ -0,0 +1 @@ +description: Bad subchart diff --git a/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart/values.yaml b/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart/values.yaml new file mode 100644 index 00000000..e69de29b diff --git a/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/Chart.yaml b/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/Chart.yaml new file mode 100644 index 00000000..895433e3 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: Good subchart +name: good-subchart +version: 0.0.1 \ No newline at end of file diff --git a/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/values.yaml b/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/values.yaml new file mode 100644 index 00000000..e69de29b diff --git a/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/requirements.yaml b/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/requirements.yaml new file mode 100644 index 00000000..de2fbb4d --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/requirements.yaml @@ -0,0 +1,5 @@ +dependencies: + - name: good-subchart + version: 0.0.1 + - name: bad-subchart + version: 0.0.1 \ No newline at end of file diff --git a/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/values.yaml b/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/values.yaml new file mode 100644 index 00000000..e69de29b diff --git a/cmd/helm/testdata/testcharts/chart-with-deprecated-api/Chart.yaml b/cmd/helm/testdata/testcharts/chart-with-deprecated-api/Chart.yaml new file mode 100644 index 00000000..3a6e9995 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-deprecated-api/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +appVersion: "1.0.0" +description: A Helm chart for Kubernetes +name: chart-with-deprecated-api +type: application +version: 1.0.0 diff --git a/cmd/helm/testdata/testcharts/chart-with-deprecated-api/templates/horizontalpodautoscaler.yaml b/cmd/helm/testdata/testcharts/chart-with-deprecated-api/templates/horizontalpodautoscaler.yaml new file mode 100644 index 00000000..b77a4bee --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-deprecated-api/templates/horizontalpodautoscaler.yaml @@ -0,0 +1,9 @@ +apiVersion: autoscaling/v2beta1 +kind: HorizontalPodAutoscaler +metadata: + name: deprecated +spec: + scaleTargetRef: + kind: Pod + name: pod + maxReplicas: 3 \ No newline at end of file diff --git a/cmd/helm/testdata/testcharts/chart-with-deprecated-api/values.yaml b/cmd/helm/testdata/testcharts/chart-with-deprecated-api/values.yaml new file mode 100644 index 00000000..e69de29b diff --git a/cmd/helm/testdata/testcharts/chart-with-lib-dep/.helmignore b/cmd/helm/testdata/testcharts/chart-with-lib-dep/.helmignore new file mode 100644 index 00000000..f0c13194 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-lib-dep/.helmignore @@ -0,0 +1,21 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj diff --git a/cmd/helm/testdata/testcharts/chart-with-lib-dep/Chart.yaml b/cmd/helm/testdata/testcharts/chart-with-lib-dep/Chart.yaml new file mode 100644 index 00000000..773cc9f3 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-lib-dep/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +appVersion: "1.0" +description: A Helm chart for Kubernetes +name: chart-with-lib-dep +type: application +version: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/chart-with-lib-dep/charts/common-0.0.5.tgz b/cmd/helm/testdata/testcharts/chart-with-lib-dep/charts/common-0.0.5.tgz new file mode 100644 index 0000000000000000000000000000000000000000..ca0a64ae31477b801f86d8e7ddd4d0adc254deaa GIT binary patch literal 8347 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 literal 0 HcmV?d00001 diff --git a/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/NOTES.txt b/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/NOTES.txt new file mode 100644 index 00000000..a758b797 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/NOTES.txt @@ -0,0 +1,19 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range .Values.ingress.hosts }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ . }}{{ $.Values.ingress.path }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get -o jsonpath="{.spec.ports[0].nodePort}" services {{ template "chart-with-lib-dep.fullname" . }}) + export NODE_IP=$(kubectl get nodes -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status of by running 'kubectl get svc -w {{ template "chart-with-lib-dep.fullname" . }}' + export SERVICE_IP=$(kubectl get svc {{ template "chart-with-lib-dep.fullname" . }} -o jsonpath='{.status.loadBalancer.ingress[0].ip}') + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods -l "app={{ template "chart-with-lib-dep.name" . }},release={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl port-forward $POD_NAME 8080:80 +{{- end }} diff --git a/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/_helpers.tpl b/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/_helpers.tpl new file mode 100644 index 00000000..b8be8cad --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/_helpers.tpl @@ -0,0 +1,32 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "chart-with-lib-dep.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "chart-with-lib-dep.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "chart-with-lib-dep.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/deployment.yaml b/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/deployment.yaml new file mode 100644 index 00000000..521fa597 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/deployment.yaml @@ -0,0 +1,51 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ template "chart-with-lib-dep.fullname" . }} + labels: + app.kubernetes.io/name: {{ template "chart-with-lib-dep.name" . }} + helm.sh/chart: {{ template "chart-with-lib-dep.chart" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ template "chart-with-lib-dep.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ template "chart-with-lib-dep.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + spec: + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 80 + protocol: TCP + livenessProbe: + httpGet: + path: / + port: http + readinessProbe: + httpGet: + path: / + port: http + resources: +{{ toYaml .Values.resources | indent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: +{{ toYaml . | indent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: +{{ toYaml . | indent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: +{{ toYaml . | indent 8 }} + {{- end }} diff --git a/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/ingress.yaml b/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/ingress.yaml new file mode 100644 index 00000000..42afd087 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/ingress.yaml @@ -0,0 +1,38 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "chart-with-lib-dep.fullname" . -}} +{{- $ingressPath := .Values.ingress.path -}} +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + app.kubernetes.io/name: {{ template "chart-with-lib-dep.name" . }} + helm.sh/chart: {{ template "chart-with-lib-dep.chart" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- with .Values.ingress.annotations }} + annotations: +{{ toYaml . | indent 4 }} +{{- end }} +spec: +{{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} +{{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ . }} + http: + paths: + - path: {{ $ingressPath }} + backend: + serviceName: {{ $fullName }} + servicePort: http + {{- end }} +{{- end }} diff --git a/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/service.yaml b/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/service.yaml new file mode 100644 index 00000000..4c2b91a5 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/service.yaml @@ -0,0 +1,10 @@ +{{- template "common.service" (list . "mychart.service") -}} +{{- define "mychart.service" -}} +## Define overrides for your Service resource here, e.g. +# metadata: +# labels: +# custom: label +# spec: +# ports: +# - port: 8080 +{{- end -}} diff --git a/cmd/helm/testdata/testcharts/chart-with-lib-dep/values.yaml b/cmd/helm/testdata/testcharts/chart-with-lib-dep/values.yaml new file mode 100644 index 00000000..a0cc07e9 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-lib-dep/values.yaml @@ -0,0 +1,48 @@ +# Default values for chart-with-lib-dep. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: nginx + tag: stable + pullPolicy: IfNotPresent + +nameOverride: "" +fullnameOverride: "" + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: false + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + path: / + hosts: + - chart-example.local + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/cmd/helm/testdata/testcharts/chart-with-only-crds/.helmignore b/cmd/helm/testdata/testcharts/chart-with-only-crds/.helmignore new file mode 100644 index 00000000..0e8a0eb3 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-only-crds/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/cmd/helm/testdata/testcharts/chart-with-only-crds/Chart.yaml b/cmd/helm/testdata/testcharts/chart-with-only-crds/Chart.yaml new file mode 100644 index 00000000..ec349767 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-only-crds/Chart.yaml @@ -0,0 +1,21 @@ +apiVersion: v2 +name: crd-test +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application and it is recommended to use it with quotes. +appVersion: "1.16.0" diff --git a/cmd/helm/testdata/testcharts/chart-with-only-crds/crds/test-crd.yaml b/cmd/helm/testdata/testcharts/chart-with-only-crds/crds/test-crd.yaml new file mode 100644 index 00000000..1d7350f1 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-only-crds/crds/test-crd.yaml @@ -0,0 +1,19 @@ +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: tests.test.io +spec: + group: test.io + names: + kind: Test + listKind: TestList + plural: tests + singular: test + scope: Namespaced + versions: + - name : v1alpha2 + served: true + storage: true + - name : v1alpha1 + served: true + storage: false diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/Chart.yaml b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/Chart.yaml new file mode 100644 index 00000000..4e24c2eb --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +name: chart-without-schema +description: A Helm chart for Kubernetes +type: application +version: 0.1.0 +appVersion: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/Chart.yaml b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/Chart.yaml new file mode 100644 index 00000000..b5a77c5d --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +name: subchart-with-schema +description: A Helm chart for Kubernetes +type: application +version: 0.1.0 +appVersion: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/templates/empty.yaml b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/templates/empty.yaml new file mode 100644 index 00000000..c80812f6 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/templates/empty.yaml @@ -0,0 +1 @@ +# This file is intentionally blank diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.schema.json b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.schema.json new file mode 100644 index 00000000..4ff79184 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Values", + "type": "object", + "properties": { + "age": { + "description": "Age", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "age" + ] +} diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.yaml b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.yaml new file mode 100644 index 00000000..e69de29b diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/templates/empty.yaml b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/templates/empty.yaml new file mode 100644 index 00000000..c80812f6 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/templates/empty.yaml @@ -0,0 +1 @@ +# This file is intentionally blank diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/values.schema.json b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/values.schema.json new file mode 100644 index 00000000..f3094803 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/values.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Values", + "type": "object", + "properties": { + "firstname": { + "description": "First name", + "type": "string" + }, + "lastname": { + "type": "string" + } + }, + "required": [ + "firstname", + "lastname" + ] +} diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/values.yaml b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/values.yaml new file mode 100644 index 00000000..c9deafc0 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/values.yaml @@ -0,0 +1 @@ +firstname: "John" diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-negative/Chart.yaml b/cmd/helm/testdata/testcharts/chart-with-schema-negative/Chart.yaml new file mode 100644 index 00000000..395d24f6 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema-negative/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +description: Empty testing chart +home: https://k8s.io/helm +name: empty +sources: +- https://github.com/kubernetes/helm +version: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-negative/templates/empty.yaml b/cmd/helm/testdata/testcharts/chart-with-schema-negative/templates/empty.yaml new file mode 100644 index 00000000..c80812f6 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema-negative/templates/empty.yaml @@ -0,0 +1 @@ +# This file is intentionally blank diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-negative/values.schema.json b/cmd/helm/testdata/testcharts/chart-with-schema-negative/values.schema.json new file mode 100644 index 00000000..4df89bbe --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema-negative/values.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "addresses": { + "description": "List of addresses", + "items": { + "properties": { + "city": { + "type": "string" + }, + "number": { + "type": "number" + }, + "street": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "age": { + "description": "Age", + "minimum": 0, + "type": "integer" + }, + "employmentInfo": { + "properties": { + "salary": { + "minimum": 0, + "type": "number" + }, + "title": { + "type": "string" + } + }, + "required": [ + "salary" + ], + "type": "object" + }, + "firstname": { + "description": "First name", + "type": "string" + }, + "lastname": { + "type": "string" + }, + "likesCoffee": { + "type": "boolean" + }, + "phoneNumbers": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "firstname", + "lastname", + "addresses", + "employmentInfo" + ], + "title": "Values", + "type": "object" +} diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-negative/values.yaml b/cmd/helm/testdata/testcharts/chart-with-schema-negative/values.yaml new file mode 100644 index 00000000..5a1250bf --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema-negative/values.yaml @@ -0,0 +1,14 @@ +firstname: John +lastname: Doe +age: -5 +likesCoffee: true +addresses: + - city: Springfield + street: Main + number: 12345 + - city: New York + street: Broadway + number: 67890 +phoneNumbers: + - "(888) 888-8888" + - "(555) 555-5555" diff --git a/cmd/helm/testdata/testcharts/chart-with-schema/Chart.yaml b/cmd/helm/testdata/testcharts/chart-with-schema/Chart.yaml new file mode 100644 index 00000000..395d24f6 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +description: Empty testing chart +home: https://k8s.io/helm +name: empty +sources: +- https://github.com/kubernetes/helm +version: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/chart-with-schema/extra-values.yaml b/cmd/helm/testdata/testcharts/chart-with-schema/extra-values.yaml new file mode 100644 index 00000000..76c290c4 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema/extra-values.yaml @@ -0,0 +1,2 @@ +age: -5 +employmentInfo: null diff --git a/cmd/helm/testdata/testcharts/chart-with-schema/templates/empty.yaml b/cmd/helm/testdata/testcharts/chart-with-schema/templates/empty.yaml new file mode 100644 index 00000000..c80812f6 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema/templates/empty.yaml @@ -0,0 +1 @@ +# This file is intentionally blank diff --git a/cmd/helm/testdata/testcharts/chart-with-schema/values.schema.json b/cmd/helm/testdata/testcharts/chart-with-schema/values.schema.json new file mode 100644 index 00000000..4df89bbe --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema/values.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "addresses": { + "description": "List of addresses", + "items": { + "properties": { + "city": { + "type": "string" + }, + "number": { + "type": "number" + }, + "street": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "age": { + "description": "Age", + "minimum": 0, + "type": "integer" + }, + "employmentInfo": { + "properties": { + "salary": { + "minimum": 0, + "type": "number" + }, + "title": { + "type": "string" + } + }, + "required": [ + "salary" + ], + "type": "object" + }, + "firstname": { + "description": "First name", + "type": "string" + }, + "lastname": { + "type": "string" + }, + "likesCoffee": { + "type": "boolean" + }, + "phoneNumbers": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "firstname", + "lastname", + "addresses", + "employmentInfo" + ], + "title": "Values", + "type": "object" +} diff --git a/cmd/helm/testdata/testcharts/chart-with-schema/values.yaml b/cmd/helm/testdata/testcharts/chart-with-schema/values.yaml new file mode 100644 index 00000000..042dea66 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-schema/values.yaml @@ -0,0 +1,17 @@ +firstname: John +lastname: Doe +age: 25 +likesCoffee: true +employmentInfo: + title: Software Developer + salary: 100000 +addresses: + - city: Springfield + street: Main + number: 12345 + - city: New York + street: Broadway + number: 67890 +phoneNumbers: + - "(888) 888-8888" + - "(555) 555-5555" diff --git a/cmd/helm/testdata/testcharts/chart-with-subchart-notes/Chart.yaml b/cmd/helm/testdata/testcharts/chart-with-subchart-notes/Chart.yaml new file mode 100644 index 00000000..90545a6a --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-subchart-notes/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v2 +description: Chart with subchart notes +name: chart-with-subchart-notes +version: 0.0.1 +dependencies: + - name: subchart-with-notes + version: 0.0.1 diff --git a/cmd/helm/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/Chart.yaml b/cmd/helm/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/Chart.yaml new file mode 100644 index 00000000..f0fead9e --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v2 +description: Subchart with notes +name: subchart-with-notes +version: 0.0.1 diff --git a/cmd/helm/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/templates/NOTES.txt b/cmd/helm/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/templates/NOTES.txt new file mode 100644 index 00000000..1f61a294 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/templates/NOTES.txt @@ -0,0 +1 @@ +SUBCHART NOTES diff --git a/cmd/helm/testdata/testcharts/chart-with-subchart-notes/templates/NOTES.txt b/cmd/helm/testdata/testcharts/chart-with-subchart-notes/templates/NOTES.txt new file mode 100644 index 00000000..9e166d37 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-subchart-notes/templates/NOTES.txt @@ -0,0 +1 @@ +PARENT NOTES diff --git a/cmd/helm/testdata/testcharts/chart-with-subchart-update/Chart.lock b/cmd/helm/testdata/testcharts/chart-with-subchart-update/Chart.lock new file mode 100644 index 00000000..31cda6bd --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-subchart-update/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: subchart-with-notes + repository: file://../chart-with-subchart-notes/charts/subchart-with-notes + version: 0.0.1 +digest: sha256:8ca45f73ae3f6170a09b64a967006e98e13cd91eb51e5ab0599bb87296c7df0a +generated: "2021-05-02T15:07:22.1099921+02:00" diff --git a/cmd/helm/testdata/testcharts/chart-with-subchart-update/Chart.yaml b/cmd/helm/testdata/testcharts/chart-with-subchart-update/Chart.yaml new file mode 100644 index 00000000..1bc23020 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-subchart-update/Chart.yaml @@ -0,0 +1,8 @@ +apiVersion: v2 +description: Chart with subchart that needs to be fetched +name: chart-with-subchart-update +version: 0.0.1 +dependencies: + - name: subchart-with-notes + version: 0.0.1 + repository: file://../chart-with-subchart-notes/charts diff --git a/cmd/helm/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/Chart.yaml b/cmd/helm/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/Chart.yaml new file mode 100644 index 00000000..f0fead9e --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v2 +description: Subchart with notes +name: subchart-with-notes +version: 0.0.1 diff --git a/cmd/helm/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/templates/NOTES.txt b/cmd/helm/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/templates/NOTES.txt new file mode 100644 index 00000000..1f61a294 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/templates/NOTES.txt @@ -0,0 +1 @@ +SUBCHART NOTES diff --git a/cmd/helm/testdata/testcharts/chart-with-subchart-update/templates/NOTES.txt b/cmd/helm/testdata/testcharts/chart-with-subchart-update/templates/NOTES.txt new file mode 100644 index 00000000..9e166d37 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-subchart-update/templates/NOTES.txt @@ -0,0 +1 @@ +PARENT NOTES diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/.helmignore b/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/.helmignore new file mode 100644 index 00000000..f0c13194 --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/.helmignore @@ -0,0 +1,21 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/Chart.yaml b/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/Chart.yaml new file mode 100644 index 00000000..de53ce5e --- /dev/null +++ b/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +appVersion: "1.0" +description: A Helm chart for Kubernetes +name: chart-with-template-lib-archive-dep +type: application +version: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/charts/common-0.0.5.tgz b/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/charts/common-0.0.5.tgz new file mode 100644 index 0000000000000000000000000000000000000000..465517824e7a2e6492cede07e7233e7120506185 GIT binary patch literal 8446 zcmV@YM5rY5%fRY&7@3+4`=EA|tmgQv4sa=Nx=IH6^YkGPb!*n)Flf%zGWI2Lzetw4k z?Vg_IQyVbsWLChFc$*;n|D{&TmAlTT86y- zKR>>B^z?Bs8-0i|X#LNYt^escEPaI*j=IO)vy;!n(T4!0UOw^lzb76N1@y!(Qz`x} z;~D;w$y_{~hI#3Ezf7Y-4Dq*sA0t^rlSGb0nTlEXN{XlyGAtsfKTdP8Nay%wFpuJq z2$K=iiINijPd$6oO%ZtBlTu`PdKE!;VqQjZR7UWtoQ9>y-Pe zn`&RDIrVWJl~aut&+CY9p=}6o&0}#rm5G=aSZh2_0Jbzya8G0+^ALu~VczLXjKVS$ zdvOFf>NArC&_SGpGa1a2=#RPV?+2bhv@ZZn3sL6LWCC!{lj|@k0mbudl81;P0Bssx zNs*RQcwYe4gdC3nNrcJ-#>bUOhjYS&xRwARjQaEAA0J;lf82R?@$_+Dgb1e;I6fj1 zf9EoWwUdmIpJkEG^Pvue^N;%8o5aEV3YbDs(d^l}ViKoDOIe?*tFU6J23tC(?FIkFWTmkogrbxsU7u z^9Y1LTM)CFH~XUV_AND5lWf$W3P8q>_u=3!IDRT+>nuFcZKjgGu1s6Ej(c z$g(~7Ekqm+WL)rXA%=k6bk^gi@T|z>kl$q~NEH6rp+9=!tD~=u2vWp10tHj|yl1Ii z4n$SII`O_miDtF(ItBFRfPNrS7{S8Egu4wJB zCWv36HE68{dN)0`f zkTLwoCnS`Zn)1$0ANOf$zkziuCZo$O-r8YG7!(pe1i4EWMNSnhzsUV zL{A}s+Gujf%>{BC)LVEU*%l-Sa*5D{g5(d1QU$0X*ww-Vc^hn*WEAEjL(T*nQ#0V< zaR`DhLY73t0jh{+P5-Rlhj}!BS0INc1W^tTfres`4Iu7FS=?)2a~+O(a>csY(Imzx z%2k{pkZCxNk;6a>YyK@48HlZi@f_v%lgk6~EFH;9;JpL!V;YXW4dXBw%6uQtJR$}d zN0WIT;?fR~Q(lDe9F|7G7+`mO=>8e7%ZPYb^rvN+^-WWF48%%rfVB}2G*Ey_3R#k( z3$v^zUcYV%He^9xynTD1RV`HuepY%HhZJwbA9EBAZ{PM=N0mcDgPuIINR+VPcqFqp zUEu0j%)z>{oHYQvyTAo|E#5%tR;p1gD?bHEo_2(23&@8t-@ff&H{zZntlfE&`~r-G zBhad%n2*QNjb}u&*sR$UQ0Mrx=3OgaR zgGy5PZRSfddbqFYX$4rJb#18PQjr=`)1n8&E9Oz>DCh>fs9^bmnKywo{{?0*TiF;N8Lg#$ zbdXRChq(_sdXN?gdZz-e9TUqY(lTqb2YSMo7bLQzL;P_rR)#t@TZ*McqEI z+P4cW#SawgTdx3_naY~7@CjE^C=tERtV0#j_fUxSH?Z!M14gOUxJcQqQ(hTOzO+xW zRy-P*AS@I>=*~#e(lm7ZCr=3+?|)pb-UZc+D*MBp4NT6rhPTvf>%iG0Mx^W zuzM5P3)mnIlh8V_3E9VRrqW89H*Fi6^k>OXCqQ z-je;1@z;=YE49JFu2K>e9q}u%kwyTB*NLQEhu-#43wn%C^LcV$wq?$+8mpQR1UKm^ zDFtRo1hl;9i3GO9H%hEbK=PHi3uBoBme^$q6NFk%#?R975|$6<&bc<<_G7pxP_^=OSv^}{o$}W9?W(I6{r>^Fwi`Y~s(wn!M$=rOpLAMfnW?vOT28h7 zI;*Unt2@BjSsO-i=Nd;>5=3)xnWqDqD3ZYUvgCPXpelOeu&T&_0Y}l6ZC2Q|+EBBM z-MSBFqf>czth3fzB`bi-vJ{wsy|q$YrlaSuiuVhi*dm%mahPkS z=aT|M>IY3HaWVodjRDKau<0^ZV;8kGV*b)#v8lbzWViL3Mmq}{1PxC-l*9?Vi$Wk` zp(5%zT!90lIngV3g~m6XHq->!!03IFn5byI$}tj)IzoUM{{UqZ>p+l6uo@_o(BDE7 z$!A9Q#T5+%a>uKB@W^?GxIzDCh6g|?ErQa-c9yxEz%u5G0~GE^93e4rGzJBM0Kijq z;1Gz5gx62g9U#)Uu?T;(NX0ar$0PFCldL31JWG2GNue=qR^dMSI{%nQ=+tV6$^*wn z@yJF&2c@uZrtUNgV3(^XPZM_VUL*$IK4U9DY1&8Zcovs1DncEn=_`SUV~*T4N~BGN zR29fl^jH9HWzxsVP($hJ%<&(?zY1|UAObtVxQp* z)vl6UK(V;ME(3U-8e=+P%IG;Z1r~aDx!kx=P}Eh5E)_MQi>ofoGk4E~g^^R6iLU8P zxMQbuS?@#lf>4-;Oz*e}#BE@oxE>%v@rOrLJTbGNr>7&Km25|?w|E9UEp0}9W z2_IdOy$!XVVlAVaOLY$G(Mgq3Yi!)IaB>Cgc3U0;REr5^-z_%`o%UEov&)}y4*Qj1 zh@=$ad26Wu`h6Zp_8q9no_NzC?Bed`yC??P3xpy3G5qWX5 z`Y(E4D{#c10zAtuvm~oUmqCRqxy-{`6kK+oI_VW0!B9WYQeh3tiB3m8Y)o<7XR#fv zaii9ga(c1qpqyJaNeqZ4%Ofzrd{{4Eyb*&3r(hN$$=J1DSxJ+`%1WEjsoUng3HCq7 z-yJ#NsXqyj7vE2_;o@8T|MbcC|IG&9|2ls-`Q{sg6{@T~eTcesjy|g6kS#yCMVeJZ zusiOjECgZOpkA;;fB=gc`Rb>YErS~wGXaH2hB$iYFUWhpZTe$}Ym?_S|lC6oBcDefoNE_k`ND7x^`9@6tm1HVOM3HDVB_!w?-5VFETa z$#%79uS|GT5T%Ezt6&(1(4phEs8%cEq7x2rVf-?mOCQahSdGi8^Ng0Q0z(I?@cO4l zH@1UGi@Aj`P*Ar>1N~;XI52HHTQk~H168e8g;72j;u{>Ox^h`iJ-S66)Poqu;yjj$ z0C0JGOURFnHEkE6jpo?1gK+o?uqlk!R$QfnzB9K-X^^ zQ#)u z{&VL+pS|55qSWCChp<;Fo~9$o__(0IG0v02#Tr=nKyVb%pHA0r#B=;R9e=R#m%$-# z;1{qLF($wPORg*iSo9^_3l;|*E|KBPak6dnG3DTMby9`r?KFml!)v!D#9C`&y;P~# z7S3!@UMhy@3DiFDw@a8k3MEEWXQ-qneg<;>4WrI~O6c?#R}Q4}_tDl$zEyy>sQmVN z%ME}f{cdj#m;>=v1h2N5TY+#jcx#1Q)aUI0XzgxSR4LH1w{6yywcz>Zx zJrh?;Ui!ks2Em+FaOdOX>P47)-me84;{3Pi%A8qR-@ZQ9EHJn<8f%~N0wH)M7ad|% z&+!-2O#dK~7*M)!a%fBB#yMWeepUiOhymfERyj>0j zL9o$k(8RPh)A26|y<_*sstZv?VIPdO%PZ&YZM zMFUMs$%K1?g{kF6{=3PIl`~1rkXTDojPb$I>0hg~R_4RsfaesTs~Jui7DGL8Lwx+i z*FVs=Nc6YQuBx>?f~jKqLgfZM!6QY(EnANFAm_$J&1_r>V;ZW?#%p_Nx={xf0KDBX zwvmK83Ta2yTza~eR$YhFa*NK@P1Qq9I?^S-PureN9XG$*)@|($?SpK@)^4lpPRVg6 z_w}x*Z?;8ESl1Zig)9#o6I%D9G`+ee144ssYtjyH$N>3O-^Hxn^3Zysw#w$Qx^?xxUqyrl@e<2H-i1%zU|;Mx;U$!+z;q<#H)sxhkR+s@C5o_ z6R-~{Zci;&;OX&xQZ2z=#F#%8Gq6H@?h=khi0urFsOZMDxuid#1w8NgrY=RJ7dD_> zSAR}_LiG;3puWG4(*c;GwOf#U!0k=wC5ndTcv;7Hp4*PI!mne+p>v#%!2&|t!JE9?)QR6eQ1`fKd4xZOnJ+pS##Q=+diEXp*J(b=$PubWa4L4p zOIKW*>yYj>Hn69YA@6HTadt|*heN!&r$W&Mz;OkW`5Kptf?&efv$Y6VzGes;#gV2J zpavwLxxGZD9A)M@C+@0I3hQ{4^g2;7D>bEUHLk-V9nyB2T+~@~iV{|`Ek?2_CiMO0 zc4?vC@)jT=gc{DW1j@BQlmTaS45qvSw^#`~0V%E`F>*~^Bc^^xx&-R>dP&hbVH}&i z4py<(D{-&UBI;J$s$$@tt(8J*uC6dzO8su1l|8ENtn+((G#Xsra6c-*38Qpi(}lqPt3VqIHhHC8o`wS{k)l^D}F{wmkdLdV`Shssq!T~2c;mA$Y0Zb6v>>%u?! z&NV-68b~J2f_55Ot0-#H*rc`^Jarlt=!<=@Pd_B0Q#a1_^$T6kJCS7rR>&Oa*K0|t}+Vqs4Z9Jbz?F28!D4!fiph<&|dgd#{xAs^Ek@!Sauec?AD1x z{Btlbhg0P(PNE?!Pbr7fBrVcxnig%x*1*Bg2egb*`WwVGY`UmMPlWk!3WAyf5pbVM_D@YYDf=DpU6Y2XlX!?#``aO)@c*-`FSJ>|ZeEXH>|@VXnv z>8^0p{J1<@86iMNE*)8ErUf1i$3`DQ?B z(-BdY9R;4&Rx=q=+9HX8?lf!9n8kg@QU3r;D?17Nvtk^wDC*-9sJ(nqv+k%LP`!@p+FtEIO*=?O%>xj>>Y401sl7~pr7k-x=%4sr!AII>2z(dLXEnKmTBNR87wu&7Z$RMhzS`$o2ZhRV@FM;(Nk(G@W6z^j2Pz#me z_NtHMMMYd3WWbe1cC1IuD$!Hzt2t>x4j9MvkY@c@n^Dg4+q-+jZOZ&U5V9DC86R>F zq!-Hx*yeNw;P}#L5jHUrGyv^eBmLH;obU8qAOf#HPSZY{SPDSV?*_-gNe5%Yg0r(P zyT>P|eRYS2;y*aOPAd%1S*OzaCAQ{q_w0VO|0P8f;6^Ow`5S8+F~O5(dtu-E*g5w8 z%0=aWL#RnK;b^gbrqtkBJ$^UtR`5zyg9`Eu$-bJZJa)9hNe;x%#!I?L*XD2x}We@!U65cF!S zw~BFY8ik`IO#U^9N~jc7>Sy_4lqM0FeER4M!S=4IGvh#NmDKE06%va7of~_%KgK%# zKkgpa^1q%Oojlm_|6LTij!g-VbT}Qls)#6xKzj+v8vD=1(;qv$9i#L&bkhf$4`a$q z0i33MAz29avC48_4jXmQuG#cpV`fi|4*J-b_%w^+{{ZE4G)dB2-u@Wt`2VbX*5Lo{ zF8}kL6bw29e#YH6W~w3z;4H^Rzh~j_73}}4I4I^BXVWUCc#eere@+zxwJFtMNT)=2 z0l|5WK}W#xV62lbc#qE}KsM0U;UD{f_z4q;f+c}15hM(A0R5bRoCzL1e~H20($fj6 z#fyi}Ve=1$MlgxWL;9D&_kzKn`62zQA515Q_>caxxJnLfM|l5Yo{>N+yf1>{I)i@) z;VbyJoWbw^gQ_pWJWA(%pVHqX<*{Z=~{v_!7Vt`Twyd z+W+U>UH<<&DUJ1isbaUv?A9#7HS)jPJ*~_Cv!m0U{dXtDJv>|*HQ6yyk$9xo5ue+D z;*q=&fjDYbG0mUV>UODaEjaP@Yt^aK&5A%rtX>_U7GLQtpBKe$zSE3!^kZ{2m3mHa zo?G*H(|Kbj(YVr&2_1B*Z4<4D!>o!K)JadBIo1G@aZ!1uqZFaDO{~sJZ*xCPgQd;DsLjYg|u&mrRLDA}P0Xj-}kAy*MVz_kO zO_Hu}a(x76Q@=W6sxgiN=n@5eQi`uuUD|26F|qVm5 zye|%yS`H>sNyol-%;z}#FMZ*^^z{`CCCffg|6e-SKi&1o0Y%x)+~~qUJePQ(A9|dDu8eSpLAOL$2!+Bw@PieW0FR0<>sLHpzqWe> z78!JOu)%pVA;qyQa{465=dTXL=U1rx13oB#C(d~UpVeNuClnv_RJFf2xQ?3lrXOH9)Z{#DXt536O7or&r8u^^4q;D zF4_|0*=7y|O)S{4KTSjJ1C^%yZ%v8%UYo&n^8fVcK~?@AADx`;&VTNtw8)Qk0$6<< z)Smlyiztv#<6KG-X+~qZta@tiN@_!-L&Mk>Yn^V_?_1LkCi4W z$z)45VBf2T+O(*_@3x51`k7xyp;)Y3|BM55#4 z+V0$MIq5e2VLKgyrS1Hmzr}ug0RU_F|ECTA-^uR&@7#Yi9B_+uIW2U+>`$96K+wdx)LlazV}rBMCZEiKdnCZ_}qGQk|Wsv&FW0%W&CdU$s-#J4;HJGIw!Zw^Ew&-{h{ntN5?uvzGX;UHtdm z6m|Y%Qf1o1AC+o%p))aa(n_6?>h1PD#gY zJ=|-`o1uh#XC)OeKyYaiKlePZBbMbq#*a8r^qBsHI(;pDBx)H&IYTey6>avVli~7z zoeG>Yw^2{!+0E+J>)F)#zsvj6^;CZROkx8WI!zxnVH-wVAtKY6|~x8`OjR zDt5jT^0&W~7ToS9fF8GdJh*6GPPb9BO*^SnyzTSxnl`-572ukU>cBM{>0<%@b}vtm zAZAn&Al!4k(Hjw`*FZ*ZI7Kq%M#i7fObvqWu)3P|sz=VXROvc%HCSLM7ZmBKWbck7 zs;3NYP3m47VpS+uBV+r)9~4RCe963s=l|6E$Q1!>-{fvv+b_#f1Nk) z|35h2?f>qiv|qt;uQnnhm@~R+4tp<}abi%%r$di|TxMwzm1&O0b-l*xGMtc?n*O>3 ze!5KKXt=02hqz{DUgCJlJydfJ8Gy~M$2kjVZf49&yOlZKz|GV0akaxQ#1K?TE)sc7 ziE0FXje)x4BsbS-{tCrVp37kg#*8C>NZ7^+N@(LpukCEt-g9%7Y2Uqoy2Z|DMf{hF zmwW$hz&ig=_oR0I*M0C{7yorPrK*f|=3X7b&DoW~=AZFbte_Qwa?8zl9$8J=A)xN`8jINRD={;{ch zSJsuL{J&k_50-WQpJx82)6)mL_>Vg&O4`+YKSpBc&n&suYxt9$_^~A$+Dj6x^_xs4 zdRTq*AVWtC$`zDYu+wvZ5;yK#HYqW{S z=r86i+SF5Y`(7eDc zVQyr3R8em|NM&qo0PL4vi_<_5!26s}F{UjHhD>x0Xejw~|-egB1QU=&JEdrHEIt>CEtv%dd}n=<=92zSKn;o(8OM@#S> zYFc5(0#_R!xxRXQ%zfa$rtiOMiLGgzk94**j`?3M7Jt0|r`i8O7{f;tq39Bbhr@%1 zO-l}zo#EQJ1_D-Jv7w}jF??!Gg4BiJqa;WzF+;Dc zVQyr3R8em|NM&qo0PL4vi_<_5!26s}F{UjHhD>x0Xejw~|-egB1QU=&JEdrHEIt>CEtv%dd}n=<=92zSKn;o(8OM@#S> zYFc5(0#_R!xxRXQ%zfa$rtiOMiLGgzk94**j`?3M7Jt0|r`i8O7{f;tq39Bbhr@%1 zO-l}zo#EQJ1_D-Jv7w}jF??!Gg4BiJqa;WzF+;Dc zVQyr3R8em|NM&qo0PL4vi_<_5!26s}F)eG5__n?E62T&$9nR zaPZh}uYVQ7^}*#!N0u3azW+itFbbuoJtg79R&dn+S>OM~O_}{4ggavP@bIACqb2wb zHLb8?fvb&=Twgst=05OW)AwJs#MU&^XY$NVoBi$C7~)9n8sjNv1SP;?2z!{Nch zrX>f<&Tws90|BeA*icf%7(TToLFz*AQ4*wspoCYeko Tb2>i)00960YK^g$02BZKS`FmP literal 0 HcmV?d00001 diff --git a/cmd/helm/testdata/testcharts/compressedchart-0.3.0.tgz b/cmd/helm/testdata/testcharts/compressedchart-0.3.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..051bd6fd9a030a3f4327e8002a8912c1f5f68050 GIT binary patch literal 477 zcmV<30V4h%iwG0|00000|0w_~VMtOiV@ORlOnEsqVl!4SWK%V1T2nbTPgYhoO;>Dc zVQyr3R8em|NM&qo0PL4vi_<_5!26s}Fj!(#55d%N%6r{|dRMblL`R$bVgxL;q*9Y4-md z>^-)d>tBU%y?6Q2k!8-o(0?Ht7=_Z)o|156D>&%?bm;&5rp*4Ig*#&Q@bIACqb2wb z4K1-}fvdHT++00A=05OWGxT3|#MZRVM>^RWhx{++^FQAIRrdcZjNv1SP;?2z!~Vg; zx+4dR-f&}F3jyn|*iur(7(R6-LFz;BQ4*w%n4x9A0E<$0#EPK51s@!5z`Na*+mIko1U8OTq2AtqxfdU)P_4<|CYeko Tb38u+00960Q~zm}02BZK0h8&p literal 0 HcmV?d00001 diff --git a/cmd/helm/testdata/testcharts/compressedchart-with-hyphens-0.1.0.tgz b/cmd/helm/testdata/testcharts/compressedchart-with-hyphens-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..379210a92c1795429d2c4baae389f1e971f26824 GIT binary patch literal 548 zcmV+<0^9u`iwG0|00000|0w_~VMtOiV@ORlOnEsqVl!4SWK%V1T2nbTPgYhoO;>Dc zVQyr3R8em|NM&qo0PL4fi`y^|#dG$jc->rDXjD0A64=|)92WW)wwIoYVoz*QSrU?* zG;H^~7dcDXrjTqgP3fZFMMBsbi zPWu0B_M89nr2n%p#E0nPPIpGV%QZGNX)If*N~tSYQG5|qH0t|nfN!leE_nEwltQJ< z5{(E&Ep_!Aj+6*;9W6i9KdlR0WlY0RR8Xa#gbc6aWCh@%&~0 literal 0 HcmV?d00001 diff --git a/cmd/helm/testdata/testcharts/deprecated/Chart.yaml b/cmd/helm/testdata/testcharts/deprecated/Chart.yaml new file mode 100644 index 00000000..10185bee --- /dev/null +++ b/cmd/helm/testdata/testcharts/deprecated/Chart.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +description: Deprecated testing chart +home: https://helm.sh/helm +name: deprecated +sources: + - https://github.com/helm/helm +version: 0.1.0 +deprecated: true diff --git a/cmd/helm/testdata/testcharts/deprecated/README.md b/cmd/helm/testdata/testcharts/deprecated/README.md new file mode 100644 index 00000000..0df9a8bb --- /dev/null +++ b/cmd/helm/testdata/testcharts/deprecated/README.md @@ -0,0 +1,3 @@ +#Deprecated + +This space intentionally left blank. diff --git a/cmd/helm/testdata/testcharts/empty/Chart.yaml b/cmd/helm/testdata/testcharts/empty/Chart.yaml new file mode 100644 index 00000000..4f1dc001 --- /dev/null +++ b/cmd/helm/testdata/testcharts/empty/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +description: Empty testing chart +home: https://helm.sh/helm +name: empty +sources: + - https://github.com/helm/helm +version: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/empty/README.md b/cmd/helm/testdata/testcharts/empty/README.md new file mode 100644 index 00000000..ed73c179 --- /dev/null +++ b/cmd/helm/testdata/testcharts/empty/README.md @@ -0,0 +1,3 @@ +#Empty + +This space intentionally left blank. diff --git a/cmd/helm/testdata/testcharts/empty/templates/empty.yaml b/cmd/helm/testdata/testcharts/empty/templates/empty.yaml new file mode 100644 index 00000000..c80812f6 --- /dev/null +++ b/cmd/helm/testdata/testcharts/empty/templates/empty.yaml @@ -0,0 +1 @@ +# This file is intentionally blank diff --git a/cmd/helm/testdata/testcharts/empty/values.yaml b/cmd/helm/testdata/testcharts/empty/values.yaml new file mode 100644 index 00000000..1f0ff00e --- /dev/null +++ b/cmd/helm/testdata/testcharts/empty/values.yaml @@ -0,0 +1 @@ +Name: my-empty diff --git a/cmd/helm/testdata/testcharts/issue-7233/.helmignore b/cmd/helm/testdata/testcharts/issue-7233/.helmignore new file mode 100644 index 00000000..50af0317 --- /dev/null +++ b/cmd/helm/testdata/testcharts/issue-7233/.helmignore @@ -0,0 +1,22 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/cmd/helm/testdata/testcharts/issue-7233/Chart.yaml b/cmd/helm/testdata/testcharts/issue-7233/Chart.yaml new file mode 100644 index 00000000..b31997ac --- /dev/null +++ b/cmd/helm/testdata/testcharts/issue-7233/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +appVersion: "1.0" +description: A Helm chart for Kubernetes +name: issue-7233 +version: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/issue-7233/requirements.lock b/cmd/helm/testdata/testcharts/issue-7233/requirements.lock new file mode 100644 index 00000000..62744125 --- /dev/null +++ b/cmd/helm/testdata/testcharts/issue-7233/requirements.lock @@ -0,0 +1,6 @@ +dependencies: +- name: alpine + repository: file://../alpine + version: 0.1.0 +digest: sha256:7b380b1a826e7be1eecb089f66209d6d3df54be4bf879d4a8e6f8a9e871710e5 +generated: "2020-01-31T11:30:21.911547651Z" diff --git a/cmd/helm/testdata/testcharts/issue-7233/requirements.yaml b/cmd/helm/testdata/testcharts/issue-7233/requirements.yaml new file mode 100644 index 00000000..f0195cb1 --- /dev/null +++ b/cmd/helm/testdata/testcharts/issue-7233/requirements.yaml @@ -0,0 +1,4 @@ +dependencies: +- name: alpine + version: 0.1.0 + repository: file://../alpine diff --git a/cmd/helm/testdata/testcharts/issue-7233/templates/configmap.yaml b/cmd/helm/testdata/testcharts/issue-7233/templates/configmap.yaml new file mode 100644 index 00000000..53880b25 --- /dev/null +++ b/cmd/helm/testdata/testcharts/issue-7233/templates/configmap.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-configmap +data: + myvalue: "Hello World" + drink: {{ .Values.favoriteDrink }} diff --git a/cmd/helm/testdata/testcharts/issue-7233/values.yaml b/cmd/helm/testdata/testcharts/issue-7233/values.yaml new file mode 100644 index 00000000..b1aa168d --- /dev/null +++ b/cmd/helm/testdata/testcharts/issue-7233/values.yaml @@ -0,0 +1 @@ +favoriteDrink: coffee diff --git a/cmd/helm/testdata/testcharts/issue-9027/Chart.yaml b/cmd/helm/testdata/testcharts/issue-9027/Chart.yaml new file mode 100644 index 00000000..ea6761a1 --- /dev/null +++ b/cmd/helm/testdata/testcharts/issue-9027/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: issue-9027 +version: 0.1.0 +dependencies: + - name: subchart + version: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/issue-9027/charts/subchart/Chart.yaml b/cmd/helm/testdata/testcharts/issue-9027/charts/subchart/Chart.yaml new file mode 100644 index 00000000..0639b180 --- /dev/null +++ b/cmd/helm/testdata/testcharts/issue-9027/charts/subchart/Chart.yaml @@ -0,0 +1,3 @@ +apiVersion: v2 +name: subchart +version: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/issue-9027/charts/subchart/templates/values.yaml b/cmd/helm/testdata/testcharts/issue-9027/charts/subchart/templates/values.yaml new file mode 100644 index 00000000..fe0018e1 --- /dev/null +++ b/cmd/helm/testdata/testcharts/issue-9027/charts/subchart/templates/values.yaml @@ -0,0 +1 @@ +{{ .Values | toYaml }} diff --git a/cmd/helm/testdata/testcharts/issue-9027/charts/subchart/values.yaml b/cmd/helm/testdata/testcharts/issue-9027/charts/subchart/values.yaml new file mode 100644 index 00000000..0da52421 --- /dev/null +++ b/cmd/helm/testdata/testcharts/issue-9027/charts/subchart/values.yaml @@ -0,0 +1,17 @@ +global: + hash: + key1: 1 + key2: 2 + key3: 3 + key4: 4 + key5: 5 + key6: 6 + + +hash: + key1: 1 + key2: 2 + key3: 3 + key4: 4 + key5: 5 + key6: 6 diff --git a/cmd/helm/testdata/testcharts/issue-9027/templates/values.yaml b/cmd/helm/testdata/testcharts/issue-9027/templates/values.yaml new file mode 100644 index 00000000..fe0018e1 --- /dev/null +++ b/cmd/helm/testdata/testcharts/issue-9027/templates/values.yaml @@ -0,0 +1 @@ +{{ .Values | toYaml }} diff --git a/cmd/helm/testdata/testcharts/issue-9027/values.yaml b/cmd/helm/testdata/testcharts/issue-9027/values.yaml new file mode 100644 index 00000000..22577e4f --- /dev/null +++ b/cmd/helm/testdata/testcharts/issue-9027/values.yaml @@ -0,0 +1,11 @@ +global: + hash: + key1: null + key2: null + key3: 13 + +subchart: + hash: + key1: null + key2: null + key3: 13 diff --git a/cmd/helm/testdata/testcharts/issue1979/Chart.yaml b/cmd/helm/testdata/testcharts/issue1979/Chart.yaml new file mode 100644 index 00000000..5269b5cf --- /dev/null +++ b/cmd/helm/testdata/testcharts/issue1979/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +description: Deploy a basic Alpine Linux pod +home: https://helm.sh/helm +name: alpine +sources: + - https://github.com/helm/helm +version: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/issue1979/README.md b/cmd/helm/testdata/testcharts/issue1979/README.md new file mode 100644 index 00000000..fcf7ee01 --- /dev/null +++ b/cmd/helm/testdata/testcharts/issue1979/README.md @@ -0,0 +1,13 @@ +#Alpine: A simple Helm chart + +Run a single pod of Alpine Linux. + +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.yaml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/cmd/helm/testdata/testcharts/issue1979/extra_values.yaml b/cmd/helm/testdata/testcharts/issue1979/extra_values.yaml new file mode 100644 index 00000000..468bbacb --- /dev/null +++ b/cmd/helm/testdata/testcharts/issue1979/extra_values.yaml @@ -0,0 +1,2 @@ +test: + Name: extra-values diff --git a/cmd/helm/testdata/testcharts/issue1979/more_values.yaml b/cmd/helm/testdata/testcharts/issue1979/more_values.yaml new file mode 100644 index 00000000..3d21e1fe --- /dev/null +++ b/cmd/helm/testdata/testcharts/issue1979/more_values.yaml @@ -0,0 +1,2 @@ +test: + Name: more-values diff --git a/cmd/helm/testdata/testcharts/issue1979/templates/alpine-pod.yaml b/cmd/helm/testdata/testcharts/issue1979/templates/alpine-pod.yaml new file mode 100644 index 00000000..6f025fec --- /dev/null +++ b/cmd/helm/testdata/testcharts/issue1979/templates/alpine-pod.yaml @@ -0,0 +1,26 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{.Release.Name}}-{{.Values.Name}}" + labels: + # The "app.kubernetes.io/managed-by" label is used to track which tool + # deployed a given chart. It is useful for admins who want to see what + # releases a particular tool is responsible for. + app.kubernetes.io/managed-by: {{.Release.Service | quote }} + # The "app.kubernetes.io/instance" convention makes it easy to tie a release + # to all of the Kubernetes resources that were created as part of that + # release. + app.kubernetes.io/instance: {{.Release.Name | quote }} + # This makes it easy to audit chart usage. + helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}" + values: {{.Values.test.Name}} +spec: + # This shows how to use a simple value. This will look for a passed-in value + # called restartPolicy. If it is not found, it will use the default value. + # {{default "Never" .restartPolicy}} is a slightly optimized version of the + # more conventional syntax: {{.restartPolicy | default "Never"}} + restartPolicy: {{default "Never" .Values.restartPolicy}} + containers: + - name: waiter + image: "alpine:3.9" + command: ["/bin/sleep","9000"] diff --git a/cmd/helm/testdata/testcharts/issue1979/values.yaml b/cmd/helm/testdata/testcharts/issue1979/values.yaml new file mode 100644 index 00000000..879d760f --- /dev/null +++ b/cmd/helm/testdata/testcharts/issue1979/values.yaml @@ -0,0 +1,2 @@ +# The pod name +Name: my-alpine diff --git a/cmd/helm/testdata/testcharts/lib-chart/.helmignore b/cmd/helm/testdata/testcharts/lib-chart/.helmignore new file mode 100644 index 00000000..f0c13194 --- /dev/null +++ b/cmd/helm/testdata/testcharts/lib-chart/.helmignore @@ -0,0 +1,21 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj diff --git a/cmd/helm/testdata/testcharts/lib-chart/Chart.yaml b/cmd/helm/testdata/testcharts/lib-chart/Chart.yaml new file mode 100755 index 00000000..4dcddc85 --- /dev/null +++ b/cmd/helm/testdata/testcharts/lib-chart/Chart.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +description: Common chartbuilding components and helpers +name: lib-chart +version: 0.0.5 +appVersion: 0.0.5 +home: https://helm.sh +maintainers: +- name: technosophos + email: technosophos@gmail.com +- name: prydonius + email: adnan@bitnami.com +type: Library diff --git a/cmd/helm/testdata/testcharts/lib-chart/README.md b/cmd/helm/testdata/testcharts/lib-chart/README.md new file mode 100644 index 00000000..87b753f2 --- /dev/null +++ b/cmd/helm/testdata/testcharts/lib-chart/README.md @@ -0,0 +1,831 @@ +# Common: The Helm Helper Chart + +This chart is designed to make it easier for you to build and maintain Helm +charts. + +It provides utilities that reflect best practices of Kubernetes chart development, +making it faster for you to write charts. + +## Tips + +A few tips for working with Common: + +- Be careful when using functions that generate random data (like `common.fullname.unique`). + They may trigger unwanted upgrades or have other side effects. + +In this document, we use `release-name` as the name of the release. + +## Resource Kinds + +Kubernetes defines a variety of resource kinds, from `Secret` to `StatefulSet`. +We define some of the most common kinds in a way that lets you easily work with +them. + +The resource kind templates are designed to make it much faster for you to +define _basic_ versions of these resources. They allow you to extend and modify +just what you need, without having to copy around lots of boilerplate. + +To make use of these templates you must define a template that will extend the +base template (though it can be empty). The name of this template is then passed +to the base template, for example: + +```yaml +{{- template "common.service" (list . "mychart.service") -}} +{{- define "mychart.service" -}} +## Define overrides for your Service resource here, e.g. +# metadata: +# labels: +# custom: label +# spec: +# ports: +# - port: 8080 +{{- end -}} +``` + +Note that the `common.service` template defines two parameters: + + - The root context (usually `.`) + - A template name containing the service definition overrides + +A limitation of the Go template library is that a template can only take a +single argument. The `list` function is used to workaround this by constructing +a list or array of arguments that is passed to the template. + +The `common.service` template is responsible for rendering the templates with +the root context and merging any overrides. As you can see, this makes it very +easy to create a basic `Service` resource without having to copy around the +standard metadata and labels. + +Each implemented base resource is described in greater detail below. + +### `common.service` + +The `common.service` template creates a basic `Service` resource with the +following defaults: + +- Service type (ClusterIP, NodePort, LoadBalancer) made configurable by `.Values.service.type` +- Named port `http` configured on port 80 +- Selector set to `app.kubernetes.io/name: {{ template "common.name" }}, app.kubernetes.io/instance: {{ .Release.Name | quote }}` to match the default used in the `Deployment` resource + +Example template: + +```yaml +{{- template "common.service" (list . "mychart.mail.service") -}} +{{- define "mychart.mail.service" -}} +metadata: + name: {{ template "common.fullname" . }}-mail # overrides the default name to add a suffix + labels: # appended to the labels section + protocol: mail +spec: + ports: # composes the `ports` section of the service definition. + - name: smtp + port: 25 + targetPort: 25 + - name: imaps + port: 993 + targetPort: 993 + selector: # this is appended to the default selector + protocol: mail +{{- end -}} +--- +{{ template "common.service" (list . "mychart.web.service") -}} +{{- define "mychart.web.service" -}} +metadata: + name: {{ template "common.fullname" . }}-www # overrides the default name to add a suffix + labels: # appended to the labels section + protocol: www +spec: + ports: # composes the `ports` section of the service definition. + - name: www + port: 80 + targetPort: 8080 +{{- end -}} +``` + +The above template defines _two_ services: a web service and a mail service. + +The most important part of a service definition is the `ports` object, which +defines the ports that this service will listen on. Most of the time, +`selector` is computed for you. But you can replace it or add to it. + +The output of the example above is: + +```yaml +apiVersion: v1 +kind: Service +metadata: + labels: + app.kubernetes.io/name: service + helm.sh/chart: service-0.1.0 + app.kubernetes.io/managed-by: Helm + protocol: mail + app.kubernetes.io/instance: release-name + name: release-name-service-mail +spec: + ports: + - name: smtp + port: 25 + targetPort: 25 + - name: imaps + port: 993 + targetPort: 993 + selector: + app.kubernetes.io/name: service + app.kubernetes.io/instance: release-name + protocol: mail + type: ClusterIP +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app.kubernetes.io/name: service + helm.sh/chart: service-0.1.0 + app.kubernetes.io/managed-by: Helm + protocol: www + app.kubernetes.io/instance: release-name + name: release-name-service-www +spec: + ports: + - name: www + port: 80 + targetPort: 8080 + type: ClusterIP +``` + +## `common.deployment` + +The `common.deployment` template defines a basic `Deployment`. Underneath the +hood, it uses `common.container` (see next section). + +By default, the pod template within the deployment defines the labels `app: {{ template "common.name" . }}` +and `release: {{ .Release.Name | quote }` as this is also used as the selector. The +standard set of labels are not used as some of these can change during upgrades, +which causes the replica sets and pods to not correctly match. + +Example use: + +```yaml +{{- template "common.deployment" (list . "mychart.deployment") -}} +{{- define "mychart.deployment" -}} +## Define overrides for your Deployment resource here, e.g. +spec: + replicas: {{ .Values.replicaCount }} +{{- end -}} +``` + +## `common.container` + +The `common.container` template creates a basic `Container` spec to be used +within a `Deployment` or `ReplicaSet`. It holds the following defaults: + +- The name is set to the chart name +- Uses `.Values.image` to describe the image to run, with the following spec: + ```yaml + image: + repository: nginx + tag: stable + pullPolicy: IfNotPresent + ``` +- Exposes the named port `http` as port 80 +- Lays out the compute resources using `.Values.resources` + +Example use: + +```yaml +{{- template "common.deployment" (list . "mychart.deployment") -}} +{{- define "mychart.deployment" -}} +## Define overrides for your Deployment resource here, e.g. +spec: + template: + spec: + containers: + - {{ template "common.container" (list . "mychart.deployment.container") }} +{{- end -}} +{{- define "mychart.deployment.container" -}} +## Define overrides for your Container here, e.g. +livenessProbe: + httpGet: + path: / + port: 80 +readinessProbe: + httpGet: + path: / + port: 80 +{{- end -}} +``` + +The above example creates a `Deployment` resource which makes use of the +`common.container` template to populate the PodSpec's container list. The usage +of this template is similar to the other resources, you must define and +reference a template that contains overrides for the container object. + +The most important part of a container definition is the image you want to run. +As mentioned above, this is derived from `.Values.image` by default. It is a +best practice to define the image, tag and pull policy in your charts' values as +this makes it easy for an operator to change the image registry, or use a +specific tag or version. Another example of configuration that should be exposed +to chart operators is the container's required compute resources, as this is +also very specific to an operators environment. An example `values.yaml` for +your chart could look like: + +```yaml +image: + repository: nginx + tag: stable + pullPolicy: IfNotPresent +resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi +``` + +The output of running the above values through the earlier template is: + +```yaml +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + labels: + app.kubernetes.io/name: deployment + helm.sh/chart: deployment-0.1.0 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/instance: release-name + name: release-name-deployment +spec: + template: + metadata: + labels: + app.kubernetes.io/name: deployment + spec: + containers: + - image: nginx:stable + imagePullPolicy: IfNotPresent + livenessProbe: + httpGet: + path: / + port: 80 + name: deployment + ports: + - containerPort: 80 + name: http + readinessProbe: + httpGet: + path: / + port: 80 + resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi +``` + +## `common.configmap` + +The `common.configmap` template creates an empty `ConfigMap` resource that you +can override with your configuration. + +Example use: + +```yaml +{{- template "common.configmap" (list . "mychart.configmap") -}} +{{- define "mychart.configmap" -}} +data: + zeus: cat + athena: cat + julius: cat + one: |- + {{ .Files.Get "file1.txt" }} +{{- end -}} +``` + +Output: + +```yaml +apiVersion: v1 +data: + athena: cat + julius: cat + one: This is a file. + zeus: cat +kind: ConfigMap +metadata: + labels: + app.kubernetes.io/name: configmap + helm.sh/chart: configmap-0.1.0 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/instance: release-name + name: release-name-configmap +``` + +## `common.secret` + +The `common.secret` template creates an empty `Secret` resource that you +can override with your secrets. + +Example use: + +```yaml +{{- template "common.secret" (list . "mychart.secret") -}} +{{- define "mychart.secret" -}} +data: + zeus: {{ print "cat" | b64enc }} + athena: {{ print "cat" | b64enc }} + julius: {{ print "cat" | b64enc }} + one: |- + {{ .Files.Get "file1.txt" | b64enc }} +{{- end -}} +``` + +Output: + +```yaml +apiVersion: v1 +data: + athena: Y2F0 + julius: Y2F0 + one: VGhpcyBpcyBhIGZpbGUuCg== + zeus: Y2F0 +kind: Secret +metadata: + labels: + app.kubernetes.io/name: secret + helm.sh/chart: secret-0.1.0 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/instance: release-name + name: release-name-secret +type: Opaque +``` + +## `common.ingress` + +The `common.ingress` template is designed to give you a well-defined `Ingress` +resource, that can be configured using `.Values.ingress`. An example values file +that can be used to configure the `Ingress` resource is: + +```yaml +ingress: + hosts: + - chart-example.local + annotations: + kubernetes.io/ingress.class: nginx + kubernetes.io/tls-acme: "true" + tls: + - secretName: chart-example-tls + hosts: + - chart-example.local +``` + +Example use: + +```yaml +{{- template "common.ingress" (list . "mychart.ingress") -}} +{{- define "mychart.ingress" -}} +{{- end -}} +``` + +Output: + +```yaml +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: nginx + kubernetes.io/tls-acme: "true" + labels: + app.kubernetes.io/name: ingress + helm.sh/chart: ingress-0.1.0 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/instance: release-name + name: release-name-ingress +spec: + rules: + - host: chart-example.local + http: + paths: + - backend: + serviceName: release-name-ingress + servicePort: 80 + path: / + tls: + - hosts: + - chart-example.local + secretName: chart-example-tls +``` + +## `common.persistentvolumeclaim` + +`common.persistentvolumeclaim` can be used to easily add a +`PersistentVolumeClaim` resource to your chart that can be configured using +`.Values.persistence`: + +| Value | Description | +| ------------------------- | ------------------------------------------------------------------------------------------------------- | +| persistence.enabled | Whether or not to claim a persistent volume. If false, `common.volume.pvc` will use an emptyDir instead | +| persistence.storageClass | `StorageClass` name | +| persistence.accessMode | Access mode for persistent volume | +| persistence.size | Size of persistent volume | +| persistence.existingClaim | If defined, `PersistentVolumeClaim` is not created and `common.volume.pvc` helper uses this claim | + +An example values file that can be used to configure the +`PersistentVolumeClaim` resource is: + +```yaml +persistence: + enabled: true + storageClass: fast + accessMode: ReadWriteOnce + size: 8Gi +``` + +Example use: + +```yaml +{{- template "common.persistentvolumeclaim" (list . "mychart.persistentvolumeclaim") -}} +{{- define "mychart.persistentvolumeclaim" -}} +{{- end -}} +``` + +Output: + +```yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + labels: + app.kubernetes.io/name: persistentvolumeclaim + helm.sh/chart: persistentvolumeclaim-0.1.0 + app.kubernetes.io/managed-by: Helm + app.kubernetes.io/instance: release-name + name: release-name-persistentvolumeclaim +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 8Gi + storageClassName: "fast" +``` + +## Partial API Objects + +When writing Kubernetes resources, you may find the following helpers useful to +construct parts of the spec. + +### EnvVar + +Use the EnvVar helpers within a container spec to simplify specifying key-value +environment variables or referencing secrets as values. + +Example Use: + +```yaml +{{- template "common.deployment" (list . "mychart.deployment") -}} +{{- define "mychart.deployment" -}} +spec: + template: + spec: + containers: + - {{ template "common.container" (list . "mychart.deployment.container") }} +{{- end -}} +{{- define "mychart.deployment.container" -}} +{{- $fullname := include "common.fullname" . -}} +env: +- {{ template "common.envvar.value" (list "ZEUS" "cat") }} +- {{ template "common.envvar.secret" (list "ATHENA" "secret-name" "athena") }} +{{- end -}} +``` + +Output: + +```yaml +... + spec: + containers: + - env: + - name: ZEUS + value: cat + - name: ATHENA + valueFrom: + secretKeyRef: + key: athena + name: secret-name +... +``` + +### Volume + +Use the Volume helpers within a `Deployment` spec to help define ConfigMap and +PersistentVolumeClaim volumes. + +Example Use: + +```yaml +{{- template "common.deployment" (list . "mychart.deployment") -}} +{{- define "mychart.deployment" -}} +spec: + template: + spec: + volumes: + - {{ template "common.volume.configMap" (list "config" "configmap-name") }} + - {{ template "common.volume.pvc" (list "data" "pvc-name" .Values.persistence) }} +{{- end -}} +``` + +Output: + +```yaml +... + spec: + volumes: + - configMap: + name: configmap-name + name: config + - name: data + persistentVolumeClaim: + claimName: pvc-name +... +``` + +The `common.volume.pvc` helper uses the following configuration from the `.Values.persistence` object: + +| Value | Description | +| ------------------------- | ----------------------------------------------------- | +| persistence.enabled | If false, creates an `emptyDir` instead | +| persistence.existingClaim | If set, uses this instead of the passed in claim name | + +## Utilities + +### `common.fullname` + +The `common.fullname` template generates a name suitable for the `name:` field +in Kubernetes metadata. It is used like this: + +```yaml +name: {{ template "common.fullname" . }} +``` + +The following different values can influence it: + +```yaml +# By default, fullname uses '{{ .Release.Name }}-{{ .Chart.Name }}'. This +# overrides that and uses the given string instead. +fullnameOverride: "some-name" + +# This adds a prefix +fullnamePrefix: "pre-" +# This appends a suffix +fullnameSuffix: "-suf" + +# Global versions of the above +global: + fullnamePrefix: "pp-" + fullnameSuffix: "-ps" +``` + +Example output: + +```yaml +--- +# with the values above +name: pp-pre-some-name-suf-ps + +--- +# the default, for release "happy-panda" and chart "wordpress" +name: happy-panda-wordpress +``` + +Output of this function is truncated at 54 characters, which leaves 9 additional +characters for customized overriding. Thus you can easily extend this name +in your own charts: + +```yaml +{{- define "my.fullname" -}} + {{ template "common.fullname" . }}-my-stuff +{{- end -}} +``` + +### `common.fullname.unique` + +The `common.fullname.unique` variant of fullname appends a unique seven-character +sequence to the end of the common name field. + +This takes all of the same parameters as `common.fullname` + +Example template: + +```yaml +uniqueName: {{ template "common.fullname.unique" . }} +``` + +Example output: + +```yaml +uniqueName: release-name-fullname-jl0dbwx +``` + +It is also impacted by the prefix and suffix definitions, as well as by +`.Values.fullnameOverride` + +Note that the effective maximum length of this function is 63 characters, not 54. + +### `common.name` + +The `common.name` template generates a name suitable for the `app` label. It is used like this: + +```yaml +app: {{ template "common.name" . }} +``` + +The following different values can influence it: + +```yaml +# By default, name uses '{{ .Chart.Name }}'. This +# overrides that and uses the given string instead. +nameOverride: "some-name" + +# This adds a prefix +namePrefix: "pre-" +# This appends a suffix +nameSuffix: "-suf" + +# Global versions of the above +global: + namePrefix: "pp-" + nameSuffix: "-ps" +``` + +Example output: + +```yaml +--- +# with the values above +name: pp-pre-some-name-suf-ps + +--- +# the default, for chart "wordpress" +name: wordpress +``` + +Output of this function is truncated at 54 characters, which leaves 9 additional +characters for customized overriding. Thus you can easily extend this name +in your own charts: + +```yaml +{{- define "my.name" -}} + {{ template "common.name" . }}-my-stuff +{{- end -}} +``` + +### `common.metadata` + +The `common.metadata` helper generates the `metadata:` section of a Kubernetes +resource. + +This takes three objects: + - .top: top context + - .fullnameOverride: override the fullname with this name + - .metadata + - .labels: key/value list of labels + - .annotations: key/value list of annotations + - .hook: name(s) of hook(s) + +It generates standard labels, annotations, hooks, and a name field. + +Example template: + +```yaml +{{ template "common.metadata" (dict "top" . "metadata" .Values.bio) }} +--- +{{ template "common.metadata" (dict "top" . "metadata" .Values.pet "fullnameOverride" .Values.pet.fullnameOverride) }} +``` + +Example values: + +```yaml +bio: + name: example + labels: + first: matt + last: butcher + nick: technosophos + annotations: + format: bio + destination: archive + hook: pre-install + +pet: + fullnameOverride: Zeus + +``` + +Example output: + +```yaml +metadata: + name: release-name-metadata + labels: + app.kubernetes.io/name: metadata + app.kubernetes.io/managed-by: "Helm" + app.kubernetes.io/instance: "release-name" + helm.sh/chart: metadata-0.1.0 + first: "matt" + last: "butcher" + nick: "technosophos" + annotations: + "destination": "archive" + "format": "bio" + "helm.sh/hook": "pre-install" +--- +metadata: + name: Zeus + labels: + app.kubernetes.io/name: metadata + app.kubernetes.io/managed-by: "Helm" + app.kubernetes.io/instance: "release-name" + helm.sh/chart: metadata-0.1.0 + annotations: +``` + +Most of the common templates that define a resource type (e.g. `common.configmap` +or `common.job`) use this to generate the metadata, which means they inherit +the same `labels`, `annotations`, `nameOverride`, and `hook` fields. + +### `common.labelize` + +`common.labelize` turns a map into a set of labels. + +Example template: + +```yaml +{{- $map := dict "first" "1" "second" "2" "third" "3" -}} +{{- template "common.labelize" $map -}} +``` + +Example output: + +```yaml +first: "1" +second: "2" +third: "3" +``` + +### `common.labels.standard` + +`common.labels.standard` prints the standard set of labels. + +Example usage: + +``` +{{ template "common.labels.standard" . }} +``` + +Example output: + +```yaml +app.kubernetes.io/name: labelizer +app.kubernetes.io/managed-by: "Tiller" +app.kubernetes.io/instance: "release-name" +helm.sh/chart: labelizer-0.1.0 +``` + +### `common.hook` + +The `common.hook` template is a convenience for defining hooks. + +Example template: + +```yaml +{{ template "common.hook" "pre-install,post-install" }} +``` + +Example output: + +```yaml +"helm.sh/hook": "pre-install,post-install" +``` + +### `common.chartref` + +The `common.chartref` helper prints the chart name and version, escaped to be +legal in a Kubernetes label field. + +Example template: + +```yaml +chartref: {{ template "common.chartref" . }} +``` + +For the chart `foo` with version `1.2.3-beta.55+1234`, this will render: + +```yaml +chartref: foo-1.2.3-beta.55_1234 +``` + +(Note that `+` is an illegal character in label values) diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_chartref.tpl b/cmd/helm/testdata/testcharts/lib-chart/templates/_chartref.tpl new file mode 100644 index 00000000..e6c14866 --- /dev/null +++ b/cmd/helm/testdata/testcharts/lib-chart/templates/_chartref.tpl @@ -0,0 +1,14 @@ +{{- /* +common.chartref prints a chart name and version. + +It does minimal escaping for use in Kubernetes labels. + +Example output: + + zookeeper-1.2.3 + wordpress-3.2.1_20170219 + +*/ -}} +{{- define "common.chartref" -}} + {{- replace "+" "_" .Chart.Version | printf "%s-%s" .Chart.Name -}} +{{- end -}} diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_configmap.yaml b/cmd/helm/testdata/testcharts/lib-chart/templates/_configmap.yaml new file mode 100644 index 00000000..03dbbf85 --- /dev/null +++ b/cmd/helm/testdata/testcharts/lib-chart/templates/_configmap.yaml @@ -0,0 +1,9 @@ +{{- define "common.configmap.tpl" -}} +apiVersion: v1 +kind: ConfigMap +{{ template "common.metadata" . }} +data: {} +{{- end -}} +{{- define "common.configmap" -}} +{{- template "common.util.merge" (append . "common.configmap.tpl") -}} +{{- end -}} diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_container.yaml b/cmd/helm/testdata/testcharts/lib-chart/templates/_container.yaml new file mode 100644 index 00000000..540eb0e6 --- /dev/null +++ b/cmd/helm/testdata/testcharts/lib-chart/templates/_container.yaml @@ -0,0 +1,15 @@ +{{- define "common.container.tpl" -}} +name: {{ .Chart.Name }} +image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" +imagePullPolicy: {{ .Values.image.pullPolicy }} +ports: +- name: http + containerPort: 80 +resources: +{{ toYaml .Values.resources | indent 2 }} +{{- end -}} +{{- define "common.container" -}} +{{- /* clear new line so indentation works correctly */ -}} +{{- println "" -}} +{{- include "common.util.merge" (append . "common.container.tpl") | indent 8 -}} +{{- end -}} diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_deployment.yaml b/cmd/helm/testdata/testcharts/lib-chart/templates/_deployment.yaml new file mode 100644 index 00000000..e99a8cd3 --- /dev/null +++ b/cmd/helm/testdata/testcharts/lib-chart/templates/_deployment.yaml @@ -0,0 +1,18 @@ +{{- define "common.deployment.tpl" -}} +apiVersion: extensions/v1beta1 +kind: Deployment +{{ template "common.metadata" . }} +spec: + template: + metadata: + labels: + app.kubernetes.io/name: {{ template "common.name" . }} + app.kubernetes.io/instance: {{ .Release.Name | quote }} + spec: + containers: + - +{{ include "common.container.tpl" . | indent 8 }} +{{- end -}} +{{- define "common.deployment" -}} +{{- template "common.util.merge" (append . "common.deployment.tpl") -}} +{{- end -}} diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_envvar.tpl b/cmd/helm/testdata/testcharts/lib-chart/templates/_envvar.tpl new file mode 100644 index 00000000..709251f8 --- /dev/null +++ b/cmd/helm/testdata/testcharts/lib-chart/templates/_envvar.tpl @@ -0,0 +1,31 @@ +{{- define "common.envvar.value" -}} + {{- $name := index . 0 -}} + {{- $value := index . 1 -}} + + name: {{ $name }} + value: {{ default "" $value | quote }} +{{- end -}} + +{{- define "common.envvar.configmap" -}} + {{- $name := index . 0 -}} + {{- $configMapName := index . 1 -}} + {{- $configMapKey := index . 2 -}} + + name: {{ $name }} + valueFrom: + configMapKeyRef: + name: {{ $configMapName }} + key: {{ $configMapKey }} +{{- end -}} + +{{- define "common.envvar.secret" -}} + {{- $name := index . 0 -}} + {{- $secretName := index . 1 -}} + {{- $secretKey := index . 2 -}} + + name: {{ $name }} + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: {{ $secretKey }} +{{- end -}} diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_fullname.tpl b/cmd/helm/testdata/testcharts/lib-chart/templates/_fullname.tpl new file mode 100644 index 00000000..2da6cdf1 --- /dev/null +++ b/cmd/helm/testdata/testcharts/lib-chart/templates/_fullname.tpl @@ -0,0 +1,39 @@ +{{- /* +fullname defines a suitably unique name for a resource by combining +the release name and the chart name. + +The prevailing wisdom is that names should only contain a-z, 0-9 plus dot (.) and dash (-), and should +not exceed 63 characters. + +Parameters: + +- .Values.fullnameOverride: Replaces the computed name with this given name +- .Values.fullnamePrefix: Prefix +- .Values.global.fullnamePrefix: Global prefix +- .Values.fullnameSuffix: Suffix +- .Values.global.fullnameSuffix: Global suffix + +The applied order is: "global prefix + prefix + name + suffix + global suffix" + +Usage: 'name: "{{- template "common.fullname" . -}}"' +*/ -}} +{{- define "common.fullname"}} + {{- $global := default (dict) .Values.global -}} + {{- $base := default (printf "%s-%s" .Release.Name .Chart.Name) .Values.fullnameOverride -}} + {{- $gpre := default "" $global.fullnamePrefix -}} + {{- $pre := default "" .Values.fullnamePrefix -}} + {{- $suf := default "" .Values.fullnameSuffix -}} + {{- $gsuf := default "" $global.fullnameSuffix -}} + {{- $name := print $gpre $pre $base $suf $gsuf -}} + {{- $name | lower | trunc 54 | trimSuffix "-" -}} +{{- end -}} + +{{- /* +common.fullname.unique adds a random suffix to the unique name. + +This takes the same parameters as common.fullname + +*/ -}} +{{- define "common.fullname.unique" -}} + {{ template "common.fullname" . }}-{{ randAlphaNum 7 | lower }} +{{- end }} diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_ingress.yaml b/cmd/helm/testdata/testcharts/lib-chart/templates/_ingress.yaml new file mode 100644 index 00000000..78411e15 --- /dev/null +++ b/cmd/helm/testdata/testcharts/lib-chart/templates/_ingress.yaml @@ -0,0 +1,27 @@ +{{- define "common.ingress.tpl" -}} +apiVersion: extensions/v1beta1 +kind: Ingress +{{ template "common.metadata" . }} + {{- if .Values.ingress.annotations }} + annotations: + {{ include "common.annotate" .Values.ingress.annotations | indent 4 }} + {{- end }} +spec: + rules: + {{- range $host := .Values.ingress.hosts }} + - host: {{ $host }} + http: + paths: + - path: / + backend: + serviceName: {{ template "common.fullname" $ }} + servicePort: 80 + {{- end }} + {{- if .Values.ingress.tls }} + tls: +{{ toYaml .Values.ingress.tls | indent 4 }} + {{- end -}} +{{- end -}} +{{- define "common.ingress" -}} +{{- template "common.util.merge" (append . "common.ingress.tpl") -}} +{{- end -}} diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_metadata.yaml b/cmd/helm/testdata/testcharts/lib-chart/templates/_metadata.yaml new file mode 100644 index 00000000..f96ed09f --- /dev/null +++ b/cmd/helm/testdata/testcharts/lib-chart/templates/_metadata.yaml @@ -0,0 +1,10 @@ +{{- /* +common.metadata creates a standard metadata header. +It creates a 'metadata:' section with name and labels. +*/ -}} +{{ define "common.metadata" -}} +metadata: + name: {{ template "common.fullname" . }} + labels: +{{ include "common.labels.standard" . | indent 4 -}} +{{- end -}} diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_metadata_annotations.tpl b/cmd/helm/testdata/testcharts/lib-chart/templates/_metadata_annotations.tpl new file mode 100644 index 00000000..dffe1eca --- /dev/null +++ b/cmd/helm/testdata/testcharts/lib-chart/templates/_metadata_annotations.tpl @@ -0,0 +1,18 @@ +{{- /* +common.hook defines a hook. + +This is to be used in a 'metadata.annotations' section. + +This should be called as 'template "common.metadata.hook" "post-install"' + +Any valid hook may be passed in. Separate multiple hooks with a ",". +*/ -}} +{{- define "common.hook" -}} +"helm.sh/hook": {{printf "%s" . | quote}} +{{- end -}} + +{{- define "common.annotate" -}} +{{- range $k, $v := . }} +{{ $k | quote }}: {{ $v | quote }} +{{- end -}} +{{- end -}} diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_metadata_labels.tpl b/cmd/helm/testdata/testcharts/lib-chart/templates/_metadata_labels.tpl new file mode 100644 index 00000000..bcb8cdaa --- /dev/null +++ b/cmd/helm/testdata/testcharts/lib-chart/templates/_metadata_labels.tpl @@ -0,0 +1,28 @@ +{{- /* +common.labelize takes a dict or map and generates labels. + +Values will be quoted. Keys will not. + +Example output: + + first: "Matt" + last: "Butcher" + +*/ -}} +{{- define "common.labelize" -}} +{{- range $k, $v := . }} +{{ $k }}: {{ $v | quote }} +{{- end -}} +{{- end -}} + +{{- /* +common.labels.standard prints the standard Helm labels. + +The standard labels are frequently used in metadata. +*/ -}} +{{- define "common.labels.standard" -}} +app.kubernetes.io/name: {{ template "common.name" . }} +helm.sh/chart: {{ template "common.chartref" . }} +app.kubernetes.io/managed-by: {{ .Release.Service | quote }} +app.kubernetes.io/instance: {{ .Release.Name | quote }} +{{- end -}} diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_name.tpl b/cmd/helm/testdata/testcharts/lib-chart/templates/_name.tpl new file mode 100644 index 00000000..1d42fb06 --- /dev/null +++ b/cmd/helm/testdata/testcharts/lib-chart/templates/_name.tpl @@ -0,0 +1,29 @@ +{{- /* +name defines a template for the name of the chart. It should be used for the `app` label. +This is common practice in many Kubernetes manifests, and is not Helm-specific. + +The prevailing wisdom is that names should only contain a-z, 0-9 plus dot (.) and dash (-), and should +not exceed 63 characters. + +Parameters: + +- .Values.nameOverride: Replaces the computed name with this given name +- .Values.namePrefix: Prefix +- .Values.global.namePrefix: Global prefix +- .Values.nameSuffix: Suffix +- .Values.global.nameSuffix: Global suffix + +The applied order is: "global prefix + prefix + name + suffix + global suffix" + +Usage: 'name: "{{- template "common.name" . -}}"' +*/ -}} +{{- define "common.name"}} + {{- $global := default (dict) .Values.global -}} + {{- $base := default .Chart.Name .Values.nameOverride -}} + {{- $gpre := default "" $global.namePrefix -}} + {{- $pre := default "" .Values.namePrefix -}} + {{- $suf := default "" .Values.nameSuffix -}} + {{- $gsuf := default "" $global.nameSuffix -}} + {{- $name := print $gpre $pre $base $suf $gsuf -}} + {{- $name | lower | trunc 54 | trimSuffix "-" -}} +{{- end -}} diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_persistentvolumeclaim.yaml b/cmd/helm/testdata/testcharts/lib-chart/templates/_persistentvolumeclaim.yaml new file mode 100644 index 00000000..6c1578c7 --- /dev/null +++ b/cmd/helm/testdata/testcharts/lib-chart/templates/_persistentvolumeclaim.yaml @@ -0,0 +1,24 @@ +{{- define "common.persistentvolumeclaim.tpl" -}} +apiVersion: v1 +kind: PersistentVolumeClaim +{{ template "common.metadata" . }} +spec: + accessModes: + - {{ .Values.persistence.accessMode | quote }} + resources: + requests: + storage: {{ .Values.persistence.size | quote }} +{{- if .Values.persistence.storageClass }} +{{- if (eq "-" .Values.persistence.storageClass) }} + storageClassName: "" +{{- else }} + storageClassName: "{{ .Values.persistence.storageClass }}" +{{- end }} +{{- end }} +{{- end -}} +{{- define "common.persistentvolumeclaim" -}} +{{- $top := first . -}} +{{- if and $top.Values.persistence.enabled (not $top.Values.persistence.existingClaim) -}} +{{- template "common.util.merge" (append . "common.persistentvolumeclaim.tpl") -}} +{{- end -}} +{{- end -}} diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_secret.yaml b/cmd/helm/testdata/testcharts/lib-chart/templates/_secret.yaml new file mode 100644 index 00000000..0615d35c --- /dev/null +++ b/cmd/helm/testdata/testcharts/lib-chart/templates/_secret.yaml @@ -0,0 +1,10 @@ +{{- define "common.secret.tpl" -}} +apiVersion: v1 +kind: Secret +{{ template "common.metadata" . }} +type: Opaque +data: {} +{{- end -}} +{{- define "common.secret" -}} +{{- template "common.util.merge" (append . "common.secret.tpl") -}} +{{- end -}} diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_service.yaml b/cmd/helm/testdata/testcharts/lib-chart/templates/_service.yaml new file mode 100644 index 00000000..b9dfc378 --- /dev/null +++ b/cmd/helm/testdata/testcharts/lib-chart/templates/_service.yaml @@ -0,0 +1,17 @@ +{{- define "common.service.tpl" -}} +apiVersion: v1 +kind: Service +{{ template "common.metadata" . }} +spec: + type: {{ .Values.service.type }} + ports: + - name: http + port: 80 + targetPort: http + selector: + app.kubernetes.io/name: {{ template "common.name" . }} + app.kubernetes.io/instance: {{ .Release.Name | quote }} +{{- end -}} +{{- define "common.service" -}} +{{- template "common.util.merge" (append . "common.service.tpl") -}} +{{- end -}} diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_util.tpl b/cmd/helm/testdata/testcharts/lib-chart/templates/_util.tpl new file mode 100644 index 00000000..a7d4cc75 --- /dev/null +++ b/cmd/helm/testdata/testcharts/lib-chart/templates/_util.tpl @@ -0,0 +1,15 @@ +{{- /* +common.util.merge will merge two YAML templates and output the result. + +This takes an array of three values: +- the top context +- the template name of the overrides (destination) +- the template name of the base (source) + +*/ -}} +{{- define "common.util.merge" -}} +{{- $top := first . -}} +{{- $overrides := fromYaml (include (index . 1) $top) | default (dict ) -}} +{{- $tpl := fromYaml (include (index . 2) $top) | default (dict ) -}} +{{- toYaml (merge $overrides $tpl) -}} +{{- end -}} diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_volume.tpl b/cmd/helm/testdata/testcharts/lib-chart/templates/_volume.tpl new file mode 100644 index 00000000..521a1f48 --- /dev/null +++ b/cmd/helm/testdata/testcharts/lib-chart/templates/_volume.tpl @@ -0,0 +1,22 @@ +{{- define "common.volume.configMap" -}} + {{- $name := index . 0 -}} + {{- $configMapName := index . 1 -}} + + name: {{ $name }} + configMap: + name: {{ $configMapName }} +{{- end -}} + +{{- define "common.volume.pvc" -}} + {{- $name := index . 0 -}} + {{- $claimName := index . 1 -}} + {{- $persistence := index . 2 -}} + + name: {{ $name }} + {{- if $persistence.enabled }} + persistentVolumeClaim: + claimName: {{ $persistence.existingClaim | default $claimName }} + {{- else }} + emptyDir: {} + {{- end -}} +{{- end -}} diff --git a/cmd/helm/testdata/testcharts/lib-chart/values.yaml b/cmd/helm/testdata/testcharts/lib-chart/values.yaml new file mode 100644 index 00000000..b7cf514d --- /dev/null +++ b/cmd/helm/testdata/testcharts/lib-chart/values.yaml @@ -0,0 +1,4 @@ +# Default values for commons. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name: value diff --git a/cmd/helm/testdata/testcharts/object-order/Chart.yaml b/cmd/helm/testdata/testcharts/object-order/Chart.yaml new file mode 100644 index 00000000..d2eb42fd --- /dev/null +++ b/cmd/helm/testdata/testcharts/object-order/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: object-order +description: Test ordering of manifests in output +type: application +version: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/object-order/templates/01-a.yml b/cmd/helm/testdata/testcharts/object-order/templates/01-a.yml new file mode 100644 index 00000000..32aa4a47 --- /dev/null +++ b/cmd/helm/testdata/testcharts/object-order/templates/01-a.yml @@ -0,0 +1,57 @@ +# 1 +kind: NetworkPolicy +apiVersion: networking.k8s.io/v1 +metadata: + name: first +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress + +--- + +# 2 +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: second +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress + +--- + +# 3 +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: third +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress + +--- + +# 4 (Deployment should come after all NetworkPolicy manifests, since 'helm template' outputs in install order) +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fourth +spec: + selector: + matchLabels: + pod: fourth + replicas: 1 + template: + metadata: + labels: + pod: fourth + spec: + containers: + - name: hello-world + image: gcr.io/google-samples/node-hello:1.0 diff --git a/cmd/helm/testdata/testcharts/object-order/templates/02-b.yml b/cmd/helm/testdata/testcharts/object-order/templates/02-b.yml new file mode 100644 index 00000000..895db8cf --- /dev/null +++ b/cmd/helm/testdata/testcharts/object-order/templates/02-b.yml @@ -0,0 +1,143 @@ +# 5 +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: fifth +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress + +--- + +# 6 (implementation detail: currently, 'helm template' outputs hook manifests last; and yes, NetworkPolicy won't make a reasonable hook, this is just a dummy unit test manifest) +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + annotations: + "helm.sh/hook": pre-install + name: sixth +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress + +--- + +# 7 +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: seventh +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress + +--- + +# 8 +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: eighth +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress + +--- + +# 9 +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: ninth +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress + +--- + +# 10 +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: tenth +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress + +--- + +# 11 +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: eleventh +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress + +--- + +# 12 +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: twelfth +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress + +--- + +# 13 +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: thirteenth +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress + +--- + +# 14 +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: fourteenth +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress + +--- + +# 15 (11th object within 02-b.yml, in order to test `SplitManifests` which assigns `manifest-10` +# to this object which should then come *after* `manifest-9`) +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: fifteenth +spec: + podSelector: {} + policyTypes: + - Egress + - Ingress diff --git a/cmd/helm/testdata/testcharts/object-order/values.yaml b/cmd/helm/testdata/testcharts/object-order/values.yaml new file mode 100644 index 00000000..e69de29b diff --git a/cmd/helm/testdata/testcharts/oci-dependent-chart-0.1.0.tgz b/cmd/helm/testdata/testcharts/oci-dependent-chart-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..7b4cbeccc1c06dd27ff6376bcc6607ad03fa3839 GIT binary patch literal 3599 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 V$A9GbR{#J2|NqD05>xDc zVQyr3R8em|NM&qo0PK{zYV$x4g;nb*23$-3NWU)N&csC^NtY(&SQ_DlU9Ff|8T|G^ za%V#lh=@thS7=o1ZFbK&gK#2jnUs^}ND}@%OyBfO&PEG?h*+29ToLiQVwP7?_P@yL zI1Ma8b~7i_FmV`{SsQ%M$8b5@3*jnN45@T9YE&=p2h=9&w(}W z$?+C$9uN+r2y|ofk(Ta0{KWJPp`$V@VjMq`0UE1~Q@$ zJRGL~X*n=`@No8{Kwvjm3an`imvnLGDc zVQyr3R8em|NM&qo0PLDkZ|gJ^$M@_{aZn#FRNy#Cn@W{HLc2&H9zYt}D{_;w#CMME z*-p2C;IpHq>Zr2P(h^ct_3t6woXoMc{Nn#vE0 zC6^7l|K|p9$p1KD9sg4{O@seKuw~Dj0F+WMxSYf9e~@iW|9My1*>&#u1de0lKdn&K zqEy;q@8>wU{>RgI{-;Use+WkKGxr{?a^N+rF93M1P{E=RvJk2S!}CX8qN8L4zf{73 zYmCv>J8%_B+1d{^_jx5$`6XDC+>0fG;lA2x=c*thP+0QS`U;;Lix1)j3wRd3g3rF7 z@RO3SpjG>qt093w3sNYggnqpH_0DUHWCXXmuC;>u+dC+Pb%d6}N9}e!d_ot$*{I!a z8mcl{Z8pEUrHcOUXu8l>~?UXRN#aXIU2N|3OfVl)=^OWsm>8l?2D<|16ut!T%u$`9I|U-To)N6#$3x zf711T8VCOep||{>kk0q>=bl;M->}dBz6yX7^8aiS>i-JA^%?@`}`j+|99X2%Vv{l@P7!xD?8WCKL5{}|HJdY cK?wQ(y7PY^5V&6c3IG8A|GIljmH;#W0IyW9zyJUM literal 0 HcmV?d00001 diff --git a/cmd/helm/testdata/testcharts/reqtest/.helmignore b/cmd/helm/testdata/testcharts/reqtest/.helmignore new file mode 100644 index 00000000..f0c13194 --- /dev/null +++ b/cmd/helm/testdata/testcharts/reqtest/.helmignore @@ -0,0 +1,21 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj diff --git a/cmd/helm/testdata/testcharts/reqtest/Chart.lock b/cmd/helm/testdata/testcharts/reqtest/Chart.lock new file mode 100755 index 00000000..ab1ae8cc --- /dev/null +++ b/cmd/helm/testdata/testcharts/reqtest/Chart.lock @@ -0,0 +1,3 @@ +dependencies: [] +digest: Not implemented +generated: 2016-09-13T17:25:17.593788787-06:00 diff --git a/cmd/helm/testdata/testcharts/reqtest/Chart.yaml b/cmd/helm/testdata/testcharts/reqtest/Chart.yaml new file mode 100644 index 00000000..07b6e2c9 --- /dev/null +++ b/cmd/helm/testdata/testcharts/reqtest/Chart.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +description: A Helm chart for Kubernetes +name: reqtest +version: 0.1.0 +dependencies: + - name: reqsubchart + version: 0.1.0 + repository: "https://example.com/charts" + - name: reqsubchart2 + version: 0.2.0 + repository: "https://example.com/charts" + - name: reqsubchart3 + version: ">=0.1.0" + repository: "https://example.com/charts" diff --git a/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/.helmignore b/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/.helmignore new file mode 100644 index 00000000..f0c13194 --- /dev/null +++ b/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/.helmignore @@ -0,0 +1,21 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj diff --git a/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/Chart.yaml b/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/Chart.yaml new file mode 100644 index 00000000..35613553 --- /dev/null +++ b/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: A Helm chart for Kubernetes +name: reqsubchart +version: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/values.yaml b/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/values.yaml new file mode 100644 index 00000000..0f0b63f2 --- /dev/null +++ b/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/values.yaml @@ -0,0 +1,4 @@ +# Default values for reqsubchart. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name: value diff --git a/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/.helmignore b/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/.helmignore new file mode 100644 index 00000000..f0c13194 --- /dev/null +++ b/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/.helmignore @@ -0,0 +1,21 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj diff --git a/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/Chart.yaml b/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/Chart.yaml new file mode 100644 index 00000000..5b927737 --- /dev/null +++ b/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: A Helm chart for Kubernetes +name: reqsubchart2 +version: 0.2.0 diff --git a/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/values.yaml b/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/values.yaml new file mode 100644 index 00000000..0f0b63f2 --- /dev/null +++ b/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/values.yaml @@ -0,0 +1,4 @@ +# Default values for reqsubchart. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name: value diff --git a/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart3-0.2.0.tgz b/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart3-0.2.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..37962b0abcfcf4eb6920f3304f56e1452f079f04 GIT binary patch literal 498 zcmVDc zVQyr3R8em|NM&qo0PL1gi_<_5fbV&K#YkSX$Y#?J3il>GLGeMM(pT9e(`2uk-F0S@ zdW!ybX-f63A}3f*4)ZaW*2-P|KV2eU?Eqk|1)>|mo3`PG|m&AbB+7_FS6nn|BGz${~<`=osJR7F+>lCcMrQ39hAMXwQ&su zU2S!PEJXtETf@M82%aJXw`eUi)|b%fShdDA&w*We+Lo8%tVmih{ z`$CqVDIMdn&^GBl9EUu(^yfsSuC{%Ejj_mLCV3acOqO~pW-@j`%)W^P)|!m(8D3w# oWFZ6jYpkM>rbe9}{zv|Lx=AM{CLR;t0{{U3|IDE~9sm#k0Pxi6ng9R* literal 0 HcmV?d00001 diff --git a/cmd/helm/testdata/testcharts/reqtest/values.yaml b/cmd/helm/testdata/testcharts/reqtest/values.yaml new file mode 100644 index 00000000..d57f76b0 --- /dev/null +++ b/cmd/helm/testdata/testcharts/reqtest/values.yaml @@ -0,0 +1,4 @@ +# Default values for reqtest. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name: value diff --git a/cmd/helm/testdata/testcharts/signtest-0.1.0.tgz b/cmd/helm/testdata/testcharts/signtest-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..c74e5b0ef5cbb7989379cb291937147a5cdef34b GIT binary patch literal 973 zcmV;;12X&{iwG0|00000|0w_~VMtOiV@ORlOnEsqVl!4SWK%V1T2nbTPgYhoO;>Dc zVQyr3R8em|NM&qo0PI;!Z{s!-&Y8brUgz=_Sktr}$Ea^Xvp|8iX|P!oD2ie|mc|mX z6v>j56T{7aFGN{RptN<1*ba)-bCFFAIYWuhe96m92l8R?O^z<`H5TgZ&=5k1>0}bG zLWuTN3`du{-*J36nocjyKpfnXKSAjOx-;==UG2^NM}SuTM9xd2XRsQwlzif(4e|dK zd`qf;q&gX}G!DKi7vwYr@=RkvGiXi^TQzG4KIDSE^{zVnQ|$P^LRFGKiUZike<8*# z{*Onaj{hgY=CLE|my8|%0~JgTD{>4VF*=~sVa28w)d|0wGg8BYv-qqfgS&OPO6ZZHjWOhV=w{qp0jiKm`e}7wAQ%b!RMqDWXdd{ zz>wrpXYas~!XQ@!7DN7Q9CgahK~siRbpijkj+XL)Qn;5PhyQ)W;YY33V04^WnFN*` zD5;4vetq}pE*M9QXEJoI;9%JCzjnq)X#?!#|M*4xA6<+){+|MWSN~s=Rb~wc3-mI9 zt9U}-d#TF@uqI`>sRDZ*g7ve(po$;d=kdC257cLhc~iQC{EYQ?!kG+tx!{Q@qI^B6 zYa*N;ZT^3Fe|7!CdtRgm)Ul8M6ESSZ|H-uD|49%7Iz3=v6~R4v$VijJKq-{IivA&| zCNYP39{YigFwmCVbI#buoM8S`Kh7bQj*?*9x~T;`Agsu(!ON(~nzX7OqF<=vKeEJ> z)h)9Giw+A4i^A#e;`HZiQiyBkB|M$hS&LG{ht9ST#$-&KR`}ShFIx8n|ViWC6ihh>Q4(d&FZbS zwzqfY?IgA%@H_lgnotSGashYlS&90`8}00960pc^bi03-ka*J$OZ literal 0 HcmV?d00001 diff --git a/cmd/helm/testdata/testcharts/signtest-0.1.0.tgz.prov b/cmd/helm/testdata/testcharts/signtest-0.1.0.tgz.prov new file mode 100644 index 00000000..d325bb26 --- /dev/null +++ b/cmd/helm/testdata/testcharts/signtest-0.1.0.tgz.prov @@ -0,0 +1,21 @@ +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA512 + +apiVersion: v1 +description: A Helm chart for Kubernetes +name: signtest +version: 0.1.0 + +... +files: + signtest-0.1.0.tgz: sha256:e5ef611620fb97704d8751c16bab17fedb68883bfb0edc76f78a70e9173f9b55 +-----BEGIN PGP SIGNATURE----- + +wsBcBAEBCgAQBQJcoosfCRCEO7+YH8GHYgAA220IALAs8T8NPgkcLvHu+5109cAN +BOCNPSZDNsqLZW/2Dc9cKoBG7Jen4Qad+i5l9351kqn3D9Gm6eRfAWcjfggRobV/ +9daZ19h0nl4O1muQNAkjvdgZt8MOP3+PB3I3/Tu2QCYjI579SLUmuXlcZR5BCFPR +PJy+e3QpV2PcdeU2KZLG4tjtlrq+3QC9ZHHEJLs+BVN9d46Dwo6CxJdHJrrrAkTw +M8MhA92vbiTTPRSCZI9x5qDAwJYhoq0oxLflpuL2tIlo3qVoCsaTSURwMESEHO32 +XwYG7BaVDMELWhAorBAGBGBwWFbJ1677qQ2gd9CN0COiVhekWlFRcnn60800r84= +=k9Y9 +-----END PGP SIGNATURE----- \ No newline at end of file diff --git a/cmd/helm/testdata/testcharts/signtest/.helmignore b/cmd/helm/testdata/testcharts/signtest/.helmignore new file mode 100644 index 00000000..435b756d --- /dev/null +++ b/cmd/helm/testdata/testcharts/signtest/.helmignore @@ -0,0 +1,5 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +.git diff --git a/cmd/helm/testdata/testcharts/signtest/Chart.yaml b/cmd/helm/testdata/testcharts/signtest/Chart.yaml new file mode 100644 index 00000000..f1f73723 --- /dev/null +++ b/cmd/helm/testdata/testcharts/signtest/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: A Helm chart for Kubernetes +name: signtest +version: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/signtest/alpine/Chart.yaml b/cmd/helm/testdata/testcharts/signtest/alpine/Chart.yaml new file mode 100644 index 00000000..eec26122 --- /dev/null +++ b/cmd/helm/testdata/testcharts/signtest/alpine/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +description: Deploy a basic Alpine Linux pod +home: https://helm.sh/helm +name: alpine +sources: +- https://github.com/helm/helm +version: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/signtest/alpine/README.md b/cmd/helm/testdata/testcharts/signtest/alpine/README.md new file mode 100644 index 00000000..28bebae0 --- /dev/null +++ b/cmd/helm/testdata/testcharts/signtest/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.yaml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/cmd/helm/testdata/testcharts/signtest/alpine/templates/alpine-pod.yaml b/cmd/helm/testdata/testcharts/signtest/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..5bbae10a --- /dev/null +++ b/cmd/helm/testdata/testcharts/signtest/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service}} + chartName: {{.Chart.Name}} + chartVersion: {{.Chart.Version | quote}} +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.3" + command: ["/bin/sleep","9000"] diff --git a/cmd/helm/testdata/testcharts/signtest/alpine/values.yaml b/cmd/helm/testdata/testcharts/signtest/alpine/values.yaml new file mode 100644 index 00000000..bb6c06ae --- /dev/null +++ b/cmd/helm/testdata/testcharts/signtest/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: my-alpine diff --git a/cmd/helm/testdata/testcharts/signtest/templates/pod.yaml b/cmd/helm/testdata/testcharts/signtest/templates/pod.yaml new file mode 100644 index 00000000..9b00ccaf --- /dev/null +++ b/cmd/helm/testdata/testcharts/signtest/templates/pod.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Pod +metadata: + name: signtest +spec: + restartPolicy: Never + containers: + - name: waiter + image: "alpine:3.3" + command: ["/bin/sleep","9000"] diff --git a/cmd/helm/testdata/testcharts/signtest/values.yaml b/cmd/helm/testdata/testcharts/signtest/values.yaml new file mode 100644 index 00000000..e69de29b diff --git a/cmd/helm/testdata/testcharts/subchart/Chart.yaml b/cmd/helm/testdata/testcharts/subchart/Chart.yaml new file mode 100644 index 00000000..ae844c34 --- /dev/null +++ b/cmd/helm/testdata/testcharts/subchart/Chart.yaml @@ -0,0 +1,39 @@ +apiVersion: v1 +description: A Helm chart for Kubernetes +name: subchart +version: 0.1.0 +dependencies: + - name: subcharta + repository: http://localhost:10191 + version: 0.1.0 + condition: subcharta.enabled + tags: + - front-end + - subcharta + import-values: + - child: SCAdata + parent: imported-chartA + - child: SCAdata + parent: overridden-chartA + - child: SCAdata + parent: imported-chartA-B + + - name: subchartb + repository: http://localhost:10191 + version: 0.1.0 + condition: subchartb.enabled + import-values: + - child: SCBdata + parent: imported-chartB + - child: SCBdata + parent: imported-chartA-B + - child: exports.SCBexported2 + parent: exports.SCBexported2 + # - child: exports.configmap + # parent: configmap + - configmap + - SCBexported1 + + tags: + - front-end + - subchartb diff --git a/cmd/helm/testdata/testcharts/subchart/charts/subchartA/Chart.yaml b/cmd/helm/testdata/testcharts/subchart/charts/subchartA/Chart.yaml new file mode 100644 index 00000000..be3edcef --- /dev/null +++ b/cmd/helm/testdata/testcharts/subchart/charts/subchartA/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: A Helm chart for Kubernetes +name: subcharta +version: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/subchart/charts/subchartA/templates/service.yaml b/cmd/helm/testdata/testcharts/subchart/charts/subchartA/templates/service.yaml new file mode 100644 index 00000000..27501e1e --- /dev/null +++ b/cmd/helm/testdata/testcharts/subchart/charts/subchartA/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Chart.Name }} + labels: + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.externalPort }} + targetPort: {{ .Values.service.internalPort }} + protocol: TCP + name: {{ .Values.service.name }} + selector: + app.kubernetes.io/name: {{ .Chart.Name }} diff --git a/cmd/helm/testdata/testcharts/subchart/charts/subchartA/values.yaml b/cmd/helm/testdata/testcharts/subchart/charts/subchartA/values.yaml new file mode 100644 index 00000000..f0381ae6 --- /dev/null +++ b/cmd/helm/testdata/testcharts/subchart/charts/subchartA/values.yaml @@ -0,0 +1,17 @@ +# Default values for subchart. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +# subchartA +service: + name: apache + type: ClusterIP + externalPort: 80 + internalPort: 80 +SCAdata: + SCAbool: false + SCAfloat: 3.1 + SCAint: 55 + SCAstring: "jabba" + SCAnested1: + SCAnested2: true + diff --git a/cmd/helm/testdata/testcharts/subchart/charts/subchartB/Chart.yaml b/cmd/helm/testdata/testcharts/subchart/charts/subchartB/Chart.yaml new file mode 100644 index 00000000..c3c6bbaf --- /dev/null +++ b/cmd/helm/testdata/testcharts/subchart/charts/subchartB/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: A Helm chart for Kubernetes +name: subchartb +version: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/subchart/charts/subchartB/templates/service.yaml b/cmd/helm/testdata/testcharts/subchart/charts/subchartB/templates/service.yaml new file mode 100644 index 00000000..27501e1e --- /dev/null +++ b/cmd/helm/testdata/testcharts/subchart/charts/subchartB/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Chart.Name }} + labels: + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.externalPort }} + targetPort: {{ .Values.service.internalPort }} + protocol: TCP + name: {{ .Values.service.name }} + selector: + app.kubernetes.io/name: {{ .Chart.Name }} diff --git a/cmd/helm/testdata/testcharts/subchart/charts/subchartB/values.yaml b/cmd/helm/testdata/testcharts/subchart/charts/subchartB/values.yaml new file mode 100644 index 00000000..0ada0aad --- /dev/null +++ b/cmd/helm/testdata/testcharts/subchart/charts/subchartB/values.yaml @@ -0,0 +1,39 @@ +# Default values for subchart. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +service: + name: nginx + type: ClusterIP + externalPort: 80 + internalPort: 80 + +SCBdata: + SCBbool: true + SCBfloat: 7.77 + SCBint: 33 + SCBstring: "boba" + +exports: + SCBexported1: + SCBexported1A: + SCBexported1B: 1965 + + SCBexported2: + SCBexported2A: "blaster" + + configmap: + configmap: + value: "bar" + +global: + kolla: + nova: + api: + all: + port: 8774 + metadata: + all: + port: 8775 + + + diff --git a/cmd/helm/testdata/testcharts/subchart/crds/crdA.yaml b/cmd/helm/testdata/testcharts/subchart/crds/crdA.yaml new file mode 100644 index 00000000..ad770b63 --- /dev/null +++ b/cmd/helm/testdata/testcharts/subchart/crds/crdA.yaml @@ -0,0 +1,14 @@ +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: testcrds.testcrdgroups.example.com +spec: + group: testcrdgroups.example.com + version: v1alpha1 + names: + kind: TestCRD + listKind: TestCRDList + plural: testcrds + shortNames: + - tc + singular: authconfig diff --git a/cmd/helm/testdata/testcharts/subchart/extra_values.yaml b/cmd/helm/testdata/testcharts/subchart/extra_values.yaml new file mode 100644 index 00000000..5976bd17 --- /dev/null +++ b/cmd/helm/testdata/testcharts/subchart/extra_values.yaml @@ -0,0 +1,5 @@ +# This file is used to test values passed by file at the command line + +configmap: + enabled: true + value: "qux" \ No newline at end of file diff --git a/cmd/helm/testdata/testcharts/subchart/templates/NOTES.txt b/cmd/helm/testdata/testcharts/subchart/templates/NOTES.txt new file mode 100644 index 00000000..4bdf443f --- /dev/null +++ b/cmd/helm/testdata/testcharts/subchart/templates/NOTES.txt @@ -0,0 +1 @@ +Sample notes for {{ .Chart.Name }} \ No newline at end of file diff --git a/cmd/helm/testdata/testcharts/subchart/templates/service.yaml b/cmd/helm/testdata/testcharts/subchart/templates/service.yaml new file mode 100644 index 00000000..fee94dce --- /dev/null +++ b/cmd/helm/testdata/testcharts/subchart/templates/service.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Chart.Name }} + labels: + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + app.kubernetes.io/instance: "{{ .Release.Name }}" + kube-version/major: "{{ .Capabilities.KubeVersion.Major }}" + kube-version/minor: "{{ .Capabilities.KubeVersion.Minor }}" + kube-version/version: "v{{ .Capabilities.KubeVersion.Major }}.{{ .Capabilities.KubeVersion.Minor }}.0" +{{- if .Capabilities.APIVersions.Has "helm.k8s.io/test" }} + kube-api-version/test: v1 +{{- end }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.externalPort }} + targetPort: {{ .Values.service.internalPort }} + protocol: TCP + name: {{ .Values.service.name }} + selector: + app.kubernetes.io/name: {{ .Chart.Name }} diff --git a/cmd/helm/testdata/testcharts/subchart/templates/subdir/configmap.yaml b/cmd/helm/testdata/testcharts/subchart/templates/subdir/configmap.yaml new file mode 100644 index 00000000..e404a6cb --- /dev/null +++ b/cmd/helm/testdata/testcharts/subchart/templates/subdir/configmap.yaml @@ -0,0 +1,8 @@ +{{ if .Values.configmap.enabled -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Chart.Name }}-cm +data: + value: {{ .Values.configmap.value }} +{{- end }} \ No newline at end of file diff --git a/cmd/helm/testdata/testcharts/subchart/templates/subdir/role.yaml b/cmd/helm/testdata/testcharts/subchart/templates/subdir/role.yaml new file mode 100644 index 00000000..31cff920 --- /dev/null +++ b/cmd/helm/testdata/testcharts/subchart/templates/subdir/role.yaml @@ -0,0 +1,8 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ .Chart.Name }}-role +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["get","list","watch"] diff --git a/cmd/helm/testdata/testcharts/subchart/templates/subdir/rolebinding.yaml b/cmd/helm/testdata/testcharts/subchart/templates/subdir/rolebinding.yaml new file mode 100644 index 00000000..5d193f1a --- /dev/null +++ b/cmd/helm/testdata/testcharts/subchart/templates/subdir/rolebinding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ .Chart.Name }}-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ .Chart.Name }}-role +subjects: +- kind: ServiceAccount + name: {{ .Chart.Name }}-sa + namespace: default diff --git a/cmd/helm/testdata/testcharts/subchart/templates/subdir/serviceaccount.yaml b/cmd/helm/testdata/testcharts/subchart/templates/subdir/serviceaccount.yaml new file mode 100644 index 00000000..7126c7d8 --- /dev/null +++ b/cmd/helm/testdata/testcharts/subchart/templates/subdir/serviceaccount.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Chart.Name }}-sa diff --git a/cmd/helm/testdata/testcharts/subchart/templates/tests/test-config.yaml b/cmd/helm/testdata/testcharts/subchart/templates/tests/test-config.yaml new file mode 100644 index 00000000..0aa3eea2 --- /dev/null +++ b/cmd/helm/testdata/testcharts/subchart/templates/tests/test-config.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: "{{ .Release.Name }}-testconfig" + annotations: + "helm.sh/hook": test +data: + message: Hello World diff --git a/cmd/helm/testdata/testcharts/subchart/templates/tests/test-nothing.yaml b/cmd/helm/testdata/testcharts/subchart/templates/tests/test-nothing.yaml new file mode 100644 index 00000000..0fe6dbbf --- /dev/null +++ b/cmd/helm/testdata/testcharts/subchart/templates/tests/test-nothing.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ .Release.Name }}-test" + annotations: + "helm.sh/hook": test +spec: + containers: + - name: test + image: "alpine:latest" + envFrom: + - configMapRef: + name: "{{ .Release.Name }}-testconfig" + command: + - echo + - "$message" + restartPolicy: Never diff --git a/cmd/helm/testdata/testcharts/subchart/values.yaml b/cmd/helm/testdata/testcharts/subchart/values.yaml new file mode 100644 index 00000000..bcbebb5c --- /dev/null +++ b/cmd/helm/testdata/testcharts/subchart/values.yaml @@ -0,0 +1,59 @@ +# Default values for subchart. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +# subchart +service: + name: nginx + type: ClusterIP + externalPort: 80 + internalPort: 80 + + +SC1data: + SC1bool: true + SC1float: 3.14 + SC1int: 100 + SC1string: "dollywood" + SC1extra1: 11 + +imported-chartA: + SC1extra2: 1.337 + +overridden-chartA: + SCAbool: true + SCAfloat: 3.14 + SCAint: 100 + SCAstring: "jabbathehut" + SC1extra3: true + +imported-chartA-B: + SC1extra5: "tiller" + +overridden-chartA-B: + SCAbool: true + SCAfloat: 3.33 + SCAint: 555 + SCAstring: "wormwood" + SCAextra1: 23 + + SCBbool: true + SCBfloat: 0.25 + SCBint: 98 + SCBstring: "murkwood" + SCBextra1: 13 + + SC1extra6: 77 + +SCBexported1A: + SC1extra7: true + +exports: + SC1exported1: + global: + SC1exported2: + all: + SC1exported3: "SC1expstr" + +configmap: + enabled: false + value: "foo" diff --git a/cmd/helm/testdata/testcharts/upgradetest/templates/configmap.yaml b/cmd/helm/testdata/testcharts/upgradetest/templates/configmap.yaml new file mode 100644 index 00000000..b6b90efb --- /dev/null +++ b/cmd/helm/testdata/testcharts/upgradetest/templates/configmap.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: "{{ .Release.Name }}-configmap" +data: + myvalue: "Hello World" + drink: {{ .Values.favoriteDrink }} \ No newline at end of file diff --git a/cmd/helm/testdata/testcharts/upgradetest/values.yaml b/cmd/helm/testdata/testcharts/upgradetest/values.yaml new file mode 100644 index 00000000..c429f41f --- /dev/null +++ b/cmd/helm/testdata/testcharts/upgradetest/values.yaml @@ -0,0 +1 @@ +favoriteDrink: beer \ No newline at end of file diff --git a/cmd/helm/testdata/testplugin/plugin.yaml b/cmd/helm/testdata/testplugin/plugin.yaml new file mode 100644 index 00000000..890292cb --- /dev/null +++ b/cmd/helm/testdata/testplugin/plugin.yaml @@ -0,0 +1,4 @@ +name: testplugin +usage: "echo test" +description: "This echos test" +command: "echo test" diff --git a/cmd/helm/testdata/testserver/index.yaml b/cmd/helm/testdata/testserver/index.yaml new file mode 100644 index 00000000..9cde8e8d --- /dev/null +++ b/cmd/helm/testdata/testserver/index.yaml @@ -0,0 +1 @@ +apiVersion: v1 diff --git a/cmd/helm/testdata/testserver/repository/repositories.yaml b/cmd/helm/testdata/testserver/repository/repositories.yaml new file mode 100644 index 00000000..271301c9 --- /dev/null +++ b/cmd/helm/testdata/testserver/repository/repositories.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +generated: 2016-10-04T13:50:02.87649685-06:00 +repositories: +- cache: "" + name: test + url: http://127.0.0.1:49216 diff --git a/cmd/helm/uninstall.go b/cmd/helm/uninstall.go new file mode 100644 index 00000000..9ced8fef --- /dev/null +++ b/cmd/helm/uninstall.go @@ -0,0 +1,92 @@ +/* +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 ( + "fmt" + "io" + "time" + + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/action" +) + +const uninstallDesc = ` +This command takes a release name and uninstalls the release. + +It removes all of the resources associated with the last release of the chart +as well as the release history, freeing it up for future use. + +Use the '--dry-run' flag to see which releases will be uninstalled without actually +uninstalling them. +` + +func newUninstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + client := action.NewUninstall(cfg) + + cmd := &cobra.Command{ + Use: "uninstall RELEASE_NAME [...]", + Aliases: []string{"del", "delete", "un"}, + SuggestFor: []string{"remove", "rm"}, + Short: "uninstall a release", + Long: uninstallDesc, + Args: require.MinimumNArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return compListReleases(toComplete, args, cfg) + }, + RunE: func(cmd *cobra.Command, args []string) error { + validationErr := validateCascadeFlag(client) + if validationErr != nil { + return validationErr + } + for i := 0; i < len(args); i++ { + + res, err := client.Run(args[i]) + if err != nil { + return err + } + if res != nil && res.Info != "" { + fmt.Fprintln(out, res.Info) + } + + fmt.Fprintf(out, "release \"%s\" uninstalled\n", args[i]) + } + return nil + }, + } + + f := cmd.Flags() + f.BoolVar(&client.DryRun, "dry-run", false, "simulate a uninstall") + f.BoolVar(&client.DisableHooks, "no-hooks", false, "prevent hooks from running during uninstallation") + f.BoolVar(&client.IgnoreNotFound, "ignore-not-found", false, `Treat "release not found" as a successful uninstall`) + f.BoolVar(&client.KeepHistory, "keep-history", false, "remove all associated resources and mark the release as deleted, but retain the release history") + f.BoolVar(&client.Wait, "wait", false, "if set, will wait until all the resources are deleted before returning. It will wait for as long as --timeout") + f.StringVar(&client.DeletionPropagation, "cascade", "background", "Must be \"background\", \"orphan\", or \"foreground\". Selects the deletion cascading strategy for the dependents. Defaults to background.") + f.DurationVar(&client.Timeout, "timeout", 300*time.Second, "time to wait for any individual Kubernetes operation (like Jobs for hooks)") + f.StringVar(&client.Description, "description", "", "add a custom description") + + return cmd +} + +func validateCascadeFlag(client *action.Uninstall) error { + if client.DeletionPropagation != "background" && client.DeletionPropagation != "foreground" && client.DeletionPropagation != "orphan" { + return fmt.Errorf("invalid cascade value (%s). Must be \"background\", \"foreground\", or \"orphan\"", client.DeletionPropagation) + } + return nil +} diff --git a/cmd/helm/uninstall_test.go b/cmd/helm/uninstall_test.go new file mode 100644 index 00000000..23b61058 --- /dev/null +++ b/cmd/helm/uninstall_test.go @@ -0,0 +1,83 @@ +/* +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 ( + "testing" + + "helm.sh/helm/v3/pkg/release" +) + +func TestUninstall(t *testing.T) { + tests := []cmdTestCase{ + { + name: "basic uninstall", + cmd: "uninstall aeneas", + golden: "output/uninstall.txt", + rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "aeneas"})}, + }, + { + name: "multiple uninstall", + cmd: "uninstall aeneas aeneas2", + golden: "output/uninstall-multiple.txt", + rels: []*release.Release{ + release.Mock(&release.MockReleaseOptions{Name: "aeneas"}), + release.Mock(&release.MockReleaseOptions{Name: "aeneas2"}), + }, + }, + { + name: "uninstall with timeout", + cmd: "uninstall aeneas --timeout 120s", + golden: "output/uninstall-timeout.txt", + rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "aeneas"})}, + }, + { + name: "uninstall without hooks", + cmd: "uninstall aeneas --no-hooks", + golden: "output/uninstall-no-hooks.txt", + rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "aeneas"})}, + }, + { + name: "keep history", + cmd: "uninstall aeneas --keep-history", + golden: "output/uninstall-keep-history.txt", + rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "aeneas"})}, + }, + { + name: "wait", + cmd: "uninstall aeneas --wait", + golden: "output/uninstall-wait.txt", + rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "aeneas"})}, + }, + { + name: "uninstall without release", + cmd: "uninstall", + golden: "output/uninstall-no-args.txt", + wantError: true, + }, + } + runTestCmd(t, tests) +} + +func TestUninstallCompletion(t *testing.T) { + checkReleaseCompletion(t, "uninstall", true) +} + +func TestUninstallFileCompletion(t *testing.T) { + checkFileCompletion(t, "uninstall", false) + checkFileCompletion(t, "uninstall myrelease", false) +} diff --git a/cmd/helm/upgrade.go b/cmd/helm/upgrade.go new file mode 100644 index 00000000..acd1c72f --- /dev/null +++ b/cmd/helm/upgrade.go @@ -0,0 +1,287 @@ +/* +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 ( + "context" + "fmt" + "io" + "log" + "os" + "os/signal" + "syscall" + "time" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/cli/output" + "helm.sh/helm/v3/pkg/cli/values" + "helm.sh/helm/v3/pkg/downloader" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/storage/driver" +) + +const upgradeDesc = ` +This command upgrades a release to a new version of a chart. + +The upgrade arguments must be a release and chart. The chart +argument can be either: a chart reference('example/mariadb'), a path to a chart directory, +a packaged chart, or a fully qualified URL. For chart references, the latest +version will be specified unless the '--version' flag is set. + +To override values in a chart, use either the '--values' flag and pass in a file +or use the '--set' flag and pass configuration from the command line, to force string +values, use '--set-string'. You can use '--set-file' to set individual +values from a file when the value itself is too long for the command line +or is dynamically generated. You can also use '--set-json' to set json values +(scalars/objects/arrays) from the command line. + +You can specify the '--values'/'-f' flag multiple times. The priority will be given to the +last (right-most) file specified. For example, if both myvalues.yaml and override.yaml +contained a key called 'Test', the value set in override.yaml would take precedence: + + $ helm upgrade -f myvalues.yaml -f override.yaml redis ./redis + +You can specify the '--set' flag multiple times. The priority will be given to the +last (right-most) set specified. For example, if both 'bar' and 'newbar' values are +set for a key called 'foo', the 'newbar' value would take precedence: + + $ helm upgrade --set foo=bar --set foo=newbar redis ./redis + +You can update the values for an existing release with this command as well via the +'--reuse-values' flag. The 'RELEASE' and 'CHART' arguments should be set to the original +parameters, and existing values will be merged with any values set via '--values'/'-f' +or '--set' flags. Priority is given to new values. + + $ helm upgrade --reuse-values --set foo=bar --set foo=newbar redis ./redis + +The --dry-run flag will output all generated chart manifests, including Secrets +which can contain sensitive values. Please carefully consider how and when this +flag is used. +` + +func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + client := action.NewUpgrade(cfg) + valueOpts := &values.Options{} + var outfmt output.Format + var createNamespace bool + + cmd := &cobra.Command{ + Use: "upgrade [RELEASE] [CHART]", + Short: "upgrade a release", + Long: upgradeDesc, + Args: require.ExactArgs(2), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return compListReleases(toComplete, args, cfg) + } + if len(args) == 1 { + return compListCharts(toComplete, true) + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + RunE: func(cmd *cobra.Command, args []string) error { + client.Namespace = settings.Namespace() + + registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile, + client.InsecureSkipTLSverify, client.PlainHTTP) + if err != nil { + return fmt.Errorf("missing registry client: %w", err) + } + client.SetRegistryClient(registryClient) + + // This is for the case where "" is specifically passed in as a + // value. When there is no value passed in NoOptDefVal will be used + // and it is set to client. See addInstallFlags. + if client.DryRunOption == "" { + client.DryRunOption = "none" + } + // Fixes #7002 - Support reading values from STDIN for `upgrade` command + // Must load values AFTER determining if we have to call install so that values loaded from stdin are are not read twice + if client.Install { + // If a release does not exist, install it. + histClient := action.NewHistory(cfg) + histClient.Max = 1 + if _, err := histClient.Run(args[0]); err == driver.ErrReleaseNotFound { + // Only print this to stdout for table output + if outfmt == output.Table { + fmt.Fprintf(out, "Release %q does not exist. Installing it now.\n", args[0]) + } + instClient := action.NewInstall(cfg) + instClient.CreateNamespace = createNamespace + instClient.ChartPathOptions = client.ChartPathOptions + instClient.Force = client.Force + instClient.DryRun = client.DryRun + instClient.DryRunOption = client.DryRunOption + instClient.DisableHooks = client.DisableHooks + instClient.SkipCRDs = client.SkipCRDs + instClient.Timeout = client.Timeout + instClient.Wait = client.Wait + instClient.WaitForJobs = client.WaitForJobs + instClient.Devel = client.Devel + instClient.Namespace = client.Namespace + instClient.Atomic = client.Atomic + instClient.PostRenderer = client.PostRenderer + instClient.DisableOpenAPIValidation = client.DisableOpenAPIValidation + instClient.SubNotes = client.SubNotes + instClient.Description = client.Description + instClient.DependencyUpdate = client.DependencyUpdate + instClient.Labels = client.Labels + instClient.EnableDNS = client.EnableDNS + + rel, err := runInstall(args, instClient, valueOpts, out) + if err != nil { + return err + } + return outfmt.Write(out, &statusPrinter{rel, settings.Debug, false, false, false}) + } else if err != nil { + return err + } + } + + if client.Version == "" && client.Devel { + debug("setting version to >0.0.0-0") + client.Version = ">0.0.0-0" + } + + chartPath, err := client.ChartPathOptions.LocateChart(args[1], settings) + if err != nil { + return err + } + // Validate dry-run flag value is one of the allowed values + if err := validateDryRunOptionFlag(client.DryRunOption); err != nil { + return err + } + + p := getter.All(settings) + vals, err := valueOpts.MergeValues(p) + if err != nil { + return err + } + + // Check chart dependencies to make sure all are present in /charts + ch, err := loader.Load(chartPath) + if err != nil { + return err + } + if req := ch.Metadata.Dependencies; req != nil { + if err := action.CheckDependencies(ch, req); err != nil { + err = errors.Wrap(err, "An error occurred while checking for chart dependencies. You may need to run `helm dependency build` to fetch missing dependencies") + if client.DependencyUpdate { + man := &downloader.Manager{ + Out: out, + ChartPath: chartPath, + Keyring: client.ChartPathOptions.Keyring, + SkipUpdate: false, + Getters: p, + RepositoryConfig: settings.RepositoryConfig, + RepositoryCache: settings.RepositoryCache, + Debug: settings.Debug, + } + if err := man.Update(); err != nil { + return err + } + // Reload the chart with the updated Chart.lock file. + if ch, err = loader.Load(chartPath); err != nil { + return errors.Wrap(err, "failed reloading chart after repo update") + } + } else { + return err + } + } + } + + if ch.Metadata.Deprecated { + warning("This chart is deprecated") + } + + // Create context and prepare the handle of SIGTERM + ctx := context.Background() + ctx, cancel := context.WithCancel(ctx) + + // Set up channel on which to send signal notifications. + // We must use a buffered channel or risk missing the signal + // if we're not ready to receive when the signal is sent. + cSignal := make(chan os.Signal, 2) + signal.Notify(cSignal, os.Interrupt, syscall.SIGTERM) + go func() { + <-cSignal + fmt.Fprintf(out, "Release %s has been cancelled.\n", args[0]) + cancel() + }() + + rel, err := client.RunWithContext(ctx, args[0], ch, vals) + if err != nil { + return errors.Wrap(err, "UPGRADE FAILED") + } + + if outfmt == output.Table { + fmt.Fprintf(out, "Release %q has been upgraded. Happy Helming!\n", args[0]) + } + + return outfmt.Write(out, &statusPrinter{rel, settings.Debug, false, false, false}) + }, + } + + f := cmd.Flags() + f.BoolVar(&createNamespace, "create-namespace", false, "if --install is set, create the release namespace if not present") + f.BoolVarP(&client.Install, "install", "i", false, "if a release by this name doesn't already exist, run an install") + f.BoolVar(&client.Devel, "devel", false, "use development versions, too. Equivalent to version '>0.0.0-0'. If --version is set, this is ignored") + f.StringVar(&client.DryRunOption, "dry-run", "", "simulate an install. If --dry-run is set with no option being specified or as '--dry-run=client', it will not attempt cluster connections. Setting '--dry-run=server' allows attempting cluster connections.") + f.Lookup("dry-run").NoOptDefVal = "client" + f.BoolVar(&client.Recreate, "recreate-pods", false, "performs pods restart for the resource if applicable") + f.MarkDeprecated("recreate-pods", "functionality will no longer be updated. Consult the documentation for other methods to recreate pods") + f.BoolVar(&client.Force, "force", false, "force resource updates through a replacement strategy") + f.BoolVar(&client.DisableHooks, "no-hooks", false, "disable pre/post upgrade hooks") + f.BoolVar(&client.DisableOpenAPIValidation, "disable-openapi-validation", false, "if set, the upgrade process will not validate rendered templates against the Kubernetes OpenAPI Schema") + f.BoolVar(&client.SkipCRDs, "skip-crds", false, "if set, no CRDs will be installed when an upgrade is performed with install flag enabled. By default, CRDs are installed if not already present, when an upgrade is performed with install flag enabled") + f.DurationVar(&client.Timeout, "timeout", 300*time.Second, "time to wait for any individual Kubernetes operation (like Jobs for hooks)") + f.BoolVar(&client.ResetValues, "reset-values", false, "when upgrading, reset the values to the ones built into the chart") + f.BoolVar(&client.ReuseValues, "reuse-values", false, "when upgrading, reuse the last release's values and merge in any overrides from the command line via --set and -f. If '--reset-values' is specified, this is ignored") + f.BoolVar(&client.ResetThenReuseValues, "reset-then-reuse-values", false, "when upgrading, reset the values to the ones built into the chart, apply the last release's values and merge in any overrides from the command line via --set and -f. If '--reset-values' or '--reuse-values' is specified, this is ignored") + f.BoolVar(&client.Wait, "wait", false, "if set, will wait until all Pods, PVCs, Services, and minimum number of Pods of a Deployment, StatefulSet, or ReplicaSet are in a ready state before marking the release as successful. It will wait for as long as --timeout") + f.BoolVar(&client.WaitForJobs, "wait-for-jobs", false, "if set and --wait enabled, will wait until all Jobs have been completed before marking the release as successful. It will wait for as long as --timeout") + f.BoolVar(&client.Atomic, "atomic", false, "if set, upgrade process rolls back changes made in case of failed upgrade. The --wait flag will be set automatically if --atomic is used") + f.IntVar(&client.MaxHistory, "history-max", settings.MaxHistory, "limit the maximum number of revisions saved per release. Use 0 for no limit") + f.BoolVar(&client.CleanupOnFail, "cleanup-on-fail", false, "allow deletion of new resources created in this upgrade when upgrade fails") + f.BoolVar(&client.SubNotes, "render-subchart-notes", false, "if set, render subchart notes along with the parent") + f.StringToStringVarP(&client.Labels, "labels", "l", nil, "Labels that would be added to release metadata. Should be separated by comma. Original release labels will be merged with upgrade labels. You can unset label using null.") + f.StringVar(&client.Description, "description", "", "add a custom description") + f.BoolVar(&client.DependencyUpdate, "dependency-update", false, "update dependencies if they are missing before installing the chart") + f.BoolVar(&client.EnableDNS, "enable-dns", false, "enable DNS lookups when rendering templates") + addChartPathOptionsFlags(f, &client.ChartPathOptions) + addValueOptionsFlags(f, valueOpts) + bindOutputFlag(cmd, &outfmt) + bindPostRenderFlag(cmd, &client.PostRenderer) + + err := cmd.RegisterFlagCompletionFunc("version", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 2 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return compVersionFlag(args[1], toComplete) + }) + + if err != nil { + log.Fatal(err) + } + + return cmd +} diff --git a/cmd/helm/upgrade_test.go b/cmd/helm/upgrade_test.go new file mode 100644 index 00000000..485267d1 --- /dev/null +++ b/cmd/helm/upgrade_test.go @@ -0,0 +1,460 @@ +/* +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 ( + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/release" +) + +func TestUpgradeCmd(t *testing.T) { + + tmpChart := t.TempDir() + cfile := &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV1, + Name: "testUpgradeChart", + Description: "A Helm chart for Kubernetes", + Version: "0.1.0", + }, + } + chartPath := filepath.Join(tmpChart, cfile.Metadata.Name) + if err := chartutil.SaveDir(cfile, tmpChart); err != nil { + t.Fatalf("Error creating chart for upgrade: %v", err) + } + ch, err := loader.Load(chartPath) + if err != nil { + t.Fatalf("Error loading chart: %v", err) + } + _ = release.Mock(&release.MockReleaseOptions{ + Name: "funny-bunny", + Chart: ch, + }) + + // update chart version + cfile.Metadata.Version = "0.1.2" + + if err := chartutil.SaveDir(cfile, tmpChart); err != nil { + t.Fatalf("Error creating chart: %v", err) + } + ch, err = loader.Load(chartPath) + if err != nil { + t.Fatalf("Error loading updated chart: %v", err) + } + + // update chart version again + cfile.Metadata.Version = "0.1.3" + + if err := chartutil.SaveDir(cfile, tmpChart); err != nil { + t.Fatalf("Error creating chart: %v", err) + } + var ch2 *chart.Chart + ch2, err = loader.Load(chartPath) + if err != nil { + t.Fatalf("Error loading updated chart: %v", err) + } + + missingDepsPath := "testdata/testcharts/chart-missing-deps" + badDepsPath := "testdata/testcharts/chart-bad-requirements" + presentDepsPath := "testdata/testcharts/chart-with-subchart-update" + + relWithStatusMock := func(n string, v int, ch *chart.Chart, status release.Status) *release.Release { + return release.Mock(&release.MockReleaseOptions{Name: n, Version: v, Chart: ch, Status: status}) + } + + relMock := func(n string, v int, ch *chart.Chart) *release.Release { + return release.Mock(&release.MockReleaseOptions{Name: n, Version: v, Chart: ch}) + } + + tests := []cmdTestCase{ + { + name: "upgrade a release", + cmd: fmt.Sprintf("upgrade funny-bunny '%s'", chartPath), + golden: "output/upgrade.txt", + rels: []*release.Release{relMock("funny-bunny", 2, ch)}, + }, + { + name: "upgrade a release with timeout", + cmd: fmt.Sprintf("upgrade funny-bunny --timeout 120s '%s'", chartPath), + golden: "output/upgrade-with-timeout.txt", + rels: []*release.Release{relMock("funny-bunny", 3, ch2)}, + }, + { + name: "upgrade a release with --reset-values", + cmd: fmt.Sprintf("upgrade funny-bunny --reset-values '%s'", chartPath), + golden: "output/upgrade-with-reset-values.txt", + rels: []*release.Release{relMock("funny-bunny", 4, ch2)}, + }, + { + name: "upgrade a release with --reuse-values", + cmd: fmt.Sprintf("upgrade funny-bunny --reuse-values '%s'", chartPath), + golden: "output/upgrade-with-reset-values2.txt", + rels: []*release.Release{relMock("funny-bunny", 5, ch2)}, + }, + { + name: "install a release with 'upgrade --install'", + cmd: fmt.Sprintf("upgrade zany-bunny -i '%s'", chartPath), + golden: "output/upgrade-with-install.txt", + rels: []*release.Release{relMock("zany-bunny", 1, ch)}, + }, + { + name: "install a release with 'upgrade --install' and timeout", + cmd: fmt.Sprintf("upgrade crazy-bunny -i --timeout 120s '%s'", chartPath), + golden: "output/upgrade-with-install-timeout.txt", + rels: []*release.Release{relMock("crazy-bunny", 1, ch)}, + }, + { + name: "upgrade a release with wait", + cmd: fmt.Sprintf("upgrade crazy-bunny --wait '%s'", chartPath), + golden: "output/upgrade-with-wait.txt", + rels: []*release.Release{relMock("crazy-bunny", 2, ch2)}, + }, + { + name: "upgrade a release with wait-for-jobs", + cmd: fmt.Sprintf("upgrade crazy-bunny --wait --wait-for-jobs '%s'", chartPath), + golden: "output/upgrade-with-wait-for-jobs.txt", + rels: []*release.Release{relMock("crazy-bunny", 2, ch2)}, + }, + { + name: "upgrade a release with missing dependencies", + cmd: fmt.Sprintf("upgrade bonkers-bunny %s", missingDepsPath), + golden: "output/upgrade-with-missing-dependencies.txt", + wantError: true, + }, + { + name: "upgrade a release with bad dependencies", + cmd: fmt.Sprintf("upgrade bonkers-bunny '%s'", badDepsPath), + golden: "output/upgrade-with-bad-dependencies.txt", + wantError: true, + }, + { + name: "upgrade a release with resolving missing dependencies", + cmd: fmt.Sprintf("upgrade --dependency-update funny-bunny %s", presentDepsPath), + golden: "output/upgrade-with-dependency-update.txt", + rels: []*release.Release{relMock("funny-bunny", 2, ch2)}, + }, + { + name: "upgrade a non-existent release", + cmd: fmt.Sprintf("upgrade funny-bunny '%s'", chartPath), + golden: "output/upgrade-with-bad-or-missing-existing-release.txt", + wantError: true, + }, + { + name: "upgrade a failed release", + cmd: fmt.Sprintf("upgrade funny-bunny '%s'", chartPath), + golden: "output/upgrade.txt", + rels: []*release.Release{relWithStatusMock("funny-bunny", 2, ch, release.StatusFailed)}, + }, + { + name: "upgrade a pending install release", + cmd: fmt.Sprintf("upgrade funny-bunny '%s'", chartPath), + golden: "output/upgrade-with-pending-install.txt", + wantError: true, + rels: []*release.Release{relWithStatusMock("funny-bunny", 2, ch, release.StatusPendingInstall)}, + }, + } + runTestCmd(t, tests) +} + +func TestUpgradeWithValue(t *testing.T) { + releaseName := "funny-bunny-v2" + relMock, ch, chartPath := prepareMockRelease(releaseName, t) + + defer resetEnv()() + + store := storageFixture() + + store.Create(relMock(releaseName, 3, ch)) + + cmd := fmt.Sprintf("upgrade %s --set favoriteDrink=tea '%s'", releaseName, chartPath) + _, _, err := executeActionCommandC(store, cmd) + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + + updatedRel, err := store.Get(releaseName, 4) + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + + if !strings.Contains(updatedRel.Manifest, "drink: tea") { + t.Errorf("The value is not set correctly. manifest: %s", updatedRel.Manifest) + } + +} + +func TestUpgradeWithStringValue(t *testing.T) { + releaseName := "funny-bunny-v3" + relMock, ch, chartPath := prepareMockRelease(releaseName, t) + + defer resetEnv()() + + store := storageFixture() + + store.Create(relMock(releaseName, 3, ch)) + + cmd := fmt.Sprintf("upgrade %s --set-string favoriteDrink=coffee '%s'", releaseName, chartPath) + _, _, err := executeActionCommandC(store, cmd) + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + + updatedRel, err := store.Get(releaseName, 4) + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + + if !strings.Contains(updatedRel.Manifest, "drink: coffee") { + t.Errorf("The value is not set correctly. manifest: %s", updatedRel.Manifest) + } + +} + +func TestUpgradeInstallWithSubchartNotes(t *testing.T) { + + releaseName := "wacky-bunny-v1" + relMock, ch, _ := prepareMockRelease(releaseName, t) + + defer resetEnv()() + + store := storageFixture() + + 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) + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + + upgradedRel, err := store.Get(releaseName, 2) + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + + if !strings.Contains(upgradedRel.Info.Notes, "PARENT NOTES") { + t.Errorf("The parent notes are not set correctly. NOTES: %s", upgradedRel.Info.Notes) + } + + if !strings.Contains(upgradedRel.Info.Notes, "SUBCHART NOTES") { + t.Errorf("The subchart notes are not set correctly. NOTES: %s", upgradedRel.Info.Notes) + } + +} + +func TestUpgradeWithValuesFile(t *testing.T) { + + releaseName := "funny-bunny-v4" + relMock, ch, chartPath := prepareMockRelease(releaseName, t) + + defer resetEnv()() + + store := storageFixture() + + store.Create(relMock(releaseName, 3, ch)) + + cmd := fmt.Sprintf("upgrade %s --values testdata/testcharts/upgradetest/values.yaml '%s'", releaseName, chartPath) + _, _, err := executeActionCommandC(store, cmd) + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + + updatedRel, err := store.Get(releaseName, 4) + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + + if !strings.Contains(updatedRel.Manifest, "drink: beer") { + t.Errorf("The value is not set correctly. manifest: %s", updatedRel.Manifest) + } + +} + +func TestUpgradeWithValuesFromStdin(t *testing.T) { + + releaseName := "funny-bunny-v5" + relMock, ch, chartPath := prepareMockRelease(releaseName, t) + + defer resetEnv()() + + store := storageFixture() + + store.Create(relMock(releaseName, 3, ch)) + + in, err := os.Open("testdata/testcharts/upgradetest/values.yaml") + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + + cmd := fmt.Sprintf("upgrade %s --values - '%s'", releaseName, chartPath) + _, _, err = executeActionCommandStdinC(store, in, cmd) + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + + updatedRel, err := store.Get(releaseName, 4) + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + + if !strings.Contains(updatedRel.Manifest, "drink: beer") { + t.Errorf("The value is not set correctly. manifest: %s", updatedRel.Manifest) + } +} + +func TestUpgradeInstallWithValuesFromStdin(t *testing.T) { + + releaseName := "funny-bunny-v6" + _, _, chartPath := prepareMockRelease(releaseName, t) + + defer resetEnv()() + + store := storageFixture() + + in, err := os.Open("testdata/testcharts/upgradetest/values.yaml") + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + + cmd := fmt.Sprintf("upgrade %s -f - --install '%s'", releaseName, chartPath) + _, _, err = executeActionCommandStdinC(store, in, cmd) + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + + updatedRel, err := store.Get(releaseName, 1) + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + + if !strings.Contains(updatedRel.Manifest, "drink: beer") { + t.Errorf("The value is not set correctly. manifest: %s", updatedRel.Manifest) + } + +} + +func prepareMockRelease(releaseName string, t *testing.T) (func(n string, v int, ch *chart.Chart) *release.Release, *chart.Chart, string) { + tmpChart := t.TempDir() + configmapData, err := os.ReadFile("testdata/testcharts/upgradetest/templates/configmap.yaml") + if err != nil { + t.Fatalf("Error loading template yaml %v", err) + } + cfile := &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV1, + Name: "testUpgradeChart", + Description: "A Helm chart for Kubernetes", + Version: "0.1.0", + }, + Templates: []*chart.File{{Name: "templates/configmap.yaml", Data: configmapData}}, + } + chartPath := filepath.Join(tmpChart, cfile.Metadata.Name) + if err := chartutil.SaveDir(cfile, tmpChart); err != nil { + t.Fatalf("Error creating chart for upgrade: %v", err) + } + ch, err := loader.Load(chartPath) + if err != nil { + t.Fatalf("Error loading chart: %v", err) + } + _ = release.Mock(&release.MockReleaseOptions{ + Name: releaseName, + Chart: ch, + }) + + relMock := func(n string, v int, ch *chart.Chart) *release.Release { + return release.Mock(&release.MockReleaseOptions{Name: n, Version: v, Chart: ch}) + } + + return relMock, ch, chartPath +} + +func TestUpgradeOutputCompletion(t *testing.T) { + outputFlagCompletionTest(t, "upgrade") +} + +func TestUpgradeVersionCompletion(t *testing.T) { + repoFile := "testdata/helmhome/helm/repositories.yaml" + repoCache := "testdata/helmhome/helm/repository" + + repoSetup := fmt.Sprintf("--repository-config %s --repository-cache %s", repoFile, repoCache) + + tests := []cmdTestCase{{ + name: "completion for upgrade version flag", + cmd: fmt.Sprintf("%s __complete upgrade releasename testing/alpine --version ''", repoSetup), + golden: "output/version-comp.txt", + }, { + name: "completion for upgrade version flag, no filter", + cmd: fmt.Sprintf("%s __complete upgrade releasename testing/alpine --version 0.3", repoSetup), + golden: "output/version-comp.txt", + }, { + name: "completion for upgrade version flag too few args", + cmd: fmt.Sprintf("%s __complete upgrade releasename --version ''", repoSetup), + golden: "output/version-invalid-comp.txt", + }, { + name: "completion for upgrade version flag too many args", + cmd: fmt.Sprintf("%s __complete upgrade releasename testing/alpine badarg --version ''", repoSetup), + golden: "output/version-invalid-comp.txt", + }, { + name: "completion for upgrade version flag invalid chart", + cmd: fmt.Sprintf("%s __complete upgrade releasename invalid/invalid --version ''", repoSetup), + golden: "output/version-invalid-comp.txt", + }} + runTestCmd(t, tests) +} + +func TestUpgradeFileCompletion(t *testing.T) { + checkFileCompletion(t, "upgrade", false) + checkFileCompletion(t, "upgrade myrelease", true) + checkFileCompletion(t, "upgrade myrelease repo/chart", false) +} + +func TestUpgradeInstallWithLabels(t *testing.T) { + releaseName := "funny-bunny-labels" + _, _, chartPath := prepareMockRelease(releaseName, t) + + defer resetEnv()() + + store := storageFixture() + + expectedLabels := map[string]string{ + "key1": "val1", + "key2": "val2", + } + cmd := fmt.Sprintf("upgrade %s --install --labels key1=val1,key2=val2 '%s'", releaseName, chartPath) + _, _, err := executeActionCommandC(store, cmd) + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + + updatedRel, err := store.Get(releaseName, 1) + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + + if !reflect.DeepEqual(updatedRel.Labels, expectedLabels) { + t.Errorf("Expected {%v}, got {%v}", expectedLabels, updatedRel.Labels) + } +} diff --git a/cmd/helm/verify.go b/cmd/helm/verify.go new file mode 100644 index 00000000..d126c9ef --- /dev/null +++ b/cmd/helm/verify.go @@ -0,0 +1,70 @@ +/* +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 ( + "fmt" + "io" + + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/pkg/action" +) + +const verifyDesc = ` +Verify that the given chart has a valid provenance file. + +Provenance files provide cryptographic verification that a chart has not been +tampered with, and was packaged by a trusted provider. + +This command can be used to verify a local chart. Several other commands provide +'--verify' flags that run the same validation. To generate a signed package, use +the 'helm package --sign' command. +` + +func newVerifyCmd(out io.Writer) *cobra.Command { + client := action.NewVerify() + + cmd := &cobra.Command{ + Use: "verify PATH", + Short: "verify that a chart at the given path has been signed and is valid", + Long: verifyDesc, + Args: require.ExactArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + // Allow file completion when completing the argument for the path + return nil, cobra.ShellCompDirectiveDefault + } + // No more completions, so disable file completion + return nil, cobra.ShellCompDirectiveNoFileComp + }, + RunE: func(cmd *cobra.Command, args []string) error { + err := client.Run(args[0]) + if err != nil { + return err + } + + fmt.Fprint(out, client.Out) + + return nil + }, + } + + cmd.Flags().StringVar(&client.Keyring, "keyring", defaultKeyring(), "keyring containing public keys") + + return cmd +} diff --git a/cmd/helm/verify_test.go b/cmd/helm/verify_test.go new file mode 100644 index 00000000..23b79355 --- /dev/null +++ b/cmd/helm/verify_test.go @@ -0,0 +1,97 @@ +/* +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 ( + "fmt" + "runtime" + "testing" +) + +func TestVerifyCmd(t *testing.T) { + + statExe := "stat" + statPathMsg := "no such file or directory" + statFileMsg := statPathMsg + if runtime.GOOS == "windows" { + statExe = "FindFirstFile" + statPathMsg = "The system cannot find the path specified." + statFileMsg = "The system cannot find the file specified." + } + + tests := []struct { + name string + cmd string + expect string + wantError bool + }{ + { + name: "verify requires a chart", + cmd: "verify", + expect: "\"helm verify\" requires 1 argument\n\nUsage: helm verify PATH [flags]", + wantError: true, + }, + { + name: "verify requires that chart exists", + cmd: "verify no/such/file", + expect: fmt.Sprintf("%s no/such/file: %s", statExe, statPathMsg), + wantError: true, + }, + { + name: "verify requires that chart is not a directory", + cmd: "verify testdata/testcharts/signtest", + expect: "unpacked charts cannot be verified", + wantError: true, + }, + { + name: "verify requires that chart has prov file", + cmd: "verify testdata/testcharts/compressedchart-0.1.0.tgz", + expect: fmt.Sprintf("could not load provenance file testdata/testcharts/compressedchart-0.1.0.tgz.prov: %s testdata/testcharts/compressedchart-0.1.0.tgz.prov: %s", statExe, statFileMsg), + wantError: true, + }, + { + name: "verify validates a properly signed chart", + cmd: "verify testdata/testcharts/signtest-0.1.0.tgz --keyring testdata/helm-test-key.pub", + expect: "Signed by: Helm Testing (This key should only be used for testing. DO NOT TRUST.) \nUsing Key With Fingerprint: 5E615389B53CA37F0EE60BD3843BBF981FC18762\nChart Hash Verified: sha256:e5ef611620fb97704d8751c16bab17fedb68883bfb0edc76f78a70e9173f9b55\n", + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, out, err := executeActionCommand(tt.cmd) + if tt.wantError { + if err == nil { + t.Errorf("Expected error, but got none: %q", out) + } + if err.Error() != tt.expect { + t.Errorf("Expected error %q, got %q", tt.expect, err) + } + return + } else if err != nil { + t.Errorf("Unexpected error: %s", err) + } + if out != tt.expect { + t.Errorf("Expected %q, got %q", tt.expect, out) + } + }) + } +} + +func TestVerifyFileCompletion(t *testing.T) { + checkFileCompletion(t, "verify", true) + checkFileCompletion(t, "verify mypath", false) +} diff --git a/cmd/helm/version.go b/cmd/helm/version.go new file mode 100644 index 00000000..d62778f7 --- /dev/null +++ b/cmd/helm/version.go @@ -0,0 +1,103 @@ +/* +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 ( + "fmt" + "io" + "text/template" + + "github.com/spf13/cobra" + + "helm.sh/helm/v3/cmd/helm/require" + "helm.sh/helm/v3/internal/version" +) + +const versionDesc = ` +Show the version for Helm. + +This will print a representation the version of Helm. +The output will look something like this: + +version.BuildInfo{Version:"v3.2.1", GitCommit:"fe51cd1e31e6a202cba7dead9552a6d418ded79a", GitTreeState:"clean", GoVersion:"go1.13.10"} + +- Version is the semantic version of the release. +- GitCommit is the SHA for the commit that this version was built from. +- GitTreeState is "clean" if there are no local code changes when this binary was + built, and "dirty" if the binary was built from locally modified code. +- GoVersion is the version of Go that was used to compile Helm. + +When using the --template flag the following properties are available to use in +the template: + +- .Version contains the semantic version of Helm +- .GitCommit is the git commit +- .GitTreeState is the state of the git tree when Helm was built +- .GoVersion contains the version of Go that Helm was compiled with + +For example, --template='Version: {{.Version}}' outputs 'Version: v3.2.1'. +` + +type versionOptions struct { + short bool + template string +} + +func newVersionCmd(out io.Writer) *cobra.Command { + o := &versionOptions{} + + cmd := &cobra.Command{ + Use: "version", + Short: "print the client version information", + Long: versionDesc, + Args: require.NoArgs, + ValidArgsFunction: noCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + return o.run(out) + }, + } + f := cmd.Flags() + f.BoolVar(&o.short, "short", false, "print the version number") + f.StringVar(&o.template, "template", "", "template for version string format") + f.BoolP("client", "c", true, "display client version information") + f.MarkHidden("client") + + return cmd +} + +func (o *versionOptions) run(out io.Writer) error { + if o.template != "" { + tt, err := template.New("_").Parse(o.template) + if err != nil { + return err + } + return tt.Execute(out, version.Get()) + } + fmt.Fprintln(out, formatVersion(o.short)) + return nil +} + +func formatVersion(short bool) string { + v := version.Get() + if short { + if len(v.GitCommit) >= 7 { + return fmt.Sprintf("%s+g%s", v.Version, v.GitCommit[:7]) + } + return version.GetVersion() + } + return fmt.Sprintf("%#v", v) +} diff --git a/cmd/helm/version_test.go b/cmd/helm/version_test.go new file mode 100644 index 00000000..aa3cbfb7 --- /dev/null +++ b/cmd/helm/version_test.go @@ -0,0 +1,49 @@ +/* +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 ( + "testing" +) + +func TestVersion(t *testing.T) { + tests := []cmdTestCase{{ + name: "default", + cmd: "version", + golden: "output/version.txt", + }, { + name: "short", + cmd: "version --short", + golden: "output/version-short.txt", + }, { + name: "template", + cmd: "version --template='Version: {{.Version}}'", + golden: "output/version-template.txt", + }, { + name: "client", + cmd: "version --client", + golden: "output/version-client.txt", + }, { + name: "client shorthand", + cmd: "version -c", + golden: "output/version-client-shorthand.txt", + }} + runTestCmd(t, tests) +} + +func TestVersionFileCompletion(t *testing.T) { + checkFileCompletion(t, "version", false) +} diff --git a/code-of-conduct.md b/code-of-conduct.md new file mode 100644 index 00000000..91ccaf03 --- /dev/null +++ b/code-of-conduct.md @@ -0,0 +1,3 @@ +# Community Code of Conduct + +Helm follows the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/master/code-of-conduct.md). diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..e200d4fc --- /dev/null +++ b/go.mod @@ -0,0 +1,169 @@ +module helm.sh/helm/v3 + +go 1.21 + +require ( + github.com/BurntSushi/toml v1.3.2 + github.com/DATA-DOG/go-sqlmock v1.5.2 + github.com/Masterminds/semver/v3 v3.2.1 + github.com/Masterminds/sprig/v3 v3.2.3 + github.com/Masterminds/squirrel v1.5.4 + github.com/Masterminds/vcs v1.13.3 + github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535 + github.com/containerd/containerd v1.7.12 + github.com/cyphar/filepath-securejoin v0.2.4 + github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2 + github.com/evanphx/json-patch v5.7.0+incompatible + github.com/foxcpp/go-mockdns v1.0.0 + github.com/gobwas/glob v0.2.3 + github.com/gofrs/flock v0.8.1 + github.com/gosuri/uitable v0.0.4 + github.com/hashicorp/go-multierror v1.1.1 + github.com/jmoiron/sqlx v1.3.5 + github.com/lib/pq v1.10.9 + github.com/mattn/go-shellwords v1.0.12 + github.com/mitchellh/copystructure v1.2.0 + github.com/moby/term v0.5.0 + github.com/opencontainers/image-spec v1.1.0-rc5 + github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 + github.com/pkg/errors v0.9.1 + github.com/rubenv/sql-migrate v1.5.2 + github.com/sirupsen/logrus v1.9.3 + github.com/spf13/cobra v1.8.0 + github.com/spf13/pflag v1.0.5 + github.com/stretchr/testify v1.8.4 + github.com/xeipuuv/gojsonschema v1.2.0 + golang.org/x/crypto v0.17.0 + golang.org/x/term v0.15.0 + golang.org/x/text v0.14.0 + k8s.io/api v0.29.0 + k8s.io/apiextensions-apiserver v0.29.0 + k8s.io/apimachinery v0.29.0 + k8s.io/apiserver v0.29.0 + k8s.io/cli-runtime v0.29.0 + k8s.io/client-go v0.29.0 + k8s.io/klog/v2 v2.110.1 + k8s.io/kubectl v0.29.0 + oras.land/oras-go v1.2.4 + sigs.k8s.io/yaml v1.3.0 +) + +require ( + github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/MakeNowJust/heredoc v1.0.0 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Microsoft/hcsshim v0.11.4 // indirect + github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/bshuster-repo/logrus-logstash-hook v1.0.0 // indirect + github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd // indirect + github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b // indirect + github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/chai2010/gettext-go v1.0.2 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/docker/cli v24.0.6+incompatible // indirect + github.com/docker/distribution v2.8.2+incompatible // indirect + github.com/docker/docker v24.0.7+incompatible // indirect + github.com/docker/docker-credential-helpers v0.7.0 // indirect + github.com/docker/go-connections v0.4.0 // indirect + github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect + github.com/docker/go-metrics v0.0.1 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1 // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect + github.com/fatih/color v1.13.0 // indirect + github.com/felixge/httpsnoop v1.0.3 // indirect + github.com/fvbommel/sortorder v1.1.0 // indirect + github.com/go-errors/errors v1.4.2 // indirect + github.com/go-gorp/gorp/v3 v3.1.0 // indirect + github.com/go-logr/logr v1.3.0 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.22.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/gomodule/redigo v1.8.2 // indirect + github.com/google/btree v1.0.1 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/go-cmp v0.6.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.3.0 // indirect + github.com/gorilla/handlers v1.5.1 // indirect + github.com/gorilla/mux v1.8.0 // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/golang-lru v0.5.4 // indirect + github.com/huandu/xstrings v1.4.0 // indirect + github.com/imdario/mergo v0.3.13 // 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/klauspost/compress v1.16.0 // indirect + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect + github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/mattn/go-runewidth v0.0.9 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/miekg/dns v1.1.25 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/moby/locker v1.0.1 // indirect + github.com/moby/spdystream v0.2.0 // 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/morikuni/aec v1.0.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/opencontainers/go-digest v1.0.0 // 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.16.0 // indirect + github.com/prometheus/client_model v0.4.0 // indirect + github.com/prometheus/common v0.44.0 // indirect + github.com/prometheus/procfs v0.10.1 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/shopspring/decimal v1.3.1 // indirect + github.com/spf13/cast v1.5.0 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xlab/treeprint v1.2.0 // indirect + github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43 // indirect + github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50 // indirect + github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.45.0 // indirect + go.opentelemetry.io/otel v1.19.0 // indirect + go.opentelemetry.io/otel/metric v1.19.0 // indirect + go.opentelemetry.io/otel/trace v1.19.0 // indirect + go.starlark.net v0.0.0-20230525235612-a134d8f9ddca // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/oauth2 v0.10.0 // indirect + golang.org/x/sync v0.3.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/time v0.3.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect + google.golang.org/grpc v1.58.3 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/component-base v0.29.0 // indirect + k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 // indirect + k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/kustomize/api v0.13.5-0.20230601165947-6ce0bf390ce3 // indirect + sigs.k8s.io/kustomize/kyaml v0.14.3-0.20230601165947-6ce0bf390ce3 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..2799262d --- /dev/null +++ b/go.sum @@ -0,0 +1,598 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +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/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +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.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= +github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= +github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= +github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/Masterminds/vcs v1.13.3 h1:IIA2aBdXvfbIM+yl/eTnL4hb1XwdpvuQLglAix1gweE= +github.com/Masterminds/vcs v1.13.3/go.mod h1:TiE7xuEjl1N4j016moRd6vezp6e6Lz23gypeXfzXeW8= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/Microsoft/hcsshim v0.11.4 h1:68vKo2VN8DE9AdN4tnkWnmdhqdbpUFM8OF3Airm7fz8= +github.com/Microsoft/hcsshim v0.11.4/go.mod h1:smjE4dvqPX9Zldna+t5FG3rnoHhaB7QYxPRqGcpAD9w= +github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs= +github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535 h1:4daAzAu0S6Vi7/lbWECcX0j45yZReDZ56BQsrVBOEEY= +github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= +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/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y= +github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= +github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70= +github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= +github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd h1:rFt+Y/IK1aEZkEHchZRSq9OQbsSzIT/OrI8YFFmRIng= +github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= +github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b h1:otBG+dV+YK+Soembjv71DPz3uX/V/6MMlSyD9JBQ6kQ= +github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50= +github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0 h1:nvj0OLI3YqYXer/kZD8Ri1aaunCxIEsOst1BVJswV0o= +github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +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/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM= +github.com/containerd/cgroups v1.1.0/go.mod h1:6ppBcbh/NOOUU+dMKrykgaBnK9lCIBxHqJDGwsa1mIw= +github.com/containerd/containerd v1.7.12 h1:+KQsnv4VnzyxWcfO9mlxxELaoztsDEjOuCMPAuPqgU0= +github.com/containerd/containerd v1.7.12/go.mod h1:/5OMpE1p0ylxtEUGY8kuCYkDRzJm9NO1TFMWjUpdevk= +github.com/containerd/continuity v0.4.2 h1:v3y/4Yz5jwnvqPKJJ+7Wf93fyWoCB3F5EclWG023MDM= +github.com/containerd/continuity v0.4.2/go.mod h1:F6PTNCKepoxEaXLQp3wDAjygEnImnZ/7o4JzpodfroQ= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= +github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= +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/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2 h1:aBfCb7iqHmDEIp6fBvC/hQUddQfg+3qdYjwzaiP9Hnc= +github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2/go.mod h1:WHNsWjnIn2V1LYOrME7e8KxSeKunYHsxEm4am0BUtcI= +github.com/docker/cli v24.0.6+incompatible h1:fF+XCQCgJjjQNIMjzaSmiKJSCcfcXb3TWTcc7GAneOY= +github.com/docker/cli v24.0.6+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= +github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v24.0.7+incompatible h1:Wo6l37AuwP3JaMnZa226lzVXGA3F9Ig1seQen0cKYlM= +github.com/docker/docker v24.0.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker-credential-helpers v0.7.0 h1:xtCHsjxogADNZcdv1pKUHXryefjlVRqWqIhk/uXJp0A= +github.com/docker/docker-credential-helpers v0.7.0/go.mod h1:rETQfLdHNT3foU5kuNkFR1R1V12OJRRO5lzt2D1b5X0= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= +github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= +github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1 h1:ZClxb8laGDf5arXfYcAtECDFgAgHklGI8CxgjHnXKJ4= +github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/evanphx/json-patch v5.7.0+incompatible h1:vgGkfT/9f8zE6tvSCe74nfpAVDQ2tG6yudJd8LBksgI= +github.com/evanphx/json-patch v5.7.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +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/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= +github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/foxcpp/go-mockdns v1.0.0 h1:7jBqxd3WDWwi/6WhDvacvH1XsN3rOLXyHM1uhvIx6FI= +github.com/foxcpp/go-mockdns v1.0.0/go.mod h1:lgRN6+KxQBawyIghpnl5CezHFGS9VLzvtVlwxvzXTQ4= +github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= +github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= +github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw= +github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= +github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +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-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= +github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/gobuffalo/logger v1.0.6 h1:nnZNpxYo0zx+Aj9RfMPBm+x9zAU2OayFh/xrAWi34HU= +github.com/gobuffalo/logger v1.0.6/go.mod h1:J31TBEHR1QLV2683OXTAItYIg8pv2JMHnF/quuAbMjs= +github.com/gobuffalo/packd v1.0.1 h1:U2wXfRr4E9DH8IdsDLlRFwTZTK7hLfq9qT/QHXGVe/0= +github.com/gobuffalo/packd v1.0.1/go.mod h1:PP2POP3p3RXGz7Jh6eYEf93S7vA2za6xM7QT85L4+VY= +github.com/gobuffalo/packr/v2 v2.8.3 h1:xE1yzvnO56cUC0sTpKR3DIbxZgB54AftTFMhB2XEWlY= +github.com/gobuffalo/packr/v2 v2.8.3/go.mod h1:0SahksCVcx4IMnigTjiFuyldmTrdTctXsOdiU5KwbKc= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +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/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/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.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.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/gomodule/redigo v1.8.2 h1:H5XSIre1MB5NbPYFp+i1NBbb5qN1W8Y8YAQoAYbkm8k= +github.com/gomodule/redigo v1.8.2/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0= +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/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +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.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.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.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/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +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.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= +github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= +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 v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY= +github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= +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/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= +github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= +github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +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/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.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/karrick/godirwalk v1.16.1 h1:DynhcF+bztK8gooS0+NDJFrdNZjJ3gzVzC545UNA9iw= +github.com/karrick/godirwalk v1.16.1/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= +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/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= +github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4= +github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +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/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +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/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/markbates/errx v1.1.0 h1:QDFeR+UP95dO12JgW+tgi2UVfo0V8YBHiUIOaeBPiEI= +github.com/markbates/errx v1.1.0/go.mod h1:PLa46Oex9KNbVDZhKel8v1OT7hD5JZ2eI7AHhA0wswc= +github.com/markbates/oncer v1.0.0 h1:E83IaVAHygyndzPimgUYJjbshhDTALZyXxvk9FOlQRY= +github.com/markbates/oncer v1.0.0/go.mod h1:Z59JA581E9GP6w96jai+TGqafHPW+cPfRxz2aSZ0mcI= +github.com/markbates/safe v1.0.1 h1:yjZkbvRM6IzKj9tlu/zMJLS0n/V351OZWRnF3QfaUxI= +github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= +github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= +github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/miekg/dns v1.1.25 h1:dFwPR6SfLtrSwgDcIq2bcU/gVutB4sNApq2HBdqcakg= +github.com/miekg/dns v1.1.25/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f h1:2+myh5ml7lgEU/51gbeLHfKGNfgEQQIWrlbdaOsidbQ= +github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= +github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= +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/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78= +github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +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/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +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/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4= +github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= +github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg= +github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0-rc5 h1:Ygwkfw9bpDvs+c9E34SdgGOj41dX/cbdlwvlWt0pnFI= +github.com/opencontainers/image-spec v1.1.0-rc5/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8= +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/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= +github.com/pkg/errors v0.8.0/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= +github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= +github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= +github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= +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.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= +github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= +github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= +github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= +github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= +github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rubenv/sql-migrate v1.5.2 h1:bMDqOnrJVV/6JQgQ/MxOpU+AdO8uzYYA/TxFUBzFtS0= +github.com/rubenv/sql-migrate v1.5.2/go.mod h1:H38GW8Vqf8F0Su5XignRyaRcbXbJunSWxs+kmzlg0Is= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +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/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= +github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +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/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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +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.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +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/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43 h1:+lm10QQTNSBd8DVTNGHx7o/IKu9HYDvLMffDhbyLccI= +github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs= +github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50 h1:hlE8//ciYMztlGpl/VA+Zm1AcTPHYkHJPbHqE6WJUXE= +github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA= +github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f h1:ERexzlUfuTvpE74urLSbIQW0Z/6hF9t8U4NsJLaioAY= +github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.45.0 h1:x8Z78aZx8cOF0+Kkazoc7lwUNMGy0LrzEMxTm4BbTxg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.45.0/go.mod h1:62CPTSry9QZtOaSsE3tOzhx6LzDhHnXJ6xHeMNNiM6Q= +go.opentelemetry.io/otel v1.19.0 h1:MuS/TNf4/j4IXsZuJegVzI1cwut7Qc00344rgH7p8bs= +go.opentelemetry.io/otel v1.19.0/go.mod h1:i0QyjOq3UPoTzff0PJB2N66fb4S0+rSbSB15/oyH9fY= +go.opentelemetry.io/otel/metric v1.19.0 h1:aTzpGtV0ar9wlV4Sna9sdJyII5jTVJEvKETPiOKwvpE= +go.opentelemetry.io/otel/metric v1.19.0/go.mod h1:L5rUsV9kM1IxCj1MmSdS+JQAcVm319EUrDVLrt7jqt8= +go.opentelemetry.io/otel/trace v1.19.0 h1:DFVQmlVbfVeOuBRrwdtaehRrWiL1JoVs9CPIQ1Dzxpg= +go.opentelemetry.io/otel/trace v1.19.0/go.mod h1:mfaSyvGyEJEI0nyV2I4qhNQnbBOUUmYZpYojqMnX2vo= +go.starlark.net v0.0.0-20230525235612-a134d8f9ddca h1:VdD38733bfYv5tUZwEIskMM93VanwNIi5bIKnDrJdEY= +go.starlark.net v0.0.0-20230525235612-a134d8f9ddca/go.mod h1:jxU+3+j+71eXOW14274+SmmuW82qJzl6iZSeqEtTGds= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/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-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +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/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-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +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.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= +golang.org/x/mod v0.12.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-20181114220301-adae6a3d119a/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-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-20190923162816-aa69164e4478/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-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +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.10.0 h1:zHCpF2Khkwy4mMB4bv0U37YtJdTGW8jI0glAApi0Kh8= +golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI= +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-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-20201020160332-67f06af15bc9/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.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +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-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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/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-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-20210630005230-0f9fa26af87c/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-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +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.3.0/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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +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.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/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-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/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-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.12.0 h1:YW6HUoUmYBpwSgyaGaZq1fHjrBjX1rlpZ54T6mu2kss= +golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM= +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= +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.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-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d h1:uvYuEyMHKNt+lT4K3bN6fGswmK8qSvcreM3BwjDh+y4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d/go.mod h1:+Bk1OCOj40wS2hwAMA+aCW9ypzm63QTBBHp6lQ3p+9M= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.58.3 h1:BjnpXut1btbtgN/6sp+brB2Kbm2LjNXnidYujAVbSoQ= +google.golang.org/grpc v1.58.3/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= +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.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +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.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +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.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/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/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o= +gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +k8s.io/api v0.29.0 h1:NiCdQMY1QOp1H8lfRyeEf8eOwV6+0xA6XEE44ohDX2A= +k8s.io/api v0.29.0/go.mod h1:sdVmXoz2Bo/cb77Pxi71IPTSErEW32xa4aXwKH7gfBA= +k8s.io/apiextensions-apiserver v0.29.0 h1:0VuspFG7Hj+SxyF/Z/2T0uFbI5gb5LRgEyUVE3Q4lV0= +k8s.io/apiextensions-apiserver v0.29.0/go.mod h1:TKmpy3bTS0mr9pylH0nOt/QzQRrW7/h7yLdRForMZwc= +k8s.io/apimachinery v0.29.0 h1:+ACVktwyicPz0oc6MTMLwa2Pw3ouLAfAon1wPLtG48o= +k8s.io/apimachinery v0.29.0/go.mod h1:eVBxQ/cwiJxH58eK/jd/vAk4mrxmVlnpBH5J2GbMeis= +k8s.io/apiserver v0.29.0 h1:Y1xEMjJkP+BIi0GSEv1BBrf1jLU9UPfAnnGGbbDdp7o= +k8s.io/apiserver v0.29.0/go.mod h1:31n78PsRKPmfpee7/l9NYEv67u6hOL6AfcE761HapDM= +k8s.io/cli-runtime v0.29.0 h1:q2kC3cex4rOBLfPOnMSzV2BIrrQlx97gxHJs21KxKS4= +k8s.io/cli-runtime v0.29.0/go.mod h1:VKudXp3X7wR45L+nER85YUzOQIru28HQpXr0mTdeCrk= +k8s.io/client-go v0.29.0 h1:KmlDtFcrdUzOYrBhXHgKw5ycWzc3ryPX5mQe0SkG3y8= +k8s.io/client-go v0.29.0/go.mod h1:yLkXH4HKMAywcrD82KMSmfYg2DlE8mepPR4JGSo5n38= +k8s.io/component-base v0.29.0 h1:T7rjd5wvLnPBV1vC4zWd/iWRbV8Mdxs+nGaoaFzGw3s= +k8s.io/component-base v0.29.0/go.mod h1:sADonFTQ9Zc9yFLghpDpmNXEdHyQmFIGbiuZbqAXQ1M= +k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0= +k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo= +k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 h1:aVUu9fTY98ivBPKR9Y5w/AuzbMm96cd3YHRTU83I780= +k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA= +k8s.io/kubectl v0.29.0 h1:Oqi48gXjikDhrBF67AYuZRTcJV4lg2l42GmvsP7FmYI= +k8s.io/kubectl v0.29.0/go.mod h1:0jMjGWIcMIQzmUaMgAzhSELv5WtHo2a8pq67DtviAJs= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +oras.land/oras-go v1.2.4 h1:djpBY2/2Cs1PV87GSJlxv4voajVOMZxqqtq9AB8YNvY= +oras.land/oras-go v1.2.4/go.mod h1:DYcGfb3YF1nKjcezfX2SNlDAeQFKSXmf+qrFmrh4324= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/kustomize/api v0.13.5-0.20230601165947-6ce0bf390ce3 h1:XX3Ajgzov2RKUdc5jW3t5jwY7Bo7dcRm+tFxT+NfgY0= +sigs.k8s.io/kustomize/api v0.13.5-0.20230601165947-6ce0bf390ce3/go.mod h1:9n16EZKMhXBNSiUC5kSdFQJkdH3zbxS/JoO619G1VAY= +sigs.k8s.io/kustomize/kyaml v0.14.3-0.20230601165947-6ce0bf390ce3 h1:W6cLQc5pnqM7vh3b7HvGNfXrJ/xL6BDMS0v1V/HHg5U= +sigs.k8s.io/kustomize/kyaml v0.14.3-0.20230601165947-6ce0bf390ce3/go.mod h1:JWP1Fj0VWGHyw3YUPjXSQnRnrwezrZSrApfX5S0nIag= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/internal/fileutil/fileutil.go b/internal/fileutil/fileutil.go new file mode 100644 index 00000000..4ea09cca --- /dev/null +++ b/internal/fileutil/fileutil.go @@ -0,0 +1,50 @@ +/* +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 fileutil + +import ( + "io" + "os" + "path/filepath" + + "helm.sh/helm/v3/internal/third_party/dep/fs" +) + +// AtomicWriteFile atomically (as atomic as os.Rename allows) writes a file to a +// disk. +func AtomicWriteFile(filename string, reader io.Reader, mode os.FileMode) error { + tempFile, err := os.CreateTemp(filepath.Split(filename)) + if err != nil { + return err + } + tempName := tempFile.Name() + + if _, err := io.Copy(tempFile, reader); err != nil { + tempFile.Close() // return value is ignored as we are already on error path + return err + } + + if err := tempFile.Close(); err != nil { + return err + } + + if err := os.Chmod(tempName, mode); err != nil { + return err + } + + return fs.RenameWithFallback(tempName, filename) +} diff --git a/internal/fileutil/fileutil_test.go b/internal/fileutil/fileutil_test.go new file mode 100644 index 00000000..92920d3c --- /dev/null +++ b/internal/fileutil/fileutil_test.go @@ -0,0 +1,57 @@ +/* +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 fileutil + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestAtomicWriteFile(t *testing.T) { + dir := t.TempDir() + + testpath := filepath.Join(dir, "test") + stringContent := "Test content" + reader := bytes.NewReader([]byte(stringContent)) + mode := os.FileMode(0644) + + err := AtomicWriteFile(testpath, reader, mode) + if err != nil { + t.Errorf("AtomicWriteFile error: %s", err) + } + + got, err := os.ReadFile(testpath) + if err != nil { + t.Fatal(err) + } + + if stringContent != string(got) { + t.Fatalf("expected: %s, got: %s", stringContent, string(got)) + } + + gotinfo, err := os.Stat(testpath) + if err != nil { + t.Fatal(err) + } + + if mode != gotinfo.Mode() { + t.Fatalf("expected %s: to be the same mode as %s", + mode, gotinfo.Mode()) + } +} diff --git a/internal/monocular/client.go b/internal/monocular/client.go new file mode 100644 index 00000000..88a2564b --- /dev/null +++ b/internal/monocular/client.go @@ -0,0 +1,68 @@ +/* +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 monocular + +import ( + "errors" + "net/url" +) + +// ErrHostnameNotProvided indicates the url is missing a hostname +var ErrHostnameNotProvided = errors.New("no hostname provided") + +// Client represents a client capable of communicating with the Monocular API. +type Client struct { + + // The base URL for requests + BaseURL string + + // The internal logger to use + Log func(string, ...interface{}) +} + +// New creates a new client +func New(u string) (*Client, error) { + + // Validate we have a URL + if err := validate(u); err != nil { + return nil, err + } + + return &Client{ + BaseURL: u, + Log: nopLogger, + }, nil +} + +var nopLogger = func(_ string, _ ...interface{}) {} + +// Validate if the base URL for monocular is valid. +func validate(u string) error { + + // Check if it is parsable + p, err := url.Parse(u) + if err != nil { + return err + } + + // Check that a host is attached + if p.Hostname() == "" { + return ErrHostnameNotProvided + } + + return nil +} diff --git a/internal/monocular/client_test.go b/internal/monocular/client_test.go new file mode 100644 index 00000000..abf914ef --- /dev/null +++ b/internal/monocular/client_test.go @@ -0,0 +1,31 @@ +/* +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 monocular + +import ( + "testing" +) + +func TestNew(t *testing.T) { + c, err := New("https://hub.helm.sh") + if err != nil { + t.Errorf("error creating client: %s", err) + } + if c.BaseURL != "https://hub.helm.sh" { + t.Errorf("incorrect BaseURL. Expected \"https://hub.helm.sh\" but got %q", c.BaseURL) + } +} diff --git a/internal/monocular/doc.go b/internal/monocular/doc.go new file mode 100644 index 00000000..5d402d35 --- /dev/null +++ b/internal/monocular/doc.go @@ -0,0 +1,22 @@ +/* +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 monocular contains the logic for interacting with a Monocular +// compatible search API endpoint. For example, as implemented by the Artifact +// Hub. +// +// This is a library for interacting with a monocular compatible search API +package monocular diff --git a/internal/monocular/search.go b/internal/monocular/search.go new file mode 100644 index 00000000..4e7e8c00 --- /dev/null +++ b/internal/monocular/search.go @@ -0,0 +1,145 @@ +/* +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 monocular + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" + "time" + + "helm.sh/helm/v3/internal/version" + "helm.sh/helm/v3/pkg/chart" +) + +// SearchPath is the url path to the search API in monocular. +const SearchPath = "api/chartsvc/v1/charts/search" + +// The structs below represent the structure of the response from the monocular +// search API. The structs were not imported from monocular because monocular +// imports from Helm v2 (avoiding circular version dependency) and the mappings +// are slightly different (monocular search results do not directly reflect +// the struct definitions). + +// SearchResult represents an individual chart result +type SearchResult struct { + ID string `json:"id"` + ArtifactHub ArtifactHub `json:"artifactHub"` + Type string `json:"type"` + Attributes Chart `json:"attributes"` + Links Links `json:"links"` + Relationships Relationships `json:"relationships"` +} + +// ArtifactHub represents data specific to Artifact Hub instances +type ArtifactHub struct { + PackageURL string `json:"packageUrl"` +} + +// Chart is the attributes for the chart +type Chart struct { + Name string `json:"name"` + Repo Repo `json:"repo"` + Description string `json:"description"` + Home string `json:"home"` + Keywords []string `json:"keywords"` + Maintainers []chart.Maintainer `json:"maintainers"` + Sources []string `json:"sources"` + Icon string `json:"icon"` +} + +// Repo contains the name in monocular the url for the repository +type Repo struct { + Name string `json:"name"` + URL string `json:"url"` +} + +// Links provides a set of links relative to the chartsvc base +type Links struct { + Self string `json:"self"` +} + +// Relationships provides information on the latest version of the chart +type Relationships struct { + LatestChartVersion LatestChartVersion `json:"latestChartVersion"` +} + +// LatestChartVersion provides the details on the latest version of the chart +type LatestChartVersion struct { + Data ChartVersion `json:"data"` + Links Links `json:"links"` +} + +// ChartVersion provides the specific data on the chart version +type ChartVersion struct { + Version string `json:"version"` + AppVersion string `json:"app_version"` + Created time.Time `json:"created"` + Digest string `json:"digest"` + Urls []string `json:"urls"` + Readme string `json:"readme"` + Values string `json:"values"` +} + +// Search performs a search against the monocular search API +func (c *Client) Search(term string) ([]SearchResult, error) { + + // Create the URL to the search endpoint + // Note, this is currently an internal API for the Hub. This should be + // formatted without showing how monocular operates. + p, err := url.Parse(c.BaseURL) + if err != nil { + return nil, err + } + + // Set the path to the monocular API endpoint for search + p.Path = path.Join(p.Path, SearchPath) + + p.RawQuery = "q=" + url.QueryEscape(term) + + // Create request + req, err := http.NewRequest(http.MethodGet, p.String(), nil) + if err != nil { + return nil, err + } + + // Set the user agent so that monocular can identify where the request + // is coming from + req.Header.Set("User-Agent", version.GetUserAgent()) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != 200 { + return nil, fmt.Errorf("failed to fetch %s : %s", p.String(), res.Status) + } + + result := &searchResponse{} + + json.NewDecoder(res.Body).Decode(result) + + return result.Data, nil +} + +type searchResponse struct { + Data []SearchResult `json:"data"` +} diff --git a/internal/monocular/search_test.go b/internal/monocular/search_test.go new file mode 100644 index 00000000..9f6954af --- /dev/null +++ b/internal/monocular/search_test.go @@ -0,0 +1,49 @@ +/* +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 monocular + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +// A search response for phpmyadmin containing 2 results +var searchResult = `{"data":[{"id":"stable/phpmyadmin","type":"chart","attributes":{"name":"phpmyadmin","repo":{"name":"stable","url":"https://charts.helm.sh/stable"},"description":"phpMyAdmin is an mysql administration frontend","home":"https://www.phpmyadmin.net/","keywords":["mariadb","mysql","phpmyadmin"],"maintainers":[{"name":"Bitnami","email":"containers@bitnami.com"}],"sources":["https://github.com/bitnami/bitnami-docker-phpmyadmin"],"icon":""},"links":{"self":"/v1/charts/stable/phpmyadmin"},"relationships":{"latestChartVersion":{"data":{"version":"3.0.0","app_version":"4.9.0-1","created":"2019-08-08T17:57:31.38Z","digest":"119c499251bffd4b06ff0cd5ac98c2ce32231f84899fb4825be6c2d90971c742","urls":["https://charts.helm.sh/stable/phpmyadmin-3.0.0.tgz"],"readme":"/v1/assets/stable/phpmyadmin/versions/3.0.0/README.md","values":"/v1/assets/stable/phpmyadmin/versions/3.0.0/values.yaml"},"links":{"self":"/v1/charts/stable/phpmyadmin/versions/3.0.0"}}}},{"id":"bitnami/phpmyadmin","type":"chart","attributes":{"name":"phpmyadmin","repo":{"name":"bitnami","url":"https://charts.bitnami.com"},"description":"phpMyAdmin is an mysql administration frontend","home":"https://www.phpmyadmin.net/","keywords":["mariadb","mysql","phpmyadmin"],"maintainers":[{"name":"Bitnami","email":"containers@bitnami.com"}],"sources":["https://github.com/bitnami/bitnami-docker-phpmyadmin"],"icon":""},"links":{"self":"/v1/charts/bitnami/phpmyadmin"},"relationships":{"latestChartVersion":{"data":{"version":"3.0.0","app_version":"4.9.0-1","created":"2019-08-08T18:34:13.341Z","digest":"66d77cf6d8c2b52c488d0a294cd4996bd5bad8dc41d3829c394498fb401c008a","urls":["https://charts.bitnami.com/bitnami/phpmyadmin-3.0.0.tgz"],"readme":"/v1/assets/bitnami/phpmyadmin/versions/3.0.0/README.md","values":"/v1/assets/bitnami/phpmyadmin/versions/3.0.0/values.yaml"},"links":{"self":"/v1/charts/bitnami/phpmyadmin/versions/3.0.0"}}}}]}` + +func TestSearch(t *testing.T) { + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, searchResult) + })) + defer ts.Close() + + c, err := New(ts.URL) + if err != nil { + t.Errorf("unable to create monocular client: %s", err) + } + + results, err := c.Search("phpmyadmin") + if err != nil { + t.Errorf("unable to search monocular: %s", err) + } + + if len(results) != 2 { + t.Error("Did not receive the expected number of results") + } +} diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go new file mode 100644 index 00000000..5e8921f9 --- /dev/null +++ b/internal/resolver/resolver.go @@ -0,0 +1,263 @@ +/* +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 resolver + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/pkg/errors" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/helmpath" + "helm.sh/helm/v3/pkg/provenance" + "helm.sh/helm/v3/pkg/registry" + "helm.sh/helm/v3/pkg/repo" +) + +// Resolver resolves dependencies from semantic version ranges to a particular version. +type Resolver struct { + chartpath string + cachepath string + registryClient *registry.Client +} + +// New creates a new resolver for a given chart, helm home and registry client. +func New(chartpath, cachepath string, registryClient *registry.Client) *Resolver { + return &Resolver{ + chartpath: chartpath, + cachepath: cachepath, + registryClient: registryClient, + } +} + +// Resolve resolves dependencies and returns a lock file with the resolution. +func (r *Resolver) Resolve(reqs []*chart.Dependency, repoNames map[string]string) (*chart.Lock, error) { + + // Now we clone the dependencies, locking as we go. + locked := make([]*chart.Dependency, len(reqs)) + missing := []string{} + for i, d := range reqs { + constraint, err := semver.NewConstraint(d.Version) + if err != nil { + return nil, errors.Wrapf(err, "dependency %q has an invalid version/constraint format", d.Name) + } + + if d.Repository == "" { + // Local chart subfolder + if _, err := GetLocalPath(filepath.Join("charts", d.Name), r.chartpath); err != nil { + return nil, err + } + + locked[i] = &chart.Dependency{ + Name: d.Name, + Repository: "", + Version: d.Version, + } + continue + } + if strings.HasPrefix(d.Repository, "file://") { + + chartpath, err := GetLocalPath(d.Repository, r.chartpath) + if err != nil { + return nil, err + } + + ch, err := loader.LoadDir(chartpath) + if err != nil { + return nil, err + } + + v, err := semver.NewVersion(ch.Metadata.Version) + if err != nil { + // Not a legit entry. + continue + } + + if !constraint.Check(v) { + missing = append(missing, d.Name) + continue + } + + locked[i] = &chart.Dependency{ + Name: d.Name, + Repository: d.Repository, + Version: ch.Metadata.Version, + } + continue + } + + repoName := repoNames[d.Name] + // if the repository was not defined, but the dependency defines a repository url, bypass the cache + if repoName == "" && d.Repository != "" { + locked[i] = &chart.Dependency{ + Name: d.Name, + Repository: d.Repository, + Version: d.Version, + } + continue + } + + var vs repo.ChartVersions + var version string + var ok bool + found := true + if !registry.IsOCI(d.Repository) { + repoIndex, err := repo.LoadIndexFile(filepath.Join(r.cachepath, helmpath.CacheIndexFile(repoName))) + if err != nil { + return nil, errors.Wrapf(err, "no cached repository for %s found. (try 'helm repo update')", repoName) + } + + vs, ok = repoIndex.Entries[d.Name] + if !ok { + return nil, errors.Errorf("%s chart not found in repo %s", d.Name, d.Repository) + } + found = false + } else { + version = d.Version + + // Check to see if an explicit version has been provided + _, err := semver.NewVersion(version) + + // Use an explicit version, otherwise search for tags + if err == nil { + vs = []*repo.ChartVersion{{ + Metadata: &chart.Metadata{ + Version: version, + }, + }} + + } else { + // Retrieve list of tags for repository + ref := fmt.Sprintf("%s/%s", strings.TrimPrefix(d.Repository, fmt.Sprintf("%s://", registry.OCIScheme)), d.Name) + tags, err := r.registryClient.Tags(ref) + if err != nil { + return nil, errors.Wrapf(err, "could not retrieve list of tags for repository %s", d.Repository) + } + + vs = make(repo.ChartVersions, len(tags)) + for ti, t := range tags { + // Mock chart version objects + version := &repo.ChartVersion{ + Metadata: &chart.Metadata{ + Version: t, + }, + } + vs[ti] = version + } + } + } + + locked[i] = &chart.Dependency{ + Name: d.Name, + Repository: d.Repository, + Version: version, + } + // The version are already sorted and hence the first one to satisfy the constraint is used + for _, ver := range vs { + v, err := semver.NewVersion(ver.Version) + // OCI does not need URLs + if err != nil || (!registry.IsOCI(d.Repository) && len(ver.URLs) == 0) { + // Not a legit entry. + continue + } + if constraint.Check(v) { + found = true + locked[i].Version = v.Original() + break + } + } + + if !found { + missing = append(missing, d.Name) + } + } + if len(missing) > 0 { + return nil, errors.Errorf("can't get a valid version for repositories %s. Try changing the version constraint in Chart.yaml", strings.Join(missing, ", ")) + } + + digest, err := HashReq(reqs, locked) + if err != nil { + return nil, err + } + + return &chart.Lock{ + Generated: time.Now(), + Digest: digest, + Dependencies: locked, + }, nil +} + +// HashReq generates a hash of the dependencies. +// +// This should be used only to compare against another hash generated by this +// function. +func HashReq(req, lock []*chart.Dependency) (string, error) { + data, err := json.Marshal([2][]*chart.Dependency{req, lock}) + if err != nil { + return "", err + } + s, err := provenance.Digest(bytes.NewBuffer(data)) + return "sha256:" + s, err +} + +// HashV2Req generates a hash of requirements generated in Helm v2. +// +// This should be used only to compare against another hash generated by the +// Helm v2 hash function. It is to handle issue: +// https://github.com/helm/helm/issues/7233 +func HashV2Req(req []*chart.Dependency) (string, error) { + dep := make(map[string][]*chart.Dependency) + dep["dependencies"] = req + data, err := json.Marshal(dep) + if err != nil { + return "", err + } + s, err := provenance.Digest(bytes.NewBuffer(data)) + return "sha256:" + s, err +} + +// GetLocalPath generates absolute local path when use +// "file://" in repository of dependencies +func GetLocalPath(repo, chartpath string) (string, error) { + var depPath string + var err error + p := strings.TrimPrefix(repo, "file://") + + // root path is absolute + if strings.HasPrefix(p, "/") { + if depPath, err = filepath.Abs(p); err != nil { + return "", err + } + } else { + depPath = filepath.Join(chartpath, p) + } + + if _, err = os.Stat(depPath); os.IsNotExist(err) { + return "", errors.Errorf("directory %s not found", depPath) + } else if err != nil { + return "", err + } + + return depPath, nil +} diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go new file mode 100644 index 00000000..a7985217 --- /dev/null +++ b/internal/resolver/resolver_test.go @@ -0,0 +1,310 @@ +/* +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 resolver + +import ( + "runtime" + "testing" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/registry" +) + +func TestResolve(t *testing.T) { + tests := []struct { + name string + req []*chart.Dependency + expect *chart.Lock + err bool + }{ + { + name: "repo from invalid version", + req: []*chart.Dependency{ + {Name: "base", Repository: "file://base", Version: "1.1.0"}, + }, + expect: &chart.Lock{ + Dependencies: []*chart.Dependency{ + {Name: "base", Repository: "file://base", Version: "0.1.0"}, + }, + }, + err: true, + }, + { + name: "version failure", + req: []*chart.Dependency{ + {Name: "oedipus-rex", Repository: "http://example.com", Version: ">a1"}, + }, + err: true, + }, + { + name: "cache index failure", + req: []*chart.Dependency{ + {Name: "oedipus-rex", Repository: "http://example.com", Version: "1.0.0"}, + }, + expect: &chart.Lock{ + Dependencies: []*chart.Dependency{ + {Name: "oedipus-rex", Repository: "http://example.com", Version: "1.0.0"}, + }, + }, + }, + { + name: "chart not found failure", + req: []*chart.Dependency{ + {Name: "redis", Repository: "http://example.com", Version: "1.0.0"}, + }, + err: true, + }, + { + name: "constraint not satisfied failure", + req: []*chart.Dependency{ + {Name: "alpine", Repository: "http://example.com", Version: ">=1.0.0"}, + }, + err: true, + }, + { + name: "valid lock", + req: []*chart.Dependency{ + {Name: "alpine", Repository: "http://example.com", Version: ">=0.1.0"}, + }, + expect: &chart.Lock{ + Dependencies: []*chart.Dependency{ + {Name: "alpine", Repository: "http://example.com", Version: "0.2.0"}, + }, + }, + }, + { + name: "repo from valid local path", + req: []*chart.Dependency{ + {Name: "base", Repository: "file://base", Version: "0.1.0"}, + }, + expect: &chart.Lock{ + Dependencies: []*chart.Dependency{ + {Name: "base", Repository: "file://base", Version: "0.1.0"}, + }, + }, + }, + { + name: "repo from valid local path with range resolution", + req: []*chart.Dependency{ + {Name: "base", Repository: "file://base", Version: "^0.1.0"}, + }, + expect: &chart.Lock{ + Dependencies: []*chart.Dependency{ + {Name: "base", Repository: "file://base", Version: "0.1.0"}, + }, + }, + }, + { + name: "repo from invalid local path", + req: []*chart.Dependency{ + {Name: "nonexistent", Repository: "file://testdata/nonexistent", Version: "0.1.0"}, + }, + err: true, + }, + { + name: "repo from valid path under charts path", + req: []*chart.Dependency{ + {Name: "localdependency", Repository: "", Version: "0.1.0"}, + }, + expect: &chart.Lock{ + Dependencies: []*chart.Dependency{ + {Name: "localdependency", Repository: "", Version: "0.1.0"}, + }, + }, + }, + { + name: "repo from invalid path under charts path", + req: []*chart.Dependency{ + {Name: "nonexistentdependency", Repository: "", Version: "0.1.0"}, + }, + expect: &chart.Lock{ + Dependencies: []*chart.Dependency{ + {Name: "nonexistentlocaldependency", Repository: "", Version: "0.1.0"}, + }, + }, + err: true, + }, + } + + repoNames := map[string]string{"alpine": "kubernetes-charts", "redis": "kubernetes-charts"} + registryClient, _ := registry.NewClient() + r := New("testdata/chartpath", "testdata/repository", registryClient) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l, err := r.Resolve(tt.req, repoNames) + if err != nil { + if tt.err { + return + } + t.Fatal(err) + } + + if tt.err { + t.Fatalf("Expected error in test %q", tt.name) + } + + if h, err := HashReq(tt.req, tt.expect.Dependencies); err != nil { + t.Fatal(err) + } else if h != l.Digest { + t.Errorf("%q: hashes don't match.", tt.name) + } + + // Check fields. + if len(l.Dependencies) != len(tt.req) { + t.Errorf("%s: wrong number of dependencies in lock", tt.name) + } + d0 := l.Dependencies[0] + e0 := tt.expect.Dependencies[0] + if d0.Name != e0.Name { + t.Errorf("%s: expected name %s, got %s", tt.name, e0.Name, d0.Name) + } + if d0.Repository != e0.Repository { + t.Errorf("%s: expected repo %s, got %s", tt.name, e0.Repository, d0.Repository) + } + if d0.Version != e0.Version { + t.Errorf("%s: expected version %s, got %s", tt.name, e0.Version, d0.Version) + } + }) + } +} + +func TestHashReq(t *testing.T) { + expect := "sha256:fb239e836325c5fa14b29d1540a13b7d3ba13151b67fe719f820e0ef6d66aaaf" + + tests := []struct { + name string + chartVersion string + lockVersion string + wantError bool + }{ + { + name: "chart with the expected digest", + chartVersion: "0.1.0", + lockVersion: "0.1.0", + wantError: false, + }, + { + name: "ranged version but same resolved lock version", + chartVersion: "^0.1.0", + lockVersion: "0.1.0", + wantError: true, + }, + { + name: "ranged version resolved as higher version", + chartVersion: "^0.1.0", + lockVersion: "0.1.2", + wantError: true, + }, + { + name: "different version", + chartVersion: "0.1.2", + lockVersion: "0.1.2", + wantError: true, + }, + { + name: "different version with a range", + chartVersion: "^0.1.2", + lockVersion: "0.1.2", + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := []*chart.Dependency{ + {Name: "alpine", Version: tt.chartVersion, Repository: "http://localhost:8879/charts"}, + } + lock := []*chart.Dependency{ + {Name: "alpine", Version: tt.lockVersion, Repository: "http://localhost:8879/charts"}, + } + h, err := HashReq(req, lock) + if err != nil { + t.Fatal(err) + } + if !tt.wantError && expect != h { + t.Errorf("Expected %q, got %q", expect, h) + } else if tt.wantError && expect == h { + t.Errorf("Expected not %q, but same", expect) + } + }) + } +} + +func TestGetLocalPath(t *testing.T) { + tests := []struct { + name string + repo string + chartpath string + expect string + winExpect string + err bool + }{ + { + name: "absolute path", + repo: "file:////", + expect: "/", + winExpect: "\\", + }, + { + name: "relative path", + repo: "file://../../testdata/chartpath/base", + chartpath: "foo/bar", + expect: "testdata/chartpath/base", + winExpect: "testdata\\chartpath\\base", + }, + { + name: "current directory path", + repo: "../charts/localdependency", + chartpath: "testdata/chartpath/charts", + expect: "testdata/chartpath/charts/localdependency", + winExpect: "testdata\\chartpath\\charts\\localdependency", + }, + { + name: "invalid local path", + repo: "file://testdata/nonexistent", + chartpath: "testdata/chartpath", + err: true, + }, + { + name: "invalid path under current directory", + repo: "charts/nonexistentdependency", + chartpath: "testdata/chartpath/charts", + err: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p, err := GetLocalPath(tt.repo, tt.chartpath) + if err != nil { + if tt.err { + return + } + t.Fatal(err) + } + if tt.err { + t.Fatalf("Expected error in test %q", tt.name) + } + expect := tt.expect + if runtime.GOOS == "windows" { + expect = tt.winExpect + } + if p != expect { + t.Errorf("%q: expected %q, got %q", tt.name, expect, p) + } + }) + } +} diff --git a/internal/resolver/testdata/chartpath/base/Chart.yaml b/internal/resolver/testdata/chartpath/base/Chart.yaml new file mode 100644 index 00000000..860b0909 --- /dev/null +++ b/internal/resolver/testdata/chartpath/base/Chart.yaml @@ -0,0 +1,3 @@ +apiVersion: v2 +name: base +version: 0.1.0 diff --git a/internal/resolver/testdata/chartpath/charts/localdependency/Chart.yaml b/internal/resolver/testdata/chartpath/charts/localdependency/Chart.yaml new file mode 100644 index 00000000..083c51ee --- /dev/null +++ b/internal/resolver/testdata/chartpath/charts/localdependency/Chart.yaml @@ -0,0 +1,3 @@ +description: A Helm chart for Kubernetes +name: localdependency +version: 0.1.0 diff --git a/internal/resolver/testdata/repository/kubernetes-charts-index.yaml b/internal/resolver/testdata/repository/kubernetes-charts-index.yaml new file mode 100644 index 00000000..c6b7962a --- /dev/null +++ b/internal/resolver/testdata/repository/kubernetes-charts-index.yaml @@ -0,0 +1,49 @@ +apiVersion: v1 +entries: + alpine: + - name: alpine + urls: + - https://charts.helm.sh/stable/alpine-0.1.0.tgz + checksum: 0e6661f193211d7a5206918d42f5c2a9470b737d + home: https://helm.sh/helm + sources: + - https://github.com/helm/helm + version: 0.2.0 + description: Deploy a basic Alpine Linux pod + keywords: [] + maintainers: [] + icon: "" + apiVersion: v2 + - name: alpine + urls: + - https://charts.helm.sh/stable/alpine-0.2.0.tgz + checksum: 0e6661f193211d7a5206918d42f5c2a9470b737d + home: https://helm.sh/helm + sources: + - https://github.com/helm/helm + version: 0.1.0 + description: Deploy a basic Alpine Linux pod + keywords: [] + maintainers: [] + icon: "" + apiVersion: v2 + mariadb: + - name: mariadb + urls: + - https://charts.helm.sh/stable/mariadb-0.3.0.tgz + checksum: 65229f6de44a2be9f215d11dbff311673fc8ba56 + home: https://mariadb.org + sources: + - https://github.com/bitnami/bitnami-docker-mariadb + version: 0.3.0 + description: Chart for MariaDB + keywords: + - mariadb + - mysql + - database + - sql + maintainers: + - name: Bitnami + email: containers@bitnami.com + icon: "" + apiVersion: v2 diff --git a/internal/sympath/walk.go b/internal/sympath/walk.go new file mode 100644 index 00000000..a276cfef --- /dev/null +++ b/internal/sympath/walk.go @@ -0,0 +1,119 @@ +/* +Copyright (c) for portions of walk.go are held by The Go Authors, 2009 and are +provided under the BSD license. + +https://github.com/golang/go/blob/master/LICENSE + +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 sympath + +import ( + "log" + "os" + "path/filepath" + "sort" + + "github.com/pkg/errors" +) + +// Walk walks the file tree rooted at root, calling walkFn for each file or directory +// in the tree, including root. All errors that arise visiting files and directories +// are filtered by walkFn. The files are walked in lexical order, which makes the +// output deterministic but means that for very large directories Walk can be +// inefficient. Walk follows symbolic links. +func Walk(root string, walkFn filepath.WalkFunc) error { + info, err := os.Lstat(root) + if err != nil { + err = walkFn(root, nil, err) + } else { + err = symwalk(root, info, walkFn) + } + if err == filepath.SkipDir { + return nil + } + return err +} + +// readDirNames reads the directory named by dirname and returns +// a sorted list of directory entries. +func readDirNames(dirname string) ([]string, error) { + f, err := os.Open(dirname) + if err != nil { + return nil, err + } + names, err := f.Readdirnames(-1) + f.Close() + if err != nil { + return nil, err + } + sort.Strings(names) + return names, nil +} + +// symwalk recursively descends path, calling walkFn. +func symwalk(path string, info os.FileInfo, walkFn filepath.WalkFunc) error { + // Recursively walk symlinked directories. + if IsSymlink(info) { + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return errors.Wrapf(err, "error evaluating symlink %s", path) + } + log.Printf("found symbolic link in path: %s resolves to %s. Contents of linked file included and used", path, resolved) + if info, err = os.Lstat(resolved); err != nil { + return err + } + if err := symwalk(path, info, walkFn); err != nil && err != filepath.SkipDir { + return err + } + return nil + } + + if err := walkFn(path, info, nil); err != nil { + return err + } + + if !info.IsDir() { + return nil + } + + names, err := readDirNames(path) + if err != nil { + return walkFn(path, info, err) + } + + for _, name := range names { + filename := filepath.Join(path, name) + fileInfo, err := os.Lstat(filename) + if err != nil { + if err := walkFn(filename, fileInfo, err); err != nil && err != filepath.SkipDir { + return err + } + } else { + err = symwalk(filename, fileInfo, walkFn) + if err != nil { + if (!fileInfo.IsDir() && !IsSymlink(fileInfo)) || err != filepath.SkipDir { + return err + } + } + } + } + return nil +} + +// IsSymlink is used to determine if the fileinfo is a symbolic link. +func IsSymlink(fi os.FileInfo) bool { + return fi.Mode()&os.ModeSymlink != 0 +} diff --git a/internal/sympath/walk_test.go b/internal/sympath/walk_test.go new file mode 100644 index 00000000..25f73713 --- /dev/null +++ b/internal/sympath/walk_test.go @@ -0,0 +1,151 @@ +/* +Copyright (c) for portions of walk_test.go are held by The Go Authors, 2009 and are +provided under the BSD license. + +https://github.com/golang/go/blob/master/LICENSE + +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 sympath + +import ( + "os" + "path/filepath" + "testing" +) + +type Node struct { + name string + entries []*Node // nil if the entry is a file + marks int + expectedMarks int + symLinkedTo string +} + +var tree = &Node{ + "testdata", + []*Node{ + {"a", nil, 0, 1, ""}, + {"b", []*Node{}, 0, 1, ""}, + {"c", nil, 0, 2, ""}, + {"d", nil, 0, 0, "c"}, + { + "e", + []*Node{ + {"x", nil, 0, 1, ""}, + {"y", []*Node{}, 0, 1, ""}, + { + "z", + []*Node{ + {"u", nil, 0, 1, ""}, + {"v", nil, 0, 1, ""}, + {"w", nil, 0, 1, ""}, + }, + 0, + 1, + "", + }, + }, + 0, + 1, + "", + }, + }, + 0, + 1, + "", +} + +func walkTree(n *Node, path string, f func(path string, n *Node)) { + f(path, n) + for _, e := range n.entries { + walkTree(e, filepath.Join(path, e.name), f) + } +} + +func makeTree(t *testing.T) { + walkTree(tree, tree.name, func(path string, n *Node) { + if n.entries == nil { + if n.symLinkedTo != "" { + if err := os.Symlink(n.symLinkedTo, path); err != nil { + t.Fatalf("makeTree: %v", err) + } + } else { + fd, err := os.Create(path) + if err != nil { + t.Fatalf("makeTree: %v", err) + return + } + fd.Close() + } + } else { + if err := os.Mkdir(path, 0770); err != nil { + t.Fatalf("makeTree: %v", err) + } + } + }) +} + +func checkMarks(t *testing.T, report bool) { + walkTree(tree, tree.name, func(path string, n *Node) { + if n.marks != n.expectedMarks && report { + t.Errorf("node %s mark = %d; expected %d", path, n.marks, n.expectedMarks) + } + n.marks = 0 + }) +} + +// Assumes that each node name is unique. Good enough for a test. +// If clear is true, any incoming error is cleared before return. The errors +// are always accumulated, though. +func mark(info os.FileInfo, err error, errors *[]error, clear bool) error { + if err != nil { + *errors = append(*errors, err) + if clear { + return nil + } + return err + } + name := info.Name() + walkTree(tree, tree.name, func(path string, n *Node) { + if n.name == name { + n.marks++ + } + }) + return nil +} + +func TestWalk(t *testing.T) { + makeTree(t) + errors := make([]error, 0, 10) + clear := true + markFn := func(path string, info os.FileInfo, err error) error { + return mark(info, err, &errors, clear) + } + // Expect no errors. + err := Walk(tree.name, markFn) + if err != nil { + t.Fatalf("no error expected, found: %s", err) + } + if len(errors) != 0 { + t.Fatalf("unexpected errors: %s", errors) + } + checkMarks(t, true) + + // cleanup + if err := os.RemoveAll(tree.name); err != nil { + t.Errorf("removeTree: %v", err) + } +} diff --git a/internal/test/ensure/ensure.go b/internal/test/ensure/ensure.go new file mode 100644 index 00000000..ff2d180f --- /dev/null +++ b/internal/test/ensure/ensure.go @@ -0,0 +1,55 @@ +/* +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 ensure + +import ( + "os" + "path/filepath" + "testing" + + "helm.sh/helm/v3/pkg/helmpath" + "helm.sh/helm/v3/pkg/helmpath/xdg" +) + +// HelmHome sets up a Helm Home in a temp dir. +func HelmHome(t *testing.T) { + t.Helper() + base := t.TempDir() + os.Setenv(xdg.CacheHomeEnvVar, base) + os.Setenv(xdg.ConfigHomeEnvVar, base) + os.Setenv(xdg.DataHomeEnvVar, base) + os.Setenv(helmpath.CacheHomeEnvVar, "") + os.Setenv(helmpath.ConfigHomeEnvVar, "") + os.Setenv(helmpath.DataHomeEnvVar, "") +} + +// TempFile ensures a temp file for unit testing purposes. +// +// It returns the path to the directory (to which you will still need to join the filename) +// +// The returned directory is automatically removed when the test and all its subtests complete. +// +// tempdir := TempFile(t, "foo", []byte("bar")) +// filename := filepath.Join(tempdir, "foo") +func TempFile(t *testing.T, name string, data []byte) string { + path := t.TempDir() + filename := filepath.Join(path, name) + if err := os.WriteFile(filename, data, 0755); err != nil { + t.Fatal(err) + } + return path +} diff --git a/internal/test/test.go b/internal/test/test.go new file mode 100644 index 00000000..e6821282 --- /dev/null +++ b/internal/test/test.go @@ -0,0 +1,96 @@ +/* +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 test + +import ( + "bytes" + "flag" + "os" + "path/filepath" + + "github.com/pkg/errors" +) + +// UpdateGolden writes out the golden files with the latest values, rather than failing the test. +var updateGolden = flag.Bool("update", false, "update golden files") + +// TestingT describes a testing object compatible with the critical functions from the testing.T type +type TestingT interface { + Fatal(...interface{}) + Fatalf(string, ...interface{}) + HelperT +} + +// HelperT describes a test with a helper function +type HelperT interface { + Helper() +} + +// AssertGoldenString asserts that the given string matches the contents of the given file. +func AssertGoldenString(t TestingT, actual, filename string) { + t.Helper() + + if err := compare([]byte(actual), path(filename)); err != nil { + t.Fatalf("%v\n", err) + } +} + +// AssertGoldenFile asserts that the content of the actual file matches the contents of the expected file +func AssertGoldenFile(t TestingT, actualFileName string, expectedFilename string) { + t.Helper() + + actual, err := os.ReadFile(actualFileName) + if err != nil { + t.Fatalf("%v", err) + } + AssertGoldenString(t, string(actual), expectedFilename) +} + +func path(filename string) string { + if filepath.IsAbs(filename) { + return filename + } + return filepath.Join("testdata", filename) +} + +func compare(actual []byte, filename string) error { + actual = normalize(actual) + if err := update(filename, actual); err != nil { + return err + } + + expected, err := os.ReadFile(filename) + if err != nil { + return errors.Wrapf(err, "unable to read testdata %s", filename) + } + expected = normalize(expected) + if !bytes.Equal(expected, actual) { + return errors.Errorf("does not match golden file %s\n\nWANT:\n'%s'\n\nGOT:\n'%s'", filename, expected, actual) + } + return nil +} + +func update(filename string, in []byte) error { + if !*updateGolden { + return nil + } + return os.WriteFile(filename, normalize(in), 0666) +} + +func normalize(in []byte) []byte { + return bytes.Replace(in, []byte("\r\n"), []byte("\n"), -1) +} diff --git a/internal/third_party/dep/fs/fs.go b/internal/third_party/dep/fs/fs.go new file mode 100644 index 00000000..4e4eacc6 --- /dev/null +++ b/internal/third_party/dep/fs/fs.go @@ -0,0 +1,372 @@ +/* +Copyright (c) for portions of fs.go are held by The Go Authors, 2016 and are provided under +the BSD license. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +package fs + +import ( + "io" + "os" + "path/filepath" + "runtime" + "syscall" + + "github.com/pkg/errors" +) + +// fs contains a copy of a few functions from dep tool code to avoid a dependency on golang/dep. +// This code is copied from https://github.com/golang/dep/blob/37d6c560cdf407be7b6cd035b23dba89df9275cf/internal/fs/fs.go +// No changes to the code were made other than removing some unused functions + +// RenameWithFallback attempts to rename a file or directory, but falls back to +// copying in the event of a cross-device link error. If the fallback copy +// succeeds, src is still removed, emulating normal rename behavior. +func RenameWithFallback(src, dst string) error { + _, err := os.Stat(src) + if err != nil { + return errors.Wrapf(err, "cannot stat %s", src) + } + + err = os.Rename(src, dst) + if err == nil { + return nil + } + + return renameFallback(err, src, dst) +} + +// renameByCopy attempts to rename a file or directory by copying it to the +// destination and then removing the src thus emulating the rename behavior. +func renameByCopy(src, dst string) error { + var cerr error + if dir, _ := IsDir(src); dir { + cerr = CopyDir(src, dst) + if cerr != nil { + cerr = errors.Wrap(cerr, "copying directory failed") + } + } else { + cerr = copyFile(src, dst) + if cerr != nil { + cerr = errors.Wrap(cerr, "copying file failed") + } + } + + if cerr != nil { + return errors.Wrapf(cerr, "rename fallback failed: cannot rename %s to %s", src, dst) + } + + return errors.Wrapf(os.RemoveAll(src), "cannot delete %s", src) +} + +var ( + errSrcNotDir = errors.New("source is not a directory") + errDstExist = errors.New("destination already exists") +) + +// CopyDir recursively copies a directory tree, attempting to preserve permissions. +// Source directory must exist, destination directory must *not* exist. +func CopyDir(src, dst string) error { + src = filepath.Clean(src) + dst = filepath.Clean(dst) + + // We use os.Lstat() here to ensure we don't fall in a loop where a symlink + // actually links to a one of its parent directories. + fi, err := os.Lstat(src) + if err != nil { + return err + } + if !fi.IsDir() { + return errSrcNotDir + } + + _, err = os.Stat(dst) + if err != nil && !os.IsNotExist(err) { + return err + } + if err == nil { + return errDstExist + } + + if err = os.MkdirAll(dst, fi.Mode()); err != nil { + return errors.Wrapf(err, "cannot mkdir %s", dst) + } + + entries, err := os.ReadDir(src) + if err != nil { + return errors.Wrapf(err, "cannot read directory %s", dst) + } + + for _, entry := range entries { + srcPath := filepath.Join(src, entry.Name()) + dstPath := filepath.Join(dst, entry.Name()) + + if entry.IsDir() { + if err = CopyDir(srcPath, dstPath); err != nil { + return errors.Wrap(err, "copying directory failed") + } + } else { + // This will include symlinks, which is what we want when + // copying things. + if err = copyFile(srcPath, dstPath); err != nil { + return errors.Wrap(err, "copying file failed") + } + } + } + + return nil +} + +// copyFile copies the contents of the file named src to the file named +// by dst. The file will be created if it does not already exist. If the +// destination file exists, all its contents will be replaced by the contents +// of the source file. The file mode will be copied from the source. +func copyFile(src, dst string) (err error) { + if sym, err := IsSymlink(src); err != nil { + return errors.Wrap(err, "symlink check failed") + } else if sym { + if err := cloneSymlink(src, dst); err != nil { + if runtime.GOOS == "windows" { + // If cloning the symlink fails on Windows because the user + // does not have the required privileges, ignore the error and + // fall back to copying the file contents. + // + // ERROR_PRIVILEGE_NOT_HELD is 1314 (0x522): + // https://msdn.microsoft.com/en-us/library/windows/desktop/ms681385(v=vs.85).aspx + if lerr, ok := err.(*os.LinkError); ok && lerr.Err != syscall.Errno(1314) { + return err + } + } else { + return err + } + } else { + return nil + } + } + + in, err := os.Open(src) + if err != nil { + return + } + defer in.Close() + + out, err := os.Create(dst) + if err != nil { + return + } + + if _, err = io.Copy(out, in); err != nil { + out.Close() + return + } + + // Check for write errors on Close + if err = out.Close(); err != nil { + return + } + + si, err := os.Stat(src) + if err != nil { + return + } + + // Temporary fix for Go < 1.9 + // + // See: https://github.com/golang/dep/issues/774 + // and https://github.com/golang/go/issues/20829 + if runtime.GOOS == "windows" { + dst = fixLongPath(dst) + } + err = os.Chmod(dst, si.Mode()) + + return +} + +// cloneSymlink will create a new symlink that points to the resolved path of sl. +// If sl is a relative symlink, dst will also be a relative symlink. +func cloneSymlink(sl, dst string) error { + resolved, err := os.Readlink(sl) + if err != nil { + return err + } + + return os.Symlink(resolved, dst) +} + +// IsDir determines is the path given is a directory or not. +func IsDir(name string) (bool, error) { + fi, err := os.Stat(name) + if err != nil { + return false, err + } + if !fi.IsDir() { + return false, errors.Errorf("%q is not a directory", name) + } + return true, nil +} + +// IsSymlink determines if the given path is a symbolic link. +func IsSymlink(path string) (bool, error) { + l, err := os.Lstat(path) + if err != nil { + return false, err + } + + return l.Mode()&os.ModeSymlink == os.ModeSymlink, nil +} + +// fixLongPath returns the extended-length (\\?\-prefixed) form of +// path when needed, in order to avoid the default 260 character file +// path limit imposed by Windows. If path is not easily converted to +// the extended-length form (for example, if path is a relative path +// or contains .. elements), or is short enough, fixLongPath returns +// path unmodified. +// +// See https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx#maxpath +func fixLongPath(path string) string { + // Do nothing (and don't allocate) if the path is "short". + // Empirically (at least on the Windows Server 2013 builder), + // the kernel is arbitrarily okay with < 248 bytes. That + // matches what the docs above say: + // "When using an API to create a directory, the specified + // path cannot be so long that you cannot append an 8.3 file + // name (that is, the directory name cannot exceed MAX_PATH + // minus 12)." Since MAX_PATH is 260, 260 - 12 = 248. + // + // The MSDN docs appear to say that a normal path that is 248 bytes long + // will work; empirically the path must be less then 248 bytes long. + if len(path) < 248 { + // Don't fix. (This is how Go 1.7 and earlier worked, + // not automatically generating the \\?\ form) + return path + } + + // The extended form begins with \\?\, as in + // \\?\c:\windows\foo.txt or \\?\UNC\server\share\foo.txt. + // The extended form disables evaluation of . and .. path + // elements and disables the interpretation of / as equivalent + // to \. The conversion here rewrites / to \ and elides + // . elements as well as trailing or duplicate separators. For + // simplicity it avoids the conversion entirely for relative + // paths or paths containing .. elements. For now, + // \\server\share paths are not converted to + // \\?\UNC\server\share paths because the rules for doing so + // are less well-specified. + if len(path) >= 2 && path[:2] == `\\` { + // Don't canonicalize UNC paths. + return path + } + if !isAbs(path) { + // Relative path + return path + } + + const prefix = `\\?` + + pathbuf := make([]byte, len(prefix)+len(path)+len(`\`)) + copy(pathbuf, prefix) + n := len(path) + r, w := 0, len(prefix) + for r < n { + switch { + case os.IsPathSeparator(path[r]): + // empty block + r++ + case path[r] == '.' && (r+1 == n || os.IsPathSeparator(path[r+1])): + // /./ + r++ + case r+1 < n && path[r] == '.' && path[r+1] == '.' && (r+2 == n || os.IsPathSeparator(path[r+2])): + // /../ is currently unhandled + return path + default: + pathbuf[w] = '\\' + w++ + for ; r < n && !os.IsPathSeparator(path[r]); r++ { + pathbuf[w] = path[r] + w++ + } + } + } + // A drive's root directory needs a trailing \ + if w == len(`\\?\c:`) { + pathbuf[w] = '\\' + w++ + } + return string(pathbuf[:w]) +} + +func isAbs(path string) (b bool) { + v := volumeName(path) + if v == "" { + return false + } + path = path[len(v):] + if path == "" { + return false + } + return os.IsPathSeparator(path[0]) +} + +func volumeName(path string) (v string) { + if len(path) < 2 { + return "" + } + // with drive letter + c := path[0] + if path[1] == ':' && + ('0' <= c && c <= '9' || 'a' <= c && c <= 'z' || + 'A' <= c && c <= 'Z') { + return path[:2] + } + // is it UNC + if l := len(path); l >= 5 && os.IsPathSeparator(path[0]) && os.IsPathSeparator(path[1]) && + !os.IsPathSeparator(path[2]) && path[2] != '.' { + // first, leading `\\` and next shouldn't be `\`. its server name. + for n := 3; n < l-1; n++ { + // second, next '\' shouldn't be repeated. + if os.IsPathSeparator(path[n]) { + n++ + // third, following something characters. its share name. + if !os.IsPathSeparator(path[n]) { + if path[n] == '.' { + break + } + for ; n < l; n++ { + if os.IsPathSeparator(path[n]) { + break + } + } + return path[:n] + } + break + } + } + } + return "" +} diff --git a/internal/third_party/dep/fs/fs_test.go b/internal/third_party/dep/fs/fs_test.go new file mode 100644 index 00000000..d42c3f11 --- /dev/null +++ b/internal/third_party/dep/fs/fs_test.go @@ -0,0 +1,642 @@ +/* +Copyright (c) for portions of fs_test.go are held by The Go Authors, 2016 and are provided under +the BSD license. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +package fs + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "sync" + "testing" +) + +var ( + mu sync.Mutex +) + +func TestRenameWithFallback(t *testing.T) { + dir := t.TempDir() + + if err := RenameWithFallback(filepath.Join(dir, "does_not_exists"), filepath.Join(dir, "dst")); err == nil { + t.Fatal("expected an error for non existing file, but got nil") + } + + srcpath := filepath.Join(dir, "src") + + if srcf, err := os.Create(srcpath); err != nil { + t.Fatal(err) + } else { + srcf.Close() + } + + if err := RenameWithFallback(srcpath, filepath.Join(dir, "dst")); err != nil { + t.Fatal(err) + } + + srcpath = filepath.Join(dir, "a") + if err := os.MkdirAll(srcpath, 0777); err != nil { + t.Fatal(err) + } + + dstpath := filepath.Join(dir, "b") + if err := os.MkdirAll(dstpath, 0777); err != nil { + t.Fatal(err) + } + + if err := RenameWithFallback(srcpath, dstpath); err == nil { + t.Fatal("expected an error if dst is an existing directory, but got nil") + } +} + +func TestCopyDir(t *testing.T) { + dir := t.TempDir() + + srcdir := filepath.Join(dir, "src") + if err := os.MkdirAll(srcdir, 0755); err != nil { + t.Fatal(err) + } + + files := []struct { + path string + contents string + fi os.FileInfo + }{ + {path: "myfile", contents: "hello world"}, + {path: filepath.Join("subdir", "file"), contents: "subdir file"}, + } + + // Create structure indicated in 'files' + for i, file := range files { + fn := filepath.Join(srcdir, file.path) + dn := filepath.Dir(fn) + if err := os.MkdirAll(dn, 0755); err != nil { + t.Fatal(err) + } + + fh, err := os.Create(fn) + if err != nil { + t.Fatal(err) + } + + if _, err = fh.Write([]byte(file.contents)); err != nil { + t.Fatal(err) + } + fh.Close() + + files[i].fi, err = os.Stat(fn) + if err != nil { + t.Fatal(err) + } + } + + destdir := filepath.Join(dir, "dest") + if err := CopyDir(srcdir, destdir); err != nil { + t.Fatal(err) + } + + // Compare copy against structure indicated in 'files' + for _, file := range files { + fn := filepath.Join(srcdir, file.path) + dn := filepath.Dir(fn) + dirOK, err := IsDir(dn) + if err != nil { + t.Fatal(err) + } + if !dirOK { + t.Fatalf("expected %s to be a directory", dn) + } + + got, err := os.ReadFile(fn) + if err != nil { + t.Fatal(err) + } + + if file.contents != string(got) { + t.Fatalf("expected: %s, got: %s", file.contents, string(got)) + } + + gotinfo, err := os.Stat(fn) + if err != nil { + t.Fatal(err) + } + + if file.fi.Mode() != gotinfo.Mode() { + t.Fatalf("expected %s: %#v\n to be the same mode as %s: %#v", + file.path, file.fi.Mode(), fn, gotinfo.Mode()) + } + } +} + +func TestCopyDirFail_SrcInaccessible(t *testing.T) { + if runtime.GOOS == "windows" { + // XXX: setting permissions works differently in + // Microsoft Windows. Skipping this until a + // compatible implementation is provided. + t.Skip("skipping on windows") + } + + var currentUID = os.Getuid() + + if currentUID == 0 { + // Skipping if root, because all files are accessible + t.Skip("Skipping for root user") + } + + var srcdir, dstdir string + + cleanup := setupInaccessibleDir(t, func(dir string) error { + srcdir = filepath.Join(dir, "src") + return os.MkdirAll(srcdir, 0755) + }) + defer cleanup() + + dir := t.TempDir() + + dstdir = filepath.Join(dir, "dst") + if err := CopyDir(srcdir, dstdir); err == nil { + t.Fatalf("expected error for CopyDir(%s, %s), got none", srcdir, dstdir) + } +} + +func TestCopyDirFail_DstInaccessible(t *testing.T) { + if runtime.GOOS == "windows" { + // XXX: setting permissions works differently in + // Microsoft Windows. Skipping this until a + // compatible implementation is provided. + t.Skip("skipping on windows") + } + + var currentUID = os.Getuid() + + if currentUID == 0 { + // Skipping if root, because all files are accessible + t.Skip("Skipping for root user") + } + + var srcdir, dstdir string + + dir := t.TempDir() + + srcdir = filepath.Join(dir, "src") + if err := os.MkdirAll(srcdir, 0755); err != nil { + t.Fatal(err) + } + + cleanup := setupInaccessibleDir(t, func(dir string) error { + dstdir = filepath.Join(dir, "dst") + return nil + }) + defer cleanup() + + if err := CopyDir(srcdir, dstdir); err == nil { + t.Fatalf("expected error for CopyDir(%s, %s), got none", srcdir, dstdir) + } +} + +func TestCopyDirFail_SrcIsNotDir(t *testing.T) { + var srcdir, dstdir string + var err error + + dir := t.TempDir() + + srcdir = filepath.Join(dir, "src") + if _, err = os.Create(srcdir); err != nil { + t.Fatal(err) + } + + dstdir = filepath.Join(dir, "dst") + + if err = CopyDir(srcdir, dstdir); err == nil { + t.Fatalf("expected error for CopyDir(%s, %s), got none", srcdir, dstdir) + } + + if err != errSrcNotDir { + t.Fatalf("expected %v error for CopyDir(%s, %s), got %s", errSrcNotDir, srcdir, dstdir, err) + } + +} + +func TestCopyDirFail_DstExists(t *testing.T) { + var srcdir, dstdir string + var err error + + dir := t.TempDir() + + srcdir = filepath.Join(dir, "src") + if err = os.MkdirAll(srcdir, 0755); err != nil { + t.Fatal(err) + } + + dstdir = filepath.Join(dir, "dst") + if err = os.MkdirAll(dstdir, 0755); err != nil { + t.Fatal(err) + } + + if err = CopyDir(srcdir, dstdir); err == nil { + t.Fatalf("expected error for CopyDir(%s, %s), got none", srcdir, dstdir) + } + + if err != errDstExist { + t.Fatalf("expected %v error for CopyDir(%s, %s), got %s", errDstExist, srcdir, dstdir, err) + } +} + +func TestCopyDirFailOpen(t *testing.T) { + if runtime.GOOS == "windows" { + // XXX: setting permissions works differently in + // Microsoft Windows. os.Chmod(..., 0222) below is not + // enough for the file to be readonly, and os.Chmod(..., + // 0000) returns an invalid argument error. Skipping + // this until a compatible implementation is + // provided. + t.Skip("skipping on windows") + } + + var currentUID = os.Getuid() + + if currentUID == 0 { + // Skipping if root, because all files are accessible + t.Skip("Skipping for root user") + } + + var srcdir, dstdir string + + dir := t.TempDir() + + srcdir = filepath.Join(dir, "src") + if err := os.MkdirAll(srcdir, 0755); err != nil { + t.Fatal(err) + } + + srcfn := filepath.Join(srcdir, "file") + srcf, err := os.Create(srcfn) + if err != nil { + t.Fatal(err) + } + srcf.Close() + + // setup source file so that it cannot be read + if err = os.Chmod(srcfn, 0222); err != nil { + t.Fatal(err) + } + + dstdir = filepath.Join(dir, "dst") + + if err = CopyDir(srcdir, dstdir); err == nil { + t.Fatalf("expected error for CopyDir(%s, %s), got none", srcdir, dstdir) + } +} + +func TestCopyFile(t *testing.T) { + dir := t.TempDir() + + srcf, err := os.Create(filepath.Join(dir, "srcfile")) + if err != nil { + t.Fatal(err) + } + + want := "hello world" + if _, err := srcf.Write([]byte(want)); err != nil { + t.Fatal(err) + } + srcf.Close() + + destf := filepath.Join(dir, "destf") + if err := copyFile(srcf.Name(), destf); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(destf) + if err != nil { + t.Fatal(err) + } + + if want != string(got) { + t.Fatalf("expected: %s, got: %s", want, string(got)) + } + + wantinfo, err := os.Stat(srcf.Name()) + if err != nil { + t.Fatal(err) + } + + gotinfo, err := os.Stat(destf) + if err != nil { + t.Fatal(err) + } + + if wantinfo.Mode() != gotinfo.Mode() { + t.Fatalf("expected %s: %#v\n to be the same mode as %s: %#v", srcf.Name(), wantinfo.Mode(), destf, gotinfo.Mode()) + } +} + +func cleanUpDir(dir string) { + // NOTE(mattn): It seems that sometimes git.exe is not dead + // when cleanUpDir() is called. But we do not know any way to wait for it. + if runtime.GOOS == "windows" { + mu.Lock() + exec.Command(`taskkill`, `/F`, `/IM`, `git.exe`).Run() + mu.Unlock() + } + if dir != "" { + os.RemoveAll(dir) + } +} + +func TestCopyFileSymlink(t *testing.T) { + tempdir := t.TempDir() + + testcases := map[string]string{ + filepath.Join("./testdata/symlinks/file-symlink"): filepath.Join(tempdir, "dst-file"), + filepath.Join("./testdata/symlinks/windows-file-symlink"): filepath.Join(tempdir, "windows-dst-file"), + filepath.Join("./testdata/symlinks/invalid-symlink"): filepath.Join(tempdir, "invalid-symlink"), + } + + for symlink, dst := range testcases { + t.Run(symlink, func(t *testing.T) { + var err error + if err = copyFile(symlink, dst); err != nil { + t.Fatalf("failed to copy symlink: %s", err) + } + + var want, got string + + if runtime.GOOS == "windows" { + // Creating symlinks on Windows require an additional permission + // regular users aren't granted usually. So we copy the file + // content as a fall back instead of creating a real symlink. + srcb, err := os.ReadFile(symlink) + if err != nil { + t.Fatalf("%+v", err) + } + dstb, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("%+v", err) + } + + want = string(srcb) + got = string(dstb) + } else { + want, err = os.Readlink(symlink) + if err != nil { + t.Fatalf("%+v", err) + } + + got, err = os.Readlink(dst) + if err != nil { + t.Fatalf("could not resolve symlink: %s", err) + } + } + + if want != got { + t.Fatalf("resolved path is incorrect. expected %s, got %s", want, got) + } + }) + } +} + +func TestCopyFileFail(t *testing.T) { + if runtime.GOOS == "windows" { + // XXX: setting permissions works differently in + // Microsoft Windows. Skipping this until a + // compatible implementation is provided. + t.Skip("skipping on windows") + } + + var currentUID = os.Getuid() + + if currentUID == 0 { + // Skipping if root, because all files are accessible + t.Skip("Skipping for root user") + } + + dir := t.TempDir() + + srcf, err := os.Create(filepath.Join(dir, "srcfile")) + if err != nil { + t.Fatal(err) + } + srcf.Close() + + var dstdir string + + cleanup := setupInaccessibleDir(t, func(dir string) error { + dstdir = filepath.Join(dir, "dir") + return os.Mkdir(dstdir, 0777) + }) + defer cleanup() + + fn := filepath.Join(dstdir, "file") + if err := copyFile(srcf.Name(), fn); err == nil { + t.Fatalf("expected error for %s, got none", fn) + } +} + +// setupInaccessibleDir creates a temporary location with a single +// directory in it, in such a way that directory is not accessible +// after this function returns. +// +// op is called with the directory as argument, so that it can create +// files or other test artifacts. +// +// If setupInaccessibleDir fails in its preparation, or op fails, t.Fatal +// will be invoked. +// +// This function returns a cleanup function that removes all the temporary +// files this function creates. It is the caller's responsibility to call +// this function before the test is done running, whether there's an error or not. +func setupInaccessibleDir(t *testing.T, op func(dir string) error) func() { + dir := t.TempDir() + + subdir := filepath.Join(dir, "dir") + + cleanup := func() { + if err := os.Chmod(subdir, 0777); err != nil { + t.Error(err) + } + } + + if err := os.Mkdir(subdir, 0777); err != nil { + cleanup() + t.Fatal(err) + return nil + } + + if err := op(subdir); err != nil { + cleanup() + t.Fatal(err) + return nil + } + + if err := os.Chmod(subdir, 0666); err != nil { + cleanup() + t.Fatal(err) + return nil + } + + return cleanup +} + +func TestIsDir(t *testing.T) { + + var currentUID = os.Getuid() + + if currentUID == 0 { + // Skipping if root, because all files are accessible + t.Skip("Skipping for root user") + } + + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + var dn string + + cleanup := setupInaccessibleDir(t, func(dir string) error { + dn = filepath.Join(dir, "dir") + return os.Mkdir(dn, 0777) + }) + defer cleanup() + + tests := map[string]struct { + exists bool + err bool + }{ + wd: {true, false}, + filepath.Join(wd, "testdata"): {true, false}, + filepath.Join(wd, "main.go"): {false, true}, + filepath.Join(wd, "this_file_does_not_exist.thing"): {false, true}, + dn: {false, true}, + } + + if runtime.GOOS == "windows" { + // This test doesn't work on Microsoft Windows because + // of the differences in how file permissions are + // implemented. For this to work, the directory where + // the directory exists should be inaccessible. + delete(tests, dn) + } + + for f, want := range tests { + got, err := IsDir(f) + if err != nil && !want.err { + t.Fatalf("expected no error, got %v", err) + } + + if got != want.exists { + t.Fatalf("expected %t for %s, got %t", want.exists, f, got) + } + } +} + +func TestIsSymlink(t *testing.T) { + + var currentUID = os.Getuid() + + if currentUID == 0 { + // Skipping if root, because all files are accessible + t.Skip("Skipping for root user") + } + + dir := t.TempDir() + + dirPath := filepath.Join(dir, "directory") + if err := os.MkdirAll(dirPath, 0777); err != nil { + t.Fatal(err) + } + + filePath := filepath.Join(dir, "file") + f, err := os.Create(filePath) + if err != nil { + t.Fatal(err) + } + f.Close() + + dirSymlink := filepath.Join(dir, "dirSymlink") + fileSymlink := filepath.Join(dir, "fileSymlink") + + if err = os.Symlink(dirPath, dirSymlink); err != nil { + t.Fatal(err) + } + if err = os.Symlink(filePath, fileSymlink); err != nil { + t.Fatal(err) + } + + var ( + inaccessibleFile string + inaccessibleSymlink string + ) + + cleanup := setupInaccessibleDir(t, func(dir string) error { + inaccessibleFile = filepath.Join(dir, "file") + if fh, err := os.Create(inaccessibleFile); err != nil { + return err + } else if err = fh.Close(); err != nil { + return err + } + + inaccessibleSymlink = filepath.Join(dir, "symlink") + return os.Symlink(inaccessibleFile, inaccessibleSymlink) + }) + defer cleanup() + + tests := map[string]struct{ expected, err bool }{ + dirPath: {false, false}, + filePath: {false, false}, + dirSymlink: {true, false}, + fileSymlink: {true, false}, + inaccessibleFile: {false, true}, + inaccessibleSymlink: {false, true}, + } + + if runtime.GOOS == "windows" { + // XXX: setting permissions works differently in Windows. Skipping + // these cases until a compatible implementation is provided. + delete(tests, inaccessibleFile) + delete(tests, inaccessibleSymlink) + } + + for path, want := range tests { + got, err := IsSymlink(path) + if err != nil { + if !want.err { + t.Errorf("expected no error, got %v", err) + } + } + + if got != want.expected { + t.Errorf("expected %t for %s, got %t", want.expected, path, got) + } + } +} diff --git a/internal/third_party/dep/fs/rename.go b/internal/third_party/dep/fs/rename.go new file mode 100644 index 00000000..a3e5e56a --- /dev/null +++ b/internal/third_party/dep/fs/rename.go @@ -0,0 +1,58 @@ +//go:build !windows + +/* +Copyright (c) for portions of rename.go are held by The Go Authors, 2016 and are provided under +the BSD license. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +package fs + +import ( + "os" + "syscall" + + "github.com/pkg/errors" +) + +// renameFallback attempts to determine the appropriate fallback to failed rename +// operation depending on the resulting error. +func renameFallback(err error, src, dst string) error { + // Rename may fail if src and dst are on different devices; fall back to + // copy if we detect that case. syscall.EXDEV is the common name for the + // cross device link error which has varying output text across different + // operating systems. + terr, ok := err.(*os.LinkError) + if !ok { + return err + } else if terr.Err != syscall.EXDEV { + return errors.Wrapf(terr, "link error: cannot rename %s to %s", src, dst) + } + + return renameByCopy(src, dst) +} diff --git a/internal/third_party/dep/fs/rename_windows.go b/internal/third_party/dep/fs/rename_windows.go new file mode 100644 index 00000000..a377720a --- /dev/null +++ b/internal/third_party/dep/fs/rename_windows.go @@ -0,0 +1,69 @@ +//go:build windows + +/* +Copyright (c) for portions of rename_windows.go are held by The Go Authors, 2016 and are provided under +the BSD license. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +package fs + +import ( + "os" + "syscall" + + "github.com/pkg/errors" +) + +// renameFallback attempts to determine the appropriate fallback to failed rename +// operation depending on the resulting error. +func renameFallback(err error, src, dst string) error { + // Rename may fail if src and dst are on different devices; fall back to + // copy if we detect that case. syscall.EXDEV is the common name for the + // cross device link error which has varying output text across different + // operating systems. + terr, ok := err.(*os.LinkError) + if !ok { + return err + } + + if terr.Err != syscall.EXDEV { + // In windows it can drop down to an operating system call that + // returns an operating system error with a different number and + // message. Checking for that as a fall back. + noerr, ok := terr.Err.(syscall.Errno) + + // 0x11 (ERROR_NOT_SAME_DEVICE) is the windows error. + // See https://msdn.microsoft.com/en-us/library/cc231199.aspx + if ok && noerr != 0x11 { + return errors.Wrapf(terr, "link error: cannot rename %s to %s", src, dst) + } + } + + return renameByCopy(src, dst) +} diff --git a/internal/third_party/dep/fs/testdata/symlinks/file-symlink b/internal/third_party/dep/fs/testdata/symlinks/file-symlink new file mode 120000 index 00000000..4c52274d --- /dev/null +++ b/internal/third_party/dep/fs/testdata/symlinks/file-symlink @@ -0,0 +1 @@ +../test.file \ No newline at end of file diff --git a/internal/third_party/dep/fs/testdata/symlinks/invalid-symlink b/internal/third_party/dep/fs/testdata/symlinks/invalid-symlink new file mode 120000 index 00000000..0edf4f30 --- /dev/null +++ b/internal/third_party/dep/fs/testdata/symlinks/invalid-symlink @@ -0,0 +1 @@ +/non/existing/file \ No newline at end of file diff --git a/internal/third_party/dep/fs/testdata/symlinks/windows-file-symlink b/internal/third_party/dep/fs/testdata/symlinks/windows-file-symlink new file mode 120000 index 00000000..af1d6c8f --- /dev/null +++ b/internal/third_party/dep/fs/testdata/symlinks/windows-file-symlink @@ -0,0 +1 @@ +C:/Users/ibrahim/go/src/github.com/golang/dep/internal/fs/testdata/test.file \ No newline at end of file diff --git a/internal/third_party/dep/fs/testdata/test.file b/internal/third_party/dep/fs/testdata/test.file new file mode 100644 index 00000000..e69de29b diff --git a/internal/third_party/k8s.io/kubernetes/deployment/util/deploymentutil.go b/internal/third_party/k8s.io/kubernetes/deployment/util/deploymentutil.go new file mode 100644 index 00000000..ae62d0e6 --- /dev/null +++ b/internal/third_party/k8s.io/kubernetes/deployment/util/deploymentutil.go @@ -0,0 +1,178 @@ +/* +Copyright 2016 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "context" + "sort" + + apps "k8s.io/api/apps/v1" + v1 "k8s.io/api/core/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + intstrutil "k8s.io/apimachinery/pkg/util/intstr" + appsclient "k8s.io/client-go/kubernetes/typed/apps/v1" +) + +// deploymentutil contains a copy of a few functions from Kubernetes controller code to avoid a dependency on k8s.io/kubernetes. +// This code is copied from https://github.com/kubernetes/kubernetes/blob/e856613dd5bb00bcfaca6974431151b5c06cbed5/pkg/controller/deployment/util/deployment_util.go +// No changes to the code were made other than removing some unused functions + +// RsListFunc returns the ReplicaSet from the ReplicaSet namespace and the List metav1.ListOptions. +type RsListFunc func(string, metav1.ListOptions) ([]*apps.ReplicaSet, error) + +// ListReplicaSets returns a slice of RSes the given deployment targets. +// Note that this does NOT attempt to reconcile ControllerRef (adopt/orphan), +// because only the controller itself should do that. +// However, it does filter out anything whose ControllerRef doesn't match. +func ListReplicaSets(deployment *apps.Deployment, getRSList RsListFunc) ([]*apps.ReplicaSet, error) { + // TODO: Right now we list replica sets by their labels. We should list them by selector, i.e. the replica set's selector + // should be a superset of the deployment's selector, see https://github.com/kubernetes/kubernetes/issues/19830. + namespace := deployment.Namespace + selector, err := metav1.LabelSelectorAsSelector(deployment.Spec.Selector) + if err != nil { + return nil, err + } + options := metav1.ListOptions{LabelSelector: selector.String()} + all, err := getRSList(namespace, options) + if err != nil { + return nil, err + } + // Only include those whose ControllerRef matches the Deployment. + owned := make([]*apps.ReplicaSet, 0, len(all)) + for _, rs := range all { + if metav1.IsControlledBy(rs, deployment) { + owned = append(owned, rs) + } + } + return owned, nil +} + +// ReplicaSetsByCreationTimestamp sorts a list of ReplicaSet by creation timestamp, using their names as a tie breaker. +type ReplicaSetsByCreationTimestamp []*apps.ReplicaSet + +func (o ReplicaSetsByCreationTimestamp) Len() int { return len(o) } +func (o ReplicaSetsByCreationTimestamp) Swap(i, j int) { o[i], o[j] = o[j], o[i] } +func (o ReplicaSetsByCreationTimestamp) Less(i, j int) bool { + if o[i].CreationTimestamp.Equal(&o[j].CreationTimestamp) { + return o[i].Name < o[j].Name + } + return o[i].CreationTimestamp.Before(&o[j].CreationTimestamp) +} + +// FindNewReplicaSet returns the new RS this given deployment targets (the one with the same pod template). +func FindNewReplicaSet(deployment *apps.Deployment, rsList []*apps.ReplicaSet) *apps.ReplicaSet { + sort.Sort(ReplicaSetsByCreationTimestamp(rsList)) + for i := range rsList { + if EqualIgnoreHash(&rsList[i].Spec.Template, &deployment.Spec.Template) { + // In rare cases, such as after cluster upgrades, Deployment may end up with + // having more than one new ReplicaSets that have the same template as its template, + // see https://github.com/kubernetes/kubernetes/issues/40415 + // We deterministically choose the oldest new ReplicaSet. + return rsList[i] + } + } + // new ReplicaSet does not exist. + return nil +} + +// EqualIgnoreHash returns true if two given podTemplateSpec are equal, ignoring the diff in value of Labels[pod-template-hash] +// We ignore pod-template-hash because: +// 1. The hash result would be different upon podTemplateSpec API changes +// (e.g. the addition of a new field will cause the hash code to change) +// 2. The deployment template won't have hash labels +func EqualIgnoreHash(template1, template2 *v1.PodTemplateSpec) bool { + t1Copy := template1.DeepCopy() + t2Copy := template2.DeepCopy() + // Remove hash labels from template.Labels before comparing + delete(t1Copy.Labels, apps.DefaultDeploymentUniqueLabelKey) + delete(t2Copy.Labels, apps.DefaultDeploymentUniqueLabelKey) + return apiequality.Semantic.DeepEqual(t1Copy, t2Copy) +} + +// GetNewReplicaSet returns a replica set that matches the intent of the given deployment; get ReplicaSetList from client interface. +// Returns nil if the new replica set doesn't exist yet. +func GetNewReplicaSet(deployment *apps.Deployment, c appsclient.AppsV1Interface) (*apps.ReplicaSet, error) { + rsList, err := ListReplicaSets(deployment, RsListFromClient(c)) + if err != nil { + return nil, err + } + return FindNewReplicaSet(deployment, rsList), nil +} + +// RsListFromClient returns an rsListFunc that wraps the given client. +func RsListFromClient(c appsclient.AppsV1Interface) RsListFunc { + return func(namespace string, options metav1.ListOptions) ([]*apps.ReplicaSet, error) { + rsList, err := c.ReplicaSets(namespace).List(context.Background(), options) + if err != nil { + return nil, err + } + var ret []*apps.ReplicaSet + for i := range rsList.Items { + ret = append(ret, &rsList.Items[i]) + } + return ret, err + } +} + +// IsRollingUpdate returns true if the strategy type is a rolling update. +func IsRollingUpdate(deployment *apps.Deployment) bool { + return deployment.Spec.Strategy.Type == apps.RollingUpdateDeploymentStrategyType +} + +// MaxUnavailable returns the maximum unavailable pods a rolling deployment can take. +func MaxUnavailable(deployment apps.Deployment) int32 { + if !IsRollingUpdate(&deployment) || *(deployment.Spec.Replicas) == 0 { + return int32(0) + } + // Error caught by validation + _, maxUnavailable, _ := ResolveFenceposts(deployment.Spec.Strategy.RollingUpdate.MaxSurge, deployment.Spec.Strategy.RollingUpdate.MaxUnavailable, *(deployment.Spec.Replicas)) + if maxUnavailable > *deployment.Spec.Replicas { + return *deployment.Spec.Replicas + } + return maxUnavailable +} + +// ResolveFenceposts resolves both maxSurge and maxUnavailable. This needs to happen in one +// step. For example: +// +// 2 desired, max unavailable 1%, surge 0% - should scale old(-1), then new(+1), then old(-1), then new(+1) +// 1 desired, max unavailable 1%, surge 0% - should scale old(-1), then new(+1) +// 2 desired, max unavailable 25%, surge 1% - should scale new(+1), then old(-1), then new(+1), then old(-1) +// 1 desired, max unavailable 25%, surge 1% - should scale new(+1), then old(-1) +// 2 desired, max unavailable 0%, surge 1% - should scale new(+1), then old(-1), then new(+1), then old(-1) +// 1 desired, max unavailable 0%, surge 1% - should scale new(+1), then old(-1) +func ResolveFenceposts(maxSurge, maxUnavailable *intstrutil.IntOrString, desired int32) (int32, int32, error) { + surge, err := intstrutil.GetValueFromIntOrPercent(intstrutil.ValueOrDefault(maxSurge, intstrutil.FromInt(0)), int(desired), true) + if err != nil { + return 0, 0, err + } + unavailable, err := intstrutil.GetValueFromIntOrPercent(intstrutil.ValueOrDefault(maxUnavailable, intstrutil.FromInt(0)), int(desired), false) + if err != nil { + return 0, 0, err + } + + if surge == 0 && unavailable == 0 { + // Validation should never allow the user to explicitly use zero values for both maxSurge + // maxUnavailable. Due to rounding down maxUnavailable though, it may resolve to zero. + // If both fenceposts resolve to zero, then we should set maxUnavailable to 1 on the + // theory that surge might not work due to quota. + unavailable = 1 + } + + return int32(surge), int32(unavailable), nil +} diff --git a/internal/tlsutil/cfg.go b/internal/tlsutil/cfg.go new file mode 100644 index 00000000..8b9d4329 --- /dev/null +++ b/internal/tlsutil/cfg.go @@ -0,0 +1,58 @@ +/* +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 tlsutil + +import ( + "crypto/tls" + "crypto/x509" + "os" + + "github.com/pkg/errors" +) + +// Options represents configurable options used to create client and server TLS configurations. +type Options struct { + CaCertFile string + // If either the KeyFile or CertFile is empty, ClientConfig() will not load them. + KeyFile string + CertFile string + // Client-only options + InsecureSkipVerify bool +} + +// ClientConfig returns a TLS configuration for use by a Helm client. +func ClientConfig(opts Options) (cfg *tls.Config, err error) { + var cert *tls.Certificate + var pool *x509.CertPool + + if opts.CertFile != "" || opts.KeyFile != "" { + if cert, err = CertFromFilePair(opts.CertFile, opts.KeyFile); err != nil { + if os.IsNotExist(err) { + return nil, errors.Wrapf(err, "could not load x509 key pair (cert: %q, key: %q)", opts.CertFile, opts.KeyFile) + } + return nil, errors.Wrapf(err, "could not read x509 key pair (cert: %q, key: %q)", opts.CertFile, opts.KeyFile) + } + } + if !opts.InsecureSkipVerify && opts.CaCertFile != "" { + if pool, err = CertPoolFromFile(opts.CaCertFile); err != nil { + return nil, err + } + } + + cfg = &tls.Config{InsecureSkipVerify: opts.InsecureSkipVerify, Certificates: []tls.Certificate{*cert}, RootCAs: pool} + return cfg, nil +} diff --git a/internal/tlsutil/tls.go b/internal/tlsutil/tls.go new file mode 100644 index 00000000..dc832ed8 --- /dev/null +++ b/internal/tlsutil/tls.go @@ -0,0 +1,78 @@ +/* +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 tlsutil + +import ( + "crypto/tls" + "crypto/x509" + "os" + + "github.com/pkg/errors" +) + +// NewClientTLS returns tls.Config appropriate for client auth. +func NewClientTLS(certFile, keyFile, caFile string, insecureSkipTLSverify bool) (*tls.Config, error) { + config := tls.Config{ + InsecureSkipVerify: insecureSkipTLSverify, + } + + if certFile != "" && keyFile != "" { + cert, err := CertFromFilePair(certFile, keyFile) + if err != nil { + return nil, err + } + config.Certificates = []tls.Certificate{*cert} + } + + if caFile != "" { + cp, err := CertPoolFromFile(caFile) + if err != nil { + return nil, err + } + config.RootCAs = cp + } + + return &config, nil +} + +// CertPoolFromFile returns an x509.CertPool containing the certificates +// in the given PEM-encoded file. +// Returns an error if the file could not be read, a certificate could not +// be parsed, or if the file does not contain any certificates +func CertPoolFromFile(filename string) (*x509.CertPool, error) { + b, err := os.ReadFile(filename) + if err != nil { + return nil, errors.Errorf("can't read CA file: %v", filename) + } + cp := x509.NewCertPool() + if !cp.AppendCertsFromPEM(b) { + return nil, errors.Errorf("failed to append certificates from file: %s", filename) + } + return cp, nil +} + +// CertFromFilePair returns an tls.Certificate containing the +// certificates public/private key pair from a pair of given PEM-encoded files. +// Returns an error if the file could not be read, a certificate could not +// be parsed, or if the file does not contain any certificates +func CertFromFilePair(certFile, keyFile string) (*tls.Certificate, error) { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, errors.Wrapf(err, "can't load key pair from cert %s and key %s", certFile, keyFile) + } + return &cert, err +} diff --git a/internal/tlsutil/tlsutil_test.go b/internal/tlsutil/tlsutil_test.go new file mode 100644 index 00000000..e31a873d --- /dev/null +++ b/internal/tlsutil/tlsutil_test.go @@ -0,0 +1,114 @@ +/* +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 tlsutil + +import ( + "path/filepath" + "testing" +) + +const tlsTestDir = "../../testdata" + +const ( + testCaCertFile = "rootca.crt" + testCertFile = "crt.pem" + testKeyFile = "key.pem" +) + +func TestClientConfig(t *testing.T) { + opts := Options{ + CaCertFile: testfile(t, testCaCertFile), + CertFile: testfile(t, testCertFile), + KeyFile: testfile(t, testKeyFile), + InsecureSkipVerify: false, + } + + cfg, err := ClientConfig(opts) + if err != nil { + t.Fatalf("error building tls client config: %v", err) + } + + if got := len(cfg.Certificates); got != 1 { + t.Fatalf("expecting 1 client certificates, got %d", got) + } + if cfg.InsecureSkipVerify { + t.Fatalf("insecure skip verify mismatch, expecting false") + } + if cfg.RootCAs == nil { + t.Fatalf("mismatch tls RootCAs, expecting non-nil") + } +} + +func testfile(t *testing.T, file string) (path string) { + var err error + if path, err = filepath.Abs(filepath.Join(tlsTestDir, file)); err != nil { + t.Fatalf("error getting absolute path to test file %q: %v", file, err) + } + return path +} + +func TestNewClientTLS(t *testing.T) { + certFile := testfile(t, testCertFile) + keyFile := testfile(t, testKeyFile) + caCertFile := testfile(t, testCaCertFile) + insecureSkipTLSverify := false + + cfg, err := NewClientTLS(certFile, keyFile, caCertFile, insecureSkipTLSverify) + if err != nil { + t.Error(err) + } + + if got := len(cfg.Certificates); got != 1 { + t.Fatalf("expecting 1 client certificates, got %d", got) + } + if cfg.InsecureSkipVerify { + t.Fatalf("insecure skip verify mismatch, expecting false") + } + if cfg.RootCAs == nil { + t.Fatalf("mismatch tls RootCAs, expecting non-nil") + } + + cfg, err = NewClientTLS("", "", caCertFile, insecureSkipTLSverify) + if err != nil { + t.Error(err) + } + + if got := len(cfg.Certificates); got != 0 { + t.Fatalf("expecting 0 client certificates, got %d", got) + } + if cfg.InsecureSkipVerify { + t.Fatalf("insecure skip verify mismatch, expecting false") + } + if cfg.RootCAs == nil { + t.Fatalf("mismatch tls RootCAs, expecting non-nil") + } + + cfg, err = NewClientTLS(certFile, keyFile, "", insecureSkipTLSverify) + if err != nil { + t.Error(err) + } + + if got := len(cfg.Certificates); got != 1 { + t.Fatalf("expecting 1 client certificates, got %d", got) + } + if cfg.InsecureSkipVerify { + t.Fatalf("insecure skip verify mismatch, expecting false") + } + if cfg.RootCAs != nil { + t.Fatalf("mismatch tls RootCAs, expecting nil") + } +} diff --git a/internal/urlutil/urlutil.go b/internal/urlutil/urlutil.go new file mode 100644 index 00000000..a8cf7398 --- /dev/null +++ b/internal/urlutil/urlutil.go @@ -0,0 +1,73 @@ +/* +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 urlutil + +import ( + "net/url" + "path" + "path/filepath" +) + +// URLJoin joins a base URL to one or more path components. +// +// It's like filepath.Join for URLs. If the baseURL is pathish, this will still +// perform a join. +// +// If the URL is unparsable, this returns an error. +func URLJoin(baseURL string, paths ...string) (string, error) { + u, err := url.Parse(baseURL) + if err != nil { + return "", err + } + // We want path instead of filepath because path always uses /. + all := []string{u.Path} + all = append(all, paths...) + u.Path = path.Join(all...) + return u.String(), nil +} + +// Equal normalizes two URLs and then compares for equality. +func Equal(a, b string) bool { + au, err := url.Parse(a) + if err != nil { + a = filepath.Clean(a) + b = filepath.Clean(b) + // If urls are paths, return true only if they are an exact match + return a == b + } + bu, err := url.Parse(b) + if err != nil { + return false + } + + for _, u := range []*url.URL{au, bu} { + if u.Path == "" { + u.Path = "/" + } + u.Path = filepath.Clean(u.Path) + } + return au.String() == bu.String() +} + +// ExtractHostname returns hostname from URL +func ExtractHostname(addr string) (string, error) { + u, err := url.Parse(addr) + if err != nil { + return "", err + } + return u.Hostname(), nil +} diff --git a/internal/urlutil/urlutil_test.go b/internal/urlutil/urlutil_test.go new file mode 100644 index 00000000..82acc40f --- /dev/null +++ b/internal/urlutil/urlutil_test.go @@ -0,0 +1,81 @@ +/* +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 urlutil + +import "testing" + +func TestURLJoin(t *testing.T) { + tests := []struct { + name, url, expect string + paths []string + }{ + {name: "URL, one path", url: "http://example.com", paths: []string{"hello"}, expect: "http://example.com/hello"}, + {name: "Long URL, one path", url: "http://example.com/but/first", paths: []string{"slurm"}, expect: "http://example.com/but/first/slurm"}, + {name: "URL, two paths", url: "http://example.com", paths: []string{"hello", "world"}, expect: "http://example.com/hello/world"}, + {name: "URL, no paths", url: "http://example.com", paths: []string{}, expect: "http://example.com"}, + {name: "basepath, two paths", url: "../example.com", paths: []string{"hello", "world"}, expect: "../example.com/hello/world"}, + } + + for _, tt := range tests { + if got, err := URLJoin(tt.url, tt.paths...); err != nil { + t.Errorf("%s: error %q", tt.name, err) + } else if got != tt.expect { + t.Errorf("%s: expected %q, got %q", tt.name, tt.expect, got) + } + } +} + +func TestEqual(t *testing.T) { + for _, tt := range []struct { + a, b string + match bool + }{ + {"http://example.com", "http://example.com", true}, + {"http://example.com", "http://another.example.com", false}, + {"https://example.com", "https://example.com", true}, + {"http://example.com/", "http://example.com", true}, + {"https://example.com", "http://example.com", false}, + {"http://example.com/foo", "http://example.com/foo/", true}, + {"http://example.com/foo//", "http://example.com/foo/", true}, + {"http://example.com/./foo/", "http://example.com/foo/", true}, + {"http://example.com/bar/../foo/", "http://example.com/foo/", true}, + {"/foo", "/foo", true}, + {"/foo", "/foo/", true}, + {"/foo/.", "/foo/", true}, + {"%/1234", "%/1234", true}, + {"%/1234", "%/123", false}, + {"/1234", "%/1234", false}, + } { + if tt.match != Equal(tt.a, tt.b) { + t.Errorf("Expected %q==%q to be %t", tt.a, tt.b, tt.match) + } + } +} + +func TestExtractHostname(t *testing.T) { + tests := map[string]string{ + "http://example.com": "example.com", + "https://example.com/foo": "example.com", + + "https://example.com:31337/not/with/a/bang/but/a/whimper": "example.com", + } + for start, expect := range tests { + if got, _ := ExtractHostname(start); got != expect { + t.Errorf("Got %q, expected %q", got, expect) + } + } +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 00000000..414957bc --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,81 @@ +/* +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 version // import "helm.sh/helm/v3/internal/version" + +import ( + "flag" + "runtime" + "strings" +) + +var ( + // version is the current version of Helm. + // Update this whenever making a new release. + // The version is of the format Major.Minor.Patch[-Prerelease][+BuildMetadata] + // + // Increment major number for new feature additions and behavioral changes. + // Increment minor number for bug fixes and performance enhancements. + version = "v3.14" + + // metadata is extra build time data + metadata = "" + // gitCommit is the git sha1 + gitCommit = "" + // gitTreeState is the state of the git tree + gitTreeState = "" +) + +// BuildInfo describes the compile time information. +type BuildInfo struct { + // Version is the current semver. + Version string `json:"version,omitempty"` + // GitCommit is the git sha1. + GitCommit string `json:"git_commit,omitempty"` + // GitTreeState is the state of the git tree. + GitTreeState string `json:"git_tree_state,omitempty"` + // GoVersion is the version of the Go compiler used. + GoVersion string `json:"go_version,omitempty"` +} + +// GetVersion returns the semver string of the version +func GetVersion() string { + if metadata == "" { + return version + } + return version + "+" + metadata +} + +// GetUserAgent returns a user agent for user with an HTTP client +func GetUserAgent() string { + return "Helm/" + strings.TrimPrefix(GetVersion(), "v") +} + +// Get returns build info +func Get() BuildInfo { + v := BuildInfo{ + Version: GetVersion(), + GitCommit: gitCommit, + GitTreeState: gitTreeState, + GoVersion: runtime.Version(), + } + + // HACK(bacongobbler): strip out GoVersion during a test run for consistent test output + if flag.Lookup("test.v") != nil { + v.GoVersion = "" + } + return v +} diff --git a/pkg/action/action.go b/pkg/action/action.go new file mode 100644 index 00000000..5693f483 --- /dev/null +++ b/pkg/action/action.go @@ -0,0 +1,424 @@ +/* +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" + "os" + "path" + "path/filepath" + "regexp" + "strings" + + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/discovery" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/engine" + "helm.sh/helm/v3/pkg/kube" + "helm.sh/helm/v3/pkg/postrender" + "helm.sh/helm/v3/pkg/registry" + "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/releaseutil" + "helm.sh/helm/v3/pkg/storage" + "helm.sh/helm/v3/pkg/storage/driver" + "helm.sh/helm/v3/pkg/time" +) + +// Timestamper is a function capable of producing a timestamp.Timestamper. +// +// By default, this is a time.Time function from the Helm time package. This can +// be overridden for testing though, so that timestamps are predictable. +var Timestamper = time.Now + +var ( + // errMissingChart indicates that a chart was not provided. + errMissingChart = errors.New("no chart provided") + // errMissingRelease indicates that a release (name) was not provided. + errMissingRelease = errors.New("no release provided") + // errInvalidRevision indicates that an invalid release revision number was provided. + errInvalidRevision = errors.New("invalid release revision") + // errPending indicates that another instance of Helm is already applying an operation on a release. + errPending = errors.New("another operation (install/upgrade/rollback) is in progress") +) + +// ValidName is a regular expression for resource names. +// +// DEPRECATED: This will be removed in Helm 4, and is no longer used here. See +// pkg/lint/rules.validateMetadataNameFunc for the replacement. +// +// According to the Kubernetes help text, the regular expression it uses is: +// +// [a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)* +// +// This follows the above regular expression (but requires a full string match, not partial). +// +// The Kubernetes documentation is here, though it is not entirely correct: +// https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names +var ValidName = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`) + +// Configuration injects the dependencies that all actions share. +type Configuration struct { + // RESTClientGetter is an interface that loads Kubernetes clients. + RESTClientGetter RESTClientGetter + + // Releases stores records of releases. + Releases *storage.Storage + + // KubeClient is a Kubernetes API client. + KubeClient kube.Interface + + // RegistryClient is a client for working with registries + RegistryClient *registry.Client + + // Capabilities describes the capabilities of the Kubernetes cluster. + Capabilities *chartutil.Capabilities + + Log func(string, ...interface{}) +} + +// renderResources renders the templates in a chart +// +// TODO: This function is badly in need of a refactor. +// TODO: As part of the refactor the duplicate code in cmd/helm/template.go should be removed +// +// This code has to do with writing files to disk. +func (cfg *Configuration) renderResources(ch *chart.Chart, values chartutil.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrender.PostRenderer, interactWithRemote, enableDNS bool) ([]*release.Hook, *bytes.Buffer, string, error) { + hs := []*release.Hook{} + b := bytes.NewBuffer(nil) + + caps, err := cfg.getCapabilities() + if err != nil { + return hs, b, "", err + } + + if ch.Metadata.KubeVersion != "" { + if !chartutil.IsCompatibleRange(ch.Metadata.KubeVersion, caps.KubeVersion.String()) { + return hs, b, "", errors.Errorf("chart requires kubeVersion: %s which is incompatible with Kubernetes %s", ch.Metadata.KubeVersion, caps.KubeVersion.String()) + } + } + + var files map[string]string + var err2 error + + // A `helm template` should not talk to the remote cluster. However, commands with the flag + //`--dry-run` with the value of `false`, `none`, or `server` should try to interact with the cluster. + // It may break in interesting and exotic ways because other data (e.g. discovery) is mocked. + if interactWithRemote && cfg.RESTClientGetter != nil { + restConfig, err := cfg.RESTClientGetter.ToRESTConfig() + if err != nil { + return hs, b, "", err + } + e := engine.New(restConfig) + e.EnableDNS = enableDNS + files, err2 = e.Render(ch, values) + } else { + var e engine.Engine + e.EnableDNS = enableDNS + files, err2 = e.Render(ch, values) + } + + if err2 != nil { + return hs, b, "", err2 + } + + // NOTES.txt gets rendered like all the other files, but because it's not a hook nor a resource, + // pull it out of here into a separate file so that we can actually use the output of the rendered + // text file. We have to spin through this map because the file contains path information, so we + // look for terminating NOTES.txt. We also remove it from the files so that we don't have to skip + // it in the sortHooks. + var notesBuffer bytes.Buffer + for k, v := range files { + if strings.HasSuffix(k, notesFileSuffix) { + if subNotes || (k == path.Join(ch.Name(), "templates", notesFileSuffix)) { + // If buffer contains data, add newline before adding more + if notesBuffer.Len() > 0 { + notesBuffer.WriteString("\n") + } + notesBuffer.WriteString(v) + } + delete(files, k) + } + } + notes := notesBuffer.String() + + // Sort hooks, manifests, and partials. Only hooks and manifests are returned, + // as partials are not used after renderer.Render. Empty manifests are also + // removed here. + hs, manifests, err := releaseutil.SortManifests(files, caps.APIVersions, releaseutil.InstallOrder) + if err != nil { + // By catching parse errors here, we can prevent bogus releases from going + // to Kubernetes. + // + // We return the files as a big blob of data to help the user debug parser + // errors. + for name, content := range files { + if strings.TrimSpace(content) == "" { + continue + } + fmt.Fprintf(b, "---\n# Source: %s\n%s\n", name, content) + } + return hs, b, "", err + } + + // Aggregate all valid manifests into one big doc. + fileWritten := make(map[string]bool) + + if includeCrds { + for _, crd := range ch.CRDObjects() { + if outputDir == "" { + fmt.Fprintf(b, "---\n# Source: %s\n%s\n", crd.Filename, string(crd.File.Data[:])) + } else { + err = writeToFile(outputDir, crd.Filename, string(crd.File.Data[:]), fileWritten[crd.Filename]) + if err != nil { + return hs, b, "", err + } + fileWritten[crd.Filename] = true + } + } + } + + for _, m := range manifests { + if outputDir == "" { + fmt.Fprintf(b, "---\n# Source: %s\n%s\n", m.Name, m.Content) + } else { + newDir := outputDir + if useReleaseName { + newDir = filepath.Join(outputDir, releaseName) + } + // NOTE: We do not have to worry about the post-renderer because + // output dir is only used by `helm template`. In the next major + // release, we should move this logic to template only as it is not + // used by install or upgrade + err = writeToFile(newDir, m.Name, m.Content, fileWritten[m.Name]) + if err != nil { + return hs, b, "", err + } + fileWritten[m.Name] = true + } + } + + if pr != nil { + b, err = pr.Run(b) + if err != nil { + return hs, b, notes, errors.Wrap(err, "error while running post render on files") + } + } + + return hs, b, notes, nil +} + +// RESTClientGetter gets the rest client +type RESTClientGetter interface { + ToRESTConfig() (*rest.Config, error) + ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) + ToRESTMapper() (meta.RESTMapper, error) +} + +// DebugLog sets the logger that writes debug strings +type DebugLog func(format string, v ...interface{}) + +// capabilities builds a Capabilities from discovery information. +func (cfg *Configuration) getCapabilities() (*chartutil.Capabilities, error) { + if cfg.Capabilities != nil { + return cfg.Capabilities, nil + } + dc, err := cfg.RESTClientGetter.ToDiscoveryClient() + if err != nil { + return nil, errors.Wrap(err, "could not get Kubernetes discovery client") + } + // force a discovery cache invalidation to always fetch the latest server version/capabilities. + dc.Invalidate() + kubeVersion, err := dc.ServerVersion() + if err != nil { + return nil, errors.Wrap(err, "could not get server version from Kubernetes") + } + // Issue #6361: + // Client-Go emits an error when an API service is registered but unimplemented. + // We trap that error here and print a warning. But since the discovery client continues + // building the API object, it is correctly populated with all valid APIs. + // See https://github.com/kubernetes/kubernetes/issues/72051#issuecomment-521157642 + apiVersions, err := GetVersionSet(dc) + if err != nil { + if discovery.IsGroupDiscoveryFailedError(err) { + cfg.Log("WARNING: The Kubernetes server has an orphaned API service. Server reports: %s", err) + cfg.Log("WARNING: To fix this, kubectl delete apiservice ") + } else { + return nil, errors.Wrap(err, "could not get apiVersions from Kubernetes") + } + } + + cfg.Capabilities = &chartutil.Capabilities{ + APIVersions: apiVersions, + KubeVersion: chartutil.KubeVersion{ + Version: kubeVersion.GitVersion, + Major: kubeVersion.Major, + Minor: kubeVersion.Minor, + }, + HelmVersion: chartutil.DefaultCapabilities.HelmVersion, + } + return cfg.Capabilities, nil +} + +// KubernetesClientSet creates a new kubernetes ClientSet based on the configuration +func (cfg *Configuration) KubernetesClientSet() (kubernetes.Interface, error) { + conf, err := cfg.RESTClientGetter.ToRESTConfig() + if err != nil { + return nil, errors.Wrap(err, "unable to generate config for kubernetes client") + } + + return kubernetes.NewForConfig(conf) +} + +// Now generates a timestamp +// +// If the configuration has a Timestamper on it, that will be used. +// Otherwise, this will use time.Now(). +func (cfg *Configuration) Now() time.Time { + return Timestamper() +} + +func (cfg *Configuration) releaseContent(name string, version int) (*release.Release, error) { + if err := chartutil.ValidateReleaseName(name); err != nil { + return nil, errors.Errorf("releaseContent: Release name is invalid: %s", name) + } + + if version <= 0 { + return cfg.Releases.Last(name) + } + + return cfg.Releases.Get(name, version) +} + +// GetVersionSet retrieves a set of available k8s API versions +func GetVersionSet(client discovery.ServerResourcesInterface) (chartutil.VersionSet, error) { + groups, resources, err := client.ServerGroupsAndResources() + if err != nil && !discovery.IsGroupDiscoveryFailedError(err) { + return chartutil.DefaultVersionSet, errors.Wrap(err, "could not get apiVersions from Kubernetes") + } + + // FIXME: The Kubernetes test fixture for cli appears to always return nil + // for calls to Discovery().ServerGroupsAndResources(). So in this case, we + // return the default API list. This is also a safe value to return in any + // other odd-ball case. + if len(groups) == 0 && len(resources) == 0 { + return chartutil.DefaultVersionSet, nil + } + + versionMap := make(map[string]interface{}) + versions := []string{} + + // Extract the groups + for _, g := range groups { + for _, gv := range g.Versions { + versionMap[gv.GroupVersion] = struct{}{} + } + } + + // Extract the resources + var id string + var ok bool + for _, r := range resources { + for _, rl := range r.APIResources { + + // A Kind at a GroupVersion can show up more than once. We only want + // it displayed once in the final output. + id = path.Join(r.GroupVersion, rl.Kind) + if _, ok = versionMap[id]; !ok { + versionMap[id] = struct{}{} + } + } + } + + // Convert to a form that NewVersionSet can use + for k := range versionMap { + versions = append(versions, k) + } + + return chartutil.VersionSet(versions), nil +} + +// recordRelease with an update operation in case reuse has been set. +func (cfg *Configuration) recordRelease(r *release.Release) { + if err := cfg.Releases.Update(r); err != nil { + cfg.Log("warning: Failed to update release %s: %s", r.Name, err) + } +} + +// Init initializes the action configuration +func (cfg *Configuration) Init(getter genericclioptions.RESTClientGetter, namespace, helmDriver string, log DebugLog) error { + kc := kube.New(getter) + kc.Log = log + + lazyClient := &lazyClient{ + namespace: namespace, + clientFn: kc.Factory.KubernetesClientSet, + } + + var store *storage.Storage + switch helmDriver { + case "secret", "secrets", "": + d := driver.NewSecrets(newSecretClient(lazyClient)) + d.Log = log + store = storage.Init(d) + case "configmap", "configmaps": + d := driver.NewConfigMaps(newConfigMapClient(lazyClient)) + d.Log = log + store = storage.Init(d) + case "memory": + var d *driver.Memory + if cfg.Releases != nil { + if mem, ok := cfg.Releases.Driver.(*driver.Memory); ok { + // This function can be called more than once (e.g., helm list --all-namespaces). + // If a memory driver was already initialized, re-use it but set the possibly new namespace. + // We re-use it in case some releases where already created in the existing memory driver. + d = mem + } + } + if d == nil { + d = driver.NewMemory() + } + d.SetNamespace(namespace) + store = storage.Init(d) + case "sql": + d, err := driver.NewSQL( + os.Getenv("HELM_DRIVER_SQL_CONNECTION_STRING"), + log, + namespace, + ) + if err != nil { + panic(fmt.Sprintf("Unable to instantiate SQL driver: %v", err)) + } + store = storage.Init(d) + default: + // Not sure what to do here. + panic("Unknown driver in HELM_DRIVER: " + helmDriver) + } + + cfg.RESTClientGetter = getter + cfg.KubeClient = kc + cfg.Releases = store + cfg.Log = log + + return nil +} diff --git a/pkg/action/action_test.go b/pkg/action/action_test.go new file mode 100644 index 00000000..c4ef6c05 --- /dev/null +++ b/pkg/action/action_test.go @@ -0,0 +1,283 @@ +/* +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 ( + "flag" + "io" + "testing" + + fakeclientset "k8s.io/client-go/kubernetes/fake" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chartutil" + kubefake "helm.sh/helm/v3/pkg/kube/fake" + "helm.sh/helm/v3/pkg/registry" + "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/storage" + "helm.sh/helm/v3/pkg/storage/driver" + "helm.sh/helm/v3/pkg/time" +) + +var verbose = flag.Bool("test.log", false, "enable test logging") + +func actionConfigFixture(t *testing.T) *Configuration { + t.Helper() + + registryClient, err := registry.NewClient() + if err != nil { + t.Fatal(err) + } + + return &Configuration{ + Releases: storage.Init(driver.NewMemory()), + KubeClient: &kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}}, + Capabilities: chartutil.DefaultCapabilities, + RegistryClient: registryClient, + Log: func(format string, v ...interface{}) { + t.Helper() + if *verbose { + t.Logf(format, v...) + } + }, + } +} + +var manifestWithHook = `kind: ConfigMap +metadata: + name: test-cm + annotations: + "helm.sh/hook": post-install,pre-delete,post-upgrade +data: + name: value` + +var manifestWithTestHook = `kind: Pod + metadata: + name: finding-nemo, + annotations: + "helm.sh/hook": test + spec: + containers: + - name: nemo-test + image: fake-image + cmd: fake-command + ` + +var rbacManifests = `apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: schedule-agents +rules: +- apiGroups: [""] + resources: ["pods", "pods/exec", "pods/log"] + verbs: ["*"] + +--- + +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: schedule-agents + namespace: {{ default .Release.Namespace}} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: schedule-agents +subjects: +- kind: ServiceAccount + name: schedule-agents + namespace: {{ .Release.Namespace }} +` + +type chartOptions struct { + *chart.Chart +} + +type chartOption func(*chartOptions) + +func buildChart(opts ...chartOption) *chart.Chart { + c := &chartOptions{ + Chart: &chart.Chart{ + // TODO: This should be more complete. + Metadata: &chart.Metadata{ + APIVersion: "v1", + Name: "hello", + Version: "0.1.0", + }, + // This adds a basic template and hooks. + Templates: []*chart.File{ + {Name: "templates/hello", Data: []byte("hello: world")}, + {Name: "templates/hooks", Data: []byte(manifestWithHook)}, + }, + }, + } + + for _, opt := range opts { + opt(c) + } + + return c.Chart +} + +func withName(name string) chartOption { + return func(opts *chartOptions) { + opts.Metadata.Name = name + } +} + +func withSampleValues() chartOption { + values := map[string]interface{}{ + "someKey": "someValue", + "nestedKey": map[string]interface{}{ + "simpleKey": "simpleValue", + "anotherNestedKey": map[string]interface{}{ + "yetAnotherNestedKey": map[string]interface{}{ + "youReadyForAnotherNestedKey": "No", + }, + }, + }, + } + return func(opts *chartOptions) { + opts.Values = values + } +} + +func withValues(values map[string]interface{}) chartOption { + return func(opts *chartOptions) { + opts.Values = values + } +} + +func withNotes(notes string) chartOption { + return func(opts *chartOptions) { + opts.Templates = append(opts.Templates, &chart.File{ + Name: "templates/NOTES.txt", + Data: []byte(notes), + }) + } +} + +func withDependency(dependencyOpts ...chartOption) chartOption { + return func(opts *chartOptions) { + opts.AddDependency(buildChart(dependencyOpts...)) + } +} + +func withMetadataDependency(dependency chart.Dependency) chartOption { + return func(opts *chartOptions) { + opts.Metadata.Dependencies = append(opts.Metadata.Dependencies, &dependency) + } +} + +func withSampleTemplates() chartOption { + return func(opts *chartOptions) { + sampleTemplates := []*chart.File{ + // This adds basic templates and partials. + {Name: "templates/goodbye", Data: []byte("goodbye: world")}, + {Name: "templates/empty", Data: []byte("")}, + {Name: "templates/with-partials", Data: []byte(`hello: {{ template "_planet" . }}`)}, + {Name: "templates/partials/_planet", Data: []byte(`{{define "_planet"}}Earth{{end}}`)}, + } + opts.Templates = append(opts.Templates, sampleTemplates...) + } +} + +func withSampleIncludingIncorrectTemplates() chartOption { + return func(opts *chartOptions) { + sampleTemplates := []*chart.File{ + // This adds basic templates and partials. + {Name: "templates/goodbye", Data: []byte("goodbye: world")}, + {Name: "templates/empty", Data: []byte("")}, + {Name: "templates/incorrect", Data: []byte("{{ .Values.bad.doh }}")}, + {Name: "templates/with-partials", Data: []byte(`hello: {{ template "_planet" . }}`)}, + {Name: "templates/partials/_planet", Data: []byte(`{{define "_planet"}}Earth{{end}}`)}, + } + opts.Templates = append(opts.Templates, sampleTemplates...) + } +} + +func withMultipleManifestTemplate() chartOption { + return func(opts *chartOptions) { + sampleTemplates := []*chart.File{ + {Name: "templates/rbac", Data: []byte(rbacManifests)}, + } + opts.Templates = append(opts.Templates, sampleTemplates...) + } +} + +func withKube(version string) chartOption { + return func(opts *chartOptions) { + opts.Metadata.KubeVersion = version + } +} + +// releaseStub creates a release stub, complete with the chartStub as its chart. +func releaseStub() *release.Release { + return namedReleaseStub("angry-panda", release.StatusDeployed) +} + +func namedReleaseStub(name string, status release.Status) *release.Release { + now := time.Now() + return &release.Release{ + Name: name, + Info: &release.Info{ + FirstDeployed: now, + LastDeployed: now, + Status: status, + Description: "Named Release Stub", + }, + Chart: buildChart(withSampleTemplates()), + Config: map[string]interface{}{"name": "value"}, + Version: 1, + Hooks: []*release.Hook{ + { + Name: "test-cm", + Kind: "ConfigMap", + Path: "test-cm", + Manifest: manifestWithHook, + Events: []release.HookEvent{ + release.HookPostInstall, + release.HookPreDelete, + }, + }, + { + Name: "finding-nemo", + Kind: "Pod", + Path: "finding-nemo", + Manifest: manifestWithTestHook, + Events: []release.HookEvent{ + release.HookTest, + }, + }, + }, + } +} + +func TestGetVersionSet(t *testing.T) { + client := fakeclientset.NewSimpleClientset() + + vs, err := GetVersionSet(client.Discovery()) + if err != nil { + t.Error(err) + } + + if !vs.Has("v1") { + t.Errorf("Expected supported versions to at least include v1.") + } + if vs.Has("nosuchversion/v1") { + t.Error("Non-existent version is reported found.") + } +} diff --git a/pkg/action/dependency.go b/pkg/action/dependency.go new file mode 100644 index 00000000..3265f1f1 --- /dev/null +++ b/pkg/action/dependency.go @@ -0,0 +1,230 @@ +/* +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 ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/gosuri/uitable" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" +) + +// Dependency is the action for building a given chart's dependency tree. +// +// It provides the implementation of 'helm dependency' and its respective subcommands. +type Dependency struct { + Verify bool + Keyring string + SkipRefresh bool + ColumnWidth uint +} + +// NewDependency creates a new Dependency object with the given configuration. +func NewDependency() *Dependency { + return &Dependency{ + ColumnWidth: 80, + } +} + +// List executes 'helm dependency list'. +func (d *Dependency) List(chartpath string, out io.Writer) error { + c, err := loader.Load(chartpath) + if err != nil { + return err + } + + if c.Metadata.Dependencies == nil { + fmt.Fprintf(out, "WARNING: no dependencies at %s\n", filepath.Join(chartpath, "charts")) + return nil + } + + d.printDependencies(chartpath, out, c) + fmt.Fprintln(out) + d.printMissing(chartpath, out, c.Metadata.Dependencies) + return nil +} + +// dependencyStatus returns a string describing the status of a dependency viz a viz the parent chart. +func (d *Dependency) dependencyStatus(chartpath string, dep *chart.Dependency, parent *chart.Chart) string { + filename := fmt.Sprintf("%s-%s.tgz", dep.Name, "*") + + // If a chart is unpacked, this will check the unpacked chart's `charts/` directory for tarballs. + // Technically, this is COMPLETELY unnecessary, and should be removed in Helm 4. It is here + // to preserved backward compatibility. In Helm 2/3, there is a "difference" between + // the tgz version (which outputs "ok" if it unpacks) and the loaded version (which outputs + // "unpacked"). Early in Helm 2's history, this would have made a difference. But it no + // longer does. However, since this code shipped with Helm 3, the output must remain stable + // until Helm 4. + switch archives, err := filepath.Glob(filepath.Join(chartpath, "charts", filename)); { + case err != nil: + return "bad pattern" + case len(archives) > 1: + // See if the second part is a SemVer + found := []string{} + for _, arc := range archives { + // we need to trip the prefix dirs and the extension off. + filename = strings.TrimSuffix(filepath.Base(arc), ".tgz") + maybeVersion := strings.TrimPrefix(filename, fmt.Sprintf("%s-", dep.Name)) + + if _, err := semver.StrictNewVersion(maybeVersion); err == nil { + // If the version parsed without an error, it is possibly a valid + // version. + found = append(found, arc) + } + } + + if l := len(found); l == 1 { + // If we get here, we do the same thing as in len(archives) == 1. + if r := statArchiveForStatus(found[0], dep); r != "" { + return r + } + + // Fall through and look for directories + } else if l > 1 { + return "too many matches" + } + + // The sanest thing to do here is to fall through and see if we have any directory + // matches. + + case len(archives) == 1: + archive := archives[0] + if r := statArchiveForStatus(archive, dep); r != "" { + return r + } + + } + // End unnecessary code. + + var depChart *chart.Chart + for _, item := range parent.Dependencies() { + if item.Name() == dep.Name { + depChart = item + } + } + + if depChart == nil { + return "missing" + } + + if depChart.Metadata.Version != dep.Version { + constraint, err := semver.NewConstraint(dep.Version) + if err != nil { + return "invalid version" + } + + v, err := semver.NewVersion(depChart.Metadata.Version) + if err != nil { + return "invalid version" + } + + if !constraint.Check(v) { + return "wrong version" + } + } + + return "unpacked" +} + +// stat an archive and return a message if the stat is successful +// +// This is a refactor of the code originally in dependencyStatus. It is here to +// support legacy behavior, and should be removed in Helm 4. +func statArchiveForStatus(archive string, dep *chart.Dependency) string { + if _, err := os.Stat(archive); err == nil { + c, err := loader.Load(archive) + if err != nil { + return "corrupt" + } + if c.Name() != dep.Name { + return "misnamed" + } + + if c.Metadata.Version != dep.Version { + constraint, err := semver.NewConstraint(dep.Version) + if err != nil { + return "invalid version" + } + + v, err := semver.NewVersion(c.Metadata.Version) + if err != nil { + return "invalid version" + } + + if !constraint.Check(v) { + return "wrong version" + } + } + return "ok" + } + return "" +} + +// printDependencies prints all of the dependencies in the yaml file. +func (d *Dependency) printDependencies(chartpath string, out io.Writer, c *chart.Chart) { + table := uitable.New() + table.MaxColWidth = d.ColumnWidth + table.AddRow("NAME", "VERSION", "REPOSITORY", "STATUS") + for _, row := range c.Metadata.Dependencies { + table.AddRow(row.Name, row.Version, row.Repository, d.dependencyStatus(chartpath, row, c)) + } + fmt.Fprintln(out, table) +} + +// printMissing prints warnings about charts that are present on disk, but are +// not in Chart.yaml. +func (d *Dependency) printMissing(chartpath string, out io.Writer, reqs []*chart.Dependency) { + folder := filepath.Join(chartpath, "charts/*") + files, err := filepath.Glob(folder) + if err != nil { + fmt.Fprintln(out, err) + return + } + + for _, f := range files { + fi, err := os.Stat(f) + if err != nil { + fmt.Fprintf(out, "Warning: %s\n", err) + } + // Skip anything that is not a directory and not a tgz file. + if !fi.IsDir() && filepath.Ext(f) != ".tgz" { + continue + } + c, err := loader.Load(f) + if err != nil { + fmt.Fprintf(out, "WARNING: %q is not a chart.\n", f) + continue + } + found := false + for _, d := range reqs { + if d.Name == c.Name() { + found = true + break + } + } + if !found { + fmt.Fprintf(out, "WARNING: %q is not in Chart.yaml.\n", f) + } + } +} diff --git a/pkg/action/dependency_test.go b/pkg/action/dependency_test.go new file mode 100644 index 00000000..c29587ae --- /dev/null +++ b/pkg/action/dependency_test.go @@ -0,0 +1,152 @@ +/* +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" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + + "helm.sh/helm/v3/internal/test" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chartutil" +) + +func TestList(t *testing.T) { + for _, tcase := range []struct { + chart string + golden string + }{ + { + chart: "testdata/charts/chart-with-compressed-dependencies", + golden: "output/list-compressed-deps.txt", + }, + { + chart: "testdata/charts/chart-with-compressed-dependencies-2.1.8.tgz", + golden: "output/list-compressed-deps-tgz.txt", + }, + { + chart: "testdata/charts/chart-with-uncompressed-dependencies", + golden: "output/list-uncompressed-deps.txt", + }, + { + chart: "testdata/charts/chart-with-uncompressed-dependencies-2.1.8.tgz", + golden: "output/list-uncompressed-deps-tgz.txt", + }, + { + chart: "testdata/charts/chart-missing-deps", + golden: "output/list-missing-deps.txt", + }, + } { + buf := bytes.Buffer{} + if err := NewDependency().List(tcase.chart, &buf); err != nil { + t.Fatal(err) + } + test.AssertGoldenString(t, buf.String(), tcase.golden) + } +} + +// TestDependencyStatus_Dashes is a regression test to make sure that dashes in +// chart names do not cause resolution problems. +func TestDependencyStatus_Dashes(t *testing.T) { + // Make a temp dir + dir := t.TempDir() + + chartpath := filepath.Join(dir, "charts") + if err := os.MkdirAll(chartpath, 0700); err != nil { + t.Fatal(err) + } + + // Add some fake charts + first := buildChart(withName("first-chart")) + _, err := chartutil.Save(first, chartpath) + if err != nil { + t.Fatal(err) + } + + second := buildChart(withName("first-chart-second-chart")) + _, err = chartutil.Save(second, chartpath) + if err != nil { + t.Fatal(err) + } + + dep := &chart.Dependency{ + Name: "first-chart", + Version: "0.1.0", + } + + // Now try to get the deps + stat := NewDependency().dependencyStatus(dir, dep, first) + if stat != "ok" { + t.Errorf("Unexpected status: %q", stat) + } +} + +func TestStatArchiveForStatus(t *testing.T) { + // Make a temp dir + dir := t.TempDir() + + chartpath := filepath.Join(dir, "charts") + if err := os.MkdirAll(chartpath, 0700); err != nil { + t.Fatal(err) + } + + // unsaved chart + lilith := buildChart(withName("lilith")) + + // dep referring to chart + dep := &chart.Dependency{ + Name: "lilith", + Version: "1.2.3", + } + + is := assert.New(t) + + lilithpath := filepath.Join(chartpath, "lilith-1.2.3.tgz") + is.Empty(statArchiveForStatus(lilithpath, dep)) + + // save the chart (version 0.1.0, because that is the default) + where, err := chartutil.Save(lilith, chartpath) + is.NoError(err) + + // Should get "wrong version" because we asked for 1.2.3 and got 0.1.0 + is.Equal("wrong version", statArchiveForStatus(where, dep)) + + // Break version on dep + dep = &chart.Dependency{ + Name: "lilith", + Version: "1.2.3.4.5", + } + is.Equal("invalid version", statArchiveForStatus(where, dep)) + + // Break the name + dep = &chart.Dependency{ + Name: "lilith2", + Version: "1.2.3", + } + is.Equal("misnamed", statArchiveForStatus(where, dep)) + + // Now create the right version + dep = &chart.Dependency{ + Name: "lilith", + Version: "0.1.0", + } + is.Equal("ok", statArchiveForStatus(where, dep)) +} diff --git a/pkg/action/doc.go b/pkg/action/doc.go new file mode 100644 index 00000000..3c91bd61 --- /dev/null +++ b/pkg/action/doc.go @@ -0,0 +1,22 @@ +/* +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 contains the logic for each action that Helm can perform. +// +// This is a library for calling top-level Helm actions like 'install', +// 'upgrade', or 'list'. Actions approximately match the command line +// invocations that the Helm client uses. +package action diff --git a/pkg/action/get.go b/pkg/action/get.go new file mode 100644 index 00000000..f44b5330 --- /dev/null +++ b/pkg/action/get.go @@ -0,0 +1,47 @@ +/* +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 ( + "helm.sh/helm/v3/pkg/release" +) + +// Get is the action for checking a given release's information. +// +// It provides the implementation of 'helm get' and its respective subcommands (except `helm get values`). +type Get struct { + cfg *Configuration + + // Initializing Version to 0 will get the latest revision of the release. + Version int +} + +// NewGet creates a new Get object with the given configuration. +func NewGet(cfg *Configuration) *Get { + return &Get{ + cfg: cfg, + } +} + +// Run executes 'helm get' against the given release. +func (g *Get) Run(name string) (*release.Release, error) { + if err := g.cfg.KubeClient.IsReachable(); err != nil { + return nil, err + } + + return g.cfg.releaseContent(name, g.Version) +} diff --git a/pkg/action/get_metadata.go b/pkg/action/get_metadata.go new file mode 100644 index 00000000..ec096ae1 --- /dev/null +++ b/pkg/action/get_metadata.go @@ -0,0 +1,69 @@ +/* +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 "time" + +// GetMetadata is the action for checking a given release's metadata. +// +// It provides the implementation of 'helm get metadata'. +type GetMetadata struct { + cfg *Configuration + + Version int +} + +type Metadata struct { + Name string `json:"name" yaml:"name"` + Chart string `json:"chart" yaml:"chart"` + Version string `json:"version" yaml:"version"` + AppVersion string `json:"appVersion" yaml:"appVersion"` + Namespace string `json:"namespace" yaml:"namespace"` + Revision int `json:"revision" yaml:"revision"` + Status string `json:"status" yaml:"status"` + DeployedAt string `json:"deployedAt" yaml:"deployedAt"` +} + +// NewGetMetadata creates a new GetMetadata object with the given configuration. +func NewGetMetadata(cfg *Configuration) *GetMetadata { + return &GetMetadata{ + cfg: cfg, + } +} + +// Run executes 'helm get metadata' against the given release. +func (g *GetMetadata) Run(name string) (*Metadata, error) { + if err := g.cfg.KubeClient.IsReachable(); err != nil { + return nil, err + } + + rel, err := g.cfg.releaseContent(name, g.Version) + if err != nil { + return nil, err + } + + return &Metadata{ + Name: rel.Name, + Chart: rel.Chart.Metadata.Name, + Version: rel.Chart.Metadata.Version, + AppVersion: rel.Chart.Metadata.AppVersion, + Namespace: rel.Namespace, + Revision: rel.Version, + Status: rel.Info.Status.String(), + DeployedAt: rel.Info.LastDeployed.Format(time.RFC3339), + }, nil +} diff --git a/pkg/action/get_values.go b/pkg/action/get_values.go new file mode 100644 index 00000000..9c32db21 --- /dev/null +++ b/pkg/action/get_values.go @@ -0,0 +1,60 @@ +/* +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 ( + "helm.sh/helm/v3/pkg/chartutil" +) + +// GetValues is the action for checking a given release's values. +// +// It provides the implementation of 'helm get values'. +type GetValues struct { + cfg *Configuration + + Version int + AllValues bool +} + +// NewGetValues creates a new GetValues object with the given configuration. +func NewGetValues(cfg *Configuration) *GetValues { + return &GetValues{ + cfg: cfg, + } +} + +// Run executes 'helm get values' against the given release. +func (g *GetValues) Run(name string) (map[string]interface{}, error) { + if err := g.cfg.KubeClient.IsReachable(); err != nil { + return nil, err + } + + rel, err := g.cfg.releaseContent(name, g.Version) + if err != nil { + return nil, err + } + + // If the user wants all values, compute the values and return. + if g.AllValues { + cfg, err := chartutil.CoalesceValues(rel.Chart, rel.Config) + if err != nil { + return nil, err + } + return cfg, nil + } + return rel.Config, nil +} diff --git a/pkg/action/history.go b/pkg/action/history.go new file mode 100644 index 00000000..0430aaf7 --- /dev/null +++ b/pkg/action/history.go @@ -0,0 +1,58 @@ +/* +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 ( + "github.com/pkg/errors" + + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/release" +) + +// History is the action for checking the release's ledger. +// +// It provides the implementation of 'helm history'. +// It returns all the revisions for a specific release. +// To list up to one revision of every release in one specific, or in all, +// namespaces, see the List action. +type History struct { + cfg *Configuration + + Max int + Version int +} + +// NewHistory creates a new History object with the given configuration. +func NewHistory(cfg *Configuration) *History { + return &History{ + cfg: cfg, + } +} + +// Run executes 'helm history' against the given release. +func (h *History) Run(name string) ([]*release.Release, error) { + if err := h.cfg.KubeClient.IsReachable(); err != nil { + return nil, err + } + + if err := chartutil.ValidateReleaseName(name); err != nil { + return nil, errors.Errorf("release name is invalid: %s", name) + } + + h.cfg.Log("getting history for release %s", name) + return h.cfg.Releases.History(name) +} diff --git a/pkg/action/hooks.go b/pkg/action/hooks.go new file mode 100644 index 00000000..0af625df --- /dev/null +++ b/pkg/action/hooks.go @@ -0,0 +1,159 @@ +/* +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" + "sort" + "time" + + "github.com/pkg/errors" + + "helm.sh/helm/v3/pkg/kube" + "helm.sh/helm/v3/pkg/release" + helmtime "helm.sh/helm/v3/pkg/time" +) + +// execHook executes all of the hooks for the given hook event. +func (cfg *Configuration) execHook(rl *release.Release, hook release.HookEvent, timeout time.Duration) error { + executingHooks := []*release.Hook{} + + for _, h := range rl.Hooks { + for _, e := range h.Events { + if e == hook { + executingHooks = append(executingHooks, h) + } + } + } + + // hooke are pre-ordered by kind, so keep order stable + sort.Stable(hookByWeight(executingHooks)) + + for _, h := range executingHooks { + // Set default delete policy to before-hook-creation + if h.DeletePolicies == nil || len(h.DeletePolicies) == 0 { + // TODO(jlegrone): Only apply before-hook-creation delete policy to run to completion + // resources. For all other resource types update in place if a + // resource with the same name already exists and is owned by the + // current release. + h.DeletePolicies = []release.HookDeletePolicy{release.HookBeforeHookCreation} + } + + if err := cfg.deleteHookByPolicy(h, release.HookBeforeHookCreation, timeout); err != nil { + return err + } + + resources, err := cfg.KubeClient.Build(bytes.NewBufferString(h.Manifest), true) + if err != nil { + return errors.Wrapf(err, "unable to build kubernetes object for %s hook %s", hook, h.Path) + } + + // Record the time at which the hook was applied to the cluster + h.LastRun = release.HookExecution{ + StartedAt: helmtime.Now(), + Phase: release.HookPhaseRunning, + } + cfg.recordRelease(rl) + + // As long as the implementation of WatchUntilReady does not panic, HookPhaseFailed or HookPhaseSucceeded + // should always be set by this function. If we fail to do that for any reason, then HookPhaseUnknown is + // the most appropriate value to surface. + h.LastRun.Phase = release.HookPhaseUnknown + + // Create hook resources + if _, err := cfg.KubeClient.Create(resources); err != nil { + h.LastRun.CompletedAt = helmtime.Now() + h.LastRun.Phase = release.HookPhaseFailed + return errors.Wrapf(err, "warning: Hook %s %s failed", hook, h.Path) + } + + // Watch hook resources until they have completed + err = cfg.KubeClient.WatchUntilReady(resources, timeout) + // Note the time of success/failure + h.LastRun.CompletedAt = helmtime.Now() + // Mark hook as succeeded or failed + if err != nil { + h.LastRun.Phase = release.HookPhaseFailed + // If a hook is failed, check the annotation of the hook to determine whether the hook should be deleted + // under failed condition. If so, then clear the corresponding resource object in the hook + if err := cfg.deleteHookByPolicy(h, release.HookFailed, timeout); err != nil { + return err + } + return err + } + h.LastRun.Phase = release.HookPhaseSucceeded + } + + // If all hooks are successful, check the annotation of each hook to determine whether the hook should be deleted + // under succeeded condition. If so, then clear the corresponding resource object in each hook + for _, h := range executingHooks { + if err := cfg.deleteHookByPolicy(h, release.HookSucceeded, timeout); err != nil { + return err + } + } + + return nil +} + +// hookByWeight is a sorter for hooks +type hookByWeight []*release.Hook + +func (x hookByWeight) Len() int { return len(x) } +func (x hookByWeight) Swap(i, j int) { x[i], x[j] = x[j], x[i] } +func (x hookByWeight) Less(i, j int) bool { + if x[i].Weight == x[j].Weight { + return x[i].Name < x[j].Name + } + return x[i].Weight < x[j].Weight +} + +// deleteHookByPolicy deletes a hook if the hook policy instructs it to +func (cfg *Configuration) deleteHookByPolicy(h *release.Hook, policy release.HookDeletePolicy, timeout time.Duration) error { + // Never delete CustomResourceDefinitions; this could cause lots of + // cascading garbage collection. + if h.Kind == "CustomResourceDefinition" { + return nil + } + if hookHasDeletePolicy(h, policy) { + resources, err := cfg.KubeClient.Build(bytes.NewBufferString(h.Manifest), false) + if err != nil { + return errors.Wrapf(err, "unable to build kubernetes object for deleting hook %s", h.Path) + } + _, errs := cfg.KubeClient.Delete(resources) + if len(errs) > 0 { + return errors.New(joinErrors(errs)) + } + + //wait for resources until they are deleted to avoid conflicts + if kubeClient, ok := cfg.KubeClient.(kube.InterfaceExt); ok { + if err := kubeClient.WaitForDelete(resources, timeout); err != nil { + return err + } + } + } + return nil +} + +// hookHasDeletePolicy determines whether the defined hook deletion policy matches the hook deletion polices +// supported by helm. If so, mark the hook as one should be deleted. +func hookHasDeletePolicy(h *release.Hook, policy release.HookDeletePolicy) bool { + for _, v := range h.DeletePolicies { + if policy == v { + return true + } + } + return false +} diff --git a/pkg/action/install.go b/pkg/action/install.go new file mode 100644 index 00000000..e3538a4f --- /dev/null +++ b/pkg/action/install.go @@ -0,0 +1,815 @@ +/* +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" + "context" + "fmt" + "io" + "net/url" + "os" + "path" + "path/filepath" + "strings" + "sync" + "text/template" + "time" + + "github.com/Masterminds/sprig/v3" + "github.com/pkg/errors" + v1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/resource" + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/downloader" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/kube" + kubefake "helm.sh/helm/v3/pkg/kube/fake" + "helm.sh/helm/v3/pkg/postrender" + "helm.sh/helm/v3/pkg/registry" + "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/releaseutil" + "helm.sh/helm/v3/pkg/repo" + "helm.sh/helm/v3/pkg/storage" + "helm.sh/helm/v3/pkg/storage/driver" +) + +// NOTESFILE_SUFFIX that we want to treat special. It goes through the templating engine +// but it's not a yaml file (resource) hence can't have hooks, etc. And the user actually +// wants to see this file after rendering in the status command. However, it must be a suffix +// since there can be filepath in front of it. +const notesFileSuffix = "NOTES.txt" + +const defaultDirectoryPermission = 0755 + +// Install performs an installation operation. +type Install struct { + cfg *Configuration + + ChartPathOptions + + ClientOnly bool + Force bool + CreateNamespace bool + DryRun bool + DryRunOption string + DisableHooks bool + Replace bool + Wait bool + WaitForJobs bool + Devel bool + DependencyUpdate bool + Timeout time.Duration + Namespace string + ReleaseName string + GenerateName bool + NameTemplate string + Description string + OutputDir string + Atomic bool + SkipCRDs bool + SubNotes bool + DisableOpenAPIValidation bool + IncludeCRDs bool + Labels map[string]string + // KubeVersion allows specifying a custom kubernetes version to use and + // APIVersions allows a manual set of supported API Versions to be passed + // (for things like templating). These are ignored if ClientOnly is false + KubeVersion *chartutil.KubeVersion + APIVersions chartutil.VersionSet + // Used by helm template to render charts with .Release.IsUpgrade. Ignored if Dry-Run is false + IsUpgrade bool + // Enable DNS lookups when rendering templates + EnableDNS bool + // Used by helm template to add the release as part of OutputDir path + // OutputDir/ + UseReleaseName bool + PostRenderer postrender.PostRenderer + // Lock to control raceconditions when the process receives a SIGTERM + Lock sync.Mutex +} + +// ChartPathOptions captures common options used for controlling chart paths +type ChartPathOptions struct { + CaFile string // --ca-file + CertFile string // --cert-file + KeyFile string // --key-file + InsecureSkipTLSverify bool // --insecure-skip-verify + PlainHTTP bool // --plain-http + Keyring string // --keyring + Password string // --password + PassCredentialsAll bool // --pass-credentials + RepoURL string // --repo + Username string // --username + Verify bool // --verify + Version string // --version + + // registryClient provides a registry client but is not added with + // options from a flag + registryClient *registry.Client +} + +// NewInstall creates a new Install object with the given configuration. +func NewInstall(cfg *Configuration) *Install { + in := &Install{ + cfg: cfg, + } + in.ChartPathOptions.registryClient = cfg.RegistryClient + + return in +} + +// SetRegistryClient sets the registry client for the install action +func (i *Install) SetRegistryClient(registryClient *registry.Client) { + i.ChartPathOptions.registryClient = registryClient +} + +// GetRegistryClient get the registry client. +func (i *Install) GetRegistryClient() *registry.Client { + return i.ChartPathOptions.registryClient +} + +func (i *Install) installCRDs(crds []chart.CRD) error { + // We do these one file at a time in the order they were read. + totalItems := []*resource.Info{} + for _, obj := range crds { + // Read in the resources + res, err := i.cfg.KubeClient.Build(bytes.NewBuffer(obj.File.Data), false) + if err != nil { + return errors.Wrapf(err, "failed to install CRD %s", obj.Name) + } + + // Send them to Kube + if _, err := i.cfg.KubeClient.Create(res); err != nil { + // If the error is CRD already exists, continue. + if apierrors.IsAlreadyExists(err) { + crdName := res[0].Name + i.cfg.Log("CRD %s is already present. Skipping.", crdName) + continue + } + return errors.Wrapf(err, "failed to install CRD %s", obj.Name) + } + totalItems = append(totalItems, res...) + } + if len(totalItems) > 0 { + // Give time for the CRD to be recognized. + if err := i.cfg.KubeClient.Wait(totalItems, 60*time.Second); err != nil { + return err + } + + // If we have already gathered the capabilities, we need to invalidate + // the cache so that the new CRDs are recognized. This should only be + // the case when an action configuration is reused for multiple actions, + // as otherwise it is later loaded by ourselves when getCapabilities + // is called later on in the installation process. + if i.cfg.Capabilities != nil { + discoveryClient, err := i.cfg.RESTClientGetter.ToDiscoveryClient() + if err != nil { + return err + } + + i.cfg.Log("Clearing discovery cache") + discoveryClient.Invalidate() + + _, _ = discoveryClient.ServerGroups() + } + + // Invalidate the REST mapper, since it will not have the new CRDs + // present. + restMapper, err := i.cfg.RESTClientGetter.ToRESTMapper() + if err != nil { + return err + } + if resettable, ok := restMapper.(meta.ResettableRESTMapper); ok { + i.cfg.Log("Clearing REST mapper cache") + resettable.Reset() + } + } + return nil +} + +// Run executes the installation +// +// If DryRun is set to true, this will prepare the release, but not install it + +func (i *Install) Run(chrt *chart.Chart, vals map[string]interface{}) (*release.Release, error) { + ctx := context.Background() + return i.RunWithContext(ctx, chrt, vals) +} + +// Run executes the installation with Context +// +// When the task is cancelled through ctx, the function returns and the install +// proceeds in the background. +func (i *Install) RunWithContext(ctx context.Context, chrt *chart.Chart, vals map[string]interface{}) (*release.Release, error) { + // Check reachability of cluster unless in client-only mode (e.g. `helm template` without `--validate`) + if !i.ClientOnly { + if err := i.cfg.KubeClient.IsReachable(); err != nil { + return nil, err + } + } + + if err := i.availableName(); err != nil { + return nil, err + } + + if err := chartutil.ProcessDependenciesWithMerge(chrt, vals); err != nil { + return nil, err + } + + var interactWithRemote bool + if !i.isDryRun() || i.DryRunOption == "server" || i.DryRunOption == "none" || i.DryRunOption == "false" { + interactWithRemote = true + } + + // Pre-install anything in the crd/ directory. We do this before Helm + // contacts the upstream server and builds the capabilities object. + if crds := chrt.CRDObjects(); !i.ClientOnly && !i.SkipCRDs && len(crds) > 0 { + // On dry run, bail here + if i.isDryRun() { + i.cfg.Log("WARNING: This chart or one of its subcharts contains CRDs. Rendering may fail or contain inaccuracies.") + } else if err := i.installCRDs(crds); err != nil { + return nil, err + } + } + + if i.ClientOnly { + // Add mock objects in here so it doesn't use Kube API server + // NOTE(bacongobbler): used for `helm template` + i.cfg.Capabilities = chartutil.DefaultCapabilities.Copy() + if i.KubeVersion != nil { + i.cfg.Capabilities.KubeVersion = *i.KubeVersion + } + i.cfg.Capabilities.APIVersions = append(i.cfg.Capabilities.APIVersions, i.APIVersions...) + i.cfg.KubeClient = &kubefake.PrintingKubeClient{Out: io.Discard} + + mem := driver.NewMemory() + mem.SetNamespace(i.Namespace) + i.cfg.Releases = storage.Init(mem) + } else if !i.ClientOnly && len(i.APIVersions) > 0 { + i.cfg.Log("API Version list given outside of client only mode, this list will be ignored") + } + + // Make sure if Atomic is set, that wait is set as well. This makes it so + // the user doesn't have to specify both + i.Wait = i.Wait || i.Atomic + + caps, err := i.cfg.getCapabilities() + if err != nil { + return nil, err + } + + // special case for helm template --is-upgrade + isUpgrade := i.IsUpgrade && i.isDryRun() + options := chartutil.ReleaseOptions{ + Name: i.ReleaseName, + Namespace: i.Namespace, + Revision: 1, + IsInstall: !isUpgrade, + IsUpgrade: isUpgrade, + } + valuesToRender, err := chartutil.ToRenderValues(chrt, vals, options, caps) + if err != nil { + return nil, err + } + + if driver.ContainsSystemLabels(i.Labels) { + return nil, fmt.Errorf("user suplied labels contains system reserved label name. System labels: %+v", driver.GetSystemLabels()) + } + + rel := i.createRelease(chrt, vals, i.Labels) + + var manifestDoc *bytes.Buffer + rel.Hooks, manifestDoc, rel.Info.Notes, err = i.cfg.renderResources(chrt, valuesToRender, i.ReleaseName, i.OutputDir, i.SubNotes, i.UseReleaseName, i.IncludeCRDs, i.PostRenderer, interactWithRemote, i.EnableDNS) + // Even for errors, attach this if available + if manifestDoc != nil { + rel.Manifest = manifestDoc.String() + } + // Check error from render + if err != nil { + rel.SetStatus(release.StatusFailed, fmt.Sprintf("failed to render resource: %s", err.Error())) + // Return a release with partial data so that the client can show debugging information. + return rel, err + } + + // Mark this release as in-progress + rel.SetStatus(release.StatusPendingInstall, "Initial install underway") + + var toBeAdopted kube.ResourceList + resources, err := i.cfg.KubeClient.Build(bytes.NewBufferString(rel.Manifest), !i.DisableOpenAPIValidation) + if err != nil { + return nil, errors.Wrap(err, "unable to build kubernetes objects from release manifest") + } + + // It is safe to use "force" here because these are resources currently rendered by the chart. + err = resources.Visit(setMetadataVisitor(rel.Name, rel.Namespace, true)) + if err != nil { + return nil, err + } + + // Install requires an extra validation step of checking that resources + // don't already exist before we actually create resources. If we continue + // forward and create the release object with resources that already exist, + // we'll end up in a state where we will delete those resources upon + // deleting the release because the manifest will be pointing at that + // resource + if !i.ClientOnly && !isUpgrade && len(resources) > 0 { + toBeAdopted, err = existingResourceConflict(resources, rel.Name, rel.Namespace) + if err != nil { + return nil, errors.Wrap(err, "Unable to continue with install") + } + } + + // Bail out here if it is a dry run + if i.isDryRun() { + rel.Info.Description = "Dry run complete" + return rel, nil + } + + if i.CreateNamespace { + ns := &v1.Namespace{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Namespace", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: i.Namespace, + Labels: map[string]string{ + "name": i.Namespace, + }, + }, + } + buf, err := yaml.Marshal(ns) + if err != nil { + return nil, err + } + resourceList, err := i.cfg.KubeClient.Build(bytes.NewBuffer(buf), true) + if err != nil { + return nil, err + } + if _, err := i.cfg.KubeClient.Create(resourceList); err != nil && !apierrors.IsAlreadyExists(err) { + return nil, err + } + } + + // If Replace is true, we need to supercede the last release. + if i.Replace { + if err := i.replaceRelease(rel); err != nil { + return nil, err + } + } + + // Store the release in history before continuing (new in Helm 3). We always know + // that this is a create operation. + if err := i.cfg.Releases.Create(rel); err != nil { + // We could try to recover gracefully here, but since nothing has been installed + // yet, this is probably safer than trying to continue when we know storage is + // not working. + return rel, err + } + + rel, err = i.performInstallCtx(ctx, rel, toBeAdopted, resources) + if err != nil { + rel, err = i.failRelease(rel, err) + } + return rel, err +} + +func (i *Install) performInstallCtx(ctx context.Context, rel *release.Release, toBeAdopted kube.ResourceList, resources kube.ResourceList) (*release.Release, error) { + type Msg struct { + r *release.Release + e error + } + resultChan := make(chan Msg, 1) + + go func() { + rel, err := i.performInstall(rel, toBeAdopted, resources) + resultChan <- Msg{rel, err} + }() + select { + case <-ctx.Done(): + err := ctx.Err() + return rel, err + case msg := <-resultChan: + return msg.r, msg.e + } +} + +// isDryRun returns true if Upgrade is set to run as a DryRun +func (i *Install) isDryRun() bool { + if i.DryRun || i.DryRunOption == "client" || i.DryRunOption == "server" || i.DryRunOption == "true" { + return true + } + return false +} + +func (i *Install) performInstall(rel *release.Release, toBeAdopted kube.ResourceList, resources kube.ResourceList) (*release.Release, error) { + var err error + // pre-install hooks + if !i.DisableHooks { + if err := i.cfg.execHook(rel, release.HookPreInstall, i.Timeout); err != nil { + return rel, fmt.Errorf("failed pre-install: %s", err) + } + } + + // At this point, we can do the install. Note that before we were detecting whether to + // do an update, but it's not clear whether we WANT to do an update if the re-use is set + // to true, since that is basically an upgrade operation. + if len(toBeAdopted) == 0 && len(resources) > 0 { + _, err = i.cfg.KubeClient.Create(resources) + } else if len(resources) > 0 { + _, err = i.cfg.KubeClient.Update(toBeAdopted, resources, i.Force) + } + if err != nil { + return rel, err + } + + if i.Wait { + if i.WaitForJobs { + err = i.cfg.KubeClient.WaitWithJobs(resources, i.Timeout) + } else { + err = i.cfg.KubeClient.Wait(resources, i.Timeout) + } + if err != nil { + return rel, err + } + } + + if !i.DisableHooks { + if err := i.cfg.execHook(rel, release.HookPostInstall, i.Timeout); err != nil { + return rel, fmt.Errorf("failed post-install: %s", err) + } + } + + if len(i.Description) > 0 { + rel.SetStatus(release.StatusDeployed, i.Description) + } else { + rel.SetStatus(release.StatusDeployed, "Install complete") + } + + // This is a tricky case. The release has been created, but the result + // cannot be recorded. The truest thing to tell the user is that the + // release was created. However, the user will not be able to do anything + // further with this release. + // + // One possible strategy would be to do a timed retry to see if we can get + // this stored in the future. + if err := i.recordRelease(rel); err != nil { + i.cfg.Log("failed to record the release: %s", err) + } + + return rel, nil +} + +func (i *Install) failRelease(rel *release.Release, err error) (*release.Release, error) { + rel.SetStatus(release.StatusFailed, fmt.Sprintf("Release %q failed: %s", i.ReleaseName, err.Error())) + if i.Atomic { + i.cfg.Log("Install failed and atomic is set, uninstalling release") + uninstall := NewUninstall(i.cfg) + uninstall.DisableHooks = i.DisableHooks + uninstall.KeepHistory = false + uninstall.Timeout = i.Timeout + if _, uninstallErr := uninstall.Run(i.ReleaseName); uninstallErr != nil { + return rel, errors.Wrapf(uninstallErr, "an error occurred while uninstalling the release. original install error: %s", err) + } + return rel, errors.Wrapf(err, "release %s failed, and has been uninstalled due to atomic being set", i.ReleaseName) + } + i.recordRelease(rel) // Ignore the error, since we have another error to deal with. + return rel, err +} + +// availableName tests whether a name is available +// +// Roughly, this will return an error if name is +// +// - empty +// - too long +// - already in use, and not deleted +// - used by a deleted release, and i.Replace is false +func (i *Install) availableName() error { + start := i.ReleaseName + + if err := chartutil.ValidateReleaseName(start); err != nil { + return errors.Wrapf(err, "release name %q", start) + } + // On dry run, bail here + if i.isDryRun() { + return nil + } + + h, err := i.cfg.Releases.History(start) + if err != nil || len(h) < 1 { + return nil + } + releaseutil.Reverse(h, releaseutil.SortByRevision) + rel := h[0] + + if st := rel.Info.Status; i.Replace && (st == release.StatusUninstalled || st == release.StatusFailed) { + return nil + } + return errors.New("cannot re-use a name that is still in use") +} + +// createRelease creates a new release object +func (i *Install) createRelease(chrt *chart.Chart, rawVals map[string]interface{}, labels map[string]string) *release.Release { + ts := i.cfg.Now() + return &release.Release{ + Name: i.ReleaseName, + Namespace: i.Namespace, + Chart: chrt, + Config: rawVals, + Info: &release.Info{ + FirstDeployed: ts, + LastDeployed: ts, + Status: release.StatusUnknown, + }, + Version: 1, + Labels: labels, + } +} + +// recordRelease with an update operation in case reuse has been set. +func (i *Install) recordRelease(r *release.Release) error { + // This is a legacy function which has been reduced to a oneliner. Could probably + // refactor it out. + return i.cfg.Releases.Update(r) +} + +// replaceRelease replaces an older release with this one +// +// This allows us to re-use names by superseding an existing release with a new one +func (i *Install) replaceRelease(rel *release.Release) error { + hist, err := i.cfg.Releases.History(rel.Name) + if err != nil || len(hist) == 0 { + // No releases exist for this name, so we can return early + return nil + } + + releaseutil.Reverse(hist, releaseutil.SortByRevision) + last := hist[0] + + // Update version to the next available + rel.Version = last.Version + 1 + + // Do not change the status of a failed release. + if last.Info.Status == release.StatusFailed { + return nil + } + + // For any other status, mark it as superseded and store the old record + last.SetStatus(release.StatusSuperseded, "superseded by new release") + return i.recordRelease(last) +} + +// write the to /. controls if the file is created or content will be appended +func writeToFile(outputDir string, name string, data string, append bool) error { + outfileName := strings.Join([]string{outputDir, name}, string(filepath.Separator)) + + err := ensureDirectoryForFile(outfileName) + if err != nil { + return err + } + + f, err := createOrOpenFile(outfileName, append) + if err != nil { + return err + } + + defer f.Close() + + _, err = f.WriteString(fmt.Sprintf("---\n# Source: %s\n%s\n", name, data)) + + if err != nil { + return err + } + + fmt.Printf("wrote %s\n", outfileName) + return nil +} + +func createOrOpenFile(filename string, append bool) (*os.File, error) { + if append { + return os.OpenFile(filename, os.O_APPEND|os.O_WRONLY, 0600) + } + return os.Create(filename) +} + +// check if the directory exists to create file. creates if don't exists +func ensureDirectoryForFile(file string) error { + baseDir := path.Dir(file) + _, err := os.Stat(baseDir) + if err != nil && !os.IsNotExist(err) { + return err + } + + return os.MkdirAll(baseDir, defaultDirectoryPermission) +} + +// NameAndChart returns the name and chart that should be used. +// +// This will read the flags and handle name generation if necessary. +func (i *Install) NameAndChart(args []string) (string, string, error) { + flagsNotSet := func() error { + if i.GenerateName { + return errors.New("cannot set --generate-name and also specify a name") + } + if i.NameTemplate != "" { + return errors.New("cannot set --name-template and also specify a name") + } + return nil + } + + if len(args) > 2 { + return args[0], args[1], errors.Errorf("expected at most two arguments, unexpected arguments: %v", strings.Join(args[2:], ", ")) + } + + if len(args) == 2 { + return args[0], args[1], flagsNotSet() + } + + if i.NameTemplate != "" { + name, err := TemplateName(i.NameTemplate) + return name, args[0], err + } + + if i.ReleaseName != "" { + return i.ReleaseName, args[0], nil + } + + if !i.GenerateName { + return "", args[0], errors.New("must either provide a name or specify --generate-name") + } + + base := filepath.Base(args[0]) + if base == "." || base == "" { + base = "chart" + } + // if present, strip out the file extension from the name + if idx := strings.Index(base, "."); idx != -1 { + base = base[0:idx] + } + + return fmt.Sprintf("%s-%d", base, time.Now().Unix()), args[0], nil +} + +// TemplateName renders a name template, returning the name or an error. +func TemplateName(nameTemplate string) (string, error) { + if nameTemplate == "" { + return "", nil + } + + t, err := template.New("name-template").Funcs(sprig.TxtFuncMap()).Parse(nameTemplate) + if err != nil { + return "", err + } + var b bytes.Buffer + if err := t.Execute(&b, nil); err != nil { + return "", err + } + + return b.String(), nil +} + +// CheckDependencies checks the dependencies for a chart. +func CheckDependencies(ch *chart.Chart, reqs []*chart.Dependency) error { + var missing []string + +OUTER: + for _, r := range reqs { + for _, d := range ch.Dependencies() { + if d.Name() == r.Name { + continue OUTER + } + } + missing = append(missing, r.Name) + } + + if len(missing) > 0 { + return errors.Errorf("found in Chart.yaml, but missing in charts/ directory: %s", strings.Join(missing, ", ")) + } + return nil +} + +// LocateChart looks for a chart directory in known places, and returns either the full path or an error. +// +// This does not ensure that the chart is well-formed; only that the requested filename exists. +// +// Order of resolution: +// - relative to current working directory +// - if path is absolute or begins with '.', error out here +// - URL +// +// If 'verify' was set on ChartPathOptions, this will attempt to also verify the chart. +func (c *ChartPathOptions) LocateChart(name string, settings *cli.EnvSettings) (string, error) { + if registry.IsOCI(name) && c.registryClient == nil { + return "", fmt.Errorf("unable to lookup chart %q, missing registry client", name) + } + + name = strings.TrimSpace(name) + version := strings.TrimSpace(c.Version) + + if _, err := os.Stat(name); err == nil { + abs, err := filepath.Abs(name) + if err != nil { + return abs, err + } + if c.Verify { + if _, err := downloader.VerifyChart(abs, c.Keyring); err != nil { + return "", err + } + } + return abs, nil + } + if filepath.IsAbs(name) || strings.HasPrefix(name, ".") { + return name, errors.Errorf("path %q not found", name) + } + + dl := downloader.ChartDownloader{ + Out: os.Stdout, + Keyring: c.Keyring, + Getters: getter.All(settings), + Options: []getter.Option{ + getter.WithPassCredentialsAll(c.PassCredentialsAll), + getter.WithTLSClientConfig(c.CertFile, c.KeyFile, c.CaFile), + getter.WithInsecureSkipVerifyTLS(c.InsecureSkipTLSverify), + getter.WithPlainHTTP(c.PlainHTTP), + }, + RepositoryConfig: settings.RepositoryConfig, + RepositoryCache: settings.RepositoryCache, + RegistryClient: c.registryClient, + } + + if registry.IsOCI(name) { + dl.Options = append(dl.Options, getter.WithRegistryClient(c.registryClient)) + } + + if c.Verify { + dl.Verify = downloader.VerifyAlways + } + if c.RepoURL != "" { + chartURL, err := repo.FindChartInAuthAndTLSAndPassRepoURL(c.RepoURL, c.Username, c.Password, name, version, + c.CertFile, c.KeyFile, c.CaFile, c.InsecureSkipTLSverify, c.PassCredentialsAll, getter.All(settings)) + if err != nil { + return "", err + } + name = chartURL + + // Only pass the user/pass on when the user has said to or when the + // location of the chart repo and the chart are the same domain. + u1, err := url.Parse(c.RepoURL) + if err != nil { + return "", err + } + u2, err := url.Parse(chartURL) + if err != nil { + return "", err + } + + // Host on URL (returned from url.Parse) contains the port if present. + // This check ensures credentials are not passed between different + // services on different ports. + if c.PassCredentialsAll || (u1.Scheme == u2.Scheme && u1.Host == u2.Host) { + dl.Options = append(dl.Options, getter.WithBasicAuth(c.Username, c.Password)) + } else { + dl.Options = append(dl.Options, getter.WithBasicAuth("", "")) + } + } else { + dl.Options = append(dl.Options, getter.WithBasicAuth(c.Username, c.Password)) + } + + if err := os.MkdirAll(settings.RepositoryCache, 0755); err != nil { + return "", err + } + + filename, _, err := dl.DownloadTo(name, version, settings.RepositoryCache) + if err != nil { + return "", err + } + + lname, err := filepath.Abs(filename) + if err != nil { + return filename, err + } + return lname, nil +} diff --git a/pkg/action/install_test.go b/pkg/action/install_test.go new file mode 100644 index 00000000..118e4b36 --- /dev/null +++ b/pkg/action/install_test.go @@ -0,0 +1,763 @@ +/* +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 ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "helm.sh/helm/v3/internal/test" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chartutil" + kubefake "helm.sh/helm/v3/pkg/kube/fake" + "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/storage/driver" + helmtime "helm.sh/helm/v3/pkg/time" +) + +type nameTemplateTestCase struct { + tpl string + expected string + expectedErrorStr string +} + +func installAction(t *testing.T) *Install { + config := actionConfigFixture(t) + instAction := NewInstall(config) + instAction.Namespace = "spaced" + instAction.ReleaseName = "test-install-release" + + return instAction +} + +func TestInstallRelease(t *testing.T) { + is := assert.New(t) + req := require.New(t) + + instAction := installAction(t) + vals := map[string]interface{}{} + ctx, done := context.WithCancel(context.Background()) + res, err := instAction.RunWithContext(ctx, buildChart(), vals) + if err != nil { + t.Fatalf("Failed install: %s", err) + } + is.Equal(res.Name, "test-install-release", "Expected release name.") + is.Equal(res.Namespace, "spaced") + + rel, err := instAction.cfg.Releases.Get(res.Name, res.Version) + is.NoError(err) + + is.Len(rel.Hooks, 1) + is.Equal(rel.Hooks[0].Manifest, manifestWithHook) + is.Equal(rel.Hooks[0].Events[0], release.HookPostInstall) + is.Equal(rel.Hooks[0].Events[1], release.HookPreDelete, "Expected event 0 is pre-delete") + + is.NotEqual(len(res.Manifest), 0) + is.NotEqual(len(rel.Manifest), 0) + is.Contains(rel.Manifest, "---\n# Source: hello/templates/hello\nhello: world") + is.Equal(rel.Info.Description, "Install complete") + + // Detecting previous bug where context termination after successful release + // caused release to fail. + done() + time.Sleep(time.Millisecond * 100) + lastRelease, err := instAction.cfg.Releases.Last(rel.Name) + req.NoError(err) + is.Equal(lastRelease.Info.Status, release.StatusDeployed) +} + +func TestInstallReleaseWithValues(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + userVals := map[string]interface{}{ + "nestedKey": map[string]interface{}{ + "simpleKey": "simpleValue", + }, + } + expectedUserValues := map[string]interface{}{ + "nestedKey": map[string]interface{}{ + "simpleKey": "simpleValue", + }, + } + res, err := instAction.Run(buildChart(withSampleValues()), userVals) + if err != nil { + t.Fatalf("Failed install: %s", err) + } + is.Equal(res.Name, "test-install-release", "Expected release name.") + is.Equal(res.Namespace, "spaced") + + rel, err := instAction.cfg.Releases.Get(res.Name, res.Version) + is.NoError(err) + + is.Len(rel.Hooks, 1) + is.Equal(rel.Hooks[0].Manifest, manifestWithHook) + is.Equal(rel.Hooks[0].Events[0], release.HookPostInstall) + is.Equal(rel.Hooks[0].Events[1], release.HookPreDelete, "Expected event 0 is pre-delete") + + is.NotEqual(len(res.Manifest), 0) + is.NotEqual(len(rel.Manifest), 0) + is.Contains(rel.Manifest, "---\n# Source: hello/templates/hello\nhello: world") + is.Equal("Install complete", rel.Info.Description) + is.Equal(expectedUserValues, rel.Config) +} + +func TestInstallReleaseClientOnly(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + instAction.ClientOnly = true + instAction.Run(buildChart(), nil) // disregard output + + is.Equal(instAction.cfg.Capabilities, chartutil.DefaultCapabilities) + is.Equal(instAction.cfg.KubeClient, &kubefake.PrintingKubeClient{Out: io.Discard}) +} + +func TestInstallRelease_NoName(t *testing.T) { + instAction := installAction(t) + instAction.ReleaseName = "" + vals := map[string]interface{}{} + _, err := instAction.Run(buildChart(), vals) + if err == nil { + t.Fatal("expected failure when no name is specified") + } + assert.Contains(t, err.Error(), "no name provided") +} + +func TestInstallRelease_WithNotes(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + instAction.ReleaseName = "with-notes" + vals := map[string]interface{}{} + res, err := instAction.Run(buildChart(withNotes("note here")), vals) + if err != nil { + t.Fatalf("Failed install: %s", err) + } + + is.Equal(res.Name, "with-notes") + is.Equal(res.Namespace, "spaced") + + rel, err := instAction.cfg.Releases.Get(res.Name, res.Version) + is.NoError(err) + is.Len(rel.Hooks, 1) + is.Equal(rel.Hooks[0].Manifest, manifestWithHook) + is.Equal(rel.Hooks[0].Events[0], release.HookPostInstall) + is.Equal(rel.Hooks[0].Events[1], release.HookPreDelete, "Expected event 0 is pre-delete") + is.NotEqual(len(res.Manifest), 0) + is.NotEqual(len(rel.Manifest), 0) + is.Contains(rel.Manifest, "---\n# Source: hello/templates/hello\nhello: world") + is.Equal(rel.Info.Description, "Install complete") + + is.Equal(rel.Info.Notes, "note here") +} + +func TestInstallRelease_WithNotesRendered(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + instAction.ReleaseName = "with-notes" + vals := map[string]interface{}{} + res, err := instAction.Run(buildChart(withNotes("got-{{.Release.Name}}")), vals) + if err != nil { + t.Fatalf("Failed install: %s", err) + } + + rel, err := instAction.cfg.Releases.Get(res.Name, res.Version) + is.NoError(err) + + expectedNotes := fmt.Sprintf("got-%s", res.Name) + is.Equal(expectedNotes, rel.Info.Notes) + is.Equal(rel.Info.Description, "Install complete") +} + +func TestInstallRelease_WithChartAndDependencyParentNotes(t *testing.T) { + // Regression: Make sure that the child's notes don't override the parent's + is := assert.New(t) + instAction := installAction(t) + instAction.ReleaseName = "with-notes" + vals := map[string]interface{}{} + res, err := instAction.Run(buildChart(withNotes("parent"), withDependency(withNotes("child"))), vals) + if err != nil { + t.Fatalf("Failed install: %s", err) + } + + rel, err := instAction.cfg.Releases.Get(res.Name, res.Version) + is.Equal("with-notes", rel.Name) + is.NoError(err) + is.Equal("parent", rel.Info.Notes) + is.Equal(rel.Info.Description, "Install complete") +} + +func TestInstallRelease_WithChartAndDependencyAllNotes(t *testing.T) { + // Regression: Make sure that the child's notes don't override the parent's + is := assert.New(t) + instAction := installAction(t) + instAction.ReleaseName = "with-notes" + instAction.SubNotes = true + vals := map[string]interface{}{} + res, err := instAction.Run(buildChart(withNotes("parent"), withDependency(withNotes("child"))), vals) + if err != nil { + t.Fatalf("Failed install: %s", err) + } + + rel, err := instAction.cfg.Releases.Get(res.Name, res.Version) + is.Equal("with-notes", rel.Name) + is.NoError(err) + // test run can return as either 'parent\nchild' or 'child\nparent' + if !strings.Contains(rel.Info.Notes, "parent") && !strings.Contains(rel.Info.Notes, "child") { + t.Fatalf("Expected 'parent\nchild' or 'child\nparent', got '%s'", rel.Info.Notes) + } + is.Equal(rel.Info.Description, "Install complete") +} + +func TestInstallRelease_DryRun(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + instAction.DryRun = true + vals := map[string]interface{}{} + res, err := instAction.Run(buildChart(withSampleTemplates()), vals) + if err != nil { + t.Fatalf("Failed install: %s", err) + } + + is.Contains(res.Manifest, "---\n# Source: hello/templates/hello\nhello: world") + is.Contains(res.Manifest, "---\n# Source: hello/templates/goodbye\ngoodbye: world") + is.Contains(res.Manifest, "hello: Earth") + is.NotContains(res.Manifest, "hello: {{ template \"_planet\" . }}") + is.NotContains(res.Manifest, "empty") + + _, err = instAction.cfg.Releases.Get(res.Name, res.Version) + is.Error(err) + is.Len(res.Hooks, 1) + is.True(res.Hooks[0].LastRun.CompletedAt.IsZero(), "expect hook to not be marked as run") + is.Equal(res.Info.Description, "Dry run complete") +} + +// Regression test for #7955 +func TestInstallRelease_DryRun_Lookup(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + instAction.DryRun = true + vals := map[string]interface{}{} + + mockChart := buildChart(withSampleTemplates()) + mockChart.Templates = append(mockChart.Templates, &chart.File{ + Name: "templates/lookup", + Data: []byte(`goodbye: {{ lookup "v1" "Namespace" "" "___" }}`), + }) + + res, err := instAction.Run(mockChart, vals) + if err != nil { + t.Fatalf("Failed install: %s", err) + } + + is.Contains(res.Manifest, "goodbye: map[]") +} + +func TestInstallReleaseIncorrectTemplate_DryRun(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + instAction.DryRun = true + vals := map[string]interface{}{} + _, err := instAction.Run(buildChart(withSampleIncludingIncorrectTemplates()), vals) + expectedErr := "\"hello/templates/incorrect\" at <.Values.bad.doh>: nil pointer evaluating interface {}.doh" + if err == nil { + t.Fatalf("Install should fail containing error: %s", expectedErr) + } + if err != nil { + is.Contains(err.Error(), expectedErr) + } +} + +func TestInstallRelease_NoHooks(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + instAction.DisableHooks = true + instAction.ReleaseName = "no-hooks" + instAction.cfg.Releases.Create(releaseStub()) + + vals := map[string]interface{}{} + res, err := instAction.Run(buildChart(), vals) + if err != nil { + t.Fatalf("Failed install: %s", err) + } + + is.True(res.Hooks[0].LastRun.CompletedAt.IsZero(), "hooks should not run with no-hooks") +} + +func TestInstallRelease_FailedHooks(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + instAction.ReleaseName = "failed-hooks" + failer := instAction.cfg.KubeClient.(*kubefake.FailingKubeClient) + failer.WatchUntilReadyError = fmt.Errorf("Failed watch") + instAction.cfg.KubeClient = failer + + vals := map[string]interface{}{} + res, err := instAction.Run(buildChart(), vals) + is.Error(err) + is.Contains(res.Info.Description, "failed post-install") + is.Equal(release.StatusFailed, res.Info.Status) +} + +func TestInstallRelease_ReplaceRelease(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + instAction.Replace = true + + rel := releaseStub() + rel.Info.Status = release.StatusUninstalled + instAction.cfg.Releases.Create(rel) + instAction.ReleaseName = rel.Name + + vals := map[string]interface{}{} + res, err := instAction.Run(buildChart(), vals) + is.NoError(err) + + // This should have been auto-incremented + is.Equal(2, res.Version) + is.Equal(res.Name, rel.Name) + + getres, err := instAction.cfg.Releases.Get(rel.Name, res.Version) + is.NoError(err) + is.Equal(getres.Info.Status, release.StatusDeployed) +} + +func TestInstallRelease_KubeVersion(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + vals := map[string]interface{}{} + _, err := instAction.Run(buildChart(withKube(">=0.0.0")), vals) + is.NoError(err) + + // This should fail for a few hundred years + instAction.ReleaseName = "should-fail" + vals = map[string]interface{}{} + _, err = instAction.Run(buildChart(withKube(">=99.0.0")), vals) + is.Error(err) + is.Contains(err.Error(), "chart requires kubeVersion") +} + +func TestInstallRelease_Wait(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + instAction.ReleaseName = "come-fail-away" + failer := instAction.cfg.KubeClient.(*kubefake.FailingKubeClient) + failer.WaitError = fmt.Errorf("I timed out") + instAction.cfg.KubeClient = failer + instAction.Wait = true + vals := map[string]interface{}{} + + goroutines := runtime.NumGoroutine() + + res, err := instAction.Run(buildChart(), vals) + is.Error(err) + is.Contains(res.Info.Description, "I timed out") + is.Equal(res.Info.Status, release.StatusFailed) + + is.Equal(goroutines, runtime.NumGoroutine()) +} +func TestInstallRelease_Wait_Interrupted(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + instAction.ReleaseName = "interrupted-release" + failer := instAction.cfg.KubeClient.(*kubefake.FailingKubeClient) + failer.WaitDuration = 10 * time.Second + instAction.cfg.KubeClient = failer + instAction.Wait = true + vals := map[string]interface{}{} + + ctx := context.Background() + ctx, cancel := context.WithCancel(ctx) + time.AfterFunc(time.Second, cancel) + + goroutines := runtime.NumGoroutine() + + res, err := instAction.RunWithContext(ctx, buildChart(), vals) + is.Error(err) + is.Contains(res.Info.Description, "Release \"interrupted-release\" failed: context canceled") + is.Equal(res.Info.Status, release.StatusFailed) + + is.Equal(goroutines+1, runtime.NumGoroutine()) // installation goroutine still is in background + time.Sleep(10 * time.Second) // wait for goroutine to finish + is.Equal(goroutines, runtime.NumGoroutine()) +} +func TestInstallRelease_WaitForJobs(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + instAction.ReleaseName = "come-fail-away" + failer := instAction.cfg.KubeClient.(*kubefake.FailingKubeClient) + failer.WaitError = fmt.Errorf("I timed out") + instAction.cfg.KubeClient = failer + instAction.Wait = true + instAction.WaitForJobs = true + vals := map[string]interface{}{} + + res, err := instAction.Run(buildChart(), vals) + is.Error(err) + is.Contains(res.Info.Description, "I timed out") + is.Equal(res.Info.Status, release.StatusFailed) +} + +func TestInstallRelease_Atomic(t *testing.T) { + is := assert.New(t) + + t.Run("atomic uninstall succeeds", func(t *testing.T) { + instAction := installAction(t) + instAction.ReleaseName = "come-fail-away" + failer := instAction.cfg.KubeClient.(*kubefake.FailingKubeClient) + failer.WaitError = fmt.Errorf("I timed out") + instAction.cfg.KubeClient = failer + instAction.Atomic = true + // disabling hooks to avoid an early fail when the + // the WaitForDelete is called on the pre-delete hook execution + instAction.DisableHooks = true + vals := map[string]interface{}{} + + res, err := instAction.Run(buildChart(), vals) + is.Error(err) + is.Contains(err.Error(), "I timed out") + is.Contains(err.Error(), "atomic") + + // Now make sure it isn't in storage any more + _, err = instAction.cfg.Releases.Get(res.Name, res.Version) + is.Error(err) + is.Equal(err, driver.ErrReleaseNotFound) + }) + + t.Run("atomic uninstall fails", func(t *testing.T) { + instAction := installAction(t) + instAction.ReleaseName = "come-fail-away-with-me" + failer := instAction.cfg.KubeClient.(*kubefake.FailingKubeClient) + failer.WaitError = fmt.Errorf("I timed out") + failer.DeleteError = fmt.Errorf("uninstall fail") + instAction.cfg.KubeClient = failer + instAction.Atomic = true + vals := map[string]interface{}{} + + _, err := instAction.Run(buildChart(), vals) + is.Error(err) + is.Contains(err.Error(), "I timed out") + is.Contains(err.Error(), "uninstall fail") + is.Contains(err.Error(), "an error occurred while uninstalling the release") + }) +} +func TestInstallRelease_Atomic_Interrupted(t *testing.T) { + + is := assert.New(t) + instAction := installAction(t) + instAction.ReleaseName = "interrupted-release" + failer := instAction.cfg.KubeClient.(*kubefake.FailingKubeClient) + failer.WaitDuration = 10 * time.Second + instAction.cfg.KubeClient = failer + instAction.Atomic = true + vals := map[string]interface{}{} + + ctx := context.Background() + ctx, cancel := context.WithCancel(ctx) + time.AfterFunc(time.Second, cancel) + + res, err := instAction.RunWithContext(ctx, buildChart(), vals) + is.Error(err) + is.Contains(err.Error(), "context canceled") + is.Contains(err.Error(), "atomic") + is.Contains(err.Error(), "uninstalled") + + // Now make sure it isn't in storage any more + _, err = instAction.cfg.Releases.Get(res.Name, res.Version) + is.Error(err) + is.Equal(err, driver.ErrReleaseNotFound) + +} +func TestNameTemplate(t *testing.T) { + testCases := []nameTemplateTestCase{ + // Just a straight up nop please + { + tpl: "foobar", + expected: "foobar", + expectedErrorStr: "", + }, + // Random numbers at the end for fun & profit + { + tpl: "foobar-{{randNumeric 6}}", + expected: "foobar-[0-9]{6}$", + expectedErrorStr: "", + }, + // Random numbers in the middle for fun & profit + { + tpl: "foobar-{{randNumeric 4}}-baz", + expected: "foobar-[0-9]{4}-baz$", + expectedErrorStr: "", + }, + // No such function + { + tpl: "foobar-{{randInteger}}", + expected: "", + expectedErrorStr: "function \"randInteger\" not defined", + }, + // Invalid template + { + tpl: "foobar-{{", + expected: "", + expectedErrorStr: "template: name-template:1: unclosed action", + }, + } + + for _, tc := range testCases { + + n, err := TemplateName(tc.tpl) + if err != nil { + if tc.expectedErrorStr == "" { + t.Errorf("Was not expecting error, but got: %v", err) + continue + } + re, compErr := regexp.Compile(tc.expectedErrorStr) + if compErr != nil { + t.Errorf("Expected error string failed to compile: %v", compErr) + continue + } + if !re.MatchString(err.Error()) { + t.Errorf("Error didn't match for %s expected %s but got %v", tc.tpl, tc.expectedErrorStr, err) + continue + } + } + if err == nil && tc.expectedErrorStr != "" { + t.Errorf("Was expecting error %s but didn't get an error back", tc.expectedErrorStr) + } + + if tc.expected != "" { + re, err := regexp.Compile(tc.expected) + if err != nil { + t.Errorf("Expected string failed to compile: %v", err) + continue + } + if !re.MatchString(n) { + t.Errorf("Returned name didn't match for %s expected %s but got %s", tc.tpl, tc.expected, n) + } + } + } +} + +func TestInstallReleaseOutputDir(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + vals := map[string]interface{}{} + + dir := t.TempDir() + + instAction.OutputDir = dir + + _, err := instAction.Run(buildChart(withSampleTemplates(), withMultipleManifestTemplate()), vals) + if err != nil { + t.Fatalf("Failed install: %s", err) + } + + _, err = os.Stat(filepath.Join(dir, "hello/templates/goodbye")) + is.NoError(err) + + _, err = os.Stat(filepath.Join(dir, "hello/templates/hello")) + is.NoError(err) + + _, err = os.Stat(filepath.Join(dir, "hello/templates/with-partials")) + is.NoError(err) + + _, err = os.Stat(filepath.Join(dir, "hello/templates/rbac")) + is.NoError(err) + + test.AssertGoldenFile(t, filepath.Join(dir, "hello/templates/rbac"), "rbac.txt") + + _, err = os.Stat(filepath.Join(dir, "hello/templates/empty")) + is.True(os.IsNotExist(err)) +} + +func TestInstallOutputDirWithReleaseName(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + vals := map[string]interface{}{} + + dir := t.TempDir() + + instAction.OutputDir = dir + instAction.UseReleaseName = true + instAction.ReleaseName = "madra" + + newDir := filepath.Join(dir, instAction.ReleaseName) + + _, err := instAction.Run(buildChart(withSampleTemplates(), withMultipleManifestTemplate()), vals) + if err != nil { + t.Fatalf("Failed install: %s", err) + } + + _, err = os.Stat(filepath.Join(newDir, "hello/templates/goodbye")) + is.NoError(err) + + _, err = os.Stat(filepath.Join(newDir, "hello/templates/hello")) + is.NoError(err) + + _, err = os.Stat(filepath.Join(newDir, "hello/templates/with-partials")) + is.NoError(err) + + _, err = os.Stat(filepath.Join(newDir, "hello/templates/rbac")) + is.NoError(err) + + test.AssertGoldenFile(t, filepath.Join(newDir, "hello/templates/rbac"), "rbac.txt") + + _, err = os.Stat(filepath.Join(newDir, "hello/templates/empty")) + is.True(os.IsNotExist(err)) +} + +func TestNameAndChart(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + chartName := "./foo" + + name, chrt, err := instAction.NameAndChart([]string{chartName}) + if err != nil { + t.Fatal(err) + } + is.Equal(instAction.ReleaseName, name) + is.Equal(chartName, chrt) + + instAction.GenerateName = true + _, _, err = instAction.NameAndChart([]string{"foo", chartName}) + if err == nil { + t.Fatal("expected an error") + } + is.Equal("cannot set --generate-name and also specify a name", err.Error()) + + instAction.GenerateName = false + instAction.NameTemplate = "{{ . }}" + _, _, err = instAction.NameAndChart([]string{"foo", chartName}) + if err == nil { + t.Fatal("expected an error") + } + is.Equal("cannot set --name-template and also specify a name", err.Error()) + + instAction.NameTemplate = "" + instAction.ReleaseName = "" + _, _, err = instAction.NameAndChart([]string{chartName}) + if err == nil { + t.Fatal("expected an error") + } + is.Equal("must either provide a name or specify --generate-name", err.Error()) + + instAction.NameTemplate = "" + instAction.ReleaseName = "" + _, _, err = instAction.NameAndChart([]string{"foo", chartName, "bar"}) + if err == nil { + t.Fatal("expected an error") + } + is.Equal("expected at most two arguments, unexpected arguments: bar", err.Error()) +} + +func TestNameAndChartGenerateName(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + + instAction.ReleaseName = "" + instAction.GenerateName = true + + tests := []struct { + Name string + Chart string + ExpectedName string + }{ + { + "local filepath", + "./chart", + fmt.Sprintf("chart-%d", helmtime.Now().Unix()), + }, + { + "dot filepath", + ".", + fmt.Sprintf("chart-%d", helmtime.Now().Unix()), + }, + { + "empty filepath", + "", + fmt.Sprintf("chart-%d", helmtime.Now().Unix()), + }, + { + "packaged chart", + "chart.tgz", + fmt.Sprintf("chart-%d", helmtime.Now().Unix()), + }, + { + "packaged chart with .tar.gz extension", + "chart.tar.gz", + fmt.Sprintf("chart-%d", helmtime.Now().Unix()), + }, + { + "packaged chart with local extension", + "./chart.tgz", + fmt.Sprintf("chart-%d", helmtime.Now().Unix()), + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + + name, chrt, err := instAction.NameAndChart([]string{tc.Chart}) + if err != nil { + t.Fatal(err) + } + + is.Equal(tc.ExpectedName, name) + is.Equal(tc.Chart, chrt) + }) + } +} + +func TestInstallWithLabels(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + instAction.Labels = map[string]string{ + "key1": "val1", + "key2": "val2", + } + res, err := instAction.Run(buildChart(), nil) + if err != nil { + t.Fatalf("Failed install: %s", err) + } + + is.Equal(instAction.Labels, res.Labels) +} + +func TestInstallWithSystemLabels(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + instAction.Labels = map[string]string{ + "owner": "val1", + "key2": "val2", + } + _, err := instAction.Run(buildChart(), nil) + if err == nil { + t.Fatal("expected an error") + } + + is.Equal(fmt.Errorf("user suplied labels contains system reserved label name. System labels: %+v", driver.GetSystemLabels()), err) +} diff --git a/pkg/action/lazyclient.go b/pkg/action/lazyclient.go new file mode 100644 index 00000000..9037782b --- /dev/null +++ b/pkg/action/lazyclient.go @@ -0,0 +1,197 @@ +/* +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 ( + "context" + "sync" + + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/watch" + applycorev1 "k8s.io/client-go/applyconfigurations/core/v1" + "k8s.io/client-go/kubernetes" + corev1 "k8s.io/client-go/kubernetes/typed/core/v1" +) + +// lazyClient is a workaround to deal with Kubernetes having an unstable client API. +// In Kubernetes v1.18 the defaults where removed which broke creating a +// client without an explicit configuration. ಠ_ಠ +type lazyClient struct { + // client caches an initialized kubernetes client + initClient sync.Once + client kubernetes.Interface + clientErr error + + // clientFn loads a kubernetes client + clientFn func() (*kubernetes.Clientset, error) + + // namespace passed to each client request + namespace string +} + +func (s *lazyClient) init() error { + s.initClient.Do(func() { + s.client, s.clientErr = s.clientFn() + }) + return s.clientErr +} + +// secretClient implements a corev1.SecretsInterface +type secretClient struct{ *lazyClient } + +var _ corev1.SecretInterface = (*secretClient)(nil) + +func newSecretClient(lc *lazyClient) *secretClient { + return &secretClient{lazyClient: lc} +} + +func (s *secretClient) Create(ctx context.Context, secret *v1.Secret, opts metav1.CreateOptions) (result *v1.Secret, err error) { + if err := s.init(); err != nil { + return nil, err + } + return s.client.CoreV1().Secrets(s.namespace).Create(ctx, secret, opts) +} + +func (s *secretClient) Update(ctx context.Context, secret *v1.Secret, opts metav1.UpdateOptions) (*v1.Secret, error) { + if err := s.init(); err != nil { + return nil, err + } + return s.client.CoreV1().Secrets(s.namespace).Update(ctx, secret, opts) +} + +func (s *secretClient) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error { + if err := s.init(); err != nil { + return err + } + return s.client.CoreV1().Secrets(s.namespace).Delete(ctx, name, opts) +} + +func (s *secretClient) DeleteCollection(ctx context.Context, opts metav1.DeleteOptions, listOpts metav1.ListOptions) error { + if err := s.init(); err != nil { + return err + } + return s.client.CoreV1().Secrets(s.namespace).DeleteCollection(ctx, opts, listOpts) +} + +func (s *secretClient) Get(ctx context.Context, name string, opts metav1.GetOptions) (*v1.Secret, error) { + if err := s.init(); err != nil { + return nil, err + } + return s.client.CoreV1().Secrets(s.namespace).Get(ctx, name, opts) +} + +func (s *secretClient) List(ctx context.Context, opts metav1.ListOptions) (*v1.SecretList, error) { + if err := s.init(); err != nil { + return nil, err + } + return s.client.CoreV1().Secrets(s.namespace).List(ctx, opts) +} + +func (s *secretClient) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + if err := s.init(); err != nil { + return nil, err + } + return s.client.CoreV1().Secrets(s.namespace).Watch(ctx, opts) +} + +func (s *secretClient) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (*v1.Secret, error) { + if err := s.init(); err != nil { + return nil, err + } + return s.client.CoreV1().Secrets(s.namespace).Patch(ctx, name, pt, data, opts, subresources...) +} + +func (s *secretClient) Apply(ctx context.Context, secretConfiguration *applycorev1.SecretApplyConfiguration, opts metav1.ApplyOptions) (*v1.Secret, error) { + if err := s.init(); err != nil { + return nil, err + } + return s.client.CoreV1().Secrets(s.namespace).Apply(ctx, secretConfiguration, opts) +} + +// configMapClient implements a corev1.ConfigMapInterface +type configMapClient struct{ *lazyClient } + +var _ corev1.ConfigMapInterface = (*configMapClient)(nil) + +func newConfigMapClient(lc *lazyClient) *configMapClient { + return &configMapClient{lazyClient: lc} +} + +func (c *configMapClient) Create(ctx context.Context, configMap *v1.ConfigMap, opts metav1.CreateOptions) (*v1.ConfigMap, error) { + if err := c.init(); err != nil { + return nil, err + } + return c.client.CoreV1().ConfigMaps(c.namespace).Create(ctx, configMap, opts) +} + +func (c *configMapClient) Update(ctx context.Context, configMap *v1.ConfigMap, opts metav1.UpdateOptions) (*v1.ConfigMap, error) { + if err := c.init(); err != nil { + return nil, err + } + return c.client.CoreV1().ConfigMaps(c.namespace).Update(ctx, configMap, opts) +} + +func (c *configMapClient) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error { + if err := c.init(); err != nil { + return err + } + return c.client.CoreV1().ConfigMaps(c.namespace).Delete(ctx, name, opts) +} + +func (c *configMapClient) DeleteCollection(ctx context.Context, opts metav1.DeleteOptions, listOpts metav1.ListOptions) error { + if err := c.init(); err != nil { + return err + } + return c.client.CoreV1().ConfigMaps(c.namespace).DeleteCollection(ctx, opts, listOpts) +} + +func (c *configMapClient) Get(ctx context.Context, name string, opts metav1.GetOptions) (*v1.ConfigMap, error) { + if err := c.init(); err != nil { + return nil, err + } + return c.client.CoreV1().ConfigMaps(c.namespace).Get(ctx, name, opts) +} + +func (c *configMapClient) List(ctx context.Context, opts metav1.ListOptions) (*v1.ConfigMapList, error) { + if err := c.init(); err != nil { + return nil, err + } + return c.client.CoreV1().ConfigMaps(c.namespace).List(ctx, opts) +} + +func (c *configMapClient) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + if err := c.init(); err != nil { + return nil, err + } + return c.client.CoreV1().ConfigMaps(c.namespace).Watch(ctx, opts) +} + +func (c *configMapClient) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (*v1.ConfigMap, error) { + if err := c.init(); err != nil { + return nil, err + } + return c.client.CoreV1().ConfigMaps(c.namespace).Patch(ctx, name, pt, data, opts, subresources...) +} + +func (c *configMapClient) Apply(ctx context.Context, configMap *applycorev1.ConfigMapApplyConfiguration, opts metav1.ApplyOptions) (*v1.ConfigMap, error) { + if err := c.init(); err != nil { + return nil, err + } + return c.client.CoreV1().ConfigMaps(c.namespace).Apply(ctx, configMap, opts) +} diff --git a/pkg/action/lint.go b/pkg/action/lint.go new file mode 100644 index 00000000..ca497f2b --- /dev/null +++ b/pkg/action/lint.go @@ -0,0 +1,129 @@ +/* +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 ( + "os" + "path/filepath" + "strings" + + "github.com/pkg/errors" + + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/lint" + "helm.sh/helm/v3/pkg/lint/support" +) + +// Lint is the action for checking that the semantics of a chart are well-formed. +// +// It provides the implementation of 'helm lint'. +type Lint struct { + Strict bool + Namespace string + WithSubcharts bool + Quiet bool + KubeVersion *chartutil.KubeVersion +} + +// LintResult is the result of Lint +type LintResult struct { + TotalChartsLinted int + Messages []support.Message + Errors []error +} + +// NewLint creates a new Lint object with the given configuration. +func NewLint() *Lint { + return &Lint{} +} + +// Run executes 'helm Lint' against the given chart. +func (l *Lint) Run(paths []string, vals map[string]interface{}) *LintResult { + lowestTolerance := support.ErrorSev + if l.Strict { + lowestTolerance = support.WarningSev + } + result := &LintResult{} + for _, path := range paths { + linter, err := lintChart(path, vals, l.Namespace, l.KubeVersion) + if err != nil { + result.Errors = append(result.Errors, err) + continue + } + + result.Messages = append(result.Messages, linter.Messages...) + result.TotalChartsLinted++ + for _, msg := range linter.Messages { + if msg.Severity >= lowestTolerance { + result.Errors = append(result.Errors, msg.Err) + } + } + } + return result +} + +// HasWarningsOrErrors checks is LintResult has any warnings or errors +func HasWarningsOrErrors(result *LintResult) bool { + for _, msg := range result.Messages { + if msg.Severity > support.InfoSev { + return true + } + } + return len(result.Errors) > 0 +} + +func lintChart(path string, vals map[string]interface{}, namespace string, kubeVersion *chartutil.KubeVersion) (support.Linter, error) { + var chartPath string + linter := support.Linter{} + + if strings.HasSuffix(path, ".tgz") || strings.HasSuffix(path, ".tar.gz") { + tempDir, err := os.MkdirTemp("", "helm-lint") + if err != nil { + return linter, errors.Wrap(err, "unable to create temp dir to extract tarball") + } + defer os.RemoveAll(tempDir) + + file, err := os.Open(path) + if err != nil { + return linter, errors.Wrap(err, "unable to open tarball") + } + defer file.Close() + + if err = chartutil.Expand(tempDir, file); err != nil { + return linter, errors.Wrap(err, "unable to extract tarball") + } + + files, err := os.ReadDir(tempDir) + if err != nil { + return linter, errors.Wrapf(err, "unable to read temporary output directory %s", tempDir) + } + if !files[0].IsDir() { + return linter, errors.Errorf("unexpected file %s in temporary output directory %s", files[0].Name(), tempDir) + } + + chartPath = filepath.Join(tempDir, files[0].Name()) + } else { + chartPath = path + } + + // Guard: Error out if this is not a chart. + if _, err := os.Stat(filepath.Join(chartPath, "Chart.yaml")); err != nil { + return linter, errors.Wrap(err, "unable to check Chart.yaml file in chart") + } + + return lint.AllWithKubeVersion(chartPath, vals, namespace, kubeVersion), nil +} diff --git a/pkg/action/lint_test.go b/pkg/action/lint_test.go new file mode 100644 index 00000000..80bf4ce7 --- /dev/null +++ b/pkg/action/lint_test.go @@ -0,0 +1,159 @@ +/* +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 ( + "testing" +) + +var ( + values = make(map[string]interface{}) + namespace = "testNamespace" + chart1MultipleChartLint = "testdata/charts/multiplecharts-lint-chart-1" + chart2MultipleChartLint = "testdata/charts/multiplecharts-lint-chart-2" + corruptedTgzChart = "testdata/charts/corrupted-compressed-chart.tgz" + chartWithNoTemplatesDir = "testdata/charts/chart-with-no-templates-dir" +) + +func TestLintChart(t *testing.T) { + tests := []struct { + name string + chartPath string + err bool + }{ + { + name: "decompressed-chart", + chartPath: "testdata/charts/decompressedchart/", + }, + { + name: "archived-chart-path", + chartPath: "testdata/charts/compressedchart-0.1.0.tgz", + }, + { + name: "archived-chart-path-with-hyphens", + chartPath: "testdata/charts/compressedchart-with-hyphens-0.1.0.tgz", + }, + { + name: "archived-tar-gz-chart-path", + chartPath: "testdata/charts/compressedchart-0.1.0.tar.gz", + }, + { + name: "invalid-archived-chart-path", + chartPath: "testdata/charts/invalidcompressedchart0.1.0.tgz", + err: true, + }, + { + name: "chart-missing-manifest", + chartPath: "testdata/charts/chart-missing-manifest", + err: true, + }, + { + name: "chart-with-schema", + chartPath: "testdata/charts/chart-with-schema", + }, + { + name: "chart-with-schema-negative", + chartPath: "testdata/charts/chart-with-schema-negative", + }, + { + name: "pre-release-chart", + chartPath: "testdata/charts/pre-release-chart-0.1.0-alpha.tgz", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := lintChart(tt.chartPath, map[string]interface{}{}, namespace, nil) + switch { + case err != nil && !tt.err: + t.Errorf("%s", err) + case err == nil && tt.err: + t.Errorf("Expected a chart parsing error") + } + }) + } +} + +func TestNonExistentChart(t *testing.T) { + t.Run("should error out for non existent tgz chart", func(t *testing.T) { + testCharts := []string{"non-existent-chart.tgz"} + expectedError := "unable to open tarball: open non-existent-chart.tgz: no such file or directory" + testLint := NewLint() + + result := testLint.Run(testCharts, values) + if len(result.Errors) != 1 { + t.Error("expected one error, but got", len(result.Errors)) + } + + actual := result.Errors[0].Error() + if actual != expectedError { + t.Errorf("expected '%s', but got '%s'", expectedError, actual) + } + }) + + t.Run("should error out for corrupted tgz chart", func(t *testing.T) { + testCharts := []string{corruptedTgzChart} + expectedEOFError := "unable to extract tarball: EOF" + testLint := NewLint() + + result := testLint.Run(testCharts, values) + if len(result.Errors) != 1 { + t.Error("expected one error, but got", len(result.Errors)) + } + + actual := result.Errors[0].Error() + if actual != expectedEOFError { + t.Errorf("expected '%s', but got '%s'", expectedEOFError, actual) + } + }) +} + +func TestLint_MultipleCharts(t *testing.T) { + testCharts := []string{chart2MultipleChartLint, chart1MultipleChartLint} + testLint := NewLint() + if result := testLint.Run(testCharts, values); len(result.Errors) > 0 { + t.Error(result.Errors) + } +} + +func TestLint_EmptyResultErrors(t *testing.T) { + testCharts := []string{chart2MultipleChartLint} + testLint := NewLint() + if result := testLint.Run(testCharts, values); len(result.Errors) > 0 { + t.Error("Expected no error, got more") + } +} + +func TestLint_ChartWithWarnings(t *testing.T) { + t.Run("should pass when not strict", func(t *testing.T) { + testCharts := []string{chartWithNoTemplatesDir} + testLint := NewLint() + testLint.Strict = false + if result := testLint.Run(testCharts, values); len(result.Errors) > 0 { + t.Error("Expected no error, got more") + } + }) + + t.Run("should pass with no errors when strict", func(t *testing.T) { + testCharts := []string{chartWithNoTemplatesDir} + testLint := NewLint() + testLint.Strict = true + if result := testLint.Run(testCharts, values); len(result.Errors) != 0 { + t.Error("expected no errors, but got", len(result.Errors)) + } + }) +} diff --git a/pkg/action/list.go b/pkg/action/list.go new file mode 100644 index 00000000..af0725c4 --- /dev/null +++ b/pkg/action/list.go @@ -0,0 +1,324 @@ +/* +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 ( + "path" + "regexp" + + "k8s.io/apimachinery/pkg/labels" + + "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/releaseutil" +) + +// ListStates represents zero or more status codes that a list item may have set +// +// Because this is used as a bitmask filter, more than one bit can be flipped +// in the ListStates. +type ListStates uint + +const ( + // ListDeployed filters on status "deployed" + ListDeployed ListStates = 1 << iota + // ListUninstalled filters on status "uninstalled" + ListUninstalled + // ListUninstalling filters on status "uninstalling" (uninstall in progress) + ListUninstalling + // ListPendingInstall filters on status "pending" (deployment in progress) + ListPendingInstall + // ListPendingUpgrade filters on status "pending_upgrade" (upgrade in progress) + ListPendingUpgrade + // ListPendingRollback filters on status "pending_rollback" (rollback in progress) + ListPendingRollback + // ListSuperseded filters on status "superseded" (historical release version that is no longer deployed) + ListSuperseded + // ListFailed filters on status "failed" (release version not deployed because of error) + ListFailed + // ListUnknown filters on an unknown status + ListUnknown +) + +// FromName takes a state name and returns a ListStates representation. +// +// Currently, there are only names for individual flipped bits, so the returned +// ListStates will only match one of the constants. However, it is possible that +// this behavior could change in the future. +func (s ListStates) FromName(str string) ListStates { + switch str { + case "deployed": + return ListDeployed + case "uninstalled": + return ListUninstalled + case "superseded": + return ListSuperseded + case "failed": + return ListFailed + case "uninstalling": + return ListUninstalling + case "pending-install": + return ListPendingInstall + case "pending-upgrade": + return ListPendingUpgrade + case "pending-rollback": + return ListPendingRollback + } + return ListUnknown +} + +// ListAll is a convenience for enabling all list filters +const ListAll = ListDeployed | ListUninstalled | ListUninstalling | ListPendingInstall | ListPendingRollback | ListPendingUpgrade | ListSuperseded | ListFailed + +// Sorter is a top-level sort +type Sorter uint + +const ( + // ByNameDesc sorts by descending lexicographic order + ByNameDesc Sorter = iota + 1 + // ByDateAsc sorts by ascending dates (oldest updated release first) + ByDateAsc + // ByDateDesc sorts by descending dates (latest updated release first) + ByDateDesc +) + +// List is the action for listing releases. +// +// It provides, for example, the implementation of 'helm list'. +// It returns no more than one revision of every release in one specific, or in +// all, namespaces. +// To list all the revisions of a specific release, see the History action. +type List struct { + cfg *Configuration + + // All ignores the limit/offset + All bool + // AllNamespaces searches across namespaces + AllNamespaces bool + // Sort indicates the sort to use + // + // see pkg/releaseutil for several useful sorters + Sort Sorter + // Overrides the default lexicographic sorting + ByDate bool + SortReverse bool + // StateMask accepts a bitmask of states for items to show. + // The default is ListDeployed + StateMask ListStates + // Limit is the number of items to return per Run() + Limit int + // Offset is the starting index for the Run() call + Offset int + // Filter is a filter that is applied to the results + Filter string + Short bool + NoHeaders bool + TimeFormat string + Uninstalled bool + Superseded bool + Uninstalling bool + Deployed bool + Failed bool + Pending bool + Selector string +} + +// NewList constructs a new *List +func NewList(cfg *Configuration) *List { + return &List{ + StateMask: ListDeployed | ListFailed, + cfg: cfg, + } +} + +// Run executes the list command, returning a set of matches. +func (l *List) Run() ([]*release.Release, error) { + if err := l.cfg.KubeClient.IsReachable(); err != nil { + return nil, err + } + + var filter *regexp.Regexp + if l.Filter != "" { + var err error + filter, err = regexp.Compile(l.Filter) + if err != nil { + return nil, err + } + } + + results, err := l.cfg.Releases.List(func(rel *release.Release) bool { + // Skip anything that doesn't match the filter. + if filter != nil && !filter.MatchString(rel.Name) { + return false + } + + return true + }) + + if err != nil { + return nil, err + } + + if results == nil { + return results, nil + } + + // by definition, superseded releases are never shown if + // only the latest releases are returned. so if requested statemask + // is _only_ ListSuperseded, skip the latest release filter + if l.StateMask != ListSuperseded { + results = filterLatestReleases(results) + } + + // State mask application must occur after filtering to + // latest releases, otherwise outdated entries can be returned + results = l.filterStateMask(results) + + // Skip anything that doesn't match the selector + selectorObj, err := labels.Parse(l.Selector) + if err != nil { + return nil, err + } + results = l.filterSelector(results, selectorObj) + + // Unfortunately, we have to sort before truncating, which can incur substantial overhead + l.sort(results) + + // Guard on offset + if l.Offset >= len(results) { + return []*release.Release{}, nil + } + + // Calculate the limit and offset, and then truncate results if necessary. + limit := len(results) + if l.Limit > 0 && l.Limit < limit { + limit = l.Limit + } + last := l.Offset + limit + if l := len(results); l < last { + last = l + } + results = results[l.Offset:last] + + return results, err +} + +// sort is an in-place sort where order is based on the value of a.Sort +func (l *List) sort(rels []*release.Release) { + if l.SortReverse { + l.Sort = ByNameDesc + } + + if l.ByDate { + l.Sort = ByDateDesc + if l.SortReverse { + l.Sort = ByDateAsc + } + } + + switch l.Sort { + case ByDateDesc: + releaseutil.SortByDate(rels) + case ByDateAsc: + releaseutil.Reverse(rels, releaseutil.SortByDate) + case ByNameDesc: + releaseutil.Reverse(rels, releaseutil.SortByName) + default: + releaseutil.SortByName(rels) + } +} + +// filterLatestReleases returns a list scrubbed of old releases. +func filterLatestReleases(releases []*release.Release) []*release.Release { + latestReleases := make(map[string]*release.Release) + + for _, rls := range releases { + name, namespace := rls.Name, rls.Namespace + key := path.Join(namespace, name) + if latestRelease, exists := latestReleases[key]; exists && latestRelease.Version > rls.Version { + continue + } + latestReleases[key] = rls + } + + var list = make([]*release.Release, 0, len(latestReleases)) + for _, rls := range latestReleases { + list = append(list, rls) + } + return list +} + +func (l *List) filterStateMask(releases []*release.Release) []*release.Release { + desiredStateReleases := make([]*release.Release, 0) + + for _, rls := range releases { + currentStatus := l.StateMask.FromName(rls.Info.Status.String()) + mask := l.StateMask & currentStatus + if mask == 0 { + continue + } + desiredStateReleases = append(desiredStateReleases, rls) + } + + return desiredStateReleases +} + +func (l *List) filterSelector(releases []*release.Release, selector labels.Selector) []*release.Release { + desiredStateReleases := make([]*release.Release, 0) + + for _, rls := range releases { + if selector.Matches(labels.Set(rls.Labels)) { + desiredStateReleases = append(desiredStateReleases, rls) + } + } + + return desiredStateReleases +} + +// SetStateMask calculates the state mask based on parameters. +func (l *List) SetStateMask() { + if l.All { + l.StateMask = ListAll + return + } + + state := ListStates(0) + if l.Deployed { + state |= ListDeployed + } + if l.Uninstalled { + state |= ListUninstalled + } + if l.Uninstalling { + state |= ListUninstalling + } + if l.Pending { + state |= ListPendingInstall | ListPendingRollback | ListPendingUpgrade + } + if l.Failed { + state |= ListFailed + } + if l.Superseded { + state |= ListSuperseded + } + + // Apply a default + if state == 0 { + state = ListDeployed | ListFailed + } + + l.StateMask = state +} diff --git a/pkg/action/list_test.go b/pkg/action/list_test.go new file mode 100644 index 00000000..73009d52 --- /dev/null +++ b/pkg/action/list_test.go @@ -0,0 +1,368 @@ +/* +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 ( + "testing" + + "github.com/stretchr/testify/assert" + + "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/storage" +) + +func TestListStates(t *testing.T) { + for input, expect := range map[string]ListStates{ + "deployed": ListDeployed, + "uninstalled": ListUninstalled, + "uninstalling": ListUninstalling, + "superseded": ListSuperseded, + "failed": ListFailed, + "pending-install": ListPendingInstall, + "pending-rollback": ListPendingRollback, + "pending-upgrade": ListPendingUpgrade, + "unknown": ListUnknown, + "totally made up key": ListUnknown, + } { + if expect != expect.FromName(input) { + t.Errorf("Expected %d for %s", expect, input) + } + // This is a cheap way to verify that ListAll actually allows everything but Unknown + if got := expect.FromName(input); got != ListUnknown && got&ListAll == 0 { + t.Errorf("Expected %s to match the ListAll filter", input) + } + } + + filter := ListDeployed | ListPendingRollback + if status := filter.FromName("deployed"); filter&status == 0 { + t.Errorf("Expected %d to match mask %d", status, filter) + } + if status := filter.FromName("failed"); filter&status != 0 { + t.Errorf("Expected %d to fail to match mask %d", status, filter) + } +} + +func TestList_Empty(t *testing.T) { + lister := NewList(actionConfigFixture(t)) + list, err := lister.Run() + assert.NoError(t, err) + assert.Len(t, list, 0) +} + +func newListFixture(t *testing.T) *List { + return NewList(actionConfigFixture(t)) +} + +func TestList_OneNamespace(t *testing.T) { + is := assert.New(t) + lister := newListFixture(t) + makeMeSomeReleases(lister.cfg.Releases, t) + list, err := lister.Run() + is.NoError(err) + is.Len(list, 3) +} + +func TestList_AllNamespaces(t *testing.T) { + is := assert.New(t) + lister := newListFixture(t) + makeMeSomeReleases(lister.cfg.Releases, t) + lister.AllNamespaces = true + lister.SetStateMask() + list, err := lister.Run() + is.NoError(err) + is.Len(list, 3) +} + +func TestList_Sort(t *testing.T) { + is := assert.New(t) + lister := newListFixture(t) + lister.Sort = ByNameDesc // Other sorts are tested elsewhere + makeMeSomeReleases(lister.cfg.Releases, t) + list, err := lister.Run() + is.NoError(err) + is.Len(list, 3) + is.Equal("two", list[0].Name) + is.Equal("three", list[1].Name) + is.Equal("one", list[2].Name) +} + +func TestList_Limit(t *testing.T) { + is := assert.New(t) + lister := newListFixture(t) + lister.Limit = 2 + makeMeSomeReleases(lister.cfg.Releases, t) + list, err := lister.Run() + is.NoError(err) + is.Len(list, 2) + // Lex order means one, three, two + is.Equal("one", list[0].Name) + is.Equal("three", list[1].Name) +} + +func TestList_BigLimit(t *testing.T) { + is := assert.New(t) + lister := newListFixture(t) + lister.Limit = 20 + makeMeSomeReleases(lister.cfg.Releases, t) + list, err := lister.Run() + is.NoError(err) + is.Len(list, 3) + + // Lex order means one, three, two + is.Equal("one", list[0].Name) + is.Equal("three", list[1].Name) + is.Equal("two", list[2].Name) +} + +func TestList_LimitOffset(t *testing.T) { + is := assert.New(t) + lister := newListFixture(t) + lister.Limit = 2 + lister.Offset = 1 + makeMeSomeReleases(lister.cfg.Releases, t) + list, err := lister.Run() + is.NoError(err) + is.Len(list, 2) + + // Lex order means one, three, two + is.Equal("three", list[0].Name) + is.Equal("two", list[1].Name) +} + +func TestList_LimitOffsetOutOfBounds(t *testing.T) { + is := assert.New(t) + lister := newListFixture(t) + lister.Limit = 2 + lister.Offset = 3 // Last item is index 2 + makeMeSomeReleases(lister.cfg.Releases, t) + list, err := lister.Run() + is.NoError(err) + is.Len(list, 0) + + lister.Limit = 10 + lister.Offset = 1 + list, err = lister.Run() + is.NoError(err) + is.Len(list, 2) +} + +func TestList_StateMask(t *testing.T) { + is := assert.New(t) + lister := newListFixture(t) + makeMeSomeReleases(lister.cfg.Releases, t) + one, err := lister.cfg.Releases.Get("one", 1) + is.NoError(err) + one.SetStatus(release.StatusUninstalled, "uninstalled") + err = lister.cfg.Releases.Update(one) + is.NoError(err) + + res, err := lister.Run() + is.NoError(err) + is.Len(res, 2) + is.Equal("three", res[0].Name) + is.Equal("two", res[1].Name) + + lister.StateMask = ListUninstalled + res, err = lister.Run() + is.NoError(err) + is.Len(res, 1) + is.Equal("one", res[0].Name) + + lister.StateMask |= ListDeployed + res, err = lister.Run() + is.NoError(err) + is.Len(res, 3) +} + +func TestList_StateMaskWithStaleRevisions(t *testing.T) { + is := assert.New(t) + lister := newListFixture(t) + lister.StateMask = ListFailed + + makeMeSomeReleasesWithStaleFailure(lister.cfg.Releases, t) + + res, err := lister.Run() + + is.NoError(err) + is.Len(res, 1) + + // "dirty" release should _not_ be present as most recent + // release is deployed despite failed release in past + is.Equal("failed", res[0].Name) +} + +func makeMeSomeReleasesWithStaleFailure(store *storage.Storage, t *testing.T) { + t.Helper() + one := namedReleaseStub("clean", release.StatusDeployed) + one.Namespace = "default" + one.Version = 1 + + two := namedReleaseStub("dirty", release.StatusDeployed) + two.Namespace = "default" + two.Version = 1 + + three := namedReleaseStub("dirty", release.StatusFailed) + three.Namespace = "default" + three.Version = 2 + + four := namedReleaseStub("dirty", release.StatusDeployed) + four.Namespace = "default" + four.Version = 3 + + five := namedReleaseStub("failed", release.StatusFailed) + five.Namespace = "default" + five.Version = 1 + + for _, rel := range []*release.Release{one, two, three, four, five} { + if err := store.Create(rel); err != nil { + t.Fatal(err) + } + } + + all, err := store.ListReleases() + assert.NoError(t, err) + assert.Len(t, all, 5, "sanity test: five items added") +} + +func TestList_Filter(t *testing.T) { + is := assert.New(t) + lister := newListFixture(t) + lister.Filter = "th." + makeMeSomeReleases(lister.cfg.Releases, t) + + res, err := lister.Run() + is.NoError(err) + is.Len(res, 1) + is.Equal("three", res[0].Name) +} + +func TestList_FilterFailsCompile(t *testing.T) { + is := assert.New(t) + lister := newListFixture(t) + lister.Filter = "t[h.{{{" + makeMeSomeReleases(lister.cfg.Releases, t) + + _, err := lister.Run() + is.Error(err) +} + +func makeMeSomeReleases(store *storage.Storage, t *testing.T) { + t.Helper() + one := releaseStub() + one.Name = "one" + one.Namespace = "default" + one.Version = 1 + two := releaseStub() + two.Name = "two" + two.Namespace = "default" + two.Version = 2 + three := releaseStub() + three.Name = "three" + three.Namespace = "default" + three.Version = 3 + + for _, rel := range []*release.Release{one, two, three} { + if err := store.Create(rel); err != nil { + t.Fatal(err) + } + } + + all, err := store.ListReleases() + assert.NoError(t, err) + assert.Len(t, all, 3, "sanity test: three items added") +} + +func TestFilterLatestReleases(t *testing.T) { + t.Run("should filter old versions of the same release", func(t *testing.T) { + r1 := releaseStub() + r1.Name = "r" + r1.Version = 1 + r2 := releaseStub() + r2.Name = "r" + r2.Version = 2 + another := releaseStub() + another.Name = "another" + another.Version = 1 + + filteredList := filterLatestReleases([]*release.Release{r1, r2, another}) + expectedFilteredList := []*release.Release{r2, another} + + assert.ElementsMatch(t, expectedFilteredList, filteredList) + }) + + t.Run("should not filter out any version across namespaces", func(t *testing.T) { + r1 := releaseStub() + r1.Name = "r" + r1.Namespace = "default" + r1.Version = 1 + r2 := releaseStub() + r2.Name = "r" + r2.Namespace = "testing" + r2.Version = 2 + + filteredList := filterLatestReleases([]*release.Release{r1, r2}) + expectedFilteredList := []*release.Release{r1, r2} + + assert.ElementsMatch(t, expectedFilteredList, filteredList) + }) +} + +func TestSelectorList(t *testing.T) { + r1 := releaseStub() + r1.Name = "r1" + r1.Version = 1 + r1.Labels = map[string]string{"key": "value1"} + r2 := releaseStub() + r2.Name = "r2" + r2.Version = 1 + r2.Labels = map[string]string{"key": "value2"} + r3 := releaseStub() + r3.Name = "r3" + r3.Version = 1 + r3.Labels = map[string]string{} + + lister := newListFixture(t) + for _, rel := range []*release.Release{r1, r2, r3} { + if err := lister.cfg.Releases.Create(rel); err != nil { + t.Fatal(err) + } + } + + t.Run("should fail selector parsing", func(t *testing.T) { + is := assert.New(t) + lister.Selector = "a?=b" + + _, err := lister.Run() + is.Error(err) + }) + + t.Run("should select one release with matching label", func(t *testing.T) { + lister.Selector = "key==value1" + res, _ := lister.Run() + + expectedFilteredList := []*release.Release{r1} + assert.ElementsMatch(t, expectedFilteredList, res) + }) + + t.Run("should select two releases with non matching label", func(t *testing.T) { + lister.Selector = "key!=value1" + res, _ := lister.Run() + + expectedFilteredList := []*release.Release{r2, r3} + assert.ElementsMatch(t, expectedFilteredList, res) + }) +} diff --git a/pkg/action/package.go b/pkg/action/package.go new file mode 100644 index 00000000..b79fcb54 --- /dev/null +++ b/pkg/action/package.go @@ -0,0 +1,181 @@ +/* +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 ( + "bufio" + "fmt" + "os" + "syscall" + + "github.com/Masterminds/semver/v3" + "github.com/pkg/errors" + "golang.org/x/term" + + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/provenance" +) + +// Package is the action for packaging a chart. +// +// It provides the implementation of 'helm package'. +type Package struct { + Sign bool + Key string + Keyring string + PassphraseFile string + Version string + AppVersion string + Destination string + DependencyUpdate bool + + RepositoryConfig string + RepositoryCache string +} + +// NewPackage creates a new Package object with the given configuration. +func NewPackage() *Package { + return &Package{} +} + +// Run executes 'helm package' against the given chart and returns the path to the packaged chart. +func (p *Package) Run(path string, _ map[string]interface{}) (string, error) { + ch, err := loader.LoadDir(path) + if err != nil { + return "", err + } + + // If version is set, modify the version. + if p.Version != "" { + ch.Metadata.Version = p.Version + } + + if err := validateVersion(ch.Metadata.Version); err != nil { + return "", err + } + + if p.AppVersion != "" { + ch.Metadata.AppVersion = p.AppVersion + } + + if reqs := ch.Metadata.Dependencies; reqs != nil { + if err := CheckDependencies(ch, reqs); err != nil { + return "", err + } + } + + var dest string + if p.Destination == "." { + // Save to the current working directory. + dest, err = os.Getwd() + if err != nil { + return "", err + } + } else { + // Otherwise save to set destination + dest = p.Destination + } + + name, err := chartutil.Save(ch, dest) + if err != nil { + return "", errors.Wrap(err, "failed to save") + } + + if p.Sign { + err = p.Clearsign(name) + } + + return name, err +} + +// validateVersion Verify that version is a Version, and error out if it is not. +func validateVersion(ver string) error { + if _, err := semver.NewVersion(ver); err != nil { + return err + } + return nil +} + +// Clearsign signs a chart +func (p *Package) Clearsign(filename string) error { + // Load keyring + signer, err := provenance.NewFromKeyring(p.Keyring, p.Key) + if err != nil { + return err + } + + passphraseFetcher := promptUser + if p.PassphraseFile != "" { + passphraseFetcher, err = passphraseFileFetcher(p.PassphraseFile, os.Stdin) + if err != nil { + return err + } + } + + if err := signer.DecryptKey(passphraseFetcher); err != nil { + return err + } + + sig, err := signer.ClearSign(filename) + if err != nil { + return err + } + + return os.WriteFile(filename+".prov", []byte(sig), 0644) +} + +// promptUser implements provenance.PassphraseFetcher +func promptUser(name string) ([]byte, error) { + fmt.Printf("Password for key %q > ", name) + // syscall.Stdin is not an int in all environments and needs to be coerced + // into one there (e.g., Windows) + pw, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() + return pw, err +} + +func passphraseFileFetcher(passphraseFile string, stdin *os.File) (provenance.PassphraseFetcher, error) { + file, err := openPassphraseFile(passphraseFile, stdin) + if err != nil { + return nil, err + } + defer file.Close() + + reader := bufio.NewReader(file) + passphrase, _, err := reader.ReadLine() + if err != nil { + return nil, err + } + return func(name string) ([]byte, error) { + return passphrase, nil + }, nil +} + +func openPassphraseFile(passphraseFile string, stdin *os.File) (*os.File, error) { + if passphraseFile == "-" { + stat, err := stdin.Stat() + if err != nil { + return nil, err + } + if (stat.Mode() & os.ModeNamedPipe) == 0 { + return nil, errors.New("specified reading passphrase from stdin, without input on stdin") + } + return stdin, nil + } + return os.Open(passphraseFile) +} diff --git a/pkg/action/package_test.go b/pkg/action/package_test.go new file mode 100644 index 00000000..d04efdaa --- /dev/null +++ b/pkg/action/package_test.go @@ -0,0 +1,121 @@ +/* +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 ( + "os" + "path" + "testing" + + "github.com/Masterminds/semver/v3" + + "helm.sh/helm/v3/internal/test/ensure" +) + +func TestPassphraseFileFetcher(t *testing.T) { + secret := "secret" + directory := ensure.TempFile(t, "passphrase-file", []byte(secret)) + + fetcher, err := passphraseFileFetcher(path.Join(directory, "passphrase-file"), nil) + if err != nil { + t.Fatal("Unable to create passphraseFileFetcher", err) + } + + passphrase, err := fetcher("key") + if err != nil { + t.Fatal("Unable to fetch passphrase") + } + + if string(passphrase) != secret { + t.Errorf("Expected %s got %s", secret, string(passphrase)) + } +} + +func TestPassphraseFileFetcher_WithLineBreak(t *testing.T) { + secret := "secret" + directory := ensure.TempFile(t, "passphrase-file", []byte(secret+"\n\n.")) + + fetcher, err := passphraseFileFetcher(path.Join(directory, "passphrase-file"), nil) + if err != nil { + t.Fatal("Unable to create passphraseFileFetcher", err) + } + + passphrase, err := fetcher("key") + if err != nil { + t.Fatal("Unable to fetch passphrase") + } + + if string(passphrase) != secret { + t.Errorf("Expected %s got %s", secret, string(passphrase)) + } +} + +func TestPassphraseFileFetcher_WithInvalidStdin(t *testing.T) { + directory := t.TempDir() + + stdin, err := os.CreateTemp(directory, "non-existing") + if err != nil { + t.Fatal("Unable to create test file", err) + } + + if _, err := passphraseFileFetcher("-", stdin); err == nil { + t.Error("Expected passphraseFileFetcher returning an error") + } +} + +func TestValidateVersion(t *testing.T) { + type args struct { + ver string + } + tests := []struct { + name string + args args + wantErr error + }{ + { + "normal semver version", + args{ + ver: "1.1.3-23658", + }, + nil, + }, + { + "Pre version number starting with 0", + args{ + ver: "1.1.3-023658", + }, + semver.ErrSegmentStartsZero, + }, + { + "Invalid version number", + args{ + ver: "1.1.3.sd.023658", + }, + semver.ErrInvalidSemVer, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validateVersion(tt.args.ver); err != nil { + if err != tt.wantErr { + t.Errorf("Expected {%v}, got {%v}", tt.wantErr, err) + } + + } + }) + } +} diff --git a/pkg/action/pull.go b/pkg/action/pull.go new file mode 100644 index 00000000..78755312 --- /dev/null +++ b/pkg/action/pull.go @@ -0,0 +1,172 @@ +/* +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 ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/pkg/errors" + + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/downloader" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/registry" + "helm.sh/helm/v3/pkg/repo" +) + +// Pull is the action for checking a given release's information. +// +// It provides the implementation of 'helm pull'. +type Pull struct { + ChartPathOptions + + Settings *cli.EnvSettings // TODO: refactor this out of pkg/action + + Devel bool + Untar bool + VerifyLater bool + UntarDir string + DestDir string + cfg *Configuration +} + +type PullOpt func(*Pull) + +func WithConfig(cfg *Configuration) PullOpt { + return func(p *Pull) { + p.cfg = cfg + } +} + +// NewPull creates a new Pull object. +func NewPull() *Pull { + return NewPullWithOpts() +} + +// NewPullWithOpts creates a new pull, with configuration options. +func NewPullWithOpts(opts ...PullOpt) *Pull { + p := &Pull{} + for _, fn := range opts { + fn(p) + } + + return p +} + +// SetRegistryClient sets the registry client on the pull configuration object. +func (p *Pull) SetRegistryClient(client *registry.Client) { + p.cfg.RegistryClient = client +} + +// Run executes 'helm pull' against the given release. +func (p *Pull) Run(chartRef string) (string, error) { + var out strings.Builder + + c := downloader.ChartDownloader{ + Out: &out, + Keyring: p.Keyring, + Verify: downloader.VerifyNever, + Getters: getter.All(p.Settings), + Options: []getter.Option{ + getter.WithBasicAuth(p.Username, p.Password), + getter.WithPassCredentialsAll(p.PassCredentialsAll), + getter.WithTLSClientConfig(p.CertFile, p.KeyFile, p.CaFile), + getter.WithInsecureSkipVerifyTLS(p.InsecureSkipTLSverify), + getter.WithPlainHTTP(p.PlainHTTP), + }, + RegistryClient: p.cfg.RegistryClient, + RepositoryConfig: p.Settings.RepositoryConfig, + RepositoryCache: p.Settings.RepositoryCache, + } + + if registry.IsOCI(chartRef) { + c.Options = append(c.Options, + getter.WithRegistryClient(p.cfg.RegistryClient)) + c.RegistryClient = p.cfg.RegistryClient + } + + if p.Verify { + c.Verify = downloader.VerifyAlways + } else if p.VerifyLater { + c.Verify = downloader.VerifyLater + } + + // If untar is set, we fetch to a tempdir, then untar and copy after + // verification. + dest := p.DestDir + if p.Untar { + var err error + dest, err = os.MkdirTemp("", "helm-") + if err != nil { + return out.String(), errors.Wrap(err, "failed to untar") + } + defer os.RemoveAll(dest) + } + + if p.RepoURL != "" { + chartURL, err := repo.FindChartInAuthAndTLSAndPassRepoURL(p.RepoURL, p.Username, p.Password, chartRef, p.Version, p.CertFile, p.KeyFile, p.CaFile, p.InsecureSkipTLSverify, p.PassCredentialsAll, getter.All(p.Settings)) + if err != nil { + return out.String(), err + } + chartRef = chartURL + } + + saved, v, err := c.DownloadTo(chartRef, p.Version, dest) + if err != nil { + return out.String(), err + } + + if p.Verify { + for name := range v.SignedBy.Identities { + fmt.Fprintf(&out, "Signed by: %v\n", name) + } + fmt.Fprintf(&out, "Using Key With Fingerprint: %X\n", v.SignedBy.PrimaryKey.Fingerprint) + fmt.Fprintf(&out, "Chart Hash Verified: %s\n", v.FileHash) + } + + // After verification, untar the chart into the requested directory. + if p.Untar { + ud := p.UntarDir + if !filepath.IsAbs(ud) { + ud = filepath.Join(p.DestDir, ud) + } + // Let udCheck to check conflict file/dir without replacing ud when untarDir is the current directory(.). + udCheck := ud + if udCheck == "." { + _, udCheck = filepath.Split(chartRef) + } else { + _, chartName := filepath.Split(chartRef) + udCheck = filepath.Join(udCheck, chartName) + } + + if _, err := os.Stat(udCheck); err != nil { + if err := os.MkdirAll(udCheck, 0755); err != nil { + return out.String(), errors.Wrap(err, "failed to untar (mkdir)") + } + + } else { + return out.String(), errors.Errorf("failed to untar: a file or directory with the name %s already exists", udCheck) + } + + return out.String(), chartutil.ExpandFile(ud, saved) + } + return out.String(), nil +} diff --git a/pkg/action/push.go b/pkg/action/push.go new file mode 100644 index 00000000..68d2ba42 --- /dev/null +++ b/pkg/action/push.go @@ -0,0 +1,112 @@ +/* +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 ( + "io" + "strings" + + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/pusher" + "helm.sh/helm/v3/pkg/registry" + "helm.sh/helm/v3/pkg/uploader" +) + +// Push is the action for uploading a chart. +// +// It provides the implementation of 'helm push'. +type Push struct { + Settings *cli.EnvSettings + cfg *Configuration + certFile string + keyFile string + caFile string + insecureSkipTLSverify bool + plainHTTP bool + out io.Writer +} + +// PushOpt is a type of function that sets options for a push action. +type PushOpt func(*Push) + +// WithPushConfig sets the cfg field on the push configuration object. +func WithPushConfig(cfg *Configuration) PushOpt { + return func(p *Push) { + p.cfg = cfg + } +} + +// WithTLSClientConfig sets the certFile, keyFile, and caFile fields on the push configuration object. +func WithTLSClientConfig(certFile, keyFile, caFile string) PushOpt { + return func(p *Push) { + p.certFile = certFile + p.keyFile = keyFile + p.caFile = caFile + } +} + +// WithInsecureSkipTLSVerify determines if a TLS Certificate will be checked +func WithInsecureSkipTLSVerify(insecureSkipTLSVerify bool) PushOpt { + return func(p *Push) { + p.insecureSkipTLSverify = insecureSkipTLSVerify + } +} + +// WithPlainHTTP configures the use of plain HTTP connections. +func WithPlainHTTP(plainHTTP bool) PushOpt { + return func(p *Push) { + p.plainHTTP = plainHTTP + } +} + +// WithOptWriter sets the registryOut field on the push configuration object. +func WithPushOptWriter(out io.Writer) PushOpt { + return func(p *Push) { + p.out = out + } +} + +// NewPushWithOpts creates a new push, with configuration options. +func NewPushWithOpts(opts ...PushOpt) *Push { + p := &Push{} + for _, fn := range opts { + fn(p) + } + return p +} + +// Run executes 'helm push' against the given chart archive. +func (p *Push) Run(chartRef string, remote string) (string, error) { + var out strings.Builder + + c := uploader.ChartUploader{ + Out: &out, + Pushers: pusher.All(p.Settings), + Options: []pusher.Option{ + pusher.WithTLSClientConfig(p.certFile, p.keyFile, p.caFile), + pusher.WithInsecureSkipTLSVerify(p.insecureSkipTLSverify), + pusher.WithPlainHTTP(p.plainHTTP), + }, + } + + if registry.IsOCI(remote) { + // Don't use the default registry client if tls options are set. + c.Options = append(c.Options, pusher.WithRegistryClient(p.cfg.RegistryClient)) + } + + return out.String(), c.UploadTo(chartRef, remote) +} diff --git a/pkg/action/registry_login.go b/pkg/action/registry_login.go new file mode 100644 index 00000000..cd144e1e --- /dev/null +++ b/pkg/action/registry_login.go @@ -0,0 +1,88 @@ +/* +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 ( + "io" + + "helm.sh/helm/v3/pkg/registry" +) + +// RegistryLogin performs a registry login operation. +type RegistryLogin struct { + cfg *Configuration + certFile string + keyFile string + caFile string + insecure bool +} + +type RegistryLoginOpt func(*RegistryLogin) error + +// WithCertFile specifies the path to the certificate file to use for TLS. +func WithCertFile(certFile string) RegistryLoginOpt { + return func(r *RegistryLogin) error { + r.certFile = certFile + return nil + } +} + +// WithKeyFile specifies whether to very certificates when communicating. +func WithInsecure(insecure bool) RegistryLoginOpt { + return func(r *RegistryLogin) error { + r.insecure = insecure + return nil + } +} + +// WithKeyFile specifies the path to the key file to use for TLS. +func WithKeyFile(keyFile string) RegistryLoginOpt { + return func(r *RegistryLogin) error { + r.keyFile = keyFile + return nil + } +} + +// WithCAFile specifies the path to the CA file to use for TLS. +func WithCAFile(caFile string) RegistryLoginOpt { + return func(r *RegistryLogin) error { + r.caFile = caFile + return nil + } +} + +// NewRegistryLogin creates a new RegistryLogin object with the given configuration. +func NewRegistryLogin(cfg *Configuration) *RegistryLogin { + return &RegistryLogin{ + cfg: cfg, + } +} + +// Run executes the registry login operation +func (a *RegistryLogin) Run(_ io.Writer, hostname string, username string, password string, opts ...RegistryLoginOpt) error { + for _, opt := range opts { + if err := opt(a); err != nil { + return err + } + } + + return a.cfg.RegistryClient.Login( + hostname, + registry.LoginOptBasicAuth(username, password), + registry.LoginOptInsecure(a.insecure), + registry.LoginOptTLSClientConfig(a.certFile, a.keyFile, a.caFile)) +} diff --git a/pkg/action/registry_logout.go b/pkg/action/registry_logout.go new file mode 100644 index 00000000..7ce92def --- /dev/null +++ b/pkg/action/registry_logout.go @@ -0,0 +1,38 @@ +/* +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 ( + "io" +) + +// RegistryLogout performs a registry login operation. +type RegistryLogout struct { + cfg *Configuration +} + +// NewRegistryLogout creates a new RegistryLogout object with the given configuration. +func NewRegistryLogout(cfg *Configuration) *RegistryLogout { + return &RegistryLogout{ + cfg: cfg, + } +} + +// Run executes the registry logout operation +func (a *RegistryLogout) Run(_ io.Writer, hostname string) error { + return a.cfg.RegistryClient.Logout(hostname) +} diff --git a/pkg/action/release_testing.go b/pkg/action/release_testing.go new file mode 100644 index 00000000..3c10cecf --- /dev/null +++ b/pkg/action/release_testing.go @@ -0,0 +1,152 @@ +/* +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 ( + "context" + "fmt" + "io" + "sort" + "time" + + "github.com/pkg/errors" + v1 "k8s.io/api/core/v1" + + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/release" +) + +const ( + ExcludeNameFilter = "!name" + IncludeNameFilter = "name" +) + +// ReleaseTesting is the action for testing a release. +// +// It provides the implementation of 'helm test'. +type ReleaseTesting struct { + cfg *Configuration + Timeout time.Duration + // Used for fetching logs from test pods + Namespace string + Filters map[string][]string +} + +// NewReleaseTesting creates a new ReleaseTesting object with the given configuration. +func NewReleaseTesting(cfg *Configuration) *ReleaseTesting { + return &ReleaseTesting{ + cfg: cfg, + Filters: map[string][]string{}, + } +} + +// Run executes 'helm test' against the given release. +func (r *ReleaseTesting) Run(name string) (*release.Release, error) { + if err := r.cfg.KubeClient.IsReachable(); err != nil { + return nil, err + } + + if err := chartutil.ValidateReleaseName(name); err != nil { + return nil, errors.Errorf("releaseTest: Release name is invalid: %s", name) + } + + // finds the non-deleted release with the given name + rel, err := r.cfg.Releases.Last(name) + if err != nil { + return rel, err + } + + skippedHooks := []*release.Hook{} + executingHooks := []*release.Hook{} + if len(r.Filters[ExcludeNameFilter]) != 0 { + for _, h := range rel.Hooks { + if contains(r.Filters[ExcludeNameFilter], h.Name) { + skippedHooks = append(skippedHooks, h) + } else { + executingHooks = append(executingHooks, h) + } + } + rel.Hooks = executingHooks + } + if len(r.Filters[IncludeNameFilter]) != 0 { + executingHooks = nil + for _, h := range rel.Hooks { + if contains(r.Filters[IncludeNameFilter], h.Name) { + executingHooks = append(executingHooks, h) + } else { + skippedHooks = append(skippedHooks, h) + } + } + rel.Hooks = executingHooks + } + + if err := r.cfg.execHook(rel, release.HookTest, r.Timeout); err != nil { + rel.Hooks = append(skippedHooks, rel.Hooks...) + r.cfg.Releases.Update(rel) + return rel, err + } + + rel.Hooks = append(skippedHooks, rel.Hooks...) + return rel, r.cfg.Releases.Update(rel) +} + +// GetPodLogs will write the logs for all test pods in the given release into +// the given writer. These can be immediately output to the user or captured for +// other uses +func (r *ReleaseTesting) GetPodLogs(out io.Writer, rel *release.Release) error { + client, err := r.cfg.KubernetesClientSet() + if err != nil { + return errors.Wrap(err, "unable to get kubernetes client to fetch pod logs") + } + + hooksByWight := append([]*release.Hook{}, rel.Hooks...) + sort.Stable(hookByWeight(hooksByWight)) + for _, h := range hooksByWight { + for _, e := range h.Events { + if e == release.HookTest { + if contains(r.Filters[ExcludeNameFilter], h.Name) { + continue + } + if len(r.Filters[IncludeNameFilter]) > 0 && !contains(r.Filters[IncludeNameFilter], h.Name) { + continue + } + req := client.CoreV1().Pods(r.Namespace).GetLogs(h.Name, &v1.PodLogOptions{}) + logReader, err := req.Stream(context.Background()) + if err != nil { + return errors.Wrapf(err, "unable to get pod logs for %s", h.Name) + } + + fmt.Fprintf(out, "POD LOGS: %s\n", h.Name) + _, err = io.Copy(out, logReader) + fmt.Fprintln(out) + if err != nil { + return errors.Wrapf(err, "unable to write pod logs for %s", h.Name) + } + } + } + } + return nil +} + +func contains(arr []string, value string) bool { + for _, item := range arr { + if item == value { + return true + } + } + return false +} diff --git a/pkg/action/resource_policy.go b/pkg/action/resource_policy.go new file mode 100644 index 00000000..63e83f3d --- /dev/null +++ b/pkg/action/resource_policy.go @@ -0,0 +1,46 @@ +/* +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 ( + "strings" + + "helm.sh/helm/v3/pkg/kube" + "helm.sh/helm/v3/pkg/releaseutil" +) + +func filterManifestsToKeep(manifests []releaseutil.Manifest) (keep, remaining []releaseutil.Manifest) { + for _, m := range manifests { + if m.Head.Metadata == nil || m.Head.Metadata.Annotations == nil || len(m.Head.Metadata.Annotations) == 0 { + remaining = append(remaining, m) + continue + } + + resourcePolicyType, ok := m.Head.Metadata.Annotations[kube.ResourcePolicyAnno] + if !ok { + remaining = append(remaining, m) + continue + } + + resourcePolicyType = strings.ToLower(strings.TrimSpace(resourcePolicyType)) + if resourcePolicyType == kube.KeepPolicy { + keep = append(keep, m) + } + + } + return keep, remaining +} diff --git a/pkg/action/rollback.go b/pkg/action/rollback.go new file mode 100644 index 00000000..b0be17d1 --- /dev/null +++ b/pkg/action/rollback.go @@ -0,0 +1,265 @@ +/* +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" + "strings" + "time" + + "github.com/pkg/errors" + + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/release" + helmtime "helm.sh/helm/v3/pkg/time" +) + +// Rollback is the action for rolling back to a given release. +// +// It provides the implementation of 'helm rollback'. +type Rollback struct { + cfg *Configuration + + Version int + Timeout time.Duration + Wait bool + WaitForJobs bool + DisableHooks bool + DryRun bool + Recreate bool // will (if true) recreate pods after a rollback. + Force bool // will (if true) force resource upgrade through uninstall/recreate if needed + CleanupOnFail bool + MaxHistory int // MaxHistory limits the maximum number of revisions saved per release +} + +// NewRollback creates a new Rollback object with the given configuration. +func NewRollback(cfg *Configuration) *Rollback { + return &Rollback{ + cfg: cfg, + } +} + +// Run executes 'helm rollback' against the given release. +func (r *Rollback) Run(name string) error { + if err := r.cfg.KubeClient.IsReachable(); err != nil { + return err + } + + r.cfg.Releases.MaxHistory = r.MaxHistory + + r.cfg.Log("preparing rollback of %s", name) + currentRelease, targetRelease, err := r.prepareRollback(name) + if err != nil { + return err + } + + if !r.DryRun { + r.cfg.Log("creating rolled back release for %s", name) + if err := r.cfg.Releases.Create(targetRelease); err != nil { + return err + } + } + + r.cfg.Log("performing rollback of %s", name) + if _, err := r.performRollback(currentRelease, targetRelease); err != nil { + return err + } + + if !r.DryRun { + r.cfg.Log("updating status for rolled back release for %s", name) + if err := r.cfg.Releases.Update(targetRelease); err != nil { + return err + } + } + return nil +} + +// prepareRollback finds the previous release and prepares a new release object with +// the previous release's configuration +func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Release, error) { + if err := chartutil.ValidateReleaseName(name); err != nil { + return nil, nil, errors.Errorf("prepareRollback: Release name is invalid: %s", name) + } + + if r.Version < 0 { + return nil, nil, errInvalidRevision + } + + currentRelease, err := r.cfg.Releases.Last(name) + if err != nil { + return nil, nil, err + } + + previousVersion := r.Version + if r.Version == 0 { + previousVersion = currentRelease.Version - 1 + } + + historyReleases, err := r.cfg.Releases.History(name) + if err != nil { + return nil, nil, err + } + + // Check if the history version to be rolled back exists + previousVersionExist := false + for _, historyRelease := range historyReleases { + version := historyRelease.Version + if previousVersion == version { + previousVersionExist = true + break + } + } + if !previousVersionExist { + return nil, nil, errors.Errorf("release has no %d version", previousVersion) + } + + r.cfg.Log("rolling back %s (current: v%d, target: v%d)", name, currentRelease.Version, previousVersion) + + previousRelease, err := r.cfg.Releases.Get(name, previousVersion) + if err != nil { + return nil, nil, err + } + + // Store a new release object with previous release's configuration + targetRelease := &release.Release{ + Name: name, + Namespace: currentRelease.Namespace, + Chart: previousRelease.Chart, + Config: previousRelease.Config, + Info: &release.Info{ + FirstDeployed: currentRelease.Info.FirstDeployed, + LastDeployed: helmtime.Now(), + Status: release.StatusPendingRollback, + Notes: previousRelease.Info.Notes, + // Because we lose the reference to previous version elsewhere, we set the + // message here, and only override it later if we experience failure. + Description: fmt.Sprintf("Rollback to %d", previousVersion), + }, + Version: currentRelease.Version + 1, + Labels: previousRelease.Labels, + Manifest: previousRelease.Manifest, + Hooks: previousRelease.Hooks, + } + + return currentRelease, targetRelease, nil +} + +func (r *Rollback) performRollback(currentRelease, targetRelease *release.Release) (*release.Release, error) { + if r.DryRun { + r.cfg.Log("dry run for %s", targetRelease.Name) + return targetRelease, nil + } + + current, err := r.cfg.KubeClient.Build(bytes.NewBufferString(currentRelease.Manifest), false) + if err != nil { + return targetRelease, errors.Wrap(err, "unable to build kubernetes objects from current release manifest") + } + target, err := r.cfg.KubeClient.Build(bytes.NewBufferString(targetRelease.Manifest), false) + if err != nil { + return targetRelease, errors.Wrap(err, "unable to build kubernetes objects from new release manifest") + } + + // pre-rollback hooks + if !r.DisableHooks { + if err := r.cfg.execHook(targetRelease, release.HookPreRollback, r.Timeout); err != nil { + return targetRelease, err + } + } else { + r.cfg.Log("rollback hooks disabled for %s", targetRelease.Name) + } + + // It is safe to use "force" here because these are resources currently rendered by the chart. + err = target.Visit(setMetadataVisitor(targetRelease.Name, targetRelease.Namespace, true)) + if err != nil { + return targetRelease, errors.Wrap(err, "unable to set metadata visitor from target release") + } + results, err := r.cfg.KubeClient.Update(current, target, r.Force) + + if err != nil { + msg := fmt.Sprintf("Rollback %q failed: %s", targetRelease.Name, err) + r.cfg.Log("warning: %s", msg) + currentRelease.Info.Status = release.StatusSuperseded + targetRelease.Info.Status = release.StatusFailed + targetRelease.Info.Description = msg + r.cfg.recordRelease(currentRelease) + r.cfg.recordRelease(targetRelease) + if r.CleanupOnFail { + r.cfg.Log("Cleanup on fail set, cleaning up %d resources", len(results.Created)) + _, errs := r.cfg.KubeClient.Delete(results.Created) + if errs != nil { + var errorList []string + for _, e := range errs { + errorList = append(errorList, e.Error()) + } + return targetRelease, errors.Wrapf(fmt.Errorf("unable to cleanup resources: %s", strings.Join(errorList, ", ")), "an error occurred while cleaning up resources. original rollback error: %s", err) + } + r.cfg.Log("Resource cleanup complete") + } + return targetRelease, err + } + + if r.Recreate { + // NOTE: Because this is not critical for a release to succeed, we just + // log if an error occurs and continue onward. If we ever introduce log + // levels, we should make these error level logs so users are notified + // that they'll need to go do the cleanup on their own + if err := recreate(r.cfg, results.Updated); err != nil { + r.cfg.Log(err.Error()) + } + } + + if r.Wait { + if r.WaitForJobs { + if err := r.cfg.KubeClient.WaitWithJobs(target, r.Timeout); err != nil { + targetRelease.SetStatus(release.StatusFailed, fmt.Sprintf("Release %q failed: %s", targetRelease.Name, err.Error())) + r.cfg.recordRelease(currentRelease) + r.cfg.recordRelease(targetRelease) + return targetRelease, errors.Wrapf(err, "release %s failed", targetRelease.Name) + } + } else { + if err := r.cfg.KubeClient.Wait(target, r.Timeout); err != nil { + targetRelease.SetStatus(release.StatusFailed, fmt.Sprintf("Release %q failed: %s", targetRelease.Name, err.Error())) + r.cfg.recordRelease(currentRelease) + r.cfg.recordRelease(targetRelease) + return targetRelease, errors.Wrapf(err, "release %s failed", targetRelease.Name) + } + } + } + + // post-rollback hooks + if !r.DisableHooks { + if err := r.cfg.execHook(targetRelease, release.HookPostRollback, r.Timeout); err != nil { + return targetRelease, err + } + } + + deployed, err := r.cfg.Releases.DeployedAll(currentRelease.Name) + if err != nil && !strings.Contains(err.Error(), "has no deployed releases") { + return nil, err + } + // Supersede all previous deployments, see issue #2941. + for _, rel := range deployed { + r.cfg.Log("superseding previous deployment %d", rel.Version) + rel.Info.Status = release.StatusSuperseded + r.cfg.recordRelease(rel) + } + + targetRelease.Info.Status = release.StatusDeployed + + return targetRelease, nil +} diff --git a/pkg/action/show.go b/pkg/action/show.go new file mode 100644 index 00000000..6ed855b8 --- /dev/null +++ b/pkg/action/show.go @@ -0,0 +1,165 @@ +/* +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" + "strings" + + "github.com/pkg/errors" + "k8s.io/cli-runtime/pkg/printers" + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/registry" +) + +// ShowOutputFormat is the format of the output of `helm show` +type ShowOutputFormat string + +const ( + // ShowAll is the format which shows all the information of a chart + ShowAll ShowOutputFormat = "all" + // ShowChart is the format which only shows the chart's definition + ShowChart ShowOutputFormat = "chart" + // ShowValues is the format which only shows the chart's values + ShowValues ShowOutputFormat = "values" + // ShowReadme is the format which only shows the chart's README + ShowReadme ShowOutputFormat = "readme" + // ShowCRDs is the format which only shows the chart's CRDs + ShowCRDs ShowOutputFormat = "crds" +) + +var readmeFileNames = []string{"readme.md", "readme.txt", "readme"} + +func (o ShowOutputFormat) String() string { + return string(o) +} + +// Show is the action for checking a given release's information. +// +// It provides the implementation of 'helm show' and its respective subcommands. +type Show struct { + ChartPathOptions + Devel bool + OutputFormat ShowOutputFormat + JSONPathTemplate string + chart *chart.Chart // for testing +} + +// NewShow creates a new Show object with the given configuration. +// Deprecated: Use NewShowWithConfig +// TODO Helm 4: Fold NewShowWithConfig back into NewShow +func NewShow(output ShowOutputFormat) *Show { + return &Show{ + OutputFormat: output, + } +} + +// NewShowWithConfig creates a new Show object with the given configuration. +func NewShowWithConfig(output ShowOutputFormat, cfg *Configuration) *Show { + sh := &Show{ + OutputFormat: output, + } + sh.ChartPathOptions.registryClient = cfg.RegistryClient + + return sh +} + +// SetRegistryClient sets the registry client to use when pulling a chart from a registry. +func (s *Show) SetRegistryClient(client *registry.Client) { + s.ChartPathOptions.registryClient = client +} + +// Run executes 'helm show' against the given release. +func (s *Show) Run(chartpath string) (string, error) { + if s.chart == nil { + chrt, err := loader.Load(chartpath) + if err != nil { + return "", err + } + s.chart = chrt + } + cf, err := yaml.Marshal(s.chart.Metadata) + if err != nil { + return "", err + } + + var out strings.Builder + if s.OutputFormat == ShowChart || s.OutputFormat == ShowAll { + fmt.Fprintf(&out, "%s\n", cf) + } + + if (s.OutputFormat == ShowValues || s.OutputFormat == ShowAll) && s.chart.Values != nil { + if s.OutputFormat == ShowAll { + fmt.Fprintln(&out, "---") + } + if s.JSONPathTemplate != "" { + printer, err := printers.NewJSONPathPrinter(s.JSONPathTemplate) + if err != nil { + return "", errors.Wrapf(err, "error parsing jsonpath %s", s.JSONPathTemplate) + } + printer.Execute(&out, s.chart.Values) + } else { + for _, f := range s.chart.Raw { + if f.Name == chartutil.ValuesfileName { + fmt.Fprintln(&out, string(f.Data)) + } + } + } + } + + if s.OutputFormat == ShowReadme || s.OutputFormat == ShowAll { + readme := findReadme(s.chart.Files) + if readme != nil { + if s.OutputFormat == ShowAll { + fmt.Fprintln(&out, "---") + } + fmt.Fprintf(&out, "%s\n", readme.Data) + } + } + + if s.OutputFormat == ShowCRDs || s.OutputFormat == ShowAll { + crds := s.chart.CRDObjects() + if len(crds) > 0 { + if s.OutputFormat == ShowAll && !bytes.HasPrefix(crds[0].File.Data, []byte("---")) { + fmt.Fprintln(&out, "---") + } + for _, crd := range crds { + fmt.Fprintf(&out, "%s\n", string(crd.File.Data)) + } + } + } + return out.String(), nil +} + +func findReadme(files []*chart.File) (file *chart.File) { + for _, file := range files { + for _, n := range readmeFileNames { + if file == nil { + continue + } + if strings.EqualFold(file.Name, n) { + return file + } + } + } + return nil +} diff --git a/pkg/action/show_test.go b/pkg/action/show_test.go new file mode 100644 index 00000000..8b617ea8 --- /dev/null +++ b/pkg/action/show_test.go @@ -0,0 +1,153 @@ +/* +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 ( + "testing" + + "helm.sh/helm/v3/pkg/chart" +) + +func TestShow(t *testing.T) { + config := actionConfigFixture(t) + client := NewShowWithConfig(ShowAll, config) + client.chart = &chart.Chart{ + Metadata: &chart.Metadata{Name: "alpine"}, + Files: []*chart.File{ + {Name: "README.md", Data: []byte("README\n")}, + {Name: "crds/ignoreme.txt", Data: []byte("error")}, + {Name: "crds/foo.yaml", Data: []byte("---\nfoo\n")}, + {Name: "crds/bar.json", Data: []byte("---\nbar\n")}, + }, + Raw: []*chart.File{ + {Name: "values.yaml", Data: []byte("VALUES\n")}, + }, + Values: map[string]interface{}{}, + } + + output, err := client.Run("") + if err != nil { + t.Fatal(err) + } + + expect := `name: alpine + +--- +VALUES + +--- +README + +--- +foo + +--- +bar + +` + if output != expect { + t.Errorf("Expected\n%q\nGot\n%q\n", expect, output) + } +} + +func TestShowNoValues(t *testing.T) { + client := NewShow(ShowAll) + client.chart = new(chart.Chart) + + // Regression tests for missing values. See issue #1024. + client.OutputFormat = ShowValues + output, err := client.Run("") + if err != nil { + t.Fatal(err) + } + + if len(output) != 0 { + t.Errorf("expected empty values buffer, got %s", output) + } +} + +func TestShowValuesByJsonPathFormat(t *testing.T) { + client := NewShow(ShowValues) + client.JSONPathTemplate = "{$.nestedKey.simpleKey}" + client.chart = buildChart(withSampleValues()) + output, err := client.Run("") + if err != nil { + t.Fatal(err) + } + expect := "simpleValue" + if output != expect { + t.Errorf("Expected\n%q\nGot\n%q\n", expect, output) + } +} + +func TestShowCRDs(t *testing.T) { + client := NewShow(ShowCRDs) + client.chart = &chart.Chart{ + Metadata: &chart.Metadata{Name: "alpine"}, + Files: []*chart.File{ + {Name: "crds/ignoreme.txt", Data: []byte("error")}, + {Name: "crds/foo.yaml", Data: []byte("---\nfoo\n")}, + {Name: "crds/bar.json", Data: []byte("---\nbar\n")}, + }, + } + + output, err := client.Run("") + if err != nil { + t.Fatal(err) + } + + expect := `--- +foo + +--- +bar + +` + if output != expect { + t.Errorf("Expected\n%q\nGot\n%q\n", expect, output) + } +} + +func TestShowNoReadme(t *testing.T) { + client := NewShow(ShowAll) + client.chart = &chart.Chart{ + Metadata: &chart.Metadata{Name: "alpine"}, + Files: []*chart.File{ + {Name: "crds/ignoreme.txt", Data: []byte("error")}, + {Name: "crds/foo.yaml", Data: []byte("---\nfoo\n")}, + {Name: "crds/bar.json", Data: []byte("---\nbar\n")}, + }, + } + + output, err := client.Run("") + if err != nil { + t.Fatal(err) + } + + expect := `name: alpine + +--- +foo + +--- +bar + +` + if output != expect { + t.Errorf("Expected\n%q\nGot\n%q\n", expect, output) + } +} diff --git a/pkg/action/status.go b/pkg/action/status.go new file mode 100644 index 00000000..ee1c9d61 --- /dev/null +++ b/pkg/action/status.go @@ -0,0 +1,95 @@ +/* +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" + "errors" + + "helm.sh/helm/v3/pkg/kube" + "helm.sh/helm/v3/pkg/release" +) + +// Status is the action for checking the deployment status of releases. +// +// It provides the implementation of 'helm status'. +type Status struct { + cfg *Configuration + + Version int + + // If true, display description to output format, + // only affect print type table. + // TODO Helm 4: Remove this flag and output the description by default. + ShowDescription bool + + // ShowResources sets if the resources should be retrieved with the status. + // TODO Helm 4: Remove this flag and output the resources by default. + ShowResources bool + + // ShowResourcesTable is used with ShowResources. When true this will cause + // the resulting objects to be retrieved as a kind=table. + ShowResourcesTable bool +} + +// NewStatus creates a new Status object with the given configuration. +func NewStatus(cfg *Configuration) *Status { + return &Status{ + cfg: cfg, + } +} + +// Run executes 'helm status' against the given release. +func (s *Status) Run(name string) (*release.Release, error) { + if err := s.cfg.KubeClient.IsReachable(); err != nil { + return nil, err + } + + if !s.ShowResources { + return s.cfg.releaseContent(name, s.Version) + } + + rel, err := s.cfg.releaseContent(name, s.Version) + if err != nil { + return nil, err + } + + if kubeClient, ok := s.cfg.KubeClient.(kube.InterfaceResources); ok { + var resources kube.ResourceList + if s.ShowResourcesTable { + resources, err = kubeClient.BuildTable(bytes.NewBufferString(rel.Manifest), false) + if err != nil { + return nil, err + } + } else { + resources, err = s.cfg.KubeClient.Build(bytes.NewBufferString(rel.Manifest), false) + if err != nil { + return nil, err + } + } + + resp, err := kubeClient.Get(resources, true) + if err != nil { + return nil, err + } + + rel.Info.Resources = resp + + return rel, nil + } + return nil, errors.New("unable to get kubeClient with interface InterfaceResources") +} diff --git a/pkg/action/testdata/charts/chart-missing-deps/Chart.yaml b/pkg/action/testdata/charts/chart-missing-deps/Chart.yaml new file mode 100755 index 00000000..ba10ee80 --- /dev/null +++ b/pkg/action/testdata/charts/chart-missing-deps/Chart.yaml @@ -0,0 +1,2 @@ +name: chart-with-missing-deps +version: 2.1.8 diff --git a/pkg/action/testdata/charts/chart-missing-deps/requirements.lock b/pkg/action/testdata/charts/chart-missing-deps/requirements.lock new file mode 100755 index 00000000..dcda2b14 --- /dev/null +++ b/pkg/action/testdata/charts/chart-missing-deps/requirements.lock @@ -0,0 +1,6 @@ +dependencies: +- name: mariadb + repository: https://charts.helm.sh/stable/ + version: 4.3.1 +digest: sha256:82a0e5374376169d2ecf7d452c18a2ed93507f5d17c3393a1457f9ffad7e9b26 +generated: 2018-08-02T22:07:51.905271776Z diff --git a/pkg/action/testdata/charts/chart-missing-deps/requirements.yaml b/pkg/action/testdata/charts/chart-missing-deps/requirements.yaml new file mode 100755 index 00000000..fef7d0b7 --- /dev/null +++ b/pkg/action/testdata/charts/chart-missing-deps/requirements.yaml @@ -0,0 +1,7 @@ +dependencies: +- name: mariadb + version: 4.x.x + repository: https://charts.helm.sh/stable/ + condition: mariadb.enabled + tags: + - wordpress-database diff --git a/pkg/action/testdata/charts/chart-with-compressed-dependencies-2.1.8.tgz b/pkg/action/testdata/charts/chart-with-compressed-dependencies-2.1.8.tgz new file mode 100644 index 0000000000000000000000000000000000000000..7a22b1d827f593f101879554ae7831f2a40d2278 GIT binary patch literal 10962 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 AQ~&?~ literal 0 HcmV?d00001 diff --git a/pkg/action/testdata/charts/chart-with-compressed-dependencies/Chart.yaml b/pkg/action/testdata/charts/chart-with-compressed-dependencies/Chart.yaml new file mode 100755 index 00000000..1d16590b --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-compressed-dependencies/Chart.yaml @@ -0,0 +1,2 @@ +name: chart-with-compressed-dependencies +version: 2.1.8 diff --git a/pkg/action/testdata/charts/chart-with-compressed-dependencies/charts/mariadb-4.3.1.tgz b/pkg/action/testdata/charts/chart-with-compressed-dependencies/charts/mariadb-4.3.1.tgz new file mode 100644 index 0000000000000000000000000000000000000000..5b38fa1c399527b1e3dc452bd90e5e8312c7051a GIT binary patch literal 8401 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 literal 0 HcmV?d00001 diff --git a/pkg/action/testdata/charts/chart-with-compressed-dependencies/requirements.lock b/pkg/action/testdata/charts/chart-with-compressed-dependencies/requirements.lock new file mode 100755 index 00000000..dcda2b14 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-compressed-dependencies/requirements.lock @@ -0,0 +1,6 @@ +dependencies: +- name: mariadb + repository: https://charts.helm.sh/stable/ + version: 4.3.1 +digest: sha256:82a0e5374376169d2ecf7d452c18a2ed93507f5d17c3393a1457f9ffad7e9b26 +generated: 2018-08-02T22:07:51.905271776Z diff --git a/pkg/action/testdata/charts/chart-with-compressed-dependencies/requirements.yaml b/pkg/action/testdata/charts/chart-with-compressed-dependencies/requirements.yaml new file mode 100755 index 00000000..fef7d0b7 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-compressed-dependencies/requirements.yaml @@ -0,0 +1,7 @@ +dependencies: +- name: mariadb + version: 4.x.x + repository: https://charts.helm.sh/stable/ + condition: mariadb.enabled + tags: + - wordpress-database diff --git a/pkg/action/testdata/charts/chart-with-no-templates-dir/Chart.yaml b/pkg/action/testdata/charts/chart-with-no-templates-dir/Chart.yaml new file mode 100644 index 00000000..d3458f6a --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-no-templates-dir/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: chart-with-no-templates-dir +description: an example chart +version: 199.44.12345-Alpha.1+cafe009 +icon: http://riverrun.io diff --git a/pkg/action/testdata/charts/chart-with-schema-negative/Chart.yaml b/pkg/action/testdata/charts/chart-with-schema-negative/Chart.yaml new file mode 100644 index 00000000..395d24f6 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-schema-negative/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +description: Empty testing chart +home: https://k8s.io/helm +name: empty +sources: +- https://github.com/kubernetes/helm +version: 0.1.0 diff --git a/pkg/action/testdata/charts/chart-with-schema-negative/templates/empty.yaml b/pkg/action/testdata/charts/chart-with-schema-negative/templates/empty.yaml new file mode 100644 index 00000000..c80812f6 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-schema-negative/templates/empty.yaml @@ -0,0 +1 @@ +# This file is intentionally blank diff --git a/pkg/action/testdata/charts/chart-with-schema-negative/values.schema.json b/pkg/action/testdata/charts/chart-with-schema-negative/values.schema.json new file mode 100644 index 00000000..4df89bbe --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-schema-negative/values.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "addresses": { + "description": "List of addresses", + "items": { + "properties": { + "city": { + "type": "string" + }, + "number": { + "type": "number" + }, + "street": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "age": { + "description": "Age", + "minimum": 0, + "type": "integer" + }, + "employmentInfo": { + "properties": { + "salary": { + "minimum": 0, + "type": "number" + }, + "title": { + "type": "string" + } + }, + "required": [ + "salary" + ], + "type": "object" + }, + "firstname": { + "description": "First name", + "type": "string" + }, + "lastname": { + "type": "string" + }, + "likesCoffee": { + "type": "boolean" + }, + "phoneNumbers": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "firstname", + "lastname", + "addresses", + "employmentInfo" + ], + "title": "Values", + "type": "object" +} diff --git a/pkg/action/testdata/charts/chart-with-schema-negative/values.yaml b/pkg/action/testdata/charts/chart-with-schema-negative/values.yaml new file mode 100644 index 00000000..5a1250bf --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-schema-negative/values.yaml @@ -0,0 +1,14 @@ +firstname: John +lastname: Doe +age: -5 +likesCoffee: true +addresses: + - city: Springfield + street: Main + number: 12345 + - city: New York + street: Broadway + number: 67890 +phoneNumbers: + - "(888) 888-8888" + - "(555) 555-5555" diff --git a/pkg/action/testdata/charts/chart-with-schema/Chart.yaml b/pkg/action/testdata/charts/chart-with-schema/Chart.yaml new file mode 100644 index 00000000..395d24f6 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-schema/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +description: Empty testing chart +home: https://k8s.io/helm +name: empty +sources: +- https://github.com/kubernetes/helm +version: 0.1.0 diff --git a/pkg/action/testdata/charts/chart-with-schema/extra-values.yaml b/pkg/action/testdata/charts/chart-with-schema/extra-values.yaml new file mode 100644 index 00000000..76c290c4 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-schema/extra-values.yaml @@ -0,0 +1,2 @@ +age: -5 +employmentInfo: null diff --git a/pkg/action/testdata/charts/chart-with-schema/templates/empty.yaml b/pkg/action/testdata/charts/chart-with-schema/templates/empty.yaml new file mode 100644 index 00000000..c80812f6 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-schema/templates/empty.yaml @@ -0,0 +1 @@ +# This file is intentionally blank diff --git a/pkg/action/testdata/charts/chart-with-schema/values.schema.json b/pkg/action/testdata/charts/chart-with-schema/values.schema.json new file mode 100644 index 00000000..4df89bbe --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-schema/values.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "addresses": { + "description": "List of addresses", + "items": { + "properties": { + "city": { + "type": "string" + }, + "number": { + "type": "number" + }, + "street": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "age": { + "description": "Age", + "minimum": 0, + "type": "integer" + }, + "employmentInfo": { + "properties": { + "salary": { + "minimum": 0, + "type": "number" + }, + "title": { + "type": "string" + } + }, + "required": [ + "salary" + ], + "type": "object" + }, + "firstname": { + "description": "First name", + "type": "string" + }, + "lastname": { + "type": "string" + }, + "likesCoffee": { + "type": "boolean" + }, + "phoneNumbers": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "firstname", + "lastname", + "addresses", + "employmentInfo" + ], + "title": "Values", + "type": "object" +} diff --git a/pkg/action/testdata/charts/chart-with-schema/values.yaml b/pkg/action/testdata/charts/chart-with-schema/values.yaml new file mode 100644 index 00000000..042dea66 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-schema/values.yaml @@ -0,0 +1,17 @@ +firstname: John +lastname: Doe +age: 25 +likesCoffee: true +employmentInfo: + title: Software Developer + salary: 100000 +addresses: + - city: Springfield + street: Main + number: 12345 + - city: New York + street: Broadway + number: 67890 +phoneNumbers: + - "(888) 888-8888" + - "(555) 555-5555" diff --git a/pkg/action/testdata/charts/chart-with-uncompressed-dependencies-2.1.8.tgz b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies-2.1.8.tgz new file mode 100644 index 0000000000000000000000000000000000000000..ad9e681795cf96f3c632c8d6ba6c406be3b96b45 GIT binary patch literal 10953 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 **Tip**: List all releases using `helm list` + +## Uninstalling the Chart + +To uninstall/delete the `my-release` deployment: + +```bash +$ helm delete my-release +``` + +The command removes all the Kubernetes components associated with the chart and deletes the release. + +## Configuration + +The following table lists the configurable parameters of the MariaDB chart and their default values. + +| Parameter | Description | Default | +|-------------------------------------------|-----------------------------------------------------|-------------------------------------------------------------------| +| `image.registry` | MariaDB image registry | `docker.io` | +| `image.repository` | MariaDB Image name | `bitnami/mariadb` | +| `image.tag` | MariaDB Image tag | `{VERSION}` | +| `image.pullPolicy` | MariaDB image pull policy | `Always` if `imageTag` is `latest`, else `IfNotPresent` | +| `image.pullSecrets` | Specify image pull secrets | `nil` (does not add image pull secrets to deployed pods) | +| `service.type` | Kubernetes service type | `ClusterIP` | +| `service.port` | MySQL service port | `3306` | +| `rootUser.password` | Password for the `root` user | _random 10 character alphanumeric string_ | +| `rootUser.forcePassword` | Force users to specify a password | `false` | +| `db.user` | Username of new user to create | `nil` | +| `db.password` | Password for the new user | _random 10 character alphanumeric string if `db.user` is defined_ | +| `db.name` | Name for new database to create | `my_database` | +| `replication.enabled` | MariaDB replication enabled | `true` | +| `replication.user` | MariaDB replication user | `replicator` | +| `replication.password` | MariaDB replication user password | _random 10 character alphanumeric string_ | +| `master.antiAffinity` | Master pod anti-affinity policy | `soft` | +| `master.persistence.enabled` | Enable persistence using a `PersistentVolumeClaim` | `true` | +| `master.persistence.annotations` | Persistent Volume Claim annotations | `{}` | +| `master.persistence.storageClass` | Persistent Volume Storage Class | `` | +| `master.persistence.accessModes` | Persistent Volume Access Modes | `[ReadWriteOnce]` | +| `master.persistence.size` | Persistent Volume Size | `8Gi` | +| `master.config` | Config file for the MariaDB Master server | `_default values in the values.yaml file_` | +| `master.resources` | CPU/Memory resource requests/limits for master node | `{}` | +| `master.livenessProbe.enabled` | Turn on and off liveness probe (master) | `true` | +| `master.livenessProbe.initialDelaySeconds`| Delay before liveness probe is initiated (master) | `120` | +| `master.livenessProbe.periodSeconds` | How often to perform the probe (master) | `10` | +| `master.livenessProbe.timeoutSeconds` | When the probe times out (master) | `1` | +| `master.livenessProbe.successThreshold` | Minimum consecutive successes for the probe (master)| `1` | +| `master.livenessProbe.failureThreshold` | Minimum consecutive failures for the probe (master) | `3` | +| `master.readinessProbe.enabled` | Turn on and off readiness probe (master) | `true` | +| `master.readinessProbe.initialDelaySeconds`| Delay before readiness probe is initiated (master) | `15` | +| `master.readinessProbe.periodSeconds` | How often to perform the probe (master) | `10` | +| `master.readinessProbe.timeoutSeconds` | When the probe times out (master) | `1` | +| `master.readinessProbe.successThreshold` | Minimum consecutive successes for the probe (master)| `1` | +| `master.readinessProbe.failureThreshold` | Minimum consecutive failures for the probe (master) | `3` | +| `slave.replicas` | Desired number of slave replicas | `1` | +| `slave.antiAffinity` | Slave pod anti-affinity policy | `soft` | +| `slave.persistence.enabled` | Enable persistence using a `PersistentVolumeClaim` | `true` | +| `slave.persistence.annotations` | Persistent Volume Claim annotations | `{}` | +| `slave.persistence.storageClass` | Persistent Volume Storage Class | `` | +| `slave.persistence.accessModes` | Persistent Volume Access Modes | `[ReadWriteOnce]` | +| `slave.persistence.size` | Persistent Volume Size | `8Gi` | +| `slave.config` | Config file for the MariaDB Slave replicas | `_default values in the values.yaml file_` | +| `slave.resources` | CPU/Memory resource requests/limits for slave node | `{}` | +| `slave.livenessProbe.enabled` | Turn on and off liveness probe (slave) | `true` | +| `slave.livenessProbe.initialDelaySeconds` | Delay before liveness probe is initiated (slave) | `120` | +| `slave.livenessProbe.periodSeconds` | How often to perform the probe (slave) | `10` | +| `slave.livenessProbe.timeoutSeconds` | When the probe times out (slave) | `1` | +| `slave.livenessProbe.successThreshold` | Minimum consecutive successes for the probe (slave) | `1` | +| `slave.livenessProbe.failureThreshold` | Minimum consecutive failures for the probe (slave) | `3` | +| `slave.readinessProbe.enabled` | Turn on and off readiness probe (slave) | `true` | +| `slave.readinessProbe.initialDelaySeconds`| Delay before readiness probe is initiated (slave) | `15` | +| `slave.readinessProbe.periodSeconds` | How often to perform the probe (slave) | `10` | +| `slave.readinessProbe.timeoutSeconds` | When the probe times out (slave) | `1` | +| `slave.readinessProbe.successThreshold` | Minimum consecutive successes for the probe (slave) | `1` | +| `slave.readinessProbe.failureThreshold` | Minimum consecutive failures for the probe (slave) | `3` | +| `metrics.enabled` | Start a side-car prometheus exporter | `false` | +| `metrics.image.registry` | Exporter image registry | `docker.io` | +`metrics.image.repository` | Exporter image name | `prom/mysqld-exporter` | +| `metrics.image.tag` | Exporter image tag | `v0.10.0` | +| `metrics.image.pullPolicy` | Exporter image pull policy | `IfNotPresent` | +| `metrics.resources` | Exporter resource requests/limit | `nil` | + +The above parameters map to the env variables defined in [bitnami/mariadb](http://github.com/bitnami/bitnami-docker-mariadb). For more information please refer to the [bitnami/mariadb](http://github.com/bitnami/bitnami-docker-mariadb) image documentation. + +Specify each parameter using the `--set key=value[,key=value]` argument to `helm install`. For example, + +```bash +$ helm install --name my-release \ + --set root.password=secretpassword,user.database=app_database \ + stable/mariadb +``` + +The above command sets the MariaDB `root` account password to `secretpassword`. Additionally it creates a database named `my_database`. + +Alternatively, a YAML file that specifies the values for the parameters can be provided while installing the chart. For example, + +```bash +$ helm install --name my-release -f values.yaml stable/mariadb +``` + +> **Tip**: You can use the default [values.yaml](values.yaml) + +## Initialize a fresh instance + +The [Bitnami MariaDB](https://github.com/bitnami/bitnami-docker-mariadb) image allows you to use your custom scripts to initialize a fresh instance. In order to execute the scripts, they must be located inside the chart folder `files/docker-entrypoint-initdb.d` so they can be consumed as a ConfigMap. + +The allowed extensions are `.sh`, `.sql` and `.sql.gz`. + +## Persistence + +The [Bitnami MariaDB](https://github.com/bitnami/bitnami-docker-mariadb) image stores the MariaDB data and configurations at the `/bitnami/mariadb` path of the container. + +The chart mounts a [Persistent Volume](kubernetes.io/docs/user-guide/persistent-volumes/) volume at this location. The volume is created using dynamic volume provisioning, by default. An existing PersistentVolumeClaim can be defined. diff --git a/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/files/docker-entrypoint-initdb.d/README.md b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/files/docker-entrypoint-initdb.d/README.md new file mode 100755 index 00000000..aaddde30 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/files/docker-entrypoint-initdb.d/README.md @@ -0,0 +1,3 @@ +You can copy here your custom .sh, .sql or .sql.gz file so they are executed during the first boot of the image. + +More info in the [bitnami-docker-mariadb](https://github.com/bitnami/bitnami-docker-mariadb#initializing-a-new-instance) repository. \ No newline at end of file diff --git a/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/NOTES.txt b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/NOTES.txt new file mode 100755 index 00000000..4ba3b668 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/NOTES.txt @@ -0,0 +1,35 @@ + +Please be patient while the chart is being deployed + +Tip: + + Watch the deployment status using the command: kubectl get pods -w --namespace {{ .Release.Namespace }} -l release={{ .Release.Name }} + +Services: + + echo Master: {{ template "mariadb.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local:{{ .Values.service.port }} +{{- if .Values.replication.enabled }} + echo Slave: {{ template "slave.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local:{{ .Values.service.port }} +{{- end }} + +Administrator credentials: + + Username: root + Password : $(kubectl get secret --namespace {{ .Release.Namespace }} {{ template "mariadb.fullname" . }} -o jsonpath="{.data.mariadb-root-password}" | base64 --decode) + +To connect to your database + + 1. Run a pod that you can use as a client: + + kubectl run {{ template "mariadb.fullname" . }}-client --rm --tty -i --image {{ template "mariadb.image" . }} --namespace {{ .Release.Namespace }} --command -- bash + + 2. To connect to master service (read/write): + + mysql -h {{ template "mariadb.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local -uroot -p {{ .Values.db.name }} + +{{- if .Values.replication.enabled }} + + 3. To connect to slave service (read-only): + + mysql -h {{ template "slave.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local -uroot -p {{ .Values.db.name }} +{{- end }} diff --git a/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/_helpers.tpl b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/_helpers.tpl new file mode 100755 index 00000000..5afe380f --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/_helpers.tpl @@ -0,0 +1,53 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "mariadb.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +*/}} +{{- define "mariadb.fullname" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "master.fullname" -}} +{{- if .Values.replication.enabled -}} +{{- printf "%s-%s" .Release.Name "mariadb-master" | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name "mariadb" | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + + +{{- define "slave.fullname" -}} +{{- printf "%s-%s" .Release.Name "mariadb-slave" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "mariadb.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Return the proper image name +*/}} +{{- define "mariadb.image" -}} +{{- $registryName := .Values.image.registry -}} +{{- $repositoryName := .Values.image.repository -}} +{{- $tag := .Values.image.tag | toString -}} +{{- printf "%s/%s:%s" $registryName $repositoryName $tag -}} +{{- end -}} + +{{/* +Return the proper image name +*/}} +{{- define "metrics.image" -}} +{{- $registryName := .Values.metrics.image.registry -}} +{{- $repositoryName := .Values.metrics.image.repository -}} +{{- $tag := .Values.metrics.image.tag | toString -}} +{{- printf "%s/%s:%s" $registryName $repositoryName $tag -}} +{{- end -}} diff --git a/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/initialization-configmap.yaml b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/initialization-configmap.yaml new file mode 100755 index 00000000..7bb96962 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/initialization-configmap.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ template "master.fullname" . }}-init-scripts + labels: + app: {{ template "mariadb.name" . }} + component: "master" + chart: {{ template "mariadb.chart" . }} + release: {{ .Release.Name | quote }} + heritage: {{ .Release.Service | quote }} +data: +{{ (.Files.Glob "files/docker-entrypoint-initdb.d/*").AsConfig | indent 2 }} diff --git a/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/master-configmap.yaml b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/master-configmap.yaml new file mode 100755 index 00000000..880a1019 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/master-configmap.yaml @@ -0,0 +1,15 @@ +{{- if .Values.master.config }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ template "master.fullname" . }} + labels: + app: {{ template "mariadb.name" . }} + component: "master" + chart: {{ template "mariadb.chart" . }} + release: {{ .Release.Name | quote }} + heritage: {{ .Release.Service | quote }} +data: + my.cnf: |- +{{ .Values.master.config | indent 4 }} +{{- end -}} diff --git a/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/master-statefulset.yaml b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/master-statefulset.yaml new file mode 100755 index 00000000..0d74f01f --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/master-statefulset.yaml @@ -0,0 +1,187 @@ +apiVersion: apps/v1beta1 +kind: StatefulSet +metadata: + name: {{ template "master.fullname" . }} + labels: + app: "{{ template "mariadb.name" . }}" + chart: {{ template "mariadb.chart" . }} + component: "master" + release: {{ .Release.Name | quote }} + heritage: {{ .Release.Service | quote }} +spec: + serviceName: "{{ template "master.fullname" . }}" + replicas: 1 + updateStrategy: + type: RollingUpdate + template: + metadata: + labels: + app: "{{ template "mariadb.name" . }}" + component: "master" + release: "{{ .Release.Name }}" + chart: {{ template "mariadb.chart" . }} + spec: + securityContext: + runAsUser: 1001 + fsGroup: 1001 + {{- if eq .Values.master.antiAffinity "hard" }} + affinity: + podAntiAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - topologyKey: "kubernetes.io/hostname" + labelSelector: + matchLabels: + app: "{{ template "mariadb.name" . }}" + release: "{{ .Release.Name }}" + {{- else if eq .Values.master.antiAffinity "soft" }} + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 1 + podAffinityTerm: + topologyKey: kubernetes.io/hostname + labelSelector: + matchLabels: + app: "{{ template "mariadb.name" . }}" + release: "{{ .Release.Name }}" + {{- end }} + {{- if .Values.image.pullSecrets }} + imagePullSecrets: + {{- range .Values.image.pullSecrets }} + - name: {{ . }} + {{- end}} + {{- end }} + containers: + - name: "mariadb" + image: {{ template "mariadb.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy | quote }} + env: + - name: MARIADB_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "mariadb.fullname" . }} + key: mariadb-root-password + {{- if .Values.db.user }} + - name: MARIADB_USER + value: "{{ .Values.db.user }}" + - name: MARIADB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "mariadb.fullname" . }} + key: mariadb-password + {{- end }} + - name: MARIADB_DATABASE + value: "{{ .Values.db.name }}" + {{- if .Values.replication.enabled }} + - name: MARIADB_REPLICATION_MODE + value: "master" + - name: MARIADB_REPLICATION_USER + value: "{{ .Values.replication.user }}" + - name: MARIADB_REPLICATION_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "mariadb.fullname" . }} + key: mariadb-replication-password + {{- end }} + ports: + - name: mysql + containerPort: 3306 + {{- if .Values.master.livenessProbe.enabled }} + livenessProbe: + exec: + command: ["sh", "-c", "exec mysqladmin status -uroot -p$MARIADB_ROOT_PASSWORD"] + initialDelaySeconds: {{ .Values.master.livenessProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.master.livenessProbe.periodSeconds }} + timeoutSeconds: {{ .Values.master.livenessProbe.timeoutSeconds }} + successThreshold: {{ .Values.master.livenessProbe.successThreshold }} + failureThreshold: {{ .Values.master.livenessProbe.failureThreshold }} + {{- end }} + {{- if .Values.master.readinessProbe.enabled }} + readinessProbe: + exec: + command: ["sh", "-c", "exec mysqladmin status -uroot -p$MARIADB_ROOT_PASSWORD"] + initialDelaySeconds: {{ .Values.master.readinessProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.master.readinessProbe.periodSeconds }} + timeoutSeconds: {{ .Values.master.readinessProbe.timeoutSeconds }} + successThreshold: {{ .Values.master.readinessProbe.successThreshold }} + failureThreshold: {{ .Values.master.readinessProbe.failureThreshold }} + {{- end }} + resources: +{{ toYaml .Values.master.resources | indent 10 }} + volumeMounts: + - name: data + mountPath: /bitnami/mariadb + - name: custom-init-scripts + mountPath: /docker-entrypoint-initdb.d +{{- if .Values.master.config }} + - name: config + mountPath: /opt/bitnami/mariadb/conf/my.cnf + subPath: my.cnf +{{- end }} +{{- if .Values.metrics.enabled }} + - name: metrics + image: {{ template "metrics.image" . }} + imagePullPolicy: {{ .Values.metrics.image.pullPolicy | quote }} + env: + - name: MARIADB_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "mariadb.fullname" . }} + key: mariadb-root-password + command: [ 'sh', '-c', 'DATA_SOURCE_NAME="root:$MARIADB_ROOT_PASSWORD@(localhost:3306)/" /bin/mysqld_exporter' ] + ports: + - name: metrics + containerPort: 9104 + livenessProbe: + httpGet: + path: /metrics + port: metrics + initialDelaySeconds: 15 + timeoutSeconds: 5 + readinessProbe: + httpGet: + path: /metrics + port: metrics + initialDelaySeconds: 5 + timeoutSeconds: 1 + resources: +{{ toYaml .Values.metrics.resources | indent 10 }} +{{- end }} + volumes: + {{- if .Values.master.config }} + - name: config + configMap: + name: {{ template "master.fullname" . }} + {{- end }} + - name: custom-init-scripts + configMap: + name: {{ template "master.fullname" . }}-init-scripts +{{- if .Values.master.persistence.enabled }} + volumeClaimTemplates: + - metadata: + name: data + labels: + app: "{{ template "mariadb.name" . }}" + chart: {{ template "mariadb.chart" . }} + component: "master" + release: {{ .Release.Name | quote }} + heritage: {{ .Release.Service | quote }} + spec: + accessModes: + {{- range .Values.master.persistence.accessModes }} + - {{ . | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.master.persistence.size | quote }} + {{- if .Values.master.persistence.storageClass }} + {{- if (eq "-" .Values.master.persistence.storageClass) }} + storageClassName: "" + {{- else }} + storageClassName: {{ .Values.master.persistence.storageClass | quote }} + {{- end }} + {{- end }} +{{- else }} + - name: "data" + emptyDir: {} +{{- end }} diff --git a/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/master-svc.yaml b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/master-svc.yaml new file mode 100755 index 00000000..460ec328 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/master-svc.yaml @@ -0,0 +1,29 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ template "mariadb.fullname" . }} + labels: + app: "{{ template "mariadb.name" . }}" + component: "master" + chart: {{ template "mariadb.chart" . }} + release: {{ .Release.Name | quote }} + heritage: {{ .Release.Service | quote }} +{{- if .Values.metrics.enabled }} + annotations: +{{ toYaml .Values.metrics.annotations | indent 4 }} +{{- end }} +spec: + type: {{ .Values.service.type }} + ports: + - name: mysql + port: {{ .Values.service.port }} + targetPort: mysql +{{- if .Values.metrics.enabled }} + - name: metrics + port: 9104 + targetPort: metrics +{{- end }} + selector: + app: "{{ template "mariadb.name" . }}" + component: "master" + release: "{{ .Release.Name }}" diff --git a/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/secrets.yaml b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/secrets.yaml new file mode 100755 index 00000000..17999d60 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/secrets.yaml @@ -0,0 +1,38 @@ +{{- if (not .Values.rootUser.existingSecret) -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ template "mariadb.fullname" . }} + labels: + app: "{{ template "mariadb.name" . }}" + chart: {{ template "mariadb.chart" . }} + release: {{ .Release.Name | quote }} + heritage: {{ .Release.Service | quote }} +type: Opaque +data: + {{- if .Values.rootUser.password }} + mariadb-root-password: "{{ .Values.rootUser.password | b64enc }}" + {{- else if (not .Values.rootUser.forcePassword) }} + mariadb-root-password: "{{ randAlphaNum 10 | b64enc }}" + {{ else }} + mariadb-root-password: {{ required "A MariaDB Root Password is required!" .Values.rootUser.password }} + {{- end }} + {{- if .Values.db.user }} + {{- if .Values.db.password }} + mariadb-password: "{{ .Values.db.password | b64enc }}" + {{- else if (not .Values.db.forcePassword) }} + mariadb-password: "{{ randAlphaNum 10 | b64enc }}" + {{- else }} + mariadb-password: {{ required "A MariaDB Database Password is required!" .Values.db.password }} + {{- end }} + {{- end }} + {{- if .Values.replication.enabled }} + {{- if .Values.replication.password }} + mariadb-replication-password: "{{ .Values.replication.password | b64enc }}" + {{- else if (not .Values.replication.forcePassword) }} + mariadb-replication-password: "{{ randAlphaNum 10 | b64enc }}" + {{- else }} + mariadb-replication-password: {{ required "A MariaDB Replication Password is required!" .Values.replication.password }} + {{- end }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/slave-configmap.yaml b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/slave-configmap.yaml new file mode 100755 index 00000000..056cf5c0 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/slave-configmap.yaml @@ -0,0 +1,15 @@ +{{- if and .Values.replication.enabled .Values.slave.config }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ template "slave.fullname" . }} + labels: + app: {{ template "mariadb.name" . }} + component: "slave" + chart: {{ template "mariadb.chart" . }} + release: {{ .Release.Name | quote }} + heritage: {{ .Release.Service | quote }} +data: + my.cnf: |- +{{ .Values.slave.config | indent 4 }} +{{- end }} diff --git a/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/slave-statefulset.yaml b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/slave-statefulset.yaml new file mode 100755 index 00000000..aa67d4a7 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/slave-statefulset.yaml @@ -0,0 +1,193 @@ +{{- if .Values.replication.enabled }} +apiVersion: apps/v1beta1 +kind: StatefulSet +metadata: + name: {{ template "slave.fullname" . }} + labels: + app: "{{ template "mariadb.name" . }}" + chart: {{ template "mariadb.chart" . }} + component: "slave" + release: {{ .Release.Name | quote }} + heritage: {{ .Release.Service | quote }} +spec: + serviceName: "{{ template "slave.fullname" . }}" + replicas: {{ .Values.slave.replicas }} + updateStrategy: + type: RollingUpdate + template: + metadata: + labels: + app: "{{ template "mariadb.name" . }}" + component: "slave" + release: "{{ .Release.Name }}" + chart: {{ template "mariadb.chart" . }} + spec: + securityContext: + runAsUser: 1001 + fsGroup: 1001 + {{- if eq .Values.slave.antiAffinity "hard" }} + affinity: + podAntiAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - topologyKey: "kubernetes.io/hostname" + labelSelector: + matchLabels: + app: "{{ template "mariadb.name" . }}" + release: "{{ .Release.Name }}" + {{- else if eq .Values.slave.antiAffinity "soft" }} + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 1 + podAffinityTerm: + topologyKey: kubernetes.io/hostname + labelSelector: + matchLabels: + app: "{{ template "mariadb.name" . }}" + release: "{{ .Release.Name }}" + {{- end }} + {{- if .Values.image.pullSecrets }} + imagePullSecrets: + {{- range .Values.image.pullSecrets }} + - name: {{ . }} + {{- end}} + {{- end }} + containers: + - name: "mariadb" + image: {{ template "mariadb.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy | quote }} + env: + - name: MARIADB_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "mariadb.fullname" . }} + key: mariadb-root-password + {{- if .Values.db.user }} + - name: MARIADB_USER + value: "{{ .Values.db.user }}" + - name: MARIADB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "mariadb.fullname" . }} + key: mariadb-password + {{- end }} + - name: MARIADB_DATABASE + value: "{{ .Values.db.name }}" + - name: MARIADB_REPLICATION_MODE + value: "slave" + - name: MARIADB_MASTER_HOST + value: {{ template "mariadb.fullname" . }} + - name: MARIADB_MASTER_PORT + value: "3306" + - name: MARIADB_MASTER_USER + value: "root" + - name: MARIADB_MASTER_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "mariadb.fullname" . }} + key: mariadb-root-password + - name: MARIADB_REPLICATION_USER + value: "{{ .Values.replication.user }}" + - name: MARIADB_REPLICATION_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "mariadb.fullname" . }} + key: mariadb-replication-password + ports: + - name: mysql + containerPort: 3306 + {{- if .Values.slave.livenessProbe.enabled }} + livenessProbe: + exec: + command: ["sh", "-c", "exec mysqladmin status -uroot -p$MARIADB_ROOT_PASSWORD"] + initialDelaySeconds: {{ .Values.slave.livenessProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.slave.livenessProbe.periodSeconds }} + timeoutSeconds: {{ .Values.slave.livenessProbe.timeoutSeconds }} + successThreshold: {{ .Values.slave.livenessProbe.successThreshold }} + failureThreshold: {{ .Values.slave.livenessProbe.failureThreshold }} + {{- end }} + {{- if .Values.slave.readinessProbe.enabled }} + readinessProbe: + exec: + command: ["sh", "-c", "exec mysqladmin status -uroot -p$MARIADB_ROOT_PASSWORD"] + initialDelaySeconds: {{ .Values.slave.readinessProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.slave.readinessProbe.periodSeconds }} + timeoutSeconds: {{ .Values.slave.readinessProbe.timeoutSeconds }} + successThreshold: {{ .Values.slave.readinessProbe.successThreshold }} + failureThreshold: {{ .Values.slave.readinessProbe.failureThreshold }} + {{- end }} + resources: +{{ toYaml .Values.slave.resources | indent 10 }} + volumeMounts: + - name: data + mountPath: /bitnami/mariadb +{{- if .Values.slave.config }} + - name: config + mountPath: /opt/bitnami/mariadb/conf/my.cnf + subPath: my.cnf +{{- end }} +{{- if .Values.metrics.enabled }} + - name: metrics + image: {{ template "metrics.image" . }} + imagePullPolicy: {{ .Values.metrics.image.pullPolicy | quote }} + env: + - name: MARIADB_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "mariadb.fullname" . }} + key: mariadb-root-password + command: [ 'sh', '-c', 'DATA_SOURCE_NAME="root:$MARIADB_ROOT_PASSWORD@(localhost:3306)/" /bin/mysqld_exporter' ] + ports: + - name: metrics + containerPort: 9104 + livenessProbe: + httpGet: + path: /metrics + port: metrics + initialDelaySeconds: 15 + timeoutSeconds: 5 + readinessProbe: + httpGet: + path: /metrics + port: metrics + initialDelaySeconds: 5 + timeoutSeconds: 1 + resources: +{{ toYaml .Values.metrics.resources | indent 10 }} +{{- end }} + volumes: + {{- if .Values.slave.config }} + - name: config + configMap: + name: {{ template "slave.fullname" . }} + {{- end }} +{{- if .Values.slave.persistence.enabled }} + volumeClaimTemplates: + - metadata: + name: data + labels: + app: "{{ template "mariadb.name" . }}" + chart: {{ template "mariadb.chart" . }} + component: "slave" + release: {{ .Release.Name | quote }} + heritage: {{ .Release.Service | quote }} + spec: + accessModes: + {{- range .Values.slave.persistence.accessModes }} + - {{ . | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.slave.persistence.size | quote }} + {{- if .Values.slave.persistence.storageClass }} + {{- if (eq "-" .Values.slave.persistence.storageClass) }} + storageClassName: "" + {{- else }} + storageClassName: {{ .Values.slave.persistence.storageClass | quote }} + {{- end }} + {{- end }} +{{- else }} + - name: "data" + emptyDir: {} +{{- end }} +{{- end }} diff --git a/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/slave-svc.yaml b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/slave-svc.yaml new file mode 100755 index 00000000..fa551371 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/slave-svc.yaml @@ -0,0 +1,31 @@ +{{- if .Values.replication.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ template "slave.fullname" . }} + labels: + app: "{{ template "mariadb.name" . }}" + chart: {{ template "mariadb.chart" . }} + component: "slave" + release: {{ .Release.Name | quote }} + heritage: {{ .Release.Service | quote }} +{{- if .Values.metrics.enabled }} + annotations: +{{ toYaml .Values.metrics.annotations | indent 4 }} +{{- end }} +spec: + type: {{ .Values.service.type }} + ports: + - name: mysql + port: {{ .Values.service.port }} + targetPort: mysql +{{- if .Values.metrics.enabled }} + - name: metrics + port: 9104 + targetPort: metrics +{{- end }} + selector: + app: "{{ template "mariadb.name" . }}" + component: "slave" + release: "{{ .Release.Name }}" +{{- end }} \ No newline at end of file diff --git a/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/test-runner.yaml b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/test-runner.yaml new file mode 100755 index 00000000..99a85d4a --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/test-runner.yaml @@ -0,0 +1,44 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ template "mariadb.fullname" . }}-test-{{ randAlphaNum 5 | lower }}" + annotations: + "helm.sh/hook": test-success +spec: + initContainers: + - name: "test-framework" + image: "dduportal/bats:0.4.0" + command: + - "bash" + - "-c" + - | + set -ex + # copy bats to tools dir + cp -R /usr/local/libexec/ /tools/bats/ + volumeMounts: + - mountPath: /tools + name: tools + containers: + - name: mariadb-test + image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy | quote }} + command: ["/tools/bats/bats", "-t", "/tests/run.sh"] + env: + - name: MARIADB_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: {{ template "mariadb.fullname" . }} + key: mariadb-root-password + volumeMounts: + - mountPath: /tests + name: tests + readOnly: true + - mountPath: /tools + name: tools + volumes: + - name: tests + configMap: + name: {{ template "mariadb.fullname" . }}-tests + - name: tools + emptyDir: {} + restartPolicy: Never diff --git a/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/tests.yaml b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/tests.yaml new file mode 100755 index 00000000..957f3fd1 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/templates/tests.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ template "mariadb.fullname" . }}-tests +data: + run.sh: |- + @test "Testing MariaDB is accessible" { + mysql -h {{ template "mariadb.fullname" . }} -uroot -p$MARIADB_ROOT_PASSWORD -e 'show databases;' + } diff --git a/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/values.yaml b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/values.yaml new file mode 100755 index 00000000..ce2414e9 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/values.yaml @@ -0,0 +1,233 @@ +## Bitnami MariaDB image +## ref: https://hub.docker.com/r/bitnami/mariadb/tags/ +## +image: + registry: docker.io + repository: bitnami/mariadb + tag: 10.1.34-debian-9 + ## Specify a imagePullPolicy + ## Defaults to 'Always' if image tag is 'latest', else set to 'IfNotPresent' + ## ref: http://kubernetes.io/docs/user-guide/images/#pre-pulling-images + ## + pullPolicy: IfNotPresent + ## Optionally specify an array of imagePullSecrets. + ## Secrets must be manually created in the namespace. + ## ref: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ + ## + # pullSecrets: + # - myRegistrKeySecretName + +service: + ## Kubernetes service type + type: ClusterIP + port: 3306 + +rootUser: + ## MariaDB admin password + ## ref: https://github.com/bitnami/bitnami-docker-mariadb#setting-the-root-password-on-first-run + ## + password: + ## Use existing secret (ignores root, db and replication passwords) + # existingSecret: + ## + ## Option to force users to specify a password. That is required for 'helm upgrade' to work properly. + ## If it is not force, a random password will be generated. + forcePassword: false + +db: + ## MariaDB username and password + ## ref: https://github.com/bitnami/bitnami-docker-mariadb#creating-a-database-user-on-first-run + ## + user: + password: + ## Password is ignored if existingSecret is specified. + ## Database to create + ## ref: https://github.com/bitnami/bitnami-docker-mariadb#creating-a-database-on-first-run + ## + name: my_database + ## Option to force users to specify a password. That is required for 'helm upgrade' to work properly. + ## If it is not force, a random password will be generated. + forcePassword: false + +replication: + ## Enable replication. This enables the creation of replicas of MariaDB. If false, only a + ## master deployment would be created + enabled: true + ## + ## MariaDB replication user + ## ref: https://github.com/bitnami/bitnami-docker-mariadb#setting-up-a-replication-cluster + ## + user: replicator + ## MariaDB replication user password + ## ref: https://github.com/bitnami/bitnami-docker-mariadb#setting-up-a-replication-cluster + ## + password: + ## Password is ignored if existingSecret is specified. + ## + ## Option to force users to specify a password. That is required for 'helm upgrade' to work properly. + ## If it is not force, a random password will be generated. + forcePassword: false + +master: + antiAffinity: soft + ## Enable persistence using Persistent Volume Claims + ## ref: http://kubernetes.io/docs/user-guide/persistent-volumes/ + ## + persistence: + ## If true, use a Persistent Volume Claim, If false, use emptyDir + ## + enabled: true + ## Persistent Volume Storage Class + ## If defined, storageClassName: + ## If set to "-", storageClassName: "", which disables dynamic provisioning + ## If undefined (the default) or set to null, no storageClassName spec is + ## set, choosing the default provisioner. (gp2 on AWS, standard on + ## GKE, AWS & OpenStack) + ## + # storageClass: "-" + ## Persistent Volume Claim annotations + ## + annotations: + ## Persistent Volume Access Mode + ## + accessModes: + - ReadWriteOnce + ## Persistent Volume size + ## + size: 8Gi + ## + + ## Configure MySQL with a custom my.cnf file + ## ref: https://mysql.com/kb/en/mysql/configuring-mysql-with-mycnf/#example-of-configuration-file + ## + config: |- + [mysqld] + skip-name-resolve + explicit_defaults_for_timestamp + basedir=/opt/bitnami/mariadb + port=3306 + socket=/opt/bitnami/mariadb/tmp/mysql.sock + tmpdir=/opt/bitnami/mariadb/tmp + max_allowed_packet=16M + bind-address=0.0.0.0 + pid-file=/opt/bitnami/mariadb/tmp/mysqld.pid + log-error=/opt/bitnami/mariadb/logs/mysqld.log + character-set-server=UTF8 + collation-server=utf8_general_ci + + [client] + port=3306 + socket=/opt/bitnami/mariadb/tmp/mysql.sock + default-character-set=UTF8 + + [manager] + port=3306 + socket=/opt/bitnami/mariadb/tmp/mysql.sock + pid-file=/opt/bitnami/mariadb/tmp/mysqld.pid + + ## Configure master resource requests and limits + ## ref: http://kubernetes.io/docs/user-guide/compute-resources/ + ## + resources: {} + livenessProbe: + enabled: true + ## + ## Initializing the database could take some time + initialDelaySeconds: 120 + ## + ## Default Kubernetes values + periodSeconds: 10 + timeoutSeconds: 1 + successThreshold: 1 + failureThreshold: 3 + readinessProbe: + enabled: true + initialDelaySeconds: 15 + ## + ## Default Kubernetes values + periodSeconds: 10 + timeoutSeconds: 1 + successThreshold: 1 + failureThreshold: 3 + +slave: + replicas: 1 + antiAffinity: soft + persistence: + ## If true, use a Persistent Volume Claim, If false, use emptyDir + ## + enabled: true + # storageClass: "-" + annotations: + accessModes: + - ReadWriteOnce + ## Persistent Volume size + ## + size: 8Gi + ## + + ## Configure MySQL slave with a custom my.cnf file + ## ref: https://mysql.com/kb/en/mysql/configuring-mysql-with-mycnf/#example-of-configuration-file + ## + config: |- + [mysqld] + skip-name-resolve + explicit_defaults_for_timestamp + basedir=/opt/bitnami/mariadb + port=3306 + socket=/opt/bitnami/mariadb/tmp/mysql.sock + tmpdir=/opt/bitnami/mariadb/tmp + max_allowed_packet=16M + bind-address=0.0.0.0 + pid-file=/opt/bitnami/mariadb/tmp/mysqld.pid + log-error=/opt/bitnami/mariadb/logs/mysqld.log + character-set-server=UTF8 + collation-server=utf8_general_ci + + [client] + port=3306 + socket=/opt/bitnami/mariadb/tmp/mysql.sock + default-character-set=UTF8 + + [manager] + port=3306 + socket=/opt/bitnami/mariadb/tmp/mysql.sock + pid-file=/opt/bitnami/mariadb/tmp/mysqld.pid + + ## + ## Configure slave resource requests and limits + ## ref: http://kubernetes.io/docs/user-guide/compute-resources/ + ## + resources: {} + livenessProbe: + enabled: true + ## + ## Initializing the database could take some time + initialDelaySeconds: 120 + ## + ## Default Kubernetes values + periodSeconds: 10 + timeoutSeconds: 1 + successThreshold: 1 + failureThreshold: 3 + readinessProbe: + enabled: true + initialDelaySeconds: 15 + ## + ## Default Kubernetes values + periodSeconds: 10 + timeoutSeconds: 1 + successThreshold: 1 + failureThreshold: 3 + +metrics: + enabled: false + image: + registry: docker.io + repository: prom/mysqld-exporter + tag: v0.10.0 + pullPolicy: IfNotPresent + resources: {} + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "9104" diff --git a/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/requirements.lock b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/requirements.lock new file mode 100755 index 00000000..dcda2b14 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/requirements.lock @@ -0,0 +1,6 @@ +dependencies: +- name: mariadb + repository: https://charts.helm.sh/stable/ + version: 4.3.1 +digest: sha256:82a0e5374376169d2ecf7d452c18a2ed93507f5d17c3393a1457f9ffad7e9b26 +generated: 2018-08-02T22:07:51.905271776Z diff --git a/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/requirements.yaml b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/requirements.yaml new file mode 100755 index 00000000..fef7d0b7 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/requirements.yaml @@ -0,0 +1,7 @@ +dependencies: +- name: mariadb + version: 4.x.x + repository: https://charts.helm.sh/stable/ + condition: mariadb.enabled + tags: + - wordpress-database diff --git a/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/templates/NOTES.txt b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/templates/NOTES.txt new file mode 100755 index 00000000..75ed9b64 --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/templates/NOTES.txt @@ -0,0 +1 @@ +Placeholder. diff --git a/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/values.yaml b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/values.yaml new file mode 100755 index 00000000..3cb66daf --- /dev/null +++ b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/values.yaml @@ -0,0 +1,254 @@ +## Bitnami WordPress image version +## ref: https://hub.docker.com/r/bitnami/wordpress/tags/ +## +image: + registry: docker.io + repository: bitnami/wordpress + tag: 4.9.8-debian-9 + ## Specify a imagePullPolicy + ## Defaults to 'Always' if image tag is 'latest', else set to 'IfNotPresent' + ## ref: http://kubernetes.io/docs/user-guide/images/#pre-pulling-images + ## + pullPolicy: IfNotPresent + ## Optionally specify an array of imagePullSecrets. + ## Secrets must be manually created in the namespace. + ## ref: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ + ## + # pullSecrets: + # - myRegistrKeySecretName + +## User of the application +## ref: https://github.com/bitnami/bitnami-docker-wordpress#environment-variables +## +wordpressUsername: user + +## Application password +## Defaults to a random 10-character alphanumeric string if not set +## ref: https://github.com/bitnami/bitnami-docker-wordpress#environment-variables +## +# wordpressPassword: + +## Admin email +## ref: https://github.com/bitnami/bitnami-docker-wordpress#environment-variables +## +wordpressEmail: user@example.com + +## First name +## ref: https://github.com/bitnami/bitnami-docker-wordpress#environment-variables +## +wordpressFirstName: FirstName + +## Last name +## ref: https://github.com/bitnami/bitnami-docker-wordpress#environment-variables +## +wordpressLastName: LastName + +## Blog name +## ref: https://github.com/bitnami/bitnami-docker-wordpress#environment-variables +## +wordpressBlogName: User's Blog! + +## Table prefix +## ref: https://github.com/bitnami/bitnami-docker-wordpress#environment-variables +## +wordpressTablePrefix: wp_ + +## Set to `yes` to allow the container to be started with blank passwords +## ref: https://github.com/bitnami/bitnami-docker-wordpress#environment-variables +allowEmptyPassword: yes + +## SMTP mail delivery configuration +## ref: https://github.com/bitnami/bitnami-docker-wordpress/#smtp-configuration +## +# smtpHost: +# smtpPort: +# smtpUser: +# smtpPassword: +# smtpUsername: +# smtpProtocol: + +replicaCount: 1 + +externalDatabase: +## All of these values are only used when mariadb.enabled is set to false + ## Database host + host: localhost + + ## non-root Username for Wordpress Database + user: bn_wordpress + + ## Database password + password: "" + + ## Database name + database: bitnami_wordpress + + ## Database port number + port: 3306 + +## +## MariaDB chart configuration +## +mariadb: + ## Whether to deploy a mariadb server to satisfy the applications database requirements. To use an external database set this to false and configure the externalDatabase parameters + enabled: true + ## Disable MariaDB replication + replication: + enabled: false + + ## Create a database and a database user + ## ref: https://github.com/bitnami/bitnami-docker-mariadb/blob/master/README.md#creating-a-database-user-on-first-run + ## + db: + name: bitnami_wordpress + user: bn_wordpress + ## If the password is not specified, mariadb will generates a random password + ## + # password: + + ## MariaDB admin password + ## ref: https://github.com/bitnami/bitnami-docker-mariadb/blob/master/README.md#setting-the-root-password-on-first-run + ## + # rootUser: + # password: + + ## Enable persistence using Persistent Volume Claims + ## ref: http://kubernetes.io/docs/user-guide/persistent-volumes/ + ## + master: + persistence: + enabled: true + ## mariadb data Persistent Volume Storage Class + ## If defined, storageClassName: + ## If set to "-", storageClassName: "", which disables dynamic provisioning + ## If undefined (the default) or set to null, no storageClassName spec is + ## set, choosing the default provisioner. (gp2 on AWS, standard on + ## GKE, AWS & OpenStack) + ## + # storageClass: "-" + accessMode: ReadWriteOnce + size: 8Gi + +## Kubernetes configuration +## For minikube, set this to NodePort, elsewhere use LoadBalancer or ClusterIP +## +serviceType: LoadBalancer +## +## serviceType: NodePort +## nodePorts: +## http: +## https: +nodePorts: + http: "" + https: "" +## Enable client source IP preservation +## ref http://kubernetes.io/docs/tasks/access-application-cluster/create-external-load-balancer/#preserving-the-client-source-ip +## +serviceExternalTrafficPolicy: Cluster + +## Allow health checks to be pointed at the https port +healthcheckHttps: false + +## Configure extra options for liveness and readiness probes +## ref: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-probes/#configure-probes) +livenessProbe: + initialDelaySeconds: 120 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + successThreshold: 1 +readinessProbe: + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + successThreshold: 1 + +## Configure the ingress resource that allows you to access the +## Wordpress installation. Set up the URL +## ref: http://kubernetes.io/docs/user-guide/ingress/ +## +ingress: + ## Set to true to enable ingress record generation + enabled: false + + ## The list of hostnames to be covered with this ingress record. + ## Most likely this will be just one host, but in the event more hosts are needed, this is an array + hosts: + - name: wordpress.local + + ## Set this to true in order to enable TLS on the ingress record + ## A side effect of this will be that the backend wordpress service will be connected at port 443 + tls: false + + ## If TLS is set to true, you must declare what secret will store the key/certificate for TLS + tlsSecret: wordpress.local-tls + + ## Ingress annotations done as key:value pairs + ## If you're using kube-lego, you will want to add: + ## kubernetes.io/tls-acme: true + ## + ## For a full list of possible ingress annotations, please see + ## ref: https://github.com/kubernetes/ingress-nginx/blob/master/docs/annotations.md + ## + ## If tls is set to true, annotation ingress.kubernetes.io/secure-backends: "true" will automatically be set + annotations: + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: true + + secrets: + ## If you're providing your own certificates, please use this to add the certificates as secrets + ## key and certificate should start with -----BEGIN CERTIFICATE----- or + ## -----BEGIN RSA PRIVATE KEY----- + ## + ## name should line up with a tlsSecret set further up + ## If you're using kube-lego, this is unneeded, as it will create the secret for you if it is not set + ## + ## It is also possible to create and manage the certificates outside of this helm chart + ## Please see README.md for more information + # - name: wordpress.local-tls + # key: + # certificate: + +## Enable persistence using Persistent Volume Claims +## ref: http://kubernetes.io/docs/user-guide/persistent-volumes/ +## +persistence: + enabled: true + ## wordpress data Persistent Volume Storage Class + ## If defined, storageClassName: + ## If set to "-", storageClassName: "", which disables dynamic provisioning + ## If undefined (the default) or set to null, no storageClassName spec is + ## set, choosing the default provisioner. (gp2 on AWS, standard on + ## GKE, AWS & OpenStack) + ## + # storageClass: "-" + ## + ## If you want to reuse an existing claim, you can pass the name of the PVC using + ## the existingClaim variable + # existingClaim: your-claim + accessMode: ReadWriteOnce + size: 10Gi + +## Configure resource requests and limits +## ref: http://kubernetes.io/docs/user-guide/compute-resources/ +## +resources: + requests: + memory: 512Mi + cpu: 300m + +## Node labels for pod assignment +## Ref: https://kubernetes.io/docs/user-guide/node-selection/ +## +nodeSelector: {} + +## Tolerations for pod assignment +## Ref: https://kubernetes.io/docs/concepts/configuration/taint-and-toleration/ +## +tolerations: [] + +## Affinity for pod assignment +## Ref: https://kubernetes.io/docs/concepts/configuration/assign-pod-node/#affinity-and-anti-affinity +## +affinity: {} diff --git a/pkg/action/testdata/charts/compressedchart-0.1.0.tar.gz b/pkg/action/testdata/charts/compressedchart-0.1.0.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..3c9c24d76063d6a904405b2d6a7a84cb087f1ce4 GIT binary patch literal 477 zcmV<30V4h%iwG0|00000|0w_~VMtOiV@ORlOnEsqVl!4SWK%V1T2nbTPgYhoO;>Dc zVQyr3R8em|NM&qo0PL4vi_<_5!26s}F{UjHhD>x0Xejw~|-egB1QU=&JEdrHEIt>CEtv%dd}n=<=92zSKn;o(8OM@#S> zYFc5(0#_R!xxRXQ%zfa$rtiOMiLGgzk94**j`?3M7Jt0|r`i8O7{f;tq39Bbhr@%1 zO-l}zo#EQJ1_D-Jv7w}jF??!Gg4BiJqa;WzF+;Dc zVQyr3R8em|NM&qo0PL4vi_<_5!26s}F{UjHhD>x0Xejw~|-egB1QU=&JEdrHEIt>CEtv%dd}n=<=92zSKn;o(8OM@#S> zYFc5(0#_R!xxRXQ%zfa$rtiOMiLGgzk94**j`?3M7Jt0|r`i8O7{f;tq39Bbhr@%1 zO-l}zo#EQJ1_D-Jv7w}jF??!Gg4BiJqa;WzF+;Dc zVQyr3R8em|NM&qo0PL4vi_<_5!26s}F)eG5__n?E62T&$9nR zaPZh}uYVQ7^}*#!N0u3azW+itFbbuoJtg79R&dn+S>OM~O_}{4ggavP@bIACqb2wb zHLb8?fvb&=Twgst=05OW)AwJs#MU&^XY$NVoBi$C7~)9n8sjNv1SP;?2z!{Nch zrX>f<&Tws90|BeA*icf%7(TToLFz*AQ4*wspoCYeko Tb2>i)00960YK^g$02BZKS`FmP literal 0 HcmV?d00001 diff --git a/pkg/action/testdata/charts/compressedchart-0.3.0.tgz b/pkg/action/testdata/charts/compressedchart-0.3.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..051bd6fd9a030a3f4327e8002a8912c1f5f68050 GIT binary patch literal 477 zcmV<30V4h%iwG0|00000|0w_~VMtOiV@ORlOnEsqVl!4SWK%V1T2nbTPgYhoO;>Dc zVQyr3R8em|NM&qo0PL4vi_<_5!26s}Fj!(#55d%N%6r{|dRMblL`R$bVgxL;q*9Y4-md z>^-)d>tBU%y?6Q2k!8-o(0?Ht7=_Z)o|156D>&%?bm;&5rp*4Ig*#&Q@bIACqb2wb z4K1-}fvdHT++00A=05OWGxT3|#MZRVM>^RWhx{++^FQAIRrdcZjNv1SP;?2z!~Vg; zx+4dR-f&}F3jyn|*iur(7(R6-LFz;BQ4*w%n4x9A0E<$0#EPK51s@!5z`Na*+mIko1U8OTq2AtqxfdU)P_4<|CYeko Tb38u+00960Q~zm}02BZK0h8&p literal 0 HcmV?d00001 diff --git a/pkg/action/testdata/charts/compressedchart-with-hyphens-0.1.0.tgz b/pkg/action/testdata/charts/compressedchart-with-hyphens-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..379210a92c1795429d2c4baae389f1e971f26824 GIT binary patch literal 548 zcmV+<0^9u`iwG0|00000|0w_~VMtOiV@ORlOnEsqVl!4SWK%V1T2nbTPgYhoO;>Dc zVQyr3R8em|NM&qo0PL4fi`y^|#dG$jc->rDXjD0A64=|)92WW)wwIoYVoz*QSrU?* zG;H^~7dcDXrjTqgP3fZFMMBsbi zPWu0B_M89nr2n%p#E0nPPIpGV%QZGNX)If*N~tSYQG5|qH0t|nfN!leE_nEwltQJ< z5{(E&Ep_!Aj+6*;9W6i9KdlR0WlY0RR8Xa#gbc6aWCh@%&~0 literal 0 HcmV?d00001 diff --git a/pkg/action/testdata/charts/corrupted-compressed-chart.tgz b/pkg/action/testdata/charts/corrupted-compressed-chart.tgz new file mode 100644 index 00000000..e69de29b diff --git a/pkg/action/testdata/charts/decompressedchart/Chart.yaml b/pkg/action/testdata/charts/decompressedchart/Chart.yaml new file mode 100644 index 00000000..92ba4d88 --- /dev/null +++ b/pkg/action/testdata/charts/decompressedchart/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: A Helm chart for Kubernetes +name: decompressedchart +version: 0.1.0 diff --git a/pkg/action/testdata/charts/decompressedchart/values.yaml b/pkg/action/testdata/charts/decompressedchart/values.yaml new file mode 100644 index 00000000..a940d1fd --- /dev/null +++ b/pkg/action/testdata/charts/decompressedchart/values.yaml @@ -0,0 +1,4 @@ +# Default values for decompressedchart. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. + name: my-decompressed-chart diff --git a/pkg/action/testdata/charts/multiplecharts-lint-chart-1/Chart.yaml b/pkg/action/testdata/charts/multiplecharts-lint-chart-1/Chart.yaml new file mode 100644 index 00000000..e33c97e8 --- /dev/null +++ b/pkg/action/testdata/charts/multiplecharts-lint-chart-1/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +name: multiplecharts-lint-chart-1 +version: "1" +icon: "" \ No newline at end of file diff --git a/pkg/action/testdata/charts/multiplecharts-lint-chart-1/templates/configmap.yaml b/pkg/action/testdata/charts/multiplecharts-lint-chart-1/templates/configmap.yaml new file mode 100644 index 00000000..88ebf246 --- /dev/null +++ b/pkg/action/testdata/charts/multiplecharts-lint-chart-1/templates/configmap.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +metadata: + name: multicharttest-chart1-configmap +data: + dat: | + {{ .Values.config | indent 4 }} diff --git a/pkg/action/testdata/charts/multiplecharts-lint-chart-1/values.yaml b/pkg/action/testdata/charts/multiplecharts-lint-chart-1/values.yaml new file mode 100644 index 00000000..aafb09e4 --- /dev/null +++ b/pkg/action/testdata/charts/multiplecharts-lint-chart-1/values.yaml @@ -0,0 +1 @@ +config: "Test" \ No newline at end of file diff --git a/pkg/action/testdata/charts/multiplecharts-lint-chart-2/Chart.yaml b/pkg/action/testdata/charts/multiplecharts-lint-chart-2/Chart.yaml new file mode 100644 index 00000000..b27de275 --- /dev/null +++ b/pkg/action/testdata/charts/multiplecharts-lint-chart-2/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +name: multiplecharts-lint-chart-2 +version: "1" +icon: "" \ No newline at end of file diff --git a/pkg/action/testdata/charts/multiplecharts-lint-chart-2/templates/configmap.yaml b/pkg/action/testdata/charts/multiplecharts-lint-chart-2/templates/configmap.yaml new file mode 100644 index 00000000..8484bfe6 --- /dev/null +++ b/pkg/action/testdata/charts/multiplecharts-lint-chart-2/templates/configmap.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +metadata: + name: multicharttest-chart2-configmap +data: + {{ toYaml .Values.config | indent 4 }} diff --git a/pkg/action/testdata/charts/multiplecharts-lint-chart-2/values.yaml b/pkg/action/testdata/charts/multiplecharts-lint-chart-2/values.yaml new file mode 100644 index 00000000..9139f486 --- /dev/null +++ b/pkg/action/testdata/charts/multiplecharts-lint-chart-2/values.yaml @@ -0,0 +1,2 @@ +config: + test: "Test" \ No newline at end of file diff --git a/pkg/action/testdata/charts/pre-release-chart-0.1.0-alpha.tgz b/pkg/action/testdata/charts/pre-release-chart-0.1.0-alpha.tgz new file mode 100644 index 0000000000000000000000000000000000000000..5d5770fed227de5f776eb9080eb377aad6293c1b GIT binary patch literal 355 zcmV-p0i6CHiwG0|00000|0w_~VMtOiV@ORlOnEsqVl!4SWK%V1T2nbTPgYhoO;>Dc zVQyr3R8em|NM&qo0PK{zYV$x4g;nb*23$-3NWU)N&csC^NtY(&SQ_DlU9Ff|8T|G^ za%V#lh=@thS7=o1ZFbK&gK#2jnUs^}ND}@%OyBfO&PEG?h*+29ToLiQVwP7?_P@yL zI1Ma8b~7i_FmV`{SsQ%M$8b5@3*jnN45@T9YE&=p2h=9&w(}W z$?+C$9uN+r2y|ofk(Ta0{KWJPp`$V@VjMq`0UE1~Q@$ zJRGL~X*n=`@No8{Kwvjm3an`imvnLG 0 { + rel.Info.Description = u.Description + } else { + rel.Info.Description = "Uninstallation complete" + } + + if !u.KeepHistory { + u.cfg.Log("purge requested for %s", name) + err := u.purgeReleases(rels...) + if err != nil { + errs = append(errs, errors.Wrap(err, "uninstall: Failed to purge the release")) + } + + // Return the errors that occurred while deleting the release, if any + if len(errs) > 0 { + return res, errors.Errorf("uninstallation completed with %d error(s): %s", len(errs), joinErrors(errs)) + } + + return res, nil + } + + if err := u.cfg.Releases.Update(rel); err != nil { + u.cfg.Log("uninstall: Failed to store updated release: %s", err) + } + + if len(errs) > 0 { + return res, errors.Errorf("uninstallation completed with %d error(s): %s", len(errs), joinErrors(errs)) + } + return res, nil +} + +func (u *Uninstall) purgeReleases(rels ...*release.Release) error { + for _, rel := range rels { + if _, err := u.cfg.Releases.Delete(rel.Name, rel.Version); err != nil { + return err + } + } + return nil +} + +func joinErrors(errs []error) string { + es := make([]string, 0, len(errs)) + for _, e := range errs { + es = append(es, e.Error()) + } + return strings.Join(es, "; ") +} + +// deleteRelease deletes the release and returns list of delete resources and manifests that were kept in the deletion process +func (u *Uninstall) deleteRelease(rel *release.Release) (kube.ResourceList, string, []error) { + var errs []error + caps, err := u.cfg.getCapabilities() + if err != nil { + return nil, rel.Manifest, []error{errors.Wrap(err, "could not get apiVersions from Kubernetes")} + } + + manifests := releaseutil.SplitManifests(rel.Manifest) + _, files, err := releaseutil.SortManifests(manifests, caps.APIVersions, releaseutil.UninstallOrder) + if err != nil { + // We could instead just delete everything in no particular order. + // FIXME: One way to delete at this point would be to try a label-based + // deletion. The problem with this is that we could get a false positive + // and delete something that was not legitimately part of this release. + return nil, rel.Manifest, []error{errors.Wrap(err, "corrupted release record. You must manually delete the resources")} + } + + filesToKeep, filesToDelete := filterManifestsToKeep(files) + var kept string + for _, f := range filesToKeep { + kept += "[" + f.Head.Kind + "] " + f.Head.Metadata.Name + "\n" + } + + var builder strings.Builder + for _, file := range filesToDelete { + builder.WriteString("\n---\n" + file.Content) + } + + resources, err := u.cfg.KubeClient.Build(strings.NewReader(builder.String()), false) + if err != nil { + return nil, "", []error{errors.Wrap(err, "unable to build kubernetes objects for delete")} + } + if len(resources) > 0 { + if kubeClient, ok := u.cfg.KubeClient.(kube.InterfaceDeletionPropagation); ok { + _, errs = kubeClient.DeleteWithPropagationPolicy(resources, parseCascadingFlag(u.cfg, u.DeletionPropagation)) + return resources, kept, errs + } + _, errs = u.cfg.KubeClient.Delete(resources) + } + return resources, kept, errs +} + +func parseCascadingFlag(cfg *Configuration, cascadingFlag string) v1.DeletionPropagation { + switch cascadingFlag { + case "orphan": + return v1.DeletePropagationOrphan + case "foreground": + return v1.DeletePropagationForeground + case "background": + return v1.DeletePropagationBackground + default: + cfg.Log("uninstall: given cascade value: %s, defaulting to delete propagation background", cascadingFlag) + return v1.DeletePropagationBackground + } +} diff --git a/pkg/action/uninstall_test.go b/pkg/action/uninstall_test.go new file mode 100644 index 00000000..869ffb8c --- /dev/null +++ b/pkg/action/uninstall_test.go @@ -0,0 +1,140 @@ +/* +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 ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + kubefake "helm.sh/helm/v3/pkg/kube/fake" + "helm.sh/helm/v3/pkg/release" +) + +func uninstallAction(t *testing.T) *Uninstall { + config := actionConfigFixture(t) + unAction := NewUninstall(config) + return unAction +} + +func TestUninstallRelease_ignoreNotFound(t *testing.T) { + unAction := uninstallAction(t) + unAction.DryRun = false + unAction.IgnoreNotFound = true + + is := assert.New(t) + res, err := unAction.Run("release-non-exist") + is.Nil(res) + is.NoError(err) +} + +func TestUninstallRelease_deleteRelease(t *testing.T) { + is := assert.New(t) + + unAction := uninstallAction(t) + unAction.DisableHooks = true + unAction.DryRun = false + unAction.KeepHistory = true + + rel := releaseStub() + rel.Name = "keep-secret" + rel.Manifest = `{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "name": "secret", + "annotations": { + "helm.sh/resource-policy": "keep" + } + }, + "type": "Opaque", + "data": { + "password": "password" + } + }` + unAction.cfg.Releases.Create(rel) + res, err := unAction.Run(rel.Name) + is.NoError(err) + expected := `These resources were kept due to the resource policy: +[Secret] secret +` + is.Contains(res.Info, expected) +} + +func TestUninstallRelease_Wait(t *testing.T) { + is := assert.New(t) + + unAction := uninstallAction(t) + unAction.DisableHooks = true + unAction.DryRun = false + unAction.Wait = true + + rel := releaseStub() + rel.Name = "come-fail-away" + rel.Manifest = `{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "name": "secret" + }, + "type": "Opaque", + "data": { + "password": "password" + } + }` + unAction.cfg.Releases.Create(rel) + failer := unAction.cfg.KubeClient.(*kubefake.FailingKubeClient) + failer.WaitError = fmt.Errorf("U timed out") + unAction.cfg.KubeClient = failer + res, err := unAction.Run(rel.Name) + is.Error(err) + is.Contains(err.Error(), "U timed out") + is.Equal(res.Release.Info.Status, release.StatusUninstalled) +} + +func TestUninstallRelease_Cascade(t *testing.T) { + is := assert.New(t) + + unAction := uninstallAction(t) + unAction.DisableHooks = true + unAction.DryRun = false + unAction.Wait = false + unAction.DeletionPropagation = "foreground" + + rel := releaseStub() + rel.Name = "come-fail-away" + rel.Manifest = `{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "name": "secret" + }, + "type": "Opaque", + "data": { + "password": "password" + } + }` + unAction.cfg.Releases.Create(rel) + failer := unAction.cfg.KubeClient.(*kubefake.FailingKubeClient) + failer.DeleteWithPropagationError = fmt.Errorf("Uninstall with cascade failed") + failer.BuildDummy = true + unAction.cfg.KubeClient = failer + _, err := unAction.Run(rel.Name) + is.Error(err) + is.Contains(err.Error(), "failed to delete release: come-fail-away") +} diff --git a/pkg/action/upgrade.go b/pkg/action/upgrade.go new file mode 100644 index 00000000..ffb7538a --- /dev/null +++ b/pkg/action/upgrade.go @@ -0,0 +1,627 @@ +/* +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" + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/pkg/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/resource" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/kube" + "helm.sh/helm/v3/pkg/postrender" + "helm.sh/helm/v3/pkg/registry" + "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/releaseutil" + "helm.sh/helm/v3/pkg/storage/driver" +) + +// Upgrade is the action for upgrading releases. +// +// It provides the implementation of 'helm upgrade'. +type Upgrade struct { + cfg *Configuration + + ChartPathOptions + + // Install is a purely informative flag that indicates whether this upgrade was done in "install" mode. + // + // Applications may use this to determine whether this Upgrade operation was done as part of a + // pure upgrade (Upgrade.Install == false) or as part of an install-or-upgrade operation + // (Upgrade.Install == true). + // + // Setting this to `true` will NOT cause `Upgrade` to perform an install if the release does not exist. + // That process must be handled by creating an Install action directly. See cmd/upgrade.go for an + // example of how this flag is used. + Install bool + // Devel indicates that the operation is done in devel mode. + Devel bool + // Namespace is the namespace in which this operation should be performed. + Namespace string + // SkipCRDs skips installing CRDs when install flag is enabled during upgrade + SkipCRDs bool + // Timeout is the timeout for this operation + Timeout time.Duration + // Wait determines whether the wait operation should be performed after the upgrade is requested. + Wait bool + // WaitForJobs determines whether the wait operation for the Jobs should be performed after the upgrade is requested. + WaitForJobs bool + // DisableHooks disables hook processing if set to true. + DisableHooks bool + // DryRun controls whether the operation is prepared, but not executed. + DryRun bool + // DryRunOption controls whether the operation is prepared, but not executed with options on whether or not to interact with the remote cluster. + DryRunOption string + // Force will, if set to `true`, ignore certain warnings and perform the upgrade anyway. + // + // This should be used with caution. + Force bool + // ResetValues will reset the values to the chart's built-ins rather than merging with existing. + ResetValues bool + // ReuseValues will re-use the user's last supplied values. + ReuseValues bool + // ResetThenReuseValues will reset the values to the chart's built-ins then merge with user's last supplied values. + ResetThenReuseValues bool + // Recreate will (if true) recreate pods after a rollback. + Recreate bool + // MaxHistory limits the maximum number of revisions saved per release + MaxHistory int + // Atomic, if true, will roll back on failure. + Atomic bool + // CleanupOnFail will, if true, cause the upgrade to delete newly-created resources on a failed update. + CleanupOnFail bool + // SubNotes determines whether sub-notes are rendered in the chart. + SubNotes bool + // Description is the description of this operation + Description string + Labels map[string]string + // PostRender is an optional post-renderer + // + // If this is non-nil, then after templates are rendered, they will be sent to the + // post renderer before sending to the Kubernetes API server. + PostRenderer postrender.PostRenderer + // DisableOpenAPIValidation controls whether OpenAPI validation is enforced. + DisableOpenAPIValidation bool + // Get missing dependencies + DependencyUpdate bool + // Lock to control raceconditions when the process receives a SIGTERM + Lock sync.Mutex + // Enable DNS lookups when rendering templates + EnableDNS bool +} + +type resultMessage struct { + r *release.Release + e error +} + +// NewUpgrade creates a new Upgrade object with the given configuration. +func NewUpgrade(cfg *Configuration) *Upgrade { + up := &Upgrade{ + cfg: cfg, + } + up.ChartPathOptions.registryClient = cfg.RegistryClient + + return up +} + +// SetRegistryClient sets the registry client to use when fetching charts. +func (u *Upgrade) SetRegistryClient(client *registry.Client) { + u.ChartPathOptions.registryClient = client +} + +// Run executes the upgrade on the given release. +func (u *Upgrade) Run(name string, chart *chart.Chart, vals map[string]interface{}) (*release.Release, error) { + ctx := context.Background() + return u.RunWithContext(ctx, name, chart, vals) +} + +// RunWithContext executes the upgrade on the given release with context. +func (u *Upgrade) RunWithContext(ctx context.Context, name string, chart *chart.Chart, vals map[string]interface{}) (*release.Release, error) { + if err := u.cfg.KubeClient.IsReachable(); err != nil { + return nil, err + } + + // Make sure if Atomic is set, that wait is set as well. This makes it so + // the user doesn't have to specify both + u.Wait = u.Wait || u.Atomic + + if err := chartutil.ValidateReleaseName(name); err != nil { + return nil, errors.Errorf("release name is invalid: %s", name) + } + + u.cfg.Log("preparing upgrade for %s", name) + currentRelease, upgradedRelease, err := u.prepareUpgrade(name, chart, vals) + if err != nil { + return nil, err + } + + u.cfg.Releases.MaxHistory = u.MaxHistory + + u.cfg.Log("performing update for %s", name) + res, err := u.performUpgrade(ctx, currentRelease, upgradedRelease) + if err != nil { + return res, err + } + + // Do not update for dry runs + if !u.isDryRun() { + u.cfg.Log("updating status for upgraded release for %s", name) + if err := u.cfg.Releases.Update(upgradedRelease); err != nil { + return res, err + } + } + + return res, nil +} + +// isDryRun returns true if Upgrade is set to run as a DryRun +func (u *Upgrade) isDryRun() bool { + if u.DryRun || u.DryRunOption == "client" || u.DryRunOption == "server" || u.DryRunOption == "true" { + return true + } + return false +} + +// prepareUpgrade builds an upgraded release for an upgrade operation. +func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[string]interface{}) (*release.Release, *release.Release, error) { + if chart == nil { + return nil, nil, errMissingChart + } + + // finds the last non-deleted release with the given name + lastRelease, err := u.cfg.Releases.Last(name) + if err != nil { + // to keep existing behavior of returning the "%q has no deployed releases" error when an existing release does not exist + if errors.Is(err, driver.ErrReleaseNotFound) { + return nil, nil, driver.NewErrNoDeployedReleases(name) + } + return nil, nil, err + } + + // Concurrent `helm upgrade`s will either fail here with `errPending` or when creating the release with "already exists". This should act as a pessimistic lock. + if lastRelease.Info.Status.IsPending() { + return nil, nil, errPending + } + + var currentRelease *release.Release + if lastRelease.Info.Status == release.StatusDeployed { + // no need to retrieve the last deployed release from storage as the last release is deployed + currentRelease = lastRelease + } else { + // finds the deployed release with the given name + currentRelease, err = u.cfg.Releases.Deployed(name) + if err != nil { + if errors.Is(err, driver.ErrNoDeployedReleases) && + (lastRelease.Info.Status == release.StatusFailed || lastRelease.Info.Status == release.StatusSuperseded) { + currentRelease = lastRelease + } else { + return nil, nil, err + } + } + } + + // determine if values will be reused + vals, err = u.reuseValues(chart, currentRelease, vals) + if err != nil { + return nil, nil, err + } + + if err := chartutil.ProcessDependenciesWithMerge(chart, vals); err != nil { + return nil, nil, err + } + + // Increment revision count. This is passed to templates, and also stored on + // the release object. + revision := lastRelease.Version + 1 + + options := chartutil.ReleaseOptions{ + Name: name, + Namespace: currentRelease.Namespace, + Revision: revision, + IsUpgrade: true, + } + + caps, err := u.cfg.getCapabilities() + if err != nil { + return nil, nil, err + } + valuesToRender, err := chartutil.ToRenderValues(chart, vals, options, caps) + if err != nil { + return nil, nil, err + } + + // Determine whether or not to interact with remote + var interactWithRemote bool + if !u.isDryRun() || u.DryRunOption == "server" || u.DryRunOption == "none" || u.DryRunOption == "false" { + interactWithRemote = true + } + + hooks, manifestDoc, notesTxt, err := u.cfg.renderResources(chart, valuesToRender, "", "", u.SubNotes, false, false, u.PostRenderer, interactWithRemote, u.EnableDNS) + if err != nil { + return nil, nil, err + } + + if driver.ContainsSystemLabels(u.Labels) { + return nil, nil, fmt.Errorf("user suplied labels contains system reserved label name. System labels: %+v", driver.GetSystemLabels()) + } + + // Store an upgraded release. + upgradedRelease := &release.Release{ + Name: name, + Namespace: currentRelease.Namespace, + Chart: chart, + Config: vals, + Info: &release.Info{ + FirstDeployed: currentRelease.Info.FirstDeployed, + LastDeployed: Timestamper(), + Status: release.StatusPendingUpgrade, + Description: "Preparing upgrade", // This should be overwritten later. + }, + Version: revision, + Manifest: manifestDoc.String(), + Hooks: hooks, + Labels: mergeCustomLabels(lastRelease.Labels, u.Labels), + } + + if len(notesTxt) > 0 { + upgradedRelease.Info.Notes = notesTxt + } + err = validateManifest(u.cfg.KubeClient, manifestDoc.Bytes(), !u.DisableOpenAPIValidation) + return currentRelease, upgradedRelease, err +} + +func (u *Upgrade) performUpgrade(ctx context.Context, originalRelease, upgradedRelease *release.Release) (*release.Release, error) { + current, err := u.cfg.KubeClient.Build(bytes.NewBufferString(originalRelease.Manifest), false) + if err != nil { + // Checking for removed Kubernetes API error so can provide a more informative error message to the user + // Ref: https://github.com/helm/helm/issues/7219 + if strings.Contains(err.Error(), "unable to recognize \"\": no matches for kind") { + return upgradedRelease, errors.Wrap(err, "current release manifest contains removed kubernetes api(s) for this "+ + "kubernetes version and it is therefore unable to build the kubernetes "+ + "objects for performing the diff. error from kubernetes") + } + return upgradedRelease, errors.Wrap(err, "unable to build kubernetes objects from current release manifest") + } + target, err := u.cfg.KubeClient.Build(bytes.NewBufferString(upgradedRelease.Manifest), !u.DisableOpenAPIValidation) + if err != nil { + return upgradedRelease, errors.Wrap(err, "unable to build kubernetes objects from new release manifest") + } + + // It is safe to use force only on target because these are resources currently rendered by the chart. + err = target.Visit(setMetadataVisitor(upgradedRelease.Name, upgradedRelease.Namespace, true)) + if err != nil { + return upgradedRelease, err + } + + // Do a basic diff using gvk + name to figure out what new resources are being created so we can validate they don't already exist + existingResources := make(map[string]bool) + for _, r := range current { + existingResources[objectKey(r)] = true + } + + var toBeCreated kube.ResourceList + for _, r := range target { + if !existingResources[objectKey(r)] { + toBeCreated = append(toBeCreated, r) + } + } + + toBeUpdated, err := existingResourceConflict(toBeCreated, upgradedRelease.Name, upgradedRelease.Namespace) + if err != nil { + return nil, errors.Wrap(err, "Unable to continue with update") + } + + toBeUpdated.Visit(func(r *resource.Info, err error) error { + if err != nil { + return err + } + current.Append(r) + return nil + }) + + // Run if it is a dry run + if u.isDryRun() { + u.cfg.Log("dry run for %s", upgradedRelease.Name) + if len(u.Description) > 0 { + upgradedRelease.Info.Description = u.Description + } else { + upgradedRelease.Info.Description = "Dry run complete" + } + return upgradedRelease, nil + } + + u.cfg.Log("creating upgraded release for %s", upgradedRelease.Name) + if err := u.cfg.Releases.Create(upgradedRelease); err != nil { + return nil, err + } + rChan := make(chan resultMessage) + ctxChan := make(chan resultMessage) + doneChan := make(chan interface{}) + defer close(doneChan) + go u.releasingUpgrade(rChan, upgradedRelease, current, target, originalRelease) + go u.handleContext(ctx, doneChan, ctxChan, upgradedRelease) + select { + case result := <-rChan: + return result.r, result.e + case result := <-ctxChan: + return result.r, result.e + } +} + +// Function used to lock the Mutex, this is important for the case when the atomic flag is set. +// In that case the upgrade will finish before the rollback is finished so it is necessary to wait for the rollback to finish. +// The rollback will be trigger by the function failRelease +func (u *Upgrade) reportToPerformUpgrade(c chan<- resultMessage, rel *release.Release, created kube.ResourceList, err error) { + u.Lock.Lock() + if err != nil { + rel, err = u.failRelease(rel, created, err) + } + c <- resultMessage{r: rel, e: err} + u.Lock.Unlock() +} + +// Setup listener for SIGINT and SIGTERM +func (u *Upgrade) handleContext(ctx context.Context, done chan interface{}, c chan<- resultMessage, upgradedRelease *release.Release) { + select { + case <-ctx.Done(): + err := ctx.Err() + + // when the atomic flag is set the ongoing release finish first and doesn't give time for the rollback happens. + u.reportToPerformUpgrade(c, upgradedRelease, kube.ResourceList{}, err) + case <-done: + return + } +} +func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *release.Release, current kube.ResourceList, target kube.ResourceList, originalRelease *release.Release) { + // pre-upgrade hooks + + if !u.DisableHooks { + if err := u.cfg.execHook(upgradedRelease, release.HookPreUpgrade, u.Timeout); err != nil { + u.reportToPerformUpgrade(c, upgradedRelease, kube.ResourceList{}, fmt.Errorf("pre-upgrade hooks failed: %s", err)) + return + } + } else { + u.cfg.Log("upgrade hooks disabled for %s", upgradedRelease.Name) + } + + results, err := u.cfg.KubeClient.Update(current, target, u.Force) + if err != nil { + u.cfg.recordRelease(originalRelease) + u.reportToPerformUpgrade(c, upgradedRelease, results.Created, err) + return + } + + if u.Recreate { + // NOTE: Because this is not critical for a release to succeed, we just + // log if an error occurs and continue onward. If we ever introduce log + // levels, we should make these error level logs so users are notified + // that they'll need to go do the cleanup on their own + if err := recreate(u.cfg, results.Updated); err != nil { + u.cfg.Log(err.Error()) + } + } + + if u.Wait { + u.cfg.Log( + "waiting for release %s resources (created: %d updated: %d deleted: %d)", + upgradedRelease.Name, len(results.Created), len(results.Updated), len(results.Deleted)) + if u.WaitForJobs { + if err := u.cfg.KubeClient.WaitWithJobs(target, u.Timeout); err != nil { + u.cfg.recordRelease(originalRelease) + u.reportToPerformUpgrade(c, upgradedRelease, results.Created, err) + return + } + } else { + if err := u.cfg.KubeClient.Wait(target, u.Timeout); err != nil { + u.cfg.recordRelease(originalRelease) + u.reportToPerformUpgrade(c, upgradedRelease, results.Created, err) + return + } + } + } + + // post-upgrade hooks + if !u.DisableHooks { + if err := u.cfg.execHook(upgradedRelease, release.HookPostUpgrade, u.Timeout); err != nil { + u.reportToPerformUpgrade(c, upgradedRelease, results.Created, fmt.Errorf("post-upgrade hooks failed: %s", err)) + return + } + } + + originalRelease.Info.Status = release.StatusSuperseded + u.cfg.recordRelease(originalRelease) + + upgradedRelease.Info.Status = release.StatusDeployed + if len(u.Description) > 0 { + upgradedRelease.Info.Description = u.Description + } else { + upgradedRelease.Info.Description = "Upgrade complete" + } + u.reportToPerformUpgrade(c, upgradedRelease, nil, nil) +} + +func (u *Upgrade) failRelease(rel *release.Release, created kube.ResourceList, err error) (*release.Release, error) { + msg := fmt.Sprintf("Upgrade %q failed: %s", rel.Name, err) + u.cfg.Log("warning: %s", msg) + + rel.Info.Status = release.StatusFailed + rel.Info.Description = msg + u.cfg.recordRelease(rel) + if u.CleanupOnFail && len(created) > 0 { + u.cfg.Log("Cleanup on fail set, cleaning up %d resources", len(created)) + _, errs := u.cfg.KubeClient.Delete(created) + if errs != nil { + var errorList []string + for _, e := range errs { + errorList = append(errorList, e.Error()) + } + return rel, errors.Wrapf(fmt.Errorf("unable to cleanup resources: %s", strings.Join(errorList, ", ")), "an error occurred while cleaning up resources. original upgrade error: %s", err) + } + u.cfg.Log("Resource cleanup complete") + } + if u.Atomic { + u.cfg.Log("Upgrade failed and atomic is set, rolling back to last successful release") + + // As a protection, get the last successful release before rollback. + // If there are no successful releases, bail out + hist := NewHistory(u.cfg) + fullHistory, herr := hist.Run(rel.Name) + if herr != nil { + return rel, errors.Wrapf(herr, "an error occurred while finding last successful release. original upgrade error: %s", err) + } + + // There isn't a way to tell if a previous release was successful, but + // generally failed releases do not get superseded unless the next + // release is successful, so this should be relatively safe + filteredHistory := releaseutil.FilterFunc(func(r *release.Release) bool { + return r.Info.Status == release.StatusSuperseded || r.Info.Status == release.StatusDeployed + }).Filter(fullHistory) + if len(filteredHistory) == 0 { + return rel, errors.Wrap(err, "unable to find a previously successful release when attempting to rollback. original upgrade error") + } + + releaseutil.Reverse(filteredHistory, releaseutil.SortByRevision) + + rollin := NewRollback(u.cfg) + rollin.Version = filteredHistory[0].Version + rollin.Wait = true + rollin.WaitForJobs = u.WaitForJobs + rollin.DisableHooks = u.DisableHooks + rollin.Recreate = u.Recreate + rollin.Force = u.Force + rollin.Timeout = u.Timeout + if rollErr := rollin.Run(rel.Name); rollErr != nil { + return rel, errors.Wrapf(rollErr, "an error occurred while rolling back the release. original upgrade error: %s", err) + } + return rel, errors.Wrapf(err, "release %s failed, and has been rolled back due to atomic being set", rel.Name) + } + + return rel, err +} + +// reuseValues copies values from the current release to a new release if the +// new release does not have any values. +// +// If the request already has values, or if there are no values in the current +// release, this does nothing. +// +// This is skipped if the u.ResetValues flag is set, in which case the +// request values are not altered. +func (u *Upgrade) reuseValues(chart *chart.Chart, current *release.Release, newVals map[string]interface{}) (map[string]interface{}, error) { + if u.ResetValues { + // If ResetValues is set, we completely ignore current.Config. + u.cfg.Log("resetting values to the chart's original version") + return newVals, nil + } + + // If the ReuseValues flag is set, we always copy the old values over the new config's values. + if u.ReuseValues { + u.cfg.Log("reusing the old release's values") + + // We have to regenerate the old coalesced values: + oldVals, err := chartutil.CoalesceValues(current.Chart, current.Config) + if err != nil { + return nil, errors.Wrap(err, "failed to rebuild old values") + } + + newVals = chartutil.CoalesceTables(newVals, current.Config) + + chart.Values = oldVals + + return newVals, nil + } + + // If the ResetThenReuseValues flag is set, we use the new chart's values, but we copy the old config's values over the new config's values. + if u.ResetThenReuseValues { + u.cfg.Log("merging values from old release to new values") + + newVals = chartutil.CoalesceTables(newVals, current.Config) + + return newVals, nil + } + + if len(newVals) == 0 && len(current.Config) > 0 { + u.cfg.Log("copying values from %s (v%d) to new release.", current.Name, current.Version) + newVals = current.Config + } + return newVals, nil +} + +func validateManifest(c kube.Interface, manifest []byte, openAPIValidation bool) error { + _, err := c.Build(bytes.NewReader(manifest), openAPIValidation) + return err +} + +// recreate captures all the logic for recreating pods for both upgrade and +// rollback. If we end up refactoring rollback to use upgrade, this can just be +// made an unexported method on the upgrade action. +func recreate(cfg *Configuration, resources kube.ResourceList) error { + for _, res := range resources { + versioned := kube.AsVersioned(res) + selector, err := kube.SelectorsForObject(versioned) + if err != nil { + // If no selector is returned, it means this object is + // definitely not a pod, so continue onward + continue + } + + client, err := cfg.KubernetesClientSet() + if err != nil { + return errors.Wrapf(err, "unable to recreate pods for object %s/%s because an error occurred", res.Namespace, res.Name) + } + + pods, err := client.CoreV1().Pods(res.Namespace).List(context.Background(), metav1.ListOptions{ + LabelSelector: selector.String(), + }) + if err != nil { + return errors.Wrapf(err, "unable to recreate pods for object %s/%s because an error occurred", res.Namespace, res.Name) + } + + // Restart pods + for _, pod := range pods.Items { + // Delete each pod for get them restarted with changed spec. + if err := client.CoreV1().Pods(pod.Namespace).Delete(context.Background(), pod.Name, *metav1.NewPreconditionDeleteOptions(string(pod.UID))); err != nil { + return errors.Wrapf(err, "unable to recreate pods for object %s/%s because an error occurred", res.Namespace, res.Name) + } + } + } + return nil +} + +func objectKey(r *resource.Info) string { + gvk := r.Object.GetObjectKind().GroupVersionKind() + return fmt.Sprintf("%s/%s/%s/%s", gvk.GroupVersion().String(), gvk.Kind, r.Namespace, r.Name) +} + +func mergeCustomLabels(current, desired map[string]string) map[string]string { + labels := mergeStrStrMaps(current, desired) + for k, v := range labels { + if v == "null" { + delete(labels, k) + } + } + return labels +} diff --git a/pkg/action/upgrade_test.go b/pkg/action/upgrade_test.go new file mode 100644 index 00000000..e259605c --- /dev/null +++ b/pkg/action/upgrade_test.go @@ -0,0 +1,537 @@ +/* +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 ( + "context" + "fmt" + "reflect" + "testing" + "time" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/storage/driver" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + kubefake "helm.sh/helm/v3/pkg/kube/fake" + "helm.sh/helm/v3/pkg/release" + helmtime "helm.sh/helm/v3/pkg/time" +) + +func upgradeAction(t *testing.T) *Upgrade { + config := actionConfigFixture(t) + upAction := NewUpgrade(config) + upAction.Namespace = "spaced" + + return upAction +} + +func TestUpgradeRelease_Success(t *testing.T) { + is := assert.New(t) + req := require.New(t) + + upAction := upgradeAction(t) + rel := releaseStub() + rel.Name = "previous-release" + rel.Info.Status = release.StatusDeployed + req.NoError(upAction.cfg.Releases.Create(rel)) + + upAction.Wait = true + vals := map[string]interface{}{} + + ctx, done := context.WithCancel(context.Background()) + res, err := upAction.RunWithContext(ctx, rel.Name, buildChart(), vals) + done() + req.NoError(err) + is.Equal(res.Info.Status, release.StatusDeployed) + + // Detecting previous bug where context termination after successful release + // caused release to fail. + time.Sleep(time.Millisecond * 100) + lastRelease, err := upAction.cfg.Releases.Last(rel.Name) + req.NoError(err) + is.Equal(lastRelease.Info.Status, release.StatusDeployed) +} + +func TestUpgradeRelease_Wait(t *testing.T) { + is := assert.New(t) + req := require.New(t) + + upAction := upgradeAction(t) + rel := releaseStub() + rel.Name = "come-fail-away" + rel.Info.Status = release.StatusDeployed + upAction.cfg.Releases.Create(rel) + + failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) + failer.WaitError = fmt.Errorf("I timed out") + upAction.cfg.KubeClient = failer + upAction.Wait = true + vals := map[string]interface{}{} + + res, err := upAction.Run(rel.Name, buildChart(), vals) + req.Error(err) + is.Contains(res.Info.Description, "I timed out") + is.Equal(res.Info.Status, release.StatusFailed) +} + +func TestUpgradeRelease_WaitForJobs(t *testing.T) { + is := assert.New(t) + req := require.New(t) + + upAction := upgradeAction(t) + rel := releaseStub() + rel.Name = "come-fail-away" + rel.Info.Status = release.StatusDeployed + upAction.cfg.Releases.Create(rel) + + failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) + failer.WaitError = fmt.Errorf("I timed out") + upAction.cfg.KubeClient = failer + upAction.Wait = true + upAction.WaitForJobs = true + vals := map[string]interface{}{} + + res, err := upAction.Run(rel.Name, buildChart(), vals) + req.Error(err) + is.Contains(res.Info.Description, "I timed out") + is.Equal(res.Info.Status, release.StatusFailed) +} + +func TestUpgradeRelease_CleanupOnFail(t *testing.T) { + is := assert.New(t) + req := require.New(t) + + upAction := upgradeAction(t) + rel := releaseStub() + rel.Name = "come-fail-away" + rel.Info.Status = release.StatusDeployed + upAction.cfg.Releases.Create(rel) + + failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) + failer.WaitError = fmt.Errorf("I timed out") + failer.DeleteError = fmt.Errorf("I tried to delete nil") + upAction.cfg.KubeClient = failer + upAction.Wait = true + upAction.CleanupOnFail = true + vals := map[string]interface{}{} + + res, err := upAction.Run(rel.Name, buildChart(), vals) + req.Error(err) + is.NotContains(err.Error(), "unable to cleanup resources") + is.Contains(res.Info.Description, "I timed out") + is.Equal(res.Info.Status, release.StatusFailed) +} + +func TestUpgradeRelease_Atomic(t *testing.T) { + is := assert.New(t) + req := require.New(t) + + t.Run("atomic rollback succeeds", func(t *testing.T) { + upAction := upgradeAction(t) + + rel := releaseStub() + rel.Name = "nuketown" + rel.Info.Status = release.StatusDeployed + upAction.cfg.Releases.Create(rel) + + failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) + // We can't make Update error because then the rollback won't work + failer.WatchUntilReadyError = fmt.Errorf("arming key removed") + upAction.cfg.KubeClient = failer + upAction.Atomic = true + vals := map[string]interface{}{} + + res, err := upAction.Run(rel.Name, buildChart(), vals) + req.Error(err) + is.Contains(err.Error(), "arming key removed") + is.Contains(err.Error(), "atomic") + + // Now make sure it is actually upgraded + updatedRes, err := upAction.cfg.Releases.Get(res.Name, 3) + is.NoError(err) + // Should have rolled back to the previous + is.Equal(updatedRes.Info.Status, release.StatusDeployed) + }) + + t.Run("atomic uninstall fails", func(t *testing.T) { + upAction := upgradeAction(t) + rel := releaseStub() + rel.Name = "fallout" + rel.Info.Status = release.StatusDeployed + upAction.cfg.Releases.Create(rel) + + failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) + failer.UpdateError = fmt.Errorf("update fail") + upAction.cfg.KubeClient = failer + upAction.Atomic = true + vals := map[string]interface{}{} + + _, err := upAction.Run(rel.Name, buildChart(), vals) + req.Error(err) + is.Contains(err.Error(), "update fail") + is.Contains(err.Error(), "an error occurred while rolling back the release") + }) +} + +func TestUpgradeRelease_ReuseValues(t *testing.T) { + is := assert.New(t) + + t.Run("reuse values should work with values", func(t *testing.T) { + upAction := upgradeAction(t) + + existingValues := map[string]interface{}{ + "name": "value", + "maxHeapSize": "128m", + "replicas": 2, + } + newValues := map[string]interface{}{ + "name": "newValue", + "maxHeapSize": "512m", + "cpu": "12m", + } + expectedValues := map[string]interface{}{ + "name": "newValue", + "maxHeapSize": "512m", + "cpu": "12m", + "replicas": 2, + } + + rel := releaseStub() + rel.Name = "nuketown" + rel.Info.Status = release.StatusDeployed + rel.Config = existingValues + + err := upAction.cfg.Releases.Create(rel) + is.NoError(err) + + upAction.ReuseValues = true + // setting newValues and upgrading + res, err := upAction.Run(rel.Name, buildChart(), newValues) + is.NoError(err) + + // Now make sure it is actually upgraded + updatedRes, err := upAction.cfg.Releases.Get(res.Name, 2) + is.NoError(err) + + if updatedRes == nil { + is.Fail("Updated Release is nil") + return + } + is.Equal(release.StatusDeployed, updatedRes.Info.Status) + is.Equal(expectedValues, updatedRes.Config) + }) + + t.Run("reuse values should not install disabled charts", func(t *testing.T) { + upAction := upgradeAction(t) + chartDefaultValues := map[string]interface{}{ + "subchart": map[string]interface{}{ + "enabled": true, + }, + } + dependency := chart.Dependency{ + Name: "subchart", + Version: "0.1.0", + Repository: "http://some-repo.com", + Condition: "subchart.enabled", + } + sampleChart := buildChart( + withName("sample"), + withValues(chartDefaultValues), + withMetadataDependency(dependency), + ) + now := helmtime.Now() + existingValues := map[string]interface{}{ + "subchart": map[string]interface{}{ + "enabled": false, + }, + } + rel := &release.Release{ + Name: "nuketown", + Info: &release.Info{ + FirstDeployed: now, + LastDeployed: now, + Status: release.StatusDeployed, + Description: "Named Release Stub", + }, + Chart: sampleChart, + Config: existingValues, + Version: 1, + } + err := upAction.cfg.Releases.Create(rel) + is.NoError(err) + + upAction.ReuseValues = true + sampleChartWithSubChart := buildChart( + withName(sampleChart.Name()), + withValues(sampleChart.Values), + withDependency(withName("subchart")), + withMetadataDependency(dependency), + ) + // reusing values and upgrading + res, err := upAction.Run(rel.Name, sampleChartWithSubChart, map[string]interface{}{}) + is.NoError(err) + + // Now get the upgraded release + updatedRes, err := upAction.cfg.Releases.Get(res.Name, 2) + is.NoError(err) + + if updatedRes == nil { + is.Fail("Updated Release is nil") + return + } + is.Equal(release.StatusDeployed, updatedRes.Info.Status) + is.Equal(0, len(updatedRes.Chart.Dependencies()), "expected 0 dependencies") + + expectedValues := map[string]interface{}{ + "subchart": map[string]interface{}{ + "enabled": false, + }, + } + is.Equal(expectedValues, updatedRes.Config) + }) +} + +func TestUpgradeRelease_ResetThenReuseValues(t *testing.T) { + is := assert.New(t) + + t.Run("reset then reuse values should work with values", func(t *testing.T) { + upAction := upgradeAction(t) + + existingValues := map[string]interface{}{ + "name": "value", + "maxHeapSize": "128m", + "replicas": 2, + } + newValues := map[string]interface{}{ + "name": "newValue", + "maxHeapSize": "512m", + "cpu": "12m", + } + newChartValues := map[string]interface{}{ + "memory": "256m", + } + expectedValues := map[string]interface{}{ + "name": "newValue", + "maxHeapSize": "512m", + "cpu": "12m", + "replicas": 2, + } + + rel := releaseStub() + rel.Name = "nuketown" + rel.Info.Status = release.StatusDeployed + rel.Config = existingValues + + err := upAction.cfg.Releases.Create(rel) + is.NoError(err) + + upAction.ResetThenReuseValues = true + // setting newValues and upgrading + res, err := upAction.Run(rel.Name, buildChart(withValues(newChartValues)), newValues) + is.NoError(err) + + // Now make sure it is actually upgraded + updatedRes, err := upAction.cfg.Releases.Get(res.Name, 2) + is.NoError(err) + + if updatedRes == nil { + is.Fail("Updated Release is nil") + return + } + is.Equal(release.StatusDeployed, updatedRes.Info.Status) + is.Equal(expectedValues, updatedRes.Config) + is.Equal(newChartValues, updatedRes.Chart.Values) + }) +} + +func TestUpgradeRelease_Pending(t *testing.T) { + req := require.New(t) + + upAction := upgradeAction(t) + rel := releaseStub() + rel.Name = "come-fail-away" + rel.Info.Status = release.StatusDeployed + upAction.cfg.Releases.Create(rel) + rel2 := releaseStub() + rel2.Name = "come-fail-away" + rel2.Info.Status = release.StatusPendingUpgrade + rel2.Version = 2 + upAction.cfg.Releases.Create(rel2) + + vals := map[string]interface{}{} + + _, err := upAction.Run(rel.Name, buildChart(), vals) + req.Contains(err.Error(), "progress", err) +} + +func TestUpgradeRelease_Interrupted_Wait(t *testing.T) { + + is := assert.New(t) + req := require.New(t) + + upAction := upgradeAction(t) + rel := releaseStub() + rel.Name = "interrupted-release" + rel.Info.Status = release.StatusDeployed + upAction.cfg.Releases.Create(rel) + + failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) + failer.WaitDuration = 10 * time.Second + upAction.cfg.KubeClient = failer + upAction.Wait = true + vals := map[string]interface{}{} + + ctx := context.Background() + ctx, cancel := context.WithCancel(ctx) + time.AfterFunc(time.Second, cancel) + + res, err := upAction.RunWithContext(ctx, rel.Name, buildChart(), vals) + + req.Error(err) + is.Contains(res.Info.Description, "Upgrade \"interrupted-release\" failed: context canceled") + is.Equal(res.Info.Status, release.StatusFailed) + +} + +func TestUpgradeRelease_Interrupted_Atomic(t *testing.T) { + + is := assert.New(t) + req := require.New(t) + + upAction := upgradeAction(t) + rel := releaseStub() + rel.Name = "interrupted-release" + rel.Info.Status = release.StatusDeployed + upAction.cfg.Releases.Create(rel) + + failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) + failer.WaitDuration = 5 * time.Second + upAction.cfg.KubeClient = failer + upAction.Atomic = true + vals := map[string]interface{}{} + + ctx := context.Background() + ctx, cancel := context.WithCancel(ctx) + time.AfterFunc(time.Second, cancel) + + res, err := upAction.RunWithContext(ctx, rel.Name, buildChart(), vals) + + req.Error(err) + is.Contains(err.Error(), "release interrupted-release failed, and has been rolled back due to atomic being set: context canceled") + + // Now make sure it is actually upgraded + updatedRes, err := upAction.cfg.Releases.Get(res.Name, 3) + is.NoError(err) + // Should have rolled back to the previous + is.Equal(updatedRes.Info.Status, release.StatusDeployed) +} + +func TestMergeCustomLabels(t *testing.T) { + var tests = [][3]map[string]string{ + {nil, nil, map[string]string{}}, + {map[string]string{}, map[string]string{}, map[string]string{}}, + {map[string]string{"k1": "v1", "k2": "v2"}, nil, map[string]string{"k1": "v1", "k2": "v2"}}, + {nil, map[string]string{"k1": "v1", "k2": "v2"}, map[string]string{"k1": "v1", "k2": "v2"}}, + {map[string]string{"k1": "v1", "k2": "v2"}, map[string]string{"k1": "null", "k2": "v3"}, map[string]string{"k2": "v3"}}, + } + for _, test := range tests { + if output := mergeCustomLabels(test[0], test[1]); !reflect.DeepEqual(test[2], output) { + t.Errorf("Expected {%v}, got {%v}", test[2], output) + } + } +} + +func TestUpgradeRelease_Labels(t *testing.T) { + is := assert.New(t) + upAction := upgradeAction(t) + + rel := releaseStub() + rel.Name = "labels" + // It's needed to check that suppressed release would keep original labels + rel.Labels = map[string]string{ + "key1": "val1", + "key2": "val2.1", + } + rel.Info.Status = release.StatusDeployed + + err := upAction.cfg.Releases.Create(rel) + is.NoError(err) + + upAction.Labels = map[string]string{ + "key1": "null", + "key2": "val2.2", + "key3": "val3", + } + // setting newValues and upgrading + res, err := upAction.Run(rel.Name, buildChart(), nil) + is.NoError(err) + + // Now make sure it is actually upgraded and labels were merged + updatedRes, err := upAction.cfg.Releases.Get(res.Name, 2) + is.NoError(err) + + if updatedRes == nil { + is.Fail("Updated Release is nil") + return + } + is.Equal(release.StatusDeployed, updatedRes.Info.Status) + is.Equal(mergeCustomLabels(rel.Labels, upAction.Labels), updatedRes.Labels) + + // Now make sure it is suppressed release still contains original labels + initialRes, err := upAction.cfg.Releases.Get(res.Name, 1) + is.NoError(err) + + if initialRes == nil { + is.Fail("Updated Release is nil") + return + } + is.Equal(initialRes.Info.Status, release.StatusSuperseded) + is.Equal(initialRes.Labels, rel.Labels) +} + +func TestUpgradeRelease_SystemLabels(t *testing.T) { + is := assert.New(t) + upAction := upgradeAction(t) + + rel := releaseStub() + rel.Name = "labels" + // It's needed to check that suppressed release would keep original labels + rel.Labels = map[string]string{ + "key1": "val1", + "key2": "val2.1", + } + rel.Info.Status = release.StatusDeployed + + err := upAction.cfg.Releases.Create(rel) + is.NoError(err) + + upAction.Labels = map[string]string{ + "key1": "null", + "key2": "val2.2", + "owner": "val3", + } + // setting newValues and upgrading + _, err = upAction.Run(rel.Name, buildChart(), nil) + if err == nil { + t.Fatal("expected an error") + } + + is.Equal(fmt.Errorf("user suplied labels contains system reserved label name. System labels: %+v", driver.GetSystemLabels()), err) +} diff --git a/pkg/action/validate.go b/pkg/action/validate.go new file mode 100644 index 00000000..73eb1937 --- /dev/null +++ b/pkg/action/validate.go @@ -0,0 +1,184 @@ +/* +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 ( + "fmt" + + "github.com/pkg/errors" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/resource" + + "helm.sh/helm/v3/pkg/kube" +) + +var accessor = meta.NewAccessor() + +const ( + appManagedByLabel = "app.kubernetes.io/managed-by" + appManagedByHelm = "Helm" + helmReleaseNameAnnotation = "meta.helm.sh/release-name" + helmReleaseNamespaceAnnotation = "meta.helm.sh/release-namespace" +) + +func existingResourceConflict(resources kube.ResourceList, releaseName, releaseNamespace string) (kube.ResourceList, error) { + var requireUpdate kube.ResourceList + + err := resources.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + + helper := resource.NewHelper(info.Client, info.Mapping) + existing, err := helper.Get(info.Namespace, info.Name) + if err != nil { + if apierrors.IsNotFound(err) { + return nil + } + return errors.Wrapf(err, "could not get information about the resource %s", resourceString(info)) + } + + // Allow adoption of the resource if it is managed by Helm and is annotated with correct release name and namespace. + if err := checkOwnership(existing, releaseName, releaseNamespace); err != nil { + return fmt.Errorf("%s exists and cannot be imported into the current release: %s", resourceString(info), err) + } + + requireUpdate.Append(info) + return nil + }) + + return requireUpdate, err +} + +func checkOwnership(obj runtime.Object, releaseName, releaseNamespace string) error { + lbls, err := accessor.Labels(obj) + if err != nil { + return err + } + annos, err := accessor.Annotations(obj) + if err != nil { + return err + } + + var errs []error + if err := requireValue(lbls, appManagedByLabel, appManagedByHelm); err != nil { + errs = append(errs, fmt.Errorf("label validation error: %s", err)) + } + if err := requireValue(annos, helmReleaseNameAnnotation, releaseName); err != nil { + errs = append(errs, fmt.Errorf("annotation validation error: %s", err)) + } + if err := requireValue(annos, helmReleaseNamespaceAnnotation, releaseNamespace); err != nil { + errs = append(errs, fmt.Errorf("annotation validation error: %s", err)) + } + + if len(errs) > 0 { + err := errors.New("invalid ownership metadata") + for _, e := range errs { + err = fmt.Errorf("%w; %s", err, e) + } + return err + } + + return nil +} + +func requireValue(meta map[string]string, k, v string) error { + actual, ok := meta[k] + if !ok { + return fmt.Errorf("missing key %q: must be set to %q", k, v) + } + if actual != v { + return fmt.Errorf("key %q must equal %q: current value is %q", k, v, actual) + } + return nil +} + +// setMetadataVisitor adds release tracking metadata to all resources. If force is enabled, existing +// ownership metadata will be overwritten. Otherwise an error will be returned if any resource has an +// existing and conflicting value for the managed by label or Helm release/namespace annotations. +func setMetadataVisitor(releaseName, releaseNamespace string, force bool) resource.VisitorFunc { + return func(info *resource.Info, err error) error { + if err != nil { + return err + } + + if !force { + if err := checkOwnership(info.Object, releaseName, releaseNamespace); err != nil { + return fmt.Errorf("%s cannot be owned: %s", resourceString(info), err) + } + } + + if err := mergeLabels(info.Object, map[string]string{ + appManagedByLabel: appManagedByHelm, + }); err != nil { + return fmt.Errorf( + "%s labels could not be updated: %s", + resourceString(info), err, + ) + } + + if err := mergeAnnotations(info.Object, map[string]string{ + helmReleaseNameAnnotation: releaseName, + helmReleaseNamespaceAnnotation: releaseNamespace, + }); err != nil { + return fmt.Errorf( + "%s annotations could not be updated: %s", + resourceString(info), err, + ) + } + + return nil + } +} + +func resourceString(info *resource.Info) string { + _, k := info.Mapping.GroupVersionKind.ToAPIVersionAndKind() + return fmt.Sprintf( + "%s %q in namespace %q", + k, info.Name, info.Namespace, + ) +} + +func mergeLabels(obj runtime.Object, labels map[string]string) error { + current, err := accessor.Labels(obj) + if err != nil { + return err + } + return accessor.SetLabels(obj, mergeStrStrMaps(current, labels)) +} + +func mergeAnnotations(obj runtime.Object, annotations map[string]string) error { + current, err := accessor.Annotations(obj) + if err != nil { + return err + } + return accessor.SetAnnotations(obj, mergeStrStrMaps(current, annotations)) +} + +// merge two maps, always taking the value on the right +func mergeStrStrMaps(current, desired map[string]string) map[string]string { + result := make(map[string]string) + for k, v := range current { + result[k] = v + } + for k, desiredVal := range desired { + result[k] = desiredVal + } + return result +} diff --git a/pkg/action/validate_test.go b/pkg/action/validate_test.go new file mode 100644 index 00000000..a9c1cb49 --- /dev/null +++ b/pkg/action/validate_test.go @@ -0,0 +1,123 @@ +/* +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 ( + "testing" + + "helm.sh/helm/v3/pkg/kube" + + appsv1 "k8s.io/api/apps/v1" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/api/meta" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/resource" +) + +func newDeploymentResource(name, namespace string) *resource.Info { + return &resource.Info{ + Name: name, + Mapping: &meta.RESTMapping{ + Resource: schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployment"}, + GroupVersionKind: schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}, + }, + Object: &appsv1.Deployment{ + ObjectMeta: v1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + }, + } +} + +func TestCheckOwnership(t *testing.T) { + deployFoo := newDeploymentResource("foo", "ns-a") + + // Verify that a resource that lacks labels/annotations is not owned + err := checkOwnership(deployFoo.Object, "rel-a", "ns-a") + assert.EqualError(t, err, `invalid ownership metadata; label validation error: missing key "app.kubernetes.io/managed-by": must be set to "Helm"; annotation validation error: missing key "meta.helm.sh/release-name": must be set to "rel-a"; annotation validation error: missing key "meta.helm.sh/release-namespace": must be set to "ns-a"`) + + // Set managed by label and verify annotation error message + _ = accessor.SetLabels(deployFoo.Object, map[string]string{ + appManagedByLabel: appManagedByHelm, + }) + err = checkOwnership(deployFoo.Object, "rel-a", "ns-a") + assert.EqualError(t, err, `invalid ownership metadata; annotation validation error: missing key "meta.helm.sh/release-name": must be set to "rel-a"; annotation validation error: missing key "meta.helm.sh/release-namespace": must be set to "ns-a"`) + + // Set only the release name annotation and verify missing release namespace error message + _ = accessor.SetAnnotations(deployFoo.Object, map[string]string{ + helmReleaseNameAnnotation: "rel-a", + }) + err = checkOwnership(deployFoo.Object, "rel-a", "ns-a") + assert.EqualError(t, err, `invalid ownership metadata; annotation validation error: missing key "meta.helm.sh/release-namespace": must be set to "ns-a"`) + + // Set both release name and namespace annotations and verify no ownership errors + _ = accessor.SetAnnotations(deployFoo.Object, map[string]string{ + helmReleaseNameAnnotation: "rel-a", + helmReleaseNamespaceAnnotation: "ns-a", + }) + err = checkOwnership(deployFoo.Object, "rel-a", "ns-a") + assert.NoError(t, err) + + // Verify ownership error for wrong release name + err = checkOwnership(deployFoo.Object, "rel-b", "ns-a") + assert.EqualError(t, err, `invalid ownership metadata; annotation validation error: key "meta.helm.sh/release-name" must equal "rel-b": current value is "rel-a"`) + + // Verify ownership error for wrong release namespace + err = checkOwnership(deployFoo.Object, "rel-a", "ns-b") + assert.EqualError(t, err, `invalid ownership metadata; annotation validation error: key "meta.helm.sh/release-namespace" must equal "ns-b": current value is "ns-a"`) + + // Verify ownership error for wrong manager label + _ = accessor.SetLabels(deployFoo.Object, map[string]string{ + appManagedByLabel: "helm", + }) + err = checkOwnership(deployFoo.Object, "rel-a", "ns-a") + assert.EqualError(t, err, `invalid ownership metadata; label validation error: key "app.kubernetes.io/managed-by" must equal "Helm": current value is "helm"`) +} + +func TestSetMetadataVisitor(t *testing.T) { + var ( + err error + deployFoo = newDeploymentResource("foo", "ns-a") + deployBar = newDeploymentResource("bar", "ns-a-system") + resources = kube.ResourceList{deployFoo, deployBar} + ) + + // Set release tracking metadata and verify no error + err = resources.Visit(setMetadataVisitor("rel-a", "ns-a", true)) + assert.NoError(t, err) + + // Verify that release "b" cannot take ownership of "a" + err = resources.Visit(setMetadataVisitor("rel-b", "ns-a", false)) + assert.Error(t, err) + + // Force release "b" to take ownership + err = resources.Visit(setMetadataVisitor("rel-b", "ns-a", true)) + assert.NoError(t, err) + + // Check that there is now no ownership error when setting metadata without force + err = resources.Visit(setMetadataVisitor("rel-b", "ns-a", false)) + assert.NoError(t, err) + + // Add a new resource that is missing ownership metadata and verify error + resources.Append(newDeploymentResource("baz", "default")) + err = resources.Visit(setMetadataVisitor("rel-b", "ns-a", false)) + assert.Error(t, err) + assert.Contains(t, err.Error(), `Deployment "baz" in namespace "" cannot be owned`) +} diff --git a/pkg/action/verify.go b/pkg/action/verify.go new file mode 100644 index 00000000..f3623949 --- /dev/null +++ b/pkg/action/verify.go @@ -0,0 +1,59 @@ +/* +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 ( + "fmt" + "strings" + + "helm.sh/helm/v3/pkg/downloader" +) + +// Verify is the action for building a given chart's Verify tree. +// +// It provides the implementation of 'helm verify'. +type Verify struct { + Keyring string + Out string +} + +// NewVerify creates a new Verify object with the given configuration. +func NewVerify() *Verify { + return &Verify{} +} + +// Run executes 'helm verify'. +func (v *Verify) Run(chartfile string) error { + var out strings.Builder + p, err := downloader.VerifyChart(chartfile, v.Keyring) + if err != nil { + return err + } + + for name := range p.SignedBy.Identities { + fmt.Fprintf(&out, "Signed by: %v\n", name) + } + fmt.Fprintf(&out, "Using Key With Fingerprint: %X\n", p.SignedBy.PrimaryKey.Fingerprint) + fmt.Fprintf(&out, "Chart Hash Verified: %s\n", p.FileHash) + + // TODO(mattfarina): The output is set as a property rather than returned + // to maintain the Go API. In Helm v4 this function should return the out + // and the property on the struct can be removed. + v.Out = out.String() + + return nil +} diff --git a/pkg/chart/chart.go b/pkg/chart/chart.go new file mode 100644 index 00000000..a3bed63a --- /dev/null +++ b/pkg/chart/chart.go @@ -0,0 +1,173 @@ +/* +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 chart + +import ( + "path/filepath" + "regexp" + "strings" +) + +// APIVersionV1 is the API version number for version 1. +const APIVersionV1 = "v1" + +// APIVersionV2 is the API version number for version 2. +const APIVersionV2 = "v2" + +// aliasNameFormat defines the characters that are legal in an alias name. +var aliasNameFormat = regexp.MustCompile("^[a-zA-Z0-9_-]+$") + +// Chart is a helm package that contains metadata, a default config, zero or more +// optionally parameterizable templates, and zero or more charts (dependencies). +type Chart struct { + // Raw contains the raw contents of the files originally contained in the chart archive. + // + // This should not be used except in special cases like `helm show values`, + // where we want to display the raw values, comments and all. + Raw []*File `json:"-"` + // Metadata is the contents of the Chartfile. + Metadata *Metadata `json:"metadata"` + // Lock is the contents of Chart.lock. + Lock *Lock `json:"lock"` + // Templates for this chart. + Templates []*File `json:"templates"` + // Values are default config for this chart. + Values map[string]interface{} `json:"values"` + // Schema is an optional JSON schema for imposing structure on Values + Schema []byte `json:"schema"` + // Files are miscellaneous files in a chart archive, + // e.g. README, LICENSE, etc. + Files []*File `json:"files"` + + parent *Chart + dependencies []*Chart +} + +type CRD struct { + // Name is the File.Name for the crd file + Name string + // Filename is the File obj Name including (sub-)chart.ChartFullPath + Filename string + // File is the File obj for the crd + File *File +} + +// SetDependencies replaces the chart dependencies. +func (ch *Chart) SetDependencies(charts ...*Chart) { + ch.dependencies = nil + ch.AddDependency(charts...) +} + +// Name returns the name of the chart. +func (ch *Chart) Name() string { + if ch.Metadata == nil { + return "" + } + return ch.Metadata.Name +} + +// AddDependency determines if the chart is a subchart. +func (ch *Chart) AddDependency(charts ...*Chart) { + for i, x := range charts { + charts[i].parent = ch + ch.dependencies = append(ch.dependencies, x) + } +} + +// Root finds the root chart. +func (ch *Chart) Root() *Chart { + if ch.IsRoot() { + return ch + } + return ch.Parent().Root() +} + +// Dependencies are the charts that this chart depends on. +func (ch *Chart) Dependencies() []*Chart { return ch.dependencies } + +// IsRoot determines if the chart is the root chart. +func (ch *Chart) IsRoot() bool { return ch.parent == nil } + +// Parent returns a subchart's parent chart. +func (ch *Chart) Parent() *Chart { return ch.parent } + +// ChartPath returns the full path to this chart in dot notation. +func (ch *Chart) ChartPath() string { + if !ch.IsRoot() { + return ch.Parent().ChartPath() + "." + ch.Name() + } + return ch.Name() +} + +// ChartFullPath returns the full path to this chart. +func (ch *Chart) ChartFullPath() string { + if !ch.IsRoot() { + return ch.Parent().ChartFullPath() + "/charts/" + ch.Name() + } + return ch.Name() +} + +// Validate validates the metadata. +func (ch *Chart) Validate() error { + return ch.Metadata.Validate() +} + +// AppVersion returns the appversion of the chart. +func (ch *Chart) AppVersion() string { + if ch.Metadata == nil { + return "" + } + return ch.Metadata.AppVersion +} + +// CRDs returns a list of File objects in the 'crds/' directory of a Helm chart. +// Deprecated: use CRDObjects() +func (ch *Chart) CRDs() []*File { + files := []*File{} + // Find all resources in the crds/ directory + for _, f := range ch.Files { + if strings.HasPrefix(f.Name, "crds/") && hasManifestExtension(f.Name) { + files = append(files, f) + } + } + // Get CRDs from dependencies, too. + for _, dep := range ch.Dependencies() { + files = append(files, dep.CRDs()...) + } + return files +} + +// CRDObjects returns a list of CRD objects in the 'crds/' directory of a Helm chart & subcharts +func (ch *Chart) CRDObjects() []CRD { + crds := []CRD{} + // Find all resources in the crds/ directory + for _, f := range ch.Files { + if strings.HasPrefix(f.Name, "crds/") && hasManifestExtension(f.Name) { + mycrd := CRD{Name: f.Name, Filename: filepath.Join(ch.ChartFullPath(), f.Name), File: f} + crds = append(crds, mycrd) + } + } + // Get CRDs from dependencies, too. + for _, dep := range ch.Dependencies() { + crds = append(crds, dep.CRDObjects()...) + } + return crds +} + +func hasManifestExtension(fname string) bool { + ext := filepath.Ext(fname) + return strings.EqualFold(ext, ".yaml") || strings.EqualFold(ext, ".yml") || strings.EqualFold(ext, ".json") +} diff --git a/pkg/chart/chart_test.go b/pkg/chart/chart_test.go new file mode 100644 index 00000000..62d60765 --- /dev/null +++ b/pkg/chart/chart_test.go @@ -0,0 +1,211 @@ +/* +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 chart + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCRDs(t *testing.T) { + chrt := Chart{ + Files: []*File{ + { + Name: "crds/foo.yaml", + Data: []byte("hello"), + }, + { + Name: "bar.yaml", + Data: []byte("hello"), + }, + { + Name: "crds/foo/bar/baz.yaml", + Data: []byte("hello"), + }, + { + Name: "crdsfoo/bar/baz.yaml", + Data: []byte("hello"), + }, + { + Name: "crds/README.md", + Data: []byte("# hello"), + }, + }, + } + + is := assert.New(t) + crds := chrt.CRDs() + is.Equal(2, len(crds)) + is.Equal("crds/foo.yaml", crds[0].Name) + is.Equal("crds/foo/bar/baz.yaml", crds[1].Name) +} + +func TestSaveChartNoRawData(t *testing.T) { + chrt := Chart{ + Raw: []*File{ + { + Name: "fhqwhgads.yaml", + Data: []byte("Everybody to the Limit"), + }, + }, + } + + is := assert.New(t) + data, err := json.Marshal(chrt) + if err != nil { + t.Fatal(err) + } + + res := &Chart{} + if err := json.Unmarshal(data, res); err != nil { + t.Fatal(err) + } + + is.Equal([]*File(nil), res.Raw) +} + +func TestMetadata(t *testing.T) { + chrt := Chart{ + Metadata: &Metadata{ + Name: "foo.yaml", + AppVersion: "1.0.0", + APIVersion: "v2", + Version: "1.0.0", + Type: "application", + }, + } + + is := assert.New(t) + + is.Equal("foo.yaml", chrt.Name()) + is.Equal("1.0.0", chrt.AppVersion()) + is.Equal(nil, chrt.Validate()) +} + +func TestIsRoot(t *testing.T) { + chrt1 := Chart{ + parent: &Chart{ + Metadata: &Metadata{ + Name: "foo", + }, + }, + } + + chrt2 := Chart{ + Metadata: &Metadata{ + Name: "foo", + }, + } + + is := assert.New(t) + + is.Equal(false, chrt1.IsRoot()) + is.Equal(true, chrt2.IsRoot()) +} + +func TestChartPath(t *testing.T) { + chrt1 := Chart{ + parent: &Chart{ + Metadata: &Metadata{ + Name: "foo", + }, + }, + } + + chrt2 := Chart{ + Metadata: &Metadata{ + Name: "foo", + }, + } + + is := assert.New(t) + + is.Equal("foo.", chrt1.ChartPath()) + is.Equal("foo", chrt2.ChartPath()) +} + +func TestChartFullPath(t *testing.T) { + chrt1 := Chart{ + parent: &Chart{ + Metadata: &Metadata{ + Name: "foo", + }, + }, + } + + chrt2 := Chart{ + Metadata: &Metadata{ + Name: "foo", + }, + } + + is := assert.New(t) + + is.Equal("foo/charts/", chrt1.ChartFullPath()) + is.Equal("foo", chrt2.ChartFullPath()) +} + +func TestCRDObjects(t *testing.T) { + chrt := Chart{ + Files: []*File{ + { + Name: "crds/foo.yaml", + Data: []byte("hello"), + }, + { + Name: "bar.yaml", + Data: []byte("hello"), + }, + { + Name: "crds/foo/bar/baz.yaml", + Data: []byte("hello"), + }, + { + Name: "crdsfoo/bar/baz.yaml", + Data: []byte("hello"), + }, + { + Name: "crds/README.md", + Data: []byte("# hello"), + }, + }, + } + + expected := []CRD{ + { + Name: "crds/foo.yaml", + Filename: "crds/foo.yaml", + File: &File{ + Name: "crds/foo.yaml", + Data: []byte("hello"), + }, + }, + { + Name: "crds/foo/bar/baz.yaml", + Filename: "crds/foo/bar/baz.yaml", + File: &File{ + Name: "crds/foo/bar/baz.yaml", + Data: []byte("hello"), + }, + }, + } + + is := assert.New(t) + crds := chrt.CRDObjects() + is.Equal(expected, crds) +} diff --git a/pkg/chart/dependency.go b/pkg/chart/dependency.go new file mode 100644 index 00000000..4ef5eeb3 --- /dev/null +++ b/pkg/chart/dependency.go @@ -0,0 +1,82 @@ +/* +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 chart + +import "time" + +// Dependency describes a chart upon which another chart depends. +// +// Dependencies can be used to express developer intent, or to capture the state +// of a chart. +type Dependency struct { + // Name is the name of the dependency. + // + // This must mach the name in the dependency's Chart.yaml. + Name string `json:"name"` + // Version is the version (range) of this chart. + // + // A lock file will always produce a single version, while a dependency + // may contain a semantic version range. + Version string `json:"version,omitempty"` + // The URL to the repository. + // + // Appending `index.yaml` to this string should result in a URL that can be + // used to fetch the repository index. + Repository string `json:"repository"` + // A yaml path that resolves to a boolean, used for enabling/disabling charts (e.g. subchart1.enabled ) + Condition string `json:"condition,omitempty"` + // Tags can be used to group charts for enabling/disabling together + Tags []string `json:"tags,omitempty"` + // Enabled bool determines if chart should be loaded + Enabled bool `json:"enabled,omitempty"` + // ImportValues holds the mapping of source values to parent key to be imported. Each item can be a + // string or pair of child/parent sublist items. + ImportValues []interface{} `json:"import-values,omitempty"` + // Alias usable alias to be used for the chart + Alias string `json:"alias,omitempty"` +} + +// Validate checks for common problems with the dependency datastructure in +// the chart. This check must be done at load time before the dependency's charts are +// loaded. +func (d *Dependency) Validate() error { + if d == nil { + return ValidationError("dependencies must not contain empty or null nodes") + } + d.Name = sanitizeString(d.Name) + d.Version = sanitizeString(d.Version) + d.Repository = sanitizeString(d.Repository) + d.Condition = sanitizeString(d.Condition) + for i := range d.Tags { + d.Tags[i] = sanitizeString(d.Tags[i]) + } + if d.Alias != "" && !aliasNameFormat.MatchString(d.Alias) { + return ValidationErrorf("dependency %q has disallowed characters in the alias", d.Name) + } + return nil +} + +// Lock is a lock file for dependencies. +// +// It represents the state that the dependencies should be in. +type Lock struct { + // Generated is the date the lock file was last generated. + Generated time.Time `json:"generated"` + // Digest is a hash of the dependencies in Chart.yaml. + Digest string `json:"digest"` + // Dependencies is the list of dependencies that this lock file has locked. + Dependencies []*Dependency `json:"dependencies"` +} diff --git a/pkg/chart/dependency_test.go b/pkg/chart/dependency_test.go new file mode 100644 index 00000000..90488a96 --- /dev/null +++ b/pkg/chart/dependency_test.go @@ -0,0 +1,44 @@ +/* +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 chart + +import ( + "testing" +) + +func TestValidateDependency(t *testing.T) { + dep := &Dependency{ + Name: "example", + } + for value, shouldFail := range map[string]bool{ + "abcdefghijklmenopQRSTUVWXYZ-0123456780_": false, + "-okay": false, + "_okay": false, + "- bad": true, + " bad": true, + "bad\nvalue": true, + "bad ": true, + "bad$": true, + } { + dep.Alias = value + res := dep.Validate() + if res != nil && !shouldFail { + t.Errorf("Failed on case %q", dep.Alias) + } else if res == nil && shouldFail { + t.Errorf("Expected failure for %q", dep.Alias) + } + } +} diff --git a/pkg/chart/errors.go b/pkg/chart/errors.go new file mode 100644 index 00000000..2fad5f37 --- /dev/null +++ b/pkg/chart/errors.go @@ -0,0 +1,30 @@ +/* +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 chart + +import "fmt" + +// ValidationError represents a data validation error. +type ValidationError string + +func (v ValidationError) Error() string { + return "validation: " + string(v) +} + +// ValidationErrorf takes a message and formatting options and creates a ValidationError +func ValidationErrorf(msg string, args ...interface{}) ValidationError { + return ValidationError(fmt.Sprintf(msg, args...)) +} diff --git a/pkg/chart/file.go b/pkg/chart/file.go new file mode 100644 index 00000000..9dd7c08d --- /dev/null +++ b/pkg/chart/file.go @@ -0,0 +1,27 @@ +/* +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 chart + +// File represents a file as a name/value pair. +// +// By convention, name is a relative path within the scope of the chart's +// base directory. +type File struct { + // Name is the path-like name of the template. + Name string `json:"name"` + // Data is the template as byte data. + Data []byte `json:"data"` +} diff --git a/pkg/chart/loader/archive.go b/pkg/chart/loader/archive.go new file mode 100644 index 00000000..196e5f81 --- /dev/null +++ b/pkg/chart/loader/archive.go @@ -0,0 +1,205 @@ +/* +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 loader + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "fmt" + "io" + "net/http" + "os" + "path" + "regexp" + "strings" + + "github.com/pkg/errors" + + "helm.sh/helm/v3/pkg/chart" +) + +var drivePathPattern = regexp.MustCompile(`^[a-zA-Z]:/`) + +// FileLoader loads a chart from a file +type FileLoader string + +// Load loads a chart +func (l FileLoader) Load() (*chart.Chart, error) { + return LoadFile(string(l)) +} + +// LoadFile loads from an archive file. +func LoadFile(name string) (*chart.Chart, error) { + if fi, err := os.Stat(name); err != nil { + return nil, err + } else if fi.IsDir() { + return nil, errors.New("cannot load a directory") + } + + raw, err := os.Open(name) + if err != nil { + return nil, err + } + defer raw.Close() + + err = ensureArchive(name, raw) + if err != nil { + return nil, err + } + + c, err := LoadArchive(raw) + if err != nil { + if err == gzip.ErrHeader { + return nil, fmt.Errorf("file '%s' does not appear to be a valid chart file (details: %s)", name, err) + } + } + return c, err +} + +// ensureArchive's job is to return an informative error if the file does not appear to be a gzipped archive. +// +// Sometimes users will provide a values.yaml for an argument where a chart is expected. One common occurrence +// of this is invoking `helm template values.yaml mychart` which would otherwise produce a confusing error +// if we didn't check for this. +func ensureArchive(name string, raw *os.File) error { + defer raw.Seek(0, 0) // reset read offset to allow archive loading to proceed. + + // Check the file format to give us a chance to provide the user with more actionable feedback. + buffer := make([]byte, 512) + _, err := raw.Read(buffer) + if err != nil && err != io.EOF { + return fmt.Errorf("file '%s' cannot be read: %s", name, err) + } + + // Helm may identify achieve of the application/x-gzip as application/vnd.ms-fontobject. + // Fix for: https://github.com/helm/helm/issues/12261 + if contentType := http.DetectContentType(buffer); contentType != "application/x-gzip" && !isGZipApplication(buffer) { + // TODO: Is there a way to reliably test if a file content is YAML? ghodss/yaml accepts a wide + // variety of content (Makefile, .zshrc) as valid YAML without errors. + + // Wrong content type. Let's check if it's yaml and give an extra hint? + if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") { + return fmt.Errorf("file '%s' seems to be a YAML file, but expected a gzipped archive", name) + } + return fmt.Errorf("file '%s' does not appear to be a gzipped archive; got '%s'", name, contentType) + } + return nil +} + +// isGZipApplication checks whether the achieve is of the application/x-gzip type. +func isGZipApplication(data []byte) bool { + sig := []byte("\x1F\x8B\x08") + return bytes.HasPrefix(data, sig) +} + +// LoadArchiveFiles reads in files out of an archive into memory. This function +// performs important path security checks and should always be used before +// expanding a tarball +func LoadArchiveFiles(in io.Reader) ([]*BufferedFile, error) { + unzipped, err := gzip.NewReader(in) + if err != nil { + return nil, err + } + defer unzipped.Close() + + files := []*BufferedFile{} + tr := tar.NewReader(unzipped) + for { + b := bytes.NewBuffer(nil) + hd, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + + if hd.FileInfo().IsDir() { + // Use this instead of hd.Typeflag because we don't have to do any + // inference chasing. + continue + } + + switch hd.Typeflag { + // We don't want to process these extension header files. + case tar.TypeXGlobalHeader, tar.TypeXHeader: + continue + } + + // Archive could contain \ if generated on Windows + delimiter := "/" + if strings.ContainsRune(hd.Name, '\\') { + delimiter = "\\" + } + + parts := strings.Split(hd.Name, delimiter) + n := strings.Join(parts[1:], delimiter) + + // Normalize the path to the / delimiter + n = strings.ReplaceAll(n, delimiter, "/") + + if path.IsAbs(n) { + return nil, errors.New("chart illegally contains absolute paths") + } + + n = path.Clean(n) + if n == "." { + // In this case, the original path was relative when it should have been absolute. + return nil, errors.Errorf("chart illegally contains content outside the base directory: %q", hd.Name) + } + if strings.HasPrefix(n, "..") { + return nil, errors.New("chart illegally references parent directory") + } + + // In some particularly arcane acts of path creativity, it is possible to intermix + // UNIX and Windows style paths in such a way that you produce a result of the form + // c:/foo even after all the built-in absolute path checks. So we explicitly check + // for this condition. + if drivePathPattern.MatchString(n) { + return nil, errors.New("chart contains illegally named files") + } + + if parts[0] == "Chart.yaml" { + return nil, errors.New("chart yaml not in base directory") + } + + if _, err := io.Copy(b, tr); err != nil { + return nil, err + } + + data := bytes.TrimPrefix(b.Bytes(), utf8bom) + + files = append(files, &BufferedFile{Name: n, Data: data}) + b.Reset() + } + + if len(files) == 0 { + return nil, errors.New("no files in chart archive") + } + return files, nil +} + +// LoadArchive loads from a reader containing a compressed tar archive. +func LoadArchive(in io.Reader) (*chart.Chart, error) { + files, err := LoadArchiveFiles(in) + if err != nil { + return nil, err + } + + return LoadFiles(files) +} diff --git a/pkg/chart/loader/archive_test.go b/pkg/chart/loader/archive_test.go new file mode 100644 index 00000000..41b0af1a --- /dev/null +++ b/pkg/chart/loader/archive_test.go @@ -0,0 +1,90 @@ +/* +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 loader + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "testing" +) + +func TestLoadArchiveFiles(t *testing.T) { + tcs := []struct { + name string + generate func(w *tar.Writer) + check func(t *testing.T, files []*BufferedFile, err error) + }{ + { + name: "empty input should return no files", + generate: func(w *tar.Writer) {}, + check: func(t *testing.T, files []*BufferedFile, err error) { + if err.Error() != "no files in chart archive" { + t.Fatalf(`expected "no files in chart archive", got [%#v]`, err) + } + }, + }, + { + name: "should ignore files with XGlobalHeader type", + generate: func(w *tar.Writer) { + // simulate the presence of a `pax_global_header` file like you would get when + // processing a GitHub release archive. + err := w.WriteHeader(&tar.Header{ + Typeflag: tar.TypeXGlobalHeader, + Name: "pax_global_header", + }) + if err != nil { + t.Fatal(err) + } + + // we need to have at least one file, otherwise we'll get the "no files in chart archive" error + err = w.WriteHeader(&tar.Header{ + Typeflag: tar.TypeReg, + Name: "dir/empty", + }) + if err != nil { + t.Fatal(err) + } + }, + check: func(t *testing.T, files []*BufferedFile, err error) { + if err != nil { + t.Fatalf(`got unwanted error [%#v] for tar file with pax_global_header content`, err) + } + + if len(files) != 1 { + t.Fatalf(`expected to get one file but got [%v]`, files) + } + }, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + buf := &bytes.Buffer{} + gzw := gzip.NewWriter(buf) + tw := tar.NewWriter(gzw) + + tc.generate(tw) + + _ = tw.Close() + _ = gzw.Close() + + files, err := LoadArchiveFiles(buf) + tc.check(t, files, err) + }) + } +} diff --git a/pkg/chart/loader/directory.go b/pkg/chart/loader/directory.go new file mode 100644 index 00000000..9bcbee60 --- /dev/null +++ b/pkg/chart/loader/directory.go @@ -0,0 +1,119 @@ +/* +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 loader + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/pkg/errors" + + "helm.sh/helm/v3/internal/sympath" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/ignore" +) + +var utf8bom = []byte{0xEF, 0xBB, 0xBF} + +// DirLoader loads a chart from a directory +type DirLoader string + +// Load loads the chart +func (l DirLoader) Load() (*chart.Chart, error) { + return LoadDir(string(l)) +} + +// LoadDir loads from a directory. +// +// This loads charts only from directories. +func LoadDir(dir string) (*chart.Chart, error) { + topdir, err := filepath.Abs(dir) + if err != nil { + return nil, err + } + + // Just used for errors. + c := &chart.Chart{} + + rules := ignore.Empty() + ifile := filepath.Join(topdir, ignore.HelmIgnore) + if _, err := os.Stat(ifile); err == nil { + r, err := ignore.ParseFile(ifile) + if err != nil { + return c, err + } + rules = r + } + rules.AddDefaults() + + files := []*BufferedFile{} + topdir += string(filepath.Separator) + + walk := func(name string, fi os.FileInfo, err error) error { + n := strings.TrimPrefix(name, topdir) + if n == "" { + // No need to process top level. Avoid bug with helmignore .* matching + // empty names. See issue 1779. + return nil + } + + // Normalize to / since it will also work on Windows + n = filepath.ToSlash(n) + + if err != nil { + return err + } + if fi.IsDir() { + // Directory-based ignore rules should involve skipping the entire + // contents of that directory. + if rules.Ignore(n, fi) { + return filepath.SkipDir + } + return nil + } + + // If a .helmignore file matches, skip this file. + if rules.Ignore(n, fi) { + return nil + } + + // Irregular files include devices, sockets, and other uses of files that + // are not regular files. In Go they have a file mode type bit set. + // See https://golang.org/pkg/os/#FileMode for examples. + if !fi.Mode().IsRegular() { + return fmt.Errorf("cannot load irregular file %s as it has file mode type bits set", name) + } + + data, err := os.ReadFile(name) + if err != nil { + return errors.Wrapf(err, "error reading %s", n) + } + + data = bytes.TrimPrefix(data, utf8bom) + + files = append(files, &BufferedFile{Name: n, Data: data}) + return nil + } + if err = sympath.Walk(topdir, walk); err != nil { + return c, err + } + + return LoadFiles(files) +} diff --git a/pkg/chart/loader/load.go b/pkg/chart/loader/load.go new file mode 100644 index 00000000..7cc8878a --- /dev/null +++ b/pkg/chart/loader/load.go @@ -0,0 +1,200 @@ +/* +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 loader + +import ( + "bytes" + "log" + "os" + "path/filepath" + "strings" + + "github.com/pkg/errors" + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/pkg/chart" +) + +// ChartLoader loads a chart. +type ChartLoader interface { + Load() (*chart.Chart, error) +} + +// Loader returns a new ChartLoader appropriate for the given chart name +func Loader(name string) (ChartLoader, error) { + fi, err := os.Stat(name) + if err != nil { + return nil, err + } + if fi.IsDir() { + return DirLoader(name), nil + } + return FileLoader(name), nil + +} + +// Load takes a string name, tries to resolve it to a file or directory, and then loads it. +// +// This is the preferred way to load a chart. It will discover the chart encoding +// and hand off to the appropriate chart reader. +// +// If a .helmignore file is present, the directory loader will skip loading any files +// matching it. But .helmignore is not evaluated when reading out of an archive. +func Load(name string) (*chart.Chart, error) { + l, err := Loader(name) + if err != nil { + return nil, err + } + return l.Load() +} + +// BufferedFile represents an archive file buffered for later processing. +type BufferedFile struct { + Name string + Data []byte +} + +// LoadFiles loads from in-memory files. +func LoadFiles(files []*BufferedFile) (*chart.Chart, error) { + c := new(chart.Chart) + subcharts := make(map[string][]*BufferedFile) + + // do not rely on assumed ordering of files in the chart and crash + // if Chart.yaml was not coming early enough to initialize metadata + for _, f := range files { + c.Raw = append(c.Raw, &chart.File{Name: f.Name, Data: f.Data}) + if f.Name == "Chart.yaml" { + if c.Metadata == nil { + c.Metadata = new(chart.Metadata) + } + if err := yaml.Unmarshal(f.Data, c.Metadata); err != nil { + return c, errors.Wrap(err, "cannot load Chart.yaml") + } + // NOTE(bacongobbler): while the chart specification says that APIVersion must be set, + // Helm 2 accepted charts that did not provide an APIVersion in their chart metadata. + // Because of that, if APIVersion is unset, we should assume we're loading a v1 chart. + if c.Metadata.APIVersion == "" { + c.Metadata.APIVersion = chart.APIVersionV1 + } + } + } + for _, f := range files { + switch { + case f.Name == "Chart.yaml": + // already processed + continue + case f.Name == "Chart.lock": + c.Lock = new(chart.Lock) + if err := yaml.Unmarshal(f.Data, &c.Lock); err != nil { + return c, errors.Wrap(err, "cannot load Chart.lock") + } + case f.Name == "values.yaml": + c.Values = make(map[string]interface{}) + if err := yaml.Unmarshal(f.Data, &c.Values); err != nil { + return c, errors.Wrap(err, "cannot load values.yaml") + } + case f.Name == "values.schema.json": + c.Schema = f.Data + + // Deprecated: requirements.yaml is deprecated use Chart.yaml. + // We will handle it for you because we are nice people + case f.Name == "requirements.yaml": + if c.Metadata == nil { + c.Metadata = new(chart.Metadata) + } + if c.Metadata.APIVersion != chart.APIVersionV1 { + log.Printf("Warning: Dependencies are handled in Chart.yaml since apiVersion \"v2\". We recommend migrating dependencies to Chart.yaml.") + } + if err := yaml.Unmarshal(f.Data, c.Metadata); err != nil { + return c, errors.Wrap(err, "cannot load requirements.yaml") + } + if c.Metadata.APIVersion == chart.APIVersionV1 { + c.Files = append(c.Files, &chart.File{Name: f.Name, Data: f.Data}) + } + // Deprecated: requirements.lock is deprecated use Chart.lock. + case f.Name == "requirements.lock": + c.Lock = new(chart.Lock) + if err := yaml.Unmarshal(f.Data, &c.Lock); err != nil { + return c, errors.Wrap(err, "cannot load requirements.lock") + } + if c.Metadata == nil { + c.Metadata = new(chart.Metadata) + } + if c.Metadata.APIVersion == chart.APIVersionV1 { + c.Files = append(c.Files, &chart.File{Name: f.Name, Data: f.Data}) + } + + case strings.HasPrefix(f.Name, "templates/"): + c.Templates = append(c.Templates, &chart.File{Name: f.Name, Data: f.Data}) + case strings.HasPrefix(f.Name, "charts/"): + if filepath.Ext(f.Name) == ".prov" { + c.Files = append(c.Files, &chart.File{Name: f.Name, Data: f.Data}) + continue + } + + fname := strings.TrimPrefix(f.Name, "charts/") + cname := strings.SplitN(fname, "/", 2)[0] + subcharts[cname] = append(subcharts[cname], &BufferedFile{Name: fname, Data: f.Data}) + default: + c.Files = append(c.Files, &chart.File{Name: f.Name, Data: f.Data}) + } + } + + if c.Metadata == nil { + return c, errors.New("Chart.yaml file is missing") + } + + if err := c.Validate(); err != nil { + return c, err + } + + for n, files := range subcharts { + var sc *chart.Chart + var err error + switch { + case strings.IndexAny(n, "_.") == 0: + continue + case filepath.Ext(n) == ".tgz": + file := files[0] + if file.Name != n { + return c, errors.Errorf("error unpacking tar in %s: expected %s, got %s", c.Name(), n, file.Name) + } + // Untar the chart and add to c.Dependencies + sc, err = LoadArchive(bytes.NewBuffer(file.Data)) + default: + // We have to trim the prefix off of every file, and ignore any file + // that is in charts/, but isn't actually a chart. + buff := make([]*BufferedFile, 0, len(files)) + for _, f := range files { + parts := strings.SplitN(f.Name, "/", 2) + if len(parts) < 2 { + continue + } + f.Name = parts[1] + buff = append(buff, f) + } + sc, err = LoadFiles(buff) + } + + if err != nil { + return c, errors.Wrapf(err, "error unpacking %s in %s", n, c.Name()) + } + c.AddDependency(sc) + } + + return c, nil +} diff --git a/pkg/chart/loader/load_test.go b/pkg/chart/loader/load_test.go new file mode 100644 index 00000000..098e6155 --- /dev/null +++ b/pkg/chart/loader/load_test.go @@ -0,0 +1,648 @@ +/* +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 loader + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "io" + "log" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "helm.sh/helm/v3/pkg/chart" +) + +func TestLoadDir(t *testing.T) { + l, err := Loader("testdata/frobnitz") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + c, err := l.Load() + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyFrobnitz(t, c) + verifyChart(t, c) + verifyDependencies(t, c) + verifyDependenciesLock(t, c) +} + +func TestLoadDirWithDevNull(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test only works on unix systems with /dev/null present") + } + + l, err := Loader("testdata/frobnitz_with_dev_null") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + if _, err := l.Load(); err == nil { + t.Errorf("packages with an irregular file (/dev/null) should not load") + } +} + +func TestLoadDirWithSymlink(t *testing.T) { + sym := filepath.Join("..", "LICENSE") + link := filepath.Join("testdata", "frobnitz_with_symlink", "LICENSE") + + if err := os.Symlink(sym, link); err != nil { + t.Fatal(err) + } + + defer os.Remove(link) + + l, err := Loader("testdata/frobnitz_with_symlink") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + + c, err := l.Load() + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyFrobnitz(t, c) + verifyChart(t, c) + verifyDependencies(t, c) + verifyDependenciesLock(t, c) +} + +func TestBomTestData(t *testing.T) { + testFiles := []string{"frobnitz_with_bom/.helmignore", "frobnitz_with_bom/templates/template.tpl", "frobnitz_with_bom/Chart.yaml"} + for _, file := range testFiles { + data, err := os.ReadFile("testdata/" + file) + if err != nil || !bytes.HasPrefix(data, utf8bom) { + t.Errorf("Test file has no BOM or is invalid: testdata/%s", file) + } + } + + archive, err := os.ReadFile("testdata/frobnitz_with_bom.tgz") + if err != nil { + t.Fatalf("Error reading archive frobnitz_with_bom.tgz: %s", err) + } + unzipped, err := gzip.NewReader(bytes.NewReader(archive)) + if err != nil { + t.Fatalf("Error reading archive frobnitz_with_bom.tgz: %s", err) + } + defer unzipped.Close() + for _, testFile := range testFiles { + data := make([]byte, 3) + err := unzipped.Reset(bytes.NewReader(archive)) + if err != nil { + t.Fatalf("Error reading archive frobnitz_with_bom.tgz: %s", err) + } + tr := tar.NewReader(unzipped) + for { + file, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("Error reading archive frobnitz_with_bom.tgz: %s", err) + } + if file != nil && strings.EqualFold(file.Name, testFile) { + _, err := tr.Read(data) + if err != nil { + t.Fatalf("Error reading archive frobnitz_with_bom.tgz: %s", err) + } else { + break + } + } + } + if !bytes.Equal(data, utf8bom) { + t.Fatalf("Test file has no BOM or is invalid: frobnitz_with_bom.tgz/%s", testFile) + } + } +} + +func TestLoadDirWithUTFBOM(t *testing.T) { + l, err := Loader("testdata/frobnitz_with_bom") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + c, err := l.Load() + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyFrobnitz(t, c) + verifyChart(t, c) + verifyDependencies(t, c) + verifyDependenciesLock(t, c) + verifyBomStripped(t, c.Files) +} + +func TestLoadArchiveWithUTFBOM(t *testing.T) { + l, err := Loader("testdata/frobnitz_with_bom.tgz") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + c, err := l.Load() + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyFrobnitz(t, c) + verifyChart(t, c) + verifyDependencies(t, c) + verifyDependenciesLock(t, c) + verifyBomStripped(t, c.Files) +} + +func TestLoadV1(t *testing.T) { + l, err := Loader("testdata/frobnitz.v1") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + c, err := l.Load() + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyDependencies(t, c) + verifyDependenciesLock(t, c) +} + +func TestLoadFileV1(t *testing.T) { + l, err := Loader("testdata/frobnitz.v1.tgz") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + c, err := l.Load() + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyDependencies(t, c) + verifyDependenciesLock(t, c) +} + +func TestLoadFile(t *testing.T) { + l, err := Loader("testdata/frobnitz-1.2.3.tgz") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + c, err := l.Load() + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyFrobnitz(t, c) + verifyChart(t, c) + verifyDependencies(t, c) +} + +func TestLoadFiles_BadCases(t *testing.T) { + for _, tt := range []struct { + name string + bufferedFiles []*BufferedFile + expectError string + }{ + { + name: "These files contain only requirements.lock", + bufferedFiles: []*BufferedFile{ + { + Name: "requirements.lock", + Data: []byte(""), + }, + }, + expectError: "validation: chart.metadata.apiVersion is required"}, + } { + _, err := LoadFiles(tt.bufferedFiles) + if err == nil { + t.Fatal("expected error when load illegal files") + } + if !strings.Contains(err.Error(), tt.expectError) { + t.Errorf("Expected error to contain %q, got %q for %s", tt.expectError, err.Error(), tt.name) + } + } +} + +func TestLoadFiles(t *testing.T) { + goodFiles := []*BufferedFile{ + { + Name: "Chart.yaml", + Data: []byte(`apiVersion: v1 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +`), + }, + { + Name: "values.yaml", + Data: []byte("var: some values"), + }, + { + Name: "values.schema.json", + Data: []byte("type: Values"), + }, + { + Name: "templates/deployment.yaml", + Data: []byte("some deployment"), + }, + { + Name: "templates/service.yaml", + Data: []byte("some service"), + }, + } + + c, err := LoadFiles(goodFiles) + if err != nil { + t.Errorf("Expected good files to be loaded, got %v", err) + } + + if c.Name() != "frobnitz" { + t.Errorf("Expected chart name to be 'frobnitz', got %s", c.Name()) + } + + if c.Values["var"] != "some values" { + t.Error("Expected chart values to be populated with default values") + } + + if len(c.Raw) != 5 { + t.Errorf("Expected %d files, got %d", 5, len(c.Raw)) + } + + if !bytes.Equal(c.Schema, []byte("type: Values")) { + t.Error("Expected chart schema to be populated with default values") + } + + if len(c.Templates) != 2 { + t.Errorf("Expected number of templates == 2, got %d", len(c.Templates)) + } + + if _, err = LoadFiles([]*BufferedFile{}); err == nil { + t.Fatal("Expected err to be non-nil") + } + if err.Error() != "Chart.yaml file is missing" { + t.Errorf("Expected chart metadata missing error, got '%s'", err.Error()) + } +} + +// Test the order of file loading. The Chart.yaml file needs to come first for +// later comparison checks. See https://github.com/helm/helm/pull/8948 +func TestLoadFilesOrder(t *testing.T) { + goodFiles := []*BufferedFile{ + { + Name: "requirements.yaml", + Data: []byte("dependencies:"), + }, + { + Name: "values.yaml", + Data: []byte("var: some values"), + }, + + { + Name: "templates/deployment.yaml", + Data: []byte("some deployment"), + }, + { + Name: "templates/service.yaml", + Data: []byte("some service"), + }, + { + Name: "Chart.yaml", + Data: []byte(`apiVersion: v1 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +`), + }, + } + + // Capture stderr to make sure message about Chart.yaml handle dependencies + // is not present + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("Unable to create pipe: %s", err) + } + stderr := log.Writer() + log.SetOutput(w) + defer func() { + log.SetOutput(stderr) + }() + + _, err = LoadFiles(goodFiles) + if err != nil { + t.Errorf("Expected good files to be loaded, got %v", err) + } + w.Close() + + var text bytes.Buffer + io.Copy(&text, r) + if text.String() != "" { + t.Errorf("Expected no message to Stderr, got %s", text.String()) + } + +} + +// Packaging the chart on a Windows machine will produce an +// archive that has \\ as delimiters. Test that we support these archives +func TestLoadFileBackslash(t *testing.T) { + c, err := Load("testdata/frobnitz_backslash-1.2.3.tgz") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyChartFileAndTemplate(t, c, "frobnitz_backslash") + verifyChart(t, c) + verifyDependencies(t, c) +} + +func TestLoadV2WithReqs(t *testing.T) { + l, err := Loader("testdata/frobnitz.v2.reqs") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + c, err := l.Load() + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyDependencies(t, c) + verifyDependenciesLock(t, c) +} + +func TestLoadInvalidArchive(t *testing.T) { + tmpdir := t.TempDir() + + writeTar := func(filename, internalPath string, body []byte) { + dest, err := os.Create(filename) + if err != nil { + t.Fatal(err) + } + zipper := gzip.NewWriter(dest) + tw := tar.NewWriter(zipper) + + h := &tar.Header{ + Name: internalPath, + Mode: 0755, + Size: int64(len(body)), + ModTime: time.Now(), + } + if err := tw.WriteHeader(h); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(body); err != nil { + t.Fatal(err) + } + tw.Close() + zipper.Close() + dest.Close() + } + + for _, tt := range []struct { + chartname string + internal string + expectError string + }{ + {"illegal-dots.tgz", "../../malformed-helm-test", "chart illegally references parent directory"}, + {"illegal-dots2.tgz", "/foo/../../malformed-helm-test", "chart illegally references parent directory"}, + {"illegal-dots3.tgz", "/../../malformed-helm-test", "chart illegally references parent directory"}, + {"illegal-dots4.tgz", "./../../malformed-helm-test", "chart illegally references parent directory"}, + {"illegal-name.tgz", "./.", "chart illegally contains content outside the base directory"}, + {"illegal-name2.tgz", "/./.", "chart illegally contains content outside the base directory"}, + {"illegal-name3.tgz", "missing-leading-slash", "chart illegally contains content outside the base directory"}, + {"illegal-name4.tgz", "/missing-leading-slash", "Chart.yaml file is missing"}, + {"illegal-abspath.tgz", "//foo", "chart illegally contains absolute paths"}, + {"illegal-abspath2.tgz", "///foo", "chart illegally contains absolute paths"}, + {"illegal-abspath3.tgz", "\\\\foo", "chart illegally contains absolute paths"}, + {"illegal-abspath3.tgz", "\\..\\..\\foo", "chart illegally references parent directory"}, + + // Under special circumstances, this can get normalized to things that look like absolute Windows paths + {"illegal-abspath4.tgz", "\\.\\c:\\\\foo", "chart contains illegally named files"}, + {"illegal-abspath5.tgz", "/./c://foo", "chart contains illegally named files"}, + {"illegal-abspath6.tgz", "\\\\?\\Some\\windows\\magic", "chart illegally contains absolute paths"}, + } { + illegalChart := filepath.Join(tmpdir, tt.chartname) + writeTar(illegalChart, tt.internal, []byte("hello: world")) + _, err := Load(illegalChart) + if err == nil { + t.Fatal("expected error when unpacking illegal files") + } + if !strings.Contains(err.Error(), tt.expectError) { + t.Errorf("Expected error to contain %q, got %q for %s", tt.expectError, err.Error(), tt.chartname) + } + } + + // Make sure that absolute path gets interpreted as relative + illegalChart := filepath.Join(tmpdir, "abs-path.tgz") + writeTar(illegalChart, "/Chart.yaml", []byte("hello: world")) + _, err := Load(illegalChart) + if err.Error() != "validation: chart.metadata.name is required" { + t.Error(err) + } + + // And just to validate that the above was not spurious + illegalChart = filepath.Join(tmpdir, "abs-path2.tgz") + writeTar(illegalChart, "files/whatever.yaml", []byte("hello: world")) + _, err = Load(illegalChart) + if err.Error() != "Chart.yaml file is missing" { + t.Errorf("Unexpected error message: %s", err) + } + + // Finally, test that drive letter gets stripped off on Windows + illegalChart = filepath.Join(tmpdir, "abs-winpath.tgz") + writeTar(illegalChart, "c:\\Chart.yaml", []byte("hello: world")) + _, err = Load(illegalChart) + if err.Error() != "validation: chart.metadata.name is required" { + t.Error(err) + } +} + +func verifyChart(t *testing.T, c *chart.Chart) { + t.Helper() + if c.Name() == "" { + t.Fatalf("No chart metadata found on %v", c) + } + t.Logf("Verifying chart %s", c.Name()) + if len(c.Templates) != 1 { + t.Errorf("Expected 1 template, got %d", len(c.Templates)) + } + + numfiles := 6 + if len(c.Files) != numfiles { + t.Errorf("Expected %d extra files, got %d", numfiles, len(c.Files)) + for _, n := range c.Files { + t.Logf("\t%s", n.Name) + } + } + + if len(c.Dependencies()) != 2 { + t.Errorf("Expected 2 dependencies, got %d (%v)", len(c.Dependencies()), c.Dependencies()) + for _, d := range c.Dependencies() { + t.Logf("\tSubchart: %s\n", d.Name()) + } + } + + expect := map[string]map[string]string{ + "alpine": { + "version": "0.1.0", + }, + "mariner": { + "version": "4.3.2", + }, + } + + for _, dep := range c.Dependencies() { + if dep.Metadata == nil { + t.Fatalf("expected metadata on dependency: %v", dep) + } + exp, ok := expect[dep.Name()] + if !ok { + t.Fatalf("Unknown dependency %s", dep.Name()) + } + if exp["version"] != dep.Metadata.Version { + t.Errorf("Expected %s version %s, got %s", dep.Name(), exp["version"], dep.Metadata.Version) + } + } + +} + +func verifyDependencies(t *testing.T, c *chart.Chart) { + if len(c.Metadata.Dependencies) != 2 { + t.Errorf("Expected 2 dependencies, got %d", len(c.Metadata.Dependencies)) + } + tests := []*chart.Dependency{ + {Name: "alpine", Version: "0.1.0", Repository: "https://example.com/charts"}, + {Name: "mariner", Version: "4.3.2", Repository: "https://example.com/charts"}, + } + for i, tt := range tests { + d := c.Metadata.Dependencies[i] + if d.Name != tt.Name { + t.Errorf("Expected dependency named %q, got %q", tt.Name, d.Name) + } + if d.Version != tt.Version { + t.Errorf("Expected dependency named %q to have version %q, got %q", tt.Name, tt.Version, d.Version) + } + if d.Repository != tt.Repository { + t.Errorf("Expected dependency named %q to have repository %q, got %q", tt.Name, tt.Repository, d.Repository) + } + } +} + +func verifyDependenciesLock(t *testing.T, c *chart.Chart) { + if len(c.Metadata.Dependencies) != 2 { + t.Errorf("Expected 2 dependencies, got %d", len(c.Metadata.Dependencies)) + } + tests := []*chart.Dependency{ + {Name: "alpine", Version: "0.1.0", Repository: "https://example.com/charts"}, + {Name: "mariner", Version: "4.3.2", Repository: "https://example.com/charts"}, + } + for i, tt := range tests { + d := c.Metadata.Dependencies[i] + if d.Name != tt.Name { + t.Errorf("Expected dependency named %q, got %q", tt.Name, d.Name) + } + if d.Version != tt.Version { + t.Errorf("Expected dependency named %q to have version %q, got %q", tt.Name, tt.Version, d.Version) + } + if d.Repository != tt.Repository { + t.Errorf("Expected dependency named %q to have repository %q, got %q", tt.Name, tt.Repository, d.Repository) + } + } +} + +func verifyFrobnitz(t *testing.T, c *chart.Chart) { + verifyChartFileAndTemplate(t, c, "frobnitz") +} + +func verifyChartFileAndTemplate(t *testing.T, c *chart.Chart, name string) { + if c.Metadata == nil { + t.Fatal("Metadata is nil") + } + if c.Name() != name { + t.Errorf("Expected %s, got %s", name, c.Name()) + } + if len(c.Templates) != 1 { + t.Fatalf("Expected 1 template, got %d", len(c.Templates)) + } + if c.Templates[0].Name != "templates/template.tpl" { + t.Errorf("Unexpected template: %s", c.Templates[0].Name) + } + if len(c.Templates[0].Data) == 0 { + t.Error("No template data.") + } + if len(c.Files) != 6 { + t.Fatalf("Expected 6 Files, got %d", len(c.Files)) + } + if len(c.Dependencies()) != 2 { + t.Fatalf("Expected 2 Dependency, got %d", len(c.Dependencies())) + } + if len(c.Metadata.Dependencies) != 2 { + t.Fatalf("Expected 2 Dependencies.Dependency, got %d", len(c.Metadata.Dependencies)) + } + if len(c.Lock.Dependencies) != 2 { + t.Fatalf("Expected 2 Lock.Dependency, got %d", len(c.Lock.Dependencies)) + } + + for _, dep := range c.Dependencies() { + switch dep.Name() { + case "mariner": + case "alpine": + if len(dep.Templates) != 1 { + t.Fatalf("Expected 1 template, got %d", len(dep.Templates)) + } + if dep.Templates[0].Name != "templates/alpine-pod.yaml" { + t.Errorf("Unexpected template: %s", dep.Templates[0].Name) + } + if len(dep.Templates[0].Data) == 0 { + t.Error("No template data.") + } + if len(dep.Files) != 1 { + t.Fatalf("Expected 1 Files, got %d", len(dep.Files)) + } + if len(dep.Dependencies()) != 2 { + t.Fatalf("Expected 2 Dependency, got %d", len(dep.Dependencies())) + } + default: + t.Errorf("Unexpected dependency %s", dep.Name()) + } + } +} + +func verifyBomStripped(t *testing.T, files []*chart.File) { + for _, file := range files { + if bytes.HasPrefix(file.Data, utf8bom) { + t.Errorf("Byte Order Mark still present in processed file %s", file.Name) + } + } +} diff --git a/pkg/chart/loader/testdata/LICENSE b/pkg/chart/loader/testdata/LICENSE new file mode 100644 index 00000000..6121943b --- /dev/null +++ b/pkg/chart/loader/testdata/LICENSE @@ -0,0 +1 @@ +LICENSE placeholder. diff --git a/pkg/chart/loader/testdata/albatross/Chart.yaml b/pkg/chart/loader/testdata/albatross/Chart.yaml new file mode 100644 index 00000000..eeef737f --- /dev/null +++ b/pkg/chart/loader/testdata/albatross/Chart.yaml @@ -0,0 +1,4 @@ +name: albatross +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/chart/loader/testdata/albatross/values.yaml b/pkg/chart/loader/testdata/albatross/values.yaml new file mode 100644 index 00000000..3121cd7c --- /dev/null +++ b/pkg/chart/loader/testdata/albatross/values.yaml @@ -0,0 +1,4 @@ +albatross: "true" + +global: + author: Coleridge diff --git a/pkg/chart/loader/testdata/frobnitz-1.2.3.tgz b/pkg/chart/loader/testdata/frobnitz-1.2.3.tgz new file mode 100644 index 0000000000000000000000000000000000000000..b2b76a83c768f144f8b436a0deeb9da2c9a57bd1 GIT binary patch literal 3482 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 literal 0 HcmV?d00001 diff --git a/pkg/chart/loader/testdata/frobnitz.v1.tgz b/pkg/chart/loader/testdata/frobnitz.v1.tgz new file mode 100644 index 0000000000000000000000000000000000000000..6282f9b73fb2877b76328adeaccdc8d94b1097c2 GIT binary patch literal 3525 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 literal 0 HcmV?d00001 diff --git a/pkg/chart/loader/testdata/frobnitz.v1/.helmignore b/pkg/chart/loader/testdata/frobnitz.v1/.helmignore new file mode 100644 index 00000000..9973a57b --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v1/.helmignore @@ -0,0 +1 @@ +ignore/ diff --git a/pkg/chart/loader/testdata/frobnitz.v1/Chart.lock b/pkg/chart/loader/testdata/frobnitz.v1/Chart.lock new file mode 100644 index 00000000..6fcc2ed9 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v1/Chart.lock @@ -0,0 +1,8 @@ +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts +digest: invalid diff --git a/pkg/chart/loader/testdata/frobnitz.v1/Chart.yaml b/pkg/chart/loader/testdata/frobnitz.v1/Chart.yaml new file mode 100644 index 00000000..134cd110 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v1/Chart.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +annotations: + extrakey: extravalue + anotherkey: anothervalue diff --git a/pkg/chart/loader/testdata/frobnitz.v1/INSTALL.txt b/pkg/chart/loader/testdata/frobnitz.v1/INSTALL.txt new file mode 100644 index 00000000..2010438c --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v1/INSTALL.txt @@ -0,0 +1 @@ +This is an install document. The client may display this. diff --git a/pkg/chart/loader/testdata/frobnitz.v1/LICENSE b/pkg/chart/loader/testdata/frobnitz.v1/LICENSE new file mode 100644 index 00000000..6121943b --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v1/LICENSE @@ -0,0 +1 @@ +LICENSE placeholder. diff --git a/pkg/chart/loader/testdata/frobnitz.v1/README.md b/pkg/chart/loader/testdata/frobnitz.v1/README.md new file mode 100644 index 00000000..8cf4cc3d --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v1/README.md @@ -0,0 +1,11 @@ +# Frobnitz + +This is an example chart. + +## Usage + +This is an example. It has no usage. + +## Development + +For developer info, see the top-level repository. diff --git a/pkg/chart/loader/testdata/frobnitz.v1/charts/_ignore_me b/pkg/chart/loader/testdata/frobnitz.v1/charts/_ignore_me new file mode 100644 index 00000000..2cecca68 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v1/charts/_ignore_me @@ -0,0 +1 @@ +This should be ignored by the loader, but may be included in a chart. diff --git a/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/Chart.yaml b/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/Chart.yaml new file mode 100644 index 00000000..79e0d65d --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: alpine +description: Deploy a basic Alpine Linux pod +version: 0.1.0 +home: https://helm.sh/helm diff --git a/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/README.md b/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/README.md new file mode 100644 index 00000000..b30b949d --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.toml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/charts/mast1/Chart.yaml b/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/charts/mast1/Chart.yaml new file mode 100644 index 00000000..1c9dd5fa --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/charts/mast1/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: mast1 +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/charts/mast1/values.yaml b/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/charts/mast1/values.yaml new file mode 100644 index 00000000..42c39c26 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/charts/mast1/values.yaml @@ -0,0 +1,4 @@ +# Default values for mast1. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name = "value" diff --git a/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/charts/mast2-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..61cb62051110b55f3d08213dc81dcf0b1c2d8e53 GIT binary patch literal 252 zcmVDc zVQyr3R8em|NM&qo0PNJUs=_c72H?(liabH@pWIst-7YSIyL+rhEHrIN(t?QZE=F{y zgNRfS&$pa5Ly`mMk2OB%pV`*9knW7FlL-Joo@KED7*{C$d;N~z z37$S{+}wvSU9}|VtF|fRpv9Ve>8dWo|9?5B+RE}Y9CFh-x#(Bq8Vck^V=NUiPLCKa z8z5CF#JgK!4>;$4Fm+FUst4d+{(+nP|0&J+e}(;l^U4@w-{=?s0RR8vgVbLD3;+OM Cs&R<` literal 0 HcmV?d00001 diff --git a/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/templates/alpine-pod.yaml b/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..21ae20aa --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service}} + app.kubernetes.io/name: {{.Chart.Name}} + helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}" +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.9" + command: ["/bin/sleep","9000"] diff --git a/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/values.yaml b/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/values.yaml new file mode 100644 index 00000000..6c2aab7b --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: "my-alpine" diff --git a/pkg/chart/loader/testdata/frobnitz.v1/charts/mariner-4.3.2.tgz b/pkg/chart/loader/testdata/frobnitz.v1/charts/mariner-4.3.2.tgz new file mode 100644 index 0000000000000000000000000000000000000000..3190136b050e62c628b3c817fd963ac9dc4a9e25 GIT binary patch literal 967 zcmV;&133I2iwFR+9h6)E1MQb_y%a`Bd>#= zhbqf2w!b6}wZ9@rvIhtwzm?~C&+QLm+G2!l%`$_aUgS(@pdjdX3a%E}VXVc7`)dg( zL%IRN2}c1D3xoMi2w@WuWOMZ?5i&3FelBVyq_Ce_8fREdP%NDf<&d!wu3{&VUQNs{N%vK$Ha~k^gB2!0bO7r0ic0 zbqCp*X#j?=|H@GNONsuE)&Idbh>2MX(Z}|+=@*sLod*wS?A8Ugp#mMPuMO0NnZmos9_rr z3xpDL+otj~lRh?B4hHFTl+d5-8NBXmUXB~EyF^bx7m*+!*g>obczvGF|8xkWsHN8; z%#+wiWP{=2pC*98@$VNzzsll&G#D7&11-;D>HT0x|DV2?6}a~*p46@R|2l??e_8dX z@Bb>D3t~VC_*wjq2Dy!6J-_5ME%%J+xmsbK5a#qO>ZV?Wt`d|TDdrB^B&LqFr@lYdUA zs&4-JAg3&uc4dA0gv*bXU9QePa9^8wR{HovA)j@)JOAIkak0Jl)aKqA_3XLM#?H=V z-{tlG=xy^os=1Z><53;&+C4=zp|%rwv!)UyY=%k_t%Y|wNRQl`C6N_Z&S;S+~wb1=)2Uh_4I81`y>DO zoLPSt=btB&x{CUK`0DA&){efW_O36RxnsYZNT0r;JbTLR{H4M3C$8(Cee==aQ~Gbq p$`5v3?NB{4-j0XQHf literal 0 HcmV?d00001 diff --git a/pkg/chart/loader/testdata/frobnitz.v1/docs/README.md b/pkg/chart/loader/testdata/frobnitz.v1/docs/README.md new file mode 100644 index 00000000..d40747ca --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v1/docs/README.md @@ -0,0 +1 @@ +This is a placeholder for documentation. diff --git a/pkg/chart/loader/testdata/frobnitz.v1/icon.svg b/pkg/chart/loader/testdata/frobnitz.v1/icon.svg new file mode 100644 index 00000000..89213060 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v1/icon.svg @@ -0,0 +1,8 @@ + + + Example icon + + + diff --git a/pkg/chart/loader/testdata/frobnitz.v1/ignore/me.txt b/pkg/chart/loader/testdata/frobnitz.v1/ignore/me.txt new file mode 100644 index 00000000..e69de29b diff --git a/pkg/chart/loader/testdata/frobnitz.v1/requirements.yaml b/pkg/chart/loader/testdata/frobnitz.v1/requirements.yaml new file mode 100644 index 00000000..5eb0bc98 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v1/requirements.yaml @@ -0,0 +1,7 @@ +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts diff --git a/pkg/chart/loader/testdata/frobnitz.v1/templates/template.tpl b/pkg/chart/loader/testdata/frobnitz.v1/templates/template.tpl new file mode 100644 index 00000000..c651ee6a --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v1/templates/template.tpl @@ -0,0 +1 @@ +Hello {{.Name | default "world"}} diff --git a/pkg/chart/loader/testdata/frobnitz.v1/values.yaml b/pkg/chart/loader/testdata/frobnitz.v1/values.yaml new file mode 100644 index 00000000..61f50125 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v1/values.yaml @@ -0,0 +1,6 @@ +# A values file contains configuration. + +name: "Some Name" + +section: + name: "Name in a section" diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/.helmignore b/pkg/chart/loader/testdata/frobnitz.v2.reqs/.helmignore new file mode 100644 index 00000000..9973a57b --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v2.reqs/.helmignore @@ -0,0 +1 @@ +ignore/ diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/Chart.yaml b/pkg/chart/loader/testdata/frobnitz.v2.reqs/Chart.yaml new file mode 100644 index 00000000..f3ab3029 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v2.reqs/Chart.yaml @@ -0,0 +1,20 @@ +apiVersion: v2 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +annotations: + extrakey: extravalue + anotherkey: anothervalue diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/INSTALL.txt b/pkg/chart/loader/testdata/frobnitz.v2.reqs/INSTALL.txt new file mode 100644 index 00000000..2010438c --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v2.reqs/INSTALL.txt @@ -0,0 +1 @@ +This is an install document. The client may display this. diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/LICENSE b/pkg/chart/loader/testdata/frobnitz.v2.reqs/LICENSE new file mode 100644 index 00000000..6121943b --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v2.reqs/LICENSE @@ -0,0 +1 @@ +LICENSE placeholder. diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/README.md b/pkg/chart/loader/testdata/frobnitz.v2.reqs/README.md new file mode 100644 index 00000000..8cf4cc3d --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v2.reqs/README.md @@ -0,0 +1,11 @@ +# Frobnitz + +This is an example chart. + +## Usage + +This is an example. It has no usage. + +## Development + +For developer info, see the top-level repository. diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/_ignore_me b/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/_ignore_me new file mode 100644 index 00000000..2cecca68 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/_ignore_me @@ -0,0 +1 @@ +This should be ignored by the loader, but may be included in a chart. diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/Chart.yaml b/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/Chart.yaml new file mode 100644 index 00000000..79e0d65d --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: alpine +description: Deploy a basic Alpine Linux pod +version: 0.1.0 +home: https://helm.sh/helm diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/README.md b/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/README.md new file mode 100644 index 00000000..b30b949d --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.toml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/Chart.yaml b/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/Chart.yaml new file mode 100644 index 00000000..1c9dd5fa --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: mast1 +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/values.yaml b/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/values.yaml new file mode 100644 index 00000000..42c39c26 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/values.yaml @@ -0,0 +1,4 @@ +# Default values for mast1. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name = "value" diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast2-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..61cb62051110b55f3d08213dc81dcf0b1c2d8e53 GIT binary patch literal 252 zcmVDc zVQyr3R8em|NM&qo0PNJUs=_c72H?(liabH@pWIst-7YSIyL+rhEHrIN(t?QZE=F{y zgNRfS&$pa5Ly`mMk2OB%pV`*9knW7FlL-Joo@KED7*{C$d;N~z z37$S{+}wvSU9}|VtF|fRpv9Ve>8dWo|9?5B+RE}Y9CFh-x#(Bq8Vck^V=NUiPLCKa z8z5CF#JgK!4>;$4Fm+FUst4d+{(+nP|0&J+e}(;l^U4@w-{=?s0RR8vgVbLD3;+OM Cs&R<` literal 0 HcmV?d00001 diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/templates/alpine-pod.yaml b/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..21ae20aa --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service}} + app.kubernetes.io/name: {{.Chart.Name}} + helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}" +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.9" + command: ["/bin/sleep","9000"] diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/values.yaml b/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/values.yaml new file mode 100644 index 00000000..6c2aab7b --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: "my-alpine" diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/mariner-4.3.2.tgz b/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/mariner-4.3.2.tgz new file mode 100644 index 0000000000000000000000000000000000000000..3190136b050e62c628b3c817fd963ac9dc4a9e25 GIT binary patch literal 967 zcmV;&133I2iwFR+9h6)E1MQb_y%a`Bd>#= zhbqf2w!b6}wZ9@rvIhtwzm?~C&+QLm+G2!l%`$_aUgS(@pdjdX3a%E}VXVc7`)dg( zL%IRN2}c1D3xoMi2w@WuWOMZ?5i&3FelBVyq_Ce_8fREdP%NDf<&d!wu3{&VUQNs{N%vK$Ha~k^gB2!0bO7r0ic0 zbqCp*X#j?=|H@GNONsuE)&Idbh>2MX(Z}|+=@*sLod*wS?A8Ugp#mMPuMO0NnZmos9_rr z3xpDL+otj~lRh?B4hHFTl+d5-8NBXmUXB~EyF^bx7m*+!*g>obczvGF|8xkWsHN8; z%#+wiWP{=2pC*98@$VNzzsll&G#D7&11-;D>HT0x|DV2?6}a~*p46@R|2l??e_8dX z@Bb>D3t~VC_*wjq2Dy!6J-_5ME%%J+xmsbK5a#qO>ZV?Wt`d|TDdrB^B&LqFr@lYdUA zs&4-JAg3&uc4dA0gv*bXU9QePa9^8wR{HovA)j@)JOAIkak0Jl)aKqA_3XLM#?H=V z-{tlG=xy^os=1Z><53;&+C4=zp|%rwv!)UyY=%k_t%Y|wNRQl`C6N_Z&S;S+~wb1=)2Uh_4I81`y>DO zoLPSt=btB&x{CUK`0DA&){efW_O36RxnsYZNT0r;JbTLR{H4M3C$8(Cee==aQ~Gbq p$`5v3?NB{4-j0XQHf literal 0 HcmV?d00001 diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/docs/README.md b/pkg/chart/loader/testdata/frobnitz.v2.reqs/docs/README.md new file mode 100644 index 00000000..d40747ca --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v2.reqs/docs/README.md @@ -0,0 +1 @@ +This is a placeholder for documentation. diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/icon.svg b/pkg/chart/loader/testdata/frobnitz.v2.reqs/icon.svg new file mode 100644 index 00000000..89213060 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v2.reqs/icon.svg @@ -0,0 +1,8 @@ + + + Example icon + + + diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/ignore/me.txt b/pkg/chart/loader/testdata/frobnitz.v2.reqs/ignore/me.txt new file mode 100644 index 00000000..e69de29b diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/requirements.yaml b/pkg/chart/loader/testdata/frobnitz.v2.reqs/requirements.yaml new file mode 100644 index 00000000..5eb0bc98 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v2.reqs/requirements.yaml @@ -0,0 +1,7 @@ +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/templates/template.tpl b/pkg/chart/loader/testdata/frobnitz.v2.reqs/templates/template.tpl new file mode 100644 index 00000000..c651ee6a --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v2.reqs/templates/template.tpl @@ -0,0 +1 @@ +Hello {{.Name | default "world"}} diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/values.yaml b/pkg/chart/loader/testdata/frobnitz.v2.reqs/values.yaml new file mode 100644 index 00000000..61f50125 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz.v2.reqs/values.yaml @@ -0,0 +1,6 @@ +# A values file contains configuration. + +name: "Some Name" + +section: + name: "Name in a section" diff --git a/pkg/chart/loader/testdata/frobnitz/.helmignore b/pkg/chart/loader/testdata/frobnitz/.helmignore new file mode 100644 index 00000000..9973a57b --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz/.helmignore @@ -0,0 +1 @@ +ignore/ diff --git a/pkg/chart/loader/testdata/frobnitz/Chart.lock b/pkg/chart/loader/testdata/frobnitz/Chart.lock new file mode 100644 index 00000000..6fcc2ed9 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz/Chart.lock @@ -0,0 +1,8 @@ +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts +digest: invalid diff --git a/pkg/chart/loader/testdata/frobnitz/Chart.yaml b/pkg/chart/loader/testdata/frobnitz/Chart.yaml new file mode 100644 index 00000000..fcd4a4a3 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz/Chart.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +annotations: + extrakey: extravalue + anotherkey: anothervalue +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts diff --git a/pkg/chart/loader/testdata/frobnitz/INSTALL.txt b/pkg/chart/loader/testdata/frobnitz/INSTALL.txt new file mode 100644 index 00000000..2010438c --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz/INSTALL.txt @@ -0,0 +1 @@ +This is an install document. The client may display this. diff --git a/pkg/chart/loader/testdata/frobnitz/LICENSE b/pkg/chart/loader/testdata/frobnitz/LICENSE new file mode 100644 index 00000000..6121943b --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz/LICENSE @@ -0,0 +1 @@ +LICENSE placeholder. diff --git a/pkg/chart/loader/testdata/frobnitz/README.md b/pkg/chart/loader/testdata/frobnitz/README.md new file mode 100644 index 00000000..8cf4cc3d --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz/README.md @@ -0,0 +1,11 @@ +# Frobnitz + +This is an example chart. + +## Usage + +This is an example. It has no usage. + +## Development + +For developer info, see the top-level repository. diff --git a/pkg/chart/loader/testdata/frobnitz/charts/_ignore_me b/pkg/chart/loader/testdata/frobnitz/charts/_ignore_me new file mode 100644 index 00000000..2cecca68 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz/charts/_ignore_me @@ -0,0 +1 @@ +This should be ignored by the loader, but may be included in a chart. diff --git a/pkg/chart/loader/testdata/frobnitz/charts/alpine/Chart.yaml b/pkg/chart/loader/testdata/frobnitz/charts/alpine/Chart.yaml new file mode 100644 index 00000000..79e0d65d --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz/charts/alpine/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: alpine +description: Deploy a basic Alpine Linux pod +version: 0.1.0 +home: https://helm.sh/helm diff --git a/pkg/chart/loader/testdata/frobnitz/charts/alpine/README.md b/pkg/chart/loader/testdata/frobnitz/charts/alpine/README.md new file mode 100644 index 00000000..b30b949d --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz/charts/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.toml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/chart/loader/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml b/pkg/chart/loader/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml new file mode 100644 index 00000000..1c9dd5fa --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: mast1 +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/chart/loader/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml b/pkg/chart/loader/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml new file mode 100644 index 00000000..42c39c26 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml @@ -0,0 +1,4 @@ +# Default values for mast1. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name = "value" diff --git a/pkg/chart/loader/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chart/loader/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..61cb62051110b55f3d08213dc81dcf0b1c2d8e53 GIT binary patch literal 252 zcmVDc zVQyr3R8em|NM&qo0PNJUs=_c72H?(liabH@pWIst-7YSIyL+rhEHrIN(t?QZE=F{y zgNRfS&$pa5Ly`mMk2OB%pV`*9knW7FlL-Joo@KED7*{C$d;N~z z37$S{+}wvSU9}|VtF|fRpv9Ve>8dWo|9?5B+RE}Y9CFh-x#(Bq8Vck^V=NUiPLCKa z8z5CF#JgK!4>;$4Fm+FUst4d+{(+nP|0&J+e}(;l^U4@w-{=?s0RR8vgVbLD3;+OM Cs&R<` literal 0 HcmV?d00001 diff --git a/pkg/chart/loader/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml b/pkg/chart/loader/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..21ae20aa --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service}} + app.kubernetes.io/name: {{.Chart.Name}} + helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}" +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.9" + command: ["/bin/sleep","9000"] diff --git a/pkg/chart/loader/testdata/frobnitz/charts/alpine/values.yaml b/pkg/chart/loader/testdata/frobnitz/charts/alpine/values.yaml new file mode 100644 index 00000000..6c2aab7b --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz/charts/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: "my-alpine" diff --git a/pkg/chart/loader/testdata/frobnitz/charts/mariner-4.3.2.tgz b/pkg/chart/loader/testdata/frobnitz/charts/mariner-4.3.2.tgz new file mode 100644 index 0000000000000000000000000000000000000000..3190136b050e62c628b3c817fd963ac9dc4a9e25 GIT binary patch literal 967 zcmV;&133I2iwFR+9h6)E1MQb_y%a`Bd>#= zhbqf2w!b6}wZ9@rvIhtwzm?~C&+QLm+G2!l%`$_aUgS(@pdjdX3a%E}VXVc7`)dg( zL%IRN2}c1D3xoMi2w@WuWOMZ?5i&3FelBVyq_Ce_8fREdP%NDf<&d!wu3{&VUQNs{N%vK$Ha~k^gB2!0bO7r0ic0 zbqCp*X#j?=|H@GNONsuE)&Idbh>2MX(Z}|+=@*sLod*wS?A8Ugp#mMPuMO0NnZmos9_rr z3xpDL+otj~lRh?B4hHFTl+d5-8NBXmUXB~EyF^bx7m*+!*g>obczvGF|8xkWsHN8; z%#+wiWP{=2pC*98@$VNzzsll&G#D7&11-;D>HT0x|DV2?6}a~*p46@R|2l??e_8dX z@Bb>D3t~VC_*wjq2Dy!6J-_5ME%%J+xmsbK5a#qO>ZV?Wt`d|TDdrB^B&LqFr@lYdUA zs&4-JAg3&uc4dA0gv*bXU9QePa9^8wR{HovA)j@)JOAIkak0Jl)aKqA_3XLM#?H=V z-{tlG=xy^os=1Z><53;&+C4=zp|%rwv!)UyY=%k_t%Y|wNRQl`C6N_Z&S;S+~wb1=)2Uh_4I81`y>DO zoLPSt=btB&x{CUK`0DA&){efW_O36RxnsYZNT0r;JbTLR{H4M3C$8(Cee==aQ~Gbq p$`5v3?NB{4-j0XQHf literal 0 HcmV?d00001 diff --git a/pkg/chart/loader/testdata/frobnitz/docs/README.md b/pkg/chart/loader/testdata/frobnitz/docs/README.md new file mode 100644 index 00000000..d40747ca --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz/docs/README.md @@ -0,0 +1 @@ +This is a placeholder for documentation. diff --git a/pkg/chart/loader/testdata/frobnitz/icon.svg b/pkg/chart/loader/testdata/frobnitz/icon.svg new file mode 100644 index 00000000..89213060 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz/icon.svg @@ -0,0 +1,8 @@ + + + Example icon + + + diff --git a/pkg/chart/loader/testdata/frobnitz/ignore/me.txt b/pkg/chart/loader/testdata/frobnitz/ignore/me.txt new file mode 100644 index 00000000..e69de29b diff --git a/pkg/chart/loader/testdata/frobnitz/templates/template.tpl b/pkg/chart/loader/testdata/frobnitz/templates/template.tpl new file mode 100644 index 00000000..c651ee6a --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz/templates/template.tpl @@ -0,0 +1 @@ +Hello {{.Name | default "world"}} diff --git a/pkg/chart/loader/testdata/frobnitz/values.yaml b/pkg/chart/loader/testdata/frobnitz/values.yaml new file mode 100644 index 00000000..61f50125 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz/values.yaml @@ -0,0 +1,6 @@ +# A values file contains configuration. + +name: "Some Name" + +section: + name: "Name in a section" diff --git a/pkg/chart/loader/testdata/frobnitz_backslash-1.2.3.tgz b/pkg/chart/loader/testdata/frobnitz_backslash-1.2.3.tgz new file mode 100644 index 0000000000000000000000000000000000000000..a9d4c11d82bf1f3b49851053e6f0d9b6c1e9a2d4 GIT binary patch literal 3490 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 literal 0 HcmV?d00001 diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/.helmignore b/pkg/chart/loader/testdata/frobnitz_backslash/.helmignore new file mode 100755 index 00000000..9973a57b --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_backslash/.helmignore @@ -0,0 +1 @@ +ignore/ diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/Chart.lock b/pkg/chart/loader/testdata/frobnitz_backslash/Chart.lock new file mode 100755 index 00000000..6fcc2ed9 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_backslash/Chart.lock @@ -0,0 +1,8 @@ +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts +digest: invalid diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/Chart.yaml b/pkg/chart/loader/testdata/frobnitz_backslash/Chart.yaml new file mode 100755 index 00000000..b1dd40a5 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_backslash/Chart.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +name: frobnitz_backslash +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +annotations: + extrakey: extravalue + anotherkey: anothervalue +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/INSTALL.txt b/pkg/chart/loader/testdata/frobnitz_backslash/INSTALL.txt new file mode 100755 index 00000000..2010438c --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_backslash/INSTALL.txt @@ -0,0 +1 @@ +This is an install document. The client may display this. diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/LICENSE b/pkg/chart/loader/testdata/frobnitz_backslash/LICENSE new file mode 100755 index 00000000..6121943b --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_backslash/LICENSE @@ -0,0 +1 @@ +LICENSE placeholder. diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/README.md b/pkg/chart/loader/testdata/frobnitz_backslash/README.md new file mode 100755 index 00000000..8cf4cc3d --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_backslash/README.md @@ -0,0 +1,11 @@ +# Frobnitz + +This is an example chart. + +## Usage + +This is an example. It has no usage. + +## Development + +For developer info, see the top-level repository. diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/charts/_ignore_me b/pkg/chart/loader/testdata/frobnitz_backslash/charts/_ignore_me new file mode 100755 index 00000000..2cecca68 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_backslash/charts/_ignore_me @@ -0,0 +1 @@ +This should be ignored by the loader, but may be included in a chart. diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/Chart.yaml b/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/Chart.yaml new file mode 100755 index 00000000..79e0d65d --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: alpine +description: Deploy a basic Alpine Linux pod +version: 0.1.0 +home: https://helm.sh/helm diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/README.md b/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/README.md new file mode 100755 index 00000000..b30b949d --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.toml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/Chart.yaml b/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/Chart.yaml new file mode 100755 index 00000000..1c9dd5fa --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: mast1 +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/values.yaml b/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/values.yaml new file mode 100755 index 00000000..42c39c26 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/values.yaml @@ -0,0 +1,4 @@ +# Default values for mast1. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name = "value" diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast2-0.1.0.tgz new file mode 100755 index 0000000000000000000000000000000000000000..61cb62051110b55f3d08213dc81dcf0b1c2d8e53 GIT binary patch literal 252 zcmVDc zVQyr3R8em|NM&qo0PNJUs=_c72H?(liabH@pWIst-7YSIyL+rhEHrIN(t?QZE=F{y zgNRfS&$pa5Ly`mMk2OB%pV`*9knW7FlL-Joo@KED7*{C$d;N~z z37$S{+}wvSU9}|VtF|fRpv9Ve>8dWo|9?5B+RE}Y9CFh-x#(Bq8Vck^V=NUiPLCKa z8z5CF#JgK!4>;$4Fm+FUst4d+{(+nP|0&J+e}(;l^U4@w-{=?s0RR8vgVbLD3;+OM Cs&R<` literal 0 HcmV?d00001 diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/templates/alpine-pod.yaml b/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/templates/alpine-pod.yaml new file mode 100755 index 00000000..0ac5ca6a --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service | quote }} + app.kubernetes.io/name: {{.Chart.Name}} + helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}" +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.9" + command: ["/bin/sleep","9000"] diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/values.yaml b/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/values.yaml new file mode 100755 index 00000000..6c2aab7b --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: "my-alpine" diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/charts/mariner-4.3.2.tgz b/pkg/chart/loader/testdata/frobnitz_backslash/charts/mariner-4.3.2.tgz new file mode 100755 index 0000000000000000000000000000000000000000..3190136b050e62c628b3c817fd963ac9dc4a9e25 GIT binary patch literal 967 zcmV;&133I2iwFR+9h6)E1MQb_y%a`Bd>#= zhbqf2w!b6}wZ9@rvIhtwzm?~C&+QLm+G2!l%`$_aUgS(@pdjdX3a%E}VXVc7`)dg( zL%IRN2}c1D3xoMi2w@WuWOMZ?5i&3FelBVyq_Ce_8fREdP%NDf<&d!wu3{&VUQNs{N%vK$Ha~k^gB2!0bO7r0ic0 zbqCp*X#j?=|H@GNONsuE)&Idbh>2MX(Z}|+=@*sLod*wS?A8Ugp#mMPuMO0NnZmos9_rr z3xpDL+otj~lRh?B4hHFTl+d5-8NBXmUXB~EyF^bx7m*+!*g>obczvGF|8xkWsHN8; z%#+wiWP{=2pC*98@$VNzzsll&G#D7&11-;D>HT0x|DV2?6}a~*p46@R|2l??e_8dX z@Bb>D3t~VC_*wjq2Dy!6J-_5ME%%J+xmsbK5a#qO>ZV?Wt`d|TDdrB^B&LqFr@lYdUA zs&4-JAg3&uc4dA0gv*bXU9QePa9^8wR{HovA)j@)JOAIkak0Jl)aKqA_3XLM#?H=V z-{tlG=xy^os=1Z><53;&+C4=zp|%rwv!)UyY=%k_t%Y|wNRQl`C6N_Z&S;S+~wb1=)2Uh_4I81`y>DO zoLPSt=btB&x{CUK`0DA&){efW_O36RxnsYZNT0r;JbTLR{H4M3C$8(Cee==aQ~Gbq p$`5v3?NB{4-j0XQHf literal 0 HcmV?d00001 diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/docs/README.md b/pkg/chart/loader/testdata/frobnitz_backslash/docs/README.md new file mode 100755 index 00000000..d40747ca --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_backslash/docs/README.md @@ -0,0 +1 @@ +This is a placeholder for documentation. diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/icon.svg b/pkg/chart/loader/testdata/frobnitz_backslash/icon.svg new file mode 100755 index 00000000..89213060 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_backslash/icon.svg @@ -0,0 +1,8 @@ + + + Example icon + + + diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/ignore/me.txt b/pkg/chart/loader/testdata/frobnitz_backslash/ignore/me.txt new file mode 100755 index 00000000..e69de29b diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/templates/template.tpl b/pkg/chart/loader/testdata/frobnitz_backslash/templates/template.tpl new file mode 100755 index 00000000..c651ee6a --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_backslash/templates/template.tpl @@ -0,0 +1 @@ +Hello {{.Name | default "world"}} diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/values.yaml b/pkg/chart/loader/testdata/frobnitz_backslash/values.yaml new file mode 100755 index 00000000..61f50125 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_backslash/values.yaml @@ -0,0 +1,6 @@ +# A values file contains configuration. + +name: "Some Name" + +section: + name: "Name in a section" diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom.tgz b/pkg/chart/loader/testdata/frobnitz_with_bom.tgz new file mode 100644 index 0000000000000000000000000000000000000000..be0cd027db34d392a875cf8151642f9922016dbd GIT binary patch literal 3523 zcmV;!4LtH6iwFpsexY6f17>n>Vs2@4dS7>GbZB2l` znyB~YxdYx5VfMWn1PB3&iueFgG=d@A)?MdMF5{FG{)R-w{q^;#sSRI4>wKcMw~Q(D5t@dOKi zA7!-`+)B<<`fn83E%ZMmB{eNBIT_~*crVotZ@BcY(W;T0Qmaxb{D8ts z_1_YIaQ)Y8DN8d^96jbvO|I-O_N{vddll(tl;3oR_>LlRN{?j%e|Fv3;H2(Vp zjr~7>TstE)F*Jz==xkxQcDaEBPcas0JpMhRCI6F$#3!btCVH_BkNj6Fn%jSBwZwm) z0P)|Y@W$+yxc)~$Oa8};_P(7k)84AX1OJsuWy|=lP9^c*7eM?ctW-K=IW)ybf;^?n zLYQGBaQ6!t2{|K6S$Q$J!BlXk z1%gDHgN?IT7z0Dvn`Gh`8*7BF8cjTJNEv94Q{pM$OwytaT-rzO|XDx3G{X%omY7TZ4=r_AR!ZE#RS*2Gl_&&Xd$7+NWmuW zhM=wJeTh%l@bVS75?5g2%?2O(#tjrbdahA{W`Y$I(5yrauEtfZD)CNtW(loE%kkf- zOK(O1+Tj1|)GEpU_XTdM|4iqyK&JWjSAjg)|HuGnHvc1OY5w;K-2OjjVr(=C3=lZ= zjxOzDXqILO+5!cF<_vI)XPD*)$no^E>` zp6q|ULSH}rYjnc+uh;05690XHKC=V-*8OGC6MpT_yp(I|Hu%rm*Cb8IC=QPas9e!g zE*rY@z}YiL{@3fl$KUr)4oMhwctFL(W9+cYPrd&7@VQucQrz6}pR6i=wfI7OW#=dR zl-zrtc6^sPYd)(k8v2F-3|d?H9;U1~b97$FKSwQ@U3~sXR(4Ic!?Eegz<*C4r|x>m zt||rr?GoFaT~&IvU+vNAeVx1X%}Bp@RQHolbm==PEh_f&>J_6O)jjjFpBDkmn}YS-*cw3 z@jJr|^B1b3Ws|CtP0PTEeA()Us))BgExwi;*e`x_yN?0~x9i%m=t@ce@nBbyyV$?0 zN`7t{{$$@b3$~`kc6w#~&Q7^I?`QqGO@H&ARkOg37tLSS_-*f(UDa!?HNHzi;B!@R zC7s`yIVgGXlzplx{rlL)Jd!zNzy;ZdJ3CG~Yc4smrTox)UnKV|sQg(ztTZ$r5rFF|=V;9}m_XP*lVf*QUz1}>X+1b{i>J`dw<{C%& z$ZNx%yFS0tv3p^MW6J_~{+iw6zhH_h*eQ|FG7EE2b$H52uYt`~^ zvE2*zC6Pt{JzVm)={wHV^hhWxKU}B@3i_da_#VT_x~wEe$+nlfZ@e73bniP?9Fa3; z7iL^sS>p(Oy`v#Eur}l8`Tm>c8l%gqHQ|@%oZK{?So%OropsFQQKx1q7f-IB>GHtv zsM=~vy1nZCi{C%J{o{?P+AAyLd%XAX?g{y7)!Oa{tM;#hpPpUa-8isn+LQ^E70btl z_KqKTsP_buvhZn)N3<#&3|#?>WUa^<`R4Q)!Kx=me@8v<~X?ZskK{c z_Z~aY>*DE66`xg9z=KCGUO!d>@wMumYp&1I>c2@g+o!Dgp!S0ek1hY<+>nUJ zA}>{ky-;>&UY$*IKyBN<^>l~Ay?ahg+g!G8rFFx?KABrCo>yP~c5cb~k*`Kv8NcLx zCUw``{YMW@I(_!EwXbCq_BdYh$imIfU!7L_Qqd(H~ z@yh46`}MmG{@--`cRHC~aRXX!|Lc?r$^Q2VJn7#X3xS^Oe~rF*{9muuO8dWj0=NC| z-tCc3a3BXF!;j#Rfp6m|OOEIU7#Xvfu#g~2h`#|N3sJf&5S4`sTS6en;vp&m-RI<4 zfTUPx6aq#lrx@Q8f`)kzedj2#A}d1z7CPf_KO|tr&sHw z{a-%8FVw%8;CN+QdqJ!9uTp4~YDxdTK&$lMMq|L^{IAwGoBvg6wG{vN32rw3iwbW# z{l_^Mc=o?qQBeGD*n5Cu+|O$p7^g)Hn;y(T4DZ|`2Xue zJpYl<(s>pYj?01@ZTZQF8=f4F(#!-e#0CT(To6crMw(!uxRu1&Ly-4QvB)iFgvCND z!ExeJA^K`Z-hRCU&Iy@?OduLyVm@iO@I6|=e^t0RxVL^S&=ddF`uhFHN1(&}Vi)H}S&J-){hsG+JRy8m>#WM`#V|bOo8JrN*a5=tiWc>xPe|pCl}#J#7>d zE!Y8mr}wTc{YcTx|4@bf`KY*>lKm58Kj8SYTPD9fI80+6^3}7&HTWQYRZ($T*F7J+ zri)+D{`h=t%!!h!Z#Mqp?3{NN2KvP=g=_B#8r!dKa^1XBL*EJ;5wc?A?r}9mPX#WG z$=$zymSW=ikmGM{=wH3;(^sdQEX6-@6h^H4uKXv>pV_Y?H=P(;-Ah)TXbkJFN|_&o z_XcD7f3jtQq4T513l+8z(@HxWnsoh{B|kg*e{rSo?{&|dFAp*vJQ0K*)N!O}Z>C?T zJN)AB5_sZ&Lrk~rb+p0%Qwsc-{J&oxzg+*#BI&KD!_)Y$Q>vQTe``h8bg=`*WbQW-Q`ECnKu76Z6TBGW30h*i3loto`e&nGC}wS$^6qbEcy zaS|p{jYl{)QM;DVBEbYqZlEl3j)u^Rg<%nh0@%bmWczyJe;ZxCCh)|6g+|kS{YRyh z`0o>l{-3bGDy+u}UD*}?!OZq>XBUvXvfndW@?SXEf^&H}-mAkC|FsS4zpmeZLzmL| zPoE%aV1b!-ANq;Lgfm0~V`NcC{{SUexJYyrjk(T0<>%+)`D&bDbL1+dH#thfoMN{W z5obSVX$3T8$-R-VG9n^EE~a9Qk0Ci*1oA18H$`JAtqwyIK9ytQU0*0N8bc-mkHw%| zQ9`&nCegKlL+Hm*a`BdsCTvF$#j`0E$%6t^0tW2jUsv%OMMLR2be&DnbTnom^6Z$r zX&D?ug_B|-O08Im#zQGAB!xnvgclNX7mcVb`R@&nuPIu;{)?;tjpYCN0=LqC{Z=X7 zdRu3KJ==fH&;ROmdMW-za0w`V9Evj@UhfyrdZ5E3^B z)JKRgaS#y?2D;KD|M70d&3^xbW{kPss>9>@pP~i-RT{m-e_sIc|2OwD6C{;`T)s#& x%AjG3BBk*~f&>W?BuJ1TL4pJc5+q2FAVGoz2@)hokRYKE{tus-sa61Z0036V1Lgn# literal 0 HcmV?d00001 diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/.helmignore b/pkg/chart/loader/testdata/frobnitz_with_bom/.helmignore new file mode 100644 index 00000000..7a4b92da --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_bom/.helmignore @@ -0,0 +1 @@ +ignore/ diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/Chart.lock b/pkg/chart/loader/testdata/frobnitz_with_bom/Chart.lock new file mode 100644 index 00000000..ed43b227 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_bom/Chart.lock @@ -0,0 +1,8 @@ +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts +digest: invalid diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/Chart.yaml b/pkg/chart/loader/testdata/frobnitz_with_bom/Chart.yaml new file mode 100644 index 00000000..21b21f0b --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_bom/Chart.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +annotations: + extrakey: extravalue + anotherkey: anothervalue +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/INSTALL.txt b/pkg/chart/loader/testdata/frobnitz_with_bom/INSTALL.txt new file mode 100644 index 00000000..77c4e724 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_bom/INSTALL.txt @@ -0,0 +1 @@ +This is an install document. The client may display this. diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/LICENSE b/pkg/chart/loader/testdata/frobnitz_with_bom/LICENSE new file mode 100644 index 00000000..c27b00bf --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_bom/LICENSE @@ -0,0 +1 @@ +LICENSE placeholder. diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/README.md b/pkg/chart/loader/testdata/frobnitz_with_bom/README.md new file mode 100644 index 00000000..e9c40031 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_bom/README.md @@ -0,0 +1,11 @@ +# Frobnitz + +This is an example chart. + +## Usage + +This is an example. It has no usage. + +## Development + +For developer info, see the top-level repository. diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/charts/_ignore_me b/pkg/chart/loader/testdata/frobnitz_with_bom/charts/_ignore_me new file mode 100644 index 00000000..a7e3a38b --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_bom/charts/_ignore_me @@ -0,0 +1 @@ +This should be ignored by the loader, but may be included in a chart. diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/Chart.yaml b/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/Chart.yaml new file mode 100644 index 00000000..adb9853c --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: alpine +description: Deploy a basic Alpine Linux pod +version: 0.1.0 +home: https://helm.sh/helm diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/README.md b/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/README.md new file mode 100644 index 00000000..ea7526be --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.toml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/Chart.yaml b/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/Chart.yaml new file mode 100644 index 00000000..1ad84b34 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: mast1 +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/values.yaml b/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/values.yaml new file mode 100644 index 00000000..f690d53c --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/values.yaml @@ -0,0 +1,4 @@ +# Default values for mast1. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name = "value" diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast2-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..61cb62051110b55f3d08213dc81dcf0b1c2d8e53 GIT binary patch literal 252 zcmVDc zVQyr3R8em|NM&qo0PNJUs=_c72H?(liabH@pWIst-7YSIyL+rhEHrIN(t?QZE=F{y zgNRfS&$pa5Ly`mMk2OB%pV`*9knW7FlL-Joo@KED7*{C$d;N~z z37$S{+}wvSU9}|VtF|fRpv9Ve>8dWo|9?5B+RE}Y9CFh-x#(Bq8Vck^V=NUiPLCKa z8z5CF#JgK!4>;$4Fm+FUst4d+{(+nP|0&J+e}(;l^U4@w-{=?s0RR8vgVbLD3;+OM Cs&R<` literal 0 HcmV?d00001 diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/templates/alpine-pod.yaml b/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..f3e662a2 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service}} + app.kubernetes.io/name: {{.Chart.Name}} + helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}" +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.9" + command: ["/bin/sleep","9000"] diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/values.yaml b/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/values.yaml new file mode 100644 index 00000000..6b7cb259 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: "my-alpine" diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/charts/mariner-4.3.2.tgz b/pkg/chart/loader/testdata/frobnitz_with_bom/charts/mariner-4.3.2.tgz new file mode 100644 index 0000000000000000000000000000000000000000..3190136b050e62c628b3c817fd963ac9dc4a9e25 GIT binary patch literal 967 zcmV;&133I2iwFR+9h6)E1MQb_y%a`Bd>#= zhbqf2w!b6}wZ9@rvIhtwzm?~C&+QLm+G2!l%`$_aUgS(@pdjdX3a%E}VXVc7`)dg( zL%IRN2}c1D3xoMi2w@WuWOMZ?5i&3FelBVyq_Ce_8fREdP%NDf<&d!wu3{&VUQNs{N%vK$Ha~k^gB2!0bO7r0ic0 zbqCp*X#j?=|H@GNONsuE)&Idbh>2MX(Z}|+=@*sLod*wS?A8Ugp#mMPuMO0NnZmos9_rr z3xpDL+otj~lRh?B4hHFTl+d5-8NBXmUXB~EyF^bx7m*+!*g>obczvGF|8xkWsHN8; z%#+wiWP{=2pC*98@$VNzzsll&G#D7&11-;D>HT0x|DV2?6}a~*p46@R|2l??e_8dX z@Bb>D3t~VC_*wjq2Dy!6J-_5ME%%J+xmsbK5a#qO>ZV?Wt`d|TDdrB^B&LqFr@lYdUA zs&4-JAg3&uc4dA0gv*bXU9QePa9^8wR{HovA)j@)JOAIkak0Jl)aKqA_3XLM#?H=V z-{tlG=xy^os=1Z><53;&+C4=zp|%rwv!)UyY=%k_t%Y|wNRQl`C6N_Z&S;S+~wb1=)2Uh_4I81`y>DO zoLPSt=btB&x{CUK`0DA&){efW_O36RxnsYZNT0r;JbTLR{H4M3C$8(Cee==aQ~Gbq p$`5v3?NB{4-j0XQHf literal 0 HcmV?d00001 diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/docs/README.md b/pkg/chart/loader/testdata/frobnitz_with_bom/docs/README.md new file mode 100644 index 00000000..816c3e43 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_bom/docs/README.md @@ -0,0 +1 @@ +This is a placeholder for documentation. diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/icon.svg b/pkg/chart/loader/testdata/frobnitz_with_bom/icon.svg new file mode 100644 index 00000000..89213060 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_bom/icon.svg @@ -0,0 +1,8 @@ + + + Example icon + + + diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/ignore/me.txt b/pkg/chart/loader/testdata/frobnitz_with_bom/ignore/me.txt new file mode 100644 index 00000000..e69de29b diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/templates/template.tpl b/pkg/chart/loader/testdata/frobnitz_with_bom/templates/template.tpl new file mode 100644 index 00000000..bb29c549 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_bom/templates/template.tpl @@ -0,0 +1 @@ +Hello {{.Name | default "world"}} diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/values.yaml b/pkg/chart/loader/testdata/frobnitz_with_bom/values.yaml new file mode 100644 index 00000000..c24ceadf --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_bom/values.yaml @@ -0,0 +1,6 @@ +# A values file contains configuration. + +name: "Some Name" + +section: + name: "Name in a section" diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/.helmignore b/pkg/chart/loader/testdata/frobnitz_with_dev_null/.helmignore new file mode 100644 index 00000000..9973a57b --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_dev_null/.helmignore @@ -0,0 +1 @@ +ignore/ diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/Chart.lock b/pkg/chart/loader/testdata/frobnitz_with_dev_null/Chart.lock new file mode 100644 index 00000000..6fcc2ed9 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_dev_null/Chart.lock @@ -0,0 +1,8 @@ +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts +digest: invalid diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/Chart.yaml b/pkg/chart/loader/testdata/frobnitz_with_dev_null/Chart.yaml new file mode 100644 index 00000000..fcd4a4a3 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_dev_null/Chart.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +annotations: + extrakey: extravalue + anotherkey: anothervalue +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/INSTALL.txt b/pkg/chart/loader/testdata/frobnitz_with_dev_null/INSTALL.txt new file mode 100644 index 00000000..2010438c --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_dev_null/INSTALL.txt @@ -0,0 +1 @@ +This is an install document. The client may display this. diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/LICENSE b/pkg/chart/loader/testdata/frobnitz_with_dev_null/LICENSE new file mode 100644 index 00000000..6121943b --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_dev_null/LICENSE @@ -0,0 +1 @@ +LICENSE placeholder. diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/README.md b/pkg/chart/loader/testdata/frobnitz_with_dev_null/README.md new file mode 100644 index 00000000..8cf4cc3d --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_dev_null/README.md @@ -0,0 +1,11 @@ +# Frobnitz + +This is an example chart. + +## Usage + +This is an example. It has no usage. + +## Development + +For developer info, see the top-level repository. diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/_ignore_me b/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/_ignore_me new file mode 100644 index 00000000..2cecca68 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/_ignore_me @@ -0,0 +1 @@ +This should be ignored by the loader, but may be included in a chart. diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/Chart.yaml b/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/Chart.yaml new file mode 100644 index 00000000..79e0d65d --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: alpine +description: Deploy a basic Alpine Linux pod +version: 0.1.0 +home: https://helm.sh/helm diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/README.md b/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/README.md new file mode 100644 index 00000000..b30b949d --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.toml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/Chart.yaml b/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/Chart.yaml new file mode 100644 index 00000000..1c9dd5fa --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: mast1 +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/values.yaml b/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/values.yaml new file mode 100644 index 00000000..42c39c26 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/values.yaml @@ -0,0 +1,4 @@ +# Default values for mast1. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name = "value" diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast2-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..61cb62051110b55f3d08213dc81dcf0b1c2d8e53 GIT binary patch literal 252 zcmVDc zVQyr3R8em|NM&qo0PNJUs=_c72H?(liabH@pWIst-7YSIyL+rhEHrIN(t?QZE=F{y zgNRfS&$pa5Ly`mMk2OB%pV`*9knW7FlL-Joo@KED7*{C$d;N~z z37$S{+}wvSU9}|VtF|fRpv9Ve>8dWo|9?5B+RE}Y9CFh-x#(Bq8Vck^V=NUiPLCKa z8z5CF#JgK!4>;$4Fm+FUst4d+{(+nP|0&J+e}(;l^U4@w-{=?s0RR8vgVbLD3;+OM Cs&R<` literal 0 HcmV?d00001 diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/templates/alpine-pod.yaml b/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..21ae20aa --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service}} + app.kubernetes.io/name: {{.Chart.Name}} + helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}" +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.9" + command: ["/bin/sleep","9000"] diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/values.yaml b/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/values.yaml new file mode 100644 index 00000000..6c2aab7b --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: "my-alpine" diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/mariner-4.3.2.tgz b/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/mariner-4.3.2.tgz new file mode 100644 index 0000000000000000000000000000000000000000..3190136b050e62c628b3c817fd963ac9dc4a9e25 GIT binary patch literal 967 zcmV;&133I2iwFR+9h6)E1MQb_y%a`Bd>#= zhbqf2w!b6}wZ9@rvIhtwzm?~C&+QLm+G2!l%`$_aUgS(@pdjdX3a%E}VXVc7`)dg( zL%IRN2}c1D3xoMi2w@WuWOMZ?5i&3FelBVyq_Ce_8fREdP%NDf<&d!wu3{&VUQNs{N%vK$Ha~k^gB2!0bO7r0ic0 zbqCp*X#j?=|H@GNONsuE)&Idbh>2MX(Z}|+=@*sLod*wS?A8Ugp#mMPuMO0NnZmos9_rr z3xpDL+otj~lRh?B4hHFTl+d5-8NBXmUXB~EyF^bx7m*+!*g>obczvGF|8xkWsHN8; z%#+wiWP{=2pC*98@$VNzzsll&G#D7&11-;D>HT0x|DV2?6}a~*p46@R|2l??e_8dX z@Bb>D3t~VC_*wjq2Dy!6J-_5ME%%J+xmsbK5a#qO>ZV?Wt`d|TDdrB^B&LqFr@lYdUA zs&4-JAg3&uc4dA0gv*bXU9QePa9^8wR{HovA)j@)JOAIkak0Jl)aKqA_3XLM#?H=V z-{tlG=xy^os=1Z><53;&+C4=zp|%rwv!)UyY=%k_t%Y|wNRQl`C6N_Z&S;S+~wb1=)2Uh_4I81`y>DO zoLPSt=btB&x{CUK`0DA&){efW_O36RxnsYZNT0r;JbTLR{H4M3C$8(Cee==aQ~Gbq p$`5v3?NB{4-j0XQHf literal 0 HcmV?d00001 diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/docs/README.md b/pkg/chart/loader/testdata/frobnitz_with_dev_null/docs/README.md new file mode 100644 index 00000000..d40747ca --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_dev_null/docs/README.md @@ -0,0 +1 @@ +This is a placeholder for documentation. diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/icon.svg b/pkg/chart/loader/testdata/frobnitz_with_dev_null/icon.svg new file mode 100644 index 00000000..89213060 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_dev_null/icon.svg @@ -0,0 +1,8 @@ + + + Example icon + + + diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/ignore/me.txt b/pkg/chart/loader/testdata/frobnitz_with_dev_null/ignore/me.txt new file mode 100644 index 00000000..e69de29b diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/null b/pkg/chart/loader/testdata/frobnitz_with_dev_null/null new file mode 120000 index 00000000..dc1dc0cd --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_dev_null/null @@ -0,0 +1 @@ +/dev/null \ No newline at end of file diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/templates/template.tpl b/pkg/chart/loader/testdata/frobnitz_with_dev_null/templates/template.tpl new file mode 100644 index 00000000..c651ee6a --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_dev_null/templates/template.tpl @@ -0,0 +1 @@ +Hello {{.Name | default "world"}} diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/values.yaml b/pkg/chart/loader/testdata/frobnitz_with_dev_null/values.yaml new file mode 100644 index 00000000..61f50125 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_dev_null/values.yaml @@ -0,0 +1,6 @@ +# A values file contains configuration. + +name: "Some Name" + +section: + name: "Name in a section" diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/.helmignore b/pkg/chart/loader/testdata/frobnitz_with_symlink/.helmignore new file mode 100644 index 00000000..9973a57b --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_symlink/.helmignore @@ -0,0 +1 @@ +ignore/ diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/Chart.lock b/pkg/chart/loader/testdata/frobnitz_with_symlink/Chart.lock new file mode 100644 index 00000000..6fcc2ed9 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_symlink/Chart.lock @@ -0,0 +1,8 @@ +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts +digest: invalid diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/Chart.yaml b/pkg/chart/loader/testdata/frobnitz_with_symlink/Chart.yaml new file mode 100644 index 00000000..fcd4a4a3 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_symlink/Chart.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +annotations: + extrakey: extravalue + anotherkey: anothervalue +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/INSTALL.txt b/pkg/chart/loader/testdata/frobnitz_with_symlink/INSTALL.txt new file mode 100644 index 00000000..2010438c --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_symlink/INSTALL.txt @@ -0,0 +1 @@ +This is an install document. The client may display this. diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/README.md b/pkg/chart/loader/testdata/frobnitz_with_symlink/README.md new file mode 100644 index 00000000..8cf4cc3d --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_symlink/README.md @@ -0,0 +1,11 @@ +# Frobnitz + +This is an example chart. + +## Usage + +This is an example. It has no usage. + +## Development + +For developer info, see the top-level repository. diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/_ignore_me b/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/_ignore_me new file mode 100644 index 00000000..2cecca68 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/_ignore_me @@ -0,0 +1 @@ +This should be ignored by the loader, but may be included in a chart. diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/Chart.yaml b/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/Chart.yaml new file mode 100644 index 00000000..79e0d65d --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: alpine +description: Deploy a basic Alpine Linux pod +version: 0.1.0 +home: https://helm.sh/helm diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/README.md b/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/README.md new file mode 100644 index 00000000..b30b949d --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.toml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/Chart.yaml b/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/Chart.yaml new file mode 100644 index 00000000..1c9dd5fa --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: mast1 +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/values.yaml b/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/values.yaml new file mode 100644 index 00000000..42c39c26 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/values.yaml @@ -0,0 +1,4 @@ +# Default values for mast1. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name = "value" diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast2-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..61cb62051110b55f3d08213dc81dcf0b1c2d8e53 GIT binary patch literal 252 zcmVDc zVQyr3R8em|NM&qo0PNJUs=_c72H?(liabH@pWIst-7YSIyL+rhEHrIN(t?QZE=F{y zgNRfS&$pa5Ly`mMk2OB%pV`*9knW7FlL-Joo@KED7*{C$d;N~z z37$S{+}wvSU9}|VtF|fRpv9Ve>8dWo|9?5B+RE}Y9CFh-x#(Bq8Vck^V=NUiPLCKa z8z5CF#JgK!4>;$4Fm+FUst4d+{(+nP|0&J+e}(;l^U4@w-{=?s0RR8vgVbLD3;+OM Cs&R<` literal 0 HcmV?d00001 diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/templates/alpine-pod.yaml b/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..21ae20aa --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service}} + app.kubernetes.io/name: {{.Chart.Name}} + helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}" +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.9" + command: ["/bin/sleep","9000"] diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/values.yaml b/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/values.yaml new file mode 100644 index 00000000..6c2aab7b --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: "my-alpine" diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/mariner-4.3.2.tgz b/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/mariner-4.3.2.tgz new file mode 100644 index 0000000000000000000000000000000000000000..3190136b050e62c628b3c817fd963ac9dc4a9e25 GIT binary patch literal 967 zcmV;&133I2iwFR+9h6)E1MQb_y%a`Bd>#= zhbqf2w!b6}wZ9@rvIhtwzm?~C&+QLm+G2!l%`$_aUgS(@pdjdX3a%E}VXVc7`)dg( zL%IRN2}c1D3xoMi2w@WuWOMZ?5i&3FelBVyq_Ce_8fREdP%NDf<&d!wu3{&VUQNs{N%vK$Ha~k^gB2!0bO7r0ic0 zbqCp*X#j?=|H@GNONsuE)&Idbh>2MX(Z}|+=@*sLod*wS?A8Ugp#mMPuMO0NnZmos9_rr z3xpDL+otj~lRh?B4hHFTl+d5-8NBXmUXB~EyF^bx7m*+!*g>obczvGF|8xkWsHN8; z%#+wiWP{=2pC*98@$VNzzsll&G#D7&11-;D>HT0x|DV2?6}a~*p46@R|2l??e_8dX z@Bb>D3t~VC_*wjq2Dy!6J-_5ME%%J+xmsbK5a#qO>ZV?Wt`d|TDdrB^B&LqFr@lYdUA zs&4-JAg3&uc4dA0gv*bXU9QePa9^8wR{HovA)j@)JOAIkak0Jl)aKqA_3XLM#?H=V z-{tlG=xy^os=1Z><53;&+C4=zp|%rwv!)UyY=%k_t%Y|wNRQl`C6N_Z&S;S+~wb1=)2Uh_4I81`y>DO zoLPSt=btB&x{CUK`0DA&){efW_O36RxnsYZNT0r;JbTLR{H4M3C$8(Cee==aQ~Gbq p$`5v3?NB{4-j0XQHf literal 0 HcmV?d00001 diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/docs/README.md b/pkg/chart/loader/testdata/frobnitz_with_symlink/docs/README.md new file mode 100644 index 00000000..d40747ca --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_symlink/docs/README.md @@ -0,0 +1 @@ +This is a placeholder for documentation. diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/icon.svg b/pkg/chart/loader/testdata/frobnitz_with_symlink/icon.svg new file mode 100644 index 00000000..89213060 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_symlink/icon.svg @@ -0,0 +1,8 @@ + + + Example icon + + + diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/ignore/me.txt b/pkg/chart/loader/testdata/frobnitz_with_symlink/ignore/me.txt new file mode 100644 index 00000000..e69de29b diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/templates/template.tpl b/pkg/chart/loader/testdata/frobnitz_with_symlink/templates/template.tpl new file mode 100644 index 00000000..c651ee6a --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_symlink/templates/template.tpl @@ -0,0 +1 @@ +Hello {{.Name | default "world"}} diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/values.yaml b/pkg/chart/loader/testdata/frobnitz_with_symlink/values.yaml new file mode 100644 index 00000000..61f50125 --- /dev/null +++ b/pkg/chart/loader/testdata/frobnitz_with_symlink/values.yaml @@ -0,0 +1,6 @@ +# A values file contains configuration. + +name: "Some Name" + +section: + name: "Name in a section" diff --git a/pkg/chart/loader/testdata/genfrob.sh b/pkg/chart/loader/testdata/genfrob.sh new file mode 100755 index 00000000..35fdd59f --- /dev/null +++ b/pkg/chart/loader/testdata/genfrob.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +# Pack the albatross chart into the mariner chart. +echo "Packing albatross into mariner" +tar -zcvf mariner/charts/albatross-0.1.0.tgz albatross + +echo "Packing mariner into frobnitz" +tar -zcvf frobnitz/charts/mariner-4.3.2.tgz mariner +tar -zcvf frobnitz_backslash/charts/mariner-4.3.2.tgz mariner + +# Pack the frobnitz chart. +echo "Packing frobnitz" +tar --exclude=ignore/* -zcvf frobnitz-1.2.3.tgz frobnitz +tar --exclude=ignore/* -zcvf frobnitz_backslash-1.2.3.tgz frobnitz_backslash diff --git a/pkg/chart/loader/testdata/mariner/Chart.yaml b/pkg/chart/loader/testdata/mariner/Chart.yaml new file mode 100644 index 00000000..92dc4b39 --- /dev/null +++ b/pkg/chart/loader/testdata/mariner/Chart.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +name: mariner +description: A Helm chart for Kubernetes +version: 4.3.2 +home: "" +dependencies: + - name: albatross + repository: https://example.com/mariner/charts + version: "0.1.0" diff --git a/pkg/chart/loader/testdata/mariner/charts/albatross-0.1.0.tgz b/pkg/chart/loader/testdata/mariner/charts/albatross-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..128ef82f7a07c2efe1208bc21264c578661d95b7 GIT binary patch literal 306 zcmV-20nPp&iwFR+9h6)E1MSpJZo)7S1z^@b#fSq?o5YEf9b!|d`|b@)NF;-i?I88` zby6Cns?ZJeM}6OB6vcAvjGv8Nndn^z50kr*QkGiNP>W)ya-P4{6H-~8YpDt~S0vMe z))UhAThdtnrn|_A%B!!i@c2Hpa)}piu2RJ#{Pg zdyX8$@qH3GQ!^. +# This is a YAML-formatted file. https://github.com/toml-lang/toml +# Declare name/value pairs to be passed into your templates. +# name: "value" + +: + test: true diff --git a/pkg/chart/metadata.go b/pkg/chart/metadata.go new file mode 100644 index 00000000..a08a97cd --- /dev/null +++ b/pkg/chart/metadata.go @@ -0,0 +1,178 @@ +/* +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 chart + +import ( + "path/filepath" + "strings" + "unicode" + + "github.com/Masterminds/semver/v3" +) + +// Maintainer describes a Chart maintainer. +type Maintainer struct { + // Name is a user name or organization name + Name string `json:"name,omitempty"` + // Email is an optional email address to contact the named maintainer + Email string `json:"email,omitempty"` + // URL is an optional URL to an address for the named maintainer + URL string `json:"url,omitempty"` +} + +// Validate checks valid data and sanitizes string characters. +func (m *Maintainer) Validate() error { + if m == nil { + return ValidationError("maintainers must not contain empty or null nodes") + } + m.Name = sanitizeString(m.Name) + m.Email = sanitizeString(m.Email) + m.URL = sanitizeString(m.URL) + return nil +} + +// Metadata for a Chart file. This models the structure of a Chart.yaml file. +type Metadata struct { + // The name of the chart. Required. + Name string `json:"name,omitempty"` + // The URL to a relevant project page, git repo, or contact person + Home string `json:"home,omitempty"` + // Source is the URL to the source code of this chart + Sources []string `json:"sources,omitempty"` + // A SemVer 2 conformant version string of the chart. Required. + Version string `json:"version,omitempty"` + // A one-sentence description of the chart + Description string `json:"description,omitempty"` + // A list of string keywords + Keywords []string `json:"keywords,omitempty"` + // A list of name and URL/email address combinations for the maintainer(s) + Maintainers []*Maintainer `json:"maintainers,omitempty"` + // The URL to an icon file. + Icon string `json:"icon,omitempty"` + // The API Version of this chart. Required. + APIVersion string `json:"apiVersion,omitempty"` + // The condition to check to enable chart + Condition string `json:"condition,omitempty"` + // The tags to check to enable chart + Tags string `json:"tags,omitempty"` + // The version of the application enclosed inside of this chart. + AppVersion string `json:"appVersion,omitempty"` + // Whether or not this chart is deprecated + Deprecated bool `json:"deprecated,omitempty"` + // Annotations are additional mappings uninterpreted by Helm, + // made available for inspection by other applications. + Annotations map[string]string `json:"annotations,omitempty"` + // KubeVersion is a SemVer constraint specifying the version of Kubernetes required. + KubeVersion string `json:"kubeVersion,omitempty"` + // Dependencies are a list of dependencies for a chart. + Dependencies []*Dependency `json:"dependencies,omitempty"` + // Specifies the chart type: application or library + Type string `json:"type,omitempty"` +} + +// Validate checks the metadata for known issues and sanitizes string +// characters. +func (md *Metadata) Validate() error { + if md == nil { + return ValidationError("chart.metadata is required") + } + + md.Name = sanitizeString(md.Name) + md.Description = sanitizeString(md.Description) + md.Home = sanitizeString(md.Home) + md.Icon = sanitizeString(md.Icon) + md.Condition = sanitizeString(md.Condition) + md.Tags = sanitizeString(md.Tags) + md.AppVersion = sanitizeString(md.AppVersion) + md.KubeVersion = sanitizeString(md.KubeVersion) + for i := range md.Sources { + md.Sources[i] = sanitizeString(md.Sources[i]) + } + for i := range md.Keywords { + md.Keywords[i] = sanitizeString(md.Keywords[i]) + } + + if md.APIVersion == "" { + return ValidationError("chart.metadata.apiVersion is required") + } + if md.Name == "" { + return ValidationError("chart.metadata.name is required") + } + + if md.Name != filepath.Base(md.Name) { + return ValidationErrorf("chart.metadata.name %q is invalid", md.Name) + } + + if md.Version == "" { + return ValidationError("chart.metadata.version is required") + } + if !isValidSemver(md.Version) { + return ValidationErrorf("chart.metadata.version %q is invalid", md.Version) + } + if !isValidChartType(md.Type) { + return ValidationError("chart.metadata.type must be application or library") + } + + for _, m := range md.Maintainers { + if err := m.Validate(); err != nil { + return err + } + } + + // Aliases need to be validated here to make sure that the alias name does + // not contain any illegal characters. + dependencies := map[string]*Dependency{} + for _, dependency := range md.Dependencies { + if err := dependency.Validate(); err != nil { + return err + } + key := dependency.Name + if dependency.Alias != "" { + key = dependency.Alias + } + if dependencies[key] != nil { + return ValidationErrorf("more than one dependency with name or alias %q", key) + } + dependencies[key] = dependency + } + return nil +} + +func isValidChartType(in string) bool { + switch in { + case "", "application", "library": + return true + } + return false +} + +func isValidSemver(v string) bool { + _, err := semver.NewVersion(v) + return err == nil +} + +// sanitizeString normalize spaces and removes non-printable characters. +func sanitizeString(str string) string { + return strings.Map(func(r rune) rune { + if unicode.IsSpace(r) { + return ' ' + } + if unicode.IsPrint(r) { + return r + } + return -1 + }, str) +} diff --git a/pkg/chart/metadata_test.go b/pkg/chart/metadata_test.go new file mode 100644 index 00000000..62aea726 --- /dev/null +++ b/pkg/chart/metadata_test.go @@ -0,0 +1,201 @@ +/* +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 chart + +import ( + "testing" +) + +func TestValidate(t *testing.T) { + tests := []struct { + name string + md *Metadata + err error + }{ + { + "chart without metadata", + nil, + ValidationError("chart.metadata is required"), + }, + { + "chart without apiVersion", + &Metadata{Name: "test", Version: "1.0"}, + ValidationError("chart.metadata.apiVersion is required"), + }, + { + "chart without name", + &Metadata{APIVersion: "v2", Version: "1.0"}, + ValidationError("chart.metadata.name is required"), + }, + { + "chart without name", + &Metadata{Name: "../../test", APIVersion: "v2", Version: "1.0"}, + ValidationError("chart.metadata.name \"../../test\" is invalid"), + }, + { + "chart without version", + &Metadata{Name: "test", APIVersion: "v2"}, + ValidationError("chart.metadata.version is required"), + }, + { + "chart with bad type", + &Metadata{Name: "test", APIVersion: "v2", Version: "1.0", Type: "test"}, + ValidationError("chart.metadata.type must be application or library"), + }, + { + "chart without dependency", + &Metadata{Name: "test", APIVersion: "v2", Version: "1.0", Type: "application"}, + nil, + }, + { + "dependency with valid alias", + &Metadata{ + Name: "test", + APIVersion: "v2", + Version: "1.0", + Type: "application", + Dependencies: []*Dependency{ + {Name: "dependency", Alias: "legal-alias"}, + }, + }, + nil, + }, + { + "dependency with bad characters in alias", + &Metadata{ + Name: "test", + APIVersion: "v2", + Version: "1.0", + Type: "application", + Dependencies: []*Dependency{ + {Name: "bad", Alias: "illegal alias"}, + }, + }, + ValidationError("dependency \"bad\" has disallowed characters in the alias"), + }, + { + "same dependency twice", + &Metadata{ + Name: "test", + APIVersion: "v2", + Version: "1.0", + Type: "application", + Dependencies: []*Dependency{ + {Name: "foo", Alias: ""}, + {Name: "foo", Alias: ""}, + }, + }, + ValidationError("more than one dependency with name or alias \"foo\""), + }, + { + "two dependencies with alias from second dependency shadowing first one", + &Metadata{ + Name: "test", + APIVersion: "v2", + Version: "1.0", + Type: "application", + Dependencies: []*Dependency{ + {Name: "foo", Alias: ""}, + {Name: "bar", Alias: "foo"}, + }, + }, + ValidationError("more than one dependency with name or alias \"foo\""), + }, + { + // this case would make sense and could work in future versions of Helm, currently template rendering would + // result in undefined behaviour + "same dependency twice with different version", + &Metadata{ + Name: "test", + APIVersion: "v2", + Version: "1.0", + Type: "application", + Dependencies: []*Dependency{ + {Name: "foo", Alias: "", Version: "1.2.3"}, + {Name: "foo", Alias: "", Version: "1.0.0"}, + }, + }, + ValidationError("more than one dependency with name or alias \"foo\""), + }, + { + // this case would make sense and could work in future versions of Helm, currently template rendering would + // result in undefined behaviour + "two dependencies with same name but different repos", + &Metadata{ + Name: "test", + APIVersion: "v2", + Version: "1.0", + Type: "application", + Dependencies: []*Dependency{ + {Name: "foo", Repository: "repo-0"}, + {Name: "foo", Repository: "repo-1"}, + }, + }, + ValidationError("more than one dependency with name or alias \"foo\""), + }, + { + "dependencies has nil", + &Metadata{ + Name: "test", + APIVersion: "v2", + Version: "1.0", + Type: "application", + Dependencies: []*Dependency{ + nil, + }, + }, + ValidationError("dependencies must not contain empty or null nodes"), + }, + { + "maintainer not empty", + &Metadata{ + Name: "test", + APIVersion: "v2", + Version: "1.0", + Type: "application", + Maintainers: []*Maintainer{ + nil, + }, + }, + ValidationError("maintainers must not contain empty or null nodes"), + }, + { + "version invalid", + &Metadata{APIVersion: "v2", Name: "test", Version: "1.2.3.4"}, + ValidationError("chart.metadata.version \"1.2.3.4\" is invalid"), + }, + } + + for _, tt := range tests { + result := tt.md.Validate() + if result != tt.err { + t.Errorf("expected %q, got %q in test %q", tt.err, result, tt.name) + } + } +} + +func TestValidate_sanitize(t *testing.T) { + md := &Metadata{APIVersion: "v2", Name: "test", Version: "1.0", Description: "\adescr\u0081iption\rtest", Maintainers: []*Maintainer{{Name: "\r"}}} + if err := md.Validate(); err != nil { + t.Fatalf("unexpected error: %s", err) + } + if md.Description != "description test" { + t.Fatalf("description was not sanitized: %q", md.Description) + } + if md.Maintainers[0].Name != " " { + t.Fatal("maintainer name was not sanitized") + } +} diff --git a/pkg/chartutil/capabilities.go b/pkg/chartutil/capabilities.go new file mode 100644 index 00000000..5f57e11a --- /dev/null +++ b/pkg/chartutil/capabilities.go @@ -0,0 +1,126 @@ +/* +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 chartutil + +import ( + "fmt" + "strconv" + + "github.com/Masterminds/semver/v3" + "k8s.io/client-go/kubernetes/scheme" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + + helmversion "helm.sh/helm/v3/internal/version" +) + +var ( + // The Kubernetes version can be set by LDFLAGS. In order to do that the value + // must be a string. + k8sVersionMajor = "1" + k8sVersionMinor = "20" + + // DefaultVersionSet is the default version set, which includes only Core V1 ("v1"). + DefaultVersionSet = allKnownVersions() + + // DefaultCapabilities is the default set of capabilities. + DefaultCapabilities = &Capabilities{ + KubeVersion: KubeVersion{ + Version: fmt.Sprintf("v%s.%s.0", k8sVersionMajor, k8sVersionMinor), + Major: k8sVersionMajor, + Minor: k8sVersionMinor, + }, + APIVersions: DefaultVersionSet, + HelmVersion: helmversion.Get(), + } +) + +// Capabilities describes the capabilities of the Kubernetes cluster. +type Capabilities struct { + // KubeVersion is the Kubernetes version. + KubeVersion KubeVersion + // APIversions are supported Kubernetes API versions. + APIVersions VersionSet + // HelmVersion is the build information for this helm version + HelmVersion helmversion.BuildInfo +} + +func (capabilities *Capabilities) Copy() *Capabilities { + return &Capabilities{ + KubeVersion: capabilities.KubeVersion, + APIVersions: capabilities.APIVersions, + HelmVersion: capabilities.HelmVersion, + } +} + +// KubeVersion is the Kubernetes version. +type KubeVersion struct { + Version string // Kubernetes version + Major string // Kubernetes major version + Minor string // Kubernetes minor version +} + +// String implements fmt.Stringer +func (kv *KubeVersion) String() string { return kv.Version } + +// GitVersion returns the Kubernetes version string. +// +// Deprecated: use KubeVersion.Version. +func (kv *KubeVersion) GitVersion() string { return kv.Version } + +// ParseKubeVersion parses kubernetes version from string +func ParseKubeVersion(version string) (*KubeVersion, error) { + sv, err := semver.NewVersion(version) + if err != nil { + return nil, err + } + return &KubeVersion{ + Version: "v" + sv.String(), + Major: strconv.FormatUint(sv.Major(), 10), + Minor: strconv.FormatUint(sv.Minor(), 10), + }, nil +} + +// VersionSet is a set of Kubernetes API versions. +type VersionSet []string + +// Has returns true if the version string is in the set. +// +// vs.Has("apps/v1") +func (v VersionSet) Has(apiVersion string) bool { + for _, x := range v { + if x == apiVersion { + return true + } + } + return false +} + +func allKnownVersions() VersionSet { + // We should register the built in extension APIs as well so CRDs are + // supported in the default version set. This has caused problems with `helm + // template` in the past, so let's be safe + apiextensionsv1beta1.AddToScheme(scheme.Scheme) + apiextensionsv1.AddToScheme(scheme.Scheme) + + groups := scheme.Scheme.PrioritizedVersionsAllGroups() + vs := make(VersionSet, 0, len(groups)) + for _, gv := range groups { + vs = append(vs, gv.String()) + } + return vs +} diff --git a/pkg/chartutil/capabilities_test.go b/pkg/chartutil/capabilities_test.go new file mode 100644 index 00000000..b58d7e0f --- /dev/null +++ b/pkg/chartutil/capabilities_test.go @@ -0,0 +1,84 @@ +/* +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 chartutil + +import ( + "testing" +) + +func TestVersionSet(t *testing.T) { + vs := VersionSet{"v1", "apps/v1"} + if d := len(vs); d != 2 { + t.Errorf("Expected 2 versions, got %d", d) + } + + if !vs.Has("apps/v1") { + t.Error("Expected to find apps/v1") + } + + if vs.Has("Spanish/inquisition") { + t.Error("No one expects the Spanish/inquisition") + } +} + +func TestDefaultVersionSet(t *testing.T) { + if !DefaultVersionSet.Has("v1") { + t.Error("Expected core v1 version set") + } +} + +func TestDefaultCapabilities(t *testing.T) { + kv := DefaultCapabilities.KubeVersion + if kv.String() != "v1.20.0" { + t.Errorf("Expected default KubeVersion.String() to be v1.20.0, got %q", kv.String()) + } + if kv.Version != "v1.20.0" { + t.Errorf("Expected default KubeVersion.Version to be v1.20.0, got %q", kv.Version) + } + if kv.GitVersion() != "v1.20.0" { + t.Errorf("Expected default KubeVersion.GitVersion() to be v1.20.0, got %q", kv.Version) + } + if kv.Major != "1" { + t.Errorf("Expected default KubeVersion.Major to be 1, got %q", kv.Major) + } + if kv.Minor != "20" { + t.Errorf("Expected default KubeVersion.Minor to be 20, got %q", kv.Minor) + } +} + +func TestDefaultCapabilitiesHelmVersion(t *testing.T) { + hv := DefaultCapabilities.HelmVersion + + if hv.Version != "v3.14" { + t.Errorf("Expected default HelmVersion to be v3.14, got %q", hv.Version) + } +} + +func TestParseKubeVersion(t *testing.T) { + kv, err := ParseKubeVersion("v1.16.0") + if err != nil { + t.Errorf("Expected v1.16.0 to parse successfully") + } + if kv.Version != "v1.16.0" { + t.Errorf("Expected parsed KubeVersion.Version to be v1.16.0, got %q", kv.String()) + } + if kv.Major != "1" { + t.Errorf("Expected parsed KubeVersion.Major to be 1, got %q", kv.Major) + } + if kv.Minor != "16" { + t.Errorf("Expected parsed KubeVersion.Minor to be 16, got %q", kv.Minor) + } +} diff --git a/pkg/chartutil/chartfile.go b/pkg/chartutil/chartfile.go new file mode 100644 index 00000000..4f537a6e --- /dev/null +++ b/pkg/chartutil/chartfile.go @@ -0,0 +1,92 @@ +/* +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 chartutil + +import ( + "os" + "path/filepath" + + "github.com/pkg/errors" + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/pkg/chart" +) + +// LoadChartfile loads a Chart.yaml file into a *chart.Metadata. +func LoadChartfile(filename string) (*chart.Metadata, error) { + b, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + y := new(chart.Metadata) + err = yaml.Unmarshal(b, y) + return y, err +} + +// SaveChartfile saves the given metadata as a Chart.yaml file at the given path. +// +// 'filename' should be the complete path and filename ('foo/Chart.yaml') +func SaveChartfile(filename string, cf *chart.Metadata) error { + // Pull out the dependencies of a v1 Chart, since there's no way + // to tell the serializer to skip a field for just this use case + savedDependencies := cf.Dependencies + if cf.APIVersion == chart.APIVersionV1 { + cf.Dependencies = nil + } + out, err := yaml.Marshal(cf) + if cf.APIVersion == chart.APIVersionV1 { + cf.Dependencies = savedDependencies + } + if err != nil { + return err + } + return os.WriteFile(filename, out, 0644) +} + +// IsChartDir validate a chart directory. +// +// Checks for a valid Chart.yaml. +func IsChartDir(dirName string) (bool, error) { + if fi, err := os.Stat(dirName); err != nil { + return false, err + } else if !fi.IsDir() { + return false, errors.Errorf("%q is not a directory", dirName) + } + + chartYaml := filepath.Join(dirName, ChartfileName) + if _, err := os.Stat(chartYaml); os.IsNotExist(err) { + return false, errors.Errorf("no %s exists in directory %q", ChartfileName, dirName) + } + + chartYamlContent, err := os.ReadFile(chartYaml) + if err != nil { + return false, errors.Errorf("cannot read %s in directory %q", ChartfileName, dirName) + } + + chartContent := new(chart.Metadata) + if err := yaml.Unmarshal(chartYamlContent, &chartContent); err != nil { + return false, err + } + if chartContent == nil { + return false, errors.Errorf("chart metadata (%s) missing", ChartfileName) + } + if chartContent.Name == "" { + return false, errors.Errorf("invalid chart (%s): name must not be empty", ChartfileName) + } + + return true, nil +} diff --git a/pkg/chartutil/chartfile_test.go b/pkg/chartutil/chartfile_test.go new file mode 100644 index 00000000..ef5c5462 --- /dev/null +++ b/pkg/chartutil/chartfile_test.go @@ -0,0 +1,121 @@ +/* +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 chartutil + +import ( + "testing" + + "helm.sh/helm/v3/pkg/chart" +) + +const testfile = "testdata/chartfiletest.yaml" + +func TestLoadChartfile(t *testing.T) { + f, err := LoadChartfile(testfile) + if err != nil { + t.Errorf("Failed to open %s: %s", testfile, err) + return + } + verifyChartfile(t, f, "frobnitz") +} + +func verifyChartfile(t *testing.T, f *chart.Metadata, name string) { + + if f == nil { //nolint:staticcheck + t.Fatal("Failed verifyChartfile because f is nil") + } + + if f.APIVersion != chart.APIVersionV1 { //nolint:staticcheck + t.Errorf("Expected API Version %q, got %q", chart.APIVersionV1, f.APIVersion) + } + + if f.Name != name { + t.Errorf("Expected %s, got %s", name, f.Name) + } + + if f.Description != "This is a frobnitz." { + t.Errorf("Unexpected description %q", f.Description) + } + + if f.Version != "1.2.3" { + t.Errorf("Unexpected version %q", f.Version) + } + + if len(f.Maintainers) != 2 { + t.Errorf("Expected 2 maintainers, got %d", len(f.Maintainers)) + } + + if f.Maintainers[0].Name != "The Helm Team" { + t.Errorf("Unexpected maintainer name.") + } + + if f.Maintainers[1].Email != "nobody@example.com" { + t.Errorf("Unexpected maintainer email.") + } + + if len(f.Sources) != 1 { + t.Fatalf("Unexpected number of sources") + } + + if f.Sources[0] != "https://example.com/foo/bar" { + t.Errorf("Expected https://example.com/foo/bar, got %s", f.Sources) + } + + if f.Home != "http://example.com" { + t.Error("Unexpected home.") + } + + if f.Icon != "https://example.com/64x64.png" { + t.Errorf("Unexpected icon: %q", f.Icon) + } + + if len(f.Keywords) != 3 { + t.Error("Unexpected keywords") + } + + if len(f.Annotations) != 2 { + t.Fatalf("Unexpected annotations") + } + + if want, got := "extravalue", f.Annotations["extrakey"]; want != got { + t.Errorf("Want %q, but got %q", want, got) + } + + if want, got := "anothervalue", f.Annotations["anotherkey"]; want != got { + t.Errorf("Want %q, but got %q", want, got) + } + + kk := []string{"frobnitz", "sprocket", "dodad"} + for i, k := range f.Keywords { + if kk[i] != k { + t.Errorf("Expected %q, got %q", kk[i], k) + } + } +} + +func TestIsChartDir(t *testing.T) { + validChartDir, err := IsChartDir("testdata/frobnitz") + if !validChartDir { + t.Errorf("unexpected error while reading chart-directory: (%v)", err) + return + } + validChartDir, err = IsChartDir("testdata") + if validChartDir || err == nil { + t.Errorf("expected error but did not get any") + return + } +} diff --git a/pkg/chartutil/coalesce.go b/pkg/chartutil/coalesce.go new file mode 100644 index 00000000..f0272fd6 --- /dev/null +++ b/pkg/chartutil/coalesce.go @@ -0,0 +1,293 @@ +/* +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 chartutil + +import ( + "fmt" + "log" + + "github.com/mitchellh/copystructure" + "github.com/pkg/errors" + + "helm.sh/helm/v3/pkg/chart" +) + +func concatPrefix(a, b string) string { + if a == "" { + return b + } + return fmt.Sprintf("%s.%s", a, b) +} + +// CoalesceValues coalesces all of the values in a chart (and its subcharts). +// +// Values are coalesced together using the following rules: +// +// - Values in a higher level chart always override values in a lower-level +// dependency chart +// - Scalar values and arrays are replaced, maps are merged +// - A chart has access to all of the variables for it, as well as all of +// the values destined for its dependencies. +func CoalesceValues(chrt *chart.Chart, vals map[string]interface{}) (Values, error) { + valsCopy, err := copyValues(vals) + if err != nil { + return vals, err + } + return coalesce(log.Printf, chrt, valsCopy, "", false) +} + +// MergeValues is used to merge the values in a chart and its subcharts. This +// is different from Coalescing as nil/null values are preserved. +// +// Values are coalesced together using the following rules: +// +// - Values in a higher level chart always override values in a lower-level +// dependency chart +// - Scalar values and arrays are replaced, maps are merged +// - A chart has access to all of the variables for it, as well as all of +// the values destined for its dependencies. +// +// Retaining Nils is useful when processes early in a Helm action or business +// logic need to retain them for when Coalescing will happen again later in the +// business logic. +func MergeValues(chrt *chart.Chart, vals map[string]interface{}) (Values, error) { + valsCopy, err := copyValues(vals) + if err != nil { + return vals, err + } + return coalesce(log.Printf, chrt, valsCopy, "", true) +} + +func copyValues(vals map[string]interface{}) (Values, error) { + v, err := copystructure.Copy(vals) + if err != nil { + return vals, err + } + + valsCopy := v.(map[string]interface{}) + // if we have an empty map, make sure it is initialized + if valsCopy == nil { + valsCopy = make(map[string]interface{}) + } + + return valsCopy, nil +} + +type printFn func(format string, v ...interface{}) + +// coalesce coalesces the dest values and the chart values, giving priority to the dest values. +// +// This is a helper function for CoalesceValues and MergeValues. +// +// Note, the merge argument specifies whether this is being used by MergeValues +// or CoalesceValues. Coalescing removes null values and their keys in some +// situations while merging keeps the null values. +func coalesce(printf printFn, ch *chart.Chart, dest map[string]interface{}, prefix string, merge bool) (map[string]interface{}, error) { + coalesceValues(printf, ch, dest, prefix, merge) + return coalesceDeps(printf, ch, dest, prefix, merge) +} + +// coalesceDeps coalesces the dependencies of the given chart. +func coalesceDeps(printf printFn, chrt *chart.Chart, dest map[string]interface{}, prefix string, merge bool) (map[string]interface{}, error) { + for _, subchart := range chrt.Dependencies() { + if c, ok := dest[subchart.Name()]; !ok { + // If dest doesn't already have the key, create it. + dest[subchart.Name()] = make(map[string]interface{}) + } else if !istable(c) { + return dest, errors.Errorf("type mismatch on %s: %t", subchart.Name(), c) + } + if dv, ok := dest[subchart.Name()]; ok { + dvmap := dv.(map[string]interface{}) + subPrefix := concatPrefix(prefix, chrt.Metadata.Name) + // Get globals out of dest and merge them into dvmap. + coalesceGlobals(printf, dvmap, dest, subPrefix, merge) + // Now coalesce the rest of the values. + var err error + dest[subchart.Name()], err = coalesce(printf, subchart, dvmap, subPrefix, merge) + if err != nil { + return dest, err + } + } + } + return dest, nil +} + +// coalesceGlobals copies the globals out of src and merges them into dest. +// +// For convenience, returns dest. +func coalesceGlobals(printf printFn, dest, src map[string]interface{}, prefix string, _ bool) { + var dg, sg map[string]interface{} + + if destglob, ok := dest[GlobalKey]; !ok { + dg = make(map[string]interface{}) + } else if dg, ok = destglob.(map[string]interface{}); !ok { + printf("warning: skipping globals because destination %s is not a table.", GlobalKey) + return + } + + if srcglob, ok := src[GlobalKey]; !ok { + sg = make(map[string]interface{}) + } else if sg, ok = srcglob.(map[string]interface{}); !ok { + printf("warning: skipping globals because source %s is not a table.", GlobalKey) + return + } + + // EXPERIMENTAL: In the past, we have disallowed globals to test tables. This + // reverses that decision. It may somehow be possible to introduce a loop + // here, but I haven't found a way. So for the time being, let's allow + // tables in globals. + for key, val := range sg { + if istable(val) { + vv := copyMap(val.(map[string]interface{})) + if destv, ok := dg[key]; !ok { + // Here there is no merge. We're just adding. + dg[key] = vv + } else { + if destvmap, ok := destv.(map[string]interface{}); !ok { + printf("Conflict: cannot merge map onto non-map for %q. Skipping.", key) + } else { + // Basically, we reverse order of coalesce here to merge + // top-down. + subPrefix := concatPrefix(prefix, key) + // In this location coalesceTablesFullKey should always have + // merge set to true. The output of coalesceGlobals is run + // through coalesce where any nils will be removed. + coalesceTablesFullKey(printf, vv, destvmap, subPrefix, true) + dg[key] = vv + } + } + } else if dv, ok := dg[key]; ok && istable(dv) { + // It's not clear if this condition can actually ever trigger. + printf("key %s is table. Skipping", key) + } else { + // TODO: Do we need to do any additional checking on the value? + dg[key] = val + } + } + dest[GlobalKey] = dg +} + +func copyMap(src map[string]interface{}) map[string]interface{} { + m := make(map[string]interface{}, len(src)) + for k, v := range src { + m[k] = v + } + return m +} + +// coalesceValues builds up a values map for a particular chart. +// +// Values in v will override the values in the chart. +func coalesceValues(printf printFn, c *chart.Chart, v map[string]interface{}, prefix string, merge bool) { + subPrefix := concatPrefix(prefix, c.Metadata.Name) + + // Using c.Values directly when coalescing a table can cause problems where + // the original c.Values is altered. Creating a deep copy stops the problem. + // This section is fault-tolerant as there is no ability to return an error. + valuesCopy, err := copystructure.Copy(c.Values) + var vc map[string]interface{} + var ok bool + if err != nil { + // If there is an error something is wrong with copying c.Values it + // means there is a problem in the deep copying package or something + // wrong with c.Values. In this case we will use c.Values and report + // an error. + printf("warning: unable to copy values, err: %s", err) + vc = c.Values + } else { + vc, ok = valuesCopy.(map[string]interface{}) + if !ok { + // c.Values has a map[string]interface{} structure. If the copy of + // it cannot be treated as map[string]interface{} there is something + // strangely wrong. Log it and use c.Values + printf("warning: unable to convert values copy to values type") + vc = c.Values + } + } + + for key, val := range vc { + if value, ok := v[key]; ok { + if value == nil && !merge { + // When the YAML value is null and we are coalescing instead of + // merging, we remove the value's key. + // This allows Helm's various sources of values (value files or --set) to + // remove incompatible keys from any previous chart, file, or set values. + delete(v, key) + } else if dest, ok := value.(map[string]interface{}); ok { + // if v[key] is a table, merge nv's val table into v[key]. + src, ok := val.(map[string]interface{}) + if !ok { + // If the original value is nil, there is nothing to coalesce, so we don't print + // the warning + if val != nil { + printf("warning: skipped value for %s.%s: Not a table.", subPrefix, key) + } + } else { + // Because v has higher precedence than nv, dest values override src + // values. + coalesceTablesFullKey(printf, dest, src, concatPrefix(subPrefix, key), merge) + } + } + } else { + // If the key is not in v, copy it from nv. + v[key] = val + } + } +} + +// CoalesceTables merges a source map into a destination map. +// +// dest is considered authoritative. +func CoalesceTables(dst, src map[string]interface{}) map[string]interface{} { + return coalesceTablesFullKey(log.Printf, dst, src, "", false) +} + +func MergeTables(dst, src map[string]interface{}) map[string]interface{} { + return coalesceTablesFullKey(log.Printf, dst, src, "", true) +} + +// coalesceTablesFullKey merges a source map into a destination map. +// +// dest is considered authoritative. +func coalesceTablesFullKey(printf printFn, dst, src map[string]interface{}, prefix string, merge bool) map[string]interface{} { + // When --reuse-values is set but there are no modifications yet, return new values + if src == nil { + return dst + } + if dst == nil { + return src + } + // Because dest has higher precedence than src, dest values override src + // values. + for key, val := range src { + fullkey := concatPrefix(prefix, key) + if dv, ok := dst[key]; ok && !merge && dv == nil { + delete(dst, key) + } else if !ok { + dst[key] = val + } else if istable(val) { + if istable(dv) { + coalesceTablesFullKey(printf, dv.(map[string]interface{}), val.(map[string]interface{}), fullkey, merge) + } else { + printf("warning: cannot overwrite table with non table for %s (%v)", fullkey, val) + } + } else if istable(dv) && val != nil { + printf("warning: destination for %s is a table. Ignoring non-table value (%v)", fullkey, val) + } + } + return dst +} diff --git a/pkg/chartutil/coalesce_test.go b/pkg/chartutil/coalesce_test.go new file mode 100644 index 00000000..61b718d9 --- /dev/null +++ b/pkg/chartutil/coalesce_test.go @@ -0,0 +1,700 @@ +/* +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 chartutil + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "helm.sh/helm/v3/pkg/chart" +) + +// ref: http://www.yaml.org/spec/1.2/spec.html#id2803362 +var testCoalesceValuesYaml = []byte(` +top: yup +bottom: null +right: Null +left: NULL +front: ~ +back: "" +nested: + boat: null + +global: + name: Ishmael + subject: Queequeg + nested: + boat: true + +pequod: + global: + name: Stinky + harpooner: Tashtego + nested: + boat: false + sail: true + ahab: + scope: whale + boat: null + nested: + foo: true + bar: null +`) + +func withDeps(c *chart.Chart, deps ...*chart.Chart) *chart.Chart { + c.AddDependency(deps...) + return c +} + +func TestCoalesceValues(t *testing.T) { + is := assert.New(t) + + c := withDeps(&chart.Chart{ + Metadata: &chart.Metadata{Name: "moby"}, + Values: map[string]interface{}{ + "back": "exists", + "bottom": "exists", + "front": "exists", + "left": "exists", + "name": "moby", + "nested": map[string]interface{}{"boat": true}, + "override": "bad", + "right": "exists", + "scope": "moby", + "top": "nope", + "global": map[string]interface{}{ + "nested2": map[string]interface{}{"l0": "moby"}, + }, + }, + }, + withDeps(&chart.Chart{ + Metadata: &chart.Metadata{Name: "pequod"}, + Values: map[string]interface{}{ + "name": "pequod", + "scope": "pequod", + "global": map[string]interface{}{ + "nested2": map[string]interface{}{"l1": "pequod"}, + }, + }, + }, + &chart.Chart{ + Metadata: &chart.Metadata{Name: "ahab"}, + Values: map[string]interface{}{ + "global": map[string]interface{}{ + "nested": map[string]interface{}{"foo": "bar"}, + "nested2": map[string]interface{}{"l2": "ahab"}, + }, + "scope": "ahab", + "name": "ahab", + "boat": true, + "nested": map[string]interface{}{"foo": false, "bar": true}, + }, + }, + ), + &chart.Chart{ + Metadata: &chart.Metadata{Name: "spouter"}, + Values: map[string]interface{}{ + "scope": "spouter", + "global": map[string]interface{}{ + "nested2": map[string]interface{}{"l1": "spouter"}, + }, + }, + }, + ) + + vals, err := ReadValues(testCoalesceValuesYaml) + if err != nil { + t.Fatal(err) + } + + // taking a copy of the values before passing it + // to CoalesceValues as argument, so that we can + // use it for asserting later + valsCopy := make(Values, len(vals)) + for key, value := range vals { + valsCopy[key] = value + } + + v, err := CoalesceValues(c, vals) + if err != nil { + t.Fatal(err) + } + j, _ := json.MarshalIndent(v, "", " ") + t.Logf("Coalesced Values: %s", string(j)) + + tests := []struct { + tpl string + expect string + }{ + {"{{.top}}", "yup"}, + {"{{.back}}", ""}, + {"{{.name}}", "moby"}, + {"{{.global.name}}", "Ishmael"}, + {"{{.global.subject}}", "Queequeg"}, + {"{{.global.harpooner}}", ""}, + {"{{.pequod.name}}", "pequod"}, + {"{{.pequod.ahab.name}}", "ahab"}, + {"{{.pequod.ahab.scope}}", "whale"}, + {"{{.pequod.ahab.nested.foo}}", "true"}, + {"{{.pequod.ahab.global.name}}", "Ishmael"}, + {"{{.pequod.ahab.global.nested.foo}}", "bar"}, + {"{{.pequod.ahab.global.subject}}", "Queequeg"}, + {"{{.pequod.ahab.global.harpooner}}", "Tashtego"}, + {"{{.pequod.global.name}}", "Ishmael"}, + {"{{.pequod.global.nested.foo}}", ""}, + {"{{.pequod.global.subject}}", "Queequeg"}, + {"{{.spouter.global.name}}", "Ishmael"}, + {"{{.spouter.global.harpooner}}", ""}, + + {"{{.global.nested.boat}}", "true"}, + {"{{.pequod.global.nested.boat}}", "true"}, + {"{{.spouter.global.nested.boat}}", "true"}, + {"{{.pequod.global.nested.sail}}", "true"}, + {"{{.spouter.global.nested.sail}}", ""}, + + {"{{.global.nested2.l0}}", "moby"}, + {"{{.global.nested2.l1}}", ""}, + {"{{.global.nested2.l2}}", ""}, + {"{{.pequod.global.nested2.l0}}", "moby"}, + {"{{.pequod.global.nested2.l1}}", "pequod"}, + {"{{.pequod.global.nested2.l2}}", ""}, + {"{{.pequod.ahab.global.nested2.l0}}", "moby"}, + {"{{.pequod.ahab.global.nested2.l1}}", "pequod"}, + {"{{.pequod.ahab.global.nested2.l2}}", "ahab"}, + {"{{.spouter.global.nested2.l0}}", "moby"}, + {"{{.spouter.global.nested2.l1}}", "spouter"}, + {"{{.spouter.global.nested2.l2}}", ""}, + } + + for _, tt := range tests { + if o, err := ttpl(tt.tpl, v); err != nil || o != tt.expect { + t.Errorf("Expected %q to expand to %q, got %q", tt.tpl, tt.expect, o) + } + } + + nullKeys := []string{"bottom", "right", "left", "front"} + for _, nullKey := range nullKeys { + if _, ok := v[nullKey]; ok { + t.Errorf("Expected key %q to be removed, still present", nullKey) + } + } + + if _, ok := v["nested"].(map[string]interface{})["boat"]; ok { + t.Error("Expected nested boat key to be removed, still present") + } + + subchart := v["pequod"].(map[string]interface{})["ahab"].(map[string]interface{}) + if _, ok := subchart["boat"]; ok { + t.Error("Expected subchart boat key to be removed, still present") + } + + if _, ok := subchart["nested"].(map[string]interface{})["bar"]; ok { + t.Error("Expected subchart nested bar key to be removed, still present") + } + + // CoalesceValues should not mutate the passed arguments + is.Equal(valsCopy, vals) +} + +func TestMergeValues(t *testing.T) { + is := assert.New(t) + + c := withDeps(&chart.Chart{ + Metadata: &chart.Metadata{Name: "moby"}, + Values: map[string]interface{}{ + "back": "exists", + "bottom": "exists", + "front": "exists", + "left": "exists", + "name": "moby", + "nested": map[string]interface{}{"boat": true}, + "override": "bad", + "right": "exists", + "scope": "moby", + "top": "nope", + "global": map[string]interface{}{ + "nested2": map[string]interface{}{"l0": "moby"}, + }, + }, + }, + withDeps(&chart.Chart{ + Metadata: &chart.Metadata{Name: "pequod"}, + Values: map[string]interface{}{ + "name": "pequod", + "scope": "pequod", + "global": map[string]interface{}{ + "nested2": map[string]interface{}{"l1": "pequod"}, + }, + }, + }, + &chart.Chart{ + Metadata: &chart.Metadata{Name: "ahab"}, + Values: map[string]interface{}{ + "global": map[string]interface{}{ + "nested": map[string]interface{}{"foo": "bar"}, + "nested2": map[string]interface{}{"l2": "ahab"}, + }, + "scope": "ahab", + "name": "ahab", + "boat": true, + "nested": map[string]interface{}{"foo": false, "bar": true}, + }, + }, + ), + &chart.Chart{ + Metadata: &chart.Metadata{Name: "spouter"}, + Values: map[string]interface{}{ + "scope": "spouter", + "global": map[string]interface{}{ + "nested2": map[string]interface{}{"l1": "spouter"}, + }, + }, + }, + ) + + vals, err := ReadValues(testCoalesceValuesYaml) + if err != nil { + t.Fatal(err) + } + + // taking a copy of the values before passing it + // to MergeValues as argument, so that we can + // use it for asserting later + valsCopy := make(Values, len(vals)) + for key, value := range vals { + valsCopy[key] = value + } + + v, err := MergeValues(c, vals) + if err != nil { + t.Fatal(err) + } + j, _ := json.MarshalIndent(v, "", " ") + t.Logf("Coalesced Values: %s", string(j)) + + tests := []struct { + tpl string + expect string + }{ + {"{{.top}}", "yup"}, + {"{{.back}}", ""}, + {"{{.name}}", "moby"}, + {"{{.global.name}}", "Ishmael"}, + {"{{.global.subject}}", "Queequeg"}, + {"{{.global.harpooner}}", ""}, + {"{{.pequod.name}}", "pequod"}, + {"{{.pequod.ahab.name}}", "ahab"}, + {"{{.pequod.ahab.scope}}", "whale"}, + {"{{.pequod.ahab.nested.foo}}", "true"}, + {"{{.pequod.ahab.global.name}}", "Ishmael"}, + {"{{.pequod.ahab.global.nested.foo}}", "bar"}, + {"{{.pequod.ahab.global.subject}}", "Queequeg"}, + {"{{.pequod.ahab.global.harpooner}}", "Tashtego"}, + {"{{.pequod.global.name}}", "Ishmael"}, + {"{{.pequod.global.nested.foo}}", ""}, + {"{{.pequod.global.subject}}", "Queequeg"}, + {"{{.spouter.global.name}}", "Ishmael"}, + {"{{.spouter.global.harpooner}}", ""}, + + {"{{.global.nested.boat}}", "true"}, + {"{{.pequod.global.nested.boat}}", "true"}, + {"{{.spouter.global.nested.boat}}", "true"}, + {"{{.pequod.global.nested.sail}}", "true"}, + {"{{.spouter.global.nested.sail}}", ""}, + + {"{{.global.nested2.l0}}", "moby"}, + {"{{.global.nested2.l1}}", ""}, + {"{{.global.nested2.l2}}", ""}, + {"{{.pequod.global.nested2.l0}}", "moby"}, + {"{{.pequod.global.nested2.l1}}", "pequod"}, + {"{{.pequod.global.nested2.l2}}", ""}, + {"{{.pequod.ahab.global.nested2.l0}}", "moby"}, + {"{{.pequod.ahab.global.nested2.l1}}", "pequod"}, + {"{{.pequod.ahab.global.nested2.l2}}", "ahab"}, + {"{{.spouter.global.nested2.l0}}", "moby"}, + {"{{.spouter.global.nested2.l1}}", "spouter"}, + {"{{.spouter.global.nested2.l2}}", ""}, + } + + for _, tt := range tests { + if o, err := ttpl(tt.tpl, v); err != nil || o != tt.expect { + t.Errorf("Expected %q to expand to %q, got %q", tt.tpl, tt.expect, o) + } + } + + // nullKeys is different from coalescing. Here the null/nil values are not + // removed. + nullKeys := []string{"bottom", "right", "left", "front"} + for _, nullKey := range nullKeys { + if vv, ok := v[nullKey]; !ok { + t.Errorf("Expected key %q to be present but it was removed", nullKey) + } else if vv != nil { + t.Errorf("Expected key %q to be null but it has a value of %v", nullKey, vv) + } + } + + if _, ok := v["nested"].(map[string]interface{})["boat"]; !ok { + t.Error("Expected nested boat key to be present but it was removed") + } + + subchart := v["pequod"].(map[string]interface{})["ahab"].(map[string]interface{}) + if _, ok := subchart["boat"]; !ok { + t.Error("Expected subchart boat key to be present but it was removed") + } + + if _, ok := subchart["nested"].(map[string]interface{})["bar"]; !ok { + t.Error("Expected subchart nested bar key to be present but it was removed") + } + + // CoalesceValues should not mutate the passed arguments + is.Equal(valsCopy, vals) +} + +func TestCoalesceTables(t *testing.T) { + dst := map[string]interface{}{ + "name": "Ishmael", + "address": map[string]interface{}{ + "street": "123 Spouter Inn Ct.", + "city": "Nantucket", + "country": nil, + }, + "details": map[string]interface{}{ + "friends": []string{"Tashtego"}, + }, + "boat": "pequod", + "hole": nil, + } + src := map[string]interface{}{ + "occupation": "whaler", + "address": map[string]interface{}{ + "state": "MA", + "street": "234 Spouter Inn Ct.", + "country": "US", + }, + "details": "empty", + "boat": map[string]interface{}{ + "mast": true, + }, + "hole": "black", + } + + // What we expect is that anything in dst overrides anything in src, but that + // otherwise the values are coalesced. + CoalesceTables(dst, src) + + if dst["name"] != "Ishmael" { + t.Errorf("Unexpected name: %s", dst["name"]) + } + if dst["occupation"] != "whaler" { + t.Errorf("Unexpected occupation: %s", dst["occupation"]) + } + + addr, ok := dst["address"].(map[string]interface{}) + if !ok { + t.Fatal("Address went away.") + } + + if addr["street"].(string) != "123 Spouter Inn Ct." { + t.Errorf("Unexpected address: %v", addr["street"]) + } + + if addr["city"].(string) != "Nantucket" { + t.Errorf("Unexpected city: %v", addr["city"]) + } + + if addr["state"].(string) != "MA" { + t.Errorf("Unexpected state: %v", addr["state"]) + } + + if _, ok = addr["country"]; ok { + t.Error("The country is not left out.") + } + + if det, ok := dst["details"].(map[string]interface{}); !ok { + t.Fatalf("Details is the wrong type: %v", dst["details"]) + } else if _, ok := det["friends"]; !ok { + t.Error("Could not find your friends. Maybe you don't have any. :-(") + } + + if dst["boat"].(string) != "pequod" { + t.Errorf("Expected boat string, got %v", dst["boat"]) + } + + if _, ok = dst["hole"]; ok { + t.Error("The hole still exists.") + } + + dst2 := map[string]interface{}{ + "name": "Ishmael", + "address": map[string]interface{}{ + "street": "123 Spouter Inn Ct.", + "city": "Nantucket", + "country": "US", + }, + "details": map[string]interface{}{ + "friends": []string{"Tashtego"}, + }, + "boat": "pequod", + "hole": "black", + } + + // What we expect is that anything in dst should have all values set, + // this happens when the --reuse-values flag is set but the chart has no modifications yet + CoalesceTables(dst2, nil) + + if dst2["name"] != "Ishmael" { + t.Errorf("Unexpected name: %s", dst2["name"]) + } + + addr2, ok := dst2["address"].(map[string]interface{}) + if !ok { + t.Fatal("Address went away.") + } + + if addr2["street"].(string) != "123 Spouter Inn Ct." { + t.Errorf("Unexpected address: %v", addr2["street"]) + } + + if addr2["city"].(string) != "Nantucket" { + t.Errorf("Unexpected city: %v", addr2["city"]) + } + + if addr2["country"].(string) != "US" { + t.Errorf("Unexpected Country: %v", addr2["country"]) + } + + if det2, ok := dst2["details"].(map[string]interface{}); !ok { + t.Fatalf("Details is the wrong type: %v", dst2["details"]) + } else if _, ok := det2["friends"]; !ok { + t.Error("Could not find your friends. Maybe you don't have any. :-(") + } + + if dst2["boat"].(string) != "pequod" { + t.Errorf("Expected boat string, got %v", dst2["boat"]) + } + + if dst2["hole"].(string) != "black" { + t.Errorf("Expected hole string, got %v", dst2["boat"]) + } +} + +func TestMergeTables(t *testing.T) { + dst := map[string]interface{}{ + "name": "Ishmael", + "address": map[string]interface{}{ + "street": "123 Spouter Inn Ct.", + "city": "Nantucket", + "country": nil, + }, + "details": map[string]interface{}{ + "friends": []string{"Tashtego"}, + }, + "boat": "pequod", + "hole": nil, + } + src := map[string]interface{}{ + "occupation": "whaler", + "address": map[string]interface{}{ + "state": "MA", + "street": "234 Spouter Inn Ct.", + "country": "US", + }, + "details": "empty", + "boat": map[string]interface{}{ + "mast": true, + }, + "hole": "black", + } + + // What we expect is that anything in dst overrides anything in src, but that + // otherwise the values are coalesced. + MergeTables(dst, src) + + if dst["name"] != "Ishmael" { + t.Errorf("Unexpected name: %s", dst["name"]) + } + if dst["occupation"] != "whaler" { + t.Errorf("Unexpected occupation: %s", dst["occupation"]) + } + + addr, ok := dst["address"].(map[string]interface{}) + if !ok { + t.Fatal("Address went away.") + } + + if addr["street"].(string) != "123 Spouter Inn Ct." { + t.Errorf("Unexpected address: %v", addr["street"]) + } + + if addr["city"].(string) != "Nantucket" { + t.Errorf("Unexpected city: %v", addr["city"]) + } + + if addr["state"].(string) != "MA" { + t.Errorf("Unexpected state: %v", addr["state"]) + } + + // This is one test that is different from CoalesceTables. Because country + // is a nil value and it's not removed it's still present. + if _, ok = addr["country"]; !ok { + t.Error("The country is left out.") + } + + if det, ok := dst["details"].(map[string]interface{}); !ok { + t.Fatalf("Details is the wrong type: %v", dst["details"]) + } else if _, ok := det["friends"]; !ok { + t.Error("Could not find your friends. Maybe you don't have any. :-(") + } + + if dst["boat"].(string) != "pequod" { + t.Errorf("Expected boat string, got %v", dst["boat"]) + } + + // This is one test that is different from CoalesceTables. Because hole + // is a nil value and it's not removed it's still present. + if _, ok = dst["hole"]; !ok { + t.Error("The hole no longer exists.") + } + + dst2 := map[string]interface{}{ + "name": "Ishmael", + "address": map[string]interface{}{ + "street": "123 Spouter Inn Ct.", + "city": "Nantucket", + "country": "US", + }, + "details": map[string]interface{}{ + "friends": []string{"Tashtego"}, + }, + "boat": "pequod", + "hole": "black", + "nilval": nil, + } + + // What we expect is that anything in dst should have all values set, + // this happens when the --reuse-values flag is set but the chart has no modifications yet + MergeTables(dst2, nil) + + if dst2["name"] != "Ishmael" { + t.Errorf("Unexpected name: %s", dst2["name"]) + } + + addr2, ok := dst2["address"].(map[string]interface{}) + if !ok { + t.Fatal("Address went away.") + } + + if addr2["street"].(string) != "123 Spouter Inn Ct." { + t.Errorf("Unexpected address: %v", addr2["street"]) + } + + if addr2["city"].(string) != "Nantucket" { + t.Errorf("Unexpected city: %v", addr2["city"]) + } + + if addr2["country"].(string) != "US" { + t.Errorf("Unexpected Country: %v", addr2["country"]) + } + + if det2, ok := dst2["details"].(map[string]interface{}); !ok { + t.Fatalf("Details is the wrong type: %v", dst2["details"]) + } else if _, ok := det2["friends"]; !ok { + t.Error("Could not find your friends. Maybe you don't have any. :-(") + } + + if dst2["boat"].(string) != "pequod" { + t.Errorf("Expected boat string, got %v", dst2["boat"]) + } + + if dst2["hole"].(string) != "black" { + t.Errorf("Expected hole string, got %v", dst2["boat"]) + } + + if dst2["nilval"] != nil { + t.Error("Expected nilvalue to have nil value but it does not") + } +} + +func TestCoalesceValuesWarnings(t *testing.T) { + + c := withDeps(&chart.Chart{ + Metadata: &chart.Metadata{Name: "level1"}, + Values: map[string]interface{}{ + "name": "moby", + }, + }, + withDeps(&chart.Chart{ + Metadata: &chart.Metadata{Name: "level2"}, + Values: map[string]interface{}{ + "name": "pequod", + }, + }, + &chart.Chart{ + Metadata: &chart.Metadata{Name: "level3"}, + Values: map[string]interface{}{ + "name": "ahab", + "boat": true, + "spear": map[string]interface{}{ + "tip": true, + "sail": map[string]interface{}{ + "cotton": true, + }, + }, + }, + }, + ), + ) + + vals := map[string]interface{}{ + "level2": map[string]interface{}{ + "level3": map[string]interface{}{ + "boat": map[string]interface{}{"mast": true}, + "spear": map[string]interface{}{ + "tip": map[string]interface{}{ + "sharp": true, + }, + "sail": true, + }, + }, + }, + } + + warnings := make([]string, 0) + printf := func(format string, v ...interface{}) { + t.Logf(format, v...) + warnings = append(warnings, fmt.Sprintf(format, v...)) + } + + _, err := coalesce(printf, c, vals, "", false) + if err != nil { + t.Fatal(err) + } + + t.Logf("vals: %v", vals) + assert.Contains(t, warnings, "warning: skipped value for level1.level2.level3.boat: Not a table.") + assert.Contains(t, warnings, "warning: destination for level1.level2.level3.spear.tip is a table. Ignoring non-table value (true)") + assert.Contains(t, warnings, "warning: cannot overwrite table with non table for level1.level2.level3.spear.sail (map[cotton:true])") + +} + +func TestConcatPrefix(t *testing.T) { + assert.Equal(t, "b", concatPrefix("", "b")) + assert.Equal(t, "a.b", concatPrefix("a", "b")) +} diff --git a/pkg/chartutil/compatible.go b/pkg/chartutil/compatible.go new file mode 100644 index 00000000..f4656c91 --- /dev/null +++ b/pkg/chartutil/compatible.go @@ -0,0 +1,34 @@ +/* +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 chartutil + +import "github.com/Masterminds/semver/v3" + +// IsCompatibleRange compares a version to a constraint. +// It returns true if the version matches the constraint, and false in all other cases. +func IsCompatibleRange(constraint, ver string) bool { + sv, err := semver.NewVersion(ver) + if err != nil { + return false + } + + c, err := semver.NewConstraint(constraint) + if err != nil { + return false + } + return c.Check(sv) +} diff --git a/pkg/chartutil/compatible_test.go b/pkg/chartutil/compatible_test.go new file mode 100644 index 00000000..df7be616 --- /dev/null +++ b/pkg/chartutil/compatible_test.go @@ -0,0 +1,43 @@ +/* +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 version represents the current version of the project. +package chartutil + +import "testing" + +func TestIsCompatibleRange(t *testing.T) { + tests := []struct { + constraint string + ver string + expected bool + }{ + {"v2.0.0-alpha.4", "v2.0.0-alpha.4", true}, + {"v2.0.0-alpha.3", "v2.0.0-alpha.4", false}, + {"v2.0.0", "v2.0.0-alpha.4", false}, + {"v2.0.0-alpha.4", "v2.0.0", false}, + {"~v2.0.0", "v2.0.1", true}, + {"v2", "v2.0.0", true}, + {">2.0.0", "v2.1.1", true}, + {"v2.1.*", "v2.1.1", true}, + } + + for _, tt := range tests { + if IsCompatibleRange(tt.constraint, tt.ver) != tt.expected { + t.Errorf("expected constraint %s to be %v for %s", tt.constraint, tt.expected, tt.ver) + } + } +} diff --git a/pkg/chartutil/create.go b/pkg/chartutil/create.go new file mode 100644 index 00000000..0bb5a83c --- /dev/null +++ b/pkg/chartutil/create.go @@ -0,0 +1,723 @@ +/* +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 chartutil + +import ( + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/pkg/errors" + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" +) + +// chartName is a regular expression for testing the supplied name of a chart. +// This regular expression is probably stricter than it needs to be. We can relax it +// somewhat. Newline characters, as well as $, quotes, +, parens, and % are known to be +// problematic. +var chartName = regexp.MustCompile("^[a-zA-Z0-9._-]+$") + +const ( + // ChartfileName is the default Chart file name. + ChartfileName = "Chart.yaml" + // ValuesfileName is the default values file name. + ValuesfileName = "values.yaml" + // SchemafileName is the default values schema file name. + SchemafileName = "values.schema.json" + // TemplatesDir is the relative directory name for templates. + TemplatesDir = "templates" + // ChartsDir is the relative directory name for charts dependencies. + ChartsDir = "charts" + // TemplatesTestsDir is the relative directory name for tests. + TemplatesTestsDir = TemplatesDir + sep + "tests" + // IgnorefileName is the name of the Helm ignore file. + IgnorefileName = ".helmignore" + // IngressFileName is the name of the example ingress file. + IngressFileName = TemplatesDir + sep + "ingress.yaml" + // DeploymentName is the name of the example deployment file. + DeploymentName = TemplatesDir + sep + "deployment.yaml" + // ServiceName is the name of the example service file. + ServiceName = TemplatesDir + sep + "service.yaml" + // ServiceAccountName is the name of the example serviceaccount file. + ServiceAccountName = TemplatesDir + sep + "serviceaccount.yaml" + // HorizontalPodAutoscalerName is the name of the example hpa file. + HorizontalPodAutoscalerName = TemplatesDir + sep + "hpa.yaml" + // NotesName is the name of the example NOTES.txt file. + NotesName = TemplatesDir + sep + "NOTES.txt" + // HelpersName is the name of the example helpers file. + HelpersName = TemplatesDir + sep + "_helpers.tpl" + // TestConnectionName is the name of the example test file. + TestConnectionName = TemplatesTestsDir + sep + "test-connection.yaml" +) + +// maxChartNameLength is lower than the limits we know of with certain file systems, +// and with certain Kubernetes fields. +const maxChartNameLength = 250 + +const sep = string(filepath.Separator) + +const defaultChartfile = `apiVersion: v2 +name: %s +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.16.0" +` + +const defaultValues = `# Default values for %s. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: nginx + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +podAnnotations: {} +podLabels: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +livenessProbe: + httpGet: + path: / + port: http +readinessProbe: + httpGet: + path: / + port: http + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +# Additional volumes on the output Deployment definition. +volumes: [] +# - name: foo +# secret: +# secretName: mysecret +# optional: false + +# Additional volumeMounts on the output Deployment definition. +volumeMounts: [] +# - name: foo +# mountPath: "/etc/foo" +# readOnly: true + +nodeSelector: {} + +tolerations: [] + +affinity: {} +` + +const defaultIgnore = `# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ +` + +const defaultIngress = `{{- if .Values.ingress.enabled -}} +{{- $fullName := include ".fullname" . -}} +{{- $svcPort := .Values.service.port -}} +{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} + {{- end }} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include ".labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} + pathType: {{ .pathType }} + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ $fullName }} + port: + number: {{ $svcPort }} + {{- else }} + serviceName: {{ $fullName }} + servicePort: {{ $svcPort }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} +` + +const defaultDeployment = `apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include ".fullname" . }} + labels: + {{- include ".labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include ".selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include ".labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include ".serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +` + +const defaultService = `apiVersion: v1 +kind: Service +metadata: + name: {{ include ".fullname" . }} + labels: + {{- include ".labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include ".selectorLabels" . | nindent 4 }} +` + +const defaultServiceAccount = `{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include ".serviceAccountName" . }} + labels: + {{- include ".labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} +` + +const defaultHorizontalPodAutoscaler = `{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include ".fullname" . }} + labels: + {{- include ".labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include ".fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} +` + +const defaultNotes = `1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include ".fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include ".fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include ".fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include ".name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT +{{- end }} +` + +const defaultHelpers = `{{/* +Expand the name of the chart. +*/}} +{{- define ".name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define ".fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define ".chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define ".labels" -}} +helm.sh/chart: {{ include ".chart" . }} +{{ include ".selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define ".selectorLabels" -}} +app.kubernetes.io/name: {{ include ".name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define ".serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include ".fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} +` + +const defaultTestConnection = `apiVersion: v1 +kind: Pod +metadata: + name: "{{ include ".fullname" . }}-test-connection" + labels: + {{- include ".labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['{{ include ".fullname" . }}:{{ .Values.service.port }}'] + restartPolicy: Never +` + +// Stderr is an io.Writer to which error messages can be written +// +// In Helm 4, this will be replaced. It is needed in Helm 3 to preserve API backward +// compatibility. +var Stderr io.Writer = os.Stderr + +// CreateFrom creates a new chart, but scaffolds it from the src chart. +func CreateFrom(chartfile *chart.Metadata, dest, src string) error { + schart, err := loader.Load(src) + if err != nil { + return errors.Wrapf(err, "could not load %s", src) + } + + schart.Metadata = chartfile + + var updatedTemplates []*chart.File + + for _, template := range schart.Templates { + newData := transform(string(template.Data), schart.Name()) + updatedTemplates = append(updatedTemplates, &chart.File{Name: template.Name, Data: newData}) + } + + schart.Templates = updatedTemplates + b, err := yaml.Marshal(schart.Values) + if err != nil { + return errors.Wrap(err, "reading values file") + } + + var m map[string]interface{} + if err := yaml.Unmarshal(transform(string(b), schart.Name()), &m); err != nil { + return errors.Wrap(err, "transforming values file") + } + schart.Values = m + + // SaveDir looks for the file values.yaml when saving rather than the values + // key in order to preserve the comments in the YAML. The name placeholder + // needs to be replaced on that file. + for _, f := range schart.Raw { + if f.Name == ValuesfileName { + f.Data = transform(string(f.Data), schart.Name()) + } + } + + return SaveDir(schart, dest) +} + +// Create creates a new chart in a directory. +// +// Inside of dir, this will create a directory based on the name of +// chartfile.Name. It will then write the Chart.yaml into this directory and +// create the (empty) appropriate directories. +// +// The returned string will point to the newly created directory. It will be +// an absolute path, even if the provided base directory was relative. +// +// If dir does not exist, this will return an error. +// If Chart.yaml or any directories cannot be created, this will return an +// error. In such a case, this will attempt to clean up by removing the +// new chart directory. +func Create(name, dir string) (string, error) { + + // Sanity-check the name of a chart so user doesn't create one that causes problems. + if err := validateChartName(name); err != nil { + return "", err + } + + path, err := filepath.Abs(dir) + if err != nil { + return path, err + } + + if fi, err := os.Stat(path); err != nil { + return path, err + } else if !fi.IsDir() { + return path, errors.Errorf("no such directory %s", path) + } + + cdir := filepath.Join(path, name) + if fi, err := os.Stat(cdir); err == nil && !fi.IsDir() { + return cdir, errors.Errorf("file %s already exists and is not a directory", cdir) + } + + files := []struct { + path string + content []byte + }{ + { + // Chart.yaml + path: filepath.Join(cdir, ChartfileName), + content: []byte(fmt.Sprintf(defaultChartfile, name)), + }, + { + // values.yaml + path: filepath.Join(cdir, ValuesfileName), + content: []byte(fmt.Sprintf(defaultValues, name)), + }, + { + // .helmignore + path: filepath.Join(cdir, IgnorefileName), + content: []byte(defaultIgnore), + }, + { + // ingress.yaml + path: filepath.Join(cdir, IngressFileName), + content: transform(defaultIngress, name), + }, + { + // deployment.yaml + path: filepath.Join(cdir, DeploymentName), + content: transform(defaultDeployment, name), + }, + { + // service.yaml + path: filepath.Join(cdir, ServiceName), + content: transform(defaultService, name), + }, + { + // serviceaccount.yaml + path: filepath.Join(cdir, ServiceAccountName), + content: transform(defaultServiceAccount, name), + }, + { + // hpa.yaml + path: filepath.Join(cdir, HorizontalPodAutoscalerName), + content: transform(defaultHorizontalPodAutoscaler, name), + }, + { + // NOTES.txt + path: filepath.Join(cdir, NotesName), + content: transform(defaultNotes, name), + }, + { + // _helpers.tpl + path: filepath.Join(cdir, HelpersName), + content: transform(defaultHelpers, name), + }, + { + // test-connection.yaml + path: filepath.Join(cdir, TestConnectionName), + content: transform(defaultTestConnection, name), + }, + } + + for _, file := range files { + if _, err := os.Stat(file.path); err == nil { + // There is no handle to a preferred output stream here. + fmt.Fprintf(Stderr, "WARNING: File %q already exists. Overwriting.\n", file.path) + } + if err := writeFile(file.path, file.content); err != nil { + return cdir, err + } + } + // Need to add the ChartsDir explicitly as it does not contain any file OOTB + if err := os.MkdirAll(filepath.Join(cdir, ChartsDir), 0755); err != nil { + return cdir, err + } + return cdir, nil +} + +// transform performs a string replacement of the specified source for +// a given key with the replacement string +func transform(src, replacement string) []byte { + return []byte(strings.ReplaceAll(src, "", replacement)) +} + +func writeFile(name string, content []byte) error { + if err := os.MkdirAll(filepath.Dir(name), 0755); err != nil { + return err + } + return os.WriteFile(name, content, 0644) +} + +func validateChartName(name string) error { + if name == "" || len(name) > maxChartNameLength { + return fmt.Errorf("chart name must be between 1 and %d characters", maxChartNameLength) + } + if !chartName.MatchString(name) { + return fmt.Errorf("chart name must match the regular expression %q", chartName.String()) + } + return nil +} diff --git a/pkg/chartutil/create_test.go b/pkg/chartutil/create_test.go new file mode 100644 index 00000000..1697c421 --- /dev/null +++ b/pkg/chartutil/create_test.go @@ -0,0 +1,172 @@ +/* +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 chartutil + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" +) + +func TestCreate(t *testing.T) { + tdir := t.TempDir() + + c, err := Create("foo", tdir) + if err != nil { + t.Fatal(err) + } + + dir := filepath.Join(tdir, "foo") + + mychart, err := loader.LoadDir(c) + if err != nil { + t.Fatalf("Failed to load newly created chart %q: %s", c, err) + } + + if mychart.Name() != "foo" { + t.Errorf("Expected name to be 'foo', got %q", mychart.Name()) + } + + for _, f := range []string{ + ChartfileName, + DeploymentName, + HelpersName, + IgnorefileName, + NotesName, + ServiceAccountName, + ServiceName, + TemplatesDir, + TemplatesTestsDir, + TestConnectionName, + ValuesfileName, + } { + if _, err := os.Stat(filepath.Join(dir, f)); err != nil { + t.Errorf("Expected %s file: %s", f, err) + } + } +} + +func TestCreateFrom(t *testing.T) { + tdir := t.TempDir() + + cf := &chart.Metadata{ + APIVersion: chart.APIVersionV1, + Name: "foo", + Version: "0.1.0", + } + srcdir := "./testdata/frobnitz/charts/mariner" + + if err := CreateFrom(cf, tdir, srcdir); err != nil { + t.Fatal(err) + } + + dir := filepath.Join(tdir, "foo") + c := filepath.Join(tdir, cf.Name) + mychart, err := loader.LoadDir(c) + if err != nil { + t.Fatalf("Failed to load newly created chart %q: %s", c, err) + } + + if mychart.Name() != "foo" { + t.Errorf("Expected name to be 'foo', got %q", mychart.Name()) + } + + for _, f := range []string{ + ChartfileName, + ValuesfileName, + filepath.Join(TemplatesDir, "placeholder.tpl"), + } { + if _, err := os.Stat(filepath.Join(dir, f)); err != nil { + t.Errorf("Expected %s file: %s", f, err) + } + + // Check each file to make sure has been replaced + b, err := os.ReadFile(filepath.Join(dir, f)) + if err != nil { + t.Errorf("Unable to read file %s: %s", f, err) + } + if bytes.Contains(b, []byte("")) { + t.Errorf("File %s contains ", f) + } + } +} + +// TestCreate_Overwrite is a regression test for making sure that files are overwritten. +func TestCreate_Overwrite(t *testing.T) { + tdir := t.TempDir() + + var errlog bytes.Buffer + + if _, err := Create("foo", tdir); err != nil { + t.Fatal(err) + } + + dir := filepath.Join(tdir, "foo") + + tplname := filepath.Join(dir, "templates/hpa.yaml") + writeFile(tplname, []byte("FOO")) + + // Now re-run the create + Stderr = &errlog + if _, err := Create("foo", tdir); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(tplname) + if err != nil { + t.Fatal(err) + } + + if string(data) == "FOO" { + t.Fatal("File that should have been modified was not.") + } + + if errlog.Len() == 0 { + t.Errorf("Expected warnings about overwriting files.") + } +} + +func TestValidateChartName(t *testing.T) { + for name, shouldPass := range map[string]bool{ + "": false, + "abcdefghijklmnopqrstuvwxyz-_.": true, + "ABCDEFGHIJKLMNOPQRSTUVWXYZ-_.": true, + "$hello": false, + "Hellô": false, + "he%%o": false, + "he\nllo": false, + + "abcdefghijklmnopqrstuvwxyz-_." + + "abcdefghijklmnopqrstuvwxyz-_." + + "abcdefghijklmnopqrstuvwxyz-_." + + "abcdefghijklmnopqrstuvwxyz-_." + + "abcdefghijklmnopqrstuvwxyz-_." + + "abcdefghijklmnopqrstuvwxyz-_." + + "abcdefghijklmnopqrstuvwxyz-_." + + "abcdefghijklmnopqrstuvwxyz-_." + + "abcdefghijklmnopqrstuvwxyz-_." + + "ABCDEFGHIJKLMNOPQRSTUVWXYZ-_.": false, + } { + if err := validateChartName(name); (err != nil) == shouldPass { + t.Errorf("test for %q failed", name) + } + } +} diff --git a/pkg/chartutil/dependencies.go b/pkg/chartutil/dependencies.go new file mode 100644 index 00000000..205d99e0 --- /dev/null +++ b/pkg/chartutil/dependencies.go @@ -0,0 +1,356 @@ +/* +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 chartutil + +import ( + "log" + "strings" + + "github.com/mitchellh/copystructure" + + "helm.sh/helm/v3/pkg/chart" +) + +// ProcessDependencies checks through this chart's dependencies, processing accordingly. +// +// TODO: For Helm v4 this can be combined with or turned into ProcessDependenciesWithMerge +func ProcessDependencies(c *chart.Chart, v Values) error { + if err := processDependencyEnabled(c, v, ""); err != nil { + return err + } + return processDependencyImportValues(c, false) +} + +// ProcessDependenciesWithMerge checks through this chart's dependencies, processing accordingly. +// It is similar to ProcessDependencies but it does not remove nil values during +// the import/export handling process. +func ProcessDependenciesWithMerge(c *chart.Chart, v Values) error { + if err := processDependencyEnabled(c, v, ""); err != nil { + return err + } + return processDependencyImportValues(c, true) +} + +// processDependencyConditions disables charts based on condition path value in values +func processDependencyConditions(reqs []*chart.Dependency, cvals Values, cpath string) { + if reqs == nil { + return + } + for _, r := range reqs { + for _, c := range strings.Split(strings.TrimSpace(r.Condition), ",") { + if len(c) > 0 { + // retrieve value + vv, err := cvals.PathValue(cpath + c) + if err == nil { + // if not bool, warn + if bv, ok := vv.(bool); ok { + r.Enabled = bv + break + } + log.Printf("Warning: Condition path '%s' for chart %s returned non-bool value", c, r.Name) + } else if _, ok := err.(ErrNoValue); !ok { + // this is a real error + log.Printf("Warning: PathValue returned error %v", err) + } + } + } + } +} + +// processDependencyTags disables charts based on tags in values +func processDependencyTags(reqs []*chart.Dependency, cvals Values) { + if reqs == nil { + return + } + vt, err := cvals.Table("tags") + if err != nil { + return + } + for _, r := range reqs { + var hasTrue, hasFalse bool + for _, k := range r.Tags { + if b, ok := vt[k]; ok { + // if not bool, warn + if bv, ok := b.(bool); ok { + if bv { + hasTrue = true + } else { + hasFalse = true + } + } else { + log.Printf("Warning: Tag '%s' for chart %s returned non-bool value", k, r.Name) + } + } + } + if !hasTrue && hasFalse { + r.Enabled = false + } else if hasTrue || !hasTrue && !hasFalse { + r.Enabled = true + } + } +} + +func getAliasDependency(charts []*chart.Chart, dep *chart.Dependency) *chart.Chart { + for _, c := range charts { + if c == nil { + continue + } + if c.Name() != dep.Name { + continue + } + if !IsCompatibleRange(dep.Version, c.Metadata.Version) { + continue + } + + out := *c + md := *c.Metadata + out.Metadata = &md + + if dep.Alias != "" { + md.Name = dep.Alias + } + return &out + } + return nil +} + +// processDependencyEnabled removes disabled charts from dependencies +func processDependencyEnabled(c *chart.Chart, v map[string]interface{}, path string) error { + if c.Metadata.Dependencies == nil { + return nil + } + + var chartDependencies []*chart.Chart + // If any dependency is not a part of Chart.yaml + // then this should be added to chartDependencies. + // However, if the dependency is already specified in Chart.yaml + // we should not add it, as it would be anyways processed from Chart.yaml + +Loop: + for _, existing := range c.Dependencies() { + for _, req := range c.Metadata.Dependencies { + if existing.Name() == req.Name && IsCompatibleRange(req.Version, existing.Metadata.Version) { + continue Loop + } + } + chartDependencies = append(chartDependencies, existing) + } + + for _, req := range c.Metadata.Dependencies { + if req == nil { + continue + } + if chartDependency := getAliasDependency(c.Dependencies(), req); chartDependency != nil { + chartDependencies = append(chartDependencies, chartDependency) + } + if req.Alias != "" { + req.Name = req.Alias + } + } + c.SetDependencies(chartDependencies...) + + // set all to true + for _, lr := range c.Metadata.Dependencies { + lr.Enabled = true + } + cvals, err := CoalesceValues(c, v) + if err != nil { + return err + } + // flag dependencies as enabled/disabled + processDependencyTags(c.Metadata.Dependencies, cvals) + processDependencyConditions(c.Metadata.Dependencies, cvals, path) + // make a map of charts to remove + rm := map[string]struct{}{} + for _, r := range c.Metadata.Dependencies { + if !r.Enabled { + // remove disabled chart + rm[r.Name] = struct{}{} + } + } + // don't keep disabled charts in new slice + cd := []*chart.Chart{} + copy(cd, c.Dependencies()[:0]) + for _, n := range c.Dependencies() { + if _, ok := rm[n.Metadata.Name]; !ok { + cd = append(cd, n) + } + } + // don't keep disabled charts in metadata + cdMetadata := []*chart.Dependency{} + copy(cdMetadata, c.Metadata.Dependencies[:0]) + for _, n := range c.Metadata.Dependencies { + if _, ok := rm[n.Name]; !ok { + cdMetadata = append(cdMetadata, n) + } + } + + // recursively call self to process sub dependencies + for _, t := range cd { + subpath := path + t.Metadata.Name + "." + if err := processDependencyEnabled(t, cvals, subpath); err != nil { + return err + } + } + // set the correct dependencies in metadata + c.Metadata.Dependencies = nil + c.Metadata.Dependencies = append(c.Metadata.Dependencies, cdMetadata...) + c.SetDependencies(cd...) + + return nil +} + +// pathToMap creates a nested map given a YAML path in dot notation. +func pathToMap(path string, data map[string]interface{}) map[string]interface{} { + if path == "." { + return data + } + return set(parsePath(path), data) +} + +func set(path []string, data map[string]interface{}) map[string]interface{} { + if len(path) == 0 { + return nil + } + cur := data + for i := len(path) - 1; i >= 0; i-- { + cur = map[string]interface{}{path[i]: cur} + } + return cur +} + +// processImportValues merges values from child to parent based on the chart's dependencies' ImportValues field. +func processImportValues(c *chart.Chart, merge bool) error { + if c.Metadata.Dependencies == nil { + return nil + } + // combine chart values and empty config to get Values + var cvals Values + var err error + if merge { + cvals, err = MergeValues(c, nil) + } else { + cvals, err = CoalesceValues(c, nil) + } + if err != nil { + return err + } + b := make(map[string]interface{}) + // import values from each dependency if specified in import-values + for _, r := range c.Metadata.Dependencies { + var outiv []interface{} + for _, riv := range r.ImportValues { + switch iv := riv.(type) { + case map[string]interface{}: + child := iv["child"].(string) + parent := iv["parent"].(string) + + outiv = append(outiv, map[string]string{ + "child": child, + "parent": parent, + }) + + // get child table + vv, err := cvals.Table(r.Name + "." + child) + if err != nil { + log.Printf("Warning: ImportValues missing table from chart %s: %v", r.Name, err) + continue + } + // create value map from child to be merged into parent + if merge { + b = MergeTables(b, pathToMap(parent, vv.AsMap())) + } else { + b = CoalesceTables(b, pathToMap(parent, vv.AsMap())) + } + case string: + child := "exports." + iv + outiv = append(outiv, map[string]string{ + "child": child, + "parent": ".", + }) + vm, err := cvals.Table(r.Name + "." + child) + if err != nil { + log.Printf("Warning: ImportValues missing table: %v", err) + continue + } + if merge { + b = MergeTables(b, vm.AsMap()) + } else { + b = CoalesceTables(b, vm.AsMap()) + } + } + } + r.ImportValues = outiv + } + + // Imported values from a child to a parent chart have a lower priority than + // the parents values. This enables parent charts to import a large section + // from a child and then override select parts. This is why b is merged into + // cvals in the code below and not the other way around. + if merge { + // deep copying the cvals as there are cases where pointers can end + // up in the cvals when they are copied onto b in ways that break things. + cvals = deepCopyMap(cvals) + c.Values = MergeTables(cvals, b) + } else { + // Trimming the nil values from cvals is needed for backwards compatibility. + // Previously, the b value had been populated with cvals along with some + // overrides. This caused the coalescing functionality to remove the + // nil/null values. This trimming is for backwards compat. + cvals = trimNilValues(cvals) + c.Values = CoalesceTables(cvals, b) + } + + return nil +} + +func deepCopyMap(vals map[string]interface{}) map[string]interface{} { + valsCopy, err := copystructure.Copy(vals) + if err != nil { + return vals + } + return valsCopy.(map[string]interface{}) +} + +func trimNilValues(vals map[string]interface{}) map[string]interface{} { + valsCopy, err := copystructure.Copy(vals) + if err != nil { + return vals + } + valsCopyMap := valsCopy.(map[string]interface{}) + for key, val := range valsCopyMap { + if val == nil { + // Iterate over the values and remove nil keys + delete(valsCopyMap, key) + } else if istable(val) { + // Recursively call into ourselves to remove keys from inner tables + valsCopyMap[key] = trimNilValues(val.(map[string]interface{})) + } + } + + return valsCopyMap +} + +// processDependencyImportValues imports specified chart values from child to parent. +func processDependencyImportValues(c *chart.Chart, merge bool) error { + for _, d := range c.Dependencies() { + // recurse + if err := processDependencyImportValues(d, merge); err != nil { + return err + } + } + return processImportValues(c, merge) +} diff --git a/pkg/chartutil/dependencies_test.go b/pkg/chartutil/dependencies_test.go new file mode 100644 index 00000000..7e035be5 --- /dev/null +++ b/pkg/chartutil/dependencies_test.go @@ -0,0 +1,504 @@ +/* +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 chartutil + +import ( + "os" + "path/filepath" + "sort" + "strconv" + "testing" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" +) + +func loadChart(t *testing.T, path string) *chart.Chart { + t.Helper() + c, err := loader.Load(path) + if err != nil { + t.Fatalf("failed to load testdata: %s", err) + } + return c +} + +func TestLoadDependency(t *testing.T) { + tests := []*chart.Dependency{ + {Name: "alpine", Version: "0.1.0", Repository: "https://example.com/charts"}, + {Name: "mariner", Version: "4.3.2", Repository: "https://example.com/charts"}, + } + + check := func(deps []*chart.Dependency) { + if len(deps) != 2 { + t.Errorf("expected 2 dependencies, got %d", len(deps)) + } + for i, tt := range tests { + if deps[i].Name != tt.Name { + t.Errorf("expected dependency named %q, got %q", tt.Name, deps[i].Name) + } + if deps[i].Version != tt.Version { + t.Errorf("expected dependency named %q to have version %q, got %q", tt.Name, tt.Version, deps[i].Version) + } + if deps[i].Repository != tt.Repository { + t.Errorf("expected dependency named %q to have repository %q, got %q", tt.Name, tt.Repository, deps[i].Repository) + } + } + } + c := loadChart(t, "testdata/frobnitz") + check(c.Metadata.Dependencies) + check(c.Lock.Dependencies) +} + +func TestDependencyEnabled(t *testing.T) { + type M = map[string]interface{} + tests := []struct { + name string + v M + e []string // expected charts including duplicates in alphanumeric order + }{{ + "tags with no effect", + M{"tags": M{"nothinguseful": false}}, + []string{"parentchart", "parentchart.subchart1", "parentchart.subchart1.subcharta", "parentchart.subchart1.subchartb"}, + }, { + "tags disabling a group", + M{"tags": M{"front-end": false}}, + []string{"parentchart"}, + }, { + "tags disabling a group and enabling a different group", + M{"tags": M{"front-end": false, "back-end": true}}, + []string{"parentchart", "parentchart.subchart2", "parentchart.subchart2.subchartb", "parentchart.subchart2.subchartc"}, + }, { + "tags disabling only children, children still enabled since tag front-end=true in values.yaml", + M{"tags": M{"subcharta": false, "subchartb": false}}, + []string{"parentchart", "parentchart.subchart1", "parentchart.subchart1.subcharta", "parentchart.subchart1.subchartb"}, + }, { + "tags disabling all parents/children with additional tag re-enabling a parent", + M{"tags": M{"front-end": false, "subchart1": true, "back-end": false}}, + []string{"parentchart", "parentchart.subchart1"}, + }, { + "conditions enabling the parent charts, but back-end (b, c) is still disabled via values.yaml", + M{"subchart1": M{"enabled": true}, "subchart2": M{"enabled": true}}, + []string{"parentchart", "parentchart.subchart1", "parentchart.subchart1.subcharta", "parentchart.subchart1.subchartb", "parentchart.subchart2"}, + }, { + "conditions disabling the parent charts, effectively disabling children", + M{"subchart1": M{"enabled": false}, "subchart2": M{"enabled": false}}, + []string{"parentchart"}, + }, { + "conditions a child using the second condition path of child's condition", + M{"subchart1": M{"subcharta": M{"enabled": false}}}, + []string{"parentchart", "parentchart.subchart1", "parentchart.subchart1.subchartb"}, + }, { + "tags enabling a parent/child group with condition disabling one child", + M{"subchart2": M{"subchartc": M{"enabled": false}}, "tags": M{"back-end": true}}, + []string{"parentchart", "parentchart.subchart1", "parentchart.subchart1.subcharta", "parentchart.subchart1.subchartb", "parentchart.subchart2", "parentchart.subchart2.subchartb"}, + }, { + "tags will not enable a child if parent is explicitly disabled with condition", + M{"subchart1": M{"enabled": false}, "tags": M{"front-end": true}}, + []string{"parentchart"}, + }, { + "subcharts with alias also respect conditions", + M{"subchart1": M{"enabled": false}, "subchart2alias": M{"enabled": true, "subchartb": M{"enabled": true}}}, + []string{"parentchart", "parentchart.subchart2alias", "parentchart.subchart2alias.subchartb"}, + }} + + for _, tc := range tests { + c := loadChart(t, "testdata/subpop") + t.Run(tc.name, func(t *testing.T) { + if err := processDependencyEnabled(c, tc.v, ""); err != nil { + t.Fatalf("error processing enabled dependencies %v", err) + } + + names := extractChartNames(c) + if len(names) != len(tc.e) { + t.Fatalf("slice lengths do not match got %v, expected %v", len(names), len(tc.e)) + } + for i := range names { + if names[i] != tc.e[i] { + t.Fatalf("slice values do not match got %v, expected %v", names, tc.e) + } + } + }) + } +} + +// extractCharts recursively searches chart dependencies returning all charts found +func extractChartNames(c *chart.Chart) []string { + var out []string + var fn func(c *chart.Chart) + fn = func(c *chart.Chart) { + out = append(out, c.ChartPath()) + for _, d := range c.Dependencies() { + fn(d) + } + } + fn(c) + sort.Strings(out) + return out +} + +func TestProcessDependencyImportValues(t *testing.T) { + c := loadChart(t, "testdata/subpop") + + e := make(map[string]string) + + e["imported-chart1.SC1bool"] = "true" + e["imported-chart1.SC1float"] = "3.14" + e["imported-chart1.SC1int"] = "100" + e["imported-chart1.SC1string"] = "dollywood" + e["imported-chart1.SC1extra1"] = "11" + e["imported-chart1.SPextra1"] = "helm rocks" + e["imported-chart1.SC1extra1"] = "11" + + e["imported-chartA.SCAbool"] = "false" + e["imported-chartA.SCAfloat"] = "3.1" + e["imported-chartA.SCAint"] = "55" + e["imported-chartA.SCAstring"] = "jabba" + e["imported-chartA.SPextra3"] = "1.337" + e["imported-chartA.SC1extra2"] = "1.337" + e["imported-chartA.SCAnested1.SCAnested2"] = "true" + + e["imported-chartA-B.SCAbool"] = "false" + e["imported-chartA-B.SCAfloat"] = "3.1" + e["imported-chartA-B.SCAint"] = "55" + e["imported-chartA-B.SCAstring"] = "jabba" + + e["imported-chartA-B.SCBbool"] = "true" + e["imported-chartA-B.SCBfloat"] = "7.77" + e["imported-chartA-B.SCBint"] = "33" + e["imported-chartA-B.SCBstring"] = "boba" + e["imported-chartA-B.SPextra5"] = "k8s" + e["imported-chartA-B.SC1extra5"] = "tiller" + + // These values are imported from the child chart to the parent. Parent + // values take precedence over imported values. This enables importing a + // large section from a child chart and overriding a selection from it. + e["overridden-chart1.SC1bool"] = "false" + e["overridden-chart1.SC1float"] = "3.141592" + e["overridden-chart1.SC1int"] = "99" + e["overridden-chart1.SC1string"] = "pollywog" + e["overridden-chart1.SPextra2"] = "42" + + e["overridden-chartA.SCAbool"] = "true" + e["overridden-chartA.SCAfloat"] = "41.3" + e["overridden-chartA.SCAint"] = "808" + e["overridden-chartA.SCAstring"] = "jabberwocky" + e["overridden-chartA.SPextra4"] = "true" + + // These values are imported from the child chart to the parent. Parent + // values take precedence over imported values. This enables importing a + // large section from a child chart and overriding a selection from it. + e["overridden-chartA-B.SCAbool"] = "true" + e["overridden-chartA-B.SCAfloat"] = "41.3" + e["overridden-chartA-B.SCAint"] = "808" + e["overridden-chartA-B.SCAstring"] = "jabberwocky" + e["overridden-chartA-B.SCBbool"] = "false" + e["overridden-chartA-B.SCBfloat"] = "1.99" + e["overridden-chartA-B.SCBint"] = "77" + e["overridden-chartA-B.SCBstring"] = "jango" + e["overridden-chartA-B.SPextra6"] = "111" + e["overridden-chartA-B.SCAextra1"] = "23" + e["overridden-chartA-B.SCBextra1"] = "13" + e["overridden-chartA-B.SC1extra6"] = "77" + + // `exports` style + e["SCBexported1B"] = "1965" + e["SC1extra7"] = "true" + e["SCBexported2A"] = "blaster" + e["global.SC1exported2.all.SC1exported3"] = "SC1expstr" + + if err := processDependencyImportValues(c, false); err != nil { + t.Fatalf("processing import values dependencies %v", err) + } + cc := Values(c.Values) + for kk, vv := range e { + pv, err := cc.PathValue(kk) + if err != nil { + t.Fatalf("retrieving import values table %v %v", kk, err) + } + + switch pv := pv.(type) { + case float64: + if s := strconv.FormatFloat(pv, 'f', -1, 64); s != vv { + t.Errorf("failed to match imported float value %v with expected %v for key %q", s, vv, kk) + } + case bool: + if b := strconv.FormatBool(pv); b != vv { + t.Errorf("failed to match imported bool value %v with expected %v for key %q", b, vv, kk) + } + default: + if pv != vv { + t.Errorf("failed to match imported string value %q with expected %q for key %q", pv, vv, kk) + } + } + } + + // Since this was processed with coalescing there should be no null values. + // Here we verify that. + _, err := cc.PathValue("ensurenull") + if err == nil { + t.Error("expect nil value not found but found it") + } + switch xerr := err.(type) { + case ErrNoValue: + // We found what we expected + default: + t.Errorf("expected an ErrNoValue but got %q instead", xerr) + } + + c = loadChart(t, "testdata/subpop") + if err := processDependencyImportValues(c, true); err != nil { + t.Fatalf("processing import values dependencies %v", err) + } + cc = Values(c.Values) + val, err := cc.PathValue("ensurenull") + if err != nil { + t.Error("expect value but ensurenull was not found") + } + if val != nil { + t.Errorf("expect nil value but got %q instead", val) + } +} + +func TestProcessDependencyImportValuesMultiLevelPrecedence(t *testing.T) { + c := loadChart(t, "testdata/three-level-dependent-chart/umbrella") + + e := make(map[string]string) + + // The order of precedence should be: + // 1. User specified values (e.g CLI) + // 2. Parent chart values + // 3. Imported values + // 4. Sub-chart values + // The 4 app charts here deal with things differently: + // - app1 has a port value set in the umbrella chart. It does not import any + // values so the value from the umbrella chart should be used. + // - app2 has a value in the app chart and imports from the library. The + // app chart value should take precedence. + // - app3 has no value in the app chart and imports the value from the library + // chart. The library chart value should be used. + // - app4 has a value in the app chart and does not import the value from the + // library chart. The app charts value should be used. + e["app1.service.port"] = "3456" + e["app2.service.port"] = "8080" + e["app3.service.port"] = "9090" + e["app4.service.port"] = "1234" + if err := processDependencyImportValues(c, true); err != nil { + t.Fatalf("processing import values dependencies %v", err) + } + cc := Values(c.Values) + for kk, vv := range e { + pv, err := cc.PathValue(kk) + if err != nil { + t.Fatalf("retrieving import values table %v %v", kk, err) + } + + switch pv := pv.(type) { + case float64: + if s := strconv.FormatFloat(pv, 'f', -1, 64); s != vv { + t.Errorf("failed to match imported float value %v with expected %v", s, vv) + } + default: + if pv != vv { + t.Errorf("failed to match imported string value %q with expected %q", pv, vv) + } + } + } +} + +func TestProcessDependencyImportValuesForEnabledCharts(t *testing.T) { + c := loadChart(t, "testdata/import-values-from-enabled-subchart/parent-chart") + nameOverride := "parent-chart-prod" + + if err := processDependencyImportValues(c, true); err != nil { + t.Fatalf("processing import values dependencies %v", err) + } + + if len(c.Dependencies()) != 2 { + t.Fatalf("expected 2 dependencies for this chart, but got %d", len(c.Dependencies())) + } + + if err := processDependencyEnabled(c, c.Values, ""); err != nil { + t.Fatalf("expected no errors but got %q", err) + } + + if len(c.Dependencies()) != 1 { + t.Fatal("expected no changes in dependencies") + } + + if len(c.Metadata.Dependencies) != 1 { + t.Fatalf("expected 1 dependency specified in Chart.yaml, got %d", len(c.Metadata.Dependencies)) + } + + prodDependencyValues := c.Dependencies()[0].Values + if prodDependencyValues["nameOverride"] != nameOverride { + t.Fatalf("dependency chart name should be %s but got %s", nameOverride, prodDependencyValues["nameOverride"]) + } +} + +func TestGetAliasDependency(t *testing.T) { + c := loadChart(t, "testdata/frobnitz") + req := c.Metadata.Dependencies + + if len(req) == 0 { + t.Fatalf("there are no dependencies to test") + } + + // Success case + aliasChart := getAliasDependency(c.Dependencies(), req[0]) + if aliasChart == nil { + t.Fatalf("failed to get dependency chart for alias %s", req[0].Name) + } + if req[0].Alias != "" { + if aliasChart.Name() != req[0].Alias { + t.Fatalf("dependency chart name should be %s but got %s", req[0].Alias, aliasChart.Name()) + } + } else if aliasChart.Name() != req[0].Name { + t.Fatalf("dependency chart name should be %s but got %s", req[0].Name, aliasChart.Name()) + } + + if req[0].Version != "" { + if !IsCompatibleRange(req[0].Version, aliasChart.Metadata.Version) { + t.Fatalf("dependency chart version is not in the compatible range") + } + } + + // Failure case + req[0].Name = "something-else" + if aliasChart := getAliasDependency(c.Dependencies(), req[0]); aliasChart != nil { + t.Fatalf("expected no chart but got %s", aliasChart.Name()) + } + + req[0].Version = "something else which is not in the compatible range" + if IsCompatibleRange(req[0].Version, aliasChart.Metadata.Version) { + t.Fatalf("dependency chart version which is not in the compatible range should cause a failure other than a success ") + } +} + +func TestDependentChartAliases(t *testing.T) { + c := loadChart(t, "testdata/dependent-chart-alias") + req := c.Metadata.Dependencies + + if len(c.Dependencies()) != 2 { + t.Fatalf("expected 2 dependencies for this chart, but got %d", len(c.Dependencies())) + } + + if err := processDependencyEnabled(c, c.Values, ""); err != nil { + t.Fatalf("expected no errors but got %q", err) + } + + if len(c.Dependencies()) != 3 { + t.Fatal("expected alias dependencies to be added") + } + + if len(c.Dependencies()) != len(c.Metadata.Dependencies) { + t.Fatalf("expected number of chart dependencies %d, but got %d", len(c.Metadata.Dependencies), len(c.Dependencies())) + } + + aliasChart := getAliasDependency(c.Dependencies(), req[2]) + + if aliasChart == nil { + t.Fatalf("failed to get dependency chart for alias %s", req[2].Name) + } + if req[2].Alias != "" { + if aliasChart.Name() != req[2].Alias { + t.Fatalf("dependency chart name should be %s but got %s", req[2].Alias, aliasChart.Name()) + } + } else if aliasChart.Name() != req[2].Name { + t.Fatalf("dependency chart name should be %s but got %s", req[2].Name, aliasChart.Name()) + } + + req[2].Name = "dummy-name" + if aliasChart := getAliasDependency(c.Dependencies(), req[2]); aliasChart != nil { + t.Fatalf("expected no chart but got %s", aliasChart.Name()) + } + +} + +func TestDependentChartWithSubChartsAbsentInDependency(t *testing.T) { + c := loadChart(t, "testdata/dependent-chart-no-requirements-yaml") + + if len(c.Dependencies()) != 2 { + t.Fatalf("expected 2 dependencies for this chart, but got %d", len(c.Dependencies())) + } + + if err := processDependencyEnabled(c, c.Values, ""); err != nil { + t.Fatalf("expected no errors but got %q", err) + } + + if len(c.Dependencies()) != 2 { + t.Fatal("expected no changes in dependencies") + } +} + +func TestDependentChartWithSubChartsHelmignore(t *testing.T) { + // FIXME what does this test? + loadChart(t, "testdata/dependent-chart-helmignore") +} + +func TestDependentChartsWithSubChartsSymlink(t *testing.T) { + joonix := filepath.Join("testdata", "joonix") + if err := os.Symlink(filepath.Join("..", "..", "frobnitz"), filepath.Join(joonix, "charts", "frobnitz")); err != nil { + t.Fatal(err) + } + defer os.RemoveAll(filepath.Join(joonix, "charts", "frobnitz")) + c := loadChart(t, joonix) + + if c.Name() != "joonix" { + t.Fatalf("unexpected chart name: %s", c.Name()) + } + if n := len(c.Dependencies()); n != 1 { + t.Fatalf("expected 1 dependency for this chart, but got %d", n) + } +} + +func TestDependentChartsWithSubchartsAllSpecifiedInDependency(t *testing.T) { + c := loadChart(t, "testdata/dependent-chart-with-all-in-requirements-yaml") + + if len(c.Dependencies()) != 2 { + t.Fatalf("expected 2 dependencies for this chart, but got %d", len(c.Dependencies())) + } + + if err := processDependencyEnabled(c, c.Values, ""); err != nil { + t.Fatalf("expected no errors but got %q", err) + } + + if len(c.Dependencies()) != 2 { + t.Fatal("expected no changes in dependencies") + } + + if len(c.Dependencies()) != len(c.Metadata.Dependencies) { + t.Fatalf("expected number of chart dependencies %d, but got %d", len(c.Metadata.Dependencies), len(c.Dependencies())) + } +} + +func TestDependentChartsWithSomeSubchartsSpecifiedInDependency(t *testing.T) { + c := loadChart(t, "testdata/dependent-chart-with-mixed-requirements-yaml") + + if len(c.Dependencies()) != 2 { + t.Fatalf("expected 2 dependencies for this chart, but got %d", len(c.Dependencies())) + } + + if err := processDependencyEnabled(c, c.Values, ""); err != nil { + t.Fatalf("expected no errors but got %q", err) + } + + if len(c.Dependencies()) != 2 { + t.Fatal("expected no changes in dependencies") + } + + if len(c.Metadata.Dependencies) != 1 { + t.Fatalf("expected 1 dependency specified in Chart.yaml, got %d", len(c.Metadata.Dependencies)) + } +} diff --git a/pkg/chartutil/doc.go b/pkg/chartutil/doc.go new file mode 100644 index 00000000..49c55ac5 --- /dev/null +++ b/pkg/chartutil/doc.go @@ -0,0 +1,45 @@ +/* +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 chartutil contains tools for working with charts. + +Charts are described in the chart package (pkg/chart). +This package provides utilities for serializing and deserializing charts. + +A chart can be represented on the file system in one of two ways: + + - As a directory that contains a Chart.yaml file and other chart things. + - As a tarred gzipped file containing a directory that then contains a + Chart.yaml file. + +This package provides utilities for working with those file formats. + +The preferred way of loading a chart is using 'loader.Load`: + + chart, err := loader.Load(filename) + +This will attempt to discover whether the file at 'filename' is a directory or +a chart archive. It will then load accordingly. + +For accepting raw compressed tar file data from an io.Reader, the +'loader.LoadArchive()' will read in the data, uncompress it, and unpack it +into a Chart. + +When creating charts in memory, use the 'helm.sh/helm/pkg/chart' +package directly. +*/ +package chartutil // import "helm.sh/helm/v3/pkg/chartutil" diff --git a/pkg/chartutil/errors.go b/pkg/chartutil/errors.go new file mode 100644 index 00000000..0a4046d2 --- /dev/null +++ b/pkg/chartutil/errors.go @@ -0,0 +1,43 @@ +/* +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 chartutil + +import ( + "fmt" +) + +// ErrNoTable indicates that a chart does not have a matching table. +type ErrNoTable struct { + Key string +} + +func (e ErrNoTable) Error() string { return fmt.Sprintf("%q is not a table", e.Key) } + +// ErrNoValue indicates that Values does not contain a key with a value +type ErrNoValue struct { + Key string +} + +func (e ErrNoValue) Error() string { return fmt.Sprintf("%q is not a value", e.Key) } + +type ErrInvalidChartName struct { + Name string +} + +func (e ErrInvalidChartName) Error() string { + return fmt.Sprintf("%q is not a valid chart name", e.Name) +} diff --git a/pkg/chartutil/errors_test.go b/pkg/chartutil/errors_test.go new file mode 100644 index 00000000..3f63e373 --- /dev/null +++ b/pkg/chartutil/errors_test.go @@ -0,0 +1,37 @@ +/* +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 chartutil + +import ( + "testing" +) + +func TestErrorNoTableDoesNotPanic(t *testing.T) { + x := "empty" + + y := ErrNoTable{x} + + t.Logf("error is: %s", y) +} + +func TestErrorNoValueDoesNotPanic(t *testing.T) { + x := "empty" + + y := ErrNoValue{x} + + t.Logf("error is: %s", y) +} diff --git a/pkg/chartutil/expand.go b/pkg/chartutil/expand.go new file mode 100644 index 00000000..7ae1ae6f --- /dev/null +++ b/pkg/chartutil/expand.go @@ -0,0 +1,90 @@ +/* +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 chartutil + +import ( + "io" + "os" + "path/filepath" + + securejoin "github.com/cyphar/filepath-securejoin" + "github.com/pkg/errors" + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" +) + +// Expand uncompresses and extracts a chart into the specified directory. +func Expand(dir string, r io.Reader) error { + files, err := loader.LoadArchiveFiles(r) + if err != nil { + return err + } + + // Get the name of the chart + var chartName string + for _, file := range files { + if file.Name == "Chart.yaml" { + ch := &chart.Metadata{} + if err := yaml.Unmarshal(file.Data, ch); err != nil { + return errors.Wrap(err, "cannot load Chart.yaml") + } + chartName = ch.Name + } + } + if chartName == "" { + return errors.New("chart name not specified") + } + + // Find the base directory + chartdir, err := securejoin.SecureJoin(dir, chartName) + if err != nil { + return err + } + + // Copy all files verbatim. We don't parse these files because parsing can remove + // comments. + for _, file := range files { + outpath, err := securejoin.SecureJoin(chartdir, file.Name) + if err != nil { + return err + } + + // Make sure the necessary subdirs get created. + basedir := filepath.Dir(outpath) + if err := os.MkdirAll(basedir, 0755); err != nil { + return err + } + + if err := os.WriteFile(outpath, file.Data, 0644); err != nil { + return err + } + } + + return nil +} + +// ExpandFile expands the src file into the dest directory. +func ExpandFile(dest, src string) error { + h, err := os.Open(src) + if err != nil { + return err + } + defer h.Close() + return Expand(dest, h) +} diff --git a/pkg/chartutil/expand_test.go b/pkg/chartutil/expand_test.go new file mode 100644 index 00000000..f31a3d29 --- /dev/null +++ b/pkg/chartutil/expand_test.go @@ -0,0 +1,124 @@ +/* +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 chartutil + +import ( + "os" + "path/filepath" + "testing" +) + +func TestExpand(t *testing.T) { + dest := t.TempDir() + + reader, err := os.Open("testdata/frobnitz-1.2.3.tgz") + if err != nil { + t.Fatal(err) + } + + if err := Expand(dest, reader); err != nil { + t.Fatal(err) + } + + expectedChartPath := filepath.Join(dest, "frobnitz") + fi, err := os.Stat(expectedChartPath) + if err != nil { + t.Fatal(err) + } + if !fi.IsDir() { + t.Fatalf("expected a chart directory at %s", expectedChartPath) + } + + dir, err := os.Open(expectedChartPath) + if err != nil { + t.Fatal(err) + } + + fis, err := dir.Readdir(0) + if err != nil { + t.Fatal(err) + } + + expectLen := 11 + if len(fis) != expectLen { + t.Errorf("Expected %d files, but got %d", expectLen, len(fis)) + } + + for _, fi := range fis { + expect, err := os.Stat(filepath.Join("testdata", "frobnitz", fi.Name())) + if err != nil { + t.Fatal(err) + } + // os.Stat can return different values for directories, based on the OS + // for Linux, for example, os.Stat alwaty returns the size of the directory + // (value-4096) regardless of the size of the contents of the directory + mode := expect.Mode() + if !mode.IsDir() { + if fi.Size() != expect.Size() { + t.Errorf("Expected %s to have size %d, got %d", fi.Name(), expect.Size(), fi.Size()) + } + } + } +} + +func TestExpandFile(t *testing.T) { + dest := t.TempDir() + + if err := ExpandFile(dest, "testdata/frobnitz-1.2.3.tgz"); err != nil { + t.Fatal(err) + } + + expectedChartPath := filepath.Join(dest, "frobnitz") + fi, err := os.Stat(expectedChartPath) + if err != nil { + t.Fatal(err) + } + if !fi.IsDir() { + t.Fatalf("expected a chart directory at %s", expectedChartPath) + } + + dir, err := os.Open(expectedChartPath) + if err != nil { + t.Fatal(err) + } + + fis, err := dir.Readdir(0) + if err != nil { + t.Fatal(err) + } + + expectLen := 11 + if len(fis) != expectLen { + t.Errorf("Expected %d files, but got %d", expectLen, len(fis)) + } + + for _, fi := range fis { + expect, err := os.Stat(filepath.Join("testdata", "frobnitz", fi.Name())) + if err != nil { + t.Fatal(err) + } + // os.Stat can return different values for directories, based on the OS + // for Linux, for example, os.Stat alwaty returns the size of the directory + // (value-4096) regardless of the size of the contents of the directory + mode := expect.Mode() + if !mode.IsDir() { + if fi.Size() != expect.Size() { + t.Errorf("Expected %s to have size %d, got %d", fi.Name(), expect.Size(), fi.Size()) + } + } + } +} diff --git a/pkg/chartutil/jsonschema.go b/pkg/chartutil/jsonschema.go new file mode 100644 index 00000000..7b9768fd --- /dev/null +++ b/pkg/chartutil/jsonschema.go @@ -0,0 +1,93 @@ +/* +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 chartutil + +import ( + "bytes" + "fmt" + "strings" + + "github.com/pkg/errors" + "github.com/xeipuuv/gojsonschema" + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/pkg/chart" +) + +// ValidateAgainstSchema checks that values does not violate the structure laid out in schema +func ValidateAgainstSchema(chrt *chart.Chart, values map[string]interface{}) error { + var sb strings.Builder + if chrt.Schema != nil { + err := ValidateAgainstSingleSchema(values, chrt.Schema) + if err != nil { + sb.WriteString(fmt.Sprintf("%s:\n", chrt.Name())) + sb.WriteString(err.Error()) + } + } + + // For each dependency, recursively call this function with the coalesced values + for _, subchart := range chrt.Dependencies() { + subchartValues := values[subchart.Name()].(map[string]interface{}) + if err := ValidateAgainstSchema(subchart, subchartValues); err != nil { + sb.WriteString(err.Error()) + } + } + + if sb.Len() > 0 { + return errors.New(sb.String()) + } + + return nil +} + +// ValidateAgainstSingleSchema checks that values does not violate the structure laid out in this schema +func ValidateAgainstSingleSchema(values Values, schemaJSON []byte) (reterr error) { + defer func() { + if r := recover(); r != nil { + reterr = fmt.Errorf("unable to validate schema: %s", r) + } + }() + + valuesData, err := yaml.Marshal(values) + if err != nil { + return err + } + valuesJSON, err := yaml.YAMLToJSON(valuesData) + if err != nil { + return err + } + if bytes.Equal(valuesJSON, []byte("null")) { + valuesJSON = []byte("{}") + } + schemaLoader := gojsonschema.NewBytesLoader(schemaJSON) + valuesLoader := gojsonschema.NewBytesLoader(valuesJSON) + + result, err := gojsonschema.Validate(schemaLoader, valuesLoader) + if err != nil { + return err + } + + if !result.Valid() { + var sb strings.Builder + for _, desc := range result.Errors() { + sb.WriteString(fmt.Sprintf("- %s\n", desc)) + } + return errors.New(sb.String()) + } + + return nil +} diff --git a/pkg/chartutil/jsonschema_test.go b/pkg/chartutil/jsonschema_test.go new file mode 100644 index 00000000..7610db33 --- /dev/null +++ b/pkg/chartutil/jsonschema_test.go @@ -0,0 +1,167 @@ +/* +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 chartutil + +import ( + "os" + "testing" + + "helm.sh/helm/v3/pkg/chart" +) + +func TestValidateAgainstSingleSchema(t *testing.T) { + values, err := ReadValuesFile("./testdata/test-values.yaml") + if err != nil { + t.Fatalf("Error reading YAML file: %s", err) + } + schema, err := os.ReadFile("./testdata/test-values.schema.json") + if err != nil { + t.Fatalf("Error reading YAML file: %s", err) + } + + if err := ValidateAgainstSingleSchema(values, schema); err != nil { + t.Errorf("Error validating Values against Schema: %s", err) + } +} + +func TestValidateAgainstInvalidSingleSchema(t *testing.T) { + values, err := ReadValuesFile("./testdata/test-values.yaml") + if err != nil { + t.Fatalf("Error reading YAML file: %s", err) + } + schema, err := os.ReadFile("./testdata/test-values-invalid.schema.json") + if err != nil { + t.Fatalf("Error reading YAML file: %s", err) + } + + var errString string + if err := ValidateAgainstSingleSchema(values, schema); err == nil { + t.Fatalf("Expected an error, but got nil") + } else { + errString = err.Error() + } + + expectedErrString := "unable to validate schema: runtime error: invalid " + + "memory address or nil pointer dereference" + if errString != expectedErrString { + t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString) + } +} + +func TestValidateAgainstSingleSchemaNegative(t *testing.T) { + values, err := ReadValuesFile("./testdata/test-values-negative.yaml") + if err != nil { + t.Fatalf("Error reading YAML file: %s", err) + } + schema, err := os.ReadFile("./testdata/test-values.schema.json") + if err != nil { + t.Fatalf("Error reading YAML file: %s", err) + } + + var errString string + if err := ValidateAgainstSingleSchema(values, schema); err == nil { + t.Fatalf("Expected an error, but got nil") + } else { + errString = err.Error() + } + + expectedErrString := `- (root): employmentInfo is required +- age: Must be greater than or equal to 0 +` + if errString != expectedErrString { + t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString) + } +} + +const subchartSchema = `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Values", + "type": "object", + "properties": { + "age": { + "description": "Age", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "age" + ] +} +` + +func TestValidateAgainstSchema(t *testing.T) { + subchartJSON := []byte(subchartSchema) + subchart := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "subchart", + }, + Schema: subchartJSON, + } + chrt := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "chrt", + }, + } + chrt.AddDependency(subchart) + + vals := map[string]interface{}{ + "name": "John", + "subchart": map[string]interface{}{ + "age": 25, + }, + } + + if err := ValidateAgainstSchema(chrt, vals); err != nil { + t.Errorf("Error validating Values against Schema: %s", err) + } +} + +func TestValidateAgainstSchemaNegative(t *testing.T) { + subchartJSON := []byte(subchartSchema) + subchart := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "subchart", + }, + Schema: subchartJSON, + } + chrt := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "chrt", + }, + } + chrt.AddDependency(subchart) + + vals := map[string]interface{}{ + "name": "John", + "subchart": map[string]interface{}{}, + } + + var errString string + if err := ValidateAgainstSchema(chrt, vals); err == nil { + t.Fatalf("Expected an error, but got nil") + } else { + errString = err.Error() + } + + expectedErrString := `subchart: +- (root): age is required +` + if errString != expectedErrString { + t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString) + } +} diff --git a/pkg/chartutil/save.go b/pkg/chartutil/save.go new file mode 100644 index 00000000..4ee90709 --- /dev/null +++ b/pkg/chartutil/save.go @@ -0,0 +1,264 @@ +/* +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 chartutil + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/pkg/errors" + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/pkg/chart" +) + +var headerBytes = []byte("+aHR0cHM6Ly95b3V0dS5iZS96OVV6MWljandyTQo=") + +// SaveDir saves a chart as files in a directory. +// +// This takes the chart name, and creates a new subdirectory inside of the given dest +// directory, writing the chart's contents to that subdirectory. +func SaveDir(c *chart.Chart, dest string) error { + // Create the chart directory + err := validateName(c.Name()) + if err != nil { + return err + } + outdir := filepath.Join(dest, c.Name()) + if fi, err := os.Stat(outdir); err == nil && !fi.IsDir() { + return errors.Errorf("file %s already exists and is not a directory", outdir) + } + if err := os.MkdirAll(outdir, 0755); err != nil { + return err + } + + // Save the chart file. + if err := SaveChartfile(filepath.Join(outdir, ChartfileName), c.Metadata); err != nil { + return err + } + + // Save values.yaml + for _, f := range c.Raw { + if f.Name == ValuesfileName { + vf := filepath.Join(outdir, ValuesfileName) + if err := writeFile(vf, f.Data); err != nil { + return err + } + } + } + + // Save values.schema.json if it exists + if c.Schema != nil { + filename := filepath.Join(outdir, SchemafileName) + if err := writeFile(filename, c.Schema); err != nil { + return err + } + } + + // Save templates and files + for _, o := range [][]*chart.File{c.Templates, c.Files} { + for _, f := range o { + n := filepath.Join(outdir, f.Name) + if err := writeFile(n, f.Data); err != nil { + return err + } + } + } + + // Save dependencies + base := filepath.Join(outdir, ChartsDir) + for _, dep := range c.Dependencies() { + // Here, we write each dependency as a tar file. + if _, err := Save(dep, base); err != nil { + return errors.Wrapf(err, "saving %s", dep.ChartFullPath()) + } + } + return nil +} + +// Save creates an archived chart to the given directory. +// +// This takes an existing chart and a destination directory. +// +// If the directory is /foo, and the chart is named bar, with version 1.0.0, this +// will generate /foo/bar-1.0.0.tgz. +// +// This returns the absolute path to the chart archive file. +func Save(c *chart.Chart, outDir string) (string, error) { + if err := c.Validate(); err != nil { + return "", errors.Wrap(err, "chart validation") + } + + filename := fmt.Sprintf("%s-%s.tgz", c.Name(), c.Metadata.Version) + filename = filepath.Join(outDir, filename) + dir := filepath.Dir(filename) + if stat, err := os.Stat(dir); err != nil { + if os.IsNotExist(err) { + if err2 := os.MkdirAll(dir, 0755); err2 != nil { + return "", err2 + } + } else { + return "", errors.Wrapf(err, "stat %s", dir) + } + } else if !stat.IsDir() { + return "", errors.Errorf("is not a directory: %s", dir) + } + + f, err := os.Create(filename) + if err != nil { + return "", err + } + + // Wrap in gzip writer + zipper := gzip.NewWriter(f) + zipper.Header.Extra = headerBytes + zipper.Header.Comment = "Helm" + + // Wrap in tar writer + twriter := tar.NewWriter(zipper) + rollback := false + defer func() { + twriter.Close() + zipper.Close() + f.Close() + if rollback { + os.Remove(filename) + } + }() + + if err := writeTarContents(twriter, c, ""); err != nil { + rollback = true + return filename, err + } + return filename, nil +} + +func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error { + err := validateName(c.Name()) + if err != nil { + return err + } + base := filepath.Join(prefix, c.Name()) + + // Pull out the dependencies of a v1 Chart, since there's no way + // to tell the serializer to skip a field for just this use case + savedDependencies := c.Metadata.Dependencies + if c.Metadata.APIVersion == chart.APIVersionV1 { + c.Metadata.Dependencies = nil + } + // Save Chart.yaml + cdata, err := yaml.Marshal(c.Metadata) + if c.Metadata.APIVersion == chart.APIVersionV1 { + c.Metadata.Dependencies = savedDependencies + } + if err != nil { + return err + } + if err := writeToTar(out, filepath.Join(base, ChartfileName), cdata); err != nil { + return err + } + + // Save Chart.lock + // TODO: remove the APIVersion check when APIVersionV1 is not used anymore + if c.Metadata.APIVersion == chart.APIVersionV2 { + if c.Lock != nil { + ldata, err := yaml.Marshal(c.Lock) + if err != nil { + return err + } + if err := writeToTar(out, filepath.Join(base, "Chart.lock"), ldata); err != nil { + return err + } + } + } + + // Save values.yaml + for _, f := range c.Raw { + if f.Name == ValuesfileName { + if err := writeToTar(out, filepath.Join(base, ValuesfileName), f.Data); err != nil { + return err + } + } + } + + // Save values.schema.json if it exists + if c.Schema != nil { + if !json.Valid(c.Schema) { + return errors.New("Invalid JSON in " + SchemafileName) + } + if err := writeToTar(out, filepath.Join(base, SchemafileName), c.Schema); err != nil { + return err + } + } + + // Save templates + for _, f := range c.Templates { + n := filepath.Join(base, f.Name) + if err := writeToTar(out, n, f.Data); err != nil { + return err + } + } + + // Save files + for _, f := range c.Files { + n := filepath.Join(base, f.Name) + if err := writeToTar(out, n, f.Data); err != nil { + return err + } + } + + // Save dependencies + for _, dep := range c.Dependencies() { + if err := writeTarContents(out, dep, filepath.Join(base, ChartsDir)); err != nil { + return err + } + } + return nil +} + +// writeToTar writes a single file to a tar archive. +func writeToTar(out *tar.Writer, name string, body []byte) error { + // TODO: Do we need to create dummy parent directory names if none exist? + h := &tar.Header{ + Name: filepath.ToSlash(name), + Mode: 0644, + Size: int64(len(body)), + ModTime: time.Now(), + } + if err := out.WriteHeader(h); err != nil { + return err + } + _, err := out.Write(body) + return err +} + +// If the name has directory name has characters which would change the location +// they need to be removed. +func validateName(name string) error { + nname := filepath.Base(name) + + if nname != name { + return ErrInvalidChartName{name} + } + + return nil +} diff --git a/pkg/chartutil/save_test.go b/pkg/chartutil/save_test.go new file mode 100644 index 00000000..98b4e641 --- /dev/null +++ b/pkg/chartutil/save_test.go @@ -0,0 +1,264 @@ +/* +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 chartutil + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "io" + "os" + "path" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" +) + +func TestSave(t *testing.T) { + tmp := t.TempDir() + + for _, dest := range []string{tmp, filepath.Join(tmp, "newdir")} { + t.Run("outDir="+dest, func(t *testing.T) { + c := &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV1, + Name: "ahab", + Version: "1.2.3", + }, + Lock: &chart.Lock{ + Digest: "testdigest", + }, + Files: []*chart.File{ + {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, + }, + Schema: []byte("{\n \"title\": \"Values\"\n}"), + } + chartWithInvalidJSON := withSchema(*c, []byte("{")) + + where, err := Save(c, dest) + if err != nil { + t.Fatalf("Failed to save: %s", err) + } + if !strings.HasPrefix(where, dest) { + t.Fatalf("Expected %q to start with %q", where, dest) + } + if !strings.HasSuffix(where, ".tgz") { + t.Fatalf("Expected %q to end with .tgz", where) + } + + c2, err := loader.LoadFile(where) + if err != nil { + t.Fatal(err) + } + if c2.Name() != c.Name() { + t.Fatalf("Expected chart archive to have %q, got %q", c.Name(), c2.Name()) + } + if len(c2.Files) != 1 || c2.Files[0].Name != "scheherazade/shahryar.txt" { + t.Fatal("Files data did not match") + } + if c2.Lock != nil { + t.Fatal("Expected v1 chart archive not to contain Chart.lock file") + } + + if !bytes.Equal(c.Schema, c2.Schema) { + indentation := 4 + formattedExpected := Indent(indentation, string(c.Schema)) + formattedActual := Indent(indentation, string(c2.Schema)) + t.Fatalf("Schema data did not match.\nExpected:\n%s\nActual:\n%s", formattedExpected, formattedActual) + } + if _, err := Save(&chartWithInvalidJSON, dest); err == nil { + t.Fatalf("Invalid JSON was not caught while saving chart") + } + + c.Metadata.APIVersion = chart.APIVersionV2 + where, err = Save(c, dest) + if err != nil { + t.Fatalf("Failed to save: %s", err) + } + c2, err = loader.LoadFile(where) + if err != nil { + t.Fatal(err) + } + if c2.Lock == nil { + t.Fatal("Expected v2 chart archive to contain a Chart.lock file") + } + if c2.Lock.Digest != c.Lock.Digest { + t.Fatal("Chart.lock data did not match") + } + }) + } + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV1, + Name: "../ahab", + Version: "1.2.3", + }, + Lock: &chart.Lock{ + Digest: "testdigest", + }, + Files: []*chart.File{ + {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, + }, + } + _, err := Save(c, tmp) + if err == nil { + t.Fatal("Expected error saving chart with invalid name") + } +} + +// Creates a copy with a different schema; does not modify anything. +func withSchema(chart chart.Chart, schema []byte) chart.Chart { + chart.Schema = schema + return chart +} + +func Indent(n int, text string) string { + startOfLine := regexp.MustCompile(`(?m)^`) + indentation := strings.Repeat(" ", n) + return startOfLine.ReplaceAllLiteralString(text, indentation) +} + +func TestSavePreservesTimestamps(t *testing.T) { + // Test executes so quickly that if we don't subtract a second, the + // check will fail because `initialCreateTime` will be identical to the + // written timestamp for the files. + initialCreateTime := time.Now().Add(-1 * time.Second) + + tmp := t.TempDir() + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV1, + Name: "ahab", + Version: "1.2.3", + }, + Values: map[string]interface{}{ + "imageName": "testimage", + "imageId": 42, + }, + Files: []*chart.File{ + {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, + }, + Schema: []byte("{\n \"title\": \"Values\"\n}"), + } + + where, err := Save(c, tmp) + if err != nil { + t.Fatalf("Failed to save: %s", err) + } + + allHeaders, err := retrieveAllHeadersFromTar(where) + if err != nil { + t.Fatalf("Failed to parse tar: %v", err) + } + + for _, header := range allHeaders { + if header.ModTime.Before(initialCreateTime) { + t.Fatalf("File timestamp not preserved: %v", header.ModTime) + } + } +} + +// We could refactor `load.go` to use this `retrieveAllHeadersFromTar` function +// as well, so we are not duplicating components of the code which iterate +// through the tar. +func retrieveAllHeadersFromTar(path string) ([]*tar.Header, error) { + raw, err := os.Open(path) + if err != nil { + return nil, err + } + defer raw.Close() + + unzipped, err := gzip.NewReader(raw) + if err != nil { + return nil, err + } + defer unzipped.Close() + + tr := tar.NewReader(unzipped) + headers := []*tar.Header{} + for { + hd, err := tr.Next() + if err == io.EOF { + break + } + + if err != nil { + return nil, err + } + + headers = append(headers, hd) + } + + return headers, nil +} + +func TestSaveDir(t *testing.T) { + tmp := t.TempDir() + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV1, + Name: "ahab", + Version: "1.2.3", + }, + Files: []*chart.File{ + {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, + }, + Templates: []*chart.File{ + {Name: path.Join(TemplatesDir, "nested", "dir", "thing.yaml"), Data: []byte("abc: {{ .Values.abc }}")}, + }, + } + + if err := SaveDir(c, tmp); err != nil { + t.Fatalf("Failed to save: %s", err) + } + + c2, err := loader.LoadDir(tmp + "/ahab") + if err != nil { + t.Fatal(err) + } + + if c2.Name() != c.Name() { + t.Fatalf("Expected chart archive to have %q, got %q", c.Name(), c2.Name()) + } + + if len(c2.Templates) != 1 || c2.Templates[0].Name != c.Templates[0].Name { + t.Fatal("Templates data did not match") + } + + if len(c2.Files) != 1 || c2.Files[0].Name != c.Files[0].Name { + t.Fatal("Files data did not match") + } + + tmp2 := t.TempDir() + c.Metadata.Name = "../ahab" + pth := filepath.Join(tmp2, "tmpcharts") + if err := os.MkdirAll(filepath.Join(pth), 0755); err != nil { + t.Fatal(err) + } + + if err := SaveDir(c, pth); err.Error() != "\"../ahab\" is not a valid chart name" { + t.Fatalf("Did not get expected error for chart named %q", c.Name()) + } +} diff --git a/pkg/chartutil/testdata/chartfiletest.yaml b/pkg/chartutil/testdata/chartfiletest.yaml new file mode 100644 index 00000000..134cd110 --- /dev/null +++ b/pkg/chartutil/testdata/chartfiletest.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +annotations: + extrakey: extravalue + anotherkey: anothervalue diff --git a/pkg/chartutil/testdata/coleridge.yaml b/pkg/chartutil/testdata/coleridge.yaml new file mode 100644 index 00000000..b6579628 --- /dev/null +++ b/pkg/chartutil/testdata/coleridge.yaml @@ -0,0 +1,12 @@ +poet: "Coleridge" +title: "Rime of the Ancient Mariner" +stanza: ["at", "length", "did", "cross", "an", "Albatross"] + +mariner: + with: "crossbow" + shot: "ALBATROSS" + +water: + water: + where: "everywhere" + nor: "any drop to drink" diff --git a/pkg/chartutil/testdata/dependent-chart-alias/.helmignore b/pkg/chartutil/testdata/dependent-chart-alias/.helmignore new file mode 100644 index 00000000..9973a57b --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-alias/.helmignore @@ -0,0 +1 @@ +ignore/ diff --git a/pkg/chartutil/testdata/dependent-chart-alias/Chart.lock b/pkg/chartutil/testdata/dependent-chart-alias/Chart.lock new file mode 100644 index 00000000..6fcc2ed9 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-alias/Chart.lock @@ -0,0 +1,8 @@ +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts +digest: invalid diff --git a/pkg/chartutil/testdata/dependent-chart-alias/Chart.yaml b/pkg/chartutil/testdata/dependent-chart-alias/Chart.yaml new file mode 100644 index 00000000..751a3aa6 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-alias/Chart.yaml @@ -0,0 +1,29 @@ +apiVersion: v1 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts + alias: mariners2 + - name: mariner + version: "4.3.2" + repository: https://example.com/charts + alias: mariners1 diff --git a/pkg/chartutil/testdata/dependent-chart-alias/INSTALL.txt b/pkg/chartutil/testdata/dependent-chart-alias/INSTALL.txt new file mode 100644 index 00000000..2010438c --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-alias/INSTALL.txt @@ -0,0 +1 @@ +This is an install document. The client may display this. diff --git a/pkg/chartutil/testdata/dependent-chart-alias/LICENSE b/pkg/chartutil/testdata/dependent-chart-alias/LICENSE new file mode 100644 index 00000000..6121943b --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-alias/LICENSE @@ -0,0 +1 @@ +LICENSE placeholder. diff --git a/pkg/chartutil/testdata/dependent-chart-alias/README.md b/pkg/chartutil/testdata/dependent-chart-alias/README.md new file mode 100644 index 00000000..8cf4cc3d --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-alias/README.md @@ -0,0 +1,11 @@ +# Frobnitz + +This is an example chart. + +## Usage + +This is an example. It has no usage. + +## Development + +For developer info, see the top-level repository. diff --git a/pkg/chartutil/testdata/dependent-chart-alias/charts/_ignore_me b/pkg/chartutil/testdata/dependent-chart-alias/charts/_ignore_me new file mode 100644 index 00000000..2cecca68 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-alias/charts/_ignore_me @@ -0,0 +1 @@ +This should be ignored by the loader, but may be included in a chart. diff --git a/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/Chart.yaml b/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/Chart.yaml new file mode 100644 index 00000000..79e0d65d --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: alpine +description: Deploy a basic Alpine Linux pod +version: 0.1.0 +home: https://helm.sh/helm diff --git a/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/README.md b/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/README.md new file mode 100644 index 00000000..b30b949d --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.toml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/charts/mast1/Chart.yaml b/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/charts/mast1/Chart.yaml new file mode 100644 index 00000000..1c9dd5fa --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/charts/mast1/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: mast1 +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/charts/mast1/values.yaml b/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/charts/mast1/values.yaml new file mode 100644 index 00000000..42c39c26 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/charts/mast1/values.yaml @@ -0,0 +1,4 @@ +# Default values for mast1. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name = "value" diff --git a/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/charts/mast2-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..61cb62051110b55f3d08213dc81dcf0b1c2d8e53 GIT binary patch literal 252 zcmVDc zVQyr3R8em|NM&qo0PNJUs=_c72H?(liabH@pWIst-7YSIyL+rhEHrIN(t?QZE=F{y zgNRfS&$pa5Ly`mMk2OB%pV`*9knW7FlL-Joo@KED7*{C$d;N~z z37$S{+}wvSU9}|VtF|fRpv9Ve>8dWo|9?5B+RE}Y9CFh-x#(Bq8Vck^V=NUiPLCKa z8z5CF#JgK!4>;$4Fm+FUst4d+{(+nP|0&J+e}(;l^U4@w-{=?s0RR8vgVbLD3;+OM Cs&R<` literal 0 HcmV?d00001 diff --git a/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/templates/alpine-pod.yaml b/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..5bbae10a --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service}} + chartName: {{.Chart.Name}} + chartVersion: {{.Chart.Version | quote}} +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.3" + command: ["/bin/sleep","9000"] diff --git a/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/values.yaml b/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/values.yaml new file mode 100644 index 00000000..6c2aab7b --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: "my-alpine" diff --git a/pkg/chartutil/testdata/dependent-chart-alias/charts/mariner-4.3.2.tgz b/pkg/chartutil/testdata/dependent-chart-alias/charts/mariner-4.3.2.tgz new file mode 100644 index 0000000000000000000000000000000000000000..3190136b050e62c628b3c817fd963ac9dc4a9e25 GIT binary patch literal 967 zcmV;&133I2iwFR+9h6)E1MQb_y%a`Bd>#= zhbqf2w!b6}wZ9@rvIhtwzm?~C&+QLm+G2!l%`$_aUgS(@pdjdX3a%E}VXVc7`)dg( zL%IRN2}c1D3xoMi2w@WuWOMZ?5i&3FelBVyq_Ce_8fREdP%NDf<&d!wu3{&VUQNs{N%vK$Ha~k^gB2!0bO7r0ic0 zbqCp*X#j?=|H@GNONsuE)&Idbh>2MX(Z}|+=@*sLod*wS?A8Ugp#mMPuMO0NnZmos9_rr z3xpDL+otj~lRh?B4hHFTl+d5-8NBXmUXB~EyF^bx7m*+!*g>obczvGF|8xkWsHN8; z%#+wiWP{=2pC*98@$VNzzsll&G#D7&11-;D>HT0x|DV2?6}a~*p46@R|2l??e_8dX z@Bb>D3t~VC_*wjq2Dy!6J-_5ME%%J+xmsbK5a#qO>ZV?Wt`d|TDdrB^B&LqFr@lYdUA zs&4-JAg3&uc4dA0gv*bXU9QePa9^8wR{HovA)j@)JOAIkak0Jl)aKqA_3XLM#?H=V z-{tlG=xy^os=1Z><53;&+C4=zp|%rwv!)UyY=%k_t%Y|wNRQl`C6N_Z&S;S+~wb1=)2Uh_4I81`y>DO zoLPSt=btB&x{CUK`0DA&){efW_O36RxnsYZNT0r;JbTLR{H4M3C$8(Cee==aQ~Gbq p$`5v3?NB{4-j0XQHf literal 0 HcmV?d00001 diff --git a/pkg/chartutil/testdata/dependent-chart-alias/docs/README.md b/pkg/chartutil/testdata/dependent-chart-alias/docs/README.md new file mode 100644 index 00000000..d40747ca --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-alias/docs/README.md @@ -0,0 +1 @@ +This is a placeholder for documentation. diff --git a/pkg/chartutil/testdata/dependent-chart-alias/icon.svg b/pkg/chartutil/testdata/dependent-chart-alias/icon.svg new file mode 100644 index 00000000..89213060 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-alias/icon.svg @@ -0,0 +1,8 @@ + + + Example icon + + + diff --git a/pkg/chartutil/testdata/dependent-chart-alias/ignore/me.txt b/pkg/chartutil/testdata/dependent-chart-alias/ignore/me.txt new file mode 100644 index 00000000..e69de29b diff --git a/pkg/chartutil/testdata/dependent-chart-alias/templates/template.tpl b/pkg/chartutil/testdata/dependent-chart-alias/templates/template.tpl new file mode 100644 index 00000000..c651ee6a --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-alias/templates/template.tpl @@ -0,0 +1 @@ +Hello {{.Name | default "world"}} diff --git a/pkg/chartutil/testdata/dependent-chart-alias/values.yaml b/pkg/chartutil/testdata/dependent-chart-alias/values.yaml new file mode 100644 index 00000000..61f50125 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-alias/values.yaml @@ -0,0 +1,6 @@ +# A values file contains configuration. + +name: "Some Name" + +section: + name: "Name in a section" diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/.helmignore b/pkg/chartutil/testdata/dependent-chart-helmignore/.helmignore new file mode 100644 index 00000000..8a71bc82 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-helmignore/.helmignore @@ -0,0 +1,2 @@ +ignore/ +.* diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/Chart.yaml b/pkg/chartutil/testdata/dependent-chart-helmignore/Chart.yaml new file mode 100644 index 00000000..7c071c27 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-helmignore/Chart.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/charts/.ignore_me b/pkg/chartutil/testdata/dependent-chart-helmignore/charts/.ignore_me new file mode 100644 index 00000000..e69de29b diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/charts/_ignore_me b/pkg/chartutil/testdata/dependent-chart-helmignore/charts/_ignore_me new file mode 100644 index 00000000..2cecca68 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-helmignore/charts/_ignore_me @@ -0,0 +1 @@ +This should be ignored by the loader, but may be included in a chart. diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/Chart.yaml b/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/Chart.yaml new file mode 100644 index 00000000..79e0d65d --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: alpine +description: Deploy a basic Alpine Linux pod +version: 0.1.0 +home: https://helm.sh/helm diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/README.md b/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/README.md new file mode 100644 index 00000000..b30b949d --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.toml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/Chart.yaml b/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/Chart.yaml new file mode 100644 index 00000000..1c9dd5fa --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: mast1 +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/values.yaml b/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/values.yaml new file mode 100644 index 00000000..42c39c26 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/values.yaml @@ -0,0 +1,4 @@ +# Default values for mast1. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name = "value" diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/charts/mast2-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..61cb62051110b55f3d08213dc81dcf0b1c2d8e53 GIT binary patch literal 252 zcmVDc zVQyr3R8em|NM&qo0PNJUs=_c72H?(liabH@pWIst-7YSIyL+rhEHrIN(t?QZE=F{y zgNRfS&$pa5Ly`mMk2OB%pV`*9knW7FlL-Joo@KED7*{C$d;N~z z37$S{+}wvSU9}|VtF|fRpv9Ve>8dWo|9?5B+RE}Y9CFh-x#(Bq8Vck^V=NUiPLCKa z8z5CF#JgK!4>;$4Fm+FUst4d+{(+nP|0&J+e}(;l^U4@w-{=?s0RR8vgVbLD3;+OM Cs&R<` literal 0 HcmV?d00001 diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/templates/alpine-pod.yaml b/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..5bbae10a --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service}} + chartName: {{.Chart.Name}} + chartVersion: {{.Chart.Version | quote}} +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.3" + command: ["/bin/sleep","9000"] diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/values.yaml b/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/values.yaml new file mode 100644 index 00000000..6c2aab7b --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: "my-alpine" diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/templates/template.tpl b/pkg/chartutil/testdata/dependent-chart-helmignore/templates/template.tpl new file mode 100644 index 00000000..c651ee6a --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-helmignore/templates/template.tpl @@ -0,0 +1 @@ +Hello {{.Name | default "world"}} diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/values.yaml b/pkg/chartutil/testdata/dependent-chart-helmignore/values.yaml new file mode 100644 index 00000000..61f50125 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-helmignore/values.yaml @@ -0,0 +1,6 @@ +# A values file contains configuration. + +name: "Some Name" + +section: + name: "Name in a section" diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/.helmignore b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/.helmignore new file mode 100644 index 00000000..9973a57b --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/.helmignore @@ -0,0 +1 @@ +ignore/ diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/Chart.yaml b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/Chart.yaml new file mode 100644 index 00000000..7c071c27 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/Chart.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/INSTALL.txt b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/INSTALL.txt new file mode 100644 index 00000000..2010438c --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/INSTALL.txt @@ -0,0 +1 @@ +This is an install document. The client may display this. diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/LICENSE b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/LICENSE new file mode 100644 index 00000000..6121943b --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/LICENSE @@ -0,0 +1 @@ +LICENSE placeholder. diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/README.md b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/README.md new file mode 100644 index 00000000..8cf4cc3d --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/README.md @@ -0,0 +1,11 @@ +# Frobnitz + +This is an example chart. + +## Usage + +This is an example. It has no usage. + +## Development + +For developer info, see the top-level repository. diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/_ignore_me b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/_ignore_me new file mode 100644 index 00000000..2cecca68 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/_ignore_me @@ -0,0 +1 @@ +This should be ignored by the loader, but may be included in a chart. diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/Chart.yaml b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/Chart.yaml new file mode 100644 index 00000000..79e0d65d --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: alpine +description: Deploy a basic Alpine Linux pod +version: 0.1.0 +home: https://helm.sh/helm diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/README.md b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/README.md new file mode 100644 index 00000000..b30b949d --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.toml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml new file mode 100644 index 00000000..1c9dd5fa --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: mast1 +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/values.yaml b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/values.yaml new file mode 100644 index 00000000..42c39c26 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/values.yaml @@ -0,0 +1,4 @@ +# Default values for mast1. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name = "value" diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..61cb62051110b55f3d08213dc81dcf0b1c2d8e53 GIT binary patch literal 252 zcmVDc zVQyr3R8em|NM&qo0PNJUs=_c72H?(liabH@pWIst-7YSIyL+rhEHrIN(t?QZE=F{y zgNRfS&$pa5Ly`mMk2OB%pV`*9knW7FlL-Joo@KED7*{C$d;N~z z37$S{+}wvSU9}|VtF|fRpv9Ve>8dWo|9?5B+RE}Y9CFh-x#(Bq8Vck^V=NUiPLCKa z8z5CF#JgK!4>;$4Fm+FUst4d+{(+nP|0&J+e}(;l^U4@w-{=?s0RR8vgVbLD3;+OM Cs&R<` literal 0 HcmV?d00001 diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/templates/alpine-pod.yaml b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..5bbae10a --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service}} + chartName: {{.Chart.Name}} + chartVersion: {{.Chart.Version | quote}} +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.3" + command: ["/bin/sleep","9000"] diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/values.yaml b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/values.yaml new file mode 100644 index 00000000..6c2aab7b --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: "my-alpine" diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/mariner-4.3.2.tgz b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/mariner-4.3.2.tgz new file mode 100644 index 0000000000000000000000000000000000000000..3190136b050e62c628b3c817fd963ac9dc4a9e25 GIT binary patch literal 967 zcmV;&133I2iwFR+9h6)E1MQb_y%a`Bd>#= zhbqf2w!b6}wZ9@rvIhtwzm?~C&+QLm+G2!l%`$_aUgS(@pdjdX3a%E}VXVc7`)dg( zL%IRN2}c1D3xoMi2w@WuWOMZ?5i&3FelBVyq_Ce_8fREdP%NDf<&d!wu3{&VUQNs{N%vK$Ha~k^gB2!0bO7r0ic0 zbqCp*X#j?=|H@GNONsuE)&Idbh>2MX(Z}|+=@*sLod*wS?A8Ugp#mMPuMO0NnZmos9_rr z3xpDL+otj~lRh?B4hHFTl+d5-8NBXmUXB~EyF^bx7m*+!*g>obczvGF|8xkWsHN8; z%#+wiWP{=2pC*98@$VNzzsll&G#D7&11-;D>HT0x|DV2?6}a~*p46@R|2l??e_8dX z@Bb>D3t~VC_*wjq2Dy!6J-_5ME%%J+xmsbK5a#qO>ZV?Wt`d|TDdrB^B&LqFr@lYdUA zs&4-JAg3&uc4dA0gv*bXU9QePa9^8wR{HovA)j@)JOAIkak0Jl)aKqA_3XLM#?H=V z-{tlG=xy^os=1Z><53;&+C4=zp|%rwv!)UyY=%k_t%Y|wNRQl`C6N_Z&S;S+~wb1=)2Uh_4I81`y>DO zoLPSt=btB&x{CUK`0DA&){efW_O36RxnsYZNT0r;JbTLR{H4M3C$8(Cee==aQ~Gbq p$`5v3?NB{4-j0XQHf literal 0 HcmV?d00001 diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/docs/README.md b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/docs/README.md new file mode 100644 index 00000000..d40747ca --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/docs/README.md @@ -0,0 +1 @@ +This is a placeholder for documentation. diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/icon.svg b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/icon.svg new file mode 100644 index 00000000..89213060 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/icon.svg @@ -0,0 +1,8 @@ + + + Example icon + + + diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/ignore/me.txt b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/ignore/me.txt new file mode 100644 index 00000000..e69de29b diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/templates/template.tpl b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/templates/template.tpl new file mode 100644 index 00000000..c651ee6a --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/templates/template.tpl @@ -0,0 +1 @@ +Hello {{.Name | default "world"}} diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/values.yaml b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/values.yaml new file mode 100644 index 00000000..61f50125 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/values.yaml @@ -0,0 +1,6 @@ +# A values file contains configuration. + +name: "Some Name" + +section: + name: "Name in a section" diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/.helmignore b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/.helmignore new file mode 100644 index 00000000..9973a57b --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/.helmignore @@ -0,0 +1 @@ +ignore/ diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/Chart.yaml b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/Chart.yaml new file mode 100644 index 00000000..fe7a9968 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v1 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/INSTALL.txt b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/INSTALL.txt new file mode 100644 index 00000000..2010438c --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/INSTALL.txt @@ -0,0 +1 @@ +This is an install document. The client may display this. diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/LICENSE b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/LICENSE new file mode 100644 index 00000000..6121943b --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/LICENSE @@ -0,0 +1 @@ +LICENSE placeholder. diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/README.md b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/README.md new file mode 100644 index 00000000..8cf4cc3d --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/README.md @@ -0,0 +1,11 @@ +# Frobnitz + +This is an example chart. + +## Usage + +This is an example. It has no usage. + +## Development + +For developer info, see the top-level repository. diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/_ignore_me b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/_ignore_me new file mode 100644 index 00000000..2cecca68 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/_ignore_me @@ -0,0 +1 @@ +This should be ignored by the loader, but may be included in a chart. diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/Chart.yaml b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/Chart.yaml new file mode 100644 index 00000000..79e0d65d --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: alpine +description: Deploy a basic Alpine Linux pod +version: 0.1.0 +home: https://helm.sh/helm diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/README.md b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/README.md new file mode 100644 index 00000000..b30b949d --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.toml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml new file mode 100644 index 00000000..1c9dd5fa --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: mast1 +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/values.yaml b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/values.yaml new file mode 100644 index 00000000..42c39c26 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/values.yaml @@ -0,0 +1,4 @@ +# Default values for mast1. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name = "value" diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..61cb62051110b55f3d08213dc81dcf0b1c2d8e53 GIT binary patch literal 252 zcmVDc zVQyr3R8em|NM&qo0PNJUs=_c72H?(liabH@pWIst-7YSIyL+rhEHrIN(t?QZE=F{y zgNRfS&$pa5Ly`mMk2OB%pV`*9knW7FlL-Joo@KED7*{C$d;N~z z37$S{+}wvSU9}|VtF|fRpv9Ve>8dWo|9?5B+RE}Y9CFh-x#(Bq8Vck^V=NUiPLCKa z8z5CF#JgK!4>;$4Fm+FUst4d+{(+nP|0&J+e}(;l^U4@w-{=?s0RR8vgVbLD3;+OM Cs&R<` literal 0 HcmV?d00001 diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/templates/alpine-pod.yaml b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..5bbae10a --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service}} + chartName: {{.Chart.Name}} + chartVersion: {{.Chart.Version | quote}} +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.3" + command: ["/bin/sleep","9000"] diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/values.yaml b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/values.yaml new file mode 100644 index 00000000..6c2aab7b --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: "my-alpine" diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/mariner-4.3.2.tgz b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/mariner-4.3.2.tgz new file mode 100644 index 0000000000000000000000000000000000000000..3190136b050e62c628b3c817fd963ac9dc4a9e25 GIT binary patch literal 967 zcmV;&133I2iwFR+9h6)E1MQb_y%a`Bd>#= zhbqf2w!b6}wZ9@rvIhtwzm?~C&+QLm+G2!l%`$_aUgS(@pdjdX3a%E}VXVc7`)dg( zL%IRN2}c1D3xoMi2w@WuWOMZ?5i&3FelBVyq_Ce_8fREdP%NDf<&d!wu3{&VUQNs{N%vK$Ha~k^gB2!0bO7r0ic0 zbqCp*X#j?=|H@GNONsuE)&Idbh>2MX(Z}|+=@*sLod*wS?A8Ugp#mMPuMO0NnZmos9_rr z3xpDL+otj~lRh?B4hHFTl+d5-8NBXmUXB~EyF^bx7m*+!*g>obczvGF|8xkWsHN8; z%#+wiWP{=2pC*98@$VNzzsll&G#D7&11-;D>HT0x|DV2?6}a~*p46@R|2l??e_8dX z@Bb>D3t~VC_*wjq2Dy!6J-_5ME%%J+xmsbK5a#qO>ZV?Wt`d|TDdrB^B&LqFr@lYdUA zs&4-JAg3&uc4dA0gv*bXU9QePa9^8wR{HovA)j@)JOAIkak0Jl)aKqA_3XLM#?H=V z-{tlG=xy^os=1Z><53;&+C4=zp|%rwv!)UyY=%k_t%Y|wNRQl`C6N_Z&S;S+~wb1=)2Uh_4I81`y>DO zoLPSt=btB&x{CUK`0DA&){efW_O36RxnsYZNT0r;JbTLR{H4M3C$8(Cee==aQ~Gbq p$`5v3?NB{4-j0XQHf literal 0 HcmV?d00001 diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/docs/README.md b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/docs/README.md new file mode 100644 index 00000000..d40747ca --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/docs/README.md @@ -0,0 +1 @@ +This is a placeholder for documentation. diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/icon.svg b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/icon.svg new file mode 100644 index 00000000..89213060 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/icon.svg @@ -0,0 +1,8 @@ + + + Example icon + + + diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/ignore/me.txt b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/ignore/me.txt new file mode 100644 index 00000000..e69de29b diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/templates/template.tpl b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/templates/template.tpl new file mode 100644 index 00000000..c651ee6a --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/templates/template.tpl @@ -0,0 +1 @@ +Hello {{.Name | default "world"}} diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/values.yaml b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/values.yaml new file mode 100644 index 00000000..61f50125 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/values.yaml @@ -0,0 +1,6 @@ +# A values file contains configuration. + +name: "Some Name" + +section: + name: "Name in a section" diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/.helmignore b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/.helmignore new file mode 100644 index 00000000..9973a57b --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/.helmignore @@ -0,0 +1 @@ +ignore/ diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/Chart.yaml b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/Chart.yaml new file mode 100644 index 00000000..7fc39e28 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/Chart.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/INSTALL.txt b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/INSTALL.txt new file mode 100644 index 00000000..2010438c --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/INSTALL.txt @@ -0,0 +1 @@ +This is an install document. The client may display this. diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/LICENSE b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/LICENSE new file mode 100644 index 00000000..6121943b --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/LICENSE @@ -0,0 +1 @@ +LICENSE placeholder. diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/README.md b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/README.md new file mode 100644 index 00000000..8cf4cc3d --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/README.md @@ -0,0 +1,11 @@ +# Frobnitz + +This is an example chart. + +## Usage + +This is an example. It has no usage. + +## Development + +For developer info, see the top-level repository. diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/_ignore_me b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/_ignore_me new file mode 100644 index 00000000..2cecca68 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/_ignore_me @@ -0,0 +1 @@ +This should be ignored by the loader, but may be included in a chart. diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/Chart.yaml b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/Chart.yaml new file mode 100644 index 00000000..79e0d65d --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: alpine +description: Deploy a basic Alpine Linux pod +version: 0.1.0 +home: https://helm.sh/helm diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/README.md b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/README.md new file mode 100644 index 00000000..b30b949d --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.toml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml new file mode 100644 index 00000000..1c9dd5fa --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: mast1 +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/values.yaml b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/values.yaml new file mode 100644 index 00000000..42c39c26 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/values.yaml @@ -0,0 +1,4 @@ +# Default values for mast1. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name = "value" diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..61cb62051110b55f3d08213dc81dcf0b1c2d8e53 GIT binary patch literal 252 zcmVDc zVQyr3R8em|NM&qo0PNJUs=_c72H?(liabH@pWIst-7YSIyL+rhEHrIN(t?QZE=F{y zgNRfS&$pa5Ly`mMk2OB%pV`*9knW7FlL-Joo@KED7*{C$d;N~z z37$S{+}wvSU9}|VtF|fRpv9Ve>8dWo|9?5B+RE}Y9CFh-x#(Bq8Vck^V=NUiPLCKa z8z5CF#JgK!4>;$4Fm+FUst4d+{(+nP|0&J+e}(;l^U4@w-{=?s0RR8vgVbLD3;+OM Cs&R<` literal 0 HcmV?d00001 diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/templates/alpine-pod.yaml b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..5bbae10a --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service}} + chartName: {{.Chart.Name}} + chartVersion: {{.Chart.Version | quote}} +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.3" + command: ["/bin/sleep","9000"] diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/values.yaml b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/values.yaml new file mode 100644 index 00000000..6c2aab7b --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: "my-alpine" diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/mariner-4.3.2.tgz b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/mariner-4.3.2.tgz new file mode 100644 index 0000000000000000000000000000000000000000..3190136b050e62c628b3c817fd963ac9dc4a9e25 GIT binary patch literal 967 zcmV;&133I2iwFR+9h6)E1MQb_y%a`Bd>#= zhbqf2w!b6}wZ9@rvIhtwzm?~C&+QLm+G2!l%`$_aUgS(@pdjdX3a%E}VXVc7`)dg( zL%IRN2}c1D3xoMi2w@WuWOMZ?5i&3FelBVyq_Ce_8fREdP%NDf<&d!wu3{&VUQNs{N%vK$Ha~k^gB2!0bO7r0ic0 zbqCp*X#j?=|H@GNONsuE)&Idbh>2MX(Z}|+=@*sLod*wS?A8Ugp#mMPuMO0NnZmos9_rr z3xpDL+otj~lRh?B4hHFTl+d5-8NBXmUXB~EyF^bx7m*+!*g>obczvGF|8xkWsHN8; z%#+wiWP{=2pC*98@$VNzzsll&G#D7&11-;D>HT0x|DV2?6}a~*p46@R|2l??e_8dX z@Bb>D3t~VC_*wjq2Dy!6J-_5ME%%J+xmsbK5a#qO>ZV?Wt`d|TDdrB^B&LqFr@lYdUA zs&4-JAg3&uc4dA0gv*bXU9QePa9^8wR{HovA)j@)JOAIkak0Jl)aKqA_3XLM#?H=V z-{tlG=xy^os=1Z><53;&+C4=zp|%rwv!)UyY=%k_t%Y|wNRQl`C6N_Z&S;S+~wb1=)2Uh_4I81`y>DO zoLPSt=btB&x{CUK`0DA&){efW_O36RxnsYZNT0r;JbTLR{H4M3C$8(Cee==aQ~Gbq p$`5v3?NB{4-j0XQHf literal 0 HcmV?d00001 diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/docs/README.md b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/docs/README.md new file mode 100644 index 00000000..d40747ca --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/docs/README.md @@ -0,0 +1 @@ +This is a placeholder for documentation. diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/icon.svg b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/icon.svg new file mode 100644 index 00000000..89213060 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/icon.svg @@ -0,0 +1,8 @@ + + + Example icon + + + diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/ignore/me.txt b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/ignore/me.txt new file mode 100644 index 00000000..e69de29b diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/templates/template.tpl b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/templates/template.tpl new file mode 100644 index 00000000..c651ee6a --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/templates/template.tpl @@ -0,0 +1 @@ +Hello {{.Name | default "world"}} diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/values.yaml b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/values.yaml new file mode 100644 index 00000000..61f50125 --- /dev/null +++ b/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/values.yaml @@ -0,0 +1,6 @@ +# A values file contains configuration. + +name: "Some Name" + +section: + name: "Name in a section" diff --git a/pkg/chartutil/testdata/frobnitz-1.2.3.tgz b/pkg/chartutil/testdata/frobnitz-1.2.3.tgz new file mode 100644 index 0000000000000000000000000000000000000000..8731dce02cc9603e7813a07670a7d057129a8b20 GIT binary patch literal 3485 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 LkUR>*0C)fZJ*e(i literal 0 HcmV?d00001 diff --git a/pkg/chartutil/testdata/frobnitz/.helmignore b/pkg/chartutil/testdata/frobnitz/.helmignore new file mode 100644 index 00000000..9973a57b --- /dev/null +++ b/pkg/chartutil/testdata/frobnitz/.helmignore @@ -0,0 +1 @@ +ignore/ diff --git a/pkg/chartutil/testdata/frobnitz/Chart.lock b/pkg/chartutil/testdata/frobnitz/Chart.lock new file mode 100644 index 00000000..6fcc2ed9 --- /dev/null +++ b/pkg/chartutil/testdata/frobnitz/Chart.lock @@ -0,0 +1,8 @@ +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts +digest: invalid diff --git a/pkg/chartutil/testdata/frobnitz/Chart.yaml b/pkg/chartutil/testdata/frobnitz/Chart.yaml new file mode 100644 index 00000000..fcd4a4a3 --- /dev/null +++ b/pkg/chartutil/testdata/frobnitz/Chart.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +annotations: + extrakey: extravalue + anotherkey: anothervalue +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts diff --git a/pkg/chartutil/testdata/frobnitz/INSTALL.txt b/pkg/chartutil/testdata/frobnitz/INSTALL.txt new file mode 100644 index 00000000..2010438c --- /dev/null +++ b/pkg/chartutil/testdata/frobnitz/INSTALL.txt @@ -0,0 +1 @@ +This is an install document. The client may display this. diff --git a/pkg/chartutil/testdata/frobnitz/LICENSE b/pkg/chartutil/testdata/frobnitz/LICENSE new file mode 100644 index 00000000..6121943b --- /dev/null +++ b/pkg/chartutil/testdata/frobnitz/LICENSE @@ -0,0 +1 @@ +LICENSE placeholder. diff --git a/pkg/chartutil/testdata/frobnitz/README.md b/pkg/chartutil/testdata/frobnitz/README.md new file mode 100644 index 00000000..8cf4cc3d --- /dev/null +++ b/pkg/chartutil/testdata/frobnitz/README.md @@ -0,0 +1,11 @@ +# Frobnitz + +This is an example chart. + +## Usage + +This is an example. It has no usage. + +## Development + +For developer info, see the top-level repository. diff --git a/pkg/chartutil/testdata/frobnitz/charts/_ignore_me b/pkg/chartutil/testdata/frobnitz/charts/_ignore_me new file mode 100644 index 00000000..2cecca68 --- /dev/null +++ b/pkg/chartutil/testdata/frobnitz/charts/_ignore_me @@ -0,0 +1 @@ +This should be ignored by the loader, but may be included in a chart. diff --git a/pkg/chartutil/testdata/frobnitz/charts/alpine/Chart.yaml b/pkg/chartutil/testdata/frobnitz/charts/alpine/Chart.yaml new file mode 100644 index 00000000..79e0d65d --- /dev/null +++ b/pkg/chartutil/testdata/frobnitz/charts/alpine/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: alpine +description: Deploy a basic Alpine Linux pod +version: 0.1.0 +home: https://helm.sh/helm diff --git a/pkg/chartutil/testdata/frobnitz/charts/alpine/README.md b/pkg/chartutil/testdata/frobnitz/charts/alpine/README.md new file mode 100644 index 00000000..b30b949d --- /dev/null +++ b/pkg/chartutil/testdata/frobnitz/charts/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.toml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/chartutil/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml b/pkg/chartutil/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml new file mode 100644 index 00000000..1c9dd5fa --- /dev/null +++ b/pkg/chartutil/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: mast1 +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/chartutil/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml b/pkg/chartutil/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml new file mode 100644 index 00000000..42c39c26 --- /dev/null +++ b/pkg/chartutil/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml @@ -0,0 +1,4 @@ +# Default values for mast1. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name = "value" diff --git a/pkg/chartutil/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chartutil/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..61cb62051110b55f3d08213dc81dcf0b1c2d8e53 GIT binary patch literal 252 zcmVDc zVQyr3R8em|NM&qo0PNJUs=_c72H?(liabH@pWIst-7YSIyL+rhEHrIN(t?QZE=F{y zgNRfS&$pa5Ly`mMk2OB%pV`*9knW7FlL-Joo@KED7*{C$d;N~z z37$S{+}wvSU9}|VtF|fRpv9Ve>8dWo|9?5B+RE}Y9CFh-x#(Bq8Vck^V=NUiPLCKa z8z5CF#JgK!4>;$4Fm+FUst4d+{(+nP|0&J+e}(;l^U4@w-{=?s0RR8vgVbLD3;+OM Cs&R<` literal 0 HcmV?d00001 diff --git a/pkg/chartutil/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml b/pkg/chartutil/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..5bbae10a --- /dev/null +++ b/pkg/chartutil/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service}} + chartName: {{.Chart.Name}} + chartVersion: {{.Chart.Version | quote}} +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.3" + command: ["/bin/sleep","9000"] diff --git a/pkg/chartutil/testdata/frobnitz/charts/alpine/values.yaml b/pkg/chartutil/testdata/frobnitz/charts/alpine/values.yaml new file mode 100644 index 00000000..6c2aab7b --- /dev/null +++ b/pkg/chartutil/testdata/frobnitz/charts/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: "my-alpine" diff --git a/pkg/chartutil/testdata/frobnitz/charts/mariner/Chart.yaml b/pkg/chartutil/testdata/frobnitz/charts/mariner/Chart.yaml new file mode 100644 index 00000000..92dc4b39 --- /dev/null +++ b/pkg/chartutil/testdata/frobnitz/charts/mariner/Chart.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +name: mariner +description: A Helm chart for Kubernetes +version: 4.3.2 +home: "" +dependencies: + - name: albatross + repository: https://example.com/mariner/charts + version: "0.1.0" diff --git a/pkg/chartutil/testdata/frobnitz/charts/mariner/charts/albatross/Chart.yaml b/pkg/chartutil/testdata/frobnitz/charts/mariner/charts/albatross/Chart.yaml new file mode 100644 index 00000000..b5188fde --- /dev/null +++ b/pkg/chartutil/testdata/frobnitz/charts/mariner/charts/albatross/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: albatross +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/chartutil/testdata/frobnitz/charts/mariner/charts/albatross/values.yaml b/pkg/chartutil/testdata/frobnitz/charts/mariner/charts/albatross/values.yaml new file mode 100644 index 00000000..3121cd7c --- /dev/null +++ b/pkg/chartutil/testdata/frobnitz/charts/mariner/charts/albatross/values.yaml @@ -0,0 +1,4 @@ +albatross: "true" + +global: + author: Coleridge diff --git a/pkg/chartutil/testdata/frobnitz/charts/mariner/templates/placeholder.tpl b/pkg/chartutil/testdata/frobnitz/charts/mariner/templates/placeholder.tpl new file mode 100644 index 00000000..29c11843 --- /dev/null +++ b/pkg/chartutil/testdata/frobnitz/charts/mariner/templates/placeholder.tpl @@ -0,0 +1 @@ +# This is a placeholder. diff --git a/pkg/chartutil/testdata/frobnitz/charts/mariner/values.yaml b/pkg/chartutil/testdata/frobnitz/charts/mariner/values.yaml new file mode 100644 index 00000000..b0ccb008 --- /dev/null +++ b/pkg/chartutil/testdata/frobnitz/charts/mariner/values.yaml @@ -0,0 +1,7 @@ +# Default values for . +# This is a YAML-formatted file. https://github.com/toml-lang/toml +# Declare name/value pairs to be passed into your templates. +# name: "value" + +: + test: true diff --git a/pkg/chartutil/testdata/frobnitz/docs/README.md b/pkg/chartutil/testdata/frobnitz/docs/README.md new file mode 100644 index 00000000..d40747ca --- /dev/null +++ b/pkg/chartutil/testdata/frobnitz/docs/README.md @@ -0,0 +1 @@ +This is a placeholder for documentation. diff --git a/pkg/chartutil/testdata/frobnitz/icon.svg b/pkg/chartutil/testdata/frobnitz/icon.svg new file mode 100644 index 00000000..89213060 --- /dev/null +++ b/pkg/chartutil/testdata/frobnitz/icon.svg @@ -0,0 +1,8 @@ + + + Example icon + + + diff --git a/pkg/chartutil/testdata/frobnitz/ignore/me.txt b/pkg/chartutil/testdata/frobnitz/ignore/me.txt new file mode 100644 index 00000000..e69de29b diff --git a/pkg/chartutil/testdata/frobnitz/templates/template.tpl b/pkg/chartutil/testdata/frobnitz/templates/template.tpl new file mode 100644 index 00000000..c651ee6a --- /dev/null +++ b/pkg/chartutil/testdata/frobnitz/templates/template.tpl @@ -0,0 +1 @@ +Hello {{.Name | default "world"}} diff --git a/pkg/chartutil/testdata/frobnitz/values.yaml b/pkg/chartutil/testdata/frobnitz/values.yaml new file mode 100644 index 00000000..61f50125 --- /dev/null +++ b/pkg/chartutil/testdata/frobnitz/values.yaml @@ -0,0 +1,6 @@ +# A values file contains configuration. + +name: "Some Name" + +section: + name: "Name in a section" diff --git a/pkg/chartutil/testdata/frobnitz_backslash-1.2.3.tgz b/pkg/chartutil/testdata/frobnitz_backslash-1.2.3.tgz new file mode 100644 index 0000000000000000000000000000000000000000..6929659514d35d2a2fe1098c1c097194378cc3b5 GIT binary patch literal 3496 zcmV;Z4Oj9XiwFRyACz1G1MOT3ToiR4hwMx@-;eUlKKvc@#1m$BXJ3MVAdnJ>5AaC} z!|bp-F}t(P%mOQNsF;#^q^4=viQaXWhmSHfO;b_pG9Ou5mZz3yDwMB$rz`CJXLdnY zR~K|!wdVT~XLkO-`TzejzxnDQ%Ve1;enLhTPULVj&KczazJ@}f&}lS4+-kMr zR)MLVTNi**rB9msk`vbUz8cDJo#h4>Nu2N>k zO=JYP{h^TQNseGC3ojC;87U6He|Q}wD$A|y1yQ0HszPKrq%DtO^<0DufUr73@dsyN z86t<|#UFY`kLzV7oHFz95BB0b!8wyOBMAnPvf{bd6}_c?%aIm)9eZXETgE3uk3UM(A0f zXh?q*GsEM;trJHg^LZABcPGNRmy6R@VV*c7GLo#=;M#XK z=}8M|){|y}A{*QroVLLGC0^mJo3B8Xr~(lWvZRIKD4t<$o+n5Ma?aK3(1f!B1KfI4 zqbd|@QjZV%nU>Iac+me&F8a_7XsrFOR%x}EQX$xXT1+F^|Nejn{tGG%<#G)^8^as_ zHSnO@`ma!_r1jq)i0q$lqHDE%6e27h{i9`(ko^EQzy%BLMIo+*FE1|-%~PQaYmj5` zBIU3RagyDYL|i+Zz2(!CImZ*RGCVw7F18}|*B|GMKpv&%jZp}u(IRl=rwm5k^@Gw! zAw0?PNHjbvQZQ1Y6PybY5FX=5xp+uuV@ZM+$+IaI(t~{10|IQ~|E})Uid(&F;eIwn z(@}^S&$S`)hJA1(>|8H)qST1JsJ-;Ul6s+&Na2P=KSV2}2l-D-NllAQPDc5B-Zx`- z)&Dx|Hv1n_O7iay)S2>TK$+n+(lj*439E@T^QdTj6Ep?)p>1dbdWy5qaLdDUq#(g> z10Lib^52)%U*qjROp9sN8ceYNME_s1|9*XV;6Ez()|A1_uy?r~O@UYbD;4hgU#(M1 z`rjXL@>wov^WP~v$p7$!*!aN-s7dduF}%q?rc%1+f8a_W$-h4k44$iv%*yK7g083@ z5Mx28OcoprMsT=+^hAV$L>?G%4wxBW6^NYl@nkMZGZw*;l*yiBSfFW;3CH zBS~mV!av3mMhk8A;){}b=_lMV|3foGj*rIhCjTmhyZq}gtwNH2f8dczF6b!($?*|T zhveckrI+-?{lSC$^CZ0II8XY_32eImr-Q#F|IZ)rz<)98$oa4Uz#IRS>f7|cMukcG z-yaADvB1f2Ay5qsK1NK#bHYwGWw5eh-WpnTPA!fI$#swd_Ys+lBMGq{0IF|StIz;7 zGr)nX4UzQo!;QP@|Jps2x7?2^@T&jSZt;IisZ&Vtf4^W5Nz)7{C_qKgod}(@Y}`uo z0Lf!mT8|VKN&@_w#+~B-z6=6+m4D6c>%T^d|M&&BO8M$l0E28HT;oaux(t8^Jy8k2 z3hrG0<%|IM!Ut@u|EJdJFqIPef0#lktpAe#_v_=%{LgTvhB8d|odxvj|0&$+zc7VX zEBSwZfvEpEBV(oYAPf3#BG2KjO$^P`3=WrrP>^MHR)7c;W`efrA&Kbbx$*-K`YI&c zEAA}+u7GWr7@|da!-RVt!;AbwXRprx(`hvd!T(chbdvo013@!e1S|`h_d-CkvkS%< zI}Pks(e|;}r`{e=9vwenN5b1<-#@gux-T~(ZbH`quZ>PGjnCM5!u+x2BAUS@A}TQP*W9phfqD!0(ooe;4xj$uoTe+w~mtde0H9XFS#}KCxAK z^pZ~Ou<+q+CND()kTtd2SGB$k5Wax*VjB zSEoPu(;V}pg{MORj(;)x#kgm__gYvgGA{J3wN|*{RFRzxb2!+lb@D>%-<>8aBjwb^b@&CsoY+ zsNJ#gSEIL-Cm%h$ect4!_c`7VZ9b+(-aOpiF@8tu{iD%IPu@(oCoP>F@?`lUN5U(I zwuYQI^L^lzPOR)eKyrp-ey`J8drTW$EpOgAv*+^_^HO3CHv4GTmeAoD_F=r$bfCO* zEC+-crJSB6SDV6B?H#}y*&~81{W0zj)xoKSRXK9N@t$u~9njfJ)yt&`!BRU-T zu}#6r{-<*)rghB?SRJ#d;C$b-#o7BmP3iM;Z1l4q-dGbAS5XjF@zQG}E3bCqJG5*z zCACBoDF6Jx^|Cg~d228C-5$BxfIO4@v_l=3{8tBv3Y3M`%-nXOYB6nlD(T%PhFo@B zFCO#Hyj8~&?WY{?5*vS6@b$!-)6+gV>xi4N>!+e&KNp4X&Z}_j?X_j@m)n|Onx%M$ z-?6T99%ECk-r>S*YR4R#ZZ~~!+%bG+V93w<9}K1V8`9OG;L1d;BWR_;3^$M z4^AL<=2vE2x12w_yqU&;y>T$4Y>TC8{pwW*><-)V>jzgR?;gE&)A7S6*&%DseX;Xu z|DxV@`|xXDUI;F$uBqPJhgobqbnV9pHIqJhHmR)Yv!83aJ9d^%FL-n7GP`mEyXN}Q zDHnHdHUvezm0FD-Iv!m>+26Nm(sNQun}51#+Bff(M*dvAaeaD{eL8byWy-5t3v`^l z7n!-QqTt5s>;89e%;cq)=dL+?>Po-q(|7HYhrO{esqIfI$|@J%*mdpdZe@q!ZJCNL zt(wwI*|L+%g<-ZRC!QS|x!Zlf{kqUjPh8mk1t{pOR zq;~Kq`bGG#+tP+HQNkCw0?vH3x_rB$Q}bh=+h#<@o-aF+E<1{%Q#KW>7#ONHCGMY6 zavpt-Usha_)_&jmx3qC{n^nH5iLNR;ba2D$bF)^y)-oVwF}eD&E~7%K3#v;`KfgS5 zNRNMQ*faWk@k=ciN9PrlU60D;AM`%4?jI<=fArys=KiVB0j7d zb)u{6L;?}o8B2M!3;GNg(fi{~=~-?2Ru(C&L;h0!#If-;lNa4|^xk{D=;i9kmv(g_ zzOU*6FVyiv@mCoEtsd}=e@M_2`B&bf3)uAd9|qk($^Y{Qn#TWoO9px4Kjgie{#UEi z()XYI0+0D0k=YH?Ke5iwFp5uu7+_1XW|6F!gpPsd)DI%Lj*kMGCja*q4Du%bn9|+< zQ)_fm{f}QDyr+L#@LXgzDhsanq1Q37gOkHxn+fNI+<*{+7YO1>f`)fa^qHJb2e9B2 z%K@GdiUln=$BCta@TZNjvULG?PB>XO2}A)zJWo;=5+q2FAVGoz2@)hokRU;V1PKx( WNRS{wf&>XZ!T$k(as0gicmMzx5%RPE literal 0 HcmV?d00001 diff --git a/pkg/chartutil/testdata/genfrob.sh b/pkg/chartutil/testdata/genfrob.sh new file mode 100755 index 00000000..35fdd59f --- /dev/null +++ b/pkg/chartutil/testdata/genfrob.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +# Pack the albatross chart into the mariner chart. +echo "Packing albatross into mariner" +tar -zcvf mariner/charts/albatross-0.1.0.tgz albatross + +echo "Packing mariner into frobnitz" +tar -zcvf frobnitz/charts/mariner-4.3.2.tgz mariner +tar -zcvf frobnitz_backslash/charts/mariner-4.3.2.tgz mariner + +# Pack the frobnitz chart. +echo "Packing frobnitz" +tar --exclude=ignore/* -zcvf frobnitz-1.2.3.tgz frobnitz +tar --exclude=ignore/* -zcvf frobnitz_backslash-1.2.3.tgz frobnitz_backslash diff --git a/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/Chart.lock b/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/Chart.lock new file mode 100644 index 00000000..b2f17fb3 --- /dev/null +++ b/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/Chart.lock @@ -0,0 +1,9 @@ +dependencies: +- name: dev + repository: file://envs/dev + version: v0.1.0 +- name: prod + repository: file://envs/prod + version: v0.1.0 +digest: sha256:9403fc24f6cf9d6055820126cf7633b4bd1fed3c77e4880c674059f536346182 +generated: "2020-02-03T10:38:51.180474+01:00" diff --git a/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/Chart.yaml b/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/Chart.yaml new file mode 100644 index 00000000..24b26d9e --- /dev/null +++ b/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/Chart.yaml @@ -0,0 +1,22 @@ +apiVersion: v2 +name: parent-chart +version: v0.1.0 +appVersion: v0.1.0 +dependencies: + - name: dev + repository: "file://envs/dev" + version: ">= 0.0.1" + condition: dev.enabled,global.dev.enabled + tags: + - dev + import-values: + - data + + - name: prod + repository: "file://envs/prod" + version: ">= 0.0.1" + condition: prod.enabled,global.prod.enabled + tags: + - prod + import-values: + - data \ No newline at end of file diff --git a/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/charts/dev-v0.1.0.tgz b/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/charts/dev-v0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..d28e1621c86a56affb0617a912930d982ee5d09c GIT binary patch literal 333 zcmV-T0kZxdiwG0|00000|0w_~VMtOiV@ORlOnEsqVl!4SWK%V1T2nbTPgYhoO;>Dc zVQyr3R8em|NM&qo0PK}PYr`-Mg>%lY5bWGeZqs!5+TB+M-CZQ2GbE0Y9n>MvEZ~_~beXUgrQc z1sW=VuODc zVQyr3R8em|NM&qo0PK~)YJ)%!hCTZf13f1l6W36$d4NhGy$?F13%a|^u9EiYi-y+X zrIcVxVZX~T{~R1){(qg==KlCX61K0@waFSFA{Kc*RYY7?#Dhw*eUYg{p-|-sX1h%7 z6TnrrSY98ByIH2oEUQmBkeoRjtJ5jyR=-iu i)>JGtn?PqS;UNZ5Boc}Ioc90#0RR69wG({+3;+PL5}8~8 literal 0 HcmV?d00001 diff --git a/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/Chart.yaml b/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/Chart.yaml new file mode 100644 index 00000000..80a52f53 --- /dev/null +++ b/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v2 +name: dev +version: v0.1.0 +appVersion: v0.1.0 \ No newline at end of file diff --git a/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/values.yaml b/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/values.yaml new file mode 100644 index 00000000..38f03484 --- /dev/null +++ b/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/values.yaml @@ -0,0 +1,9 @@ +# Dev values parent-chart +nameOverride: parent-chart-dev +exports: + data: + resources: + autoscaler: + minReplicas: 1 + maxReplicas: 3 + targetCPUUtilizationPercentage: 80 diff --git a/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/Chart.yaml b/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/Chart.yaml new file mode 100644 index 00000000..bda4be45 --- /dev/null +++ b/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v2 +name: prod +version: v0.1.0 +appVersion: v0.1.0 \ No newline at end of file diff --git a/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/values.yaml b/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/values.yaml new file mode 100644 index 00000000..10cc756b --- /dev/null +++ b/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/values.yaml @@ -0,0 +1,9 @@ +# Prod values parent-chart +nameOverride: parent-chart-prod +exports: + data: + resources: + autoscaler: + minReplicas: 2 + maxReplicas: 5 + targetCPUUtilizationPercentage: 90 diff --git a/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/templates/autoscaler.yaml b/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/templates/autoscaler.yaml new file mode 100644 index 00000000..976e5a8f --- /dev/null +++ b/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/templates/autoscaler.yaml @@ -0,0 +1,16 @@ +################################################################################################### +# parent-chart horizontal pod autoscaler +################################################################################################### +apiVersion: autoscaling/v1 +kind: HorizontalPodAutoscaler +metadata: + name: {{ .Release.Name }}-autoscaler + namespace: {{ .Release.Namespace }} +spec: + scaleTargetRef: + apiVersion: apps/v1beta1 + kind: Deployment + name: {{ .Release.Name }} + minReplicas: {{ required "A valid .Values.resources.autoscaler.minReplicas entry required!" .Values.resources.autoscaler.minReplicas }} + maxReplicas: {{ required "A valid .Values.resources.autoscaler.maxReplicas entry required!" .Values.resources.autoscaler.maxReplicas }} + targetCPUUtilizationPercentage: {{ required "A valid .Values.resources.autoscaler.targetCPUUtilizationPercentage!" .Values.resources.autoscaler.targetCPUUtilizationPercentage }} \ No newline at end of file diff --git a/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/values.yaml b/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/values.yaml new file mode 100644 index 00000000..b812f0a3 --- /dev/null +++ b/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/values.yaml @@ -0,0 +1,10 @@ +# Default values for parent-chart. +nameOverride: parent-chart +tags: + dev: false + prod: true +resources: + autoscaler: + minReplicas: 0 + maxReplicas: 0 + targetCPUUtilizationPercentage: 99 \ No newline at end of file diff --git a/pkg/chartutil/testdata/joonix/Chart.yaml b/pkg/chartutil/testdata/joonix/Chart.yaml new file mode 100644 index 00000000..c3464c56 --- /dev/null +++ b/pkg/chartutil/testdata/joonix/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: A Helm chart for Kubernetes +name: joonix +version: 1.2.3 diff --git a/pkg/chartutil/testdata/joonix/charts/.gitkeep b/pkg/chartutil/testdata/joonix/charts/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/pkg/chartutil/testdata/subpop/Chart.yaml b/pkg/chartutil/testdata/subpop/Chart.yaml new file mode 100644 index 00000000..27118672 --- /dev/null +++ b/pkg/chartutil/testdata/subpop/Chart.yaml @@ -0,0 +1,41 @@ +apiVersion: v1 +description: A Helm chart for Kubernetes +name: parentchart +version: 0.1.0 +dependencies: + - name: subchart1 + repository: http://localhost:10191 + version: 0.1.0 + condition: subchart1.enabled + tags: + - front-end + - subchart1 + import-values: + - child: SC1data + parent: imported-chart1 + - child: SC1data + parent: overridden-chart1 + - child: imported-chartA + parent: imported-chartA + - child: imported-chartA-B + parent: imported-chartA-B + - child: overridden-chartA-B + parent: overridden-chartA-B + - child: SCBexported1A + parent: . + - SCBexported2 + - SC1exported1 + + - name: subchart2 + repository: http://localhost:10191 + version: 0.1.0 + condition: subchart2.enabled + tags: + - back-end + - subchart2 + + - name: subchart2 + alias: subchart2alias + repository: http://localhost:10191 + version: 0.1.0 + condition: subchart2alias.enabled diff --git a/pkg/chartutil/testdata/subpop/README.md b/pkg/chartutil/testdata/subpop/README.md new file mode 100644 index 00000000..e43fbfe9 --- /dev/null +++ b/pkg/chartutil/testdata/subpop/README.md @@ -0,0 +1,18 @@ +## Subpop + +This chart is for testing the processing of enabled/disabled charts +via conditions and tags. + +Currently there are three levels: + +```` +parent +-1 tags: front-end, subchart1 +--A tags: front-end, subchartA +--B tags: front-end, subchartB +-2 tags: back-end, subchart2 +--B tags: back-end, subchartB +--C tags: back-end, subchartC +```` + +Tags and conditions are currently in requirements.yaml files. \ No newline at end of file diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/Chart.yaml b/pkg/chartutil/testdata/subpop/charts/subchart1/Chart.yaml new file mode 100644 index 00000000..9d8c03ee --- /dev/null +++ b/pkg/chartutil/testdata/subpop/charts/subchart1/Chart.yaml @@ -0,0 +1,36 @@ +apiVersion: v1 +description: A Helm chart for Kubernetes +name: subchart1 +version: 0.1.0 +dependencies: + - name: subcharta + repository: http://localhost:10191 + version: 0.1.0 + condition: subcharta.enabled + tags: + - front-end + - subcharta + import-values: + - child: SCAdata + parent: imported-chartA + - child: SCAdata + parent: overridden-chartA + - child: SCAdata + parent: imported-chartA-B + + - name: subchartb + repository: http://localhost:10191 + version: 0.1.0 + condition: subchartb.enabled + import-values: + - child: SCBdata + parent: imported-chartB + - child: SCBdata + parent: imported-chartA-B + - child: exports.SCBexported2 + parent: exports.SCBexported2 + - SCBexported1 + + tags: + - front-end + - subchartb diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA/Chart.yaml b/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA/Chart.yaml new file mode 100644 index 00000000..be3edcef --- /dev/null +++ b/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: A Helm chart for Kubernetes +name: subcharta +version: 0.1.0 diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA/templates/service.yaml b/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA/templates/service.yaml new file mode 100644 index 00000000..27501e1e --- /dev/null +++ b/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Chart.Name }} + labels: + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.externalPort }} + targetPort: {{ .Values.service.internalPort }} + protocol: TCP + name: {{ .Values.service.name }} + selector: + app.kubernetes.io/name: {{ .Chart.Name }} diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA/values.yaml b/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA/values.yaml new file mode 100644 index 00000000..f0381ae6 --- /dev/null +++ b/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA/values.yaml @@ -0,0 +1,17 @@ +# Default values for subchart. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +# subchartA +service: + name: apache + type: ClusterIP + externalPort: 80 + internalPort: 80 +SCAdata: + SCAbool: false + SCAfloat: 3.1 + SCAint: 55 + SCAstring: "jabba" + SCAnested1: + SCAnested2: true + diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB/Chart.yaml b/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB/Chart.yaml new file mode 100644 index 00000000..c3c6bbaf --- /dev/null +++ b/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: A Helm chart for Kubernetes +name: subchartb +version: 0.1.0 diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB/templates/service.yaml b/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB/templates/service.yaml new file mode 100644 index 00000000..27501e1e --- /dev/null +++ b/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Chart.Name }} + labels: + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.externalPort }} + targetPort: {{ .Values.service.internalPort }} + protocol: TCP + name: {{ .Values.service.name }} + selector: + app.kubernetes.io/name: {{ .Chart.Name }} diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB/values.yaml b/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB/values.yaml new file mode 100644 index 00000000..774fdd75 --- /dev/null +++ b/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB/values.yaml @@ -0,0 +1,35 @@ +# Default values for subchart. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +service: + name: nginx + type: ClusterIP + externalPort: 80 + internalPort: 80 + +SCBdata: + SCBbool: true + SCBfloat: 7.77 + SCBint: 33 + SCBstring: "boba" + +exports: + SCBexported1: + SCBexported1A: + SCBexported1B: 1965 + + SCBexported2: + SCBexported2A: "blaster" + +global: + kolla: + nova: + api: + all: + port: 8774 + metadata: + all: + port: 8775 + + + diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/crds/crdA.yaml b/pkg/chartutil/testdata/subpop/charts/subchart1/crds/crdA.yaml new file mode 100644 index 00000000..fca77fd4 --- /dev/null +++ b/pkg/chartutil/testdata/subpop/charts/subchart1/crds/crdA.yaml @@ -0,0 +1,13 @@ +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: testCRDs +spec: + group: testCRDGroups + names: + kind: TestCRD + listKind: TestCRDList + plural: TestCRDs + shortNames: + - tc + singular: authconfig diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/templates/NOTES.txt b/pkg/chartutil/testdata/subpop/charts/subchart1/templates/NOTES.txt new file mode 100644 index 00000000..4bdf443f --- /dev/null +++ b/pkg/chartutil/testdata/subpop/charts/subchart1/templates/NOTES.txt @@ -0,0 +1 @@ +Sample notes for {{ .Chart.Name }} \ No newline at end of file diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/templates/service.yaml b/pkg/chartutil/testdata/subpop/charts/subchart1/templates/service.yaml new file mode 100644 index 00000000..fee94dce --- /dev/null +++ b/pkg/chartutil/testdata/subpop/charts/subchart1/templates/service.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Chart.Name }} + labels: + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + app.kubernetes.io/instance: "{{ .Release.Name }}" + kube-version/major: "{{ .Capabilities.KubeVersion.Major }}" + kube-version/minor: "{{ .Capabilities.KubeVersion.Minor }}" + kube-version/version: "v{{ .Capabilities.KubeVersion.Major }}.{{ .Capabilities.KubeVersion.Minor }}.0" +{{- if .Capabilities.APIVersions.Has "helm.k8s.io/test" }} + kube-api-version/test: v1 +{{- end }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.externalPort }} + targetPort: {{ .Values.service.internalPort }} + protocol: TCP + name: {{ .Values.service.name }} + selector: + app.kubernetes.io/name: {{ .Chart.Name }} diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/templates/subdir/role.yaml b/pkg/chartutil/testdata/subpop/charts/subchart1/templates/subdir/role.yaml new file mode 100644 index 00000000..91b954e5 --- /dev/null +++ b/pkg/chartutil/testdata/subpop/charts/subchart1/templates/subdir/role.yaml @@ -0,0 +1,7 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ .Chart.Name }}-role +rules: +- resources: ["*"] + verbs: ["get","list","watch"] diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/templates/subdir/rolebinding.yaml b/pkg/chartutil/testdata/subpop/charts/subchart1/templates/subdir/rolebinding.yaml new file mode 100644 index 00000000..5d193f1a --- /dev/null +++ b/pkg/chartutil/testdata/subpop/charts/subchart1/templates/subdir/rolebinding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ .Chart.Name }}-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ .Chart.Name }}-role +subjects: +- kind: ServiceAccount + name: {{ .Chart.Name }}-sa + namespace: default diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/templates/subdir/serviceaccount.yaml b/pkg/chartutil/testdata/subpop/charts/subchart1/templates/subdir/serviceaccount.yaml new file mode 100644 index 00000000..7126c7d8 --- /dev/null +++ b/pkg/chartutil/testdata/subpop/charts/subchart1/templates/subdir/serviceaccount.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Chart.Name }}-sa diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/values.yaml b/pkg/chartutil/testdata/subpop/charts/subchart1/values.yaml new file mode 100644 index 00000000..a974e316 --- /dev/null +++ b/pkg/chartutil/testdata/subpop/charts/subchart1/values.yaml @@ -0,0 +1,55 @@ +# Default values for subchart. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +# subchart1 +service: + name: nginx + type: ClusterIP + externalPort: 80 + internalPort: 80 + + +SC1data: + SC1bool: true + SC1float: 3.14 + SC1int: 100 + SC1string: "dollywood" + SC1extra1: 11 + +imported-chartA: + SC1extra2: 1.337 + +overridden-chartA: + SCAbool: true + SCAfloat: 3.14 + SCAint: 100 + SCAstring: "jabbathehut" + SC1extra3: true + +imported-chartA-B: + SC1extra5: "tiller" + +overridden-chartA-B: + SCAbool: true + SCAfloat: 3.33 + SCAint: 555 + SCAstring: "wormwood" + SCAextra1: 23 + + SCBbool: true + SCBfloat: 0.25 + SCBint: 98 + SCBstring: "murkwood" + SCBextra1: 13 + + SC1extra6: 77 + +SCBexported1A: + SC1extra7: true + +exports: + SC1exported1: + global: + SC1exported2: + all: + SC1exported3: "SC1expstr" \ No newline at end of file diff --git a/pkg/chartutil/testdata/subpop/charts/subchart2/Chart.yaml b/pkg/chartutil/testdata/subpop/charts/subchart2/Chart.yaml new file mode 100644 index 00000000..f936528a --- /dev/null +++ b/pkg/chartutil/testdata/subpop/charts/subchart2/Chart.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +description: A Helm chart for Kubernetes +name: subchart2 +version: 0.1.0 +dependencies: + - name: subchartb + repository: http://localhost:10191 + version: 0.1.0 + condition: subchartb.enabled + tags: + - back-end + - subchartb + - name: subchartc + repository: http://localhost:10191 + version: 0.1.0 + condition: subchartc.enabled + tags: + - back-end + - subchartc diff --git a/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/Chart.yaml b/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/Chart.yaml new file mode 100644 index 00000000..c3c6bbaf --- /dev/null +++ b/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: A Helm chart for Kubernetes +name: subchartb +version: 0.1.0 diff --git a/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/templates/service.yaml b/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/templates/service.yaml new file mode 100644 index 00000000..3f168bdb --- /dev/null +++ b/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: subchart2-{{ .Chart.Name }} + labels: + helm.sh/hart: "{{ .Chart.Name }}-{{ .Chart.Version }}" +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.externalPort }} + targetPort: {{ .Values.service.internalPort }} + protocol: TCP + name: subchart2-{{ .Values.service.name }} + selector: + app.kubernetes.io/name: {{ .Chart.Name }} diff --git a/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/values.yaml b/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/values.yaml new file mode 100644 index 00000000..5e5b2106 --- /dev/null +++ b/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/values.yaml @@ -0,0 +1,21 @@ +# Default values for subchart. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +replicaCount: 1 +image: + repository: nginx + tag: stable + pullPolicy: IfNotPresent +service: + name: nginx + type: ClusterIP + externalPort: 80 + internalPort: 80 +resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi + diff --git a/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC/Chart.yaml b/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC/Chart.yaml new file mode 100644 index 00000000..dcc45c08 --- /dev/null +++ b/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: A Helm chart for Kubernetes +name: subchartc +version: 0.1.0 diff --git a/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC/templates/service.yaml b/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC/templates/service.yaml new file mode 100644 index 00000000..27501e1e --- /dev/null +++ b/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Chart.Name }} + labels: + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.externalPort }} + targetPort: {{ .Values.service.internalPort }} + protocol: TCP + name: {{ .Values.service.name }} + selector: + app.kubernetes.io/name: {{ .Chart.Name }} diff --git a/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC/values.yaml b/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC/values.yaml new file mode 100644 index 00000000..5e5b2106 --- /dev/null +++ b/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC/values.yaml @@ -0,0 +1,21 @@ +# Default values for subchart. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +replicaCount: 1 +image: + repository: nginx + tag: stable + pullPolicy: IfNotPresent +service: + name: nginx + type: ClusterIP + externalPort: 80 + internalPort: 80 +resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi + diff --git a/pkg/chartutil/testdata/subpop/charts/subchart2/templates/service.yaml b/pkg/chartutil/testdata/subpop/charts/subchart2/templates/service.yaml new file mode 100644 index 00000000..27501e1e --- /dev/null +++ b/pkg/chartutil/testdata/subpop/charts/subchart2/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Chart.Name }} + labels: + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.externalPort }} + targetPort: {{ .Values.service.internalPort }} + protocol: TCP + name: {{ .Values.service.name }} + selector: + app.kubernetes.io/name: {{ .Chart.Name }} diff --git a/pkg/chartutil/testdata/subpop/charts/subchart2/values.yaml b/pkg/chartutil/testdata/subpop/charts/subchart2/values.yaml new file mode 100644 index 00000000..5e5b2106 --- /dev/null +++ b/pkg/chartutil/testdata/subpop/charts/subchart2/values.yaml @@ -0,0 +1,21 @@ +# Default values for subchart. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +replicaCount: 1 +image: + repository: nginx + tag: stable + pullPolicy: IfNotPresent +service: + name: nginx + type: ClusterIP + externalPort: 80 + internalPort: 80 +resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi + diff --git a/pkg/chartutil/testdata/subpop/noreqs/Chart.yaml b/pkg/chartutil/testdata/subpop/noreqs/Chart.yaml new file mode 100644 index 00000000..bbb0941c --- /dev/null +++ b/pkg/chartutil/testdata/subpop/noreqs/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: A Helm chart for Kubernetes +name: parentchart +version: 0.1.0 diff --git a/pkg/chartutil/testdata/subpop/noreqs/templates/service.yaml b/pkg/chartutil/testdata/subpop/noreqs/templates/service.yaml new file mode 100644 index 00000000..27501e1e --- /dev/null +++ b/pkg/chartutil/testdata/subpop/noreqs/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Chart.Name }} + labels: + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.externalPort }} + targetPort: {{ .Values.service.internalPort }} + protocol: TCP + name: {{ .Values.service.name }} + selector: + app.kubernetes.io/name: {{ .Chart.Name }} diff --git a/pkg/chartutil/testdata/subpop/noreqs/values.yaml b/pkg/chartutil/testdata/subpop/noreqs/values.yaml new file mode 100644 index 00000000..4ed3b7ad --- /dev/null +++ b/pkg/chartutil/testdata/subpop/noreqs/values.yaml @@ -0,0 +1,26 @@ +# Default values for subchart. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +replicaCount: 1 +image: + repository: nginx + tag: stable + pullPolicy: IfNotPresent +service: + name: nginx + type: ClusterIP + externalPort: 80 + internalPort: 80 +resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi + + +# switch-like +tags: + front-end: true + back-end: false diff --git a/pkg/chartutil/testdata/subpop/values.yaml b/pkg/chartutil/testdata/subpop/values.yaml new file mode 100644 index 00000000..ba70ed40 --- /dev/null +++ b/pkg/chartutil/testdata/subpop/values.yaml @@ -0,0 +1,45 @@ +# parent/values.yaml + +imported-chart1: + SPextra1: "helm rocks" + +overridden-chart1: + SC1bool: false + SC1float: 3.141592 + SC1int: 99 + SC1string: "pollywog" + SPextra2: 42 + + +imported-chartA: + SPextra3: 1.337 + +overridden-chartA: + SCAbool: true + SCAfloat: 41.3 + SCAint: 808 + SCAstring: "jabberwocky" + SPextra4: true + +imported-chartA-B: + SPextra5: "k8s" + +overridden-chartA-B: + SCAbool: true + SCAfloat: 41.3 + SCAint: 808 + SCAstring: "jabberwocky" + SCBbool: false + SCBfloat: 1.99 + SCBint: 77 + SCBstring: "jango" + SPextra6: 111 + +tags: + front-end: true + back-end: false + +subchart2alias: + enabled: false + +ensurenull: null diff --git a/pkg/chartutil/testdata/test-values-invalid.schema.json b/pkg/chartutil/testdata/test-values-invalid.schema.json new file mode 100644 index 00000000..35a16a2c --- /dev/null +++ b/pkg/chartutil/testdata/test-values-invalid.schema.json @@ -0,0 +1 @@ + 1E1111111 diff --git a/pkg/chartutil/testdata/test-values-negative.yaml b/pkg/chartutil/testdata/test-values-negative.yaml new file mode 100644 index 00000000..5a1250bf --- /dev/null +++ b/pkg/chartutil/testdata/test-values-negative.yaml @@ -0,0 +1,14 @@ +firstname: John +lastname: Doe +age: -5 +likesCoffee: true +addresses: + - city: Springfield + street: Main + number: 12345 + - city: New York + street: Broadway + number: 67890 +phoneNumbers: + - "(888) 888-8888" + - "(555) 555-5555" diff --git a/pkg/chartutil/testdata/test-values.schema.json b/pkg/chartutil/testdata/test-values.schema.json new file mode 100644 index 00000000..4df89bbe --- /dev/null +++ b/pkg/chartutil/testdata/test-values.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "addresses": { + "description": "List of addresses", + "items": { + "properties": { + "city": { + "type": "string" + }, + "number": { + "type": "number" + }, + "street": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "age": { + "description": "Age", + "minimum": 0, + "type": "integer" + }, + "employmentInfo": { + "properties": { + "salary": { + "minimum": 0, + "type": "number" + }, + "title": { + "type": "string" + } + }, + "required": [ + "salary" + ], + "type": "object" + }, + "firstname": { + "description": "First name", + "type": "string" + }, + "lastname": { + "type": "string" + }, + "likesCoffee": { + "type": "boolean" + }, + "phoneNumbers": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "firstname", + "lastname", + "addresses", + "employmentInfo" + ], + "title": "Values", + "type": "object" +} diff --git a/pkg/chartutil/testdata/test-values.yaml b/pkg/chartutil/testdata/test-values.yaml new file mode 100644 index 00000000..042dea66 --- /dev/null +++ b/pkg/chartutil/testdata/test-values.yaml @@ -0,0 +1,17 @@ +firstname: John +lastname: Doe +age: 25 +likesCoffee: true +employmentInfo: + title: Software Developer + salary: 100000 +addresses: + - city: Springfield + street: Main + number: 12345 + - city: New York + street: Broadway + number: 67890 +phoneNumbers: + - "(888) 888-8888" + - "(555) 555-5555" diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/README.md b/pkg/chartutil/testdata/three-level-dependent-chart/README.md new file mode 100644 index 00000000..536bb979 --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/README.md @@ -0,0 +1,16 @@ +# Three Level Dependent Chart + +This chart is for testing the processing of multi-level dependencies. + +Consists of the following charts: + +- Library Chart +- App Chart (Uses Library Chart as dependency, 2x: app1/app2) +- Umbrella Chart (Has all the app charts as dependencies) + +The precedence is as follows: `library < app < umbrella` + +Catches two use-cases: + +- app overwriting library (app2) +- umbrella overwriting app and library (app1) diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/Chart.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/Chart.yaml new file mode 100644 index 00000000..e5dbe313 --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/Chart.yaml @@ -0,0 +1,19 @@ +apiVersion: v2 +name: umbrella +description: A Helm chart for Kubernetes +type: application +version: 0.1.0 + +dependencies: +- name: app1 + version: 0.1.0 + condition: app1.enabled +- name: app2 + version: 0.1.0 + condition: app2.enabled +- name: app3 + version: 0.1.0 + condition: app3.enabled +- name: app4 + version: 0.1.0 + condition: app4.enabled diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/Chart.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/Chart.yaml new file mode 100644 index 00000000..388245e3 --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/Chart.yaml @@ -0,0 +1,11 @@ +apiVersion: v2 +name: app1 +description: A Helm chart for Kubernetes +type: application +version: 0.1.0 + +dependencies: +- name: library + version: 0.1.0 + import-values: + - defaults diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/Chart.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/Chart.yaml new file mode 100644 index 00000000..f2f8a90d --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: library +description: A Helm chart for Kubernetes +type: library +version: 0.1.0 diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/templates/service.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/templates/service.yaml new file mode 100644 index 00000000..3fd398b5 --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/templates/service.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Service +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/values.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/values.yaml new file mode 100644 index 00000000..0c08b6cd --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/values.yaml @@ -0,0 +1,5 @@ +exports: + defaults: + service: + type: ClusterIP + port: 9090 diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/templates/service.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/templates/service.yaml new file mode 100644 index 00000000..8ed8ddf1 --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/templates/service.yaml @@ -0,0 +1 @@ +{{- include "library.service" . }} diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/values.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/values.yaml new file mode 100644 index 00000000..3728aa93 --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/values.yaml @@ -0,0 +1,3 @@ +service: + type: ClusterIP + port: 1234 diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/Chart.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/Chart.yaml new file mode 100644 index 00000000..fea2768c --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/Chart.yaml @@ -0,0 +1,11 @@ +apiVersion: v2 +name: app2 +description: A Helm chart for Kubernetes +type: application +version: 0.1.0 + +dependencies: +- name: library + version: 0.1.0 + import-values: + - defaults diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/Chart.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/Chart.yaml new file mode 100644 index 00000000..f2f8a90d --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: library +description: A Helm chart for Kubernetes +type: library +version: 0.1.0 diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/templates/service.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/templates/service.yaml new file mode 100644 index 00000000..3fd398b5 --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/templates/service.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Service +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/values.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/values.yaml new file mode 100644 index 00000000..0c08b6cd --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/values.yaml @@ -0,0 +1,5 @@ +exports: + defaults: + service: + type: ClusterIP + port: 9090 diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/templates/service.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/templates/service.yaml new file mode 100644 index 00000000..8ed8ddf1 --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/templates/service.yaml @@ -0,0 +1 @@ +{{- include "library.service" . }} diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/values.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/values.yaml new file mode 100644 index 00000000..98bd6d24 --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/values.yaml @@ -0,0 +1,3 @@ +service: + type: ClusterIP + port: 8080 diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/Chart.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/Chart.yaml new file mode 100644 index 00000000..a42f5877 --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/Chart.yaml @@ -0,0 +1,11 @@ +apiVersion: v2 +name: app3 +description: A Helm chart for Kubernetes +type: application +version: 0.1.0 + +dependencies: +- name: library + version: 0.1.0 + import-values: + - defaults diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/Chart.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/Chart.yaml new file mode 100644 index 00000000..f2f8a90d --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: library +description: A Helm chart for Kubernetes +type: library +version: 0.1.0 diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/templates/service.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/templates/service.yaml new file mode 100644 index 00000000..3fd398b5 --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/templates/service.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Service +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/values.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/values.yaml new file mode 100644 index 00000000..0c08b6cd --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/values.yaml @@ -0,0 +1,5 @@ +exports: + defaults: + service: + type: ClusterIP + port: 9090 diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/templates/service.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/templates/service.yaml new file mode 100644 index 00000000..8ed8ddf1 --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/templates/service.yaml @@ -0,0 +1 @@ +{{- include "library.service" . }} diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/values.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/values.yaml new file mode 100644 index 00000000..b738e2a5 --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/values.yaml @@ -0,0 +1,2 @@ +service: + type: ClusterIP diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/Chart.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/Chart.yaml new file mode 100644 index 00000000..574bfdfd --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/Chart.yaml @@ -0,0 +1,9 @@ +apiVersion: v2 +name: app4 +description: A Helm chart for Kubernetes +type: application +version: 0.1.0 + +dependencies: +- name: library + version: 0.1.0 diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/Chart.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/Chart.yaml new file mode 100644 index 00000000..f2f8a90d --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: library +description: A Helm chart for Kubernetes +type: library +version: 0.1.0 diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/templates/service.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/templates/service.yaml new file mode 100644 index 00000000..3fd398b5 --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/templates/service.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Service +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/values.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/values.yaml new file mode 100644 index 00000000..0c08b6cd --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/values.yaml @@ -0,0 +1,5 @@ +exports: + defaults: + service: + type: ClusterIP + port: 9090 diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/templates/service.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/templates/service.yaml new file mode 100644 index 00000000..8ed8ddf1 --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/templates/service.yaml @@ -0,0 +1 @@ +{{- include "library.service" . }} diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/values.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/values.yaml new file mode 100644 index 00000000..3728aa93 --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/values.yaml @@ -0,0 +1,3 @@ +service: + type: ClusterIP + port: 1234 diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/values.yaml b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/values.yaml new file mode 100644 index 00000000..de0bafa5 --- /dev/null +++ b/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/values.yaml @@ -0,0 +1,14 @@ +app1: + enabled: true + service: + type: ClusterIP + port: 3456 + +app2: + enabled: true + +app3: + enabled: true + +app4: + enabled: true diff --git a/pkg/chartutil/validate_name.go b/pkg/chartutil/validate_name.go new file mode 100644 index 00000000..05c090cb --- /dev/null +++ b/pkg/chartutil/validate_name.go @@ -0,0 +1,112 @@ +/* +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 chartutil + +import ( + "fmt" + "regexp" + + "github.com/pkg/errors" +) + +// validName is a regular expression for resource names. +// +// According to the Kubernetes help text, the regular expression it uses is: +// +// [a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)* +// +// This follows the above regular expression (but requires a full string match, not partial). +// +// The Kubernetes documentation is here, though it is not entirely correct: +// https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names +var validName = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`) + +var ( + // errMissingName indicates that a release (name) was not provided. + errMissingName = errors.New("no name provided") + + // errInvalidName indicates that an invalid release name was provided + errInvalidName = fmt.Errorf( + "invalid release name, must match regex %s and the length must not be longer than 53", + validName.String()) + + // errInvalidKubernetesName indicates that the name does not meet the Kubernetes + // restrictions on metadata names. + errInvalidKubernetesName = fmt.Errorf( + "invalid metadata name, must match regex %s and the length must not be longer than 253", + validName.String()) +) + +const ( + // According to the Kubernetes docs (https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#rfc-1035-label-names) + // some resource names have a max length of 63 characters while others have a max + // length of 253 characters. As we cannot be sure the resources used in a chart, we + // therefore need to limit it to 63 chars and reserve 10 chars for additional part to name + // of the resource. The reason is that chart maintainers can use release name as part of + // the resource name (and some additional chars). + maxReleaseNameLen = 53 + // maxMetadataNameLen is the maximum length Kubernetes allows for any name. + maxMetadataNameLen = 253 +) + +// ValidateReleaseName performs checks for an entry for a Helm release name +// +// For Helm to allow a name, it must be below a certain character count (53) and also match +// a regular expression. +// +// According to the Kubernetes help text, the regular expression it uses is: +// +// [a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)* +// +// This follows the above regular expression (but requires a full string match, not partial). +// +// The Kubernetes documentation is here, though it is not entirely correct: +// https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names +func ValidateReleaseName(name string) error { + // This case is preserved for backwards compatibility + if name == "" { + return errMissingName + + } + if len(name) > maxReleaseNameLen || !validName.MatchString(name) { + return errInvalidName + } + return nil +} + +// ValidateMetadataName validates the name field of a Kubernetes metadata object. +// +// Empty strings, strings longer than 253 chars, or strings that don't match the regexp +// will fail. +// +// According to the Kubernetes help text, the regular expression it uses is: +// +// [a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)* +// +// This follows the above regular expression (but requires a full string match, not partial). +// +// The Kubernetes documentation is here, though it is not entirely correct: +// https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names +// +// Deprecated: remove in Helm 4. Name validation now uses rules defined in +// pkg/lint/rules.validateMetadataNameFunc() +func ValidateMetadataName(name string) error { + if name == "" || len(name) > maxMetadataNameLen || !validName.MatchString(name) { + return errInvalidKubernetesName + } + return nil +} diff --git a/pkg/chartutil/validate_name_test.go b/pkg/chartutil/validate_name_test.go new file mode 100644 index 00000000..5f0792f9 --- /dev/null +++ b/pkg/chartutil/validate_name_test.go @@ -0,0 +1,91 @@ +/* +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 chartutil + +import "testing" + +// TestValidateName is a regression test for ValidateName +// +// Kubernetes has strict naming conventions for resource names. This test represents +// those conventions. +// +// See https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names +// +// NOTE: At the time of this writing, the docs above say that names cannot begin with +// digits. However, `kubectl`'s regular expression explicit allows this, and +// Kubernetes (at least as of 1.18) also accepts resources whose names begin with digits. +func TestValidateReleaseName(t *testing.T) { + names := map[string]bool{ + "": false, + "foo": true, + "foo.bar1234baz.seventyone": true, + "FOO": false, + "123baz": true, + "foo.BAR.baz": false, + "one-two": true, + "-two": false, + "one_two": false, + "a..b": false, + "%^&#$%*@^*@&#^": false, + "example:com": false, + "example%%com": false, + "a1111111111111111111111111111111111111111111111111111111111z": false, + } + for input, expectPass := range names { + if err := ValidateReleaseName(input); (err == nil) != expectPass { + st := "fail" + if expectPass { + st = "succeed" + } + t.Errorf("Expected %q to %s", input, st) + } + } +} + +func TestValidateMetadataName(t *testing.T) { + names := map[string]bool{ + "": false, + "foo": true, + "foo.bar1234baz.seventyone": true, + "FOO": false, + "123baz": true, + "foo.BAR.baz": false, + "one-two": true, + "-two": false, + "one_two": false, + "a..b": false, + "%^&#$%*@^*@&#^": false, + "example:com": false, + "example%%com": false, + "a1111111111111111111111111111111111111111111111111111111111z": true, + "a1111111111111111111111111111111111111111111111111111111111z" + + "a1111111111111111111111111111111111111111111111111111111111z" + + "a1111111111111111111111111111111111111111111111111111111111z" + + "a1111111111111111111111111111111111111111111111111111111111z" + + "a1111111111111111111111111111111111111111111111111111111111z" + + "a1111111111111111111111111111111111111111111111111111111111z": false, + } + for input, expectPass := range names { + if err := ValidateMetadataName(input); (err == nil) != expectPass { + st := "fail" + if expectPass { + st = "succeed" + } + t.Errorf("Expected %q to %s", input, st) + } + } +} diff --git a/pkg/chartutil/values.go b/pkg/chartutil/values.go new file mode 100644 index 00000000..2fa2bdab --- /dev/null +++ b/pkg/chartutil/values.go @@ -0,0 +1,212 @@ +/* +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 chartutil + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/pkg/errors" + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/pkg/chart" +) + +// GlobalKey is the name of the Values key that is used for storing global vars. +const GlobalKey = "global" + +// Values represents a collection of chart values. +type Values map[string]interface{} + +// YAML encodes the Values into a YAML string. +func (v Values) YAML() (string, error) { + b, err := yaml.Marshal(v) + return string(b), err +} + +// Table gets a table (YAML subsection) from a Values object. +// +// The table is returned as a Values. +// +// Compound table names may be specified with dots: +// +// foo.bar +// +// The above will be evaluated as "The table bar inside the table +// foo". +// +// An ErrNoTable is returned if the table does not exist. +func (v Values) Table(name string) (Values, error) { + table := v + var err error + + for _, n := range parsePath(name) { + if table, err = tableLookup(table, n); err != nil { + break + } + } + return table, err +} + +// AsMap is a utility function for converting Values to a map[string]interface{}. +// +// It protects against nil map panics. +func (v Values) AsMap() map[string]interface{} { + if len(v) == 0 { + return map[string]interface{}{} + } + return v +} + +// Encode writes serialized Values information to the given io.Writer. +func (v Values) Encode(w io.Writer) error { + out, err := yaml.Marshal(v) + if err != nil { + return err + } + _, err = w.Write(out) + return err +} + +func tableLookup(v Values, simple string) (Values, error) { + v2, ok := v[simple] + if !ok { + return v, ErrNoTable{simple} + } + if vv, ok := v2.(map[string]interface{}); ok { + return vv, nil + } + + // This catches a case where a value is of type Values, but doesn't (for some + // reason) match the map[string]interface{}. This has been observed in the + // wild, and might be a result of a nil map of type Values. + if vv, ok := v2.(Values); ok { + return vv, nil + } + + return Values{}, ErrNoTable{simple} +} + +// ReadValues will parse YAML byte data into a Values. +func ReadValues(data []byte) (vals Values, err error) { + err = yaml.Unmarshal(data, &vals) + if len(vals) == 0 { + vals = Values{} + } + return vals, err +} + +// ReadValuesFile will parse a YAML file into a map of values. +func ReadValuesFile(filename string) (Values, error) { + data, err := os.ReadFile(filename) + if err != nil { + return map[string]interface{}{}, err + } + return ReadValues(data) +} + +// ReleaseOptions represents the additional release options needed +// for the composition of the final values struct +type ReleaseOptions struct { + Name string + Namespace string + Revision int + IsUpgrade bool + IsInstall bool +} + +// ToRenderValues composes the struct from the data coming from the Releases, Charts and Values files +// +// This takes both ReleaseOptions and Capabilities to merge into the render values. +func ToRenderValues(chrt *chart.Chart, chrtVals map[string]interface{}, options ReleaseOptions, caps *Capabilities) (Values, error) { + if caps == nil { + caps = DefaultCapabilities + } + top := map[string]interface{}{ + "Chart": chrt.Metadata, + "Capabilities": caps, + "Release": map[string]interface{}{ + "Name": options.Name, + "Namespace": options.Namespace, + "IsUpgrade": options.IsUpgrade, + "IsInstall": options.IsInstall, + "Revision": options.Revision, + "Service": "Helm", + }, + } + + vals, err := CoalesceValues(chrt, chrtVals) + if err != nil { + return top, err + } + + if err := ValidateAgainstSchema(chrt, vals); err != nil { + errFmt := "values don't meet the specifications of the schema(s) in the following chart(s):\n%s" + return top, fmt.Errorf(errFmt, err.Error()) + } + + top["Values"] = vals + return top, nil +} + +// istable is a special-purpose function to see if the present thing matches the definition of a YAML table. +func istable(v interface{}) bool { + _, ok := v.(map[string]interface{}) + return ok +} + +// PathValue takes a path that traverses a YAML structure and returns the value at the end of that path. +// The path starts at the root of the YAML structure and is comprised of YAML keys separated by periods. +// Given the following YAML data the value at path "chapter.one.title" is "Loomings". +// +// chapter: +// one: +// title: "Loomings" +func (v Values) PathValue(path string) (interface{}, error) { + if path == "" { + return nil, errors.New("YAML path cannot be empty") + } + return v.pathValue(parsePath(path)) +} + +func (v Values) pathValue(path []string) (interface{}, error) { + if len(path) == 1 { + // if exists must be root key not table + if _, ok := v[path[0]]; ok && !istable(v[path[0]]) { + return v[path[0]], nil + } + return nil, ErrNoValue{path[0]} + } + + key, path := path[len(path)-1], path[:len(path)-1] + // get our table for table path + t, err := v.Table(joinPath(path...)) + if err != nil { + return nil, ErrNoValue{key} + } + // check table for key and ensure value is not a table + if k, ok := t[key]; ok && !istable(k) { + return k, nil + } + return nil, ErrNoValue{key} +} + +func parsePath(key string) []string { return strings.Split(key, ".") } + +func joinPath(path ...string) string { return strings.Join(path, ".") } diff --git a/pkg/chartutil/values_test.go b/pkg/chartutil/values_test.go new file mode 100644 index 00000000..c95fa503 --- /dev/null +++ b/pkg/chartutil/values_test.go @@ -0,0 +1,292 @@ +/* +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 chartutil + +import ( + "bytes" + "fmt" + "testing" + "text/template" + + "helm.sh/helm/v3/pkg/chart" +) + +func TestReadValues(t *testing.T) { + doc := `# Test YAML parse +poet: "Coleridge" +title: "Rime of the Ancient Mariner" +stanza: + - "at" + - "length" + - "did" + - cross + - an + - Albatross + +mariner: + with: "crossbow" + shot: "ALBATROSS" + +water: + water: + where: "everywhere" + nor: "any drop to drink" +` + + data, err := ReadValues([]byte(doc)) + if err != nil { + t.Fatalf("Error parsing bytes: %s", err) + } + matchValues(t, data) + + tests := []string{`poet: "Coleridge"`, "# Just a comment", ""} + + for _, tt := range tests { + data, err = ReadValues([]byte(tt)) + if err != nil { + t.Fatalf("Error parsing bytes (%s): %s", tt, err) + } + if data == nil { + t.Errorf(`YAML string "%s" gave a nil map`, tt) + } + } +} + +func TestToRenderValues(t *testing.T) { + + chartValues := map[string]interface{}{ + "name": "al Rashid", + "where": map[string]interface{}{ + "city": "Basrah", + "title": "caliph", + }, + } + + overrideValues := map[string]interface{}{ + "name": "Haroun", + "where": map[string]interface{}{ + "city": "Baghdad", + "date": "809 CE", + }, + } + + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: "test"}, + Templates: []*chart.File{}, + Values: chartValues, + Files: []*chart.File{ + {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, + }, + } + c.AddDependency(&chart.Chart{ + Metadata: &chart.Metadata{Name: "where"}, + }) + + o := ReleaseOptions{ + Name: "Seven Voyages", + Namespace: "default", + Revision: 1, + IsInstall: true, + } + + res, err := ToRenderValues(c, overrideValues, o, nil) + if err != nil { + t.Fatal(err) + } + + // Ensure that the top-level values are all set. + if name := res["Chart"].(*chart.Metadata).Name; name != "test" { + t.Errorf("Expected chart name 'test', got %q", name) + } + relmap := res["Release"].(map[string]interface{}) + if name := relmap["Name"]; name.(string) != "Seven Voyages" { + t.Errorf("Expected release name 'Seven Voyages', got %q", name) + } + if namespace := relmap["Namespace"]; namespace.(string) != "default" { + t.Errorf("Expected namespace 'default', got %q", namespace) + } + if revision := relmap["Revision"]; revision.(int) != 1 { + t.Errorf("Expected revision '1', got %d", revision) + } + if relmap["IsUpgrade"].(bool) { + t.Error("Expected upgrade to be false.") + } + if !relmap["IsInstall"].(bool) { + t.Errorf("Expected install to be true.") + } + if !res["Capabilities"].(*Capabilities).APIVersions.Has("v1") { + t.Error("Expected Capabilities to have v1 as an API") + } + if res["Capabilities"].(*Capabilities).KubeVersion.Major != "1" { + t.Error("Expected Capabilities to have a Kube version") + } + + vals := res["Values"].(Values) + if vals["name"] != "Haroun" { + t.Errorf("Expected 'Haroun', got %q (%v)", vals["name"], vals) + } + where := vals["where"].(map[string]interface{}) + expects := map[string]string{ + "city": "Baghdad", + "date": "809 CE", + "title": "caliph", + } + for field, expect := range expects { + if got := where[field]; got != expect { + t.Errorf("Expected %q, got %q (%v)", expect, got, where) + } + } +} + +func TestReadValuesFile(t *testing.T) { + data, err := ReadValuesFile("./testdata/coleridge.yaml") + if err != nil { + t.Fatalf("Error reading YAML file: %s", err) + } + matchValues(t, data) +} + +func ExampleValues() { + doc := ` +title: "Moby Dick" +chapter: + one: + title: "Loomings" + two: + title: "The Carpet-Bag" + three: + title: "The Spouter Inn" +` + d, err := ReadValues([]byte(doc)) + if err != nil { + panic(err) + } + ch1, err := d.Table("chapter.one") + if err != nil { + panic("could not find chapter one") + } + fmt.Print(ch1["title"]) + // Output: + // Loomings +} + +func TestTable(t *testing.T) { + doc := ` +title: "Moby Dick" +chapter: + one: + title: "Loomings" + two: + title: "The Carpet-Bag" + three: + title: "The Spouter Inn" +` + d, err := ReadValues([]byte(doc)) + if err != nil { + t.Fatalf("Failed to parse the White Whale: %s", err) + } + + if _, err := d.Table("title"); err == nil { + t.Fatalf("Title is not a table.") + } + + if _, err := d.Table("chapter"); err != nil { + t.Fatalf("Failed to get the chapter table: %s\n%v", err, d) + } + + if v, err := d.Table("chapter.one"); err != nil { + t.Errorf("Failed to get chapter.one: %s", err) + } else if v["title"] != "Loomings" { + t.Errorf("Unexpected title: %s", v["title"]) + } + + if _, err := d.Table("chapter.three"); err != nil { + t.Errorf("Chapter three is missing: %s\n%v", err, d) + } + + if _, err := d.Table("chapter.OneHundredThirtySix"); err == nil { + t.Errorf("I think you mean 'Epilogue'") + } +} + +func matchValues(t *testing.T, data map[string]interface{}) { + if data["poet"] != "Coleridge" { + t.Errorf("Unexpected poet: %s", data["poet"]) + } + + if o, err := ttpl("{{len .stanza}}", data); err != nil { + t.Errorf("len stanza: %s", err) + } else if o != "6" { + t.Errorf("Expected 6, got %s", o) + } + + if o, err := ttpl("{{.mariner.shot}}", data); err != nil { + t.Errorf(".mariner.shot: %s", err) + } else if o != "ALBATROSS" { + t.Errorf("Expected that mariner shot ALBATROSS") + } + + if o, err := ttpl("{{.water.water.where}}", data); err != nil { + t.Errorf(".water.water.where: %s", err) + } else if o != "everywhere" { + t.Errorf("Expected water water everywhere") + } +} + +func ttpl(tpl string, v map[string]interface{}) (string, error) { + var b bytes.Buffer + tt := template.Must(template.New("t").Parse(tpl)) + err := tt.Execute(&b, v) + return b.String(), err +} + +func TestPathValue(t *testing.T) { + doc := ` +title: "Moby Dick" +chapter: + one: + title: "Loomings" + two: + title: "The Carpet-Bag" + three: + title: "The Spouter Inn" +` + d, err := ReadValues([]byte(doc)) + if err != nil { + t.Fatalf("Failed to parse the White Whale: %s", err) + } + + if v, err := d.PathValue("chapter.one.title"); err != nil { + t.Errorf("Got error instead of title: %s\n%v", err, d) + } else if v != "Loomings" { + t.Errorf("No error but got wrong value for title: %s\n%v", err, d) + } + if _, err := d.PathValue("chapter.one.doesnotexist"); err == nil { + t.Errorf("Non-existent key should return error: %s\n%v", err, d) + } + if _, err := d.PathValue("chapter.doesnotexist.one"); err == nil { + t.Errorf("Non-existent key in middle of path should return error: %s\n%v", err, d) + } + if _, err := d.PathValue(""); err == nil { + t.Error("Asking for the value from an empty path should yield an error") + } + if v, err := d.PathValue("title"); err == nil { + if v != "Moby Dick" { + t.Errorf("Failed to return values for root key title") + } + } +} diff --git a/pkg/cli/environment.go b/pkg/cli/environment.go new file mode 100644 index 00000000..4f74f264 --- /dev/null +++ b/pkg/cli/environment.go @@ -0,0 +1,258 @@ +/* +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 cli describes the operating environment for the Helm CLI. + +Helm's environment encapsulates all of the service dependencies Helm has. +These dependencies are expressed as interfaces so that alternate implementations +(mocks, etc.) can be easily generated. +*/ +package cli + +import ( + "fmt" + "net/http" + "os" + "strconv" + "strings" + + "github.com/spf13/pflag" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest" + + "helm.sh/helm/v3/internal/version" + "helm.sh/helm/v3/pkg/helmpath" +) + +// defaultMaxHistory sets the maximum number of releases to 0: unlimited +const defaultMaxHistory = 10 + +// defaultBurstLimit sets the default client-side throttling limit +const defaultBurstLimit = 100 + +// defaultQPS sets the default QPS value to 0 to to use library defaults unless specified +const defaultQPS = float32(0) + +// EnvSettings describes all of the environment settings. +type EnvSettings struct { + namespace string + config *genericclioptions.ConfigFlags + + // KubeConfig is the path to the kubeconfig file + KubeConfig string + // KubeContext is the name of the kubeconfig context. + KubeContext string + // Bearer KubeToken used for authentication + KubeToken string + // Username to impersonate for the operation + KubeAsUser string + // Groups to impersonate for the operation, multiple groups parsed from a comma delimited list + KubeAsGroups []string + // Kubernetes API Server Endpoint for authentication + KubeAPIServer string + // Custom certificate authority file. + KubeCaFile string + // KubeInsecureSkipTLSVerify indicates if server's certificate will not be checked for validity. + // This makes the HTTPS connections insecure + KubeInsecureSkipTLSVerify bool + // KubeTLSServerName overrides the name to use for server certificate validation. + // If it is not provided, the hostname used to contact the server is used + KubeTLSServerName string + // Debug indicates whether or not Helm is running in Debug mode. + Debug bool + // RegistryConfig is the path to the registry config file. + RegistryConfig string + // RepositoryConfig is the path to the repositories file. + RepositoryConfig string + // RepositoryCache is the path to the repository cache directory. + RepositoryCache string + // PluginsDirectory is the path to the plugins directory. + PluginsDirectory string + // MaxHistory is the max release history maintained. + MaxHistory int + // BurstLimit is the default client-side throttling limit. + BurstLimit int + // QPS is queries per second which may be used to avoid throttling. + QPS float32 +} + +func New() *EnvSettings { + env := &EnvSettings{ + namespace: os.Getenv("HELM_NAMESPACE"), + MaxHistory: envIntOr("HELM_MAX_HISTORY", defaultMaxHistory), + KubeContext: os.Getenv("HELM_KUBECONTEXT"), + KubeToken: os.Getenv("HELM_KUBETOKEN"), + KubeAsUser: os.Getenv("HELM_KUBEASUSER"), + KubeAsGroups: envCSV("HELM_KUBEASGROUPS"), + KubeAPIServer: os.Getenv("HELM_KUBEAPISERVER"), + KubeCaFile: os.Getenv("HELM_KUBECAFILE"), + KubeTLSServerName: os.Getenv("HELM_KUBETLS_SERVER_NAME"), + KubeInsecureSkipTLSVerify: envBoolOr("HELM_KUBEINSECURE_SKIP_TLS_VERIFY", false), + PluginsDirectory: envOr("HELM_PLUGINS", helmpath.DataPath("plugins")), + RegistryConfig: envOr("HELM_REGISTRY_CONFIG", helmpath.ConfigPath("registry/config.json")), + RepositoryConfig: envOr("HELM_REPOSITORY_CONFIG", helmpath.ConfigPath("repositories.yaml")), + RepositoryCache: envOr("HELM_REPOSITORY_CACHE", helmpath.CachePath("repository")), + BurstLimit: envIntOr("HELM_BURST_LIMIT", defaultBurstLimit), + QPS: envFloat32Or("HELM_QPS", defaultQPS), + } + env.Debug, _ = strconv.ParseBool(os.Getenv("HELM_DEBUG")) + + // bind to kubernetes config flags + env.config = &genericclioptions.ConfigFlags{ + Namespace: &env.namespace, + Context: &env.KubeContext, + BearerToken: &env.KubeToken, + APIServer: &env.KubeAPIServer, + CAFile: &env.KubeCaFile, + KubeConfig: &env.KubeConfig, + Impersonate: &env.KubeAsUser, + Insecure: &env.KubeInsecureSkipTLSVerify, + TLSServerName: &env.KubeTLSServerName, + ImpersonateGroup: &env.KubeAsGroups, + WrapConfigFn: func(config *rest.Config) *rest.Config { + config.Burst = env.BurstLimit + config.QPS = env.QPS + config.Wrap(func(rt http.RoundTripper) http.RoundTripper { + return &retryingRoundTripper{wrapped: rt} + }) + config.UserAgent = version.GetUserAgent() + return config + }, + } + return env +} + +// AddFlags binds flags to the given flagset. +func (s *EnvSettings) AddFlags(fs *pflag.FlagSet) { + fs.StringVarP(&s.namespace, "namespace", "n", s.namespace, "namespace scope for this request") + fs.StringVar(&s.KubeConfig, "kubeconfig", "", "path to the kubeconfig file") + fs.StringVar(&s.KubeContext, "kube-context", s.KubeContext, "name of the kubeconfig context to use") + fs.StringVar(&s.KubeToken, "kube-token", s.KubeToken, "bearer token used for authentication") + fs.StringVar(&s.KubeAsUser, "kube-as-user", s.KubeAsUser, "username to impersonate for the operation") + fs.StringArrayVar(&s.KubeAsGroups, "kube-as-group", s.KubeAsGroups, "group to impersonate for the operation, this flag can be repeated to specify multiple groups.") + fs.StringVar(&s.KubeAPIServer, "kube-apiserver", s.KubeAPIServer, "the address and the port for the Kubernetes API server") + fs.StringVar(&s.KubeCaFile, "kube-ca-file", s.KubeCaFile, "the certificate authority file for the Kubernetes API server connection") + fs.StringVar(&s.KubeTLSServerName, "kube-tls-server-name", s.KubeTLSServerName, "server name to use for Kubernetes API server certificate validation. If it is not provided, the hostname used to contact the server is used") + fs.BoolVar(&s.KubeInsecureSkipTLSVerify, "kube-insecure-skip-tls-verify", s.KubeInsecureSkipTLSVerify, "if true, the Kubernetes API server's certificate will not be checked for validity. This will make your HTTPS connections insecure") + fs.BoolVar(&s.Debug, "debug", s.Debug, "enable verbose output") + fs.StringVar(&s.RegistryConfig, "registry-config", s.RegistryConfig, "path to the registry config file") + fs.StringVar(&s.RepositoryConfig, "repository-config", s.RepositoryConfig, "path to the file containing repository names and URLs") + fs.StringVar(&s.RepositoryCache, "repository-cache", s.RepositoryCache, "path to the file containing cached repository indexes") + fs.IntVar(&s.BurstLimit, "burst-limit", s.BurstLimit, "client-side default throttling limit") + fs.Float32Var(&s.QPS, "qps", s.QPS, "queries per second used when communicating with the Kubernetes API, not including bursting") +} + +func envOr(name, def string) string { + if v, ok := os.LookupEnv(name); ok { + return v + } + return def +} + +func envBoolOr(name string, def bool) bool { + if name == "" { + return def + } + envVal := envOr(name, strconv.FormatBool(def)) + ret, err := strconv.ParseBool(envVal) + if err != nil { + return def + } + return ret +} + +func envIntOr(name string, def int) int { + if name == "" { + return def + } + envVal := envOr(name, strconv.Itoa(def)) + ret, err := strconv.Atoi(envVal) + if err != nil { + return def + } + return ret +} + +func envFloat32Or(name string, def float32) float32 { + if name == "" { + return def + } + envVal := envOr(name, strconv.FormatFloat(float64(def), 'f', 2, 32)) + ret, err := strconv.ParseFloat(envVal, 32) + if err != nil { + return def + } + return float32(ret) +} + +func envCSV(name string) (ls []string) { + trimmed := strings.Trim(os.Getenv(name), ", ") + if trimmed != "" { + ls = strings.Split(trimmed, ",") + } + return +} + +func (s *EnvSettings) EnvVars() map[string]string { + envvars := map[string]string{ + "HELM_BIN": os.Args[0], + "HELM_CACHE_HOME": helmpath.CachePath(""), + "HELM_CONFIG_HOME": helmpath.ConfigPath(""), + "HELM_DATA_HOME": helmpath.DataPath(""), + "HELM_DEBUG": fmt.Sprint(s.Debug), + "HELM_PLUGINS": s.PluginsDirectory, + "HELM_REGISTRY_CONFIG": s.RegistryConfig, + "HELM_REPOSITORY_CACHE": s.RepositoryCache, + "HELM_REPOSITORY_CONFIG": s.RepositoryConfig, + "HELM_NAMESPACE": s.Namespace(), + "HELM_MAX_HISTORY": strconv.Itoa(s.MaxHistory), + "HELM_BURST_LIMIT": strconv.Itoa(s.BurstLimit), + "HELM_QPS": strconv.FormatFloat(float64(s.QPS), 'f', 2, 32), + + // broken, these are populated from helm flags and not kubeconfig. + "HELM_KUBECONTEXT": s.KubeContext, + "HELM_KUBETOKEN": s.KubeToken, + "HELM_KUBEASUSER": s.KubeAsUser, + "HELM_KUBEASGROUPS": strings.Join(s.KubeAsGroups, ","), + "HELM_KUBEAPISERVER": s.KubeAPIServer, + "HELM_KUBECAFILE": s.KubeCaFile, + "HELM_KUBEINSECURE_SKIP_TLS_VERIFY": strconv.FormatBool(s.KubeInsecureSkipTLSVerify), + "HELM_KUBETLS_SERVER_NAME": s.KubeTLSServerName, + } + if s.KubeConfig != "" { + envvars["KUBECONFIG"] = s.KubeConfig + } + return envvars +} + +// Namespace gets the namespace from the configuration +func (s *EnvSettings) Namespace() string { + if ns, _, err := s.config.ToRawKubeConfigLoader().Namespace(); err == nil { + return ns + } + return "default" +} + +// SetNamespace sets the namespace in the configuration +func (s *EnvSettings) SetNamespace(namespace string) { + s.namespace = namespace +} + +// RESTClientGetter gets the kubeconfig from EnvSettings +func (s *EnvSettings) RESTClientGetter() genericclioptions.RESTClientGetter { + return s.config +} diff --git a/pkg/cli/environment_test.go b/pkg/cli/environment_test.go new file mode 100644 index 00000000..1692a89d --- /dev/null +++ b/pkg/cli/environment_test.go @@ -0,0 +1,270 @@ +/* +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 cli + +import ( + "os" + "reflect" + "strings" + "testing" + + "github.com/spf13/pflag" + + "helm.sh/helm/v3/internal/version" +) + +func TestSetNamespace(t *testing.T) { + settings := New() + + if settings.namespace != "" { + t.Errorf("Expected empty namespace, got %s", settings.namespace) + } + + settings.SetNamespace("testns") + if settings.namespace != "testns" { + t.Errorf("Expected namespace testns, got %s", settings.namespace) + } + +} + +func TestEnvSettings(t *testing.T) { + tests := []struct { + name string + + // input + args string + envvars map[string]string + + // expected values + ns, kcontext string + debug bool + maxhistory int + kubeAsUser string + kubeAsGroups []string + kubeCaFile string + kubeInsecure bool + kubeTLSServer string + burstLimit int + qps float32 + }{ + { + name: "defaults", + ns: "default", + maxhistory: defaultMaxHistory, + burstLimit: defaultBurstLimit, + qps: defaultQPS, + }, + { + name: "with flags set", + args: "--debug --namespace=myns --kube-as-user=poro --kube-as-group=admins --kube-as-group=teatime --kube-as-group=snackeaters --kube-ca-file=/tmp/ca.crt --burst-limit 100 --qps 50.12 --kube-insecure-skip-tls-verify=true --kube-tls-server-name=example.org", + ns: "myns", + debug: true, + maxhistory: defaultMaxHistory, + burstLimit: 100, + qps: 50.12, + kubeAsUser: "poro", + kubeAsGroups: []string{"admins", "teatime", "snackeaters"}, + kubeCaFile: "/tmp/ca.crt", + kubeTLSServer: "example.org", + kubeInsecure: true, + }, + { + name: "with envvars set", + envvars: map[string]string{"HELM_DEBUG": "1", "HELM_NAMESPACE": "yourns", "HELM_KUBEASUSER": "pikachu", "HELM_KUBEASGROUPS": ",,,operators,snackeaters,partyanimals", "HELM_MAX_HISTORY": "5", "HELM_KUBECAFILE": "/tmp/ca.crt", "HELM_BURST_LIMIT": "150", "HELM_KUBEINSECURE_SKIP_TLS_VERIFY": "true", "HELM_KUBETLS_SERVER_NAME": "example.org", "HELM_QPS": "60.34"}, + ns: "yourns", + maxhistory: 5, + burstLimit: 150, + qps: 60.34, + debug: true, + kubeAsUser: "pikachu", + kubeAsGroups: []string{"operators", "snackeaters", "partyanimals"}, + kubeCaFile: "/tmp/ca.crt", + kubeTLSServer: "example.org", + kubeInsecure: true, + }, + { + name: "with flags and envvars set", + args: "--debug --namespace=myns --kube-as-user=poro --kube-as-group=admins --kube-as-group=teatime --kube-as-group=snackeaters --kube-ca-file=/my/ca.crt --burst-limit 175 --qps 70 --kube-insecure-skip-tls-verify=true --kube-tls-server-name=example.org", + envvars: map[string]string{"HELM_DEBUG": "1", "HELM_NAMESPACE": "yourns", "HELM_KUBEASUSER": "pikachu", "HELM_KUBEASGROUPS": ",,,operators,snackeaters,partyanimals", "HELM_MAX_HISTORY": "5", "HELM_KUBECAFILE": "/tmp/ca.crt", "HELM_BURST_LIMIT": "200", "HELM_KUBEINSECURE_SKIP_TLS_VERIFY": "true", "HELM_KUBETLS_SERVER_NAME": "example.org", "HELM_QPS": "40"}, + ns: "myns", + debug: true, + maxhistory: 5, + burstLimit: 175, + qps: 70, + kubeAsUser: "poro", + kubeAsGroups: []string{"admins", "teatime", "snackeaters"}, + kubeCaFile: "/my/ca.crt", + kubeTLSServer: "example.org", + kubeInsecure: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer resetEnv()() + + for k, v := range tt.envvars { + os.Setenv(k, v) + } + + flags := pflag.NewFlagSet("testing", pflag.ContinueOnError) + + settings := New() + settings.AddFlags(flags) + flags.Parse(strings.Split(tt.args, " ")) + + if settings.Debug != tt.debug { + t.Errorf("expected debug %t, got %t", tt.debug, settings.Debug) + } + if settings.Namespace() != tt.ns { + t.Errorf("expected namespace %q, got %q", tt.ns, settings.Namespace()) + } + if settings.KubeContext != tt.kcontext { + t.Errorf("expected kube-context %q, got %q", tt.kcontext, settings.KubeContext) + } + if settings.MaxHistory != tt.maxhistory { + t.Errorf("expected maxHistory %d, got %d", tt.maxhistory, settings.MaxHistory) + } + if tt.kubeAsUser != settings.KubeAsUser { + t.Errorf("expected kAsUser %q, got %q", tt.kubeAsUser, settings.KubeAsUser) + } + if !reflect.DeepEqual(tt.kubeAsGroups, settings.KubeAsGroups) { + t.Errorf("expected kAsGroups %+v, got %+v", len(tt.kubeAsGroups), len(settings.KubeAsGroups)) + } + if tt.kubeCaFile != settings.KubeCaFile { + t.Errorf("expected kCaFile %q, got %q", tt.kubeCaFile, settings.KubeCaFile) + } + if tt.burstLimit != settings.BurstLimit { + t.Errorf("expected BurstLimit %d, got %d", tt.burstLimit, settings.BurstLimit) + } + if tt.kubeInsecure != settings.KubeInsecureSkipTLSVerify { + t.Errorf("expected kubeInsecure %t, got %t", tt.kubeInsecure, settings.KubeInsecureSkipTLSVerify) + } + if tt.kubeTLSServer != settings.KubeTLSServerName { + t.Errorf("expected kubeTLSServer %q, got %q", tt.kubeTLSServer, settings.KubeTLSServerName) + } + }) + } +} + +func TestEnvOrBool(t *testing.T) { + const envName = "TEST_ENV_OR_BOOL" + tests := []struct { + name string + env string + val string + def bool + expected bool + }{ + { + name: "unset with default false", + def: false, + expected: false, + }, + { + name: "unset with default true", + def: true, + expected: true, + }, + { + name: "blank env with default false", + env: envName, + def: false, + expected: false, + }, + { + name: "blank env with default true", + env: envName, + def: true, + expected: true, + }, + { + name: "env true with default false", + env: envName, + val: "true", + def: false, + expected: true, + }, + { + name: "env false with default true", + env: envName, + val: "false", + def: true, + expected: false, + }, + { + name: "env fails parsing with default true", + env: envName, + val: "NOT_A_BOOL", + def: true, + expected: true, + }, + { + name: "env fails parsing with default false", + env: envName, + val: "NOT_A_BOOL", + def: false, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.env != "" { + t.Cleanup(func() { + os.Unsetenv(tt.env) + }) + os.Setenv(tt.env, tt.val) + } + actual := envBoolOr(tt.env, tt.def) + if actual != tt.expected { + t.Errorf("expected result %t, got %t", tt.expected, actual) + } + }) + } +} + +func TestUserAgentHeaderInK8sRESTClientConfig(t *testing.T) { + defer resetEnv()() + + settings := New() + restConfig, err := settings.RESTClientGetter().ToRESTConfig() + if err != nil { + t.Fatal(err) + } + + expectedUserAgent := version.GetUserAgent() + if restConfig.UserAgent != expectedUserAgent { + t.Errorf("expected User-Agent header %q in K8s REST client config, got %q", expectedUserAgent, restConfig.UserAgent) + } +} + +func resetEnv() func() { + origEnv := os.Environ() + + // ensure any local envvars do not hose us + for e := range New().EnvVars() { + os.Unsetenv(e) + } + + return func() { + for _, pair := range origEnv { + kv := strings.SplitN(pair, "=", 2) + os.Setenv(kv[0], kv[1]) + } + } +} diff --git a/pkg/cli/output/output.go b/pkg/cli/output/output.go new file mode 100644 index 00000000..a46c977a --- /dev/null +++ b/pkg/cli/output/output.go @@ -0,0 +1,140 @@ +/* +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 output + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/gosuri/uitable" + "github.com/pkg/errors" + "sigs.k8s.io/yaml" +) + +// Format is a type for capturing supported output formats +type Format string + +const ( + Table Format = "table" + JSON Format = "json" + YAML Format = "yaml" +) + +// Formats returns a list of the string representation of the supported formats +func Formats() []string { + return []string{Table.String(), JSON.String(), YAML.String()} +} + +// FormatsWithDesc returns a list of the string representation of the supported formats +// including a description +func FormatsWithDesc() map[string]string { + return map[string]string{ + Table.String(): "Output result in human-readable format", + JSON.String(): "Output result in JSON format", + YAML.String(): "Output result in YAML format", + } +} + +// ErrInvalidFormatType is returned when an unsupported format type is used +var ErrInvalidFormatType = fmt.Errorf("invalid format type") + +// String returns the string representation of the Format +func (o Format) String() string { + return string(o) +} + +// Write the output in the given format to the io.Writer. Unsupported formats +// will return an error +func (o Format) Write(out io.Writer, w Writer) error { + switch o { + case Table: + return w.WriteTable(out) + case JSON: + return w.WriteJSON(out) + case YAML: + return w.WriteYAML(out) + } + return ErrInvalidFormatType +} + +// ParseFormat takes a raw string and returns the matching Format. +// If the format does not exists, ErrInvalidFormatType is returned +func ParseFormat(s string) (out Format, err error) { + switch s { + case Table.String(): + out, err = Table, nil + case JSON.String(): + out, err = JSON, nil + case YAML.String(): + out, err = YAML, nil + default: + out, err = "", ErrInvalidFormatType + } + return +} + +// Writer is an interface that any type can implement to write supported formats +type Writer interface { + // WriteTable will write tabular output into the given io.Writer, returning + // an error if any occur + WriteTable(out io.Writer) error + // WriteJSON will write JSON formatted output into the given io.Writer, + // returning an error if any occur + WriteJSON(out io.Writer) error + // WriteYAML will write YAML formatted output into the given io.Writer, + // returning an error if any occur + WriteYAML(out io.Writer) error +} + +// EncodeJSON is a helper function to decorate any error message with a bit more +// context and avoid writing the same code over and over for printers. +func EncodeJSON(out io.Writer, obj interface{}) error { + enc := json.NewEncoder(out) + err := enc.Encode(obj) + if err != nil { + return errors.Wrap(err, "unable to write JSON output") + } + return nil +} + +// EncodeYAML is a helper function to decorate any error message with a bit more +// context and avoid writing the same code over and over for printers +func EncodeYAML(out io.Writer, obj interface{}) error { + raw, err := yaml.Marshal(obj) + if err != nil { + return errors.Wrap(err, "unable to write YAML output") + } + + _, err = out.Write(raw) + if err != nil { + return errors.Wrap(err, "unable to write YAML output") + } + return nil +} + +// EncodeTable is a helper function to decorate any error message with a bit +// more context and avoid writing the same code over and over for printers +func EncodeTable(out io.Writer, table *uitable.Table) error { + raw := table.Bytes() + raw = append(raw, []byte("\n")...) + _, err := out.Write(raw) + if err != nil { + return errors.Wrap(err, "unable to write table output") + } + return nil +} diff --git a/pkg/cli/roundtripper.go b/pkg/cli/roundtripper.go new file mode 100644 index 00000000..9cd4eacb --- /dev/null +++ b/pkg/cli/roundtripper.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 cli + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strings" +) + +type retryingRoundTripper struct { + wrapped http.RoundTripper +} + +func (rt *retryingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return rt.roundTrip(req, 1, nil) +} + +func (rt *retryingRoundTripper) roundTrip(req *http.Request, retry int, prevResp *http.Response) (*http.Response, error) { + if retry < 0 { + return prevResp, nil + } + resp, rtErr := rt.wrapped.RoundTrip(req) + if rtErr != nil { + return resp, rtErr + } + if resp.StatusCode < 500 { + return resp, rtErr + } + if resp.Header.Get("content-type") != "application/json" { + return resp, rtErr + } + b, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return resp, rtErr + } + + var ke kubernetesError + r := bytes.NewReader(b) + err = json.NewDecoder(r).Decode(&ke) + r.Seek(0, io.SeekStart) + resp.Body = io.NopCloser(r) + if err != nil { + return resp, rtErr + } + if ke.Code < 500 { + return resp, rtErr + } + // Matches messages like "etcdserver: leader changed" + if strings.HasSuffix(ke.Message, "etcdserver: leader changed") { + return rt.roundTrip(req, retry-1, resp) + } + // Matches messages like "rpc error: code = Unknown desc = raft proposal dropped" + if strings.HasSuffix(ke.Message, "raft proposal dropped") { + return rt.roundTrip(req, retry-1, resp) + } + return resp, rtErr +} + +type kubernetesError struct { + Message string `json:"message"` + Code int `json:"code"` +} diff --git a/pkg/cli/values/options.go b/pkg/cli/values/options.go new file mode 100644 index 00000000..06631cd3 --- /dev/null +++ b/pkg/cli/values/options.go @@ -0,0 +1,147 @@ +/* +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 values + +import ( + "io" + "net/url" + "os" + "strings" + + "github.com/pkg/errors" + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/strvals" +) + +// Options captures the different ways to specify values +type Options struct { + ValueFiles []string // -f/--values + StringValues []string // --set-string + Values []string // --set + FileValues []string // --set-file + JSONValues []string // --set-json + LiteralValues []string // --set-literal +} + +// MergeValues merges values from files specified via -f/--values and directly +// via --set-json, --set, --set-string, or --set-file, marshaling them to YAML +func (opts *Options) MergeValues(p getter.Providers) (map[string]interface{}, error) { + base := map[string]interface{}{} + + // User specified a values files via -f/--values + for _, filePath := range opts.ValueFiles { + currentMap := map[string]interface{}{} + + bytes, err := readFile(filePath, p) + if err != nil { + return nil, err + } + + if err := yaml.Unmarshal(bytes, ¤tMap); err != nil { + return nil, errors.Wrapf(err, "failed to parse %s", filePath) + } + // Merge with the previous map + base = mergeMaps(base, currentMap) + } + + // User specified a value via --set-json + for _, value := range opts.JSONValues { + if err := strvals.ParseJSON(value, base); err != nil { + return nil, errors.Errorf("failed parsing --set-json data %s", value) + } + } + + // User specified a value via --set + for _, value := range opts.Values { + if err := strvals.ParseInto(value, base); err != nil { + return nil, errors.Wrap(err, "failed parsing --set data") + } + } + + // User specified a value via --set-string + for _, value := range opts.StringValues { + if err := strvals.ParseIntoString(value, base); err != nil { + return nil, errors.Wrap(err, "failed parsing --set-string data") + } + } + + // User specified a value via --set-file + for _, value := range opts.FileValues { + reader := func(rs []rune) (interface{}, error) { + bytes, err := readFile(string(rs), p) + if err != nil { + return nil, err + } + return string(bytes), err + } + if err := strvals.ParseIntoFile(value, base, reader); err != nil { + return nil, errors.Wrap(err, "failed parsing --set-file data") + } + } + + // User specified a value via --set-literal + for _, value := range opts.LiteralValues { + if err := strvals.ParseLiteralInto(value, base); err != nil { + return nil, errors.Wrap(err, "failed parsing --set-literal data") + } + } + + return base, nil +} + +func mergeMaps(a, b map[string]interface{}) map[string]interface{} { + out := make(map[string]interface{}, len(a)) + for k, v := range a { + out[k] = v + } + for k, v := range b { + if v, ok := v.(map[string]interface{}); ok { + if bv, ok := out[k]; ok { + if bv, ok := bv.(map[string]interface{}); ok { + out[k] = mergeMaps(bv, v) + continue + } + } + } + out[k] = v + } + return out +} + +// readFile load a file from stdin, the local directory, or a remote file with a url. +func readFile(filePath string, p getter.Providers) ([]byte, error) { + if strings.TrimSpace(filePath) == "-" { + return io.ReadAll(os.Stdin) + } + u, err := url.Parse(filePath) + if err != nil { + return nil, err + } + + // FIXME: maybe someone handle other protocols like ftp. + g, err := p.ByScheme(u.Scheme) + if err != nil { + return os.ReadFile(filePath) + } + data, err := g.Get(filePath, getter.WithURL(filePath)) + if err != nil { + return nil, err + } + return data.Bytes(), err +} diff --git a/pkg/cli/values/options_test.go b/pkg/cli/values/options_test.go new file mode 100644 index 00000000..54124c0f --- /dev/null +++ b/pkg/cli/values/options_test.go @@ -0,0 +1,88 @@ +/* +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 values + +import ( + "reflect" + "testing" + + "helm.sh/helm/v3/pkg/getter" +) + +func TestMergeValues(t *testing.T) { + nestedMap := map[string]interface{}{ + "foo": "bar", + "baz": map[string]string{ + "cool": "stuff", + }, + } + anotherNestedMap := map[string]interface{}{ + "foo": "bar", + "baz": map[string]string{ + "cool": "things", + "awesome": "stuff", + }, + } + flatMap := map[string]interface{}{ + "foo": "bar", + "baz": "stuff", + } + anotherFlatMap := map[string]interface{}{ + "testing": "fun", + } + + testMap := mergeMaps(flatMap, nestedMap) + equal := reflect.DeepEqual(testMap, nestedMap) + if !equal { + t.Errorf("Expected a nested map to overwrite a flat value. Expected: %v, got %v", nestedMap, testMap) + } + + testMap = mergeMaps(nestedMap, flatMap) + equal = reflect.DeepEqual(testMap, flatMap) + if !equal { + t.Errorf("Expected a flat value to overwrite a map. Expected: %v, got %v", flatMap, testMap) + } + + testMap = mergeMaps(nestedMap, anotherNestedMap) + equal = reflect.DeepEqual(testMap, anotherNestedMap) + if !equal { + t.Errorf("Expected a nested map to overwrite another nested map. Expected: %v, got %v", anotherNestedMap, testMap) + } + + testMap = mergeMaps(anotherFlatMap, anotherNestedMap) + expectedMap := map[string]interface{}{ + "testing": "fun", + "foo": "bar", + "baz": map[string]string{ + "cool": "things", + "awesome": "stuff", + }, + } + equal = reflect.DeepEqual(testMap, expectedMap) + if !equal { + t.Errorf("Expected a map with different keys to merge properly with another map. Expected: %v, got %v", expectedMap, testMap) + } +} + +func TestReadFile(t *testing.T) { + var p getter.Providers + filePath := "%a.txt" + _, err := readFile(filePath, p) + if err == nil { + t.Errorf("Expected error when has special strings") + } +} diff --git a/pkg/downloader/chart_downloader.go b/pkg/downloader/chart_downloader.go new file mode 100644 index 00000000..a95894e0 --- /dev/null +++ b/pkg/downloader/chart_downloader.go @@ -0,0 +1,406 @@ +/* +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 downloader + +import ( + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/pkg/errors" + + "helm.sh/helm/v3/internal/fileutil" + "helm.sh/helm/v3/internal/urlutil" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/helmpath" + "helm.sh/helm/v3/pkg/provenance" + "helm.sh/helm/v3/pkg/registry" + "helm.sh/helm/v3/pkg/repo" +) + +// VerificationStrategy describes a strategy for determining whether to verify a chart. +type VerificationStrategy int + +const ( + // VerifyNever will skip all verification of a chart. + VerifyNever VerificationStrategy = iota + // VerifyIfPossible will attempt a verification, it will not error if verification + // data is missing. But it will not stop processing if verification fails. + VerifyIfPossible + // VerifyAlways will always attempt a verification, and will fail if the + // verification fails. + VerifyAlways + // VerifyLater will fetch verification data, but not do any verification. + // This is to accommodate the case where another step of the process will + // perform verification. + VerifyLater +) + +// ErrNoOwnerRepo indicates that a given chart URL can't be found in any repos. +var ErrNoOwnerRepo = errors.New("could not find a repo containing the given URL") + +// ChartDownloader handles downloading a chart. +// +// It is capable of performing verifications on charts as well. +type ChartDownloader struct { + // Out is the location to write warning and info messages. + Out io.Writer + // Verify indicates what verification strategy to use. + Verify VerificationStrategy + // Keyring is the keyring file used for verification. + Keyring string + // Getter collection for the operation + Getters getter.Providers + // Options provide parameters to be passed along to the Getter being initialized. + Options []getter.Option + RegistryClient *registry.Client + RepositoryConfig string + RepositoryCache string +} + +// DownloadTo retrieves a chart. Depending on the settings, it may also download a provenance file. +// +// If Verify is set to VerifyNever, the verification will be nil. +// If Verify is set to VerifyIfPossible, this will return a verification (or nil on failure), and print a warning on failure. +// If Verify is set to VerifyAlways, this will return a verification or an error if the verification fails. +// If Verify is set to VerifyLater, this will download the prov file (if it exists), but not verify it. +// +// For VerifyNever and VerifyIfPossible, the Verification may be empty. +// +// Returns a string path to the location where the file was downloaded and a verification +// (if provenance was verified), or an error if something bad happened. +func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *provenance.Verification, error) { + u, err := c.ResolveChartVersion(ref, version) + if err != nil { + return "", nil, err + } + + g, err := c.Getters.ByScheme(u.Scheme) + if err != nil { + return "", nil, err + } + + data, err := g.Get(u.String(), c.Options...) + if err != nil { + return "", nil, err + } + + name := filepath.Base(u.Path) + if u.Scheme == registry.OCIScheme { + idx := strings.LastIndexByte(name, ':') + name = fmt.Sprintf("%s-%s.tgz", name[:idx], name[idx+1:]) + } + + destfile := filepath.Join(dest, name) + if err := fileutil.AtomicWriteFile(destfile, data, 0644); err != nil { + return destfile, nil, err + } + + // If provenance is requested, verify it. + ver := &provenance.Verification{} + if c.Verify > VerifyNever { + body, err := g.Get(u.String() + ".prov") + if err != nil { + if c.Verify == VerifyAlways { + return destfile, ver, errors.Errorf("failed to fetch provenance %q", u.String()+".prov") + } + fmt.Fprintf(c.Out, "WARNING: Verification not found for %s: %s\n", ref, err) + return destfile, ver, nil + } + provfile := destfile + ".prov" + if err := fileutil.AtomicWriteFile(provfile, body, 0644); err != nil { + return destfile, nil, err + } + + if c.Verify != VerifyLater { + ver, err = VerifyChart(destfile, c.Keyring) + if err != nil { + // Fail always in this case, since it means the verification step + // failed. + return destfile, ver, err + } + } + } + return destfile, ver, nil +} + +func (c *ChartDownloader) getOciURI(ref, version string, u *url.URL) (*url.URL, error) { + var tag string + var err error + + // Evaluate whether an explicit version has been provided. Otherwise, determine version to use + _, errSemVer := semver.NewVersion(version) + if errSemVer == nil { + tag = version + } else { + // Retrieve list of repository tags + tags, err := c.RegistryClient.Tags(strings.TrimPrefix(ref, fmt.Sprintf("%s://", registry.OCIScheme))) + if err != nil { + return nil, err + } + if len(tags) == 0 { + return nil, errors.Errorf("Unable to locate any tags in provided repository: %s", ref) + } + + // Determine if version provided + // If empty, try to get the highest available tag + // If exact version, try to find it + // If semver constraint string, try to find a match + tag, err = registry.GetTagMatchingVersionOrConstraint(tags, version) + if err != nil { + return nil, err + } + } + + u.Path = fmt.Sprintf("%s:%s", u.Path, tag) + + return u, err +} + +// ResolveChartVersion resolves a chart reference to a URL. +// +// It returns the URL and sets the ChartDownloader's Options that can fetch +// the URL using the appropriate Getter. +// +// A reference may be an HTTP URL, an oci reference URL, a 'reponame/chartname' +// reference, or a local path. +// +// A version is a SemVer string (1.2.3-beta.1+f334a6789). +// +// - For fully qualified URLs, the version will be ignored (since URLs aren't versioned) +// - For a chart reference +// - If version is non-empty, this will return the URL for that version +// - If version is empty, this will return the URL for the latest version +// - If no version can be found, an error is returned +func (c *ChartDownloader) ResolveChartVersion(ref, version string) (*url.URL, error) { + u, err := url.Parse(ref) + if err != nil { + return nil, errors.Errorf("invalid chart URL format: %s", ref) + } + + if registry.IsOCI(u.String()) { + return c.getOciURI(ref, version, u) + } + + rf, err := loadRepoConfig(c.RepositoryConfig) + if err != nil { + return u, err + } + + if u.IsAbs() && len(u.Host) > 0 && len(u.Path) > 0 { + // In this case, we have to find the parent repo that contains this chart + // URL. And this is an unfortunate problem, as it requires actually going + // through each repo cache file and finding a matching URL. But basically + // we want to find the repo in case we have special SSL cert config + // for that repo. + + rc, err := c.scanReposForURL(ref, rf) + if err != nil { + // If there is no special config, return the default HTTP client and + // swallow the error. + if err == ErrNoOwnerRepo { + // Make sure to add the ref URL as the URL for the getter + c.Options = append(c.Options, getter.WithURL(ref)) + return u, nil + } + return u, err + } + + // If we get here, we don't need to go through the next phase of looking + // up the URL. We have it already. So we just set the parameters and return. + c.Options = append( + c.Options, + getter.WithURL(rc.URL), + ) + if rc.CertFile != "" || rc.KeyFile != "" || rc.CAFile != "" { + c.Options = append(c.Options, getter.WithTLSClientConfig(rc.CertFile, rc.KeyFile, rc.CAFile)) + } + if rc.Username != "" && rc.Password != "" { + c.Options = append( + c.Options, + getter.WithBasicAuth(rc.Username, rc.Password), + getter.WithPassCredentialsAll(rc.PassCredentialsAll), + ) + } + return u, nil + } + + // See if it's of the form: repo/path_to_chart + p := strings.SplitN(u.Path, "/", 2) + if len(p) < 2 { + return u, errors.Errorf("non-absolute URLs should be in form of repo_name/path_to_chart, got: %s", u) + } + + repoName := p[0] + chartName := p[1] + rc, err := pickChartRepositoryConfigByName(repoName, rf.Repositories) + + if err != nil { + return u, err + } + + // Now that we have the chart repository information we can use that URL + // to set the URL for the getter. + c.Options = append(c.Options, getter.WithURL(rc.URL)) + + r, err := repo.NewChartRepository(rc, c.Getters) + if err != nil { + return u, err + } + + if r != nil && r.Config != nil { + if r.Config.CertFile != "" || r.Config.KeyFile != "" || r.Config.CAFile != "" { + c.Options = append(c.Options, getter.WithTLSClientConfig(r.Config.CertFile, r.Config.KeyFile, r.Config.CAFile)) + } + if r.Config.Username != "" && r.Config.Password != "" { + c.Options = append(c.Options, + getter.WithBasicAuth(r.Config.Username, r.Config.Password), + getter.WithPassCredentialsAll(r.Config.PassCredentialsAll), + ) + } + } + + // Next, we need to load the index, and actually look up the chart. + idxFile := filepath.Join(c.RepositoryCache, helmpath.CacheIndexFile(r.Config.Name)) + i, err := repo.LoadIndexFile(idxFile) + if err != nil { + return u, errors.Wrap(err, "no cached repo found. (try 'helm repo update')") + } + + cv, err := i.Get(chartName, version) + if err != nil { + return u, errors.Wrapf(err, "chart %q matching %s not found in %s index. (try 'helm repo update')", chartName, version, r.Config.Name) + } + + if len(cv.URLs) == 0 { + return u, errors.Errorf("chart %q has no downloadable URLs", ref) + } + + // TODO: Seems that picking first URL is not fully correct + resolvedURL, err := repo.ResolveReferenceURL(rc.URL, cv.URLs[0]) + + if err != nil { + return u, errors.Errorf("invalid chart URL format: %s", ref) + } + + return url.Parse(resolvedURL) +} + +// VerifyChart takes a path to a chart archive and a keyring, and verifies the chart. +// +// It assumes that a chart archive file is accompanied by a provenance file whose +// name is the archive file name plus the ".prov" extension. +func VerifyChart(path, keyring string) (*provenance.Verification, error) { + // For now, error out if it's not a tar file. + switch fi, err := os.Stat(path); { + case err != nil: + return nil, err + case fi.IsDir(): + return nil, errors.New("unpacked charts cannot be verified") + case !isTar(path): + return nil, errors.New("chart must be a tgz file") + } + + provfile := path + ".prov" + if _, err := os.Stat(provfile); err != nil { + return nil, errors.Wrapf(err, "could not load provenance file %s", provfile) + } + + sig, err := provenance.NewFromKeyring(keyring, "") + if err != nil { + return nil, errors.Wrap(err, "failed to load keyring") + } + return sig.Verify(path, provfile) +} + +// isTar tests whether the given file is a tar file. +// +// Currently, this simply checks extension, since a subsequent function will +// untar the file and validate its binary format. +func isTar(filename string) bool { + return strings.EqualFold(filepath.Ext(filename), ".tgz") +} + +func pickChartRepositoryConfigByName(name string, cfgs []*repo.Entry) (*repo.Entry, error) { + for _, rc := range cfgs { + if rc.Name == name { + if rc.URL == "" { + return nil, errors.Errorf("no URL found for repository %s", name) + } + return rc, nil + } + } + return nil, errors.Errorf("repo %s not found", name) +} + +// scanReposForURL scans all repos to find which repo contains the given URL. +// +// This will attempt to find the given URL in all of the known repositories files. +// +// If the URL is found, this will return the repo entry that contained that URL. +// +// If all of the repos are checked, but the URL is not found, an ErrNoOwnerRepo +// error is returned. +// +// Other errors may be returned when repositories cannot be loaded or searched. +// +// Technically, the fact that a URL is not found in a repo is not a failure indication. +// Charts are not required to be included in an index before they are valid. So +// be mindful of this case. +// +// The same URL can technically exist in two or more repositories. This algorithm +// will return the first one it finds. Order is determined by the order of repositories +// in the repositories.yaml file. +func (c *ChartDownloader) scanReposForURL(u string, rf *repo.File) (*repo.Entry, error) { + // FIXME: This is far from optimal. Larger installations and index files will + // incur a performance hit for this type of scanning. + for _, rc := range rf.Repositories { + r, err := repo.NewChartRepository(rc, c.Getters) + if err != nil { + return nil, err + } + + idxFile := filepath.Join(c.RepositoryCache, helmpath.CacheIndexFile(r.Config.Name)) + i, err := repo.LoadIndexFile(idxFile) + if err != nil { + return nil, errors.Wrap(err, "no cached repo found. (try 'helm repo update')") + } + + for _, entry := range i.Entries { + for _, ver := range entry { + for _, dl := range ver.URLs { + if urlutil.Equal(u, dl) { + return rc, nil + } + } + } + } + } + // This means that there is no repo file for the given URL. + return nil, ErrNoOwnerRepo +} + +func loadRepoConfig(file string) (*repo.File, error) { + r, err := repo.LoadFile(file) + if err != nil && !os.IsNotExist(errors.Cause(err)) { + return nil, err + } + return r, nil +} diff --git a/pkg/downloader/chart_downloader_test.go b/pkg/downloader/chart_downloader_test.go new file mode 100644 index 00000000..131e2130 --- /dev/null +++ b/pkg/downloader/chart_downloader_test.go @@ -0,0 +1,346 @@ +/* +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 downloader + +import ( + "os" + "path/filepath" + "testing" + + "helm.sh/helm/v3/internal/test/ensure" + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/repo" + "helm.sh/helm/v3/pkg/repo/repotest" +) + +const ( + repoConfig = "testdata/repositories.yaml" + repoCache = "testdata/repository" +) + +func TestResolveChartRef(t *testing.T) { + tests := []struct { + name, ref, expect, version string + fail bool + }{ + {name: "full URL", ref: "http://example.com/foo-1.2.3.tgz", expect: "http://example.com/foo-1.2.3.tgz"}, + {name: "full URL, HTTPS", ref: "https://example.com/foo-1.2.3.tgz", expect: "https://example.com/foo-1.2.3.tgz"}, + {name: "full URL, with authentication", ref: "http://username:password@example.com/foo-1.2.3.tgz", expect: "http://username:password@example.com/foo-1.2.3.tgz"}, + {name: "reference, testing repo", ref: "testing/alpine", expect: "http://example.com/alpine-1.2.3.tgz"}, + {name: "reference, version, testing repo", ref: "testing/alpine", version: "0.2.0", expect: "http://example.com/alpine-0.2.0.tgz"}, + {name: "reference, version, malformed repo", ref: "malformed/alpine", version: "1.2.3", expect: "http://dl.example.com/alpine-1.2.3.tgz"}, + {name: "reference, querystring repo", ref: "testing-querystring/alpine", expect: "http://example.com/alpine-1.2.3.tgz?key=value"}, + {name: "reference, testing-relative repo", ref: "testing-relative/foo", expect: "http://example.com/helm/charts/foo-1.2.3.tgz"}, + {name: "reference, testing-relative repo", ref: "testing-relative/bar", expect: "http://example.com/helm/bar-1.2.3.tgz"}, + {name: "reference, testing-relative-trailing-slash repo", ref: "testing-relative-trailing-slash/foo", expect: "http://example.com/helm/charts/foo-1.2.3.tgz"}, + {name: "reference, testing-relative-trailing-slash repo", ref: "testing-relative-trailing-slash/bar", expect: "http://example.com/helm/bar-1.2.3.tgz"}, + {name: "encoded URL", ref: "encoded-url/foobar", expect: "http://example.com/with%2Fslash/charts/foobar-4.2.1.tgz"}, + {name: "full URL, HTTPS, irrelevant version", ref: "https://example.com/foo-1.2.3.tgz", version: "0.1.0", expect: "https://example.com/foo-1.2.3.tgz", fail: true}, + {name: "full URL, file", ref: "file:///foo-1.2.3.tgz", fail: true}, + {name: "invalid", ref: "invalid-1.2.3", fail: true}, + {name: "not found", ref: "nosuchthing/invalid-1.2.3", fail: true}, + } + + c := ChartDownloader{ + Out: os.Stderr, + RepositoryConfig: repoConfig, + RepositoryCache: repoCache, + Getters: getter.All(&cli.EnvSettings{ + RepositoryConfig: repoConfig, + RepositoryCache: repoCache, + }), + } + + for _, tt := range tests { + u, err := c.ResolveChartVersion(tt.ref, tt.version) + if err != nil { + if tt.fail { + continue + } + t.Errorf("%s: failed with error %q", tt.name, err) + continue + } + if got := u.String(); got != tt.expect { + t.Errorf("%s: expected %s, got %s", tt.name, tt.expect, got) + } + } +} + +func TestResolveChartOpts(t *testing.T) { + tests := []struct { + name, ref, version string + expect []getter.Option + }{ + { + name: "repo with CA-file", + ref: "testing-ca-file/foo", + expect: []getter.Option{ + getter.WithURL("https://example.com/foo-1.2.3.tgz"), + getter.WithTLSClientConfig("cert", "key", "ca"), + }, + }, + } + + c := ChartDownloader{ + Out: os.Stderr, + RepositoryConfig: repoConfig, + RepositoryCache: repoCache, + Getters: getter.All(&cli.EnvSettings{ + RepositoryConfig: repoConfig, + RepositoryCache: repoCache, + }), + } + + // snapshot options + snapshotOpts := c.Options + + for _, tt := range tests { + // reset chart downloader options for each test case + c.Options = snapshotOpts + + expect, err := getter.NewHTTPGetter(tt.expect...) + if err != nil { + t.Errorf("%s: failed to setup http client: %s", tt.name, err) + continue + } + + u, err := c.ResolveChartVersion(tt.ref, tt.version) + if err != nil { + t.Errorf("%s: failed with error %s", tt.name, err) + continue + } + + got, err := getter.NewHTTPGetter( + append( + c.Options, + getter.WithURL(u.String()), + )..., + ) + if err != nil { + t.Errorf("%s: failed to create http client: %s", tt.name, err) + continue + } + + if *(got.(*getter.HTTPGetter)) != *(expect.(*getter.HTTPGetter)) { + t.Errorf("%s: expected %s, got %s", tt.name, expect, got) + } + } +} + +func TestVerifyChart(t *testing.T) { + v, err := VerifyChart("testdata/signtest-0.1.0.tgz", "testdata/helm-test-key.pub") + if err != nil { + t.Fatal(err) + } + // The verification is tested at length in the provenance package. Here, + // we just want a quick sanity check that the v is not empty. + if len(v.FileHash) == 0 { + t.Error("Digest missing") + } +} + +func TestIsTar(t *testing.T) { + tests := map[string]bool{ + "foo.tgz": true, + "foo/bar/baz.tgz": true, + "foo-1.2.3.4.5.tgz": true, + "foo.tar.gz": false, // for our purposes + "foo.tgz.1": false, + "footgz": false, + } + + for src, expect := range tests { + if isTar(src) != expect { + t.Errorf("%q should be %t", src, expect) + } + } +} + +func TestDownloadTo(t *testing.T) { + srv := repotest.NewTempServerWithCleanupAndBasicAuth(t, "testdata/*.tgz*") + defer srv.Stop() + if err := srv.CreateIndex(); err != nil { + t.Fatal(err) + } + + if err := srv.LinkIndices(); err != nil { + t.Fatal(err) + } + + c := ChartDownloader{ + Out: os.Stderr, + Verify: VerifyAlways, + Keyring: "testdata/helm-test-key.pub", + RepositoryConfig: repoConfig, + RepositoryCache: repoCache, + Getters: getter.All(&cli.EnvSettings{ + RepositoryConfig: repoConfig, + RepositoryCache: repoCache, + }), + Options: []getter.Option{ + getter.WithBasicAuth("username", "password"), + getter.WithPassCredentialsAll(false), + }, + } + cname := "/signtest-0.1.0.tgz" + dest := srv.Root() + where, v, err := c.DownloadTo(srv.URL()+cname, "", dest) + if err != nil { + t.Fatal(err) + } + + if expect := filepath.Join(dest, cname); where != expect { + t.Errorf("Expected download to %s, got %s", expect, where) + } + + if v.FileHash == "" { + t.Error("File hash was empty, but verification is required.") + } + + if _, err := os.Stat(filepath.Join(dest, cname)); err != nil { + t.Error(err) + } +} + +func TestDownloadTo_TLS(t *testing.T) { + // Set up mock server w/ tls enabled + srv, err := repotest.NewTempServerWithCleanup(t, "testdata/*.tgz*") + srv.Stop() + if err != nil { + t.Fatal(err) + } + srv.StartTLS() + defer srv.Stop() + if err := srv.CreateIndex(); err != nil { + t.Fatal(err) + } + if err := srv.LinkIndices(); err != nil { + t.Fatal(err) + } + + repoConfig := filepath.Join(srv.Root(), "repositories.yaml") + repoCache := srv.Root() + + c := ChartDownloader{ + Out: os.Stderr, + Verify: VerifyAlways, + Keyring: "testdata/helm-test-key.pub", + RepositoryConfig: repoConfig, + RepositoryCache: repoCache, + Getters: getter.All(&cli.EnvSettings{ + RepositoryConfig: repoConfig, + RepositoryCache: repoCache, + }), + Options: []getter.Option{}, + } + cname := "test/signtest" + dest := srv.Root() + where, v, err := c.DownloadTo(cname, "", dest) + if err != nil { + t.Fatal(err) + } + + target := filepath.Join(dest, "signtest-0.1.0.tgz") + if expect := target; where != expect { + t.Errorf("Expected download to %s, got %s", expect, where) + } + + if v.FileHash == "" { + t.Error("File hash was empty, but verification is required.") + } + + if _, err := os.Stat(target); err != nil { + t.Error(err) + } +} + +func TestDownloadTo_VerifyLater(t *testing.T) { + ensure.HelmHome(t) + + dest := t.TempDir() + + // Set up a fake repo + srv, err := repotest.NewTempServerWithCleanup(t, "testdata/*.tgz*") + if err != nil { + t.Fatal(err) + } + defer srv.Stop() + if err := srv.LinkIndices(); err != nil { + t.Fatal(err) + } + + c := ChartDownloader{ + Out: os.Stderr, + Verify: VerifyLater, + RepositoryConfig: repoConfig, + RepositoryCache: repoCache, + Getters: getter.All(&cli.EnvSettings{ + RepositoryConfig: repoConfig, + RepositoryCache: repoCache, + }), + } + cname := "/signtest-0.1.0.tgz" + where, _, err := c.DownloadTo(srv.URL()+cname, "", dest) + if err != nil { + t.Fatal(err) + } + + if expect := filepath.Join(dest, cname); where != expect { + t.Errorf("Expected download to %s, got %s", expect, where) + } + + if _, err := os.Stat(filepath.Join(dest, cname)); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dest, cname+".prov")); err != nil { + t.Fatal(err) + } +} + +func TestScanReposForURL(t *testing.T) { + c := ChartDownloader{ + Out: os.Stderr, + Verify: VerifyLater, + RepositoryConfig: repoConfig, + RepositoryCache: repoCache, + Getters: getter.All(&cli.EnvSettings{ + RepositoryConfig: repoConfig, + RepositoryCache: repoCache, + }), + } + + u := "http://example.com/alpine-0.2.0.tgz" + rf, err := repo.LoadFile(repoConfig) + if err != nil { + t.Fatal(err) + } + + entry, err := c.scanReposForURL(u, rf) + if err != nil { + t.Fatal(err) + } + + if entry.Name != "testing" { + t.Errorf("Unexpected repo %q for URL %q", entry.Name, u) + } + + // A lookup failure should produce an ErrNoOwnerRepo + u = "https://no.such.repo/foo/bar-1.23.4.tgz" + if _, err = c.scanReposForURL(u, rf); err != ErrNoOwnerRepo { + t.Fatalf("expected ErrNoOwnerRepo, got %v", err) + } +} diff --git a/pkg/downloader/doc.go b/pkg/downloader/doc.go new file mode 100644 index 00000000..84846809 --- /dev/null +++ b/pkg/downloader/doc.go @@ -0,0 +1,24 @@ +/* +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 downloader provides a library for downloading charts. + +This package contains various tools for downloading charts from repository +servers, and then storing them in Helm-specific directory structures. This +library contains many functions that depend on a specific +filesystem layout. +*/ +package downloader diff --git a/pkg/downloader/manager.go b/pkg/downloader/manager.go new file mode 100644 index 00000000..68c9c6e0 --- /dev/null +++ b/pkg/downloader/manager.go @@ -0,0 +1,905 @@ +/* +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 downloader + +import ( + "crypto" + "encoding/hex" + "fmt" + "io" + "log" + "net/url" + "os" + "path" + "path/filepath" + "regexp" + "strings" + "sync" + + "github.com/Masterminds/semver/v3" + "github.com/pkg/errors" + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/internal/resolver" + "helm.sh/helm/v3/internal/third_party/dep/fs" + "helm.sh/helm/v3/internal/urlutil" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/helmpath" + "helm.sh/helm/v3/pkg/registry" + "helm.sh/helm/v3/pkg/repo" +) + +// ErrRepoNotFound indicates that chart repositories can't be found in local repo cache. +// The value of Repos is missing repos. +type ErrRepoNotFound struct { + Repos []string +} + +// Error implements the error interface. +func (e ErrRepoNotFound) Error() string { + return fmt.Sprintf("no repository definition for %s", strings.Join(e.Repos, ", ")) +} + +// Manager handles the lifecycle of fetching, resolving, and storing dependencies. +type Manager struct { + // Out is used to print warnings and notifications. + Out io.Writer + // ChartPath is the path to the unpacked base chart upon which this operates. + ChartPath string + // Verification indicates whether the chart should be verified. + Verify VerificationStrategy + // Debug is the global "--debug" flag + Debug bool + // Keyring is the key ring file. + Keyring string + // SkipUpdate indicates that the repository should not be updated first. + SkipUpdate bool + // Getter collection for the operation + Getters []getter.Provider + RegistryClient *registry.Client + RepositoryConfig string + RepositoryCache string +} + +// Build rebuilds a local charts directory from a lockfile. +// +// If the lockfile is not present, this will run a Manager.Update() +// +// If SkipUpdate is set, this will not update the repository. +func (m *Manager) Build() error { + c, err := m.loadChartDir() + if err != nil { + return err + } + + // If a lock file is found, run a build from that. Otherwise, just do + // an update. + lock := c.Lock + if lock == nil { + return m.Update() + } + + // Check that all of the repos we're dependent on actually exist. + req := c.Metadata.Dependencies + + // If using apiVersion v1, calculate the hash before resolve repo names + // because resolveRepoNames will change req if req uses repo alias + // and Helm 2 calculate the digest from the original req + // Fix for: https://github.com/helm/helm/issues/7619 + var v2Sum string + if c.Metadata.APIVersion == chart.APIVersionV1 { + v2Sum, err = resolver.HashV2Req(req) + if err != nil { + return errors.New("the lock file (requirements.lock) is out of sync with the dependencies file (requirements.yaml). Please update the dependencies") + } + } + + if _, err := m.resolveRepoNames(req); err != nil { + return err + } + + if sum, err := resolver.HashReq(req, lock.Dependencies); err != nil || sum != lock.Digest { + // If lock digest differs and chart is apiVersion v1, it maybe because the lock was built + // with Helm 2 and therefore should be checked with Helm v2 hash + // Fix for: https://github.com/helm/helm/issues/7233 + if c.Metadata.APIVersion == chart.APIVersionV1 { + log.Println("warning: a valid Helm v3 hash was not found. Checking against Helm v2 hash...") + if v2Sum != lock.Digest { + return errors.New("the lock file (requirements.lock) is out of sync with the dependencies file (requirements.yaml). Please update the dependencies") + } + } else { + return errors.New("the lock file (Chart.lock) is out of sync with the dependencies file (Chart.yaml). Please update the dependencies") + } + } + + // Check that all of the repos we're dependent on actually exist. + if err := m.hasAllRepos(lock.Dependencies); err != nil { + return err + } + + if !m.SkipUpdate { + // For each repo in the file, update the cached copy of that repo + if err := m.UpdateRepositories(); err != nil { + return err + } + } + + // Now we need to fetch every package here into charts/ + return m.downloadAll(lock.Dependencies) +} + +// Update updates a local charts directory. +// +// It first reads the Chart.yaml file, and then attempts to +// negotiate versions based on that. It will download the versions +// from remote chart repositories unless SkipUpdate is true. +func (m *Manager) Update() error { + c, err := m.loadChartDir() + if err != nil { + return err + } + + // If no dependencies are found, we consider this a successful + // completion. + req := c.Metadata.Dependencies + if req == nil { + return nil + } + + // Get the names of the repositories the dependencies need that Helm is + // configured to know about. + repoNames, err := m.resolveRepoNames(req) + if err != nil { + return err + } + + // For the repositories Helm is not configured to know about, ensure Helm + // has some information about them and, when possible, the index files + // locally. + // TODO(mattfarina): Repositories should be explicitly added by end users + // rather than automattic. In Helm v4 require users to add repositories. They + // should have to add them in order to make sure they are aware of the + // repositories and opt-in to any locations, for security. + repoNames, err = m.ensureMissingRepos(repoNames, req) + if err != nil { + return err + } + + // For each of the repositories Helm is configured to know about, update + // the index information locally. + if !m.SkipUpdate { + if err := m.UpdateRepositories(); err != nil { + return err + } + } + + // Now we need to find out which version of a chart best satisfies the + // dependencies in the Chart.yaml + lock, err := m.resolve(req, repoNames) + if err != nil { + return err + } + + // Now we need to fetch every package here into charts/ + if err := m.downloadAll(lock.Dependencies); err != nil { + return err + } + + // downloadAll might overwrite dependency version, recalculate lock digest + newDigest, err := resolver.HashReq(req, lock.Dependencies) + if err != nil { + return err + } + lock.Digest = newDigest + + // If the lock file hasn't changed, don't write a new one. + oldLock := c.Lock + if oldLock != nil && oldLock.Digest == lock.Digest { + return nil + } + + // Finally, we need to write the lockfile. + return writeLock(m.ChartPath, lock, c.Metadata.APIVersion == chart.APIVersionV1) +} + +func (m *Manager) loadChartDir() (*chart.Chart, error) { + if fi, err := os.Stat(m.ChartPath); err != nil { + return nil, errors.Wrapf(err, "could not find %s", m.ChartPath) + } else if !fi.IsDir() { + return nil, errors.New("only unpacked charts can be updated") + } + return loader.LoadDir(m.ChartPath) +} + +// resolve takes a list of dependencies and translates them into an exact version to download. +// +// This returns a lock file, which has all of the dependencies normalized to a specific version. +func (m *Manager) resolve(req []*chart.Dependency, repoNames map[string]string) (*chart.Lock, error) { + res := resolver.New(m.ChartPath, m.RepositoryCache, m.RegistryClient) + return res.Resolve(req, repoNames) +} + +// downloadAll takes a list of dependencies and downloads them into charts/ +// +// It will delete versions of the chart that exist on disk and might cause +// a conflict. +func (m *Manager) downloadAll(deps []*chart.Dependency) error { + repos, err := m.loadChartRepositories() + if err != nil { + return err + } + + destPath := filepath.Join(m.ChartPath, "charts") + tmpPath := filepath.Join(m.ChartPath, "tmpcharts") + + // Check if 'charts' directory is not actually a directory. If it does not exist, create it. + if fi, err := os.Stat(destPath); err == nil { + if !fi.IsDir() { + return errors.Errorf("%q is not a directory", destPath) + } + } else if os.IsNotExist(err) { + if err := os.MkdirAll(destPath, 0755); err != nil { + return err + } + } else { + return fmt.Errorf("unable to retrieve file info for '%s': %v", destPath, err) + } + + // Prepare tmpPath + if err := os.MkdirAll(tmpPath, 0755); err != nil { + return err + } + defer os.RemoveAll(tmpPath) + + fmt.Fprintf(m.Out, "Saving %d charts\n", len(deps)) + var saveError error + churls := make(map[string]struct{}) + for _, dep := range deps { + // No repository means the chart is in charts directory + if dep.Repository == "" { + fmt.Fprintf(m.Out, "Dependency %s did not declare a repository. Assuming it exists in the charts directory\n", dep.Name) + // NOTE: we are only validating the local dependency conforms to the constraints. No copying to tmpPath is necessary. + chartPath := filepath.Join(destPath, dep.Name) + ch, err := loader.LoadDir(chartPath) + if err != nil { + return fmt.Errorf("unable to load chart '%s': %v", chartPath, err) + } + + constraint, err := semver.NewConstraint(dep.Version) + if err != nil { + return fmt.Errorf("dependency %s has an invalid version/constraint format: %s", dep.Name, err) + } + + v, err := semver.NewVersion(ch.Metadata.Version) + if err != nil { + return fmt.Errorf("invalid version %s for dependency %s: %s", dep.Version, dep.Name, err) + } + + if !constraint.Check(v) { + saveError = fmt.Errorf("dependency %s at version %s does not satisfy the constraint %s", dep.Name, ch.Metadata.Version, dep.Version) + break + } + continue + } + if strings.HasPrefix(dep.Repository, "file://") { + if m.Debug { + fmt.Fprintf(m.Out, "Archiving %s from repo %s\n", dep.Name, dep.Repository) + } + ver, err := tarFromLocalDir(m.ChartPath, dep.Name, dep.Repository, dep.Version, tmpPath) + if err != nil { + saveError = err + break + } + dep.Version = ver + continue + } + + // Any failure to resolve/download a chart should fail: + // https://github.com/helm/helm/issues/1439 + churl, username, password, insecureskiptlsverify, passcredentialsall, caFile, certFile, keyFile, err := m.findChartURL(dep.Name, dep.Version, dep.Repository, repos) + if err != nil { + saveError = errors.Wrapf(err, "could not find %s", churl) + break + } + + if _, ok := churls[churl]; ok { + fmt.Fprintf(m.Out, "Already downloaded %s from repo %s\n", dep.Name, dep.Repository) + continue + } + + fmt.Fprintf(m.Out, "Downloading %s from repo %s\n", dep.Name, dep.Repository) + + dl := ChartDownloader{ + Out: m.Out, + Verify: m.Verify, + Keyring: m.Keyring, + RepositoryConfig: m.RepositoryConfig, + RepositoryCache: m.RepositoryCache, + RegistryClient: m.RegistryClient, + Getters: m.Getters, + Options: []getter.Option{ + getter.WithBasicAuth(username, password), + getter.WithPassCredentialsAll(passcredentialsall), + getter.WithInsecureSkipVerifyTLS(insecureskiptlsverify), + getter.WithTLSClientConfig(certFile, keyFile, caFile), + }, + } + + version := "" + if registry.IsOCI(churl) { + churl, version, err = parseOCIRef(churl) + if err != nil { + return errors.Wrapf(err, "could not parse OCI reference") + } + dl.Options = append(dl.Options, + getter.WithRegistryClient(m.RegistryClient), + getter.WithTagName(version)) + } + + if _, _, err = dl.DownloadTo(churl, version, tmpPath); err != nil { + saveError = errors.Wrapf(err, "could not download %s", churl) + break + } + + churls[churl] = struct{}{} + } + + // TODO: this should probably be refactored to be a []error, so we can capture and provide more information rather than "last error wins". + if saveError == nil { + // now we can move all downloaded charts to destPath and delete outdated dependencies + if err := m.safeMoveDeps(deps, tmpPath, destPath); err != nil { + return err + } + } else { + fmt.Fprintln(m.Out, "Save error occurred: ", saveError) + return saveError + } + return nil +} + +func parseOCIRef(chartRef string) (string, string, error) { + refTagRegexp := regexp.MustCompile(`^(oci://[^:]+(:[0-9]{1,5})?[^:]+):(.*)$`) + caps := refTagRegexp.FindStringSubmatch(chartRef) + if len(caps) != 4 { + return "", "", errors.Errorf("improperly formatted oci chart reference: %s", chartRef) + } + chartRef = caps[1] + tag := caps[3] + + return chartRef, tag, nil +} + +// safeMoveDep moves all dependencies in the source and moves them into dest. +// +// It does this by first matching the file name to an expected pattern, then loading +// the file to verify that it is a chart. +// +// Any charts in dest that do not exist in source are removed (barring local dependencies) +// +// Because it requires tar file introspection, it is more intensive than a basic move. +// +// This will only return errors that should stop processing entirely. Other errors +// will emit log messages or be ignored. +func (m *Manager) safeMoveDeps(deps []*chart.Dependency, source, dest string) error { + existsInSourceDirectory := map[string]bool{} + isLocalDependency := map[string]bool{} + sourceFiles, err := os.ReadDir(source) + if err != nil { + return err + } + // attempt to read destFiles; fail fast if we can't + destFiles, err := os.ReadDir(dest) + if err != nil { + return err + } + + for _, dep := range deps { + if dep.Repository == "" { + isLocalDependency[dep.Name] = true + } + } + + for _, file := range sourceFiles { + if file.IsDir() { + continue + } + filename := file.Name() + sourcefile := filepath.Join(source, filename) + destfile := filepath.Join(dest, filename) + existsInSourceDirectory[filename] = true + if _, err := loader.LoadFile(sourcefile); err != nil { + fmt.Fprintf(m.Out, "Could not verify %s for moving: %s (Skipping)", sourcefile, err) + continue + } + // NOTE: no need to delete the dest; os.Rename replaces it. + if err := fs.RenameWithFallback(sourcefile, destfile); err != nil { + fmt.Fprintf(m.Out, "Unable to move %s to charts dir %s (Skipping)", sourcefile, err) + continue + } + } + + fmt.Fprintln(m.Out, "Deleting outdated charts") + // find all files that exist in dest that do not exist in source; delete them (outdated dependencies) + for _, file := range destFiles { + if !file.IsDir() && !existsInSourceDirectory[file.Name()] { + fname := filepath.Join(dest, file.Name()) + ch, err := loader.LoadFile(fname) + if err != nil { + fmt.Fprintf(m.Out, "Could not verify %s for deletion: %s (Skipping)\n", fname, err) + continue + } + // local dependency - skip + if isLocalDependency[ch.Name()] { + continue + } + if err := os.Remove(fname); err != nil { + fmt.Fprintf(m.Out, "Could not delete %s: %s (Skipping)", fname, err) + continue + } + } + } + + return nil +} + +// hasAllRepos ensures that all of the referenced deps are in the local repo cache. +func (m *Manager) hasAllRepos(deps []*chart.Dependency) error { + rf, err := loadRepoConfig(m.RepositoryConfig) + if err != nil { + return err + } + repos := rf.Repositories + + // Verify that all repositories referenced in the deps are actually known + // by Helm. + missing := []string{} +Loop: + for _, dd := range deps { + // If repo is from local path or OCI, continue + if strings.HasPrefix(dd.Repository, "file://") || registry.IsOCI(dd.Repository) { + continue + } + + if dd.Repository == "" { + continue + } + for _, repo := range repos { + if urlutil.Equal(repo.URL, strings.TrimSuffix(dd.Repository, "/")) { + continue Loop + } + } + missing = append(missing, dd.Repository) + } + if len(missing) > 0 { + return ErrRepoNotFound{missing} + } + return nil +} + +// ensureMissingRepos attempts to ensure the repository information for repos +// not managed by Helm is present. This takes in the repoNames Helm is configured +// to work with along with the chart dependencies. It will find the deps not +// in a known repo and attempt to ensure the data is present for steps like +// version resolution. +func (m *Manager) ensureMissingRepos(repoNames map[string]string, deps []*chart.Dependency) (map[string]string, error) { + + var ru []*repo.Entry + + for _, dd := range deps { + + // If the chart is in the local charts directory no repository needs + // to be specified. + if dd.Repository == "" { + continue + } + + // When the repoName for a dependency is known we can skip ensuring + if _, ok := repoNames[dd.Name]; ok { + continue + } + + // The generated repository name, which will result in an index being + // locally cached, has a name pattern of "helm-manager-" followed by a + // sha256 of the repo name. This assumes end users will never create + // repositories with these names pointing to other repositories. Using + // this method of naming allows the existing repository pulling and + // resolution code to do most of the work. + rn, err := key(dd.Repository) + if err != nil { + return repoNames, err + } + rn = managerKeyPrefix + rn + + repoNames[dd.Name] = rn + + // Assuming the repository is generally available. For Helm managed + // access controls the repository needs to be added through the user + // managed system. This path will work for public charts, like those + // supplied by Bitnami, but not for protected charts, like corp ones + // behind a username and pass. + ri := &repo.Entry{ + Name: rn, + URL: dd.Repository, + } + ru = append(ru, ri) + } + + // Calls to UpdateRepositories (a public function) will only update + // repositories configured by the user. Here we update repos found in + // the dependencies that are not known to the user if update skipping + // is not configured. + if !m.SkipUpdate && len(ru) > 0 { + fmt.Fprintln(m.Out, "Getting updates for unmanaged Helm repositories...") + if err := m.parallelRepoUpdate(ru); err != nil { + return repoNames, err + } + } + + return repoNames, nil +} + +// resolveRepoNames returns the repo names of the referenced deps which can be used to fetch the cached index file +// and replaces aliased repository URLs into resolved URLs in dependencies. +func (m *Manager) resolveRepoNames(deps []*chart.Dependency) (map[string]string, error) { + rf, err := loadRepoConfig(m.RepositoryConfig) + if err != nil { + if os.IsNotExist(err) { + return make(map[string]string), nil + } + return nil, err + } + repos := rf.Repositories + + reposMap := make(map[string]string) + + // Verify that all repositories referenced in the deps are actually known + // by Helm. + missing := []string{} + for _, dd := range deps { + // Don't map the repository, we don't need to download chart from charts directory + if dd.Repository == "" { + continue + } + // if dep chart is from local path, verify the path is valid + if strings.HasPrefix(dd.Repository, "file://") { + if _, err := resolver.GetLocalPath(dd.Repository, m.ChartPath); err != nil { + return nil, err + } + + if m.Debug { + fmt.Fprintf(m.Out, "Repository from local path: %s\n", dd.Repository) + } + reposMap[dd.Name] = dd.Repository + continue + } + + if registry.IsOCI(dd.Repository) { + reposMap[dd.Name] = dd.Repository + continue + } + + found := false + + for _, repo := range repos { + if (strings.HasPrefix(dd.Repository, "@") && strings.TrimPrefix(dd.Repository, "@") == repo.Name) || + (strings.HasPrefix(dd.Repository, "alias:") && strings.TrimPrefix(dd.Repository, "alias:") == repo.Name) { + found = true + dd.Repository = repo.URL + reposMap[dd.Name] = repo.Name + break + } else if urlutil.Equal(repo.URL, dd.Repository) { + found = true + reposMap[dd.Name] = repo.Name + break + } + } + if !found { + repository := dd.Repository + // Add if URL + _, err := url.ParseRequestURI(repository) + if err == nil { + reposMap[repository] = repository + continue + } + missing = append(missing, repository) + } + } + if len(missing) > 0 { + errorMessage := fmt.Sprintf("no repository definition for %s. Please add them via 'helm repo add'", strings.Join(missing, ", ")) + // It is common for people to try to enter "stable" as a repository instead of the actual URL. + // For this case, let's give them a suggestion. + containsNonURL := false + for _, repo := range missing { + if !strings.Contains(repo, "//") && !strings.HasPrefix(repo, "@") && !strings.HasPrefix(repo, "alias:") { + containsNonURL = true + } + } + if containsNonURL { + errorMessage += ` +Note that repositories must be URLs or aliases. For example, to refer to the "example" +repository, use "https://charts.example.com/" or "@example" instead of +"example". Don't forget to add the repo, too ('helm repo add').` + } + return nil, errors.New(errorMessage) + } + return reposMap, nil +} + +// UpdateRepositories updates all of the local repos to the latest. +func (m *Manager) UpdateRepositories() error { + rf, err := loadRepoConfig(m.RepositoryConfig) + if err != nil { + return err + } + repos := rf.Repositories + if len(repos) > 0 { + fmt.Fprintln(m.Out, "Hang tight while we grab the latest from your chart repositories...") + // This prints warnings straight to out. + if err := m.parallelRepoUpdate(repos); err != nil { + return err + } + fmt.Fprintln(m.Out, "Update Complete. ⎈Happy Helming!⎈") + } + return nil +} + +func (m *Manager) parallelRepoUpdate(repos []*repo.Entry) error { + + var wg sync.WaitGroup + for _, c := range repos { + r, err := repo.NewChartRepository(c, m.Getters) + if err != nil { + return err + } + r.CachePath = m.RepositoryCache + wg.Add(1) + go func(r *repo.ChartRepository) { + if _, err := r.DownloadIndexFile(); err != nil { + // For those dependencies that are not known to helm and using a + // generated key name we display the repo url. + if strings.HasPrefix(r.Config.Name, managerKeyPrefix) { + fmt.Fprintf(m.Out, "...Unable to get an update from the %q chart repository:\n\t%s\n", r.Config.URL, err) + } else { + fmt.Fprintf(m.Out, "...Unable to get an update from the %q chart repository (%s):\n\t%s\n", r.Config.Name, r.Config.URL, err) + } + } else { + // For those dependencies that are not known to helm and using a + // generated key name we display the repo url. + if strings.HasPrefix(r.Config.Name, managerKeyPrefix) { + fmt.Fprintf(m.Out, "...Successfully got an update from the %q chart repository\n", r.Config.URL) + } else { + fmt.Fprintf(m.Out, "...Successfully got an update from the %q chart repository\n", r.Config.Name) + } + } + wg.Done() + }(r) + } + wg.Wait() + + return nil +} + +// findChartURL searches the cache of repo data for a chart that has the name and the repoURL specified. +// +// 'name' is the name of the chart. Version is an exact semver, or an empty string. If empty, the +// newest version will be returned. +// +// repoURL is the repository to search +// +// If it finds a URL that is "relative", it will prepend the repoURL. +func (m *Manager) findChartURL(name, version, repoURL string, repos map[string]*repo.ChartRepository) (url, username, password string, insecureskiptlsverify, passcredentialsall bool, caFile, certFile, keyFile string, err error) { + if registry.IsOCI(repoURL) { + return fmt.Sprintf("%s/%s:%s", repoURL, name, version), "", "", false, false, "", "", "", nil + } + + for _, cr := range repos { + + if urlutil.Equal(repoURL, cr.Config.URL) { + var entry repo.ChartVersions + entry, err = findEntryByName(name, cr) + if err != nil { + // TODO: Where linting is skipped in this function we should + // refactor to remove naked returns while ensuring the same + // behavior + //nolint:nakedret + return + } + var ve *repo.ChartVersion + ve, err = findVersionedEntry(version, entry) + if err != nil { + //nolint:nakedret + return + } + url, err = normalizeURL(repoURL, ve.URLs[0]) + if err != nil { + //nolint:nakedret + return + } + username = cr.Config.Username + password = cr.Config.Password + passcredentialsall = cr.Config.PassCredentialsAll + insecureskiptlsverify = cr.Config.InsecureSkipTLSverify + caFile = cr.Config.CAFile + certFile = cr.Config.CertFile + keyFile = cr.Config.KeyFile + //nolint:nakedret + return + } + } + url, err = repo.FindChartInRepoURL(repoURL, name, version, certFile, keyFile, caFile, m.Getters) + if err == nil { + return url, username, password, false, false, "", "", "", err + } + err = errors.Errorf("chart %s not found in %s: %s", name, repoURL, err) + return url, username, password, false, false, "", "", "", err +} + +// findEntryByName finds an entry in the chart repository whose name matches the given name. +// +// It returns the ChartVersions for that entry. +func findEntryByName(name string, cr *repo.ChartRepository) (repo.ChartVersions, error) { + for ename, entry := range cr.IndexFile.Entries { + if ename == name { + return entry, nil + } + } + return nil, errors.New("entry not found") +} + +// findVersionedEntry takes a ChartVersions list and returns a single chart version that satisfies the version constraints. +// +// If version is empty, the first chart found is returned. +func findVersionedEntry(version string, vers repo.ChartVersions) (*repo.ChartVersion, error) { + for _, verEntry := range vers { + if len(verEntry.URLs) == 0 { + // Not a legit entry. + continue + } + + if version == "" || versionEquals(version, verEntry.Version) { + return verEntry, nil + } + } + return nil, errors.New("no matching version") +} + +func versionEquals(v1, v2 string) bool { + sv1, err := semver.NewVersion(v1) + if err != nil { + // Fallback to string comparison. + return v1 == v2 + } + sv2, err := semver.NewVersion(v2) + if err != nil { + return false + } + return sv1.Equal(sv2) +} + +func normalizeURL(baseURL, urlOrPath string) (string, error) { + u, err := url.Parse(urlOrPath) + if err != nil { + return urlOrPath, err + } + if u.IsAbs() { + return u.String(), nil + } + u2, err := url.Parse(baseURL) + if err != nil { + return urlOrPath, errors.Wrap(err, "base URL failed to parse") + } + + u2.RawPath = path.Join(u2.RawPath, urlOrPath) + u2.Path = path.Join(u2.Path, urlOrPath) + return u2.String(), nil +} + +// loadChartRepositories reads the repositories.yaml, and then builds a map of +// ChartRepositories. +// +// The key is the local name (which is only present in the repositories.yaml). +func (m *Manager) loadChartRepositories() (map[string]*repo.ChartRepository, error) { + indices := map[string]*repo.ChartRepository{} + + // Load repositories.yaml file + rf, err := loadRepoConfig(m.RepositoryConfig) + if err != nil { + return indices, errors.Wrapf(err, "failed to load %s", m.RepositoryConfig) + } + + for _, re := range rf.Repositories { + lname := re.Name + idxFile := filepath.Join(m.RepositoryCache, helmpath.CacheIndexFile(lname)) + index, err := repo.LoadIndexFile(idxFile) + if err != nil { + return indices, err + } + + // TODO: use constructor + cr := &repo.ChartRepository{ + Config: re, + IndexFile: index, + } + indices[lname] = cr + } + return indices, nil +} + +// writeLock writes a lockfile to disk +func writeLock(chartpath string, lock *chart.Lock, legacyLockfile bool) error { + data, err := yaml.Marshal(lock) + if err != nil { + return err + } + lockfileName := "Chart.lock" + if legacyLockfile { + lockfileName = "requirements.lock" + } + dest := filepath.Join(chartpath, lockfileName) + return os.WriteFile(dest, data, 0644) +} + +// archive a dep chart from local directory and save it into destPath +func tarFromLocalDir(chartpath, name, repo, version, destPath string) (string, error) { + if !strings.HasPrefix(repo, "file://") { + return "", errors.Errorf("wrong format: chart %s repository %s", name, repo) + } + + origPath, err := resolver.GetLocalPath(repo, chartpath) + if err != nil { + return "", err + } + + ch, err := loader.LoadDir(origPath) + if err != nil { + return "", err + } + + constraint, err := semver.NewConstraint(version) + if err != nil { + return "", errors.Wrapf(err, "dependency %s has an invalid version/constraint format", name) + } + + v, err := semver.NewVersion(ch.Metadata.Version) + if err != nil { + return "", err + } + + if constraint.Check(v) { + _, err = chartutil.Save(ch, destPath) + return ch.Metadata.Version, err + } + + return "", errors.Errorf("can't get a valid version for dependency %s", name) +} + +// The prefix to use for cache keys created by the manager for repo names +const managerKeyPrefix = "helm-manager-" + +// key is used to turn a name, such as a repository url, into a filesystem +// safe name that is unique for querying. To accomplish this a unique hash of +// the string is used. +func key(name string) (string, error) { + in := strings.NewReader(name) + hash := crypto.SHA256.New() + if _, err := io.Copy(hash, in); err != nil { + return "", nil + } + return hex.EncodeToString(hash.Sum(nil)), nil +} diff --git a/pkg/downloader/manager_test.go b/pkg/downloader/manager_test.go new file mode 100644 index 00000000..db2487d1 --- /dev/null +++ b/pkg/downloader/manager_test.go @@ -0,0 +1,600 @@ +/* +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 downloader + +import ( + "bytes" + "os" + "path/filepath" + "reflect" + "testing" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/repo/repotest" +) + +func TestVersionEquals(t *testing.T) { + tests := []struct { + name, v1, v2 string + expect bool + }{ + {name: "semver match", v1: "1.2.3-beta.11", v2: "1.2.3-beta.11", expect: true}, + {name: "semver match, build info", v1: "1.2.3-beta.11+a", v2: "1.2.3-beta.11+b", expect: true}, + {name: "string match", v1: "abcdef123", v2: "abcdef123", expect: true}, + {name: "semver mismatch", v1: "1.2.3-beta.11", v2: "1.2.3-beta.22", expect: false}, + {name: "semver mismatch, invalid semver", v1: "1.2.3-beta.11", v2: "stinkycheese", expect: false}, + } + + for _, tt := range tests { + if versionEquals(tt.v1, tt.v2) != tt.expect { + t.Errorf("%s: failed comparison of %q and %q (expect equal: %t)", tt.name, tt.v1, tt.v2, tt.expect) + } + } +} + +func TestNormalizeURL(t *testing.T) { + tests := []struct { + name, base, path, expect string + }{ + {name: "basic URL", base: "https://example.com", path: "http://helm.sh/foo", expect: "http://helm.sh/foo"}, + {name: "relative path", base: "https://helm.sh/charts", path: "foo", expect: "https://helm.sh/charts/foo"}, + {name: "Encoded path", base: "https://helm.sh/a%2Fb/charts", path: "foo", expect: "https://helm.sh/a%2Fb/charts/foo"}, + } + + for _, tt := range tests { + got, err := normalizeURL(tt.base, tt.path) + if err != nil { + t.Errorf("%s: error %s", tt.name, err) + continue + } else if got != tt.expect { + t.Errorf("%s: expected %q, got %q", tt.name, tt.expect, got) + } + } +} + +func TestFindChartURL(t *testing.T) { + var b bytes.Buffer + m := &Manager{ + Out: &b, + RepositoryConfig: repoConfig, + RepositoryCache: repoCache, + } + repos, err := m.loadChartRepositories() + if err != nil { + t.Fatal(err) + } + + name := "alpine" + version := "0.1.0" + repoURL := "http://example.com/charts" + + churl, username, password, insecureSkipTLSVerify, passcredentialsall, _, _, _, err := m.findChartURL(name, version, repoURL, repos) + if err != nil { + t.Fatal(err) + } + + if churl != "https://charts.helm.sh/stable/alpine-0.1.0.tgz" { + t.Errorf("Unexpected URL %q", churl) + } + if username != "" { + t.Errorf("Unexpected username %q", username) + } + if password != "" { + t.Errorf("Unexpected password %q", password) + } + if passcredentialsall != false { + t.Errorf("Unexpected passcredentialsall %t", passcredentialsall) + } + if insecureSkipTLSVerify { + t.Errorf("Unexpected insecureSkipTLSVerify %t", insecureSkipTLSVerify) + } + + name = "tlsfoo" + version = "1.2.3" + repoURL = "https://example-https-insecureskiptlsverify.com" + + churl, username, password, insecureSkipTLSVerify, passcredentialsall, _, _, _, err = m.findChartURL(name, version, repoURL, repos) + if err != nil { + t.Fatal(err) + } + + if !insecureSkipTLSVerify { + t.Errorf("Unexpected insecureSkipTLSVerify %t", insecureSkipTLSVerify) + } + if churl != "https://example.com/tlsfoo-1.2.3.tgz" { + t.Errorf("Unexpected URL %q", churl) + } + if username != "" { + t.Errorf("Unexpected username %q", username) + } + if password != "" { + t.Errorf("Unexpected password %q", password) + } + if passcredentialsall != false { + t.Errorf("Unexpected passcredentialsall %t", passcredentialsall) + } +} + +func TestGetRepoNames(t *testing.T) { + b := bytes.NewBuffer(nil) + m := &Manager{ + Out: b, + RepositoryConfig: repoConfig, + RepositoryCache: repoCache, + } + tests := []struct { + name string + req []*chart.Dependency + expect map[string]string + err bool + }{ + { + name: "no repo definition, but references a url", + req: []*chart.Dependency{ + {Name: "oedipus-rex", Repository: "http://example.com/test"}, + }, + expect: map[string]string{"http://example.com/test": "http://example.com/test"}, + }, + { + name: "no repo definition failure -- stable repo", + req: []*chart.Dependency{ + {Name: "oedipus-rex", Repository: "stable"}, + }, + err: true, + }, + { + name: "no repo definition failure", + req: []*chart.Dependency{ + {Name: "oedipus-rex", Repository: "http://example.com"}, + }, + expect: map[string]string{"oedipus-rex": "testing"}, + }, + { + name: "repo from local path", + req: []*chart.Dependency{ + {Name: "local-dep", Repository: "file://./testdata/signtest"}, + }, + expect: map[string]string{"local-dep": "file://./testdata/signtest"}, + }, + { + name: "repo alias (alias:)", + req: []*chart.Dependency{ + {Name: "oedipus-rex", Repository: "alias:testing"}, + }, + expect: map[string]string{"oedipus-rex": "testing"}, + }, + { + name: "repo alias (@)", + req: []*chart.Dependency{ + {Name: "oedipus-rex", Repository: "@testing"}, + }, + expect: map[string]string{"oedipus-rex": "testing"}, + }, + { + name: "repo from local chart under charts path", + req: []*chart.Dependency{ + {Name: "local-subchart", Repository: ""}, + }, + expect: map[string]string{}, + }, + } + + for _, tt := range tests { + l, err := m.resolveRepoNames(tt.req) + if err != nil { + if tt.err { + continue + } + t.Fatal(err) + } + + if tt.err { + t.Fatalf("Expected error in test %q", tt.name) + } + + // m1 and m2 are the maps we want to compare + eq := reflect.DeepEqual(l, tt.expect) + if !eq { + t.Errorf("%s: expected map %v, got %v", tt.name, l, tt.name) + } + } +} + +func TestDownloadAll(t *testing.T) { + chartPath := t.TempDir() + m := &Manager{ + Out: new(bytes.Buffer), + RepositoryConfig: repoConfig, + RepositoryCache: repoCache, + ChartPath: chartPath, + } + signtest, err := loader.LoadDir(filepath.Join("testdata", "signtest")) + if err != nil { + t.Fatal(err) + } + if err := chartutil.SaveDir(signtest, filepath.Join(chartPath, "testdata")); err != nil { + t.Fatal(err) + } + + local, err := loader.LoadDir(filepath.Join("testdata", "local-subchart")) + if err != nil { + t.Fatal(err) + } + if err := chartutil.SaveDir(local, filepath.Join(chartPath, "charts")); err != nil { + t.Fatal(err) + } + + signDep := &chart.Dependency{ + Name: signtest.Name(), + Repository: "file://./testdata/signtest", + Version: signtest.Metadata.Version, + } + localDep := &chart.Dependency{ + Name: local.Name(), + Repository: "", + Version: local.Metadata.Version, + } + + // create a 'tmpcharts' directory to test #5567 + if err := os.MkdirAll(filepath.Join(chartPath, "tmpcharts"), 0755); err != nil { + t.Fatal(err) + } + if err := m.downloadAll([]*chart.Dependency{signDep, localDep}); err != nil { + t.Error(err) + } + + if _, err := os.Stat(filepath.Join(chartPath, "charts", "signtest-0.1.0.tgz")); os.IsNotExist(err) { + t.Error(err) + } + + // A chart with a bad name like this cannot be loaded and saved. Handling in + // the loading and saving will return an error about the invalid name. In + // this case, the chart needs to be created directly. + badchartyaml := `apiVersion: v2 +description: A Helm chart for Kubernetes +name: ../bad-local-subchart +version: 0.1.0` + if err := os.MkdirAll(filepath.Join(chartPath, "testdata", "bad-local-subchart"), 0755); err != nil { + t.Fatal(err) + } + err = os.WriteFile(filepath.Join(chartPath, "testdata", "bad-local-subchart", "Chart.yaml"), []byte(badchartyaml), 0644) + if err != nil { + t.Fatal(err) + } + + badLocalDep := &chart.Dependency{ + Name: "../bad-local-subchart", + Repository: "file://./testdata/bad-local-subchart", + Version: "0.1.0", + } + + err = m.downloadAll([]*chart.Dependency{badLocalDep}) + if err == nil { + t.Fatal("Expected error for bad dependency name") + } +} + +func TestUpdateBeforeBuild(t *testing.T) { + // Set up a fake repo + srv, err := repotest.NewTempServerWithCleanup(t, "testdata/*.tgz*") + if err != nil { + t.Fatal(err) + } + defer srv.Stop() + if err := srv.LinkIndices(); err != nil { + t.Fatal(err) + } + dir := func(p ...string) string { + return filepath.Join(append([]string{srv.Root()}, p...)...) + } + + // Save dep + d := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "dep-chart", + Version: "0.1.0", + APIVersion: "v1", + }, + } + if err := chartutil.SaveDir(d, dir()); err != nil { + t.Fatal(err) + } + // Save a chart + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "with-dependency", + Version: "0.1.0", + APIVersion: "v2", + Dependencies: []*chart.Dependency{{ + Name: d.Metadata.Name, + Version: ">=0.1.0", + Repository: "file://../dep-chart", + }}, + }, + } + if err := chartutil.SaveDir(c, dir()); err != nil { + t.Fatal(err) + } + + // Set-up a manager + b := bytes.NewBuffer(nil) + g := getter.Providers{getter.Provider{ + Schemes: []string{"http", "https"}, + New: getter.NewHTTPGetter, + }} + m := &Manager{ + ChartPath: dir(c.Metadata.Name), + Out: b, + Getters: g, + RepositoryConfig: dir("repositories.yaml"), + RepositoryCache: dir(), + } + + // Update before Build. see issue: https://github.com/helm/helm/issues/7101 + err = m.Update() + if err != nil { + t.Fatal(err) + } + + err = m.Build() + if err != nil { + t.Fatal(err) + } +} + +// TestUpdateWithNoRepo is for the case of a dependency that has no repo listed. +// This happens when the dependency is in the charts directory and does not need +// to be fetched. +func TestUpdateWithNoRepo(t *testing.T) { + // Set up a fake repo + srv, err := repotest.NewTempServerWithCleanup(t, "testdata/*.tgz*") + if err != nil { + t.Fatal(err) + } + defer srv.Stop() + if err := srv.LinkIndices(); err != nil { + t.Fatal(err) + } + dir := func(p ...string) string { + return filepath.Join(append([]string{srv.Root()}, p...)...) + } + + // Setup the dependent chart + d := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "dep-chart", + Version: "0.1.0", + APIVersion: "v1", + }, + } + + // Save a chart with the dependency + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "with-dependency", + Version: "0.1.0", + APIVersion: "v2", + Dependencies: []*chart.Dependency{{ + Name: d.Metadata.Name, + Version: "0.1.0", + }}, + }, + } + if err := chartutil.SaveDir(c, dir()); err != nil { + t.Fatal(err) + } + + // Save dependent chart into the parents charts directory. If the chart is + // not in the charts directory Helm will return an error that it is not + // found. + if err := chartutil.SaveDir(d, dir(c.Metadata.Name, "charts")); err != nil { + t.Fatal(err) + } + + // Set-up a manager + b := bytes.NewBuffer(nil) + g := getter.Providers{getter.Provider{ + Schemes: []string{"http", "https"}, + New: getter.NewHTTPGetter, + }} + m := &Manager{ + ChartPath: dir(c.Metadata.Name), + Out: b, + Getters: g, + RepositoryConfig: dir("repositories.yaml"), + RepositoryCache: dir(), + } + + // Test the update + err = m.Update() + if err != nil { + t.Fatal(err) + } +} + +// This function is the skeleton test code of failing tests for #6416 and #6871 and bugs due to #5874. +// +// This function is used by below tests that ensures success of build operation +// with optional fields, alias, condition, tags, and even with ranged version. +// Parent chart includes local-subchart 0.1.0 subchart from a fake repository, by default. +// If each of these main fields (name, version, repository) is not supplied by dep param, default value will be used. +func checkBuildWithOptionalFields(t *testing.T, chartName string, dep chart.Dependency) { + // Set up a fake repo + srv, err := repotest.NewTempServerWithCleanup(t, "testdata/*.tgz*") + if err != nil { + t.Fatal(err) + } + defer srv.Stop() + if err := srv.LinkIndices(); err != nil { + t.Fatal(err) + } + dir := func(p ...string) string { + return filepath.Join(append([]string{srv.Root()}, p...)...) + } + + // Set main fields if not exist + if dep.Name == "" { + dep.Name = "local-subchart" + } + if dep.Version == "" { + dep.Version = "0.1.0" + } + if dep.Repository == "" { + dep.Repository = srv.URL() + } + + // Save a chart + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: chartName, + Version: "0.1.0", + APIVersion: "v2", + Dependencies: []*chart.Dependency{&dep}, + }, + } + if err := chartutil.SaveDir(c, dir()); err != nil { + t.Fatal(err) + } + + // Set-up a manager + b := bytes.NewBuffer(nil) + g := getter.Providers{getter.Provider{ + Schemes: []string{"http", "https"}, + New: getter.NewHTTPGetter, + }} + m := &Manager{ + ChartPath: dir(chartName), + Out: b, + Getters: g, + RepositoryConfig: dir("repositories.yaml"), + RepositoryCache: dir(), + } + + // First build will update dependencies and create Chart.lock file. + err = m.Build() + if err != nil { + t.Fatal(err) + } + + // Second build should be passed. See PR #6655. + err = m.Build() + if err != nil { + t.Fatal(err) + } +} + +func TestBuild_WithoutOptionalFields(t *testing.T) { + // Dependency has main fields only (name/version/repository) + checkBuildWithOptionalFields(t, "without-optional-fields", chart.Dependency{}) +} + +func TestBuild_WithSemVerRange(t *testing.T) { + // Dependency version is the form of SemVer range + checkBuildWithOptionalFields(t, "with-semver-range", chart.Dependency{ + Version: ">=0.1.0", + }) +} + +func TestBuild_WithAlias(t *testing.T) { + // Dependency has an alias + checkBuildWithOptionalFields(t, "with-alias", chart.Dependency{ + Alias: "local-subchart-alias", + }) +} + +func TestBuild_WithCondition(t *testing.T) { + // Dependency has a condition + checkBuildWithOptionalFields(t, "with-condition", chart.Dependency{ + Condition: "some.condition", + }) +} + +func TestBuild_WithTags(t *testing.T) { + // Dependency has several tags + checkBuildWithOptionalFields(t, "with-tags", chart.Dependency{ + Tags: []string{"tag1", "tag2"}, + }) +} + +// Failing test for #6871 +func TestBuild_WithRepositoryAlias(t *testing.T) { + // Dependency repository is aliased in Chart.yaml + checkBuildWithOptionalFields(t, "with-repository-alias", chart.Dependency{ + Repository: "@test", + }) +} + +func TestErrRepoNotFound_Error(t *testing.T) { + type fields struct { + Repos []string + } + tests := []struct { + name string + fields fields + want string + }{ + { + name: "OK", + fields: fields{ + Repos: []string{"https://charts1.example.com", "https://charts2.example.com"}, + }, + want: "no repository definition for https://charts1.example.com, https://charts2.example.com", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := ErrRepoNotFound{ + Repos: tt.fields.Repos, + } + if got := e.Error(); got != tt.want { + t.Errorf("Error() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestKey(t *testing.T) { + tests := []struct { + name string + expect string + }{ + { + name: "file:////tmp", + expect: "afeed3459e92a874f6373aca264ce1459bfa91f9c1d6612f10ae3dc2ee955df3", + }, + { + name: "https://example.com/charts", + expect: "7065c57c94b2411ad774638d76823c7ccb56415441f5ab2f5ece2f3845728e5d", + }, + { + name: "foo/bar/baz", + expect: "15c46a4f8a189ae22f36f201048881d6c090c93583bedcf71f5443fdef224c82", + }, + } + + for _, tt := range tests { + o, err := key(tt.name) + if err != nil { + t.Fatalf("unable to generate key for %q with error: %s", tt.name, err) + } + if o != tt.expect { + t.Errorf("wrong key name generated for %q, expected %q but got %q", tt.name, tt.expect, o) + } + } +} diff --git a/pkg/downloader/testdata/helm-test-key.pub b/pkg/downloader/testdata/helm-test-key.pub new file mode 100644 index 0000000000000000000000000000000000000000..38714f25adaf701b08e11fd559a587074bbde0e4 GIT binary patch literal 1243 zcmV<11SI>J0SyFKmTjH^2mr{k15wFPQdpTAAEclY4oV^-7nTy5x$&xF;PC4il}o-Pk4@#$knlp#(|J0GE?qli_lr;7-o zyY8vBsN_GJe;#w<`JdR7riNL&RJlcS)FG+W=91;dYS6NZ2tY?kZ8Sw9{r=e4|L3E{ zRod|EPC!PWgW&pe&4qiqKQAijj;G~fyjcC^m%0p54Tn{h%YFKd0VC4n=#~SRc@BVd znj66*)Om%*SEQfX{1*Tb0RRECT}WkYZ6H)-b98BLXCNq4XlZjGYh`&Lb7*gMY-AvB zZftoVVr3w8b7f>8W^ZyJbY*jNX>MmOAVg0fPES-IR8mz_R4yqXJZNQXZ7p6uVakV zT7GGV$jaKjyjfI_a~N1!Hk?5C$0wa&4)R=i$v7t&ZMycW#RkavpF%A?>MTT2anNDzOQUm<++zEOykJ9-@&c2QXq3owqf7fek`=L@+7iF zv;IW2Q>Q&r+V@cWDF&hAUUsCKlDinerKgvJUJCl$5gjb7NhM{mBP%!M^mX-iS8xFf zuLB{@MDqvtZzF#Bxd9CXSC(y_0SExW>8~h=U8|!do4*OJj2u#!KDe3v+1T+aVzU5di=Ji2)x37y$|Z2?YXImTjH_8w>yn2@r%kznCAv zhhp0`2mpxVj5j%o&5i)?`r7iES|8dA@p2kk@+XS(tjBGN)6>tm^=gayCn`gTEC*K74Y~{I_PREk) z)PstIMx1RxB@cK8%Mey%;nVnKriAKUk2Ky?dBMG3uXItKL$3N(#3P^pQa*K$l)wUy F^>pMLK0g2e literal 0 HcmV?d00001 diff --git a/pkg/downloader/testdata/helm-test-key.secret b/pkg/downloader/testdata/helm-test-key.secret new file mode 100644 index 0000000000000000000000000000000000000000..a966aef93ed97d01d764f29940738df6df2d9d24 GIT binary patch literal 2545 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 literal 0 HcmV?d00001 diff --git a/pkg/downloader/testdata/signtest-0.1.0.tgz.prov b/pkg/downloader/testdata/signtest-0.1.0.tgz.prov new file mode 100644 index 00000000..d325bb26 --- /dev/null +++ b/pkg/downloader/testdata/signtest-0.1.0.tgz.prov @@ -0,0 +1,21 @@ +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA512 + +apiVersion: v1 +description: A Helm chart for Kubernetes +name: signtest +version: 0.1.0 + +... +files: + signtest-0.1.0.tgz: sha256:e5ef611620fb97704d8751c16bab17fedb68883bfb0edc76f78a70e9173f9b55 +-----BEGIN PGP SIGNATURE----- + +wsBcBAEBCgAQBQJcoosfCRCEO7+YH8GHYgAA220IALAs8T8NPgkcLvHu+5109cAN +BOCNPSZDNsqLZW/2Dc9cKoBG7Jen4Qad+i5l9351kqn3D9Gm6eRfAWcjfggRobV/ +9daZ19h0nl4O1muQNAkjvdgZt8MOP3+PB3I3/Tu2QCYjI579SLUmuXlcZR5BCFPR +PJy+e3QpV2PcdeU2KZLG4tjtlrq+3QC9ZHHEJLs+BVN9d46Dwo6CxJdHJrrrAkTw +M8MhA92vbiTTPRSCZI9x5qDAwJYhoq0oxLflpuL2tIlo3qVoCsaTSURwMESEHO32 +XwYG7BaVDMELWhAorBAGBGBwWFbJ1677qQ2gd9CN0COiVhekWlFRcnn60800r84= +=k9Y9 +-----END PGP SIGNATURE----- \ No newline at end of file diff --git a/pkg/downloader/testdata/signtest/.helmignore b/pkg/downloader/testdata/signtest/.helmignore new file mode 100644 index 00000000..435b756d --- /dev/null +++ b/pkg/downloader/testdata/signtest/.helmignore @@ -0,0 +1,5 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +.git diff --git a/pkg/downloader/testdata/signtest/Chart.yaml b/pkg/downloader/testdata/signtest/Chart.yaml new file mode 100644 index 00000000..f1f73723 --- /dev/null +++ b/pkg/downloader/testdata/signtest/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: A Helm chart for Kubernetes +name: signtest +version: 0.1.0 diff --git a/pkg/downloader/testdata/signtest/alpine/Chart.yaml b/pkg/downloader/testdata/signtest/alpine/Chart.yaml new file mode 100644 index 00000000..eec26122 --- /dev/null +++ b/pkg/downloader/testdata/signtest/alpine/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +description: Deploy a basic Alpine Linux pod +home: https://helm.sh/helm +name: alpine +sources: +- https://github.com/helm/helm +version: 0.1.0 diff --git a/pkg/downloader/testdata/signtest/alpine/README.md b/pkg/downloader/testdata/signtest/alpine/README.md new file mode 100644 index 00000000..28bebae0 --- /dev/null +++ b/pkg/downloader/testdata/signtest/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.yaml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/downloader/testdata/signtest/alpine/templates/alpine-pod.yaml b/pkg/downloader/testdata/signtest/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..5bbae10a --- /dev/null +++ b/pkg/downloader/testdata/signtest/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service}} + chartName: {{.Chart.Name}} + chartVersion: {{.Chart.Version | quote}} +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.3" + command: ["/bin/sleep","9000"] diff --git a/pkg/downloader/testdata/signtest/alpine/values.yaml b/pkg/downloader/testdata/signtest/alpine/values.yaml new file mode 100644 index 00000000..bb6c06ae --- /dev/null +++ b/pkg/downloader/testdata/signtest/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: my-alpine diff --git a/pkg/downloader/testdata/signtest/templates/pod.yaml b/pkg/downloader/testdata/signtest/templates/pod.yaml new file mode 100644 index 00000000..9b00ccaf --- /dev/null +++ b/pkg/downloader/testdata/signtest/templates/pod.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Pod +metadata: + name: signtest +spec: + restartPolicy: Never + containers: + - name: waiter + image: "alpine:3.3" + command: ["/bin/sleep","9000"] diff --git a/pkg/downloader/testdata/signtest/values.yaml b/pkg/downloader/testdata/signtest/values.yaml new file mode 100644 index 00000000..e69de29b diff --git a/pkg/engine/doc.go b/pkg/engine/doc.go new file mode 100644 index 00000000..6b3443aa --- /dev/null +++ b/pkg/engine/doc.go @@ -0,0 +1,24 @@ +/* +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 engine implements the Go text template engine as needed for Helm. + +When Helm renders templates it does so with additional functions and different +modes (e.g., strict, lint mode). This package handles the helm specific +implementation. +*/ +package engine // import "helm.sh/helm/v3/pkg/engine" diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go new file mode 100644 index 00000000..61c0782f --- /dev/null +++ b/pkg/engine/engine.go @@ -0,0 +1,441 @@ +/* +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 engine + +import ( + "fmt" + "log" + "path" + "path/filepath" + "regexp" + "sort" + "strings" + "text/template" + + "github.com/pkg/errors" + "k8s.io/client-go/rest" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chartutil" +) + +// Engine is an implementation of the Helm rendering implementation for templates. +type Engine struct { + // If strict is enabled, template rendering will fail if a template references + // a value that was not passed in. + Strict bool + // In LintMode, some 'required' template values may be missing, so don't fail + LintMode bool + // optional provider of clients to talk to the Kubernetes API + clientProvider *ClientProvider + // EnableDNS tells the engine to allow DNS lookups when rendering templates + EnableDNS bool +} + +// New creates a new instance of Engine using the passed in rest config. +func New(config *rest.Config) Engine { + var clientProvider ClientProvider = clientProviderFromConfig{config} + return Engine{ + clientProvider: &clientProvider, + } +} + +// Render takes a chart, optional values, and value overrides, and attempts to render the Go templates. +// +// Render can be called repeatedly on the same engine. +// +// This will look in the chart's 'templates' data (e.g. the 'templates/' directory) +// and attempt to render the templates there using the values passed in. +// +// Values are scoped to their templates. A dependency template will not have +// access to the values set for its parent. If chart "foo" includes chart "bar", +// "bar" will not have access to the values for "foo". +// +// Values should be prepared with something like `chartutils.ReadValues`. +// +// Values are passed through the templates according to scope. If the top layer +// chart includes the chart foo, which includes the chart bar, the values map +// will be examined for a table called "foo". If "foo" is found in vals, +// that section of the values will be passed into the "foo" chart. And if that +// section contains a value named "bar", that value will be passed on to the +// bar chart during render time. +func (e Engine) Render(chrt *chart.Chart, values chartutil.Values) (map[string]string, error) { + tmap := allTemplates(chrt, values) + return e.render(tmap) +} + +// Render takes a chart, optional values, and value overrides, and attempts to +// render the Go templates using the default options. +func Render(chrt *chart.Chart, values chartutil.Values) (map[string]string, error) { + return new(Engine).Render(chrt, values) +} + +// RenderWithClient takes a chart, optional values, and value overrides, and attempts to +// render the Go templates using the default options. This engine is client aware and so can have template +// functions that interact with the client. +func RenderWithClient(chrt *chart.Chart, values chartutil.Values, config *rest.Config) (map[string]string, error) { + var clientProvider ClientProvider = clientProviderFromConfig{config} + return Engine{ + clientProvider: &clientProvider, + }.Render(chrt, values) +} + +// RenderWithClientProvider takes a chart, optional values, and value overrides, and attempts to +// render the Go templates using the default options. This engine is client aware and so can have template +// functions that interact with the client. +// This function differs from RenderWithClient in that it lets you customize the way a dynamic client is constructed. +func RenderWithClientProvider(chrt *chart.Chart, values chartutil.Values, clientProvider ClientProvider) (map[string]string, error) { + return Engine{ + clientProvider: &clientProvider, + }.Render(chrt, values) +} + +// renderable is an object that can be rendered. +type renderable struct { + // tpl is the current template. + tpl string + // vals are the values to be supplied to the template. + vals chartutil.Values + // namespace prefix to the templates of the current chart + basePath string +} + +const warnStartDelim = "HELM_ERR_START" +const warnEndDelim = "HELM_ERR_END" +const recursionMaxNums = 1000 + +var warnRegex = regexp.MustCompile(warnStartDelim + `((?s).*)` + warnEndDelim) + +func warnWrap(warn string) string { + return warnStartDelim + warn + warnEndDelim +} + +// 'include' needs to be defined in the scope of a 'tpl' template as +// well as regular file-loaded templates. +func includeFun(t *template.Template, includedNames map[string]int) func(string, interface{}) (string, error) { + return func(name string, data interface{}) (string, error) { + var buf strings.Builder + if v, ok := includedNames[name]; ok { + if v > recursionMaxNums { + return "", errors.Wrapf(fmt.Errorf("unable to execute template"), "rendering template has a nested reference name: %s", name) + } + includedNames[name]++ + } else { + includedNames[name] = 1 + } + err := t.ExecuteTemplate(&buf, name, data) + includedNames[name]-- + return buf.String(), err + } +} + +// As does 'tpl', so that nested calls to 'tpl' see the templates +// defined by their enclosing contexts. +func tplFun(parent *template.Template, includedNames map[string]int, strict bool) func(string, interface{}) (string, error) { + return func(tpl string, vals interface{}) (string, error) { + t, err := parent.Clone() + if err != nil { + return "", errors.Wrapf(err, "cannot clone template") + } + + // Re-inject the missingkey option, see text/template issue https://github.com/golang/go/issues/43022 + // We have to go by strict from our engine configuration, as the option fields are private in Template. + // TODO: Remove workaround (and the strict parameter) once we build only with golang versions with a fix. + if strict { + t.Option("missingkey=error") + } else { + t.Option("missingkey=zero") + } + + // Re-inject 'include' so that it can close over our clone of t; + // this lets any 'define's inside tpl be 'include'd. + t.Funcs(template.FuncMap{ + "include": includeFun(t, includedNames), + "tpl": tplFun(t, includedNames, strict), + }) + + // We need a .New template, as template text which is just blanks + // or comments after parsing out defines just addes new named + // template definitions without changing the main template. + // https://pkg.go.dev/text/template#Template.Parse + // Use the parent's name for lack of a better way to identify the tpl + // text string. (Maybe we could use a hash appended to the name?) + t, err = t.New(parent.Name()).Parse(tpl) + if err != nil { + return "", errors.Wrapf(err, "cannot parse template %q", tpl) + } + + var buf strings.Builder + if err := t.Execute(&buf, vals); err != nil { + return "", errors.Wrapf(err, "error during tpl function execution for %q", tpl) + } + + // See comment in renderWithReferences explaining the hack. + return strings.ReplaceAll(buf.String(), "", ""), nil + } +} + +// initFunMap creates the Engine's FuncMap and adds context-specific functions. +func (e Engine) initFunMap(t *template.Template) { + funcMap := funcMap() + includedNames := make(map[string]int) + + // Add the template-rendering functions here so we can close over t. + funcMap["include"] = includeFun(t, includedNames) + funcMap["tpl"] = tplFun(t, includedNames, e.Strict) + + // Add the `required` function here so we can use lintMode + funcMap["required"] = func(warn string, val interface{}) (interface{}, error) { + if val == nil { + if e.LintMode { + // Don't fail on missing required values when linting + log.Printf("[INFO] Missing required value: %s", warn) + return "", nil + } + return val, errors.Errorf(warnWrap(warn)) + } else if _, ok := val.(string); ok { + if val == "" { + if e.LintMode { + // Don't fail on missing required values when linting + log.Printf("[INFO] Missing required value: %s", warn) + return "", nil + } + return val, errors.Errorf(warnWrap(warn)) + } + } + return val, nil + } + + // Override sprig fail function for linting and wrapping message + funcMap["fail"] = func(msg string) (string, error) { + if e.LintMode { + // Don't fail when linting + log.Printf("[INFO] Fail: %s", msg) + return "", nil + } + return "", errors.New(warnWrap(msg)) + } + + // If we are not linting and have a cluster connection, provide a Kubernetes-backed + // implementation. + if !e.LintMode && e.clientProvider != nil { + funcMap["lookup"] = newLookupFunction(*e.clientProvider) + } + + // When DNS lookups are not enabled override the sprig function and return + // an empty string. + if !e.EnableDNS { + funcMap["getHostByName"] = func(name string) string { + return "" + } + } + + t.Funcs(funcMap) +} + +// render takes a map of templates/values and renders them. +func (e Engine) render(tpls map[string]renderable) (rendered map[string]string, err error) { + // Basically, what we do here is start with an empty parent template and then + // build up a list of templates -- one for each file. Once all of the templates + // have been parsed, we loop through again and execute every template. + // + // The idea with this process is to make it possible for more complex templates + // to share common blocks, but to make the entire thing feel like a file-based + // template engine. + defer func() { + if r := recover(); r != nil { + err = errors.Errorf("rendering template failed: %v", r) + } + }() + t := template.New("gotpl") + if e.Strict { + t.Option("missingkey=error") + } else { + // Not that zero will attempt to add default values for types it knows, + // but will still emit for others. We mitigate that later. + t.Option("missingkey=zero") + } + + e.initFunMap(t) + + // We want to parse the templates in a predictable order. The order favors + // higher-level (in file system) templates over deeply nested templates. + keys := sortTemplates(tpls) + + for _, filename := range keys { + r := tpls[filename] + if _, err := t.New(filename).Parse(r.tpl); err != nil { + return map[string]string{}, cleanupParseError(filename, err) + } + } + + rendered = make(map[string]string, len(keys)) + for _, filename := range keys { + // Don't render partials. We don't care out the direct output of partials. + // They are only included from other templates. + if strings.HasPrefix(path.Base(filename), "_") { + continue + } + // At render time, add information about the template that is being rendered. + vals := tpls[filename].vals + vals["Template"] = chartutil.Values{"Name": filename, "BasePath": tpls[filename].basePath} + var buf strings.Builder + if err := t.ExecuteTemplate(&buf, filename, vals); err != nil { + return map[string]string{}, cleanupExecError(filename, err) + } + + // Work around the issue where Go will emit "" even if Options(missing=zero) + // is set. Since missing=error will never get here, we do not need to handle + // the Strict case. + rendered[filename] = strings.ReplaceAll(buf.String(), "", "") + } + + return rendered, nil +} + +func cleanupParseError(filename string, err error) error { + tokens := strings.Split(err.Error(), ": ") + if len(tokens) == 1 { + // This might happen if a non-templating error occurs + return fmt.Errorf("parse error in (%s): %s", filename, err) + } + // The first token is "template" + // The second token is either "filename:lineno" or "filename:lineNo:columnNo" + location := tokens[1] + // The remaining tokens make up a stacktrace-like chain, ending with the relevant error + errMsg := tokens[len(tokens)-1] + return fmt.Errorf("parse error at (%s): %s", string(location), errMsg) +} + +func cleanupExecError(filename string, err error) error { + if _, isExecError := err.(template.ExecError); !isExecError { + return err + } + + tokens := strings.SplitN(err.Error(), ": ", 3) + if len(tokens) != 3 { + // This might happen if a non-templating error occurs + return fmt.Errorf("execution error in (%s): %s", filename, err) + } + + // The first token is "template" + // The second token is either "filename:lineno" or "filename:lineNo:columnNo" + location := tokens[1] + + parts := warnRegex.FindStringSubmatch(tokens[2]) + if len(parts) >= 2 { + return fmt.Errorf("execution error at (%s): %s", string(location), parts[1]) + } + + return err +} + +func sortTemplates(tpls map[string]renderable) []string { + keys := make([]string, len(tpls)) + i := 0 + for key := range tpls { + keys[i] = key + i++ + } + sort.Sort(sort.Reverse(byPathLen(keys))) + return keys +} + +type byPathLen []string + +func (p byPathLen) Len() int { return len(p) } +func (p byPathLen) Swap(i, j int) { p[j], p[i] = p[i], p[j] } +func (p byPathLen) Less(i, j int) bool { + a, b := p[i], p[j] + ca, cb := strings.Count(a, "/"), strings.Count(b, "/") + if ca == cb { + return strings.Compare(a, b) == -1 + } + return ca < cb +} + +// allTemplates returns all templates for a chart and its dependencies. +// +// As it goes, it also prepares the values in a scope-sensitive manner. +func allTemplates(c *chart.Chart, vals chartutil.Values) map[string]renderable { + templates := make(map[string]renderable) + recAllTpls(c, templates, vals) + return templates +} + +// recAllTpls recurses through the templates in a chart. +// +// As it recurses, it also sets the values to be appropriate for the template +// scope. +func recAllTpls(c *chart.Chart, templates map[string]renderable, vals chartutil.Values) map[string]interface{} { + subCharts := make(map[string]interface{}) + chartMetaData := struct { + chart.Metadata + IsRoot bool + }{*c.Metadata, c.IsRoot()} + + next := map[string]interface{}{ + "Chart": chartMetaData, + "Files": newFiles(c.Files), + "Release": vals["Release"], + "Capabilities": vals["Capabilities"], + "Values": make(chartutil.Values), + "Subcharts": subCharts, + } + + // If there is a {{.Values.ThisChart}} in the parent metadata, + // copy that into the {{.Values}} for this template. + if c.IsRoot() { + next["Values"] = vals["Values"] + } else if vs, err := vals.Table("Values." + c.Name()); err == nil { + next["Values"] = vs + } + + for _, child := range c.Dependencies() { + subCharts[child.Name()] = recAllTpls(child, templates, next) + } + + newParentID := c.ChartFullPath() + for _, t := range c.Templates { + if t == nil { + continue + } + if !isTemplateValid(c, t.Name) { + continue + } + templates[path.Join(newParentID, t.Name)] = renderable{ + tpl: string(t.Data), + vals: next, + basePath: path.Join(newParentID, "templates"), + } + } + + return next +} + +// isTemplateValid returns true if the template is valid for the chart type +func isTemplateValid(ch *chart.Chart, templateName string) bool { + if isLibraryChart(ch) { + return strings.HasPrefix(filepath.Base(templateName), "_") + } + return true +} + +// isLibraryChart returns true if the chart is a library chart +func isLibraryChart(c *chart.Chart) bool { + return strings.EqualFold(c.Metadata.Type, "library") +} diff --git a/pkg/engine/engine_test.go b/pkg/engine/engine_test.go new file mode 100644 index 00000000..f8be52bf --- /dev/null +++ b/pkg/engine/engine_test.go @@ -0,0 +1,1302 @@ +/* +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 engine + +import ( + "fmt" + "path" + "strings" + "sync" + "testing" + "text/template" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/dynamic/fake" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chartutil" +) + +func TestSortTemplates(t *testing.T) { + tpls := map[string]renderable{ + "/mychart/templates/foo.tpl": {}, + "/mychart/templates/charts/foo/charts/bar/templates/foo.tpl": {}, + "/mychart/templates/bar.tpl": {}, + "/mychart/templates/charts/foo/templates/bar.tpl": {}, + "/mychart/templates/_foo.tpl": {}, + "/mychart/templates/charts/foo/templates/foo.tpl": {}, + "/mychart/templates/charts/bar/templates/foo.tpl": {}, + } + got := sortTemplates(tpls) + if len(got) != len(tpls) { + t.Fatal("Sorted results are missing templates") + } + + expect := []string{ + "/mychart/templates/charts/foo/charts/bar/templates/foo.tpl", + "/mychart/templates/charts/foo/templates/foo.tpl", + "/mychart/templates/charts/foo/templates/bar.tpl", + "/mychart/templates/charts/bar/templates/foo.tpl", + "/mychart/templates/foo.tpl", + "/mychart/templates/bar.tpl", + "/mychart/templates/_foo.tpl", + } + for i, e := range expect { + if got[i] != e { + t.Fatalf("\n\tExp:\n%s\n\tGot:\n%s", + strings.Join(expect, "\n"), + strings.Join(got, "\n"), + ) + } + } +} + +func TestFuncMap(t *testing.T) { + fns := funcMap() + forbidden := []string{"env", "expandenv"} + for _, f := range forbidden { + if _, ok := fns[f]; ok { + t.Errorf("Forbidden function %s exists in FuncMap.", f) + } + } + + // Test for Engine-specific template functions. + expect := []string{"include", "required", "tpl", "toYaml", "fromYaml", "toToml", "toJson", "fromJson", "lookup"} + for _, f := range expect { + if _, ok := fns[f]; !ok { + t.Errorf("Expected add-on function %q", f) + } + } +} + +func TestRender(t *testing.T) { + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "moby", + Version: "1.2.3", + }, + Templates: []*chart.File{ + {Name: "templates/test1", Data: []byte("{{.Values.outer | title }} {{.Values.inner | title}}")}, + {Name: "templates/test2", Data: []byte("{{.Values.global.callme | lower }}")}, + {Name: "templates/test3", Data: []byte("{{.noValue}}")}, + {Name: "templates/test4", Data: []byte("{{toJson .Values}}")}, + {Name: "templates/test5", Data: []byte("{{getHostByName \"helm.sh\"}}")}, + }, + Values: map[string]interface{}{"outer": "DEFAULT", "inner": "DEFAULT"}, + } + + vals := map[string]interface{}{ + "Values": map[string]interface{}{ + "outer": "spouter", + "inner": "inn", + "global": map[string]interface{}{ + "callme": "Ishmael", + }, + }, + } + + v, err := chartutil.CoalesceValues(c, vals) + if err != nil { + t.Fatalf("Failed to coalesce values: %s", err) + } + out, err := Render(c, v) + if err != nil { + t.Errorf("Failed to render templates: %s", err) + } + + expect := map[string]string{ + "moby/templates/test1": "Spouter Inn", + "moby/templates/test2": "ishmael", + "moby/templates/test3": "", + "moby/templates/test4": `{"global":{"callme":"Ishmael"},"inner":"inn","outer":"spouter"}`, + "moby/templates/test5": "", + } + + for name, data := range expect { + if out[name] != data { + t.Errorf("Expected %q, got %q", data, out[name]) + } + } +} + +func TestRenderRefsOrdering(t *testing.T) { + parentChart := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "parent", + Version: "1.2.3", + }, + Templates: []*chart.File{ + {Name: "templates/_helpers.tpl", Data: []byte(`{{- define "test" -}}parent value{{- end -}}`)}, + {Name: "templates/test.yaml", Data: []byte(`{{ tpl "{{ include \"test\" . }}" . }}`)}, + }, + } + childChart := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "child", + Version: "1.2.3", + }, + Templates: []*chart.File{ + {Name: "templates/_helpers.tpl", Data: []byte(`{{- define "test" -}}child value{{- end -}}`)}, + }, + } + parentChart.AddDependency(childChart) + + expect := map[string]string{ + "parent/templates/test.yaml": "parent value", + } + + for i := 0; i < 100; i++ { + out, err := Render(parentChart, chartutil.Values{}) + if err != nil { + t.Fatalf("Failed to render templates: %s", err) + } + + for name, data := range expect { + if out[name] != data { + t.Fatalf("Expected %q, got %q (iteration %d)", data, out[name], i+1) + } + } + } +} + +func TestRenderInternals(t *testing.T) { + // Test the internals of the rendering tool. + + vals := chartutil.Values{"Name": "one", "Value": "two"} + tpls := map[string]renderable{ + "one": {tpl: `Hello {{title .Name}}`, vals: vals}, + "two": {tpl: `Goodbye {{upper .Value}}`, vals: vals}, + // Test whether a template can reliably reference another template + // without regard for ordering. + "three": {tpl: `{{template "two" dict "Value" "three"}}`, vals: vals}, + } + + out, err := new(Engine).render(tpls) + if err != nil { + t.Fatalf("Failed template rendering: %s", err) + } + + if len(out) != 3 { + t.Fatalf("Expected 3 templates, got %d", len(out)) + } + + if out["one"] != "Hello One" { + t.Errorf("Expected 'Hello One', got %q", out["one"]) + } + + if out["two"] != "Goodbye TWO" { + t.Errorf("Expected 'Goodbye TWO'. got %q", out["two"]) + } + + if out["three"] != "Goodbye THREE" { + t.Errorf("Expected 'Goodbye THREE'. got %q", out["two"]) + } +} + +func TestRenderWithDNS(t *testing.T) { + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "moby", + Version: "1.2.3", + }, + Templates: []*chart.File{ + {Name: "templates/test1", Data: []byte("{{getHostByName \"helm.sh\"}}")}, + }, + Values: map[string]interface{}{}, + } + + vals := map[string]interface{}{ + "Values": map[string]interface{}{}, + } + + v, err := chartutil.CoalesceValues(c, vals) + if err != nil { + t.Fatalf("Failed to coalesce values: %s", err) + } + + var e Engine + e.EnableDNS = true + out, err := e.Render(c, v) + if err != nil { + t.Errorf("Failed to render templates: %s", err) + } + + for _, val := range c.Templates { + fp := path.Join("moby", val.Name) + if out[fp] == "" { + t.Errorf("Expected IP address, got %q", out[fp]) + } + } +} + +type kindProps struct { + shouldErr error + gvr schema.GroupVersionResource + namespaced bool +} + +type testClientProvider struct { + t *testing.T + scheme map[string]kindProps + objects []runtime.Object +} + +func (p *testClientProvider) GetClientFor(apiVersion, kind string) (dynamic.NamespaceableResourceInterface, bool, error) { + props := p.scheme[path.Join(apiVersion, kind)] + if props.shouldErr != nil { + return nil, false, props.shouldErr + } + return fake.NewSimpleDynamicClient(runtime.NewScheme(), p.objects...).Resource(props.gvr), props.namespaced, nil +} + +var _ ClientProvider = &testClientProvider{} + +// makeUnstructured is a convenience function for single-line creation of Unstructured objects. +func makeUnstructured(apiVersion, kind, name, namespace string) *unstructured.Unstructured { + ret := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": apiVersion, + "kind": kind, + "metadata": map[string]interface{}{ + "name": name, + }, + }} + if namespace != "" { + ret.Object["metadata"].(map[string]interface{})["namespace"] = namespace + } + return ret +} + +func TestRenderWithClientProvider(t *testing.T) { + provider := &testClientProvider{ + t: t, + scheme: map[string]kindProps{ + "v1/Namespace": { + gvr: schema.GroupVersionResource{ + Version: "v1", + Resource: "namespaces", + }, + }, + "v1/Pod": { + gvr: schema.GroupVersionResource{ + Version: "v1", + Resource: "pods", + }, + namespaced: true, + }, + }, + objects: []runtime.Object{ + makeUnstructured("v1", "Namespace", "default", ""), + makeUnstructured("v1", "Pod", "pod1", "default"), + makeUnstructured("v1", "Pod", "pod2", "ns1"), + makeUnstructured("v1", "Pod", "pod3", "ns1"), + }, + } + + type testCase struct { + template string + output string + } + cases := map[string]testCase{ + "ns-single": { + template: `{{ (lookup "v1" "Namespace" "" "default").metadata.name }}`, + output: "default", + }, + "ns-list": { + template: `{{ (lookup "v1" "Namespace" "" "").items | len }}`, + output: "1", + }, + "ns-missing": { + template: `{{ (lookup "v1" "Namespace" "" "absent") }}`, + output: "map[]", + }, + "pod-single": { + template: `{{ (lookup "v1" "Pod" "default" "pod1").metadata.name }}`, + output: "pod1", + }, + "pod-list": { + template: `{{ (lookup "v1" "Pod" "ns1" "").items | len }}`, + output: "2", + }, + "pod-all": { + template: `{{ (lookup "v1" "Pod" "" "").items | len }}`, + output: "3", + }, + "pod-missing": { + template: `{{ (lookup "v1" "Pod" "" "ns2") }}`, + output: "map[]", + }, + } + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "moby", + Version: "1.2.3", + }, + Values: map[string]interface{}{}, + } + + for name, exp := range cases { + c.Templates = append(c.Templates, &chart.File{ + Name: path.Join("templates", name), + Data: []byte(exp.template), + }) + } + + vals := map[string]interface{}{ + "Values": map[string]interface{}{}, + } + + v, err := chartutil.CoalesceValues(c, vals) + if err != nil { + t.Fatalf("Failed to coalesce values: %s", err) + } + + out, err := RenderWithClientProvider(c, v, provider) + if err != nil { + t.Errorf("Failed to render templates: %s", err) + } + + for name, want := range cases { + t.Run(name, func(t *testing.T) { + key := path.Join("moby/templates", name) + if out[key] != want.output { + t.Errorf("Expected %q, got %q", want, out[key]) + } + }) + } +} + +func TestRenderWithClientProvider_error(t *testing.T) { + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "moby", + Version: "1.2.3", + }, + Templates: []*chart.File{ + {Name: "templates/error", Data: []byte(`{{ lookup "v1" "Error" "" "" }}`)}, + }, + Values: map[string]interface{}{}, + } + + vals := map[string]interface{}{ + "Values": map[string]interface{}{}, + } + + v, err := chartutil.CoalesceValues(c, vals) + if err != nil { + t.Fatalf("Failed to coalesce values: %s", err) + } + + provider := &testClientProvider{ + t: t, + scheme: map[string]kindProps{ + "v1/Error": { + shouldErr: fmt.Errorf("kaboom"), + }, + }, + } + _, err = RenderWithClientProvider(c, v, provider) + if err == nil || !strings.Contains(err.Error(), "kaboom") { + t.Errorf("Expected error from client provider when rendering, got %q", err) + } +} + +func TestParallelRenderInternals(t *testing.T) { + // Make sure that we can use one Engine to run parallel template renders. + e := new(Engine) + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func(i int) { + tt := fmt.Sprintf("expect-%d", i) + tpls := map[string]renderable{ + "t": { + tpl: `{{.val}}`, + vals: map[string]interface{}{"val": tt}, + }, + } + out, err := e.render(tpls) + if err != nil { + t.Errorf("Failed to render %s: %s", tt, err) + } + if out["t"] != tt { + t.Errorf("Expected %q, got %q", tt, out["t"]) + } + wg.Done() + }(i) + } + wg.Wait() +} + +func TestParseErrors(t *testing.T) { + vals := chartutil.Values{"Values": map[string]interface{}{}} + + tplsUndefinedFunction := map[string]renderable{ + "undefined_function": {tpl: `{{foo}}`, vals: vals}, + } + _, err := new(Engine).render(tplsUndefinedFunction) + if err == nil { + t.Fatalf("Expected failures while rendering: %s", err) + } + expected := `parse error at (undefined_function:1): function "foo" not defined` + if err.Error() != expected { + t.Errorf("Expected '%s', got %q", expected, err.Error()) + } +} + +func TestExecErrors(t *testing.T) { + vals := chartutil.Values{"Values": map[string]interface{}{}} + cases := []struct { + name string + tpls map[string]renderable + expected string + }{ + { + name: "MissingRequired", + tpls: map[string]renderable{ + "missing_required": {tpl: `{{required "foo is required" .Values.foo}}`, vals: vals}, + }, + expected: `execution error at (missing_required:1:2): foo is required`, + }, + { + name: "MissingRequiredWithColons", + tpls: map[string]renderable{ + "missing_required_with_colons": {tpl: `{{required ":this: message: has many: colons:" .Values.foo}}`, vals: vals}, + }, + expected: `execution error at (missing_required_with_colons:1:2): :this: message: has many: colons:`, + }, + { + name: "Issue6044", + tpls: map[string]renderable{ + "issue6044": { + vals: vals, + tpl: `{{ $someEmptyValue := "" }} +{{ $myvar := "abc" }} +{{- required (printf "%s: something is missing" $myvar) $someEmptyValue | repeat 0 }}`, + }, + }, + expected: `execution error at (issue6044:3:4): abc: something is missing`, + }, + { + name: "MissingRequiredWithNewlines", + tpls: map[string]renderable{ + "issue9981": {tpl: `{{required "foo is required\nmore info after the break" .Values.foo}}`, vals: vals}, + }, + expected: `execution error at (issue9981:1:2): foo is required +more info after the break`, + }, + { + name: "FailWithNewlines", + tpls: map[string]renderable{ + "issue9981": {tpl: `{{fail "something is wrong\nlinebreak"}}`, vals: vals}, + }, + expected: `execution error at (issue9981:1:2): something is wrong +linebreak`, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + _, err := new(Engine).render(tt.tpls) + if err == nil { + t.Fatalf("Expected failures while rendering: %s", err) + } + if err.Error() != tt.expected { + t.Errorf("Expected %q, got %q", tt.expected, err.Error()) + } + }) + } +} + +func TestFailErrors(t *testing.T) { + vals := chartutil.Values{"Values": map[string]interface{}{}} + + failtpl := `All your base are belong to us{{ fail "This is an error" }}` + tplsFailed := map[string]renderable{ + "failtpl": {tpl: failtpl, vals: vals}, + } + _, err := new(Engine).render(tplsFailed) + if err == nil { + t.Fatalf("Expected failures while rendering: %s", err) + } + expected := `execution error at (failtpl:1:33): This is an error` + if err.Error() != expected { + t.Errorf("Expected '%s', got %q", expected, err.Error()) + } + + var e Engine + e.LintMode = true + out, err := e.render(tplsFailed) + if err != nil { + t.Fatal(err) + } + + expectStr := "All your base are belong to us" + if gotStr := out["failtpl"]; gotStr != expectStr { + t.Errorf("Expected %q, got %q (%v)", expectStr, gotStr, out) + } +} + +func TestAllTemplates(t *testing.T) { + ch1 := &chart.Chart{ + Metadata: &chart.Metadata{Name: "ch1"}, + Templates: []*chart.File{ + {Name: "templates/foo", Data: []byte("foo")}, + {Name: "templates/bar", Data: []byte("bar")}, + }, + } + dep1 := &chart.Chart{ + Metadata: &chart.Metadata{Name: "laboratory mice"}, + Templates: []*chart.File{ + {Name: "templates/pinky", Data: []byte("pinky")}, + {Name: "templates/brain", Data: []byte("brain")}, + }, + } + ch1.AddDependency(dep1) + + dep2 := &chart.Chart{ + Metadata: &chart.Metadata{Name: "same thing we do every night"}, + Templates: []*chart.File{ + {Name: "templates/innermost", Data: []byte("innermost")}, + }, + } + dep1.AddDependency(dep2) + + tpls := allTemplates(ch1, chartutil.Values{}) + if len(tpls) != 5 { + t.Errorf("Expected 5 charts, got %d", len(tpls)) + } +} + +func TestChartValuesContainsIsRoot(t *testing.T) { + ch1 := &chart.Chart{ + Metadata: &chart.Metadata{Name: "parent"}, + Templates: []*chart.File{ + {Name: "templates/isroot", Data: []byte("{{.Chart.IsRoot}}")}, + }, + } + dep1 := &chart.Chart{ + Metadata: &chart.Metadata{Name: "child"}, + Templates: []*chart.File{ + {Name: "templates/isroot", Data: []byte("{{.Chart.IsRoot}}")}, + }, + } + ch1.AddDependency(dep1) + + out, err := Render(ch1, chartutil.Values{}) + if err != nil { + t.Fatalf("failed to render templates: %s", err) + } + expects := map[string]string{ + "parent/charts/child/templates/isroot": "false", + "parent/templates/isroot": "true", + } + for file, expect := range expects { + if out[file] != expect { + t.Errorf("Expected %q, got %q", expect, out[file]) + } + } +} + +func TestRenderDependency(t *testing.T) { + deptpl := `{{define "myblock"}}World{{end}}` + toptpl := `Hello {{template "myblock"}}` + ch := &chart.Chart{ + Metadata: &chart.Metadata{Name: "outerchart"}, + Templates: []*chart.File{ + {Name: "templates/outer", Data: []byte(toptpl)}, + }, + } + ch.AddDependency(&chart.Chart{ + Metadata: &chart.Metadata{Name: "innerchart"}, + Templates: []*chart.File{ + {Name: "templates/inner", Data: []byte(deptpl)}, + }, + }) + + out, err := Render(ch, map[string]interface{}{}) + if err != nil { + t.Fatalf("failed to render chart: %s", err) + } + + if len(out) != 2 { + t.Errorf("Expected 2, got %d", len(out)) + } + + expect := "Hello World" + if out["outerchart/templates/outer"] != expect { + t.Errorf("Expected %q, got %q", expect, out["outer"]) + } + +} + +func TestRenderNestedValues(t *testing.T) { + innerpath := "templates/inner.tpl" + outerpath := "templates/outer.tpl" + // Ensure namespacing rules are working. + deepestpath := "templates/inner.tpl" + checkrelease := "templates/release.tpl" + // Ensure subcharts scopes are working. + subchartspath := "templates/subcharts.tpl" + + deepest := &chart.Chart{ + Metadata: &chart.Metadata{Name: "deepest"}, + Templates: []*chart.File{ + {Name: deepestpath, Data: []byte(`And this same {{.Values.what}} that smiles {{.Values.global.when}}`)}, + {Name: checkrelease, Data: []byte(`Tomorrow will be {{default "happy" .Release.Name }}`)}, + }, + Values: map[string]interface{}{"what": "milkshake", "where": "here"}, + } + + inner := &chart.Chart{ + Metadata: &chart.Metadata{Name: "herrick"}, + Templates: []*chart.File{ + {Name: innerpath, Data: []byte(`Old {{.Values.who}} is still a-flyin'`)}, + }, + Values: map[string]interface{}{"who": "Robert", "what": "glasses"}, + } + inner.AddDependency(deepest) + + outer := &chart.Chart{ + Metadata: &chart.Metadata{Name: "top"}, + Templates: []*chart.File{ + {Name: outerpath, Data: []byte(`Gather ye {{.Values.what}} while ye may`)}, + {Name: subchartspath, Data: []byte(`The glorious Lamp of {{.Subcharts.herrick.Subcharts.deepest.Values.where}}, the {{.Subcharts.herrick.Values.what}}`)}, + }, + Values: map[string]interface{}{ + "what": "stinkweed", + "who": "me", + "herrick": map[string]interface{}{ + "who": "time", + "what": "Sun", + }, + }, + } + outer.AddDependency(inner) + + injValues := map[string]interface{}{ + "what": "rosebuds", + "herrick": map[string]interface{}{ + "deepest": map[string]interface{}{ + "what": "flower", + "where": "Heaven", + }, + }, + "global": map[string]interface{}{ + "when": "to-day", + }, + } + + tmp, err := chartutil.CoalesceValues(outer, injValues) + if err != nil { + t.Fatalf("Failed to coalesce values: %s", err) + } + + inject := chartutil.Values{ + "Values": tmp, + "Chart": outer.Metadata, + "Release": chartutil.Values{ + "Name": "dyin", + }, + } + + t.Logf("Calculated values: %v", inject) + + out, err := Render(outer, inject) + if err != nil { + t.Fatalf("failed to render templates: %s", err) + } + + fullouterpath := "top/" + outerpath + if out[fullouterpath] != "Gather ye rosebuds while ye may" { + t.Errorf("Unexpected outer: %q", out[fullouterpath]) + } + + fullinnerpath := "top/charts/herrick/" + innerpath + if out[fullinnerpath] != "Old time is still a-flyin'" { + t.Errorf("Unexpected inner: %q", out[fullinnerpath]) + } + + fulldeepestpath := "top/charts/herrick/charts/deepest/" + deepestpath + if out[fulldeepestpath] != "And this same flower that smiles to-day" { + t.Errorf("Unexpected deepest: %q", out[fulldeepestpath]) + } + + fullcheckrelease := "top/charts/herrick/charts/deepest/" + checkrelease + if out[fullcheckrelease] != "Tomorrow will be dyin" { + t.Errorf("Unexpected release: %q", out[fullcheckrelease]) + } + + fullchecksubcharts := "top/" + subchartspath + if out[fullchecksubcharts] != "The glorious Lamp of Heaven, the Sun" { + t.Errorf("Unexpected subcharts: %q", out[fullchecksubcharts]) + } +} + +func TestRenderBuiltinValues(t *testing.T) { + inner := &chart.Chart{ + Metadata: &chart.Metadata{Name: "Latium"}, + Templates: []*chart.File{ + {Name: "templates/Lavinia", Data: []byte(`{{.Template.Name}}{{.Chart.Name}}{{.Release.Name}}`)}, + {Name: "templates/From", Data: []byte(`{{.Files.author | printf "%s"}} {{.Files.Get "book/title.txt"}}`)}, + }, + Files: []*chart.File{ + {Name: "author", Data: []byte("Virgil")}, + {Name: "book/title.txt", Data: []byte("Aeneid")}, + }, + } + + outer := &chart.Chart{ + Metadata: &chart.Metadata{Name: "Troy"}, + Templates: []*chart.File{ + {Name: "templates/Aeneas", Data: []byte(`{{.Template.Name}}{{.Chart.Name}}{{.Release.Name}}`)}, + {Name: "templates/Amata", Data: []byte(`{{.Subcharts.Latium.Chart.Name}} {{.Subcharts.Latium.Files.author | printf "%s"}}`)}, + }, + } + outer.AddDependency(inner) + + inject := chartutil.Values{ + "Values": "", + "Chart": outer.Metadata, + "Release": chartutil.Values{ + "Name": "Aeneid", + }, + } + + t.Logf("Calculated values: %v", outer) + + out, err := Render(outer, inject) + if err != nil { + t.Fatalf("failed to render templates: %s", err) + } + + expects := map[string]string{ + "Troy/charts/Latium/templates/Lavinia": "Troy/charts/Latium/templates/LaviniaLatiumAeneid", + "Troy/templates/Aeneas": "Troy/templates/AeneasTroyAeneid", + "Troy/templates/Amata": "Latium Virgil", + "Troy/charts/Latium/templates/From": "Virgil Aeneid", + } + for file, expect := range expects { + if out[file] != expect { + t.Errorf("Expected %q, got %q", expect, out[file]) + } + } + +} + +func TestAlterFuncMap_include(t *testing.T) { + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: "conrad"}, + Templates: []*chart.File{ + {Name: "templates/quote", Data: []byte(`{{include "conrad/templates/_partial" . | indent 2}} dead.`)}, + {Name: "templates/_partial", Data: []byte(`{{.Release.Name}} - he`)}, + }, + } + + // Check nested reference in include FuncMap + d := &chart.Chart{ + Metadata: &chart.Metadata{Name: "nested"}, + Templates: []*chart.File{ + {Name: "templates/quote", Data: []byte(`{{include "nested/templates/quote" . | indent 2}} dead.`)}, + {Name: "templates/_partial", Data: []byte(`{{.Release.Name}} - he`)}, + }, + } + + v := chartutil.Values{ + "Values": "", + "Chart": c.Metadata, + "Release": chartutil.Values{ + "Name": "Mistah Kurtz", + }, + } + + out, err := Render(c, v) + if err != nil { + t.Fatal(err) + } + + expect := " Mistah Kurtz - he dead." + if got := out["conrad/templates/quote"]; got != expect { + t.Errorf("Expected %q, got %q (%v)", expect, got, out) + } + + _, err = Render(d, v) + expectErrName := "nested/templates/quote" + if err == nil { + t.Errorf("Expected err of nested reference name: %v", expectErrName) + } +} + +func TestAlterFuncMap_require(t *testing.T) { + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: "conan"}, + Templates: []*chart.File{ + {Name: "templates/quote", Data: []byte(`All your base are belong to {{ required "A valid 'who' is required" .Values.who }}`)}, + {Name: "templates/bases", Data: []byte(`All {{ required "A valid 'bases' is required" .Values.bases }} of them!`)}, + }, + } + + v := chartutil.Values{ + "Values": chartutil.Values{ + "who": "us", + "bases": 2, + }, + "Chart": c.Metadata, + "Release": chartutil.Values{ + "Name": "That 90s meme", + }, + } + + out, err := Render(c, v) + if err != nil { + t.Fatal(err) + } + + expectStr := "All your base are belong to us" + if gotStr := out["conan/templates/quote"]; gotStr != expectStr { + t.Errorf("Expected %q, got %q (%v)", expectStr, gotStr, out) + } + expectNum := "All 2 of them!" + if gotNum := out["conan/templates/bases"]; gotNum != expectNum { + t.Errorf("Expected %q, got %q (%v)", expectNum, gotNum, out) + } + + // test required without passing in needed values with lint mode on + // verifies lint replaces required with an empty string (should not fail) + lintValues := chartutil.Values{ + "Values": chartutil.Values{ + "who": "us", + }, + "Chart": c.Metadata, + "Release": chartutil.Values{ + "Name": "That 90s meme", + }, + } + var e Engine + e.LintMode = true + out, err = e.Render(c, lintValues) + if err != nil { + t.Fatal(err) + } + + expectStr = "All your base are belong to us" + if gotStr := out["conan/templates/quote"]; gotStr != expectStr { + t.Errorf("Expected %q, got %q (%v)", expectStr, gotStr, out) + } + expectNum = "All of them!" + if gotNum := out["conan/templates/bases"]; gotNum != expectNum { + t.Errorf("Expected %q, got %q (%v)", expectNum, gotNum, out) + } +} + +func TestAlterFuncMap_tpl(t *testing.T) { + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: "TplFunction"}, + Templates: []*chart.File{ + {Name: "templates/base", Data: []byte(`Evaluate tpl {{tpl "Value: {{ .Values.value}}" .}}`)}, + }, + } + + v := chartutil.Values{ + "Values": chartutil.Values{ + "value": "myvalue", + }, + "Chart": c.Metadata, + "Release": chartutil.Values{ + "Name": "TestRelease", + }, + } + + out, err := Render(c, v) + if err != nil { + t.Fatal(err) + } + + expect := "Evaluate tpl Value: myvalue" + if got := out["TplFunction/templates/base"]; got != expect { + t.Errorf("Expected %q, got %q (%v)", expect, got, out) + } +} + +func TestAlterFuncMap_tplfunc(t *testing.T) { + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: "TplFunction"}, + Templates: []*chart.File{ + {Name: "templates/base", Data: []byte(`Evaluate tpl {{tpl "Value: {{ .Values.value | quote}}" .}}`)}, + }, + } + + v := chartutil.Values{ + "Values": chartutil.Values{ + "value": "myvalue", + }, + "Chart": c.Metadata, + "Release": chartutil.Values{ + "Name": "TestRelease", + }, + } + + out, err := Render(c, v) + if err != nil { + t.Fatal(err) + } + + expect := "Evaluate tpl Value: \"myvalue\"" + if got := out["TplFunction/templates/base"]; got != expect { + t.Errorf("Expected %q, got %q (%v)", expect, got, out) + } +} + +func TestAlterFuncMap_tplinclude(t *testing.T) { + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: "TplFunction"}, + Templates: []*chart.File{ + {Name: "templates/base", Data: []byte(`{{ tpl "{{include ` + "`" + `TplFunction/templates/_partial` + "`" + ` . | quote }}" .}}`)}, + {Name: "templates/_partial", Data: []byte(`{{.Template.Name}}`)}, + }, + } + v := chartutil.Values{ + "Values": chartutil.Values{ + "value": "myvalue", + }, + "Chart": c.Metadata, + "Release": chartutil.Values{ + "Name": "TestRelease", + }, + } + + out, err := Render(c, v) + if err != nil { + t.Fatal(err) + } + + expect := "\"TplFunction/templates/base\"" + if got := out["TplFunction/templates/base"]; got != expect { + t.Errorf("Expected %q, got %q (%v)", expect, got, out) + } + +} + +func TestRenderRecursionLimit(t *testing.T) { + // endless recursion should produce an error + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: "bad"}, + Templates: []*chart.File{ + {Name: "templates/base", Data: []byte(`{{include "recursion" . }}`)}, + {Name: "templates/recursion", Data: []byte(`{{define "recursion"}}{{include "recursion" . }}{{end}}`)}, + }, + } + v := chartutil.Values{ + "Values": "", + "Chart": c.Metadata, + "Release": chartutil.Values{ + "Name": "TestRelease", + }, + } + expectErr := "rendering template has a nested reference name: recursion: unable to execute template" + + _, err := Render(c, v) + if err == nil || !strings.HasSuffix(err.Error(), expectErr) { + t.Errorf("Expected err with suffix: %s", expectErr) + } + + // calling the same function many times is ok + times := 4000 + phrase := "All work and no play makes Jack a dull boy" + printFunc := `{{define "overlook"}}{{printf "` + phrase + `\n"}}{{end}}` + var repeatedIncl string + for i := 0; i < times; i++ { + repeatedIncl += `{{include "overlook" . }}` + } + + d := &chart.Chart{ + Metadata: &chart.Metadata{Name: "overlook"}, + Templates: []*chart.File{ + {Name: "templates/quote", Data: []byte(repeatedIncl)}, + {Name: "templates/_function", Data: []byte(printFunc)}, + }, + } + + out, err := Render(d, v) + if err != nil { + t.Fatal(err) + } + + var expect string + for i := 0; i < times; i++ { + expect += phrase + "\n" + } + if got := out["overlook/templates/quote"]; got != expect { + t.Errorf("Expected %q, got %q (%v)", expect, got, out) + } + +} + +func TestRenderLoadTemplateForTplFromFile(t *testing.T) { + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: "TplLoadFromFile"}, + Templates: []*chart.File{ + {Name: "templates/base", Data: []byte(`{{ tpl (.Files.Get .Values.filename) . }}`)}, + {Name: "templates/_function", Data: []byte(`{{define "test-function"}}test-function{{end}}`)}, + }, + Files: []*chart.File{ + {Name: "test", Data: []byte(`{{ tpl (.Files.Get .Values.filename2) .}}`)}, + {Name: "test2", Data: []byte(`{{include "test-function" .}}{{define "nested-define"}}nested-define-content{{end}} {{include "nested-define" .}}`)}, + }, + } + + v := chartutil.Values{ + "Values": chartutil.Values{ + "filename": "test", + "filename2": "test2", + }, + "Chart": c.Metadata, + "Release": chartutil.Values{ + "Name": "TestRelease", + }, + } + + out, err := Render(c, v) + if err != nil { + t.Fatal(err) + } + + expect := "test-function nested-define-content" + if got := out["TplLoadFromFile/templates/base"]; got != expect { + t.Fatalf("Expected %q, got %q", expect, got) + } +} + +func TestRenderTplEmpty(t *testing.T) { + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: "TplEmpty"}, + Templates: []*chart.File{ + {Name: "templates/empty-string", Data: []byte(`{{tpl "" .}}`)}, + {Name: "templates/empty-action", Data: []byte(`{{tpl "{{ \"\"}}" .}}`)}, + {Name: "templates/only-defines", Data: []byte(`{{tpl "{{define \"not-invoked\"}}not-rendered{{end}}" .}}`)}, + }, + } + v := chartutil.Values{ + "Chart": c.Metadata, + "Release": chartutil.Values{ + "Name": "TestRelease", + }, + } + + out, err := Render(c, v) + if err != nil { + t.Fatal(err) + } + + expects := map[string]string{ + "TplEmpty/templates/empty-string": "", + "TplEmpty/templates/empty-action": "", + "TplEmpty/templates/only-defines": "", + } + for file, expect := range expects { + if out[file] != expect { + t.Errorf("Expected %q, got %q", expect, out[file]) + } + } +} + +func TestRenderTplTemplateNames(t *testing.T) { + // .Template.BasePath and .Name make it through + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: "TplTemplateNames"}, + Templates: []*chart.File{ + {Name: "templates/default-basepath", Data: []byte(`{{tpl "{{ .Template.BasePath }}" .}}`)}, + {Name: "templates/default-name", Data: []byte(`{{tpl "{{ .Template.Name }}" .}}`)}, + {Name: "templates/modified-basepath", Data: []byte(`{{tpl "{{ .Template.BasePath }}" .Values.dot}}`)}, + {Name: "templates/modified-name", Data: []byte(`{{tpl "{{ .Template.Name }}" .Values.dot}}`)}, + {Name: "templates/modified-field", Data: []byte(`{{tpl "{{ .Template.Field }}" .Values.dot}}`)}, + }, + } + v := chartutil.Values{ + "Values": chartutil.Values{ + "dot": chartutil.Values{ + "Template": chartutil.Values{ + "BasePath": "path/to/template", + "Name": "name-of-template", + "Field": "extra-field", + }, + }, + }, + "Chart": c.Metadata, + "Release": chartutil.Values{ + "Name": "TestRelease", + }, + } + + out, err := Render(c, v) + if err != nil { + t.Fatal(err) + } + + expects := map[string]string{ + "TplTemplateNames/templates/default-basepath": "TplTemplateNames/templates", + "TplTemplateNames/templates/default-name": "TplTemplateNames/templates/default-name", + "TplTemplateNames/templates/modified-basepath": "path/to/template", + "TplTemplateNames/templates/modified-name": "name-of-template", + "TplTemplateNames/templates/modified-field": "extra-field", + } + for file, expect := range expects { + if out[file] != expect { + t.Errorf("Expected %q, got %q", expect, out[file]) + } + } +} + +func TestRenderTplRedefines(t *testing.T) { + // Redefining a template inside 'tpl' does not affect the outer definition + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: "TplRedefines"}, + Templates: []*chart.File{ + {Name: "templates/_partials", Data: []byte(`{{define "partial"}}original-in-partial{{end}}`)}, + {Name: "templates/partial", Data: []byte( + `before: {{include "partial" .}}\n{{tpl .Values.partialText .}}\nafter: {{include "partial" .}}`, + )}, + {Name: "templates/manifest", Data: []byte( + `{{define "manifest"}}original-in-manifest{{end}}` + + `before: {{include "manifest" .}}\n{{tpl .Values.manifestText .}}\nafter: {{include "manifest" .}}`, + )}, + {Name: "templates/manifest-only", Data: []byte( + `{{define "manifest-only"}}only-in-manifest{{end}}` + + `before: {{include "manifest-only" .}}\n{{tpl .Values.manifestOnlyText .}}\nafter: {{include "manifest-only" .}}`, + )}, + {Name: "templates/nested", Data: []byte( + `{{define "nested"}}original-in-manifest{{end}}` + + `{{define "nested-outer"}}original-outer-in-manifest{{end}}` + + `before: {{include "nested" .}} {{include "nested-outer" .}}\n` + + `{{tpl .Values.nestedText .}}\n` + + `after: {{include "nested" .}} {{include "nested-outer" .}}`, + )}, + }, + } + v := chartutil.Values{ + "Values": chartutil.Values{ + "partialText": `{{define "partial"}}redefined-in-tpl{{end}}tpl: {{include "partial" .}}`, + "manifestText": `{{define "manifest"}}redefined-in-tpl{{end}}tpl: {{include "manifest" .}}`, + "manifestOnlyText": `tpl: {{include "manifest-only" .}}`, + "nestedText": `{{define "nested"}}redefined-in-tpl{{end}}` + + `{{define "nested-outer"}}redefined-outer-in-tpl{{end}}` + + `before-inner-tpl: {{include "nested" .}} {{include "nested-outer" . }}\n` + + `{{tpl .Values.innerText .}}\n` + + `after-inner-tpl: {{include "nested" .}} {{include "nested-outer" . }}`, + "innerText": `{{define "nested"}}redefined-in-inner-tpl{{end}}inner-tpl: {{include "nested" .}} {{include "nested-outer" . }}`, + }, + "Chart": c.Metadata, + "Release": chartutil.Values{ + "Name": "TestRelease", + }, + } + + out, err := Render(c, v) + if err != nil { + t.Fatal(err) + } + + expects := map[string]string{ + "TplRedefines/templates/partial": `before: original-in-partial\ntpl: redefined-in-tpl\nafter: original-in-partial`, + "TplRedefines/templates/manifest": `before: original-in-manifest\ntpl: redefined-in-tpl\nafter: original-in-manifest`, + "TplRedefines/templates/manifest-only": `before: only-in-manifest\ntpl: only-in-manifest\nafter: only-in-manifest`, + "TplRedefines/templates/nested": `before: original-in-manifest original-outer-in-manifest\n` + + `before-inner-tpl: redefined-in-tpl redefined-outer-in-tpl\n` + + `inner-tpl: redefined-in-inner-tpl redefined-outer-in-tpl\n` + + `after-inner-tpl: redefined-in-tpl redefined-outer-in-tpl\n` + + `after: original-in-manifest original-outer-in-manifest`, + } + for file, expect := range expects { + if out[file] != expect { + t.Errorf("Expected %q, got %q", expect, out[file]) + } + } +} + +func TestRenderTplMissingKey(t *testing.T) { + // Rendering a missing key results in empty/zero output. + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: "TplMissingKey"}, + Templates: []*chart.File{ + {Name: "templates/manifest", Data: []byte( + `missingValue: {{tpl "{{.Values.noSuchKey}}" .}}`, + )}, + }, + } + v := chartutil.Values{ + "Values": chartutil.Values{}, + "Chart": c.Metadata, + "Release": chartutil.Values{ + "Name": "TestRelease", + }, + } + + out, err := Render(c, v) + if err != nil { + t.Fatal(err) + } + + expects := map[string]string{ + "TplMissingKey/templates/manifest": `missingValue: `, + } + for file, expect := range expects { + if out[file] != expect { + t.Errorf("Expected %q, got %q", expect, out[file]) + } + } +} + +func TestRenderTplMissingKeyString(t *testing.T) { + // Rendering a missing key results in error + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: "TplMissingKeyStrict"}, + Templates: []*chart.File{ + {Name: "templates/manifest", Data: []byte( + `missingValue: {{tpl "{{.Values.noSuchKey}}" .}}`, + )}, + }, + } + v := chartutil.Values{ + "Values": chartutil.Values{}, + "Chart": c.Metadata, + "Release": chartutil.Values{ + "Name": "TestRelease", + }, + } + + e := new(Engine) + e.Strict = true + + out, err := e.Render(c, v) + if err == nil { + t.Errorf("Expected error, got %v", out) + return + } + switch err.(type) { + case (template.ExecError): + errTxt := fmt.Sprint(err) + if !strings.Contains(errTxt, "noSuchKey") { + t.Errorf("Expected error to contain 'noSuchKey', got %s", errTxt) + } + default: + // Some unexpected error. + t.Fatal(err) + } +} diff --git a/pkg/engine/files.go b/pkg/engine/files.go new file mode 100644 index 00000000..f2cfdb3f --- /dev/null +++ b/pkg/engine/files.go @@ -0,0 +1,165 @@ +/* +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 engine + +import ( + "encoding/base64" + "path" + "strings" + + "github.com/gobwas/glob" + + "helm.sh/helm/v3/pkg/chart" +) + +// files is a map of files in a chart that can be accessed from a template. +type files map[string][]byte + +// NewFiles creates a new files from chart files. +// Given an []*chart.File (the format for files in a chart.Chart), extract a map of files. +func newFiles(from []*chart.File) files { + files := make(map[string][]byte) + for _, f := range from { + files[f.Name] = f.Data + } + return files +} + +// GetBytes gets a file by path. +// +// The returned data is raw. In a template context, this is identical to calling +// {{index .Files $path}}. +// +// This is intended to be accessed from within a template, so a missed key returns +// an empty []byte. +func (f files) GetBytes(name string) []byte { + if v, ok := f[name]; ok { + return v + } + return []byte{} +} + +// Get returns a string representation of the given file. +// +// Fetch the contents of a file as a string. It is designed to be called in a +// template. +// +// {{.Files.Get "foo"}} +func (f files) Get(name string) string { + return string(f.GetBytes(name)) +} + +// Glob takes a glob pattern and returns another files object only containing +// matched files. +// +// This is designed to be called from a template. +// +// {{ range $name, $content := .Files.Glob("foo/**") }} +// {{ $name }}: | +// {{ .Files.Get($name) | indent 4 }}{{ end }} +func (f files) Glob(pattern string) files { + g, err := glob.Compile(pattern, '/') + if err != nil { + g, _ = glob.Compile("**") + } + + nf := newFiles(nil) + for name, contents := range f { + if g.Match(name) { + nf[name] = contents + } + } + + return nf +} + +// AsConfig turns a Files group and flattens it to a YAML map suitable for +// including in the 'data' section of a Kubernetes ConfigMap definition. +// Duplicate keys will be overwritten, so be aware that your file names +// (regardless of path) should be unique. +// +// This is designed to be called from a template, and will return empty string +// (via toYAML function) if it cannot be serialized to YAML, or if the Files +// object is nil. +// +// The output will not be indented, so you will want to pipe this to the +// 'indent' template function. +// +// data: +// +// {{ .Files.Glob("config/**").AsConfig() | indent 4 }} +func (f files) AsConfig() string { + if f == nil { + return "" + } + + m := make(map[string]string) + + // Explicitly convert to strings, and file names + for k, v := range f { + m[path.Base(k)] = string(v) + } + + return toYAML(m) +} + +// AsSecrets returns the base64-encoded value of a Files object suitable for +// including in the 'data' section of a Kubernetes Secret definition. +// Duplicate keys will be overwritten, so be aware that your file names +// (regardless of path) should be unique. +// +// This is designed to be called from a template, and will return empty string +// (via toYAML function) if it cannot be serialized to YAML, or if the Files +// object is nil. +// +// The output will not be indented, so you will want to pipe this to the +// 'indent' template function. +// +// data: +// +// {{ .Files.Glob("secrets/*").AsSecrets() | indent 4 }} +func (f files) AsSecrets() string { + if f == nil { + return "" + } + + m := make(map[string]string) + + for k, v := range f { + m[path.Base(k)] = base64.StdEncoding.EncodeToString(v) + } + + return toYAML(m) +} + +// Lines returns each line of a named file (split by "\n") as a slice, so it can +// be ranged over in your templates. +// +// This is designed to be called from a template. +// +// {{ range .Files.Lines "foo/bar.html" }} +// {{ . }}{{ end }} +func (f files) Lines(path string) []string { + if f == nil || f[path] == nil { + return []string{} + } + s := string(f[path]) + if s[len(s)-1] == '\n' { + s = s[:len(s)-1] + } + return strings.Split(s, "\n") +} diff --git a/pkg/engine/files_test.go b/pkg/engine/files_test.go new file mode 100644 index 00000000..e53263c7 --- /dev/null +++ b/pkg/engine/files_test.go @@ -0,0 +1,111 @@ +/* +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 engine + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +var cases = []struct { + path, data string +}{ + {"ship/captain.txt", "The Captain"}, + {"ship/stowaway.txt", "Legatt"}, + {"story/name.txt", "The Secret Sharer"}, + {"story/author.txt", "Joseph Conrad"}, + {"multiline/test.txt", "bar\nfoo\n"}, + {"multiline/test_with_blank_lines.txt", "bar\nfoo\n\n\n"}, +} + +func getTestFiles() files { + a := make(files, len(cases)) + for _, c := range cases { + a[c.path] = []byte(c.data) + } + return a +} + +func TestNewFiles(t *testing.T) { + files := getTestFiles() + if len(files) != len(cases) { + t.Errorf("Expected len() = %d, got %d", len(cases), len(files)) + } + + for i, f := range cases { + if got := string(files.GetBytes(f.path)); got != f.data { + t.Errorf("%d: expected %q, got %q", i, f.data, got) + } + if got := files.Get(f.path); got != f.data { + t.Errorf("%d: expected %q, got %q", i, f.data, got) + } + } +} + +func TestFileGlob(t *testing.T) { + as := assert.New(t) + + f := getTestFiles() + + matched := f.Glob("story/**") + + as.Len(matched, 2, "Should be two files in glob story/**") + as.Equal("Joseph Conrad", matched.Get("story/author.txt")) +} + +func TestToConfig(t *testing.T) { + as := assert.New(t) + + f := getTestFiles() + out := f.Glob("**/captain.txt").AsConfig() + as.Equal("captain.txt: The Captain", out) + + out = f.Glob("ship/**").AsConfig() + as.Equal("captain.txt: The Captain\nstowaway.txt: Legatt", out) +} + +func TestToSecret(t *testing.T) { + as := assert.New(t) + + f := getTestFiles() + + out := f.Glob("ship/**").AsSecrets() + as.Equal("captain.txt: VGhlIENhcHRhaW4=\nstowaway.txt: TGVnYXR0", out) +} + +func TestLines(t *testing.T) { + as := assert.New(t) + + f := getTestFiles() + + out := f.Lines("multiline/test.txt") + as.Len(out, 2) + + as.Equal("bar", out[0]) +} + +func TestBlankLines(t *testing.T) { + as := assert.New(t) + + f := getTestFiles() + + out := f.Lines("multiline/test_with_blank_lines.txt") + as.Len(out, 4) + + as.Equal("bar", out[0]) + as.Equal("", out[3]) +} diff --git a/pkg/engine/funcs.go b/pkg/engine/funcs.go new file mode 100644 index 00000000..8f05a3a1 --- /dev/null +++ b/pkg/engine/funcs.go @@ -0,0 +1,176 @@ +/* +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 engine + +import ( + "bytes" + "encoding/json" + "strings" + "text/template" + + "github.com/BurntSushi/toml" + "github.com/Masterminds/sprig/v3" + "sigs.k8s.io/yaml" +) + +// funcMap returns a mapping of all of the functions that Engine has. +// +// Because some functions are late-bound (e.g. contain context-sensitive +// data), the functions may not all perform identically outside of an Engine +// as they will inside of an Engine. +// +// Known late-bound functions: +// +// - "include" +// - "tpl" +// +// These are late-bound in Engine.Render(). The +// version included in the FuncMap is a placeholder. +func funcMap() template.FuncMap { + f := sprig.TxtFuncMap() + delete(f, "env") + delete(f, "expandenv") + + // Add some extra functionality + extra := template.FuncMap{ + "toToml": toTOML, + "toYaml": toYAML, + "fromYaml": fromYAML, + "fromYamlArray": fromYAMLArray, + "toJson": toJSON, + "fromJson": fromJSON, + "fromJsonArray": fromJSONArray, + + // This is a placeholder for the "include" function, which is + // late-bound to a template. By declaring it here, we preserve the + // integrity of the linter. + "include": func(string, interface{}) string { return "not implemented" }, + "tpl": func(string, interface{}) interface{} { return "not implemented" }, + "required": func(string, interface{}) (interface{}, error) { return "not implemented", nil }, + // Provide a placeholder for the "lookup" function, which requires a kubernetes + // connection. + "lookup": func(string, string, string, string) (map[string]interface{}, error) { + return map[string]interface{}{}, nil + }, + } + + for k, v := range extra { + f[k] = v + } + + return f +} + +// toYAML takes an interface, marshals it to yaml, and returns a string. It will +// always return a string, even on marshal error (empty string). +// +// This is designed to be called from a template. +func toYAML(v interface{}) string { + data, err := yaml.Marshal(v) + if err != nil { + // Swallow errors inside of a template. + return "" + } + return strings.TrimSuffix(string(data), "\n") +} + +// fromYAML converts a YAML document into a map[string]interface{}. +// +// This is not a general-purpose YAML parser, and will not parse all valid +// YAML documents. Additionally, because its intended use is within templates +// it tolerates errors. It will insert the returned error message string into +// m["Error"] in the returned map. +func fromYAML(str string) map[string]interface{} { + m := map[string]interface{}{} + + if err := yaml.Unmarshal([]byte(str), &m); err != nil { + m["Error"] = err.Error() + } + return m +} + +// fromYAMLArray converts a YAML array into a []interface{}. +// +// This is not a general-purpose YAML parser, and will not parse all valid +// YAML documents. Additionally, because its intended use is within templates +// it tolerates errors. It will insert the returned error message string as +// the first and only item in the returned array. +func fromYAMLArray(str string) []interface{} { + a := []interface{}{} + + if err := yaml.Unmarshal([]byte(str), &a); err != nil { + a = []interface{}{err.Error()} + } + return a +} + +// toTOML takes an interface, marshals it to toml, and returns a string. It will +// always return a string, even on marshal error (empty string). +// +// This is designed to be called from a template. +func toTOML(v interface{}) string { + b := bytes.NewBuffer(nil) + e := toml.NewEncoder(b) + err := e.Encode(v) + if err != nil { + return err.Error() + } + return b.String() +} + +// toJSON takes an interface, marshals it to json, and returns a string. It will +// always return a string, even on marshal error (empty string). +// +// This is designed to be called from a template. +func toJSON(v interface{}) string { + data, err := json.Marshal(v) + if err != nil { + // Swallow errors inside of a template. + return "" + } + return string(data) +} + +// fromJSON converts a JSON document into a map[string]interface{}. +// +// This is not a general-purpose JSON parser, and will not parse all valid +// JSON documents. Additionally, because its intended use is within templates +// it tolerates errors. It will insert the returned error message string into +// m["Error"] in the returned map. +func fromJSON(str string) map[string]interface{} { + m := make(map[string]interface{}) + + if err := json.Unmarshal([]byte(str), &m); err != nil { + m["Error"] = err.Error() + } + return m +} + +// fromJSONArray converts a JSON array into a []interface{}. +// +// This is not a general-purpose JSON parser, and will not parse all valid +// JSON documents. Additionally, because its intended use is within templates +// it tolerates errors. It will insert the returned error message string as +// the first and only item in the returned array. +func fromJSONArray(str string) []interface{} { + a := []interface{}{} + + if err := json.Unmarshal([]byte(str), &a); err != nil { + a = []interface{}{err.Error()} + } + return a +} diff --git a/pkg/engine/funcs_test.go b/pkg/engine/funcs_test.go new file mode 100644 index 00000000..29bc121b --- /dev/null +++ b/pkg/engine/funcs_test.go @@ -0,0 +1,178 @@ +/* +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 engine + +import ( + "strings" + "testing" + "text/template" + + "github.com/stretchr/testify/assert" +) + +func TestFuncs(t *testing.T) { + //TODO write tests for failure cases + tests := []struct { + tpl, expect string + vars interface{} + }{{ + tpl: `{{ toYaml . }}`, + expect: `foo: bar`, + vars: map[string]interface{}{"foo": "bar"}, + }, { + tpl: `{{ toToml . }}`, + expect: "foo = \"bar\"\n", + vars: map[string]interface{}{"foo": "bar"}, + }, { + tpl: `{{ toJson . }}`, + expect: `{"foo":"bar"}`, + vars: map[string]interface{}{"foo": "bar"}, + }, { + tpl: `{{ fromYaml . }}`, + expect: "map[hello:world]", + vars: `hello: world`, + }, { + tpl: `{{ fromYamlArray . }}`, + expect: "[one 2 map[name:helm]]", + vars: "- one\n- 2\n- name: helm\n", + }, { + tpl: `{{ fromYamlArray . }}`, + expect: "[one 2 map[name:helm]]", + vars: `["one", 2, { "name": "helm" }]`, + }, { + // Regression for https://github.com/helm/helm/issues/2271 + tpl: `{{ toToml . }}`, + expect: "[mast]\n sail = \"white\"\n", + vars: map[string]map[string]string{"mast": {"sail": "white"}}, + }, { + tpl: `{{ fromYaml . }}`, + expect: "map[Error:error unmarshaling JSON: while decoding JSON: json: cannot unmarshal array into Go value of type map[string]interface {}]", + vars: "- one\n- two\n", + }, { + tpl: `{{ fromJson .}}`, + expect: `map[hello:world]`, + vars: `{"hello":"world"}`, + }, { + tpl: `{{ fromJson . }}`, + expect: `map[Error:json: cannot unmarshal array into Go value of type map[string]interface {}]`, + vars: `["one", "two"]`, + }, { + tpl: `{{ fromJsonArray . }}`, + expect: `[one 2 map[name:helm]]`, + vars: `["one", 2, { "name": "helm" }]`, + }, { + tpl: `{{ fromJsonArray . }}`, + expect: `[json: cannot unmarshal object into Go value of type []interface {}]`, + vars: `{"hello": "world"}`, + }, { + tpl: `{{ merge .dict (fromYaml .yaml) }}`, + expect: `map[a:map[b:c]]`, + vars: map[string]interface{}{"dict": map[string]interface{}{"a": map[string]interface{}{"b": "c"}}, "yaml": `{"a":{"b":"d"}}`}, + }, { + tpl: `{{ merge (fromYaml .yaml) .dict }}`, + expect: `map[a:map[b:d]]`, + vars: map[string]interface{}{"dict": map[string]interface{}{"a": map[string]interface{}{"b": "c"}}, "yaml": `{"a":{"b":"d"}}`}, + }, { + tpl: `{{ fromYaml . }}`, + expect: `map[Error:error unmarshaling JSON: while decoding JSON: json: cannot unmarshal array into Go value of type map[string]interface {}]`, + vars: `["one", "two"]`, + }, { + tpl: `{{ fromYamlArray . }}`, + expect: `[error unmarshaling JSON: while decoding JSON: json: cannot unmarshal object into Go value of type []interface {}]`, + vars: `hello: world`, + }, { + // This should never result in a network lookup. Regression for #7955 + tpl: `{{ lookup "v1" "Namespace" "" "unlikelynamespace99999999" }}`, + expect: `map[]`, + vars: `["one", "two"]`, + }} + + for _, tt := range tests { + var b strings.Builder + err := template.Must(template.New("test").Funcs(funcMap()).Parse(tt.tpl)).Execute(&b, tt.vars) + assert.NoError(t, err) + assert.Equal(t, tt.expect, b.String(), tt.tpl) + } +} + +// This test to check a function provided by sprig is due to a change in a +// dependency of sprig. mergo in v0.3.9 changed the way it merges and only does +// public fields (i.e. those starting with a capital letter). This test, from +// sprig, fails in the new version. This is a behavior change for mergo that +// impacts sprig and Helm users. This test will help us to not update to a +// version of mergo (even accidentally) that causes a breaking change. See +// sprig changelog and notes for more details. +// Note, Go modules assume semver is never broken. So, there is no way to tell +// the tooling to not update to a minor or patch version. `go install` could +// be used to accidentally update mergo. This test and message should catch +// the problem and explain why it's happening. +func TestMerge(t *testing.T) { + dict := map[string]interface{}{ + "src2": map[string]interface{}{ + "h": 10, + "i": "i", + "j": "j", + }, + "src1": map[string]interface{}{ + "a": 1, + "b": 2, + "d": map[string]interface{}{ + "e": "four", + }, + "g": []int{6, 7}, + "i": "aye", + "j": "jay", + "k": map[string]interface{}{ + "l": false, + }, + }, + "dst": map[string]interface{}{ + "a": "one", + "c": 3, + "d": map[string]interface{}{ + "f": 5, + }, + "g": []int{8, 9}, + "i": "eye", + "k": map[string]interface{}{ + "l": true, + }, + }, + } + tpl := `{{merge .dst .src1 .src2}}` + var b strings.Builder + err := template.Must(template.New("test").Funcs(funcMap()).Parse(tpl)).Execute(&b, dict) + assert.NoError(t, err) + + expected := map[string]interface{}{ + "a": "one", // key overridden + "b": 2, // merged from src1 + "c": 3, // merged from dst + "d": map[string]interface{}{ // deep merge + "e": "four", + "f": 5, + }, + "g": []int{8, 9}, // overridden - arrays are not merged + "h": 10, // merged from src2 + "i": "eye", // overridden twice + "j": "jay", // overridden and merged + "k": map[string]interface{}{ + "l": true, // overridden + }, + } + assert.Equal(t, expected, dict["dst"]) +} diff --git a/pkg/engine/lookup_func.go b/pkg/engine/lookup_func.go new file mode 100644 index 00000000..86a7d698 --- /dev/null +++ b/pkg/engine/lookup_func.go @@ -0,0 +1,143 @@ +/* +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 engine + +import ( + "context" + "log" + "strings" + + "github.com/pkg/errors" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/discovery" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" +) + +type lookupFunc = func(apiversion string, resource string, namespace string, name string) (map[string]interface{}, error) + +// NewLookupFunction returns a function for looking up objects in the cluster. +// +// If the resource does not exist, no error is raised. +// +// This function is considered deprecated, and will be renamed in Helm 4. It will no +// longer be a public function. +func NewLookupFunction(config *rest.Config) lookupFunc { + return newLookupFunction(clientProviderFromConfig{config: config}) +} + +type ClientProvider interface { + // GetClientFor returns a dynamic.NamespaceableResourceInterface suitable for interacting with resources + // corresponding to the provided apiVersion and kind, as well as a boolean indicating whether the resources + // are namespaced. + GetClientFor(apiVersion, kind string) (dynamic.NamespaceableResourceInterface, bool, error) +} + +type clientProviderFromConfig struct { + config *rest.Config +} + +func (c clientProviderFromConfig) GetClientFor(apiVersion, kind string) (dynamic.NamespaceableResourceInterface, bool, error) { + return getDynamicClientOnKind(apiVersion, kind, c.config) +} + +func newLookupFunction(clientProvider ClientProvider) lookupFunc { + return func(apiversion string, kind string, namespace string, name string) (map[string]interface{}, error) { + var client dynamic.ResourceInterface + c, namespaced, err := clientProvider.GetClientFor(apiversion, kind) + if err != nil { + return map[string]interface{}{}, err + } + if namespaced && namespace != "" { + client = c.Namespace(namespace) + } else { + client = c + } + if name != "" { + // this will return a single object + obj, err := client.Get(context.Background(), name, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + // Just return an empty interface when the object was not found. + // That way, users can use `if not (lookup ...)` in their templates. + return map[string]interface{}{}, nil + } + return map[string]interface{}{}, err + } + return obj.UnstructuredContent(), nil + } + // this will return a list + obj, err := client.List(context.Background(), metav1.ListOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + // Just return an empty interface when the object was not found. + // That way, users can use `if not (lookup ...)` in their templates. + return map[string]interface{}{}, nil + } + return map[string]interface{}{}, err + } + return obj.UnstructuredContent(), nil + } +} + +// getDynamicClientOnKind returns a dynamic client on an Unstructured type. This client can be further namespaced. +func getDynamicClientOnKind(apiversion string, kind string, config *rest.Config) (dynamic.NamespaceableResourceInterface, bool, error) { + gvk := schema.FromAPIVersionAndKind(apiversion, kind) + apiRes, err := getAPIResourceForGVK(gvk, config) + if err != nil { + log.Printf("[ERROR] unable to get apiresource from unstructured: %s , error %s", gvk.String(), err) + return nil, false, errors.Wrapf(err, "unable to get apiresource from unstructured: %s", gvk.String()) + } + gvr := schema.GroupVersionResource{ + Group: apiRes.Group, + Version: apiRes.Version, + Resource: apiRes.Name, + } + intf, err := dynamic.NewForConfig(config) + if err != nil { + log.Printf("[ERROR] unable to get dynamic client %s", err) + return nil, false, err + } + res := intf.Resource(gvr) + return res, apiRes.Namespaced, nil +} + +func getAPIResourceForGVK(gvk schema.GroupVersionKind, config *rest.Config) (metav1.APIResource, error) { + res := metav1.APIResource{} + discoveryClient, err := discovery.NewDiscoveryClientForConfig(config) + if err != nil { + log.Printf("[ERROR] unable to create discovery client %s", err) + return res, err + } + resList, err := discoveryClient.ServerResourcesForGroupVersion(gvk.GroupVersion().String()) + if err != nil { + log.Printf("[ERROR] unable to retrieve resource list for: %s , error: %s", gvk.GroupVersion().String(), err) + return res, err + } + for _, resource := range resList.APIResources { + // if a resource contains a "/" it's referencing a subresource. we don't support suberesource for now. + if resource.Kind == gvk.Kind && !strings.Contains(resource.Name, "/") { + res = resource + res.Group = gvk.Group + res.Version = gvk.Version + break + } + } + return res, nil +} diff --git a/pkg/gates/doc.go b/pkg/gates/doc.go new file mode 100644 index 00000000..6592cf4d --- /dev/null +++ b/pkg/gates/doc.go @@ -0,0 +1,21 @@ +/* +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 gates provides a general tool for working with experimental feature gates. + +This provides convenience methods where the user can determine if certain experimental features are enabled. +*/ +package gates diff --git a/pkg/gates/gates.go b/pkg/gates/gates.go new file mode 100644 index 00000000..69559219 --- /dev/null +++ b/pkg/gates/gates.go @@ -0,0 +1,38 @@ +/* +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 gates + +import ( + "fmt" + "os" +) + +// Gate is the name of the feature gate. +type Gate string + +// String returns the string representation of this feature gate. +func (g Gate) String() string { + return string(g) +} + +// IsEnabled determines whether a certain feature gate is enabled. +func (g Gate) IsEnabled() bool { + return os.Getenv(string(g)) != "" +} + +func (g Gate) Error() error { + return fmt.Errorf("this feature has been marked as experimental and is not enabled by default. Please set %s=1 in your environment to use this feature", g.String()) +} diff --git a/pkg/gates/gates_test.go b/pkg/gates/gates_test.go new file mode 100644 index 00000000..6bdd17ed --- /dev/null +++ b/pkg/gates/gates_test.go @@ -0,0 +1,56 @@ +/* +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 gates + +import ( + "os" + "testing" +) + +const name string = "HELM_EXPERIMENTAL_FEATURE" + +func TestIsEnabled(t *testing.T) { + os.Unsetenv(name) + g := Gate(name) + + if g.IsEnabled() { + t.Errorf("feature gate shows as available, but the environment variable %s was not set", name) + } + + os.Setenv(name, "1") + + if !g.IsEnabled() { + t.Errorf("feature gate shows as disabled, but the environment variable %s was set", name) + } +} + +func TestError(t *testing.T) { + os.Unsetenv(name) + g := Gate(name) + + if g.Error().Error() != "this feature has been marked as experimental and is not enabled by default. Please set HELM_EXPERIMENTAL_FEATURE=1 in your environment to use this feature" { + t.Errorf("incorrect error message. Received %s", g.Error().Error()) + } +} + +func TestString(t *testing.T) { + os.Unsetenv(name) + g := Gate(name) + + if g.String() != "HELM_EXPERIMENTAL_FEATURE" { + t.Errorf("incorrect string representation. Received %s", g.String()) + } +} diff --git a/pkg/getter/doc.go b/pkg/getter/doc.go new file mode 100644 index 00000000..11cf6153 --- /dev/null +++ b/pkg/getter/doc.go @@ -0,0 +1,22 @@ +/* +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 getter provides a generalize tool for fetching data by scheme. + +This provides a method by which the plugin system can load arbitrary protocol +handlers based upon a URL scheme. +*/ +package getter diff --git a/pkg/getter/getter.go b/pkg/getter/getter.go new file mode 100644 index 00000000..45ab4da7 --- /dev/null +++ b/pkg/getter/getter.go @@ -0,0 +1,212 @@ +/* +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 getter + +import ( + "bytes" + "net/http" + "time" + + "github.com/pkg/errors" + + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/registry" +) + +// options are generic parameters to be provided to the getter during instantiation. +// +// Getters may or may not ignore these parameters as they are passed in. +type options struct { + url string + certFile string + keyFile string + caFile string + unTar bool + insecureSkipVerifyTLS bool + plainHTTP bool + username string + password string + passCredentialsAll bool + userAgent string + version string + registryClient *registry.Client + timeout time.Duration + transport *http.Transport +} + +// Option allows specifying various settings configurable by the user for overriding the defaults +// used when performing Get operations with the Getter. +type Option func(*options) + +// WithURL informs the getter the server name that will be used when fetching objects. Used in conjunction with +// WithTLSClientConfig to set the TLSClientConfig's server name. +func WithURL(url string) Option { + return func(opts *options) { + opts.url = url + } +} + +// WithBasicAuth sets the request's Authorization header to use the provided credentials +func WithBasicAuth(username, password string) Option { + return func(opts *options) { + opts.username = username + opts.password = password + } +} + +func WithPassCredentialsAll(pass bool) Option { + return func(opts *options) { + opts.passCredentialsAll = pass + } +} + +// WithUserAgent sets the request's User-Agent header to use the provided agent name. +func WithUserAgent(userAgent string) Option { + return func(opts *options) { + opts.userAgent = userAgent + } +} + +// WithInsecureSkipVerifyTLS determines if a TLS Certificate will be checked +func WithInsecureSkipVerifyTLS(insecureSkipVerifyTLS bool) Option { + return func(opts *options) { + opts.insecureSkipVerifyTLS = insecureSkipVerifyTLS + } +} + +// WithTLSClientConfig sets the client auth with the provided credentials. +func WithTLSClientConfig(certFile, keyFile, caFile string) Option { + return func(opts *options) { + opts.certFile = certFile + opts.keyFile = keyFile + opts.caFile = caFile + } +} + +func WithPlainHTTP(plainHTTP bool) Option { + return func(opts *options) { + opts.plainHTTP = plainHTTP + } +} + +// WithTimeout sets the timeout for requests +func WithTimeout(timeout time.Duration) Option { + return func(opts *options) { + opts.timeout = timeout + } +} + +func WithTagName(tagname string) Option { + return func(opts *options) { + opts.version = tagname + } +} + +func WithRegistryClient(client *registry.Client) Option { + return func(opts *options) { + opts.registryClient = client + } +} + +func WithUntar() Option { + return func(opts *options) { + opts.unTar = true + } +} + +// WithTransport sets the http.Transport to allow overwriting the HTTPGetter default. +func WithTransport(transport *http.Transport) Option { + return func(opts *options) { + opts.transport = transport + } +} + +// Getter is an interface to support GET to the specified URL. +type Getter interface { + // Get file content by url string + Get(url string, options ...Option) (*bytes.Buffer, error) +} + +// Constructor is the function for every getter which creates a specific instance +// according to the configuration +type Constructor func(options ...Option) (Getter, error) + +// Provider represents any getter and the schemes that it supports. +// +// For example, an HTTP provider may provide one getter that handles both +// 'http' and 'https' schemes. +type Provider struct { + Schemes []string + New Constructor +} + +// Provides returns true if the given scheme is supported by this Provider. +func (p Provider) Provides(scheme string) bool { + for _, i := range p.Schemes { + if i == scheme { + return true + } + } + return false +} + +// Providers is a collection of Provider objects. +type Providers []Provider + +// ByScheme returns a Provider that handles the given scheme. +// +// If no provider handles this scheme, this will return an error. +func (p Providers) ByScheme(scheme string) (Getter, error) { + for _, pp := range p { + if pp.Provides(scheme) { + return pp.New() + } + } + return nil, errors.Errorf("scheme %q not supported", scheme) +} + +const ( + // The cost timeout references curl's default connection timeout. + // https://github.com/curl/curl/blob/master/lib/connect.h#L40C21-L40C21 + // The helm commands are usually executed manually. Considering the acceptable waiting time, we reduced the entire request time to 120s. + DefaultHTTPTimeout = 120 +) + +var defaultOptions = []Option{WithTimeout(time.Second * DefaultHTTPTimeout)} + +var httpProvider = Provider{ + Schemes: []string{"http", "https"}, + New: func(options ...Option) (Getter, error) { + options = append(options, defaultOptions...) + return NewHTTPGetter(options...) + }, +} + +var ociProvider = Provider{ + Schemes: []string{registry.OCIScheme}, + New: NewOCIGetter, +} + +// All finds all of the registered getters as a list of Provider instances. +// Currently, the built-in getters and the discovered plugins with downloader +// notations are collected. +func All(settings *cli.EnvSettings) Providers { + result := Providers{httpProvider, ociProvider} + pluginDownloaders, _ := collectPlugins(settings) + result = append(result, pluginDownloaders...) + return result +} diff --git a/pkg/getter/getter_test.go b/pkg/getter/getter_test.go new file mode 100644 index 00000000..ab14784a --- /dev/null +++ b/pkg/getter/getter_test.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 getter + +import ( + "testing" + + "helm.sh/helm/v3/pkg/cli" +) + +const pluginDir = "testdata/plugins" + +func TestProvider(t *testing.T) { + p := Provider{ + []string{"one", "three"}, + func(_ ...Option) (Getter, error) { return nil, nil }, + } + + if !p.Provides("three") { + t.Error("Expected provider to provide three") + } +} + +func TestProviders(t *testing.T) { + ps := Providers{ + {[]string{"one", "three"}, func(_ ...Option) (Getter, error) { return nil, nil }}, + {[]string{"two", "four"}, func(_ ...Option) (Getter, error) { return nil, nil }}, + } + + if _, err := ps.ByScheme("one"); err != nil { + t.Error(err) + } + if _, err := ps.ByScheme("four"); err != nil { + t.Error(err) + } + + if _, err := ps.ByScheme("five"); err == nil { + t.Error("Did not expect handler for five") + } +} + +func TestAll(t *testing.T) { + env := cli.New() + env.PluginsDirectory = pluginDir + + all := All(env) + if len(all) != 4 { + t.Errorf("expected 4 providers (default plus three plugins), got %d", len(all)) + } + + if _, err := all.ByScheme("test2"); err != nil { + t.Error(err) + } +} + +func TestByScheme(t *testing.T) { + env := cli.New() + env.PluginsDirectory = pluginDir + + g := All(env) + if _, err := g.ByScheme("test"); err != nil { + t.Error(err) + } + if _, err := g.ByScheme("https"); err != nil { + t.Error(err) + } +} diff --git a/pkg/getter/httpgetter.go b/pkg/getter/httpgetter.go new file mode 100644 index 00000000..b53e558e --- /dev/null +++ b/pkg/getter/httpgetter.go @@ -0,0 +1,157 @@ +/* +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 getter + +import ( + "bytes" + "crypto/tls" + "io" + "net/http" + "net/url" + "sync" + + "github.com/pkg/errors" + + "helm.sh/helm/v3/internal/tlsutil" + "helm.sh/helm/v3/internal/urlutil" + "helm.sh/helm/v3/internal/version" +) + +// HTTPGetter is the default HTTP(/S) backend handler +type HTTPGetter struct { + opts options + transport *http.Transport + once sync.Once +} + +// Get performs a Get from repo.Getter and returns the body. +func (g *HTTPGetter) Get(href string, options ...Option) (*bytes.Buffer, error) { + for _, opt := range options { + opt(&g.opts) + } + return g.get(href) +} + +func (g *HTTPGetter) get(href string) (*bytes.Buffer, error) { + // Set a helm specific user agent so that a repo server and metrics can + // separate helm calls from other tools interacting with repos. + req, err := http.NewRequest(http.MethodGet, href, nil) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", version.GetUserAgent()) + if g.opts.userAgent != "" { + req.Header.Set("User-Agent", g.opts.userAgent) + } + + // Before setting the basic auth credentials, make sure the URL associated + // with the basic auth is the one being fetched. + u1, err := url.Parse(g.opts.url) + if err != nil { + return nil, errors.Wrap(err, "Unable to parse getter URL") + } + u2, err := url.Parse(href) + if err != nil { + return nil, errors.Wrap(err, "Unable to parse URL getting from") + } + + // Host on URL (returned from url.Parse) contains the port if present. + // This check ensures credentials are not passed between different + // services on different ports. + if g.opts.passCredentialsAll || (u1.Scheme == u2.Scheme && u1.Host == u2.Host) { + if g.opts.username != "" && g.opts.password != "" { + req.SetBasicAuth(g.opts.username, g.opts.password) + } + } + + client, err := g.httpClient() + if err != nil { + return nil, err + } + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, errors.Errorf("failed to fetch %s : %s", href, resp.Status) + } + + buf := bytes.NewBuffer(nil) + _, err = io.Copy(buf, resp.Body) + return buf, err +} + +// NewHTTPGetter constructs a valid http/https client as a Getter +func NewHTTPGetter(options ...Option) (Getter, error) { + var client HTTPGetter + + for _, opt := range options { + opt(&client.opts) + } + + return &client, nil +} + +func (g *HTTPGetter) httpClient() (*http.Client, error) { + if g.opts.transport != nil { + return &http.Client{ + Transport: g.opts.transport, + Timeout: g.opts.timeout, + }, nil + } + + g.once.Do(func() { + g.transport = &http.Transport{ + DisableCompression: true, + Proxy: http.ProxyFromEnvironment, + } + }) + + if (g.opts.certFile != "" && g.opts.keyFile != "") || g.opts.caFile != "" || g.opts.insecureSkipVerifyTLS { + tlsConf, err := tlsutil.NewClientTLS(g.opts.certFile, g.opts.keyFile, g.opts.caFile, g.opts.insecureSkipVerifyTLS) + if err != nil { + return nil, errors.Wrap(err, "can't create TLS config for client") + } + + sni, err := urlutil.ExtractHostname(g.opts.url) + if err != nil { + return nil, err + } + tlsConf.ServerName = sni + + g.transport.TLSClientConfig = tlsConf + } + + if g.opts.insecureSkipVerifyTLS { + if g.transport.TLSClientConfig == nil { + g.transport.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: true, + } + } else { + g.transport.TLSClientConfig.InsecureSkipVerify = true + } + } + + client := &http.Client{ + Transport: g.transport, + Timeout: g.opts.timeout, + } + + return client, nil +} diff --git a/pkg/getter/httpgetter_test.go b/pkg/getter/httpgetter_test.go new file mode 100644 index 00000000..c727d0d7 --- /dev/null +++ b/pkg/getter/httpgetter_test.go @@ -0,0 +1,532 @@ +/* +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 getter + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/pkg/errors" + + "helm.sh/helm/v3/internal/tlsutil" + "helm.sh/helm/v3/internal/version" + "helm.sh/helm/v3/pkg/cli" +) + +func TestHTTPGetter(t *testing.T) { + g, err := NewHTTPGetter(WithURL("http://example.com")) + if err != nil { + t.Fatal(err) + } + + if _, ok := g.(*HTTPGetter); !ok { + t.Fatal("Expected NewHTTPGetter to produce an *HTTPGetter") + } + + cd := "../../testdata" + join := filepath.Join + ca, pub, priv := join(cd, "rootca.crt"), join(cd, "crt.pem"), join(cd, "key.pem") + insecure := false + timeout := time.Second * 5 + transport := &http.Transport{} + + // Test with options + g, err = NewHTTPGetter( + WithBasicAuth("I", "Am"), + WithPassCredentialsAll(false), + WithUserAgent("Groot"), + WithTLSClientConfig(pub, priv, ca), + WithInsecureSkipVerifyTLS(insecure), + WithTimeout(timeout), + WithTransport(transport), + ) + if err != nil { + t.Fatal(err) + } + + hg, ok := g.(*HTTPGetter) + if !ok { + t.Fatal("expected NewHTTPGetter to produce an *HTTPGetter") + } + + if hg.opts.username != "I" { + t.Errorf("Expected NewHTTPGetter to contain %q as the username, got %q", "I", hg.opts.username) + } + + if hg.opts.password != "Am" { + t.Errorf("Expected NewHTTPGetter to contain %q as the password, got %q", "Am", hg.opts.password) + } + + if hg.opts.passCredentialsAll != false { + t.Errorf("Expected NewHTTPGetter to contain %t as PassCredentialsAll, got %t", false, hg.opts.passCredentialsAll) + } + + if hg.opts.userAgent != "Groot" { + t.Errorf("Expected NewHTTPGetter to contain %q as the user agent, got %q", "Groot", hg.opts.userAgent) + } + + if hg.opts.certFile != pub { + t.Errorf("Expected NewHTTPGetter to contain %q as the public key file, got %q", pub, hg.opts.certFile) + } + + if hg.opts.keyFile != priv { + t.Errorf("Expected NewHTTPGetter to contain %q as the private key file, got %q", priv, hg.opts.keyFile) + } + + if hg.opts.caFile != ca { + t.Errorf("Expected NewHTTPGetter to contain %q as the CA file, got %q", ca, hg.opts.caFile) + } + + if hg.opts.insecureSkipVerifyTLS != insecure { + t.Errorf("Expected NewHTTPGetter to contain %t as InsecureSkipVerifyTLs flag, got %t", false, hg.opts.insecureSkipVerifyTLS) + } + + if hg.opts.timeout != timeout { + t.Errorf("Expected NewHTTPGetter to contain %s as Timeout flag, got %s", timeout, hg.opts.timeout) + } + + if hg.opts.transport != transport { + t.Errorf("Expected NewHTTPGetter to contain %p as Transport, got %p", transport, hg.opts.transport) + } + + // Test if setting insecureSkipVerifyTLS is being passed to the ops + insecure = true + + g, err = NewHTTPGetter( + WithInsecureSkipVerifyTLS(insecure), + ) + if err != nil { + t.Fatal(err) + } + + hg, ok = g.(*HTTPGetter) + if !ok { + t.Fatal("expected NewHTTPGetter to produce an *HTTPGetter") + } + + if hg.opts.insecureSkipVerifyTLS != insecure { + t.Errorf("Expected NewHTTPGetter to contain %t as InsecureSkipVerifyTLs flag, got %t", insecure, hg.opts.insecureSkipVerifyTLS) + } + + // Checking false by default + if hg.opts.passCredentialsAll != false { + t.Errorf("Expected NewHTTPGetter to contain %t as PassCredentialsAll, got %t", false, hg.opts.passCredentialsAll) + } + + // Test setting PassCredentialsAll + g, err = NewHTTPGetter( + WithBasicAuth("I", "Am"), + WithPassCredentialsAll(true), + ) + if err != nil { + t.Fatal(err) + } + + hg, ok = g.(*HTTPGetter) + if !ok { + t.Fatal("expected NewHTTPGetter to produce an *HTTPGetter") + } + if hg.opts.passCredentialsAll != true { + t.Errorf("Expected NewHTTPGetter to contain %t as PassCredentialsAll, got %t", true, hg.opts.passCredentialsAll) + } +} + +func TestDownload(t *testing.T) { + expect := "Call me Ishmael" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defaultUserAgent := version.GetUserAgent() + if r.UserAgent() != defaultUserAgent { + t.Errorf("Expected '%s', got '%s'", defaultUserAgent, r.UserAgent()) + } + fmt.Fprint(w, expect) + })) + defer srv.Close() + + g, err := All(cli.New()).ByScheme("http") + if err != nil { + t.Fatal(err) + } + got, err := g.Get(srv.URL, WithURL(srv.URL)) + if err != nil { + t.Fatal(err) + } + + if got.String() != expect { + t.Errorf("Expected %q, got %q", expect, got.String()) + } + + // test with http server + const expectedUserAgent = "I am Groot" + basicAuthSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != "username" || password != "password" { + t.Errorf("Expected request to use basic auth and for username == 'username' and password == 'password', got '%v', '%s', '%s'", ok, username, password) + } + if r.UserAgent() != expectedUserAgent { + t.Errorf("Expected '%s', got '%s'", expectedUserAgent, r.UserAgent()) + } + fmt.Fprint(w, expect) + })) + + defer basicAuthSrv.Close() + + u, _ := url.ParseRequestURI(basicAuthSrv.URL) + httpgetter, err := NewHTTPGetter( + WithURL(u.String()), + WithBasicAuth("username", "password"), + WithPassCredentialsAll(false), + WithUserAgent(expectedUserAgent), + ) + if err != nil { + t.Fatal(err) + } + got, err = httpgetter.Get(u.String()) + if err != nil { + t.Fatal(err) + } + + if got.String() != expect { + t.Errorf("Expected %q, got %q", expect, got.String()) + } + + // test with Get URL differing from withURL + crossAuthSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if ok || username == "username" || password == "password" { + t.Errorf("Expected request to not include but got '%v', '%s', '%s'", ok, username, password) + } + fmt.Fprint(w, expect) + })) + + defer crossAuthSrv.Close() + + u, _ = url.ParseRequestURI(crossAuthSrv.URL) + + // A different host is provided for the WithURL from the one used for Get + u2, _ := url.ParseRequestURI(crossAuthSrv.URL) + host := strings.Split(u2.Host, ":") + host[0] = host[0] + "a" + u2.Host = strings.Join(host, ":") + httpgetter, err = NewHTTPGetter( + WithURL(u2.String()), + WithBasicAuth("username", "password"), + WithPassCredentialsAll(false), + ) + if err != nil { + t.Fatal(err) + } + got, err = httpgetter.Get(u.String()) + if err != nil { + t.Fatal(err) + } + + if got.String() != expect { + t.Errorf("Expected %q, got %q", expect, got.String()) + } + + // test with Get URL differing from withURL and should pass creds + crossAuthSrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != "username" || password != "password" { + t.Errorf("Expected request to use basic auth and for username == 'username' and password == 'password', got '%v', '%s', '%s'", ok, username, password) + } + fmt.Fprint(w, expect) + })) + + defer crossAuthSrv.Close() + + u, _ = url.ParseRequestURI(crossAuthSrv.URL) + + // A different host is provided for the WithURL from the one used for Get + u2, _ = url.ParseRequestURI(crossAuthSrv.URL) + host = strings.Split(u2.Host, ":") + host[0] = host[0] + "a" + u2.Host = strings.Join(host, ":") + httpgetter, err = NewHTTPGetter( + WithURL(u2.String()), + WithBasicAuth("username", "password"), + WithPassCredentialsAll(true), + ) + if err != nil { + t.Fatal(err) + } + got, err = httpgetter.Get(u.String()) + if err != nil { + t.Fatal(err) + } + + if got.String() != expect { + t.Errorf("Expected %q, got %q", expect, got.String()) + } +} + +func TestDownloadTLS(t *testing.T) { + cd := "../../testdata" + ca, pub, priv := filepath.Join(cd, "rootca.crt"), filepath.Join(cd, "crt.pem"), filepath.Join(cd, "key.pem") + insecureSkipTLSverify := false + + tlsSrv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + tlsConf, err := tlsutil.NewClientTLS(pub, priv, ca, insecureSkipTLSverify) + if err != nil { + t.Fatal(errors.Wrap(err, "can't create TLS config for client")) + } + tlsConf.ServerName = "helm.sh" + tlsSrv.TLS = tlsConf + tlsSrv.StartTLS() + defer tlsSrv.Close() + + u, _ := url.ParseRequestURI(tlsSrv.URL) + g, err := NewHTTPGetter( + WithURL(u.String()), + WithTLSClientConfig(pub, priv, ca), + ) + if err != nil { + t.Fatal(err) + } + + if _, err := g.Get(u.String()); err != nil { + t.Error(err) + } + + // now test with TLS config being passed along in .Get (see #6635) + g, err = NewHTTPGetter() + if err != nil { + t.Fatal(err) + } + + if _, err := g.Get(u.String(), WithURL(u.String()), WithTLSClientConfig(pub, priv, ca)); err != nil { + t.Error(err) + } + + // test with only the CA file (see also #6635) + g, err = NewHTTPGetter() + if err != nil { + t.Fatal(err) + } + + if _, err := g.Get(u.String(), WithURL(u.String()), WithTLSClientConfig("", "", ca)); err != nil { + t.Error(err) + } +} + +func TestDownloadInsecureSkipTLSVerify(t *testing.T) { + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer ts.Close() + + u, _ := url.ParseRequestURI(ts.URL) + + // Ensure the default behavior did not change + g, err := NewHTTPGetter( + WithURL(u.String()), + ) + if err != nil { + t.Error(err) + } + + if _, err := g.Get(u.String()); err == nil { + t.Errorf("Expected Getter to throw an error, got %s", err) + } + + // Test certificate check skip + g, err = NewHTTPGetter( + WithURL(u.String()), + WithInsecureSkipVerifyTLS(true), + ) + if err != nil { + t.Error(err) + } + if _, err = g.Get(u.String()); err != nil { + t.Error(err) + } + +} + +func TestHTTPGetterTarDownload(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f, _ := os.Open("testdata/empty-0.0.1.tgz") + defer f.Close() + + b := make([]byte, 512) + f.Read(b) + //Get the file size + FileStat, _ := f.Stat() + FileSize := strconv.FormatInt(FileStat.Size(), 10) + + //Simulating improper header values from bitbucket + w.Header().Set("Content-Type", "application/x-tar") + w.Header().Set("Content-Encoding", "gzip") + w.Header().Set("Content-Length", FileSize) + + f.Seek(0, 0) + io.Copy(w, f) + })) + + defer srv.Close() + + g, err := NewHTTPGetter(WithURL(srv.URL)) + if err != nil { + t.Fatal(err) + } + + data, _ := g.Get(srv.URL) + mimeType := http.DetectContentType(data.Bytes()) + + expectedMimeType := "application/x-gzip" + if mimeType != expectedMimeType { + t.Fatalf("Expected response with MIME type %s, but got %s", expectedMimeType, mimeType) + } +} + +func TestHttpClientInsecureSkipVerify(t *testing.T) { + g := HTTPGetter{} + g.opts.url = "https://localhost" + verifyInsecureSkipVerify(t, &g, "Blank HTTPGetter", false) + + g = HTTPGetter{} + g.opts.url = "https://localhost" + g.opts.caFile = "testdata/ca.crt" + verifyInsecureSkipVerify(t, &g, "HTTPGetter with ca file", false) + + g = HTTPGetter{} + g.opts.url = "https://localhost" + g.opts.insecureSkipVerifyTLS = true + verifyInsecureSkipVerify(t, &g, "HTTPGetter with skip cert verification only", true) + + g = HTTPGetter{} + g.opts.url = "https://localhost" + g.opts.certFile = "testdata/client.crt" + g.opts.keyFile = "testdata/client.key" + g.opts.insecureSkipVerifyTLS = true + transport := verifyInsecureSkipVerify(t, &g, "HTTPGetter with 2 way ssl", true) + if len(transport.TLSClientConfig.Certificates) <= 0 { + t.Fatal("transport.TLSClientConfig.Certificates is not present") + } + if transport.TLSClientConfig.ServerName == "" { + t.Fatal("TLSClientConfig.ServerName is blank") + } +} + +func verifyInsecureSkipVerify(t *testing.T, g *HTTPGetter, caseName string, expectedValue bool) *http.Transport { + returnVal, err := g.httpClient() + + if err != nil { + t.Fatal(err) + } + + if returnVal == nil { //nolint:staticcheck + t.Fatalf("Expected non nil value for http client") + } + transport := (returnVal.Transport).(*http.Transport) //nolint:staticcheck + gotValue := false + if transport.TLSClientConfig != nil { + gotValue = transport.TLSClientConfig.InsecureSkipVerify + } + if gotValue != expectedValue { + t.Fatalf("Case Name = %s\nInsecureSkipVerify did not come as expected. Expected = %t; Got = %v", + caseName, expectedValue, gotValue) + } + return transport +} + +func TestDefaultHTTPTransportReuse(t *testing.T) { + g := HTTPGetter{} + + httpClient1, err := g.httpClient() + + if err != nil { + t.Fatal(err) + } + + if httpClient1 == nil { //nolint:staticcheck + t.Fatalf("Expected non nil value for http client") + } + + transport1 := (httpClient1.Transport).(*http.Transport) //nolint:staticcheck + + httpClient2, err := g.httpClient() + + if err != nil { + t.Fatal(err) + } + + if httpClient2 == nil { //nolint:staticcheck + t.Fatalf("Expected non nil value for http client") + } + + transport2 := (httpClient2.Transport).(*http.Transport) //nolint:staticcheck + + if transport1 != transport2 { + t.Fatalf("Expected default transport to be reused") + } +} + +func TestHTTPTransportOption(t *testing.T) { + transport := &http.Transport{} + + g := HTTPGetter{} + g.opts.transport = transport + httpClient1, err := g.httpClient() + + if err != nil { + t.Fatal(err) + } + + if httpClient1 == nil { //nolint:staticcheck + t.Fatalf("Expected non nil value for http client") + } + + transport1 := (httpClient1.Transport).(*http.Transport) //nolint:staticcheck + + if transport1 != transport { + t.Fatalf("Expected transport option to be applied") + } + + httpClient2, err := g.httpClient() + + if err != nil { + t.Fatal(err) + } + + if httpClient2 == nil { //nolint:staticcheck + t.Fatalf("Expected non nil value for http client") + } + + transport2 := (httpClient2.Transport).(*http.Transport) //nolint:staticcheck + + if transport1 != transport2 { + t.Fatalf("Expected applied transport to be reused") + } + + g = HTTPGetter{} + g.opts.url = "https://localhost" + g.opts.certFile = "testdata/client.crt" + g.opts.keyFile = "testdata/client.key" + g.opts.insecureSkipVerifyTLS = true + g.opts.transport = transport + usedTransport := verifyInsecureSkipVerify(t, &g, "HTTPGetter with 2 way ssl", false) + if usedTransport.TLSClientConfig != nil { + t.Fatal("transport.TLSClientConfig should not be set") + } +} diff --git a/pkg/getter/ocigetter.go b/pkg/getter/ocigetter.go new file mode 100644 index 00000000..209786bd --- /dev/null +++ b/pkg/getter/ocigetter.go @@ -0,0 +1,155 @@ +/* +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 getter + +import ( + "bytes" + "fmt" + "net" + "net/http" + "strings" + "sync" + "time" + + "helm.sh/helm/v3/internal/tlsutil" + "helm.sh/helm/v3/internal/urlutil" + "helm.sh/helm/v3/pkg/registry" +) + +// OCIGetter is the default HTTP(/S) backend handler +type OCIGetter struct { + opts options + transport *http.Transport + once sync.Once +} + +// Get performs a Get from repo.Getter and returns the body. +func (g *OCIGetter) Get(href string, options ...Option) (*bytes.Buffer, error) { + for _, opt := range options { + opt(&g.opts) + } + return g.get(href) +} + +func (g *OCIGetter) get(href string) (*bytes.Buffer, error) { + client := g.opts.registryClient + // if the user has already provided a configured registry client, use it, + // this is particularly true when user has his own way of handling the client credentials. + if client == nil { + c, err := g.newRegistryClient() + if err != nil { + return nil, err + } + client = c + } + + ref := strings.TrimPrefix(href, fmt.Sprintf("%s://", registry.OCIScheme)) + + var pullOpts []registry.PullOption + requestingProv := strings.HasSuffix(ref, ".prov") + if requestingProv { + ref = strings.TrimSuffix(ref, ".prov") + pullOpts = append(pullOpts, + registry.PullOptWithChart(false), + registry.PullOptWithProv(true)) + } + + result, err := client.Pull(ref, pullOpts...) + if err != nil { + return nil, err + } + + if requestingProv { + return bytes.NewBuffer(result.Prov.Data), nil + } + return bytes.NewBuffer(result.Chart.Data), nil +} + +// NewOCIGetter constructs a valid http/https client as a Getter +func NewOCIGetter(ops ...Option) (Getter, error) { + var client OCIGetter + + for _, opt := range ops { + opt(&client.opts) + } + + return &client, nil +} + +func (g *OCIGetter) newRegistryClient() (*registry.Client, error) { + if g.opts.transport != nil { + client, err := registry.NewClient( + registry.ClientOptHTTPClient(&http.Client{ + Transport: g.opts.transport, + Timeout: g.opts.timeout, + }), + ) + if err != nil { + return nil, err + } + return client, nil + } + + g.once.Do(func() { + g.transport = &http.Transport{ + // From https://github.com/google/go-containerregistry/blob/31786c6cbb82d6ec4fb8eb79cd9387905130534e/pkg/v1/remote/options.go#L87 + DisableCompression: true, + DialContext: (&net.Dialer{ + // By default we wrap the transport in retries, so reduce the + // default dial timeout to 5s to avoid 5x 30s of connection + // timeouts when doing the "ping" on certain http registries. + Timeout: 5 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + } + }) + + if (g.opts.certFile != "" && g.opts.keyFile != "") || g.opts.caFile != "" || g.opts.insecureSkipVerifyTLS { + tlsConf, err := tlsutil.NewClientTLS(g.opts.certFile, g.opts.keyFile, g.opts.caFile, g.opts.insecureSkipVerifyTLS) + if err != nil { + return nil, fmt.Errorf("can't create TLS config for client: %w", err) + } + + sni, err := urlutil.ExtractHostname(g.opts.url) + if err != nil { + return nil, err + } + tlsConf.ServerName = sni + + g.transport.TLSClientConfig = tlsConf + } + + opts := []registry.ClientOption{registry.ClientOptHTTPClient(&http.Client{ + Transport: g.transport, + Timeout: g.opts.timeout, + })} + if g.opts.plainHTTP { + opts = append(opts, registry.ClientOptPlainHTTP()) + } + + client, err := registry.NewClient(opts...) + + if err != nil { + return nil, err + } + + return client, nil +} diff --git a/pkg/getter/ocigetter_test.go b/pkg/getter/ocigetter_test.go new file mode 100644 index 00000000..d0834d9f --- /dev/null +++ b/pkg/getter/ocigetter_test.go @@ -0,0 +1,151 @@ +/* +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 getter + +import ( + "net/http" + "path/filepath" + "testing" + "time" + + "helm.sh/helm/v3/pkg/registry" +) + +func TestOCIGetter(t *testing.T) { + g, err := NewOCIGetter(WithURL("oci://example.com")) + if err != nil { + t.Fatal(err) + } + + if _, ok := g.(*OCIGetter); !ok { + t.Fatal("Expected NewOCIGetter to produce an *OCIGetter") + } + + cd := "../../testdata" + join := filepath.Join + ca, pub, priv := join(cd, "rootca.crt"), join(cd, "crt.pem"), join(cd, "key.pem") + timeout := time.Second * 5 + transport := &http.Transport{} + insecureSkipVerifyTLS := false + plainHTTP := false + + // Test with options + g, err = NewOCIGetter( + WithBasicAuth("I", "Am"), + WithTLSClientConfig(pub, priv, ca), + WithTimeout(timeout), + WithTransport(transport), + WithInsecureSkipVerifyTLS(insecureSkipVerifyTLS), + WithPlainHTTP(plainHTTP), + ) + if err != nil { + t.Fatal(err) + } + + og, ok := g.(*OCIGetter) + if !ok { + t.Fatal("expected NewOCIGetter to produce an *OCIGetter") + } + + if og.opts.username != "I" { + t.Errorf("Expected NewOCIGetter to contain %q as the username, got %q", "I", og.opts.username) + } + + if og.opts.password != "Am" { + t.Errorf("Expected NewOCIGetter to contain %q as the password, got %q", "Am", og.opts.password) + } + + if og.opts.certFile != pub { + t.Errorf("Expected NewOCIGetter to contain %q as the public key file, got %q", pub, og.opts.certFile) + } + + if og.opts.keyFile != priv { + t.Errorf("Expected NewOCIGetter to contain %q as the private key file, got %q", priv, og.opts.keyFile) + } + + if og.opts.caFile != ca { + t.Errorf("Expected NewOCIGetter to contain %q as the CA file, got %q", ca, og.opts.caFile) + } + + if og.opts.timeout != timeout { + t.Errorf("Expected NewOCIGetter to contain %s as Timeout flag, got %s", timeout, og.opts.timeout) + } + + if og.opts.transport != transport { + t.Errorf("Expected NewOCIGetter to contain %p as Transport, got %p", transport, og.opts.transport) + } + + if og.opts.plainHTTP != plainHTTP { + t.Errorf("Expected NewOCIGetter to have plainHTTP as %t, got %t", plainHTTP, og.opts.plainHTTP) + } + + if og.opts.insecureSkipVerifyTLS != insecureSkipVerifyTLS { + t.Errorf("Expected NewOCIGetter to have insecureSkipVerifyTLS as %t, got %t", insecureSkipVerifyTLS, og.opts.insecureSkipVerifyTLS) + } + + // Test if setting registryClient is being passed to the ops + registryClient, err := registry.NewClient() + if err != nil { + t.Fatal(err) + } + + g, err = NewOCIGetter( + WithRegistryClient(registryClient), + ) + if err != nil { + t.Fatal(err) + } + og, ok = g.(*OCIGetter) + if !ok { + t.Fatal("expected NewOCIGetter to produce an *OCIGetter") + } + + if og.opts.registryClient != registryClient { + t.Errorf("Expected NewOCIGetter to contain %p as RegistryClient, got %p", registryClient, og.opts.registryClient) + } +} + +func TestOCIHTTPTransportReuse(t *testing.T) { + g := OCIGetter{} + + _, err := g.newRegistryClient() + + if err != nil { + t.Fatal(err) + } + + if g.transport == nil { + t.Fatalf("Expected non nil value for transport") + } + + transport1 := g.transport + + _, err = g.newRegistryClient() + + if err != nil { + t.Fatal(err) + } + + if g.transport == nil { + t.Fatalf("Expected non nil value for transport") + } + + transport2 := g.transport + + if transport1 != transport2 { + t.Fatalf("Expected default transport to be reused") + } +} diff --git a/pkg/getter/plugingetter.go b/pkg/getter/plugingetter.go new file mode 100644 index 00000000..a371b52e --- /dev/null +++ b/pkg/getter/plugingetter.go @@ -0,0 +1,110 @@ +/* +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 getter + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/pkg/errors" + + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/plugin" +) + +// collectPlugins scans for getter plugins. +// This will load plugins according to the cli. +func collectPlugins(settings *cli.EnvSettings) (Providers, error) { + plugins, err := plugin.FindPlugins(settings.PluginsDirectory) + if err != nil { + return nil, err + } + var result Providers + for _, plugin := range plugins { + for _, downloader := range plugin.Metadata.Downloaders { + result = append(result, Provider{ + Schemes: downloader.Protocols, + New: NewPluginGetter( + downloader.Command, + settings, + plugin.Metadata.Name, + plugin.Dir, + ), + }) + } + } + return result, nil +} + +// pluginGetter is a generic type to invoke custom downloaders, +// implemented in plugins. +type pluginGetter struct { + command string + settings *cli.EnvSettings + name string + base string + opts options +} + +func (p *pluginGetter) setupOptionsEnv(env []string) []string { + env = append(env, fmt.Sprintf("HELM_PLUGIN_USERNAME=%s", p.opts.username)) + env = append(env, fmt.Sprintf("HELM_PLUGIN_PASSWORD=%s", p.opts.password)) + env = append(env, fmt.Sprintf("HELM_PLUGIN_PASS_CREDENTIALS_ALL=%t", p.opts.passCredentialsAll)) + return env +} + +// Get runs downloader plugin command +func (p *pluginGetter) Get(href string, options ...Option) (*bytes.Buffer, error) { + for _, opt := range options { + opt(&p.opts) + } + commands := strings.Split(p.command, " ") + argv := append(commands[1:], p.opts.certFile, p.opts.keyFile, p.opts.caFile, href) + prog := exec.Command(filepath.Join(p.base, commands[0]), argv...) + plugin.SetupPluginEnv(p.settings, p.name, p.base) + prog.Env = p.setupOptionsEnv(os.Environ()) + buf := bytes.NewBuffer(nil) + prog.Stdout = buf + prog.Stderr = os.Stderr + if err := prog.Run(); err != nil { + if eerr, ok := err.(*exec.ExitError); ok { + os.Stderr.Write(eerr.Stderr) + return nil, errors.Errorf("plugin %q exited with error", p.command) + } + return nil, err + } + return buf, nil +} + +// NewPluginGetter constructs a valid plugin getter +func NewPluginGetter(command string, settings *cli.EnvSettings, name, base string) Constructor { + return func(options ...Option) (Getter, error) { + result := &pluginGetter{ + command: command, + settings: settings, + name: name, + base: base, + } + for _, opt := range options { + opt(&result.opts) + } + return result, nil + } +} diff --git a/pkg/getter/plugingetter_test.go b/pkg/getter/plugingetter_test.go new file mode 100644 index 00000000..a18fa302 --- /dev/null +++ b/pkg/getter/plugingetter_test.go @@ -0,0 +1,101 @@ +/* +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 getter + +import ( + "runtime" + "strings" + "testing" + + "helm.sh/helm/v3/pkg/cli" +) + +func TestCollectPlugins(t *testing.T) { + env := cli.New() + env.PluginsDirectory = pluginDir + + p, err := collectPlugins(env) + if err != nil { + t.Fatal(err) + } + + if len(p) != 2 { + t.Errorf("Expected 2 plugins, got %d: %v", len(p), p) + } + + if _, err := p.ByScheme("test2"); err != nil { + t.Error(err) + } + + if _, err := p.ByScheme("test"); err != nil { + t.Error(err) + } + + if _, err := p.ByScheme("nosuchthing"); err == nil { + t.Fatal("did not expect protocol handler for nosuchthing") + } +} + +func TestPluginGetter(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("TODO: refactor this test to work on windows") + } + + env := cli.New() + env.PluginsDirectory = pluginDir + pg := NewPluginGetter("echo", env, "test", ".") + g, err := pg() + if err != nil { + t.Fatal(err) + } + + data, err := g.Get("test://foo/bar") + if err != nil { + t.Fatal(err) + } + + expect := "test://foo/bar" + got := strings.TrimSpace(data.String()) + if got != expect { + t.Errorf("Expected %q, got %q", expect, got) + } +} + +func TestPluginSubCommands(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("TODO: refactor this test to work on windows") + } + + env := cli.New() + env.PluginsDirectory = pluginDir + + pg := NewPluginGetter("echo -n", env, "test", ".") + g, err := pg() + if err != nil { + t.Fatal(err) + } + + data, err := g.Get("test://foo/bar") + if err != nil { + t.Fatal(err) + } + + expect := " test://foo/bar" + got := data.String() + if got != expect { + t.Errorf("Expected %q, got %q", expect, got) + } +} diff --git a/pkg/getter/testdata/ca.crt b/pkg/getter/testdata/ca.crt new file mode 100644 index 00000000..c1782008 --- /dev/null +++ b/pkg/getter/testdata/ca.crt @@ -0,0 +1,25 @@ +-----BEGIN CERTIFICATE----- +MIIEJDCCAwygAwIBAgIUcGE5xyj7IH7sZLntsHKxZHCd3awwDQYJKoZIhvcNAQEL +BQAwYTELMAkGA1UEBhMCSU4xDzANBgNVBAgMBktlcmFsYTEOMAwGA1UEBwwFS29j +aGkxGDAWBgNVBAoMD2NoYXJ0bXVzZXVtLmNvbTEXMBUGA1UEAwwOY2hhcnRtdXNl +dW1fY2EwIBcNMjAxMjA0MDkxMjU4WhgPMjI5NDA5MTkwOTEyNThaMGExCzAJBgNV +BAYTAklOMQ8wDQYDVQQIDAZLZXJhbGExDjAMBgNVBAcMBUtvY2hpMRgwFgYDVQQK +DA9jaGFydG11c2V1bS5jb20xFzAVBgNVBAMMDmNoYXJ0bXVzZXVtX2NhMIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqQJi/BRWzaXlkDP48kUAWgaLtD0Y +72E30WBZDAw3S+BaYulRk1LWK1QM+ALiZQb1a6YgNvuERyywOv45pZaC2xtP6Bju ++59kwBrEtNCTNa2cSqs0hSw6NCDe+K8lpFKlTdh4c5sAkiDkMBr1R6uu7o4HvfO0 +iGMZ9VUdrbf4psZIyPVRdt/sAkAKqbjQfxr6VUmMktrZNND+mwPgrhS2kPL4P+JS +zpxgpkuSUvg5DvJuypmCI0fDr6GwshqXM1ONHE0HT8MEVy1xZj9rVHt7sgQhjBX1 +PsFySZrq1lSz8R864c1l+tCGlk9+1ldQjc9tBzdvCjJB+nYfTTpBUk/VKwIDAQAB +o4HRMIHOMB0GA1UdDgQWBBSv1IMZGHWsZVqJkJoPDzVLMcUivjCBngYDVR0jBIGW +MIGTgBSv1IMZGHWsZVqJkJoPDzVLMcUivqFlpGMwYTELMAkGA1UEBhMCSU4xDzAN +BgNVBAgMBktlcmFsYTEOMAwGA1UEBwwFS29jaGkxGDAWBgNVBAoMD2NoYXJ0bXVz +ZXVtLmNvbTEXMBUGA1UEAwwOY2hhcnRtdXNldW1fY2GCFHBhOcco+yB+7GS57bBy +sWRwnd2sMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAI6Fg9F8cjB9 +2jJn1vZPpynSFs7XPlUBVh0YXBt+o6g7+nKInwFBPzPEQ7ZZotz3GIe4I7wYiQAn +c6TU2nnqK+9TLbJIyv6NOfikLgwrTy+dAW8wrOiu+IIzA8Gdy8z8m3B7v9RUYVhx +zoNoqCEvOIzCZKDH68PZDJrDVSuvPPK33Ywj3zxYeDNXU87BKGER0vjeVG4oTAcQ +hKJURh4IRy/eW9NWiFqvNgst7k5MldOgLIOUBh1faaxlWkjuGpfdr/EBAAr491S5 +IPFU7TopsrgANnxldSzVbcgfo2nt0A976T3xZQHy3xpk1rIt55xVzT0W55NRAc7v ++9NTUOB10so= +-----END CERTIFICATE----- diff --git a/pkg/getter/testdata/client.crt b/pkg/getter/testdata/client.crt new file mode 100644 index 00000000..f005f401 --- /dev/null +++ b/pkg/getter/testdata/client.crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDejCCAmKgAwIBAgIUfSn63/ldeo1prOaxXV8I0Id6HTEwDQYJKoZIhvcNAQEL +BQAwYTELMAkGA1UEBhMCSU4xDzANBgNVBAgMBktlcmFsYTEOMAwGA1UEBwwFS29j +aGkxGDAWBgNVBAoMD2NoYXJ0bXVzZXVtLmNvbTEXMBUGA1UEAwwOY2hhcnRtdXNl +dW1fY2EwIBcNMjAxMjA0MDkxMzIwWhgPMjI5NDA5MTkwOTEzMjBaMFwxCzAJBgNV +BAYTAklOMQ8wDQYDVQQIDAZLZXJhbGExDjAMBgNVBAcMBUtvY2hpMRgwFgYDVQQK +DA9jaGFydG11c2V1bS5jb20xEjAQBgNVBAMMCTEyNy4wLjAuMTCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAKeCbADaK+7yrM9rQszF54334mGoSXbXY6Ca +7FKdkgmKCjeeqZ+lr+i+6WQ+O+Tn0dhlyHier42IqUw5Rzzegvl7QrhiChd8C6sW +pEqDK7Z1U+cv9gIabYd+qWDwFw67xiMNQfdZxwI/AgPzixlfsMw3ZNKM3Q0Vxtdz +EEYdEDDNgZ34Cj+KXCPpYDi2i5hZnha4wzIfbL3+z2o7sPBBLBrrsOtPdVVkxysN +HM4h7wp7w7QyOosndFvcTaX7yRA1ka0BoulCt2wdVc2ZBRPiPMySi893VCQ8zeHP +QMFDL3rGmKVLbP1to2dgf9ZgckMEwE8chm2D8Ls87F9tsK9fVlUCAwEAAaMtMCsw +EwYDVR0lBAwwCgYIKwYBBQUHAwIwFAYDVR0RBA0wC4IJMTI3LjAuMC4xMA0GCSqG +SIb3DQEBCwUAA4IBAQCi7z5U9J5DkM6eYzyyH/8p32Azrunw+ZpwtxbKq3xEkpcX +0XtbyTG2szegKF0eLr9NizgEN8M1nvaMO1zuxFMB6tCWO/MyNWH/0T4xvFnnVzJ4 +OKlGSvyIuMW3wofxCLRG4Cpw750iWpJ0GwjTOu2ep5tbnEMC5Ueg55WqCAE/yDrd +nL1wZSGXy1bj5H6q8EM/4/yrzK80QkfdpbDR0NGkDO2mmAKL8d57NuASWljieyV3 +Ty5C8xXw5jF2JIESvT74by8ufozUOPKmgRqySgEPgAkNm0s5a05KAi5Cpyxgdylm +CEvjni1LYGhJp9wXucF9ehKSdsw4qn9T5ire8YfI +-----END CERTIFICATE----- diff --git a/pkg/getter/testdata/client.key b/pkg/getter/testdata/client.key new file mode 100644 index 00000000..4f676ba4 --- /dev/null +++ b/pkg/getter/testdata/client.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAp4JsANor7vKsz2tCzMXnjffiYahJdtdjoJrsUp2SCYoKN56p +n6Wv6L7pZD475OfR2GXIeJ6vjYipTDlHPN6C+XtCuGIKF3wLqxakSoMrtnVT5y/2 +Ahpth36pYPAXDrvGIw1B91nHAj8CA/OLGV+wzDdk0ozdDRXG13MQRh0QMM2BnfgK +P4pcI+lgOLaLmFmeFrjDMh9svf7Pajuw8EEsGuuw6091VWTHKw0cziHvCnvDtDI6 +iyd0W9xNpfvJEDWRrQGi6UK3bB1VzZkFE+I8zJKLz3dUJDzN4c9AwUMvesaYpUts +/W2jZ2B/1mByQwTATxyGbYPwuzzsX22wr19WVQIDAQABAoIBABw7qUSDgUAm+uWC +6KFnAd4115wqJye2qf4Z3pcWI9UjxREW1vQnkvyhoOjabHHqeL4GecGKzYAHdrF4 +Pf+OaXjvQ5GcRKMsrzLJACvm6+k24UtoFAjKt4dM2/OQw/IhyAWEaIfuQ9KnGAne +dKV0MXJaK84pG+DmuLr7k9SddWskElEyxK2j0tvdyI5byRfjf5schac9M4i5ZAYV +pT+PuXZQh8L8GEY2koE+uEMpXGOstD7yUxyV8zHFyBC7FVDkqF4S8IWY+RXQtVd6 +l8B8dRLjKSLBKDB+neStepcwNUyCDYiqyqsKfN7eVHDd0arPm6LeTuAsHKBw2OoN +YdAmUUkCgYEA0vb9mxsMgr0ORTZ14vWghz9K12oKPk9ajYuLTQVn8GQazp0XTIi5 +Mil2I78Qj87ApzGqOyHbkEgpg0C9/mheYLOYNHC4of83kNF+pHbDi1TckwxIaIK0 +rZLb3Az3zZQ2rAWZ2IgSyoeVO9RxYK/RuvPFp+UBeucuXiYoI0YlEXcCgYEAy0Sk +LTiYuLsnk21RIFK01iq4Y+4112K1NGTKu8Wm6wPaPsnLznP6339cEkbiSgbRgERE +jgOfa/CiSw5CVT9dWZuQ3OoQ83pMRb7IB0TobPmhBS/HQZ8Ocbfb6PnxQ3o1Bx7I +QuIpZFxzuTP80p1p2DMDxEl+r/DCvT/wgBKX6ZMCgYAdw1bYMSK8tytyPFK5aGnz +asyGQ6GaVNuzqIJIpYCae6UEjUkiNQ/bsdnHBUey4jpv3CPmH8q4OlYQ/GtRnyvh +fLT2gQirYjRWrBev4EmKOLi9zjfQ9s/CxTtbekDjsgtcjZW85MWx6Rr2y+wK9gMi +2w2BuF9TFZaHFd8Hyvej1QKBgAoFbU6pbqYU3AOhrRE54p54ZrTOhqsCu8pEedY+ +DVeizfyweDLKdwDTx5dDFV7u7R80vmh99zscFvQ6VLzdLd4AFGk/xOwsCFyb5kKt +fAP7Xpvh2iH7FHw4w0e+Is3f1YNvWhIqEj5XbIEh9gHwLsqw4SupL+y+ousvnszB +nemvAoGBAJa7bYG8MMCFJ4OFAmkpgQzHSzq7dzOR6O4GKsQQhiZ/0nRK5l3sLcDO +9viuRfhRepJGbcQ/Hw0AVIRWU01y4mejbuxfUE/FgWBoBBvpbot2zfuJgeFAIvkY +iFsZwuxPQUFobTu2hj6gh0gOKj/LpNXHkZGbZ2zTXmK3GDYlf6bR +-----END RSA PRIVATE KEY----- diff --git a/pkg/getter/testdata/empty-0.0.1.tgz b/pkg/getter/testdata/empty-0.0.1.tgz new file mode 100644 index 0000000000000000000000000000000000000000..6c4c3d20597f344826ec27c18aeca1ae585392b7 GIT binary patch literal 130 zcmb2|=3v-$Wpf+@^V *job.Spec.BackoffLimit { + c.log("Job is failed: %s/%s", job.GetNamespace(), job.GetName()) + // If a job is failed, it can't recover, so throw an error + return false, fmt.Errorf("job is failed: %s/%s", job.GetNamespace(), job.GetName()) + } + if job.Spec.Completions != nil && job.Status.Succeeded < *job.Spec.Completions { + c.log("Job is not completed: %s/%s", job.GetNamespace(), job.GetName()) + return false, nil + } + return true, nil +} + +func (c *ReadyChecker) serviceReady(s *corev1.Service) bool { + // ExternalName Services are external to cluster so helm shouldn't be checking to see if they're 'ready' (i.e. have an IP Set) + if s.Spec.Type == corev1.ServiceTypeExternalName { + return true + } + + // Ensure that the service cluster IP is not empty + if s.Spec.ClusterIP == "" { + c.log("Service does not have cluster IP address: %s/%s", s.GetNamespace(), s.GetName()) + return false + } + + // This checks if the service has a LoadBalancer and that balancer has an Ingress defined + if s.Spec.Type == corev1.ServiceTypeLoadBalancer { + // do not wait when at least 1 external IP is set + if len(s.Spec.ExternalIPs) > 0 { + c.log("Service %s/%s has external IP addresses (%v), marking as ready", s.GetNamespace(), s.GetName(), s.Spec.ExternalIPs) + return true + } + + if s.Status.LoadBalancer.Ingress == nil { + c.log("Service does not have load balancer ingress IP address: %s/%s", s.GetNamespace(), s.GetName()) + return false + } + } + + return true +} + +func (c *ReadyChecker) volumeReady(v *corev1.PersistentVolumeClaim) bool { + if v.Status.Phase != corev1.ClaimBound { + c.log("PersistentVolumeClaim is not bound: %s/%s", v.GetNamespace(), v.GetName()) + return false + } + return true +} + +func (c *ReadyChecker) deploymentReady(rs *appsv1.ReplicaSet, dep *appsv1.Deployment) bool { + // Verify the replicaset readiness + if !c.replicaSetReady(rs) { + return false + } + // Verify the generation observed by the deployment controller matches the spec generation + if dep.Status.ObservedGeneration != dep.ObjectMeta.Generation { + c.log("Deployment is not ready: %s/%s. observedGeneration (%d) does not match spec generation (%d).", dep.Namespace, dep.Name, dep.Status.ObservedGeneration, dep.ObjectMeta.Generation) + return false + } + + expectedReady := *dep.Spec.Replicas - deploymentutil.MaxUnavailable(*dep) + if !(rs.Status.ReadyReplicas >= expectedReady) { + c.log("Deployment is not ready: %s/%s. %d out of %d expected pods are ready", dep.Namespace, dep.Name, rs.Status.ReadyReplicas, expectedReady) + return false + } + return true +} + +func (c *ReadyChecker) daemonSetReady(ds *appsv1.DaemonSet) bool { + // Verify the generation observed by the daemonSet controller matches the spec generation + if ds.Status.ObservedGeneration != ds.ObjectMeta.Generation { + c.log("DaemonSet is not ready: %s/%s. observedGeneration (%d) does not match spec generation (%d).", ds.Namespace, ds.Name, ds.Status.ObservedGeneration, ds.ObjectMeta.Generation) + return false + } + + // If the update strategy is not a rolling update, there will be nothing to wait for + if ds.Spec.UpdateStrategy.Type != appsv1.RollingUpdateDaemonSetStrategyType { + return true + } + + // Make sure all the updated pods have been scheduled + if ds.Status.UpdatedNumberScheduled != ds.Status.DesiredNumberScheduled { + c.log("DaemonSet is not ready: %s/%s. %d out of %d expected pods have been scheduled", ds.Namespace, ds.Name, ds.Status.UpdatedNumberScheduled, ds.Status.DesiredNumberScheduled) + return false + } + maxUnavailable, err := intstr.GetScaledValueFromIntOrPercent(ds.Spec.UpdateStrategy.RollingUpdate.MaxUnavailable, int(ds.Status.DesiredNumberScheduled), true) + if err != nil { + // If for some reason the value is invalid, set max unavailable to the + // number of desired replicas. This is the same behavior as the + // `MaxUnavailable` function in deploymentutil + maxUnavailable = int(ds.Status.DesiredNumberScheduled) + } + + expectedReady := int(ds.Status.DesiredNumberScheduled) - maxUnavailable + if !(int(ds.Status.NumberReady) >= expectedReady) { + c.log("DaemonSet is not ready: %s/%s. %d out of %d expected pods are ready", ds.Namespace, ds.Name, ds.Status.NumberReady, expectedReady) + return false + } + return true +} + +// Because the v1 extensions API is not available on all supported k8s versions +// yet and because Go doesn't support generics, we need to have a duplicate +// function to support the v1beta1 types +func (c *ReadyChecker) crdBetaReady(crd apiextv1beta1.CustomResourceDefinition) bool { + for _, cond := range crd.Status.Conditions { + switch cond.Type { + case apiextv1beta1.Established: + if cond.Status == apiextv1beta1.ConditionTrue { + return true + } + case apiextv1beta1.NamesAccepted: + if cond.Status == apiextv1beta1.ConditionFalse { + // This indicates a naming conflict, but it's probably not the + // job of this function to fail because of that. Instead, + // we treat it as a success, since the process should be able to + // continue. + return true + } + } + } + return false +} + +func (c *ReadyChecker) crdReady(crd apiextv1.CustomResourceDefinition) bool { + for _, cond := range crd.Status.Conditions { + switch cond.Type { + case apiextv1.Established: + if cond.Status == apiextv1.ConditionTrue { + return true + } + case apiextv1.NamesAccepted: + if cond.Status == apiextv1.ConditionFalse { + // This indicates a naming conflict, but it's probably not the + // job of this function to fail because of that. Instead, + // we treat it as a success, since the process should be able to + // continue. + return true + } + } + } + return false +} + +func (c *ReadyChecker) statefulSetReady(sts *appsv1.StatefulSet) bool { + // Verify the generation observed by the statefulSet controller matches the spec generation + if sts.Status.ObservedGeneration != sts.ObjectMeta.Generation { + c.log("StatefulSet is not ready: %s/%s. observedGeneration (%d) does not match spec generation (%d).", sts.Namespace, sts.Name, sts.Status.ObservedGeneration, sts.ObjectMeta.Generation) + return false + } + + // If the update strategy is not a rolling update, there will be nothing to wait for + if sts.Spec.UpdateStrategy.Type != appsv1.RollingUpdateStatefulSetStrategyType { + c.log("StatefulSet skipped ready check: %s/%s. updateStrategy is %v", sts.Namespace, sts.Name, sts.Spec.UpdateStrategy.Type) + return true + } + + // Dereference all the pointers because StatefulSets like them + var partition int + // 1 is the default for replicas if not set + replicas := 1 + // For some reason, even if the update strategy is a rolling update, the + // actual rollingUpdate field can be nil. If it is, we can safely assume + // there is no partition value + if sts.Spec.UpdateStrategy.RollingUpdate != nil && sts.Spec.UpdateStrategy.RollingUpdate.Partition != nil { + partition = int(*sts.Spec.UpdateStrategy.RollingUpdate.Partition) + } + if sts.Spec.Replicas != nil { + replicas = int(*sts.Spec.Replicas) + } + + // Because an update strategy can use partitioning, we need to calculate the + // number of updated replicas we should have. For example, if the replicas + // is set to 3 and the partition is 2, we'd expect only one pod to be + // updated + expectedReplicas := replicas - partition + + // Make sure all the updated pods have been scheduled + if int(sts.Status.UpdatedReplicas) < expectedReplicas { + c.log("StatefulSet is not ready: %s/%s. %d out of %d expected pods have been scheduled", sts.Namespace, sts.Name, sts.Status.UpdatedReplicas, expectedReplicas) + return false + } + + if int(sts.Status.ReadyReplicas) != replicas { + c.log("StatefulSet is not ready: %s/%s. %d out of %d expected pods are ready", sts.Namespace, sts.Name, sts.Status.ReadyReplicas, replicas) + return false + } + // This check only makes sense when all partitions are being upgraded otherwise during a + // partioned rolling upgrade, this condition will never evaluate to true, leading to + // error. + if partition == 0 && sts.Status.CurrentRevision != sts.Status.UpdateRevision { + c.log("StatefulSet is not ready: %s/%s. currentRevision %s does not yet match updateRevision %s", sts.Namespace, sts.Name, sts.Status.CurrentRevision, sts.Status.UpdateRevision) + return false + } + + c.log("StatefulSet is ready: %s/%s. %d out of %d expected pods are ready", sts.Namespace, sts.Name, sts.Status.ReadyReplicas, replicas) + return true +} + +func (c *ReadyChecker) replicationControllerReady(rc *corev1.ReplicationController) bool { + // Verify the generation observed by the replicationController controller matches the spec generation + if rc.Status.ObservedGeneration != rc.ObjectMeta.Generation { + c.log("ReplicationController is not ready: %s/%s. observedGeneration (%d) does not match spec generation (%d).", rc.Namespace, rc.Name, rc.Status.ObservedGeneration, rc.ObjectMeta.Generation) + return false + } + return true +} + +func (c *ReadyChecker) replicaSetReady(rs *appsv1.ReplicaSet) bool { + // Verify the generation observed by the replicaSet controller matches the spec generation + if rs.Status.ObservedGeneration != rs.ObjectMeta.Generation { + c.log("ReplicaSet is not ready: %s/%s. observedGeneration (%d) does not match spec generation (%d).", rs.Namespace, rs.Name, rs.Status.ObservedGeneration, rs.ObjectMeta.Generation) + return false + } + return true +} + +func getPods(ctx context.Context, client kubernetes.Interface, namespace, selector string) ([]corev1.Pod, error) { + list, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: selector, + }) + return list.Items, err +} diff --git a/pkg/kube/ready_test.go b/pkg/kube/ready_test.go new file mode 100644 index 00000000..3b8c4b80 --- /dev/null +++ b/pkg/kube/ready_test.go @@ -0,0 +1,705 @@ +/* +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 kube // import "helm.sh/helm/v3/pkg/kube" + +import ( + "context" + "testing" + + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/client-go/kubernetes/fake" +) + +const defaultNamespace = metav1.NamespaceDefault + +func Test_ReadyChecker_deploymentReady(t *testing.T) { + type args struct { + rs *appsv1.ReplicaSet + dep *appsv1.Deployment + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "deployment is ready", + args: args{ + rs: newReplicaSet("foo", 1, 1, true), + dep: newDeployment("foo", 1, 1, 0, true), + }, + want: true, + }, + { + name: "deployment is not ready", + args: args{ + rs: newReplicaSet("foo", 0, 0, true), + dep: newDeployment("foo", 1, 1, 0, true), + }, + want: false, + }, + { + name: "deployment is ready when maxUnavailable is set", + args: args{ + rs: newReplicaSet("foo", 2, 1, true), + dep: newDeployment("foo", 2, 1, 1, true), + }, + want: true, + }, + { + name: "deployment is not ready when replicaset generations are out of sync", + args: args{ + rs: newReplicaSet("foo", 1, 1, false), + dep: newDeployment("foo", 1, 1, 0, true), + }, + want: false, + }, + { + name: "deployment is not ready when deployment generations are out of sync", + args: args{ + rs: newReplicaSet("foo", 1, 1, true), + dep: newDeployment("foo", 1, 1, 0, false), + }, + want: false, + }, + { + name: "deployment is not ready when generations are out of sync", + args: args{ + rs: newReplicaSet("foo", 1, 1, false), + dep: newDeployment("foo", 1, 1, 0, false), + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := NewReadyChecker(fake.NewSimpleClientset(), nil) + if got := c.deploymentReady(tt.args.rs, tt.args.dep); got != tt.want { + t.Errorf("deploymentReady() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_ReadyChecker_replicaSetReady(t *testing.T) { + type args struct { + rs *appsv1.ReplicaSet + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "replicaSet is ready", + args: args{ + rs: newReplicaSet("foo", 1, 1, true), + }, + want: true, + }, + { + name: "replicaSet is not ready when generations are out of sync", + args: args{ + rs: newReplicaSet("foo", 1, 1, false), + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := NewReadyChecker(fake.NewSimpleClientset(), nil) + if got := c.replicaSetReady(tt.args.rs); got != tt.want { + t.Errorf("replicaSetReady() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_ReadyChecker_replicationControllerReady(t *testing.T) { + type args struct { + rc *corev1.ReplicationController + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "replicationController is ready", + args: args{ + rc: newReplicationController("foo", true), + }, + want: true, + }, + { + name: "replicationController is not ready when generations are out of sync", + args: args{ + rc: newReplicationController("foo", false), + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := NewReadyChecker(fake.NewSimpleClientset(), nil) + if got := c.replicationControllerReady(tt.args.rc); got != tt.want { + t.Errorf("replicationControllerReady() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_ReadyChecker_daemonSetReady(t *testing.T) { + type args struct { + ds *appsv1.DaemonSet + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "daemonset is ready", + args: args{ + ds: newDaemonSet("foo", 0, 1, 1, 1, true), + }, + want: true, + }, + { + name: "daemonset is not ready", + args: args{ + ds: newDaemonSet("foo", 0, 0, 1, 1, true), + }, + want: false, + }, + { + name: "daemonset pods have not been scheduled successfully", + args: args{ + ds: newDaemonSet("foo", 0, 0, 1, 0, true), + }, + want: false, + }, + { + name: "daemonset is ready when maxUnavailable is set", + args: args{ + ds: newDaemonSet("foo", 1, 1, 2, 2, true), + }, + want: true, + }, + { + name: "daemonset is not ready when generations are out of sync", + args: args{ + ds: newDaemonSet("foo", 0, 1, 1, 1, false), + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := NewReadyChecker(fake.NewSimpleClientset(), nil) + if got := c.daemonSetReady(tt.args.ds); got != tt.want { + t.Errorf("daemonSetReady() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_ReadyChecker_statefulSetReady(t *testing.T) { + type args struct { + sts *appsv1.StatefulSet + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "statefulset is ready", + args: args{ + sts: newStatefulSet("foo", 1, 0, 1, 1, true), + }, + want: true, + }, + { + name: "statefulset is not ready", + args: args{ + sts: newStatefulSet("foo", 1, 0, 0, 1, true), + }, + want: false, + }, + { + name: "statefulset is ready when partition is specified", + args: args{ + sts: newStatefulSet("foo", 2, 1, 2, 1, true), + }, + want: true, + }, + { + name: "statefulset is not ready when partition is set", + args: args{ + sts: newStatefulSet("foo", 2, 1, 1, 0, true), + }, + want: false, + }, + { + name: "statefulset is ready when partition is set and no change in template", + args: args{ + sts: newStatefulSet("foo", 2, 1, 2, 2, true), + }, + want: true, + }, + { + name: "statefulset is ready when partition is greater than replicas", + args: args{ + sts: newStatefulSet("foo", 1, 2, 1, 1, true), + }, + want: true, + }, + { + name: "statefulset is not ready when generations are out of sync", + args: args{ + sts: newStatefulSet("foo", 1, 0, 1, 1, false), + }, + want: false, + }, + { + name: "statefulset is ready when current revision for current replicas does not match update revision for updated replicas when using partition !=0", + args: args{ + sts: newStatefulSetWithUpdateRevision("foo", 3, 2, 3, 3, "foo-bbbbbbb", true), + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := NewReadyChecker(fake.NewSimpleClientset(), nil) + if got := c.statefulSetReady(tt.args.sts); got != tt.want { + t.Errorf("statefulSetReady() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_ReadyChecker_podsReadyForObject(t *testing.T) { + type args struct { + namespace string + obj runtime.Object + } + tests := []struct { + name string + args args + existPods []corev1.Pod + want bool + wantErr bool + }{ + { + name: "pods ready for a replicaset", + args: args{ + namespace: defaultNamespace, + obj: newReplicaSet("foo", 1, 1, true), + }, + existPods: []corev1.Pod{ + *newPodWithCondition("foo", corev1.ConditionTrue), + }, + want: true, + wantErr: false, + }, + { + name: "pods not ready for a replicaset", + args: args{ + namespace: defaultNamespace, + obj: newReplicaSet("foo", 1, 1, true), + }, + existPods: []corev1.Pod{ + *newPodWithCondition("foo", corev1.ConditionFalse), + }, + want: false, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := NewReadyChecker(fake.NewSimpleClientset(), nil) + for _, pod := range tt.existPods { + if _, err := c.client.CoreV1().Pods(defaultNamespace).Create(context.TODO(), &pod, metav1.CreateOptions{}); err != nil { + t.Errorf("Failed to create Pod error: %v", err) + return + } + } + got, err := c.podsReadyForObject(context.TODO(), tt.args.namespace, tt.args.obj) + if (err != nil) != tt.wantErr { + t.Errorf("podsReadyForObject() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("podsReadyForObject() got = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_ReadyChecker_jobReady(t *testing.T) { + type args struct { + job *batchv1.Job + } + tests := []struct { + name string + args args + want bool + wantErr bool + }{ + { + name: "job is completed", + args: args{job: newJob("foo", 1, intToInt32(1), 1, 0)}, + want: true, + wantErr: false, + }, + { + name: "job is incomplete", + args: args{job: newJob("foo", 1, intToInt32(1), 0, 0)}, + want: false, + wantErr: false, + }, + { + name: "job is failed but within BackoffLimit", + args: args{job: newJob("foo", 1, intToInt32(1), 0, 1)}, + want: false, + wantErr: false, + }, + { + name: "job is completed with retry", + args: args{job: newJob("foo", 1, intToInt32(1), 1, 1)}, + want: true, + wantErr: false, + }, + { + name: "job is failed and beyond BackoffLimit", + args: args{job: newJob("foo", 1, intToInt32(1), 0, 2)}, + want: false, + wantErr: true, + }, + { + name: "job is completed single run", + args: args{job: newJob("foo", 0, intToInt32(1), 1, 0)}, + want: true, + wantErr: false, + }, + { + name: "job is failed single run", + args: args{job: newJob("foo", 0, intToInt32(1), 0, 1)}, + want: false, + wantErr: true, + }, + { + name: "job with null completions", + args: args{job: newJob("foo", 0, nil, 1, 0)}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := NewReadyChecker(fake.NewSimpleClientset(), nil) + got, err := c.jobReady(tt.args.job) + if (err != nil) != tt.wantErr { + t.Errorf("jobReady() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("jobReady() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_ReadyChecker_volumeReady(t *testing.T) { + type args struct { + v *corev1.PersistentVolumeClaim + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "pvc is bound", + args: args{ + v: newPersistentVolumeClaim("foo", corev1.ClaimBound), + }, + want: true, + }, + { + name: "pvc is not ready", + args: args{ + v: newPersistentVolumeClaim("foo", corev1.ClaimPending), + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := NewReadyChecker(fake.NewSimpleClientset(), nil) + if got := c.volumeReady(tt.args.v); got != tt.want { + t.Errorf("volumeReady() = %v, want %v", got, tt.want) + } + }) + } +} + +func newStatefulSetWithUpdateRevision(name string, replicas, partition, readyReplicas, updatedReplicas int, updateRevision string, generationInSync bool) *appsv1.StatefulSet { + ss := newStatefulSet(name, replicas, partition, readyReplicas, updatedReplicas, generationInSync) + ss.Status.UpdateRevision = updateRevision + return ss +} + +func newDaemonSet(name string, maxUnavailable, numberReady, desiredNumberScheduled, updatedNumberScheduled int, generationInSync bool) *appsv1.DaemonSet { + var generation, observedGeneration int64 = 1, 1 + if !generationInSync { + generation = 2 + } + return &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: defaultNamespace, + Generation: generation, + }, + Spec: appsv1.DaemonSetSpec{ + UpdateStrategy: appsv1.DaemonSetUpdateStrategy{ + Type: appsv1.RollingUpdateDaemonSetStrategyType, + RollingUpdate: &appsv1.RollingUpdateDaemonSet{ + MaxUnavailable: func() *intstr.IntOrString { i := intstr.FromInt(maxUnavailable); return &i }(), + }, + }, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"name": name}}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{"name": name}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Image: "nginx", + }, + }, + }, + }, + }, + Status: appsv1.DaemonSetStatus{ + DesiredNumberScheduled: int32(desiredNumberScheduled), + NumberReady: int32(numberReady), + UpdatedNumberScheduled: int32(updatedNumberScheduled), + ObservedGeneration: observedGeneration, + }, + } +} + +func newStatefulSet(name string, replicas, partition, readyReplicas, updatedReplicas int, generationInSync bool) *appsv1.StatefulSet { + var generation, observedGeneration int64 = 1, 1 + if !generationInSync { + generation = 2 + } + return &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: defaultNamespace, + Generation: generation, + }, + Spec: appsv1.StatefulSetSpec{ + UpdateStrategy: appsv1.StatefulSetUpdateStrategy{ + Type: appsv1.RollingUpdateStatefulSetStrategyType, + RollingUpdate: &appsv1.RollingUpdateStatefulSetStrategy{ + Partition: intToInt32(partition), + }, + }, + Replicas: intToInt32(replicas), + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"name": name}}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{"name": name}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Image: "nginx", + }, + }, + }, + }, + }, + Status: appsv1.StatefulSetStatus{ + UpdatedReplicas: int32(updatedReplicas), + ReadyReplicas: int32(readyReplicas), + ObservedGeneration: observedGeneration, + }, + } +} + +func newDeployment(name string, replicas, maxSurge, maxUnavailable int, generationInSync bool) *appsv1.Deployment { + var generation, observedGeneration int64 = 1, 1 + if !generationInSync { + generation = 2 + } + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: defaultNamespace, + Generation: generation, + }, + Spec: appsv1.DeploymentSpec{ + Strategy: appsv1.DeploymentStrategy{ + Type: appsv1.RollingUpdateDeploymentStrategyType, + RollingUpdate: &appsv1.RollingUpdateDeployment{ + MaxUnavailable: func() *intstr.IntOrString { i := intstr.FromInt(maxUnavailable); return &i }(), + MaxSurge: func() *intstr.IntOrString { i := intstr.FromInt(maxSurge); return &i }(), + }, + }, + Replicas: intToInt32(replicas), + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"name": name}}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{"name": name}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Image: "nginx", + }, + }, + }, + }, + }, + Status: appsv1.DeploymentStatus{ + ObservedGeneration: observedGeneration, + }, + } +} + +func newReplicationController(name string, generationInSync bool) *corev1.ReplicationController { + var generation, observedGeneration int64 = 1, 1 + if !generationInSync { + generation = 2 + } + return &corev1.ReplicationController{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Generation: generation, + }, + Status: corev1.ReplicationControllerStatus{ + ObservedGeneration: observedGeneration, + }, + } +} + +func newReplicaSet(name string, replicas int, readyReplicas int, generationInSync bool) *appsv1.ReplicaSet { + d := newDeployment(name, replicas, 0, 0, generationInSync) + return &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: defaultNamespace, + Labels: d.Spec.Selector.MatchLabels, + OwnerReferences: []metav1.OwnerReference{*metav1.NewControllerRef(d, d.GroupVersionKind())}, + Generation: d.Generation, + }, + Spec: appsv1.ReplicaSetSpec{ + Selector: d.Spec.Selector, + Replicas: intToInt32(replicas), + Template: d.Spec.Template, + }, + Status: appsv1.ReplicaSetStatus{ + ReadyReplicas: int32(readyReplicas), + ObservedGeneration: d.Status.ObservedGeneration, + }, + } +} + +func newPodWithCondition(name string, podReadyCondition corev1.ConditionStatus) *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: defaultNamespace, + Labels: map[string]string{"name": name}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Image: "nginx", + }, + }, + }, + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{ + { + Type: corev1.PodReady, + Status: podReadyCondition, + }, + }, + }, + } +} + +func newPersistentVolumeClaim(name string, phase corev1.PersistentVolumeClaimPhase) *corev1.PersistentVolumeClaim { + return &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: defaultNamespace, + }, + Status: corev1.PersistentVolumeClaimStatus{ + Phase: phase, + }, + } +} + +func newJob(name string, backoffLimit int, completions *int32, succeeded int, failed int) *batchv1.Job { + return &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: defaultNamespace, + }, + Spec: batchv1.JobSpec{ + BackoffLimit: intToInt32(backoffLimit), + Completions: completions, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{"name": name}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Image: "nginx", + }, + }, + }, + }, + }, + Status: batchv1.JobStatus{ + Succeeded: int32(succeeded), + Failed: int32(failed), + }, + } +} + +func intToInt32(i int) *int32 { + i32 := int32(i) + return &i32 +} diff --git a/pkg/kube/resource.go b/pkg/kube/resource.go new file mode 100644 index 00000000..ee8f83a2 --- /dev/null +++ b/pkg/kube/resource.go @@ -0,0 +1,85 @@ +/* +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 kube // import "helm.sh/helm/v3/pkg/kube" + +import "k8s.io/cli-runtime/pkg/resource" + +// ResourceList provides convenience methods for comparing collections of Infos. +type ResourceList []*resource.Info + +// Append adds an Info to the Result. +func (r *ResourceList) Append(val *resource.Info) { + *r = append(*r, val) +} + +// Visit implements resource.Visitor. +func (r ResourceList) Visit(fn resource.VisitorFunc) error { + for _, i := range r { + if err := fn(i, nil); err != nil { + return err + } + } + return nil +} + +// Filter returns a new Result with Infos that satisfy the predicate fn. +func (r ResourceList) Filter(fn func(*resource.Info) bool) ResourceList { + var result ResourceList + for _, i := range r { + if fn(i) { + result.Append(i) + } + } + return result +} + +// Get returns the Info from the result that matches the name and kind. +func (r ResourceList) Get(info *resource.Info) *resource.Info { + for _, i := range r { + if isMatchingInfo(i, info) { + return i + } + } + return nil +} + +// Contains checks to see if an object exists. +func (r ResourceList) Contains(info *resource.Info) bool { + for _, i := range r { + if isMatchingInfo(i, info) { + return true + } + } + return false +} + +// Difference will return a new Result with objects not contained in rs. +func (r ResourceList) Difference(rs ResourceList) ResourceList { + return r.Filter(func(info *resource.Info) bool { + return !rs.Contains(info) + }) +} + +// Intersect will return a new Result with objects contained in both Results. +func (r ResourceList) Intersect(rs ResourceList) ResourceList { + return r.Filter(rs.Contains) +} + +// isMatchingInfo returns true if infos match on Name and GroupVersionKind. +func isMatchingInfo(a, b *resource.Info) bool { + return a.Name == b.Name && a.Namespace == b.Namespace && a.Mapping.GroupVersionKind.Kind == b.Mapping.GroupVersionKind.Kind +} diff --git a/pkg/kube/resource_policy.go b/pkg/kube/resource_policy.go new file mode 100644 index 00000000..46b8680d --- /dev/null +++ b/pkg/kube/resource_policy.go @@ -0,0 +1,27 @@ +/* +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 kube // import "helm.sh/helm/v3/pkg/kube" + +// ResourcePolicyAnno is the annotation name for a resource policy +const ResourcePolicyAnno = "helm.sh/resource-policy" + +// KeepPolicy is the resource policy type for keep +// +// This resource policy type allows resources to skip being deleted +// +// during an uninstallRelease action. +const KeepPolicy = "keep" diff --git a/pkg/kube/resource_test.go b/pkg/kube/resource_test.go new file mode 100644 index 00000000..3c906cec --- /dev/null +++ b/pkg/kube/resource_test.go @@ -0,0 +1,61 @@ +/* +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 kube // import "helm.sh/helm/v3/pkg/kube" + +import ( + "testing" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/resource" +) + +func TestResourceList(t *testing.T) { + mapping := &meta.RESTMapping{ + Resource: schema.GroupVersionResource{Group: "group", Version: "version", Resource: "pod"}, + } + + info := func(name string) *resource.Info { + return &resource.Info{Name: name, Mapping: mapping} + } + + var r1, r2 ResourceList + r1 = []*resource.Info{info("foo"), info("bar")} + r2 = []*resource.Info{info("bar")} + + if r1.Get(info("bar")).Mapping.Resource.Resource != "pod" { + t.Error("expected get pod") + } + + diff := r1.Difference(r2) + if len(diff) != 1 { + t.Error("expected 1 result") + } + + if !diff.Contains(info("foo")) { + t.Error("expected diff to return foo") + } + + inter := r1.Intersect(r2) + if len(inter) != 1 { + t.Error("expected 1 result") + } + + if !inter.Contains(info("bar")) { + t.Error("expected intersect to return bar") + } +} diff --git a/pkg/kube/result.go b/pkg/kube/result.go new file mode 100644 index 00000000..c3e171c2 --- /dev/null +++ b/pkg/kube/result.go @@ -0,0 +1,28 @@ +/* +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 kube + +// Result contains the information of created, updated, and deleted resources +// for various kube API calls along with helper methods for using those +// resources +type Result struct { + Created ResourceList + Updated ResourceList + Deleted ResourceList +} + +// If needed, we can add methods to the Result type for things like diffing diff --git a/pkg/kube/wait.go b/pkg/kube/wait.go new file mode 100644 index 00000000..ecdd3894 --- /dev/null +++ b/pkg/kube/wait.go @@ -0,0 +1,128 @@ +/* +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 kube // import "helm.sh/helm/v3/pkg/kube" + +import ( + "context" + "fmt" + "time" + + "github.com/pkg/errors" + appsv1 "k8s.io/api/apps/v1" + appsv1beta1 "k8s.io/api/apps/v1beta1" + appsv1beta2 "k8s.io/api/apps/v1beta2" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + extensionsv1beta1 "k8s.io/api/extensions/v1beta1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + + "k8s.io/apimachinery/pkg/util/wait" +) + +type waiter struct { + c ReadyChecker + timeout time.Duration + log func(string, ...interface{}) +} + +// waitForResources polls to get the current status of all pods, PVCs, Services and +// Jobs(optional) until all are ready or a timeout is reached +func (w *waiter) waitForResources(created ResourceList) error { + w.log("beginning wait for %d resources with timeout of %v", len(created), w.timeout) + + ctx, cancel := context.WithTimeout(context.Background(), w.timeout) + defer cancel() + + return wait.PollUntilContextCancel(ctx, 2*time.Second, true, func(ctx context.Context) (bool, error) { + for _, v := range created { + ready, err := w.c.IsReady(ctx, v) + if !ready || err != nil { + return false, err + } + } + return true, nil + }) +} + +// waitForDeletedResources polls to check if all the resources are deleted or a timeout is reached +func (w *waiter) waitForDeletedResources(deleted ResourceList) error { + w.log("beginning wait for %d resources to be deleted with timeout of %v", len(deleted), w.timeout) + + ctx, cancel := context.WithTimeout(context.Background(), w.timeout) + defer cancel() + + return wait.PollUntilContextCancel(ctx, 2*time.Second, true, func(ctx context.Context) (bool, error) { + for _, v := range deleted { + err := v.Get() + if err == nil || !apierrors.IsNotFound(err) { + return false, err + } + } + return true, nil + }) +} + +// SelectorsForObject returns the pod label selector for a given object +// +// Modified version of https://github.com/kubernetes/kubernetes/blob/v1.14.1/pkg/kubectl/polymorphichelpers/helpers.go#L84 +func SelectorsForObject(object runtime.Object) (selector labels.Selector, err error) { + switch t := object.(type) { + case *extensionsv1beta1.ReplicaSet: + selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector) + case *appsv1.ReplicaSet: + selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector) + case *appsv1beta2.ReplicaSet: + selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector) + case *corev1.ReplicationController: + selector = labels.SelectorFromSet(t.Spec.Selector) + case *appsv1.StatefulSet: + selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector) + case *appsv1beta1.StatefulSet: + selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector) + case *appsv1beta2.StatefulSet: + selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector) + case *extensionsv1beta1.DaemonSet: + selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector) + case *appsv1.DaemonSet: + selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector) + case *appsv1beta2.DaemonSet: + selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector) + case *extensionsv1beta1.Deployment: + selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector) + case *appsv1.Deployment: + selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector) + case *appsv1beta1.Deployment: + selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector) + case *appsv1beta2.Deployment: + selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector) + case *batchv1.Job: + selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector) + case *corev1.Service: + if t.Spec.Selector == nil || len(t.Spec.Selector) == 0 { + return nil, fmt.Errorf("invalid service '%s': Service is defined without a selector", t.Name) + } + selector = labels.SelectorFromSet(t.Spec.Selector) + + default: + return nil, fmt.Errorf("selector for %T not implemented", object) + } + + return selector, errors.Wrap(err, "invalid label selector") +} diff --git a/pkg/lint/lint.go b/pkg/lint/lint.go new file mode 100644 index 00000000..c0e79f55 --- /dev/null +++ b/pkg/lint/lint.go @@ -0,0 +1,43 @@ +/* +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 lint // import "helm.sh/helm/v3/pkg/lint" + +import ( + "path/filepath" + + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/lint/rules" + "helm.sh/helm/v3/pkg/lint/support" +) + +// All runs all of the available linters on the given base directory. +func All(basedir string, values map[string]interface{}, namespace string, _ bool) support.Linter { + return AllWithKubeVersion(basedir, values, namespace, nil) +} + +// AllWithKubeVersion runs all the available linters on the given base directory, allowing to specify the kubernetes version. +func AllWithKubeVersion(basedir string, values map[string]interface{}, namespace string, kubeVersion *chartutil.KubeVersion) support.Linter { + // Using abs path to get directory context + chartDir, _ := filepath.Abs(basedir) + + linter := support.Linter{ChartDir: chartDir} + rules.Chartfile(&linter) + rules.ValuesWithOverrides(&linter, values) + rules.TemplatesWithKubeVersion(&linter, values, namespace, kubeVersion) + rules.Dependencies(&linter) + return linter +} diff --git a/pkg/lint/lint_test.go b/pkg/lint/lint_test.go new file mode 100644 index 00000000..5516ec66 --- /dev/null +++ b/pkg/lint/lint_test.go @@ -0,0 +1,173 @@ +/* +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 lint + +import ( + "strings" + "testing" + "time" + + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/lint/support" +) + +var values map[string]interface{} + +const namespace = "testNamespace" +const strict = false + +const badChartDir = "rules/testdata/badchartfile" +const badValuesFileDir = "rules/testdata/badvaluesfile" +const badYamlFileDir = "rules/testdata/albatross" +const goodChartDir = "rules/testdata/goodone" +const subChartValuesDir = "rules/testdata/withsubchart" +const malformedTemplate = "rules/testdata/malformed-template" + +func TestBadChart(t *testing.T) { + m := All(badChartDir, values, namespace, strict).Messages + if len(m) != 8 { + t.Errorf("Number of errors %v", len(m)) + t.Errorf("All didn't fail with expected errors, got %#v", m) + } + // There should be one INFO, and 2 ERROR messages, check for them + var i, e, e2, e3, e4, e5, e6 bool + for _, msg := range m { + if msg.Severity == support.InfoSev { + if strings.Contains(msg.Err.Error(), "icon is recommended") { + i = true + } + } + if msg.Severity == support.ErrorSev { + if strings.Contains(msg.Err.Error(), "version '0.0.0.0' is not a valid SemVer") { + e = true + } + if strings.Contains(msg.Err.Error(), "name is required") { + e2 = true + } + + if strings.Contains(msg.Err.Error(), "apiVersion is required. The value must be either \"v1\" or \"v2\"") { + e3 = true + } + + if strings.Contains(msg.Err.Error(), "chart type is not valid in apiVersion") { + e4 = true + } + + if strings.Contains(msg.Err.Error(), "dependencies are not valid in the Chart file with apiVersion") { + e5 = true + } + // This comes from the dependency check, which loads dependency info from the Chart.yaml + if strings.Contains(msg.Err.Error(), "unable to load chart") { + e6 = true + } + } + } + if !e || !e2 || !e3 || !e4 || !e5 || !i || !e6 { + t.Errorf("Didn't find all the expected errors, got %#v", m) + } +} + +func TestInvalidYaml(t *testing.T) { + m := All(badYamlFileDir, values, namespace, strict).Messages + if len(m) != 1 { + t.Fatalf("All didn't fail with expected errors, got %#v", m) + } + if !strings.Contains(m[0].Err.Error(), "deliberateSyntaxError") { + t.Errorf("All didn't have the error for deliberateSyntaxError") + } +} + +func TestBadValues(t *testing.T) { + m := All(badValuesFileDir, values, namespace, strict).Messages + if len(m) < 1 { + t.Fatalf("All didn't fail with expected errors, got %#v", m) + } + if !strings.Contains(m[0].Err.Error(), "unable to parse YAML") { + t.Errorf("All didn't have the error for invalid key format: %s", m[0].Err) + } +} + +func TestGoodChart(t *testing.T) { + m := All(goodChartDir, values, namespace, strict).Messages + if len(m) != 0 { + t.Error("All returned linter messages when it shouldn't have") + for i, msg := range m { + t.Logf("Message %d: %s", i, msg) + } + } +} + +// TestHelmCreateChart tests that a `helm create` always passes a `helm lint` test. +// +// See https://github.com/helm/helm/issues/7923 +func TestHelmCreateChart(t *testing.T) { + dir := t.TempDir() + + createdChart, err := chartutil.Create("testhelmcreatepasseslint", dir) + if err != nil { + t.Error(err) + // Fatal is bad because of the defer. + return + } + + // Note: we test with strict=true here, even though others have + // strict = false. + m := All(createdChart, values, namespace, true).Messages + if ll := len(m); ll != 1 { + t.Errorf("All should have had exactly 1 error. Got %d", ll) + for i, msg := range m { + t.Logf("Message %d: %s", i, msg.Error()) + } + } else if msg := m[0].Err.Error(); !strings.Contains(msg, "icon is recommended") { + t.Errorf("Unexpected lint error: %s", msg) + } +} + +// lint ignores import-values +// See https://github.com/helm/helm/issues/9658 +func TestSubChartValuesChart(t *testing.T) { + m := All(subChartValuesDir, values, namespace, strict).Messages + if len(m) != 0 { + t.Error("All returned linter messages when it shouldn't have") + for i, msg := range m { + t.Logf("Message %d: %s", i, msg) + } + } +} + +// lint stuck with malformed template object +// See https://github.com/helm/helm/issues/11391 +func TestMalformedTemplate(t *testing.T) { + c := time.After(3 * time.Second) + ch := make(chan int, 1) + var m []support.Message + go func() { + m = All(malformedTemplate, values, namespace, strict).Messages + ch <- 1 + }() + select { + case <-c: + t.Fatalf("lint malformed template timeout") + case <-ch: + if len(m) != 1 { + t.Fatalf("All didn't fail with expected errors, got %#v", m) + } + if !strings.Contains(m[0].Err.Error(), "invalid character '{'") { + t.Errorf("All didn't have the error for invalid character '{'") + } + } +} diff --git a/pkg/lint/rules/chartfile.go b/pkg/lint/rules/chartfile.go new file mode 100644 index 00000000..910602b7 --- /dev/null +++ b/pkg/lint/rules/chartfile.go @@ -0,0 +1,213 @@ +/* +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 rules // import "helm.sh/helm/v3/pkg/lint/rules" + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/Masterminds/semver/v3" + "github.com/asaskevich/govalidator" + "github.com/pkg/errors" + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/lint/support" +) + +// Chartfile runs a set of linter rules related to Chart.yaml file +func Chartfile(linter *support.Linter) { + chartFileName := "Chart.yaml" + chartPath := filepath.Join(linter.ChartDir, chartFileName) + + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartYamlNotDirectory(chartPath)) + + chartFile, err := chartutil.LoadChartfile(chartPath) + validChartFile := linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartYamlFormat(err)) + + // Guard clause. Following linter rules require a parsable ChartFile + if !validChartFile { + return + } + + // type check for Chart.yaml . ignoring error as any parse + // errors would already be caught in the above load function + chartFileForTypeCheck, _ := loadChartFileForTypeCheck(chartPath) + + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartName(chartFile)) + + // Chart metadata + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartAPIVersion(chartFile)) + + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartVersionType(chartFileForTypeCheck)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartVersion(chartFile)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartAppVersionType(chartFileForTypeCheck)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartMaintainer(chartFile)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartSources(chartFile)) + linter.RunLinterRule(support.InfoSev, chartFileName, validateChartIconPresence(chartFile)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartIconURL(chartFile)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartType(chartFile)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartDependencies(chartFile)) +} + +func validateChartVersionType(data map[string]interface{}) error { + return isStringValue(data, "version") +} + +func validateChartAppVersionType(data map[string]interface{}) error { + return isStringValue(data, "appVersion") +} + +func isStringValue(data map[string]interface{}, key string) error { + value, ok := data[key] + if !ok { + return nil + } + valueType := fmt.Sprintf("%T", value) + if valueType != "string" { + return errors.Errorf("%s should be of type string but it's of type %s", key, valueType) + } + return nil +} + +func validateChartYamlNotDirectory(chartPath string) error { + fi, err := os.Stat(chartPath) + + if err == nil && fi.IsDir() { + return errors.New("should be a file, not a directory") + } + return nil +} + +func validateChartYamlFormat(chartFileError error) error { + if chartFileError != nil { + return errors.Errorf("unable to parse YAML\n\t%s", chartFileError.Error()) + } + return nil +} + +func validateChartName(cf *chart.Metadata) error { + if cf.Name == "" { + return errors.New("name is required") + } + name := filepath.Base(cf.Name) + if name != cf.Name { + return fmt.Errorf("chart name %q is invalid", cf.Name) + } + return nil +} + +func validateChartAPIVersion(cf *chart.Metadata) error { + if cf.APIVersion == "" { + return errors.New("apiVersion is required. The value must be either \"v1\" or \"v2\"") + } + + if cf.APIVersion != chart.APIVersionV1 && cf.APIVersion != chart.APIVersionV2 { + return fmt.Errorf("apiVersion '%s' is not valid. The value must be either \"v1\" or \"v2\"", cf.APIVersion) + } + + return nil +} + +func validateChartVersion(cf *chart.Metadata) error { + if cf.Version == "" { + return errors.New("version is required") + } + + version, err := semver.NewVersion(cf.Version) + + if err != nil { + return errors.Errorf("version '%s' is not a valid SemVer", cf.Version) + } + + c, err := semver.NewConstraint(">0.0.0-0") + if err != nil { + return err + } + valid, msg := c.Validate(version) + + if !valid && len(msg) > 0 { + return errors.Errorf("version %v", msg[0]) + } + + return nil +} + +func validateChartMaintainer(cf *chart.Metadata) error { + for _, maintainer := range cf.Maintainers { + if maintainer.Name == "" { + return errors.New("each maintainer requires a name") + } else if maintainer.Email != "" && !govalidator.IsEmail(maintainer.Email) { + return errors.Errorf("invalid email '%s' for maintainer '%s'", maintainer.Email, maintainer.Name) + } else if maintainer.URL != "" && !govalidator.IsURL(maintainer.URL) { + return errors.Errorf("invalid url '%s' for maintainer '%s'", maintainer.URL, maintainer.Name) + } + } + return nil +} + +func validateChartSources(cf *chart.Metadata) error { + for _, source := range cf.Sources { + if source == "" || !govalidator.IsRequestURL(source) { + return errors.Errorf("invalid source URL '%s'", source) + } + } + return nil +} + +func validateChartIconPresence(cf *chart.Metadata) error { + if cf.Icon == "" { + return errors.New("icon is recommended") + } + return nil +} + +func validateChartIconURL(cf *chart.Metadata) error { + if cf.Icon != "" && !govalidator.IsRequestURL(cf.Icon) { + return errors.Errorf("invalid icon URL '%s'", cf.Icon) + } + return nil +} + +func validateChartDependencies(cf *chart.Metadata) error { + if len(cf.Dependencies) > 0 && cf.APIVersion != chart.APIVersionV2 { + return fmt.Errorf("dependencies are not valid in the Chart file with apiVersion '%s'. They are valid in apiVersion '%s'", cf.APIVersion, chart.APIVersionV2) + } + return nil +} + +func validateChartType(cf *chart.Metadata) error { + if len(cf.Type) > 0 && cf.APIVersion != chart.APIVersionV2 { + return fmt.Errorf("chart type is not valid in apiVersion '%s'. It is valid in apiVersion '%s'", cf.APIVersion, chart.APIVersionV2) + } + return nil +} + +// loadChartFileForTypeCheck loads the Chart.yaml +// in a generic form of a map[string]interface{}, so that the type +// of the values can be checked +func loadChartFileForTypeCheck(filename string) (map[string]interface{}, error) { + b, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + y := make(map[string]interface{}) + err = yaml.Unmarshal(b, &y) + return y, err +} diff --git a/pkg/lint/rules/chartfile_test.go b/pkg/lint/rules/chartfile_test.go new file mode 100644 index 00000000..a06d7dc3 --- /dev/null +++ b/pkg/lint/rules/chartfile_test.go @@ -0,0 +1,255 @@ +/* +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 rules + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/pkg/errors" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/lint/support" +) + +const ( + badCharNametDir = "testdata/badchartname" + badChartDir = "testdata/badchartfile" + anotherBadChartDir = "testdata/anotherbadchartfile" +) + +var ( + badChartNamePath = filepath.Join(badCharNametDir, "Chart.yaml") + badChartFilePath = filepath.Join(badChartDir, "Chart.yaml") + nonExistingChartFilePath = filepath.Join(os.TempDir(), "Chart.yaml") +) + +var badChart, _ = chartutil.LoadChartfile(badChartFilePath) +var badChartName, _ = chartutil.LoadChartfile(badChartNamePath) + +// Validation functions Test +func TestValidateChartYamlNotDirectory(t *testing.T) { + _ = os.Mkdir(nonExistingChartFilePath, os.ModePerm) + defer os.Remove(nonExistingChartFilePath) + + err := validateChartYamlNotDirectory(nonExistingChartFilePath) + if err == nil { + t.Errorf("validateChartYamlNotDirectory to return a linter error, got no error") + } +} + +func TestValidateChartYamlFormat(t *testing.T) { + err := validateChartYamlFormat(errors.New("Read error")) + if err == nil { + t.Errorf("validateChartYamlFormat to return a linter error, got no error") + } + + err = validateChartYamlFormat(nil) + if err != nil { + t.Errorf("validateChartYamlFormat to return no error, got a linter error") + } +} + +func TestValidateChartName(t *testing.T) { + err := validateChartName(badChart) + if err == nil { + t.Errorf("validateChartName to return a linter error, got no error") + } + + err = validateChartName(badChartName) + if err == nil { + t.Error("expected validateChartName to return a linter error for an invalid name, got no error") + } +} + +func TestValidateChartVersion(t *testing.T) { + var failTest = []struct { + Version string + ErrorMsg string + }{ + {"", "version is required"}, + {"1.2.3.4", "version '1.2.3.4' is not a valid SemVer"}, + {"waps", "'waps' is not a valid SemVer"}, + {"-3", "'-3' is not a valid SemVer"}, + } + + var successTest = []string{"0.0.1", "0.0.1+build", "0.0.1-beta"} + + for _, test := range failTest { + badChart.Version = test.Version + err := validateChartVersion(badChart) + if err == nil || !strings.Contains(err.Error(), test.ErrorMsg) { + t.Errorf("validateChartVersion(%s) to return \"%s\", got no error", test.Version, test.ErrorMsg) + } + } + + for _, version := range successTest { + badChart.Version = version + err := validateChartVersion(badChart) + if err != nil { + t.Errorf("validateChartVersion(%s) to return no error, got a linter error", version) + } + } +} + +func TestValidateChartMaintainer(t *testing.T) { + var failTest = []struct { + Name string + Email string + ErrorMsg string + }{ + {"", "", "each maintainer requires a name"}, + {"", "test@test.com", "each maintainer requires a name"}, + {"John Snow", "wrongFormatEmail.com", "invalid email"}, + } + + var successTest = []struct { + Name string + Email string + }{ + {"John Snow", ""}, + {"John Snow", "john@winterfell.com"}, + } + + for _, test := range failTest { + badChart.Maintainers = []*chart.Maintainer{{Name: test.Name, Email: test.Email}} + err := validateChartMaintainer(badChart) + if err == nil || !strings.Contains(err.Error(), test.ErrorMsg) { + t.Errorf("validateChartMaintainer(%s, %s) to return \"%s\", got no error", test.Name, test.Email, test.ErrorMsg) + } + } + + for _, test := range successTest { + badChart.Maintainers = []*chart.Maintainer{{Name: test.Name, Email: test.Email}} + err := validateChartMaintainer(badChart) + if err != nil { + t.Errorf("validateChartMaintainer(%s, %s) to return no error, got %s", test.Name, test.Email, err.Error()) + } + } +} + +func TestValidateChartSources(t *testing.T) { + var failTest = []string{"", "RiverRun", "john@winterfell", "riverrun.io"} + var successTest = []string{"http://riverrun.io", "https://riverrun.io", "https://riverrun.io/blackfish"} + for _, test := range failTest { + badChart.Sources = []string{test} + err := validateChartSources(badChart) + if err == nil || !strings.Contains(err.Error(), "invalid source URL") { + t.Errorf("validateChartSources(%s) to return \"invalid source URL\", got no error", test) + } + } + + for _, test := range successTest { + badChart.Sources = []string{test} + err := validateChartSources(badChart) + if err != nil { + t.Errorf("validateChartSources(%s) to return no error, got %s", test, err.Error()) + } + } +} + +func TestValidateChartIconPresence(t *testing.T) { + err := validateChartIconPresence(badChart) + if err == nil { + t.Errorf("validateChartIconPresence to return a linter error, got no error") + } +} + +func TestValidateChartIconURL(t *testing.T) { + var failTest = []string{"RiverRun", "john@winterfell", "riverrun.io"} + var successTest = []string{"http://riverrun.io", "https://riverrun.io", "https://riverrun.io/blackfish.png"} + for _, test := range failTest { + badChart.Icon = test + err := validateChartIconURL(badChart) + if err == nil || !strings.Contains(err.Error(), "invalid icon URL") { + t.Errorf("validateChartIconURL(%s) to return \"invalid icon URL\", got no error", test) + } + } + + for _, test := range successTest { + badChart.Icon = test + err := validateChartSources(badChart) + if err != nil { + t.Errorf("validateChartIconURL(%s) to return no error, got %s", test, err.Error()) + } + } +} + +func TestChartfile(t *testing.T) { + t.Run("Chart.yaml basic validity issues", func(t *testing.T) { + linter := support.Linter{ChartDir: badChartDir} + Chartfile(&linter) + msgs := linter.Messages + expectedNumberOfErrorMessages := 6 + + if len(msgs) != expectedNumberOfErrorMessages { + t.Errorf("Expected %d errors, got %d", expectedNumberOfErrorMessages, len(msgs)) + return + } + + if !strings.Contains(msgs[0].Err.Error(), "name is required") { + t.Errorf("Unexpected message 0: %s", msgs[0].Err) + } + + if !strings.Contains(msgs[1].Err.Error(), "apiVersion is required. The value must be either \"v1\" or \"v2\"") { + t.Errorf("Unexpected message 1: %s", msgs[1].Err) + } + + if !strings.Contains(msgs[2].Err.Error(), "version '0.0.0.0' is not a valid SemVer") { + t.Errorf("Unexpected message 2: %s", msgs[2].Err) + } + + if !strings.Contains(msgs[3].Err.Error(), "icon is recommended") { + t.Errorf("Unexpected message 3: %s", msgs[3].Err) + } + + if !strings.Contains(msgs[4].Err.Error(), "chart type is not valid in apiVersion") { + t.Errorf("Unexpected message 4: %s", msgs[4].Err) + } + + if !strings.Contains(msgs[5].Err.Error(), "dependencies are not valid in the Chart file with apiVersion") { + t.Errorf("Unexpected message 5: %s", msgs[5].Err) + } + }) + + t.Run("Chart.yaml validity issues due to type mismatch", func(t *testing.T) { + linter := support.Linter{ChartDir: anotherBadChartDir} + Chartfile(&linter) + msgs := linter.Messages + expectedNumberOfErrorMessages := 3 + + if len(msgs) != expectedNumberOfErrorMessages { + t.Errorf("Expected %d errors, got %d", expectedNumberOfErrorMessages, len(msgs)) + return + } + + if !strings.Contains(msgs[0].Err.Error(), "version should be of type string") { + t.Errorf("Unexpected message 0: %s", msgs[0].Err) + } + + if !strings.Contains(msgs[1].Err.Error(), "version '7.2445e+06' is not a valid SemVer") { + t.Errorf("Unexpected message 1: %s", msgs[1].Err) + } + + if !strings.Contains(msgs[2].Err.Error(), "appVersion should be of type string") { + t.Errorf("Unexpected message 2: %s", msgs[2].Err) + } + }) +} diff --git a/pkg/lint/rules/dependencies.go b/pkg/lint/rules/dependencies.go new file mode 100644 index 00000000..f1ab1dca --- /dev/null +++ b/pkg/lint/rules/dependencies.go @@ -0,0 +1,103 @@ +/* +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 rules // import "helm.sh/helm/v3/pkg/lint/rules" + +import ( + "fmt" + "strings" + + "github.com/pkg/errors" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/lint/support" +) + +// Dependencies runs lints against a chart's dependencies +// +// See https://github.com/helm/helm/issues/7910 +func Dependencies(linter *support.Linter) { + c, err := loader.LoadDir(linter.ChartDir) + if !linter.RunLinterRule(support.ErrorSev, "", validateChartFormat(err)) { + return + } + + linter.RunLinterRule(support.ErrorSev, linter.ChartDir, validateDependencyInMetadata(c)) + linter.RunLinterRule(support.ErrorSev, linter.ChartDir, validateDependenciesUnique(c)) + linter.RunLinterRule(support.WarningSev, linter.ChartDir, validateDependencyInChartsDir(c)) +} + +func validateChartFormat(chartError error) error { + if chartError != nil { + return errors.Errorf("unable to load chart\n\t%s", chartError) + } + return nil +} + +func validateDependencyInChartsDir(c *chart.Chart) (err error) { + dependencies := map[string]struct{}{} + missing := []string{} + for _, dep := range c.Dependencies() { + dependencies[dep.Metadata.Name] = struct{}{} + } + for _, dep := range c.Metadata.Dependencies { + if _, ok := dependencies[dep.Name]; !ok { + missing = append(missing, dep.Name) + } + } + if len(missing) > 0 { + err = fmt.Errorf("chart directory is missing these dependencies: %s", strings.Join(missing, ",")) + } + return err +} + +func validateDependencyInMetadata(c *chart.Chart) (err error) { + dependencies := map[string]struct{}{} + missing := []string{} + for _, dep := range c.Metadata.Dependencies { + dependencies[dep.Name] = struct{}{} + } + for _, dep := range c.Dependencies() { + if _, ok := dependencies[dep.Metadata.Name]; !ok { + missing = append(missing, dep.Metadata.Name) + } + } + if len(missing) > 0 { + err = fmt.Errorf("chart metadata is missing these dependencies: %s", strings.Join(missing, ",")) + } + return err +} + +func validateDependenciesUnique(c *chart.Chart) (err error) { + dependencies := map[string]*chart.Dependency{} + shadowing := []string{} + + for _, dep := range c.Metadata.Dependencies { + key := dep.Name + if dep.Alias != "" { + key = dep.Alias + } + if dependencies[key] != nil { + shadowing = append(shadowing, key) + } + dependencies[key] = dep + } + if len(shadowing) > 0 { + err = fmt.Errorf("multiple dependencies with name or alias: %s", strings.Join(shadowing, ",")) + } + return err +} diff --git a/pkg/lint/rules/dependencies_test.go b/pkg/lint/rules/dependencies_test.go new file mode 100644 index 00000000..c0afff13 --- /dev/null +++ b/pkg/lint/rules/dependencies_test.go @@ -0,0 +1,157 @@ +/* +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 rules + +import ( + "path/filepath" + "testing" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/lint/support" +) + +func chartWithBadDependencies() chart.Chart { + badChartDeps := chart.Chart{ + Metadata: &chart.Metadata{ + Name: "badchart", + Version: "0.1.0", + APIVersion: "v2", + Dependencies: []*chart.Dependency{ + { + Name: "sub2", + }, + { + Name: "sub3", + }, + }, + }, + } + + badChartDeps.SetDependencies( + &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "sub1", + Version: "0.1.0", + APIVersion: "v2", + }, + }, + &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "sub2", + Version: "0.1.0", + APIVersion: "v2", + }, + }, + ) + return badChartDeps +} + +func TestValidateDependencyInChartsDir(t *testing.T) { + c := chartWithBadDependencies() + + if err := validateDependencyInChartsDir(&c); err == nil { + t.Error("chart should have been flagged for missing deps in chart directory") + } +} + +func TestValidateDependencyInMetadata(t *testing.T) { + c := chartWithBadDependencies() + + if err := validateDependencyInMetadata(&c); err == nil { + t.Errorf("chart should have been flagged for missing deps in chart metadata") + } +} + +func TestValidateDependenciesUnique(t *testing.T) { + tests := []struct { + chart chart.Chart + }{ + {chart.Chart{ + Metadata: &chart.Metadata{ + Name: "badchart", + Version: "0.1.0", + APIVersion: "v2", + Dependencies: []*chart.Dependency{ + { + Name: "foo", + }, + { + Name: "foo", + }, + }, + }, + }}, + {chart.Chart{ + Metadata: &chart.Metadata{ + Name: "badchart", + Version: "0.1.0", + APIVersion: "v2", + Dependencies: []*chart.Dependency{ + { + Name: "foo", + Alias: "bar", + }, + { + Name: "bar", + }, + }, + }, + }}, + {chart.Chart{ + Metadata: &chart.Metadata{ + Name: "badchart", + Version: "0.1.0", + APIVersion: "v2", + Dependencies: []*chart.Dependency{ + { + Name: "foo", + Alias: "baz", + }, + { + Name: "bar", + Alias: "baz", + }, + }, + }, + }}, + } + + for _, tt := range tests { + if err := validateDependenciesUnique(&tt.chart); err == nil { + t.Errorf("chart should have been flagged for dependency shadowing") + } + } +} + +func TestDependencies(t *testing.T) { + tmp := t.TempDir() + + c := chartWithBadDependencies() + err := chartutil.SaveDir(&c, tmp) + if err != nil { + t.Fatal(err) + } + linter := support.Linter{ChartDir: filepath.Join(tmp, c.Metadata.Name)} + + Dependencies(&linter) + if l := len(linter.Messages); l != 2 { + t.Errorf("expected 2 linter errors for bad chart dependencies. Got %d.", l) + for i, msg := range linter.Messages { + t.Logf("Message: %d, Error: %#v", i, msg) + } + } +} diff --git a/pkg/lint/rules/deprecations.go b/pkg/lint/rules/deprecations.go new file mode 100644 index 00000000..90e7748a --- /dev/null +++ b/pkg/lint/rules/deprecations.go @@ -0,0 +1,106 @@ +/* +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 rules // import "helm.sh/helm/v3/pkg/lint/rules" + +import ( + "fmt" + "strconv" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/endpoints/deprecation" + kscheme "k8s.io/client-go/kubernetes/scheme" + + "helm.sh/helm/v3/pkg/chartutil" +) + +var ( + // This should be set in the Makefile based on the version of client-go being imported. + // These constants will be overwritten with LDFLAGS. The version components must be + // strings in order for LDFLAGS to set them. + k8sVersionMajor = "1" + k8sVersionMinor = "20" +) + +// deprecatedAPIError indicates than an API is deprecated in Kubernetes +type deprecatedAPIError struct { + Deprecated string + Message string +} + +func (e deprecatedAPIError) Error() string { + msg := e.Message + return msg +} + +func validateNoDeprecations(resource *K8sYamlStruct, kubeVersion *chartutil.KubeVersion) error { + // if `resource` does not have an APIVersion or Kind, we cannot test it for deprecation + if resource.APIVersion == "" { + return nil + } + if resource.Kind == "" { + return nil + } + + majorVersion := k8sVersionMajor + minorVersion := k8sVersionMinor + + if kubeVersion != nil { + majorVersion = kubeVersion.Major + minorVersion = kubeVersion.Minor + } + + runtimeObject, err := resourceToRuntimeObject(resource) + if err != nil { + // do not error for non-kubernetes resources + if runtime.IsNotRegisteredError(err) { + return nil + } + return err + } + + maj, err := strconv.Atoi(majorVersion) + if err != nil { + return err + } + min, err := strconv.Atoi(minorVersion) + if err != nil { + return err + } + + if !deprecation.IsDeprecated(runtimeObject, maj, min) { + return nil + } + gvk := fmt.Sprintf("%s %s", resource.APIVersion, resource.Kind) + return deprecatedAPIError{ + Deprecated: gvk, + Message: deprecation.WarningMessage(runtimeObject), + } +} + +func resourceToRuntimeObject(resource *K8sYamlStruct) (runtime.Object, error) { + scheme := runtime.NewScheme() + kscheme.AddToScheme(scheme) + + gvk := schema.FromAPIVersionAndKind(resource.APIVersion, resource.Kind) + out, err := scheme.New(gvk) + if err != nil { + return nil, err + } + out.GetObjectKind().SetGroupVersionKind(gvk) + return out, nil +} diff --git a/pkg/lint/rules/deprecations_test.go b/pkg/lint/rules/deprecations_test.go new file mode 100644 index 00000000..cf240900 --- /dev/null +++ b/pkg/lint/rules/deprecations_test.go @@ -0,0 +1,41 @@ +/* +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 rules // import "helm.sh/helm/v3/pkg/lint/rules" + +import "testing" + +func TestValidateNoDeprecations(t *testing.T) { + deprecated := &K8sYamlStruct{ + APIVersion: "extensions/v1beta1", + Kind: "Deployment", + } + err := validateNoDeprecations(deprecated, nil) + if err == nil { + t.Fatal("Expected deprecated extension to be flagged") + } + depErr := err.(deprecatedAPIError) + if depErr.Message == "" { + t.Fatalf("Expected error message to be non-blank: %v", err) + } + + if err := validateNoDeprecations(&K8sYamlStruct{ + APIVersion: "v1", + Kind: "Pod", + }, nil); err != nil { + t.Errorf("Expected a v1 Pod to not be deprecated") + } +} diff --git a/pkg/lint/rules/template.go b/pkg/lint/rules/template.go new file mode 100644 index 00000000..aa1dbb70 --- /dev/null +++ b/pkg/lint/rules/template.go @@ -0,0 +1,351 @@ +/* +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 rules + +import ( + "bufio" + "bytes" + "fmt" + "io" + "os" + "path" + "path/filepath" + "regexp" + "strings" + + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/api/validation" + apipath "k8s.io/apimachinery/pkg/api/validation/path" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apimachinery/pkg/util/yaml" + + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/engine" + "helm.sh/helm/v3/pkg/lint/support" +) + +var ( + crdHookSearch = regexp.MustCompile(`"?helm\.sh/hook"?:\s+crd-install`) + releaseTimeSearch = regexp.MustCompile(`\.Release\.Time`) +) + +// Templates lints the templates in the Linter. +func Templates(linter *support.Linter, values map[string]interface{}, namespace string, _ bool) { + TemplatesWithKubeVersion(linter, values, namespace, nil) +} + +// TemplatesWithKubeVersion lints the templates in the Linter, allowing to specify the kubernetes version. +func TemplatesWithKubeVersion(linter *support.Linter, values map[string]interface{}, namespace string, kubeVersion *chartutil.KubeVersion) { + fpath := "templates/" + templatesPath := filepath.Join(linter.ChartDir, fpath) + + templatesDirExist := linter.RunLinterRule(support.WarningSev, fpath, validateTemplatesDir(templatesPath)) + + // Templates directory is optional for now + if !templatesDirExist { + return + } + + // Load chart and parse templates + chart, err := loader.Load(linter.ChartDir) + + chartLoaded := linter.RunLinterRule(support.ErrorSev, fpath, err) + + if !chartLoaded { + return + } + + options := chartutil.ReleaseOptions{ + Name: "test-release", + Namespace: namespace, + } + + caps := chartutil.DefaultCapabilities.Copy() + if kubeVersion != nil { + caps.KubeVersion = *kubeVersion + } + + // lint ignores import-values + // See https://github.com/helm/helm/issues/9658 + if err := chartutil.ProcessDependenciesWithMerge(chart, values); err != nil { + return + } + + cvals, err := chartutil.CoalesceValues(chart, values) + if err != nil { + return + } + + valuesToRender, err := chartutil.ToRenderValues(chart, cvals, options, caps) + if err != nil { + linter.RunLinterRule(support.ErrorSev, fpath, err) + return + } + var e engine.Engine + e.LintMode = true + renderedContentMap, err := e.Render(chart, valuesToRender) + + renderOk := linter.RunLinterRule(support.ErrorSev, fpath, err) + + if !renderOk { + return + } + + /* Iterate over all the templates to check: + - It is a .yaml file + - All the values in the template file is defined + - {{}} include | quote + - Generated content is a valid Yaml file + - Metadata.Namespace is not set + */ + for _, template := range chart.Templates { + fileName, data := template.Name, template.Data + fpath = fileName + + linter.RunLinterRule(support.ErrorSev, fpath, validateAllowedExtension(fileName)) + // These are v3 specific checks to make sure and warn people if their + // chart is not compatible with v3 + linter.RunLinterRule(support.WarningSev, fpath, validateNoCRDHooks(data)) + linter.RunLinterRule(support.ErrorSev, fpath, validateNoReleaseTime(data)) + + // We only apply the following lint rules to yaml files + if filepath.Ext(fileName) != ".yaml" || filepath.Ext(fileName) == ".yml" { + continue + } + + // NOTE: disabled for now, Refs https://github.com/helm/helm/issues/1463 + // Check that all the templates have a matching value + // linter.RunLinterRule(support.WarningSev, fpath, validateNoMissingValues(templatesPath, valuesToRender, preExecutedTemplate)) + + // NOTE: disabled for now, Refs https://github.com/helm/helm/issues/1037 + // linter.RunLinterRule(support.WarningSev, fpath, validateQuotes(string(preExecutedTemplate))) + + renderedContent := renderedContentMap[path.Join(chart.Name(), fileName)] + if strings.TrimSpace(renderedContent) != "" { + linter.RunLinterRule(support.WarningSev, fpath, validateTopIndentLevel(renderedContent)) + + decoder := yaml.NewYAMLOrJSONDecoder(strings.NewReader(renderedContent), 4096) + + // Lint all resources if the file contains multiple documents separated by --- + for { + // Even though K8sYamlStruct only defines a few fields, an error in any other + // key will be raised as well + var yamlStruct *K8sYamlStruct + + err := decoder.Decode(&yamlStruct) + if err == io.EOF { + break + } + + // If YAML linting fails here, it will always fail in the next block as well, so we should return here. + // fix https://github.com/helm/helm/issues/11391 + if !linter.RunLinterRule(support.ErrorSev, fpath, validateYamlContent(err)) { + return + } + if yamlStruct != nil { + // NOTE: set to warnings to allow users to support out-of-date kubernetes + // Refs https://github.com/helm/helm/issues/8596 + linter.RunLinterRule(support.WarningSev, fpath, validateMetadataName(yamlStruct)) + linter.RunLinterRule(support.WarningSev, fpath, validateNoDeprecations(yamlStruct, kubeVersion)) + + linter.RunLinterRule(support.ErrorSev, fpath, validateMatchSelector(yamlStruct, renderedContent)) + linter.RunLinterRule(support.ErrorSev, fpath, validateListAnnotations(yamlStruct, renderedContent)) + } + } + } + } +} + +// validateTopIndentLevel checks that the content does not start with an indent level > 0. +// +// This error can occur when a template accidentally inserts space. It can cause +// unpredictable errors depending on whether the text is normalized before being passed +// into the YAML parser. So we trap it here. +// +// See https://github.com/helm/helm/issues/8467 +func validateTopIndentLevel(content string) error { + // Read lines until we get to a non-empty one + scanner := bufio.NewScanner(bytes.NewBufferString(content)) + for scanner.Scan() { + line := scanner.Text() + // If line is empty, skip + if strings.TrimSpace(line) == "" { + continue + } + // If it starts with one or more spaces, this is an error + if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { + return fmt.Errorf("document starts with an illegal indent: %q, which may cause parsing problems", line) + } + // Any other condition passes. + return nil + } + return scanner.Err() +} + +// Validation functions +func validateTemplatesDir(templatesPath string) error { + if fi, err := os.Stat(templatesPath); err == nil { + if !fi.IsDir() { + return errors.New("not a directory") + } + } + return nil +} + +func validateAllowedExtension(fileName string) error { + ext := filepath.Ext(fileName) + validExtensions := []string{".yaml", ".yml", ".tpl", ".txt"} + + for _, b := range validExtensions { + if b == ext { + return nil + } + } + + return errors.Errorf("file extension '%s' not valid. Valid extensions are .yaml, .yml, .tpl, or .txt", ext) +} + +func validateYamlContent(err error) error { + return errors.Wrap(err, "unable to parse YAML") +} + +// validateMetadataName uses the correct validation function for the object +// Kind, or if not set, defaults to the standard definition of a subdomain in +// DNS (RFC 1123), used by most resources. +func validateMetadataName(obj *K8sYamlStruct) error { + fn := validateMetadataNameFunc(obj) + allErrs := field.ErrorList{} + for _, msg := range fn(obj.Metadata.Name, false) { + allErrs = append(allErrs, field.Invalid(field.NewPath("metadata").Child("name"), obj.Metadata.Name, msg)) + } + if len(allErrs) > 0 { + return errors.Wrapf(allErrs.ToAggregate(), "object name does not conform to Kubernetes naming requirements: %q", obj.Metadata.Name) + } + return nil +} + +// validateMetadataNameFunc will return a name validation function for the +// object kind, if defined below. +// +// Rules should match those set in the various api validations: +// https://github.com/kubernetes/kubernetes/blob/v1.20.0/pkg/apis/core/validation/validation.go#L205-L274 +// https://github.com/kubernetes/kubernetes/blob/v1.20.0/pkg/apis/apps/validation/validation.go#L39 +// ... +// +// Implementing here to avoid importing k/k. +// +// If no mapping is defined, returns NameIsDNSSubdomain. This is used by object +// kinds that don't have special requirements, so is the most likely to work if +// new kinds are added. +func validateMetadataNameFunc(obj *K8sYamlStruct) validation.ValidateNameFunc { + switch strings.ToLower(obj.Kind) { + case "pod", "node", "secret", "endpoints", "resourcequota", // core + "controllerrevision", "daemonset", "deployment", "replicaset", "statefulset", // apps + "autoscaler", // autoscaler + "cronjob", "job", // batch + "lease", // coordination + "endpointslice", // discovery + "networkpolicy", "ingress", // networking + "podsecuritypolicy", // policy + "priorityclass", // scheduling + "podpreset", // settings + "storageclass", "volumeattachment", "csinode": // storage + return validation.NameIsDNSSubdomain + case "service": + return validation.NameIsDNS1035Label + case "namespace": + return validation.ValidateNamespaceName + case "serviceaccount": + return validation.ValidateServiceAccountName + case "certificatesigningrequest": + // No validation. + // https://github.com/kubernetes/kubernetes/blob/v1.20.0/pkg/apis/certificates/validation/validation.go#L137-L140 + return func(name string, prefix bool) []string { return nil } + case "role", "clusterrole", "rolebinding", "clusterrolebinding": + // https://github.com/kubernetes/kubernetes/blob/v1.20.0/pkg/apis/rbac/validation/validation.go#L32-L34 + return func(name string, prefix bool) []string { + return apipath.IsValidPathSegmentName(name) + } + default: + return validation.NameIsDNSSubdomain + } +} + +func validateNoCRDHooks(manifest []byte) error { + if crdHookSearch.Match(manifest) { + return errors.New("manifest is a crd-install hook. This hook is no longer supported in v3 and all CRDs should also exist the crds/ directory at the top level of the chart") + } + return nil +} + +func validateNoReleaseTime(manifest []byte) error { + if releaseTimeSearch.Match(manifest) { + return errors.New(".Release.Time has been removed in v3, please replace with the `now` function in your templates") + } + return nil +} + +// validateMatchSelector ensures that template specs have a selector declared. +// See https://github.com/helm/helm/issues/1990 +func validateMatchSelector(yamlStruct *K8sYamlStruct, manifest string) error { + switch yamlStruct.Kind { + case "Deployment", "ReplicaSet", "DaemonSet", "StatefulSet": + // verify that matchLabels or matchExpressions is present + if !(strings.Contains(manifest, "matchLabels") || strings.Contains(manifest, "matchExpressions")) { + return fmt.Errorf("a %s must contain matchLabels or matchExpressions, and %q does not", yamlStruct.Kind, yamlStruct.Metadata.Name) + } + } + return nil +} +func validateListAnnotations(yamlStruct *K8sYamlStruct, manifest string) error { + if yamlStruct.Kind == "List" { + m := struct { + Items []struct { + Metadata struct { + Annotations map[string]string + } + } + }{} + + if err := yaml.Unmarshal([]byte(manifest), &m); err != nil { + return validateYamlContent(err) + } + + for _, i := range m.Items { + if _, ok := i.Metadata.Annotations["helm.sh/resource-policy"]; ok { + return errors.New("Annotation 'helm.sh/resource-policy' within List objects are ignored") + } + } + } + return nil +} + +// K8sYamlStruct stubs a Kubernetes YAML file. +// +// DEPRECATED: In Helm 4, this will be made a private type, as it is for use only within +// the rules package. +type K8sYamlStruct struct { + APIVersion string `json:"apiVersion"` + Kind string + Metadata k8sYamlMetadata +} + +type k8sYamlMetadata struct { + Namespace string + Name string +} diff --git a/pkg/lint/rules/template_test.go b/pkg/lint/rules/template_test.go new file mode 100644 index 00000000..80f9b28e --- /dev/null +++ b/pkg/lint/rules/template_test.go @@ -0,0 +1,460 @@ +/* +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 rules + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/lint/support" +) + +const templateTestBasedir = "./testdata/albatross" + +func TestValidateAllowedExtension(t *testing.T) { + var failTest = []string{"/foo", "/test.toml"} + for _, test := range failTest { + err := validateAllowedExtension(test) + if err == nil || !strings.Contains(err.Error(), "Valid extensions are .yaml, .yml, .tpl, or .txt") { + t.Errorf("validateAllowedExtension('%s') to return \"Valid extensions are .yaml, .yml, .tpl, or .txt\", got no error", test) + } + } + var successTest = []string{"/foo.yaml", "foo.yaml", "foo.tpl", "/foo/bar/baz.yaml", "NOTES.txt"} + for _, test := range successTest { + err := validateAllowedExtension(test) + if err != nil { + t.Errorf("validateAllowedExtension('%s') to return no error but got \"%s\"", test, err.Error()) + } + } +} + +var values = map[string]interface{}{"nameOverride": "", "httpPort": 80} + +const namespace = "testNamespace" +const strict = false + +func TestTemplateParsing(t *testing.T) { + linter := support.Linter{ChartDir: templateTestBasedir} + Templates(&linter, values, namespace, strict) + res := linter.Messages + + if len(res) != 1 { + t.Fatalf("Expected one error, got %d, %v", len(res), res) + } + + if !strings.Contains(res[0].Err.Error(), "deliberateSyntaxError") { + t.Errorf("Unexpected error: %s", res[0]) + } +} + +var wrongTemplatePath = filepath.Join(templateTestBasedir, "templates", "fail.yaml") +var ignoredTemplatePath = filepath.Join(templateTestBasedir, "fail.yaml.ignored") + +// Test a template with all the existing features: +// namespaces, partial templates +func TestTemplateIntegrationHappyPath(t *testing.T) { + // Rename file so it gets ignored by the linter + os.Rename(wrongTemplatePath, ignoredTemplatePath) + defer os.Rename(ignoredTemplatePath, wrongTemplatePath) + + linter := support.Linter{ChartDir: templateTestBasedir} + Templates(&linter, values, namespace, strict) + res := linter.Messages + + if len(res) != 0 { + t.Fatalf("Expected no error, got %d, %v", len(res), res) + } +} + +func TestV3Fail(t *testing.T) { + linter := support.Linter{ChartDir: "./testdata/v3-fail"} + Templates(&linter, values, namespace, strict) + res := linter.Messages + + if len(res) != 3 { + t.Fatalf("Expected 3 errors, got %d, %v", len(res), res) + } + + if !strings.Contains(res[0].Err.Error(), ".Release.Time has been removed in v3") { + t.Errorf("Unexpected error: %s", res[0].Err) + } + if !strings.Contains(res[1].Err.Error(), "manifest is a crd-install hook") { + t.Errorf("Unexpected error: %s", res[1].Err) + } + if !strings.Contains(res[2].Err.Error(), "manifest is a crd-install hook") { + t.Errorf("Unexpected error: %s", res[2].Err) + } +} + +func TestMultiTemplateFail(t *testing.T) { + linter := support.Linter{ChartDir: "./testdata/multi-template-fail"} + Templates(&linter, values, namespace, strict) + res := linter.Messages + + if len(res) != 1 { + t.Fatalf("Expected 1 error, got %d, %v", len(res), res) + } + + if !strings.Contains(res[0].Err.Error(), "object name does not conform to Kubernetes naming requirements") { + t.Errorf("Unexpected error: %s", res[0].Err) + } +} + +func TestValidateMetadataName(t *testing.T) { + tests := []struct { + obj *K8sYamlStruct + wantErr bool + }{ + // Most kinds use IsDNS1123Subdomain. + {&K8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: ""}}, true}, + {&K8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&K8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "foo.bar1234baz.seventyone"}}, false}, + {&K8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "FOO"}}, true}, + {&K8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "123baz"}}, false}, + {&K8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "foo.BAR.baz"}}, true}, + {&K8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "one-two"}}, false}, + {&K8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "-two"}}, true}, + {&K8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "one_two"}}, true}, + {&K8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "a..b"}}, true}, + {&K8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "%^&#$%*@^*@&#^"}}, true}, + {&K8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "operator:pod"}}, true}, + {&K8sYamlStruct{Kind: "ServiceAccount", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&K8sYamlStruct{Kind: "ServiceAccount", Metadata: k8sYamlMetadata{Name: "foo.bar1234baz.seventyone"}}, false}, + {&K8sYamlStruct{Kind: "ServiceAccount", Metadata: k8sYamlMetadata{Name: "FOO"}}, true}, + {&K8sYamlStruct{Kind: "ServiceAccount", Metadata: k8sYamlMetadata{Name: "operator:sa"}}, true}, + + // Service uses IsDNS1035Label. + {&K8sYamlStruct{Kind: "Service", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&K8sYamlStruct{Kind: "Service", Metadata: k8sYamlMetadata{Name: "123baz"}}, true}, + {&K8sYamlStruct{Kind: "Service", Metadata: k8sYamlMetadata{Name: "foo.bar"}}, true}, + + // Namespace uses IsDNS1123Label. + {&K8sYamlStruct{Kind: "Namespace", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&K8sYamlStruct{Kind: "Namespace", Metadata: k8sYamlMetadata{Name: "123baz"}}, false}, + {&K8sYamlStruct{Kind: "Namespace", Metadata: k8sYamlMetadata{Name: "foo.bar"}}, true}, + {&K8sYamlStruct{Kind: "Namespace", Metadata: k8sYamlMetadata{Name: "foo-bar"}}, false}, + + // CertificateSigningRequest has no validation. + {&K8sYamlStruct{Kind: "CertificateSigningRequest", Metadata: k8sYamlMetadata{Name: ""}}, false}, + {&K8sYamlStruct{Kind: "CertificateSigningRequest", Metadata: k8sYamlMetadata{Name: "123baz"}}, false}, + {&K8sYamlStruct{Kind: "CertificateSigningRequest", Metadata: k8sYamlMetadata{Name: "%^&#$%*@^*@&#^"}}, false}, + + // RBAC uses path validation. + {&K8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&K8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "123baz"}}, false}, + {&K8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "foo.bar"}}, false}, + {&K8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "operator:role"}}, false}, + {&K8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "operator/role"}}, true}, + {&K8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "operator%role"}}, true}, + {&K8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&K8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "123baz"}}, false}, + {&K8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "foo.bar"}}, false}, + {&K8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "operator:role"}}, false}, + {&K8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "operator/role"}}, true}, + {&K8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "operator%role"}}, true}, + {&K8sYamlStruct{Kind: "RoleBinding", Metadata: k8sYamlMetadata{Name: "operator:role"}}, false}, + {&K8sYamlStruct{Kind: "ClusterRoleBinding", Metadata: k8sYamlMetadata{Name: "operator:role"}}, false}, + + // Unknown Kind + {&K8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: ""}}, true}, + {&K8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&K8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "foo.bar1234baz.seventyone"}}, false}, + {&K8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "FOO"}}, true}, + {&K8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "123baz"}}, false}, + {&K8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "foo.BAR.baz"}}, true}, + {&K8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "one-two"}}, false}, + {&K8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "-two"}}, true}, + {&K8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "one_two"}}, true}, + {&K8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "a..b"}}, true}, + {&K8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "%^&#$%*@^*@&#^"}}, true}, + {&K8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "operator:pod"}}, true}, + + // No kind + {&K8sYamlStruct{Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&K8sYamlStruct{Metadata: k8sYamlMetadata{Name: "operator:pod"}}, true}, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("%s/%s", tt.obj.Kind, tt.obj.Metadata.Name), func(t *testing.T) { + if err := validateMetadataName(tt.obj); (err != nil) != tt.wantErr { + t.Errorf("validateMetadataName() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestDeprecatedAPIFails(t *testing.T) { + mychart := chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: "v2", + Name: "failapi", + Version: "0.1.0", + Icon: "satisfy-the-linting-gods.gif", + }, + Templates: []*chart.File{ + { + Name: "templates/baddeployment.yaml", + Data: []byte("apiVersion: apps/v1beta1\nkind: Deployment\nmetadata:\n name: baddep\nspec: {selector: {matchLabels: {foo: bar}}}"), + }, + { + Name: "templates/goodsecret.yaml", + Data: []byte("apiVersion: v1\nkind: Secret\nmetadata:\n name: goodsecret"), + }, + }, + } + tmpdir := t.TempDir() + + if err := chartutil.SaveDir(&mychart, tmpdir); err != nil { + t.Fatal(err) + } + + linter := support.Linter{ChartDir: filepath.Join(tmpdir, mychart.Name())} + Templates(&linter, values, namespace, strict) + if l := len(linter.Messages); l != 1 { + for i, msg := range linter.Messages { + t.Logf("Message %d: %s", i, msg) + } + t.Fatalf("Expected 1 lint error, got %d", l) + } + + err := linter.Messages[0].Err.(deprecatedAPIError) + if err.Deprecated != "apps/v1beta1 Deployment" { + t.Errorf("Surprised to learn that %q is deprecated", err.Deprecated) + } +} + +const manifest = `apiVersion: v1 +kind: ConfigMap +metadata: + name: foo +data: + myval1: {{default "val" .Values.mymap.key1 }} + myval2: {{default "val" .Values.mymap.key2 }} +` + +// TestStrictTemplateParsingMapError is a regression test. +// +// The template engine should not produce an error when a map in values.yaml does +// not contain all possible keys. +// +// See https://github.com/helm/helm/issues/7483 +func TestStrictTemplateParsingMapError(t *testing.T) { + + ch := chart.Chart{ + Metadata: &chart.Metadata{ + Name: "regression7483", + APIVersion: "v2", + Version: "0.1.0", + }, + Values: map[string]interface{}{ + "mymap": map[string]string{ + "key1": "val1", + }, + }, + Templates: []*chart.File{ + { + Name: "templates/configmap.yaml", + Data: []byte(manifest), + }, + }, + } + dir := t.TempDir() + if err := chartutil.SaveDir(&ch, dir); err != nil { + t.Fatal(err) + } + linter := &support.Linter{ + ChartDir: filepath.Join(dir, ch.Metadata.Name), + } + Templates(linter, ch.Values, namespace, strict) + if len(linter.Messages) != 0 { + t.Errorf("expected zero messages, got %d", len(linter.Messages)) + for i, msg := range linter.Messages { + t.Logf("Message %d: %q", i, msg) + } + } +} + +func TestValidateMatchSelector(t *testing.T) { + md := &K8sYamlStruct{ + APIVersion: "apps/v1", + Kind: "Deployment", + Metadata: k8sYamlMetadata{ + Name: "mydeployment", + }, + } + manifest := ` + apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 3 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ` + if err := validateMatchSelector(md, manifest); err != nil { + t.Error(err) + } + manifest = ` + apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 3 + selector: + matchExpressions: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ` + if err := validateMatchSelector(md, manifest); err != nil { + t.Error(err) + } + manifest = ` + apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 3 + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ` + if err := validateMatchSelector(md, manifest); err == nil { + t.Error("expected Deployment with no selector to fail") + } +} + +func TestValidateTopIndentLevel(t *testing.T) { + for doc, shouldFail := range map[string]bool{ + // Should not fail + "\n\n\n\t\n \t\n": false, + "apiVersion:foo\n bar:baz": false, + "\n\n\napiVersion:foo\n\n\n": false, + // Should fail + " apiVersion:foo": true, + "\n\n apiVersion:foo\n\n": true, + } { + if err := validateTopIndentLevel(doc); (err == nil) == shouldFail { + t.Errorf("Expected %t for %q", shouldFail, doc) + } + } + +} + +// TestEmptyWithCommentsManifests checks the lint is not failing against empty manifests that contains only comments +// See https://github.com/helm/helm/issues/8621 +func TestEmptyWithCommentsManifests(t *testing.T) { + mychart := chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: "v2", + Name: "emptymanifests", + Version: "0.1.0", + Icon: "satisfy-the-linting-gods.gif", + }, + Templates: []*chart.File{ + { + Name: "templates/empty-with-comments.yaml", + Data: []byte("#@formatter:off\n"), + }, + }, + } + tmpdir := t.TempDir() + + if err := chartutil.SaveDir(&mychart, tmpdir); err != nil { + t.Fatal(err) + } + + linter := support.Linter{ChartDir: filepath.Join(tmpdir, mychart.Name())} + Templates(&linter, values, namespace, strict) + if l := len(linter.Messages); l > 0 { + for i, msg := range linter.Messages { + t.Logf("Message %d: %s", i, msg) + } + t.Fatalf("Expected 0 lint errors, got %d", l) + } +} +func TestValidateListAnnotations(t *testing.T) { + md := &K8sYamlStruct{ + APIVersion: "v1", + Kind: "List", + Metadata: k8sYamlMetadata{ + Name: "list", + }, + } + manifest := ` +apiVersion: v1 +kind: List +items: + - apiVersion: v1 + kind: ConfigMap + metadata: + annotations: + helm.sh/resource-policy: keep +` + + if err := validateListAnnotations(md, manifest); err == nil { + t.Fatal("expected list with nested keep annotations to fail") + } + + manifest = ` +apiVersion: v1 +kind: List +metadata: + annotations: + helm.sh/resource-policy: keep +items: + - apiVersion: v1 + kind: ConfigMap +` + + if err := validateListAnnotations(md, manifest); err != nil { + t.Fatalf("List objects keep annotations should pass. got: %s", err) + } +} diff --git a/pkg/lint/rules/testdata/albatross/Chart.yaml b/pkg/lint/rules/testdata/albatross/Chart.yaml new file mode 100644 index 00000000..21124acf --- /dev/null +++ b/pkg/lint/rules/testdata/albatross/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: albatross +description: testing chart +version: 199.44.12345-Alpha.1+cafe009 +icon: http://riverrun.io diff --git a/pkg/lint/rules/testdata/albatross/templates/_helpers.tpl b/pkg/lint/rules/testdata/albatross/templates/_helpers.tpl new file mode 100644 index 00000000..24f76db7 --- /dev/null +++ b/pkg/lint/rules/testdata/albatross/templates/_helpers.tpl @@ -0,0 +1,16 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{define "name"}}{{default "nginx" .Values.nameOverride | trunc 63 | trimSuffix "-" }}{{end}} + +{{/* +Create a default fully qualified app name. + +We truncate at 63 chars because some Kubernetes name fields are limited to this +(by the DNS naming spec). +*/}} +{{define "fullname"}} +{{- $name := default "nginx" .Values.nameOverride -}} +{{printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{end}} diff --git a/pkg/lint/rules/testdata/albatross/templates/fail.yaml b/pkg/lint/rules/testdata/albatross/templates/fail.yaml new file mode 100644 index 00000000..a11e0e90 --- /dev/null +++ b/pkg/lint/rules/testdata/albatross/templates/fail.yaml @@ -0,0 +1 @@ +{{ deliberateSyntaxError }} diff --git a/pkg/lint/rules/testdata/albatross/templates/svc.yaml b/pkg/lint/rules/testdata/albatross/templates/svc.yaml new file mode 100644 index 00000000..16bb27d5 --- /dev/null +++ b/pkg/lint/rules/testdata/albatross/templates/svc.yaml @@ -0,0 +1,19 @@ +# This is a service gateway to the replica set created by the deployment. +# Take a look at the deployment.yaml for general notes about this chart. +apiVersion: v1 +kind: Service +metadata: + name: "{{ .Values.name }}" + labels: + app.kubernetes.io/managed-by: {{ .Release.Service | quote }} + app.kubernetes.io/instance: {{ .Release.Name | quote }} + helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}" + kubeVersion: {{ .Capabilities.KubeVersion.Major }} +spec: + ports: + - port: {{default 80 .Values.httpPort | quote}} + targetPort: 80 + protocol: TCP + name: http + selector: + app.kubernetes.io/name: {{template "fullname" .}} diff --git a/pkg/lint/rules/testdata/albatross/values.yaml b/pkg/lint/rules/testdata/albatross/values.yaml new file mode 100644 index 00000000..74cc6a0d --- /dev/null +++ b/pkg/lint/rules/testdata/albatross/values.yaml @@ -0,0 +1 @@ +name: "mariner" diff --git a/pkg/lint/rules/testdata/anotherbadchartfile/Chart.yaml b/pkg/lint/rules/testdata/anotherbadchartfile/Chart.yaml new file mode 100644 index 00000000..e6bac769 --- /dev/null +++ b/pkg/lint/rules/testdata/anotherbadchartfile/Chart.yaml @@ -0,0 +1,15 @@ +name: "some-chart" +apiVersion: v2 +description: A Helm chart for Kubernetes +version: 72445e2 +home: "" +type: application +appVersion: 72225e2 +icon: "https://some-url.com/icon.jpeg" +dependencies: + - name: mariadb + version: 5.x.x + repository: https://charts.helm.sh/stable/ + condition: mariadb.enabled + tags: + - database diff --git a/pkg/lint/rules/testdata/badchartfile/Chart.yaml b/pkg/lint/rules/testdata/badchartfile/Chart.yaml new file mode 100644 index 00000000..3564ede3 --- /dev/null +++ b/pkg/lint/rules/testdata/badchartfile/Chart.yaml @@ -0,0 +1,11 @@ +description: A Helm chart for Kubernetes +version: 0.0.0.0 +home: "" +type: application +dependencies: +- name: mariadb + version: 5.x.x + repository: https://charts.helm.sh/stable/ + condition: mariadb.enabled + tags: + - database diff --git a/pkg/lint/rules/testdata/badchartfile/values.yaml b/pkg/lint/rules/testdata/badchartfile/values.yaml new file mode 100644 index 00000000..9f367033 --- /dev/null +++ b/pkg/lint/rules/testdata/badchartfile/values.yaml @@ -0,0 +1 @@ +# Default values for badchartfile. diff --git a/pkg/lint/rules/testdata/badchartname/Chart.yaml b/pkg/lint/rules/testdata/badchartname/Chart.yaml new file mode 100644 index 00000000..64f8fb8b --- /dev/null +++ b/pkg/lint/rules/testdata/badchartname/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +description: A Helm chart for Kubernetes +version: 0.1.0 +name: "../badchartname" +type: application diff --git a/pkg/lint/rules/testdata/badchartname/values.yaml b/pkg/lint/rules/testdata/badchartname/values.yaml new file mode 100644 index 00000000..9f367033 --- /dev/null +++ b/pkg/lint/rules/testdata/badchartname/values.yaml @@ -0,0 +1 @@ +# Default values for badchartfile. diff --git a/pkg/lint/rules/testdata/badvaluesfile/Chart.yaml b/pkg/lint/rules/testdata/badvaluesfile/Chart.yaml new file mode 100644 index 00000000..632919d0 --- /dev/null +++ b/pkg/lint/rules/testdata/badvaluesfile/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +name: badvaluesfile +description: A Helm chart for Kubernetes +version: 0.0.1 +home: "" +icon: http://riverrun.io diff --git a/pkg/lint/rules/testdata/badvaluesfile/templates/badvaluesfile.yaml b/pkg/lint/rules/testdata/badvaluesfile/templates/badvaluesfile.yaml new file mode 100644 index 00000000..6c2ceb8d --- /dev/null +++ b/pkg/lint/rules/testdata/badvaluesfile/templates/badvaluesfile.yaml @@ -0,0 +1,2 @@ +metadata: + name: {{.name | default "foo" | title}} diff --git a/pkg/lint/rules/testdata/badvaluesfile/values.yaml b/pkg/lint/rules/testdata/badvaluesfile/values.yaml new file mode 100644 index 00000000..b5a10271 --- /dev/null +++ b/pkg/lint/rules/testdata/badvaluesfile/values.yaml @@ -0,0 +1,2 @@ +# Invalid value for badvaluesfile for testing lint fails with invalid yaml format +name= "value" diff --git a/pkg/lint/rules/testdata/goodone/Chart.yaml b/pkg/lint/rules/testdata/goodone/Chart.yaml new file mode 100644 index 00000000..cb7a4bf2 --- /dev/null +++ b/pkg/lint/rules/testdata/goodone/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: goodone +description: good testing chart +version: 199.44.12345-Alpha.1+cafe009 +icon: http://riverrun.io diff --git a/pkg/lint/rules/testdata/goodone/templates/goodone.yaml b/pkg/lint/rules/testdata/goodone/templates/goodone.yaml new file mode 100644 index 00000000..cd46f62c --- /dev/null +++ b/pkg/lint/rules/testdata/goodone/templates/goodone.yaml @@ -0,0 +1,2 @@ +metadata: + name: {{ .Values.name | default "foo" | lower }} diff --git a/pkg/lint/rules/testdata/goodone/values.yaml b/pkg/lint/rules/testdata/goodone/values.yaml new file mode 100644 index 00000000..92c3d9bb --- /dev/null +++ b/pkg/lint/rules/testdata/goodone/values.yaml @@ -0,0 +1 @@ +name: "goodone-here" diff --git a/pkg/lint/rules/testdata/malformed-template/.helmignore b/pkg/lint/rules/testdata/malformed-template/.helmignore new file mode 100644 index 00000000..0e8a0eb3 --- /dev/null +++ b/pkg/lint/rules/testdata/malformed-template/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/pkg/lint/rules/testdata/malformed-template/Chart.yaml b/pkg/lint/rules/testdata/malformed-template/Chart.yaml new file mode 100644 index 00000000..11b2c71c --- /dev/null +++ b/pkg/lint/rules/testdata/malformed-template/Chart.yaml @@ -0,0 +1,25 @@ +apiVersion: v2 +name: test +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.16.0" +icon: https://riverrun.io \ No newline at end of file diff --git a/pkg/lint/rules/testdata/malformed-template/templates/bad.yaml b/pkg/lint/rules/testdata/malformed-template/templates/bad.yaml new file mode 100644 index 00000000..213198fd --- /dev/null +++ b/pkg/lint/rules/testdata/malformed-template/templates/bad.yaml @@ -0,0 +1 @@ +{ {- $relname := .Release.Name -}} diff --git a/pkg/lint/rules/testdata/malformed-template/values.yaml b/pkg/lint/rules/testdata/malformed-template/values.yaml new file mode 100644 index 00000000..1cc3182e --- /dev/null +++ b/pkg/lint/rules/testdata/malformed-template/values.yaml @@ -0,0 +1,82 @@ +# Default values for test. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: nginx + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +podAnnotations: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/pkg/lint/rules/testdata/multi-template-fail/Chart.yaml b/pkg/lint/rules/testdata/multi-template-fail/Chart.yaml new file mode 100644 index 00000000..b57427de --- /dev/null +++ b/pkg/lint/rules/testdata/multi-template-fail/Chart.yaml @@ -0,0 +1,21 @@ +apiVersion: v2 +name: multi-template-fail +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application and it is recommended to use it with quotes. +appVersion: "1.16.0" diff --git a/pkg/lint/rules/testdata/multi-template-fail/templates/multi-fail.yaml b/pkg/lint/rules/testdata/multi-template-fail/templates/multi-fail.yaml new file mode 100644 index 00000000..835be07b --- /dev/null +++ b/pkg/lint/rules/testdata/multi-template-fail/templates/multi-fail.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: game-config +data: + game.properties: cheat +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: -this:name-is-not_valid$ +data: + game.properties: empty diff --git a/pkg/lint/rules/testdata/v3-fail/Chart.yaml b/pkg/lint/rules/testdata/v3-fail/Chart.yaml new file mode 100644 index 00000000..7097e17d --- /dev/null +++ b/pkg/lint/rules/testdata/v3-fail/Chart.yaml @@ -0,0 +1,21 @@ +apiVersion: v2 +name: v3-fail +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application and it is recommended to use it with quotes. +appVersion: "1.16.0" diff --git a/pkg/lint/rules/testdata/v3-fail/templates/_helpers.tpl b/pkg/lint/rules/testdata/v3-fail/templates/_helpers.tpl new file mode 100644 index 00000000..0b89e723 --- /dev/null +++ b/pkg/lint/rules/testdata/v3-fail/templates/_helpers.tpl @@ -0,0 +1,63 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "v3-fail.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "v3-fail.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "v3-fail.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Common labels +*/}} +{{- define "v3-fail.labels" -}} +helm.sh/chart: {{ include "v3-fail.chart" . }} +{{ include "v3-fail.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{/* +Selector labels +*/}} +{{- define "v3-fail.selectorLabels" -}} +app.kubernetes.io/name: {{ include "v3-fail.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{/* +Create the name of the service account to use +*/}} +{{- define "v3-fail.serviceAccountName" -}} +{{- if .Values.serviceAccount.create -}} + {{ default (include "v3-fail.fullname" .) .Values.serviceAccount.name }} +{{- else -}} + {{ default "default" .Values.serviceAccount.name }} +{{- end -}} +{{- end -}} diff --git a/pkg/lint/rules/testdata/v3-fail/templates/deployment.yaml b/pkg/lint/rules/testdata/v3-fail/templates/deployment.yaml new file mode 100644 index 00000000..6d651ab8 --- /dev/null +++ b/pkg/lint/rules/testdata/v3-fail/templates/deployment.yaml @@ -0,0 +1,56 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "v3-fail.fullname" . }} + labels: + nope: {{ .Release.Time }} + {{- include "v3-fail.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "v3-fail.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "v3-fail.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "v3-fail.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 80 + protocol: TCP + livenessProbe: + httpGet: + path: / + port: http + readinessProbe: + httpGet: + path: / + port: http + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/pkg/lint/rules/testdata/v3-fail/templates/ingress.yaml b/pkg/lint/rules/testdata/v3-fail/templates/ingress.yaml new file mode 100644 index 00000000..4790650d --- /dev/null +++ b/pkg/lint/rules/testdata/v3-fail/templates/ingress.yaml @@ -0,0 +1,62 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "v3-fail.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} + {{- end }} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "v3-fail.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + "helm.sh/hook": crd-install + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} + pathType: {{ .pathType }} + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ $fullName }} + port: + number: {{ $svcPort }} + {{- else }} + serviceName: {{ $fullName }} + servicePort: {{ $svcPort }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/pkg/lint/rules/testdata/v3-fail/templates/service.yaml b/pkg/lint/rules/testdata/v3-fail/templates/service.yaml new file mode 100644 index 00000000..79a0f40b --- /dev/null +++ b/pkg/lint/rules/testdata/v3-fail/templates/service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "v3-fail.fullname" . }} + annotations: + helm.sh/hook: crd-install + labels: + {{- include "v3-fail.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "v3-fail.selectorLabels" . | nindent 4 }} diff --git a/pkg/lint/rules/testdata/v3-fail/values.yaml b/pkg/lint/rules/testdata/v3-fail/values.yaml new file mode 100644 index 00000000..01d99b4e --- /dev/null +++ b/pkg/lint/rules/testdata/v3-fail/values.yaml @@ -0,0 +1,66 @@ +# Default values for v3-fail. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: nginx + pullPolicy: IfNotPresent + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: false + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: [] + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/pkg/lint/rules/testdata/withsubchart/Chart.yaml b/pkg/lint/rules/testdata/withsubchart/Chart.yaml new file mode 100644 index 00000000..6648daf5 --- /dev/null +++ b/pkg/lint/rules/testdata/withsubchart/Chart.yaml @@ -0,0 +1,16 @@ +apiVersion: v2 +name: withsubchart +description: A Helm chart for Kubernetes +type: application +version: 0.1.0 +appVersion: "1.16.0" +icon: http://riverrun.io + +dependencies: + - name: subchart + version: 0.1.16 + repository: "file://../subchart" + import-values: + - child: subchart + parent: subchart + diff --git a/pkg/lint/rules/testdata/withsubchart/charts/subchart/Chart.yaml b/pkg/lint/rules/testdata/withsubchart/charts/subchart/Chart.yaml new file mode 100644 index 00000000..8610a4f2 --- /dev/null +++ b/pkg/lint/rules/testdata/withsubchart/charts/subchart/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: subchart +description: A Helm chart for Kubernetes +type: application +version: 0.1.0 +appVersion: "1.16.0" diff --git a/pkg/lint/rules/testdata/withsubchart/charts/subchart/templates/subchart.yaml b/pkg/lint/rules/testdata/withsubchart/charts/subchart/templates/subchart.yaml new file mode 100644 index 00000000..6cb6cc2a --- /dev/null +++ b/pkg/lint/rules/testdata/withsubchart/charts/subchart/templates/subchart.yaml @@ -0,0 +1,2 @@ +metadata: + name: {{ .Values.subchart.name | lower }} diff --git a/pkg/lint/rules/testdata/withsubchart/charts/subchart/values.yaml b/pkg/lint/rules/testdata/withsubchart/charts/subchart/values.yaml new file mode 100644 index 00000000..422a359d --- /dev/null +++ b/pkg/lint/rules/testdata/withsubchart/charts/subchart/values.yaml @@ -0,0 +1,2 @@ +subchart: + name: subchart \ No newline at end of file diff --git a/pkg/lint/rules/testdata/withsubchart/templates/mainchart.yaml b/pkg/lint/rules/testdata/withsubchart/templates/mainchart.yaml new file mode 100644 index 00000000..6cb6cc2a --- /dev/null +++ b/pkg/lint/rules/testdata/withsubchart/templates/mainchart.yaml @@ -0,0 +1,2 @@ +metadata: + name: {{ .Values.subchart.name | lower }} diff --git a/pkg/lint/rules/testdata/withsubchart/values.yaml b/pkg/lint/rules/testdata/withsubchart/values.yaml new file mode 100644 index 00000000..e69de29b diff --git a/pkg/lint/rules/values.go b/pkg/lint/rules/values.go new file mode 100644 index 00000000..538d8381 --- /dev/null +++ b/pkg/lint/rules/values.go @@ -0,0 +1,86 @@ +/* +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 rules + +import ( + "os" + "path/filepath" + + "github.com/pkg/errors" + + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/lint/support" +) + +// Values lints a chart's values.yaml file. +// +// This function is deprecated and will be removed in Helm 4. +func Values(linter *support.Linter) { + ValuesWithOverrides(linter, map[string]interface{}{}) +} + +// ValuesWithOverrides tests the values.yaml file. +// +// If a schema is present in the chart, values are tested against that. Otherwise, +// they are only tested for well-formedness. +// +// If additional values are supplied, they are coalesced into the values in values.yaml. +func ValuesWithOverrides(linter *support.Linter, values map[string]interface{}) { + file := "values.yaml" + vf := filepath.Join(linter.ChartDir, file) + fileExists := linter.RunLinterRule(support.InfoSev, file, validateValuesFileExistence(vf)) + + if !fileExists { + return + } + + linter.RunLinterRule(support.ErrorSev, file, validateValuesFile(vf, values)) +} + +func validateValuesFileExistence(valuesPath string) error { + _, err := os.Stat(valuesPath) + if err != nil { + return errors.Errorf("file does not exist") + } + return nil +} + +func validateValuesFile(valuesPath string, overrides map[string]interface{}) error { + values, err := chartutil.ReadValuesFile(valuesPath) + if err != nil { + return errors.Wrap(err, "unable to parse YAML") + } + + // Helm 3.0.0 carried over the values linting from Helm 2.x, which only tests the top + // level values against the top-level expectations. Subchart values are not linted. + // We could change that. For now, though, we retain that strategy, and thus can + // coalesce tables (like reuse-values does) instead of doing the full chart + // CoalesceValues + coalescedValues := chartutil.CoalesceTables(make(map[string]interface{}, len(overrides)), overrides) + coalescedValues = chartutil.CoalesceTables(coalescedValues, values) + + ext := filepath.Ext(valuesPath) + schemaPath := valuesPath[:len(valuesPath)-len(ext)] + ".schema.json" + schema, err := os.ReadFile(schemaPath) + if len(schema) == 0 { + return nil + } + if err != nil { + return err + } + return chartutil.ValidateAgainstSingleSchema(coalescedValues, schema) +} diff --git a/pkg/lint/rules/values_test.go b/pkg/lint/rules/values_test.go new file mode 100644 index 00000000..faa29d48 --- /dev/null +++ b/pkg/lint/rules/values_test.go @@ -0,0 +1,169 @@ +/* +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 rules + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + + "helm.sh/helm/v3/internal/test/ensure" +) + +var nonExistingValuesFilePath = filepath.Join("/fake/dir", "values.yaml") + +const testSchema = ` +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "helm values test schema", + "type": "object", + "additionalProperties": false, + "required": [ + "username", + "password" + ], + "properties": { + "username": { + "description": "Your username", + "type": "string" + }, + "password": { + "description": "Your password", + "type": "string" + } + } +} +` + +func TestValidateValuesYamlNotDirectory(t *testing.T) { + _ = os.Mkdir(nonExistingValuesFilePath, os.ModePerm) + defer os.Remove(nonExistingValuesFilePath) + + err := validateValuesFileExistence(nonExistingValuesFilePath) + if err == nil { + t.Errorf("validateValuesFileExistence to return a linter error, got no error") + } +} + +func TestValidateValuesFileWellFormed(t *testing.T) { + badYaml := ` + not:well[]{}formed + ` + tmpdir := ensure.TempFile(t, "values.yaml", []byte(badYaml)) + valfile := filepath.Join(tmpdir, "values.yaml") + if err := validateValuesFile(valfile, map[string]interface{}{}); err == nil { + t.Fatal("expected values file to fail parsing") + } +} + +func TestValidateValuesFileSchema(t *testing.T) { + yaml := "username: admin\npassword: swordfish" + tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) + createTestingSchema(t, tmpdir) + + valfile := filepath.Join(tmpdir, "values.yaml") + if err := validateValuesFile(valfile, map[string]interface{}{}); err != nil { + t.Fatalf("Failed validation with %s", err) + } +} + +func TestValidateValuesFileSchemaFailure(t *testing.T) { + // 1234 is an int, not a string. This should fail. + yaml := "username: 1234\npassword: swordfish" + tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) + createTestingSchema(t, tmpdir) + + valfile := filepath.Join(tmpdir, "values.yaml") + + err := validateValuesFile(valfile, map[string]interface{}{}) + if err == nil { + t.Fatal("expected values file to fail parsing") + } + + assert.Contains(t, err.Error(), "Expected: string, given: integer", "integer should be caught by schema") +} + +func TestValidateValuesFileSchemaOverrides(t *testing.T) { + yaml := "username: admin" + overrides := map[string]interface{}{ + "password": "swordfish", + } + tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) + createTestingSchema(t, tmpdir) + + valfile := filepath.Join(tmpdir, "values.yaml") + if err := validateValuesFile(valfile, overrides); err != nil { + t.Fatalf("Failed validation with %s", err) + } +} + +func TestValidateValuesFile(t *testing.T) { + tests := []struct { + name string + yaml string + overrides map[string]interface{} + errorMessage string + }{ + { + name: "value added", + yaml: "username: admin", + overrides: map[string]interface{}{"password": "swordfish"}, + }, + { + name: "value not overridden", + yaml: "username: admin\npassword:", + overrides: map[string]interface{}{"username": "anotherUser"}, + errorMessage: "Expected: string, given: null", + }, + { + name: "value overridden", + yaml: "username: admin\npassword:", + overrides: map[string]interface{}{"username": "anotherUser", "password": "swordfish"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpdir := ensure.TempFile(t, "values.yaml", []byte(tt.yaml)) + createTestingSchema(t, tmpdir) + + valfile := filepath.Join(tmpdir, "values.yaml") + + err := validateValuesFile(valfile, tt.overrides) + + switch { + case err != nil && tt.errorMessage == "": + t.Errorf("Failed validation with %s", err) + case err == nil && tt.errorMessage != "": + t.Error("expected values file to fail parsing") + case err != nil && tt.errorMessage != "": + assert.Contains(t, err.Error(), tt.errorMessage, "Failed with unexpected error") + } + }) + } +} + +func createTestingSchema(t *testing.T, dir string) string { + t.Helper() + schemafile := filepath.Join(dir, "values.schema.json") + if err := os.WriteFile(schemafile, []byte(testSchema), 0700); err != nil { + t.Fatalf("Failed to write schema to tmpdir: %s", err) + } + return schemafile +} diff --git a/pkg/lint/support/doc.go b/pkg/lint/support/doc.go new file mode 100644 index 00000000..bffefe8f --- /dev/null +++ b/pkg/lint/support/doc.go @@ -0,0 +1,23 @@ +/* +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 support contains tools for linting charts. + +Linting is the process of testing charts for errors or warnings regarding +formatting, compilation, or standards compliance. +*/ +package support // import "helm.sh/helm/v3/pkg/lint/support" diff --git a/pkg/lint/support/message.go b/pkg/lint/support/message.go new file mode 100644 index 00000000..5efbc7a6 --- /dev/null +++ b/pkg/lint/support/message.go @@ -0,0 +1,76 @@ +/* +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 support + +import "fmt" + +// Severity indicates the severity of a Message. +const ( + // UnknownSev indicates that the severity of the error is unknown, and should not stop processing. + UnknownSev = iota + // InfoSev indicates information, for example missing values.yaml file + InfoSev + // WarningSev indicates that something does not meet code standards, but will likely function. + WarningSev + // ErrorSev indicates that something will not likely function. + ErrorSev +) + +// sev matches the *Sev states. +var sev = []string{"UNKNOWN", "INFO", "WARNING", "ERROR"} + +// Linter encapsulates a linting run of a particular chart. +type Linter struct { + Messages []Message + // The highest severity of all the failing lint rules + HighestSeverity int + ChartDir string +} + +// Message describes an error encountered while linting. +type Message struct { + // Severity is one of the *Sev constants + Severity int + Path string + Err error +} + +func (m Message) Error() string { + return fmt.Sprintf("[%s] %s: %s", sev[m.Severity], m.Path, m.Err.Error()) +} + +// NewMessage creates a new Message struct +func NewMessage(severity int, path string, err error) Message { + return Message{Severity: severity, Path: path, Err: err} +} + +// RunLinterRule returns true if the validation passed +func (l *Linter) RunLinterRule(severity int, path string, err error) bool { + // severity is out of bound + if severity < 0 || severity >= len(sev) { + return false + } + + if err != nil { + l.Messages = append(l.Messages, NewMessage(severity, path, err)) + + if severity > l.HighestSeverity { + l.HighestSeverity = severity + } + } + return err == nil +} diff --git a/pkg/lint/support/message_test.go b/pkg/lint/support/message_test.go new file mode 100644 index 00000000..9e12a638 --- /dev/null +++ b/pkg/lint/support/message_test.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 support + +import ( + "testing" + + "github.com/pkg/errors" +) + +var linter = Linter{} +var errLint = errors.New("lint failed") + +func TestRunLinterRule(t *testing.T) { + var tests = []struct { + Severity int + LintError error + ExpectedMessages int + ExpectedReturn bool + ExpectedHighestSeverity int + }{ + {InfoSev, errLint, 1, false, InfoSev}, + {WarningSev, errLint, 2, false, WarningSev}, + {ErrorSev, errLint, 3, false, ErrorSev}, + // No error so it returns true + {ErrorSev, nil, 3, true, ErrorSev}, + // Retains highest severity + {InfoSev, errLint, 4, false, ErrorSev}, + // Invalid severity values + {4, errLint, 4, false, ErrorSev}, + {22, errLint, 4, false, ErrorSev}, + {-1, errLint, 4, false, ErrorSev}, + } + + for _, test := range tests { + isValid := linter.RunLinterRule(test.Severity, "chart", test.LintError) + if len(linter.Messages) != test.ExpectedMessages { + t.Errorf("RunLinterRule(%d, \"chart\", %v), linter.Messages should now have %d message, we got %d", test.Severity, test.LintError, test.ExpectedMessages, len(linter.Messages)) + } + + if linter.HighestSeverity != test.ExpectedHighestSeverity { + t.Errorf("RunLinterRule(%d, \"chart\", %v), linter.HighestSeverity should be %d, we got %d", test.Severity, test.LintError, test.ExpectedHighestSeverity, linter.HighestSeverity) + } + + if isValid != test.ExpectedReturn { + t.Errorf("RunLinterRule(%d, \"chart\", %v), should have returned %t but returned %t", test.Severity, test.LintError, test.ExpectedReturn, isValid) + } + } +} + +func TestMessage(t *testing.T) { + m := Message{ErrorSev, "Chart.yaml", errors.New("Foo")} + if m.Error() != "[ERROR] Chart.yaml: Foo" { + t.Errorf("Unexpected output: %s", m.Error()) + } + + m = Message{WarningSev, "templates/", errors.New("Bar")} + if m.Error() != "[WARNING] templates/: Bar" { + t.Errorf("Unexpected output: %s", m.Error()) + } + + m = Message{InfoSev, "templates/rc.yaml", errors.New("FooBar")} + if m.Error() != "[INFO] templates/rc.yaml: FooBar" { + t.Errorf("Unexpected output: %s", m.Error()) + } +} diff --git a/pkg/plugin/cache/cache.go b/pkg/plugin/cache/cache.go new file mode 100644 index 00000000..5f3345b6 --- /dev/null +++ b/pkg/plugin/cache/cache.go @@ -0,0 +1,67 @@ +/* +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 cache provides a key generator for vcs urls. +package cache // import "helm.sh/helm/v3/pkg/plugin/cache" + +import ( + "net/url" + "regexp" + "strings" +) + +// Thanks glide! + +// scpSyntaxRe matches the SCP-like addresses used to access repos over SSH. +var scpSyntaxRe = regexp.MustCompile(`^([a-zA-Z0-9_]+)@([a-zA-Z0-9._-]+):(.*)$`) + +// Key generates a cache key based on a url or scp string. The key is file +// system safe. +func Key(repo string) (string, error) { + var ( + u *url.URL + err error + ) + if m := scpSyntaxRe.FindStringSubmatch(repo); m != nil { + // Match SCP-like syntax and convert it to a URL. + // Eg, "git@github.com:user/repo" becomes + // "ssh://git@github.com/user/repo". + u = &url.URL{ + User: url.User(m[1]), + Host: m[2], + Path: "/" + m[3], + } + } else { + u, err = url.Parse(repo) + if err != nil { + return "", err + } + } + + var key strings.Builder + if u.Scheme != "" { + key.WriteString(u.Scheme) + key.WriteString("-") + } + if u.User != nil && u.User.Username() != "" { + key.WriteString(u.User.Username()) + key.WriteString("-") + } + key.WriteString(u.Host) + if u.Path != "" { + key.WriteString(strings.ReplaceAll(u.Path, "/", "-")) + } + return strings.ReplaceAll(key.String(), ":", "-"), nil +} diff --git a/pkg/plugin/hooks.go b/pkg/plugin/hooks.go new file mode 100644 index 00000000..e3481515 --- /dev/null +++ b/pkg/plugin/hooks.go @@ -0,0 +1,29 @@ +/* +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 plugin // import "helm.sh/helm/v3/pkg/plugin" + +// Types of hooks +const ( + // Install is executed after the plugin is added. + Install = "install" + // Delete is executed after the plugin is removed. + Delete = "delete" + // Update is executed after the plugin is updated. + Update = "update" +) + +// Hooks is a map of events to commands. +type Hooks map[string]string diff --git a/pkg/plugin/installer/base.go b/pkg/plugin/installer/base.go new file mode 100644 index 00000000..ba6a55d5 --- /dev/null +++ b/pkg/plugin/installer/base.go @@ -0,0 +1,45 @@ +/* +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 installer // import "helm.sh/helm/v3/pkg/plugin/installer" + +import ( + "path/filepath" + + "helm.sh/helm/v3/pkg/cli" +) + +type base struct { + // Source is the reference to a plugin + Source string + // PluginsDirectory is the directory where plugins are installed + PluginsDirectory string +} + +func newBase(source string) base { + settings := cli.New() + return base{ + Source: source, + PluginsDirectory: settings.PluginsDirectory, + } +} + +// Path is where the plugin will be installed. +func (b *base) Path() string { + if b.Source == "" { + return "" + } + return filepath.Join(b.PluginsDirectory, filepath.Base(b.Source)) +} diff --git a/pkg/plugin/installer/base_test.go b/pkg/plugin/installer/base_test.go new file mode 100644 index 00000000..38ef28c3 --- /dev/null +++ b/pkg/plugin/installer/base_test.go @@ -0,0 +1,48 @@ +/* +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 installer // import "helm.sh/helm/v3/pkg/plugin/installer" + +import ( + "os" + "testing" +) + +func TestPath(t *testing.T) { + tests := []struct { + source string + helmPluginsDir string + expectPath string + }{ + { + source: "", + helmPluginsDir: "/helm/data/plugins", + expectPath: "", + }, { + source: "https://github.com/jkroepke/helm-secrets", + helmPluginsDir: "/helm/data/plugins", + expectPath: "/helm/data/plugins/helm-secrets", + }, + } + + for _, tt := range tests { + + os.Setenv("HELM_PLUGINS", tt.helmPluginsDir) + baseIns := newBase(tt.source) + baseInsPath := baseIns.Path() + if baseInsPath != tt.expectPath { + t.Errorf("expected name %s, got %s", tt.expectPath, baseInsPath) + } + os.Unsetenv("HELM_PLUGINS") + } +} diff --git a/pkg/plugin/installer/doc.go b/pkg/plugin/installer/doc.go new file mode 100644 index 00000000..3e3b2ebe --- /dev/null +++ b/pkg/plugin/installer/doc.go @@ -0,0 +1,17 @@ +/* +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 installer provides an interface for installing Helm plugins. +package installer // import "helm.sh/helm/v3/pkg/plugin/installer" diff --git a/pkg/plugin/installer/http_installer.go b/pkg/plugin/installer/http_installer.go new file mode 100644 index 00000000..49274f83 --- /dev/null +++ b/pkg/plugin/installer/http_installer.go @@ -0,0 +1,268 @@ +/* +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 installer // import "helm.sh/helm/v3/pkg/plugin/installer" + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "io" + "os" + "path" + "path/filepath" + "regexp" + "strings" + + securejoin "github.com/cyphar/filepath-securejoin" + "github.com/pkg/errors" + + "helm.sh/helm/v3/internal/third_party/dep/fs" + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/helmpath" + "helm.sh/helm/v3/pkg/plugin/cache" +) + +// HTTPInstaller installs plugins from an archive served by a web server. +type HTTPInstaller struct { + CacheDir string + PluginName string + base + extractor Extractor + getter getter.Getter +} + +// TarGzExtractor extracts gzip compressed tar archives +type TarGzExtractor struct{} + +// Extractor provides an interface for extracting archives +type Extractor interface { + Extract(buffer *bytes.Buffer, targetDir string) error +} + +// Extractors contains a map of suffixes and matching implementations of extractor to return +var Extractors = map[string]Extractor{ + ".tar.gz": &TarGzExtractor{}, + ".tgz": &TarGzExtractor{}, +} + +// Convert a media type to an extractor extension. +// +// This should be refactored in Helm 4, combined with the extension-based mechanism. +func mediaTypeToExtension(mt string) (string, bool) { + switch strings.ToLower(mt) { + case "application/gzip", "application/x-gzip", "application/x-tgz", "application/x-gtar": + return ".tgz", true + default: + return "", false + } +} + +// NewExtractor creates a new extractor matching the source file name +func NewExtractor(source string) (Extractor, error) { + for suffix, extractor := range Extractors { + if strings.HasSuffix(source, suffix) { + return extractor, nil + } + } + return nil, errors.Errorf("no extractor implemented yet for %s", source) +} + +// NewHTTPInstaller creates a new HttpInstaller. +func NewHTTPInstaller(source string) (*HTTPInstaller, error) { + key, err := cache.Key(source) + if err != nil { + return nil, err + } + + extractor, err := NewExtractor(source) + if err != nil { + return nil, err + } + + get, err := getter.All(new(cli.EnvSettings)).ByScheme("http") + if err != nil { + return nil, err + } + + i := &HTTPInstaller{ + CacheDir: helmpath.CachePath("plugins", key), + PluginName: stripPluginName(filepath.Base(source)), + base: newBase(source), + extractor: extractor, + getter: get, + } + return i, nil +} + +// helper that relies on some sort of convention for plugin name (plugin-name-) +func stripPluginName(name string) string { + var strippedName string + for suffix := range Extractors { + if strings.HasSuffix(name, suffix) { + strippedName = strings.TrimSuffix(name, suffix) + break + } + } + re := regexp.MustCompile(`(.*)-[0-9]+\..*`) + return re.ReplaceAllString(strippedName, `$1`) +} + +// Install downloads and extracts the tarball into the cache directory +// and installs into the plugin directory. +// +// Implements Installer. +func (i *HTTPInstaller) Install() error { + pluginData, err := i.getter.Get(i.Source) + if err != nil { + return err + } + + if err := i.extractor.Extract(pluginData, i.CacheDir); err != nil { + return errors.Wrap(err, "extracting files from archive") + } + + if !isPlugin(i.CacheDir) { + return ErrMissingMetadata + } + + src, err := filepath.Abs(i.CacheDir) + if err != nil { + return err + } + + debug("copying %s to %s", src, i.Path()) + return fs.CopyDir(src, i.Path()) +} + +// Update updates a local repository +// Not implemented for now since tarball most likely will be packaged by version +func (i *HTTPInstaller) Update() error { + return errors.Errorf("method Update() not implemented for HttpInstaller") +} + +// Path is overridden because we want to join on the plugin name not the file name +func (i HTTPInstaller) Path() string { + if i.base.Source == "" { + return "" + } + return helmpath.DataPath("plugins", i.PluginName) +} + +// cleanJoin resolves dest as a subpath of root. +// +// This function runs several security checks on the path, generating an error if +// the supplied `dest` looks suspicious or would result in dubious behavior on the +// filesystem. +// +// cleanJoin assumes that any attempt by `dest` to break out of the CWD is an attempt +// to be malicious. (If you don't care about this, use the securejoin-filepath library.) +// It will emit an error if it detects paths that _look_ malicious, operating on the +// assumption that we don't actually want to do anything with files that already +// appear to be nefarious. +// +// - The character `:` is considered illegal because it is a separator on UNIX and a +// drive designator on Windows. +// - The path component `..` is considered suspicions, and therefore illegal +// - The character \ (backslash) is treated as a path separator and is converted to /. +// - Beginning a path with a path separator is illegal +// - Rudimentary symlink protects are offered by SecureJoin. +func cleanJoin(root, dest string) (string, error) { + + // On Windows, this is a drive separator. On UNIX-like, this is the path list separator. + // In neither case do we want to trust a TAR that contains these. + if strings.Contains(dest, ":") { + return "", errors.New("path contains ':', which is illegal") + } + + // The Go tar library does not convert separators for us. + // We assume here, as we do elsewhere, that `\\` means a Windows path. + dest = strings.ReplaceAll(dest, "\\", "/") + + // We want to alert the user that something bad was attempted. Cleaning it + // is not a good practice. + for _, part := range strings.Split(dest, "/") { + if part == ".." { + return "", errors.New("path contains '..', which is illegal") + } + } + + // If a path is absolute, the creator of the TAR is doing something shady. + if path.IsAbs(dest) { + return "", errors.New("path is absolute, which is illegal") + } + + // SecureJoin will do some cleaning, as well as some rudimentary checking of symlinks. + newpath, err := securejoin.SecureJoin(root, dest) + if err != nil { + return "", err + } + + return filepath.ToSlash(newpath), nil +} + +// Extract extracts compressed archives +// +// Implements Extractor. +func (g *TarGzExtractor) Extract(buffer *bytes.Buffer, targetDir string) error { + uncompressedStream, err := gzip.NewReader(buffer) + if err != nil { + return err + } + + if err := os.MkdirAll(targetDir, 0755); err != nil { + return err + } + + tarReader := tar.NewReader(uncompressedStream) + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + + path, err := cleanJoin(targetDir, header.Name) + if err != nil { + return err + } + + switch header.Typeflag { + case tar.TypeDir: + if err := os.Mkdir(path, 0755); err != nil { + return err + } + case tar.TypeReg: + outFile, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode)) + if err != nil { + return err + } + if _, err := io.Copy(outFile, tarReader); err != nil { + outFile.Close() + return err + } + outFile.Close() + // We don't want to process these extension header files. + case tar.TypeXGlobalHeader, tar.TypeXHeader: + continue + default: + return errors.Errorf("unknown type: %b in %s", header.Typeflag, header.Name) + } + } + return nil +} diff --git a/pkg/plugin/installer/http_installer_test.go b/pkg/plugin/installer/http_installer_test.go new file mode 100644 index 00000000..f0fe36ec --- /dev/null +++ b/pkg/plugin/installer/http_installer_test.go @@ -0,0 +1,350 @@ +/* +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 installer // import "helm.sh/helm/v3/pkg/plugin/installer" + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/base64" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + "github.com/pkg/errors" + + "helm.sh/helm/v3/internal/test/ensure" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/helmpath" +) + +var _ Installer = new(HTTPInstaller) + +// Fake http client +type TestHTTPGetter struct { + MockResponse *bytes.Buffer + MockError error +} + +func (t *TestHTTPGetter) Get(_ string, _ ...getter.Option) (*bytes.Buffer, error) { + return t.MockResponse, t.MockError +} + +// Fake plugin tarball data +var fakePluginB64 = "H4sIAKRj51kAA+3UX0vCUBgGcC9jn+Iwuk3Peza3GeyiUlJQkcogCOzgli7dJm4TvYk+a5+k479UqquUCJ/fLs549sLO2TnvWnJa9aXnjwujYdYLovxMhsPcfnHOLdNkOXthM/IVQQYjg2yyLLJ4kXGhLp5j0z3P41tZksqxmspL3B/O+j/XtZu1y8rdYzkOZRCxduKPk53ny6Wwz/GfIIf1As8lxzGJSmoHNLJZphKHG4YpTCE0wVk3DULfpSJ3DMMqkj3P5JfMYLdX1Vr9Ie/5E5cstcdC8K04iGLX5HaJuKpWL17F0TCIBi5pf/0pjtLhun5j3f9v6r7wfnI/H0eNp9d1/5P6Gez0vzo7wsoxfrAZbTny/o9k6J8z/VkO/LPlWdC1iVpbEEcq5nmeJ13LEtmbV0k2r2PrOs9PuuNglC5rL1Y5S/syXRQmutaNw1BGnnp8Wq3UG51WvX1da3bKtZtCN/R09DwAAAAAAAAAAAAAAAAAAADAb30AoMczDwAoAAA=" + +func TestStripName(t *testing.T) { + if stripPluginName("fake-plugin-0.0.1.tar.gz") != "fake-plugin" { + t.Errorf("name does not match expected value") + } + if stripPluginName("fake-plugin-0.0.1.tgz") != "fake-plugin" { + t.Errorf("name does not match expected value") + } + if stripPluginName("fake-plugin.tgz") != "fake-plugin" { + t.Errorf("name does not match expected value") + } + if stripPluginName("fake-plugin.tar.gz") != "fake-plugin" { + t.Errorf("name does not match expected value") + } +} + +func mockArchiveServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, ".tar.gz") { + w.Header().Add("Content-Type", "text/html") + fmt.Fprintln(w, "broken") + return + } + w.Header().Add("Content-Type", "application/gzip") + fmt.Fprintln(w, "test") + })) +} + +func TestHTTPInstaller(t *testing.T) { + ensure.HelmHome(t) + + srv := mockArchiveServer() + defer srv.Close() + source := srv.URL + "/plugins/fake-plugin-0.0.1.tar.gz" + + if err := os.MkdirAll(helmpath.DataPath("plugins"), 0755); err != nil { + t.Fatalf("Could not create %s: %s", helmpath.DataPath("plugins"), err) + } + + i, err := NewForSource(source, "0.0.1") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + // ensure a HTTPInstaller was returned + httpInstaller, ok := i.(*HTTPInstaller) + if !ok { + t.Fatal("expected a HTTPInstaller") + } + + // inject fake http client responding with minimal plugin tarball + mockTgz, err := base64.StdEncoding.DecodeString(fakePluginB64) + if err != nil { + t.Fatalf("Could not decode fake tgz plugin: %s", err) + } + + httpInstaller.getter = &TestHTTPGetter{ + MockResponse: bytes.NewBuffer(mockTgz), + } + + // install the plugin + if err := Install(i); err != nil { + t.Fatal(err) + } + if i.Path() != helmpath.DataPath("plugins", "fake-plugin") { + t.Fatalf("expected path '$XDG_CONFIG_HOME/helm/plugins/fake-plugin', got %q", i.Path()) + } + + // Install again to test plugin exists error + if err := Install(i); err == nil { + t.Fatal("expected error for plugin exists, got none") + } else if err.Error() != "plugin already exists" { + t.Fatalf("expected error for plugin exists, got (%v)", err) + } + +} + +func TestHTTPInstallerNonExistentVersion(t *testing.T) { + ensure.HelmHome(t) + srv := mockArchiveServer() + defer srv.Close() + source := srv.URL + "/plugins/fake-plugin-0.0.1.tar.gz" + + if err := os.MkdirAll(helmpath.DataPath("plugins"), 0755); err != nil { + t.Fatalf("Could not create %s: %s", helmpath.DataPath("plugins"), err) + } + + i, err := NewForSource(source, "0.0.2") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + // ensure a HTTPInstaller was returned + httpInstaller, ok := i.(*HTTPInstaller) + if !ok { + t.Fatal("expected a HTTPInstaller") + } + + // inject fake http client responding with error + httpInstaller.getter = &TestHTTPGetter{ + MockError: errors.Errorf("failed to download plugin for some reason"), + } + + // attempt to install the plugin + if err := Install(i); err == nil { + t.Fatal("expected error from http client") + } + +} + +func TestHTTPInstallerUpdate(t *testing.T) { + srv := mockArchiveServer() + defer srv.Close() + source := srv.URL + "/plugins/fake-plugin-0.0.1.tar.gz" + ensure.HelmHome(t) + + if err := os.MkdirAll(helmpath.DataPath("plugins"), 0755); err != nil { + t.Fatalf("Could not create %s: %s", helmpath.DataPath("plugins"), err) + } + + i, err := NewForSource(source, "0.0.1") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + // ensure a HTTPInstaller was returned + httpInstaller, ok := i.(*HTTPInstaller) + if !ok { + t.Fatal("expected a HTTPInstaller") + } + + // inject fake http client responding with minimal plugin tarball + mockTgz, err := base64.StdEncoding.DecodeString(fakePluginB64) + if err != nil { + t.Fatalf("Could not decode fake tgz plugin: %s", err) + } + + httpInstaller.getter = &TestHTTPGetter{ + MockResponse: bytes.NewBuffer(mockTgz), + } + + // install the plugin before updating + if err := Install(i); err != nil { + t.Fatal(err) + } + if i.Path() != helmpath.DataPath("plugins", "fake-plugin") { + t.Fatalf("expected path '$XDG_CONFIG_HOME/helm/plugins/fake-plugin', got %q", i.Path()) + } + + // Update plugin, should fail because it is not implemented + if err := Update(i); err == nil { + t.Fatal("update method not implemented for http installer") + } +} + +func TestExtract(t *testing.T) { + source := "https://repo.localdomain/plugins/fake-plugin-0.0.1.tar.gz" + + tempDir := t.TempDir() + + // Set the umask to default open permissions so we can actually test + oldmask := syscall.Umask(0000) + defer func() { + syscall.Umask(oldmask) + }() + + // Write a tarball to a buffer for us to extract + var tarbuf bytes.Buffer + tw := tar.NewWriter(&tarbuf) + var files = []struct { + Name, Body string + Mode int64 + }{ + {"plugin.yaml", "plugin metadata", 0600}, + {"README.md", "some text", 0777}, + } + for _, file := range files { + hdr := &tar.Header{ + Name: file.Name, + Typeflag: tar.TypeReg, + Mode: file.Mode, + Size: int64(len(file.Body)), + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte(file.Body)); err != nil { + t.Fatal(err) + } + } + + // Add pax global headers. This should be ignored. + // Note the PAX header that isn't global cannot be written using WriteHeader. + // Details are in the internal Go function for the tar packaged named + // allowedFormats. For a TypeXHeader it will return a message stating + // "cannot manually encode TypeXHeader, TypeGNULongName, or TypeGNULongLink headers" + if err := tw.WriteHeader(&tar.Header{ + Name: "pax_global_header", + Typeflag: tar.TypeXGlobalHeader, + }); err != nil { + t.Fatal(err) + } + + if err := tw.Close(); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + if _, err := gz.Write(tarbuf.Bytes()); err != nil { + t.Fatal(err) + } + gz.Close() + // END tarball creation + + extractor, err := NewExtractor(source) + if err != nil { + t.Fatal(err) + } + + if err = extractor.Extract(&buf, tempDir); err != nil { + t.Fatalf("Did not expect error but got error: %v", err) + } + + pluginYAMLFullPath := filepath.Join(tempDir, "plugin.yaml") + if info, err := os.Stat(pluginYAMLFullPath); err != nil { + if os.IsNotExist(err) { + t.Fatalf("Expected %s to exist but doesn't", pluginYAMLFullPath) + } + t.Fatal(err) + } else if info.Mode().Perm() != 0600 { + t.Fatalf("Expected %s to have 0600 mode it but has %o", pluginYAMLFullPath, info.Mode().Perm()) + } + + readmeFullPath := filepath.Join(tempDir, "README.md") + if info, err := os.Stat(readmeFullPath); err != nil { + if os.IsNotExist(err) { + t.Fatalf("Expected %s to exist but doesn't", readmeFullPath) + } + t.Fatal(err) + } else if info.Mode().Perm() != 0777 { + t.Fatalf("Expected %s to have 0777 mode it but has %o", readmeFullPath, info.Mode().Perm()) + } + +} + +func TestCleanJoin(t *testing.T) { + for i, fixture := range []struct { + path string + expect string + expectError bool + }{ + {"foo/bar.txt", "/tmp/foo/bar.txt", false}, + {"/foo/bar.txt", "", true}, + {"./foo/bar.txt", "/tmp/foo/bar.txt", false}, + {"./././././foo/bar.txt", "/tmp/foo/bar.txt", false}, + {"../../../../foo/bar.txt", "", true}, + {"foo/../../../../bar.txt", "", true}, + {"c:/foo/bar.txt", "/tmp/c:/foo/bar.txt", true}, + {"foo\\bar.txt", "/tmp/foo/bar.txt", false}, + {"c:\\foo\\bar.txt", "", true}, + } { + out, err := cleanJoin("/tmp", fixture.path) + if err != nil { + if !fixture.expectError { + t.Errorf("Test %d: Path was not cleaned: %s", i, err) + } + continue + } + if fixture.expect != out { + t.Errorf("Test %d: Expected %q but got %q", i, fixture.expect, out) + } + } + +} + +func TestMediaTypeToExtension(t *testing.T) { + + for mt, shouldPass := range map[string]bool{ + "": false, + "application/gzip": true, + "application/x-gzip": true, + "application/x-tgz": true, + "application/x-gtar": true, + "application/json": false, + } { + ext, ok := mediaTypeToExtension(mt) + if ok != shouldPass { + t.Errorf("Media type %q failed test", mt) + } + if shouldPass && ext == "" { + t.Errorf("Expected an extension but got empty string") + } + if !shouldPass && len(ext) != 0 { + t.Error("Expected extension to be empty for unrecognized type") + } + } +} diff --git a/pkg/plugin/installer/installer.go b/pkg/plugin/installer/installer.go new file mode 100644 index 00000000..6f01494e --- /dev/null +++ b/pkg/plugin/installer/installer.go @@ -0,0 +1,135 @@ +/* +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 installer + +import ( + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/pkg/errors" + + "helm.sh/helm/v3/pkg/plugin" +) + +// ErrMissingMetadata indicates that plugin.yaml is missing. +var ErrMissingMetadata = errors.New("plugin metadata (plugin.yaml) missing") + +// Debug enables verbose output. +var Debug bool + +// Installer provides an interface for installing helm client plugins. +type Installer interface { + // Install adds a plugin. + Install() error + // Path is the directory of the installed plugin. + Path() string + // Update updates a plugin. + Update() error +} + +// Install installs a plugin. +func Install(i Installer) error { + if err := os.MkdirAll(filepath.Dir(i.Path()), 0755); err != nil { + return err + } + if _, pathErr := os.Stat(i.Path()); !os.IsNotExist(pathErr) { + return errors.New("plugin already exists") + } + return i.Install() +} + +// Update updates a plugin. +func Update(i Installer) error { + if _, pathErr := os.Stat(i.Path()); os.IsNotExist(pathErr) { + return errors.New("plugin does not exist") + } + return i.Update() +} + +// NewForSource determines the correct Installer for the given source. +func NewForSource(source, version string) (Installer, error) { + // Check if source is a local directory + if isLocalReference(source) { + return NewLocalInstaller(source) + } else if isRemoteHTTPArchive(source) { + return NewHTTPInstaller(source) + } + return NewVCSInstaller(source, version) +} + +// FindSource determines the correct Installer for the given source. +func FindSource(location string) (Installer, error) { + installer, err := existingVCSRepo(location) + if err != nil && err.Error() == "Cannot detect VCS" { + return installer, errors.New("cannot get information about plugin source") + } + return installer, err +} + +// isLocalReference checks if the source exists on the filesystem. +func isLocalReference(source string) bool { + _, err := os.Stat(source) + return err == nil +} + +// isRemoteHTTPArchive checks if the source is a http/https url and is an archive +// +// It works by checking whether the source looks like a URL and, if it does, running a +// HEAD operation to see if the remote resource is a file that we understand. +func isRemoteHTTPArchive(source string) bool { + if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") { + res, err := http.Head(source) + if err != nil { + // If we get an error at the network layer, we can't install it. So + // we return false. + return false + } + + // Next, we look for the content type or content disposition headers to see + // if they have matching extractors. + contentType := res.Header.Get("content-type") + foundSuffix, ok := mediaTypeToExtension(contentType) + if !ok { + // Media type not recognized + return false + } + + for suffix := range Extractors { + if strings.HasSuffix(foundSuffix, suffix) { + return true + } + } + } + return false +} + +// isPlugin checks if the directory contains a plugin.yaml file. +func isPlugin(dirname string) bool { + _, err := os.Stat(filepath.Join(dirname, plugin.PluginFileName)) + return err == nil +} + +var logger = log.New(os.Stderr, "[debug] ", log.Lshortfile) + +func debug(format string, args ...interface{}) { + if Debug { + logger.Output(2, fmt.Sprintf(format, args...)) + } +} diff --git a/pkg/plugin/installer/installer_test.go b/pkg/plugin/installer/installer_test.go new file mode 100644 index 00000000..a1146492 --- /dev/null +++ b/pkg/plugin/installer/installer_test.go @@ -0,0 +1,40 @@ +/* +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 installer + +import "testing" + +func TestIsRemoteHTTPArchive(t *testing.T) { + srv := mockArchiveServer() + defer srv.Close() + source := srv.URL + "/plugins/fake-plugin-0.0.1.tar.gz" + + if isRemoteHTTPArchive("/not/a/URL") { + t.Errorf("Expected non-URL to return false") + } + + if isRemoteHTTPArchive("https://127.0.0.1:123/fake/plugin-1.2.3.tgz") { + t.Errorf("Bad URL should not have succeeded.") + } + + if !isRemoteHTTPArchive(source) { + t.Errorf("Expected %q to be a valid archive URL", source) + } + + if isRemoteHTTPArchive(source + "-not-an-extension") { + t.Error("Expected media type match to fail") + } +} diff --git a/pkg/plugin/installer/local_installer.go b/pkg/plugin/installer/local_installer.go new file mode 100644 index 00000000..759df38b --- /dev/null +++ b/pkg/plugin/installer/local_installer.go @@ -0,0 +1,68 @@ +/* +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 installer // import "helm.sh/helm/v3/pkg/plugin/installer" + +import ( + "os" + "path/filepath" + + "github.com/pkg/errors" +) + +// ErrPluginNotAFolder indicates that the plugin path is not a folder. +var ErrPluginNotAFolder = errors.New("expected plugin to be a folder") + +// LocalInstaller installs plugins from the filesystem. +type LocalInstaller struct { + base +} + +// NewLocalInstaller creates a new LocalInstaller. +func NewLocalInstaller(source string) (*LocalInstaller, error) { + src, err := filepath.Abs(source) + if err != nil { + return nil, errors.Wrap(err, "unable to get absolute path to plugin") + } + i := &LocalInstaller{ + base: newBase(src), + } + return i, nil +} + +// Install creates a symlink to the plugin directory. +// +// Implements Installer. +func (i *LocalInstaller) Install() error { + stat, err := os.Stat(i.Source) + if err != nil { + return err + } + if !stat.IsDir() { + return ErrPluginNotAFolder + } + + if !isPlugin(i.Source) { + return ErrMissingMetadata + } + debug("symlinking %s to %s", i.Source, i.Path()) + return os.Symlink(i.Source, i.Path()) +} + +// Update updates a local repository +func (i *LocalInstaller) Update() error { + debug("local repository is auto-updated") + return nil +} diff --git a/pkg/plugin/installer/local_installer_test.go b/pkg/plugin/installer/local_installer_test.go new file mode 100644 index 00000000..51408f12 --- /dev/null +++ b/pkg/plugin/installer/local_installer_test.go @@ -0,0 +1,65 @@ +/* +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 installer // import "helm.sh/helm/v3/pkg/plugin/installer" + +import ( + "os" + "path/filepath" + "testing" + + "helm.sh/helm/v3/pkg/helmpath" +) + +var _ Installer = new(LocalInstaller) + +func TestLocalInstaller(t *testing.T) { + // Make a temp dir + tdir := t.TempDir() + if err := os.WriteFile(filepath.Join(tdir, "plugin.yaml"), []byte{}, 0644); err != nil { + t.Fatal(err) + } + + source := "../testdata/plugdir/good/echo" + i, err := NewForSource(source, "") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if err := Install(i); err != nil { + t.Fatal(err) + } + + if i.Path() != helmpath.DataPath("plugins", "echo") { + t.Fatalf("expected path '$XDG_CONFIG_HOME/helm/plugins/helm-env', got %q", i.Path()) + } + defer os.RemoveAll(filepath.Dir(helmpath.DataPath())) // helmpath.DataPath is like /tmp/helm013130971/helm +} + +func TestLocalInstallerNotAFolder(t *testing.T) { + source := "../testdata/plugdir/good/echo/plugin.yaml" + i, err := NewForSource(source, "") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + err = Install(i) + if err == nil { + t.Fatal("expected error") + } + if err != ErrPluginNotAFolder { + t.Fatalf("expected error to equal: %q", err) + } +} diff --git a/pkg/plugin/installer/vcs_installer.go b/pkg/plugin/installer/vcs_installer.go new file mode 100644 index 00000000..f7df5b32 --- /dev/null +++ b/pkg/plugin/installer/vcs_installer.go @@ -0,0 +1,176 @@ +/* +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 installer // import "helm.sh/helm/v3/pkg/plugin/installer" + +import ( + "os" + "sort" + + "github.com/Masterminds/semver/v3" + "github.com/Masterminds/vcs" + "github.com/pkg/errors" + + "helm.sh/helm/v3/internal/third_party/dep/fs" + "helm.sh/helm/v3/pkg/helmpath" + "helm.sh/helm/v3/pkg/plugin/cache" +) + +// VCSInstaller installs plugins from remote a repository. +type VCSInstaller struct { + Repo vcs.Repo + Version string + base +} + +func existingVCSRepo(location string) (Installer, error) { + repo, err := vcs.NewRepo("", location) + if err != nil { + return nil, err + } + i := &VCSInstaller{ + Repo: repo, + base: newBase(repo.Remote()), + } + return i, nil +} + +// NewVCSInstaller creates a new VCSInstaller. +func NewVCSInstaller(source, version string) (*VCSInstaller, error) { + key, err := cache.Key(source) + if err != nil { + return nil, err + } + cachedpath := helmpath.CachePath("plugins", key) + repo, err := vcs.NewRepo(source, cachedpath) + if err != nil { + return nil, err + } + i := &VCSInstaller{ + Repo: repo, + Version: version, + base: newBase(source), + } + return i, err +} + +// Install clones a remote repository and installs into the plugin directory. +// +// Implements Installer. +func (i *VCSInstaller) Install() error { + if err := i.sync(i.Repo); err != nil { + return err + } + + ref, err := i.solveVersion(i.Repo) + if err != nil { + return err + } + if ref != "" { + if err := i.setVersion(i.Repo, ref); err != nil { + return err + } + } + + if !isPlugin(i.Repo.LocalPath()) { + return ErrMissingMetadata + } + + debug("copying %s to %s", i.Repo.LocalPath(), i.Path()) + return fs.CopyDir(i.Repo.LocalPath(), i.Path()) +} + +// Update updates a remote repository +func (i *VCSInstaller) Update() error { + debug("updating %s", i.Repo.Remote()) + if i.Repo.IsDirty() { + return errors.New("plugin repo was modified") + } + if err := i.Repo.Update(); err != nil { + return err + } + if !isPlugin(i.Repo.LocalPath()) { + return ErrMissingMetadata + } + return nil +} + +func (i *VCSInstaller) solveVersion(repo vcs.Repo) (string, error) { + if i.Version == "" { + return "", nil + } + + if repo.IsReference(i.Version) { + return i.Version, nil + } + + // Create the constraint first to make sure it's valid before + // working on the repo. + constraint, err := semver.NewConstraint(i.Version) + if err != nil { + return "", err + } + + // Get the tags + refs, err := repo.Tags() + if err != nil { + return "", err + } + debug("found refs: %s", refs) + + // Convert and filter the list to semver.Version instances + semvers := getSemVers(refs) + + // Sort semver list + sort.Sort(sort.Reverse(semver.Collection(semvers))) + for _, v := range semvers { + if constraint.Check(v) { + // If the constraint passes get the original reference + ver := v.Original() + debug("setting to %s", ver) + return ver, nil + } + } + + return "", errors.Errorf("requested version %q does not exist for plugin %q", i.Version, i.Repo.Remote()) +} + +// setVersion attempts to checkout the version +func (i *VCSInstaller) setVersion(repo vcs.Repo, ref string) error { + debug("setting version to %q", i.Version) + return repo.UpdateVersion(ref) +} + +// sync will clone or update a remote repo. +func (i *VCSInstaller) sync(repo vcs.Repo) error { + if _, err := os.Stat(repo.LocalPath()); os.IsNotExist(err) { + debug("cloning %s to %s", repo.Remote(), repo.LocalPath()) + return repo.Get() + } + debug("updating %s", repo.Remote()) + return repo.Update() +} + +// Filter a list of versions to only included semantic versions. The response +// is a mapping of the original version to the semantic version. +func getSemVers(refs []string) []*semver.Version { + var sv []*semver.Version + for _, r := range refs { + if v, err := semver.NewVersion(r); err == nil { + sv = append(sv, v) + } + } + return sv +} diff --git a/pkg/plugin/installer/vcs_installer_test.go b/pkg/plugin/installer/vcs_installer_test.go new file mode 100644 index 00000000..0bb0b678 --- /dev/null +++ b/pkg/plugin/installer/vcs_installer_test.go @@ -0,0 +1,181 @@ +/* +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 installer // import "helm.sh/helm/v3/pkg/plugin/installer" + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/Masterminds/vcs" + + "helm.sh/helm/v3/internal/test/ensure" + "helm.sh/helm/v3/pkg/helmpath" +) + +var _ Installer = new(VCSInstaller) + +type testRepo struct { + local, remote, current string + tags, branches []string + err error + vcs.Repo +} + +func (r *testRepo) LocalPath() string { return r.local } +func (r *testRepo) Remote() string { return r.remote } +func (r *testRepo) Update() error { return r.err } +func (r *testRepo) Get() error { return r.err } +func (r *testRepo) IsReference(string) bool { return false } +func (r *testRepo) Tags() ([]string, error) { return r.tags, r.err } +func (r *testRepo) Branches() ([]string, error) { return r.branches, r.err } +func (r *testRepo) UpdateVersion(version string) error { + r.current = version + return r.err +} + +func TestVCSInstaller(t *testing.T) { + ensure.HelmHome(t) + + if err := os.MkdirAll(helmpath.DataPath("plugins"), 0755); err != nil { + t.Fatalf("Could not create %s: %s", helmpath.DataPath("plugins"), err) + } + + source := "https://github.com/adamreese/helm-env" + testRepoPath, _ := filepath.Abs("../testdata/plugdir/good/echo") + repo := &testRepo{ + local: testRepoPath, + tags: []string{"0.1.0", "0.1.1"}, + } + + i, err := NewForSource(source, "~0.1.0") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + // ensure a VCSInstaller was returned + vcsInstaller, ok := i.(*VCSInstaller) + if !ok { + t.Fatal("expected a VCSInstaller") + } + + // set the testRepo in the VCSInstaller + vcsInstaller.Repo = repo + + if err := Install(i); err != nil { + t.Fatal(err) + } + if repo.current != "0.1.1" { + t.Fatalf("expected version '0.1.1', got %q", repo.current) + } + if i.Path() != helmpath.DataPath("plugins", "helm-env") { + t.Fatalf("expected path '$XDG_CONFIG_HOME/helm/plugins/helm-env', got %q", i.Path()) + } + + // Install again to test plugin exists error + if err := Install(i); err == nil { + t.Fatalf("expected error for plugin exists, got none") + } else if err.Error() != "plugin already exists" { + t.Fatalf("expected error for plugin exists, got (%v)", err) + } + + // Testing FindSource method, expect error because plugin code is not a cloned repository + if _, err := FindSource(i.Path()); err == nil { + t.Fatalf("expected error for inability to find plugin source, got none") + } else if err.Error() != "cannot get information about plugin source" { + t.Fatalf("expected error for inability to find plugin source, got (%v)", err) + } +} + +func TestVCSInstallerNonExistentVersion(t *testing.T) { + ensure.HelmHome(t) + + source := "https://github.com/adamreese/helm-env" + version := "0.2.0" + + i, err := NewForSource(source, version) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + // ensure a VCSInstaller was returned + if _, ok := i.(*VCSInstaller); !ok { + t.Fatal("expected a VCSInstaller") + } + + if err := Install(i); err == nil { + t.Fatalf("expected error for version does not exists, got none") + } else if err.Error() != fmt.Sprintf("requested version %q does not exist for plugin %q", version, source) { + t.Fatalf("expected error for version does not exists, got (%v)", err) + } +} +func TestVCSInstallerUpdate(t *testing.T) { + ensure.HelmHome(t) + + source := "https://github.com/adamreese/helm-env" + + i, err := NewForSource(source, "") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + // ensure a VCSInstaller was returned + if _, ok := i.(*VCSInstaller); !ok { + t.Fatal("expected a VCSInstaller") + } + + if err := Update(i); err == nil { + t.Fatal("expected error for plugin does not exist, got none") + } else if err.Error() != "plugin does not exist" { + t.Fatalf("expected error for plugin does not exist, got (%v)", err) + } + + // Install plugin before update + if err := Install(i); err != nil { + t.Fatal(err) + } + + // Test FindSource method for positive result + pluginInfo, err := FindSource(i.Path()) + if err != nil { + t.Fatal(err) + } + + vcsInstaller := pluginInfo.(*VCSInstaller) + + repoRemote := vcsInstaller.Repo.Remote() + if repoRemote != source { + t.Fatalf("invalid source found, expected %q got %q", source, repoRemote) + } + + // Update plugin + if err := Update(i); err != nil { + t.Fatal(err) + } + + // Test update failure + if err := os.Remove(filepath.Join(vcsInstaller.Repo.LocalPath(), "plugin.yaml")); err != nil { + t.Fatal(err) + } + // Testing update for error + if err := Update(vcsInstaller); err == nil { + t.Fatalf("expected error for plugin modified, got none") + } else if err.Error() != "plugin repo was modified" { + t.Fatalf("expected error for plugin modified, got (%v)", err) + } + +} diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go new file mode 100644 index 00000000..5bb74348 --- /dev/null +++ b/pkg/plugin/plugin.go @@ -0,0 +1,288 @@ +/* +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 plugin // import "helm.sh/helm/v3/pkg/plugin" + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "unicode" + + "github.com/pkg/errors" + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/pkg/cli" +) + +const PluginFileName = "plugin.yaml" + +// Downloaders represents the plugins capability if it can retrieve +// charts from special sources +type Downloaders struct { + // Protocols are the list of schemes from the charts URL. + Protocols []string `json:"protocols"` + // Command is the executable path with which the plugin performs + // the actual download for the corresponding Protocols + Command string `json:"command"` +} + +// PlatformCommand represents a command for a particular operating system and architecture +type PlatformCommand struct { + OperatingSystem string `json:"os"` + Architecture string `json:"arch"` + Command string `json:"command"` +} + +// Metadata describes a plugin. +// +// This is the plugin equivalent of a chart.Metadata. +type Metadata struct { + // Name is the name of the plugin + Name string `json:"name"` + + // Version is a SemVer 2 version of the plugin. + Version string `json:"version"` + + // Usage is the single-line usage text shown in help + Usage string `json:"usage"` + + // Description is a long description shown in places like `helm help` + Description string `json:"description"` + + // Command is the command, as a single string. + // + // The command will be passed through environment expansion, so env vars can + // be present in this command. Unless IgnoreFlags is set, this will + // also merge the flags passed from Helm. + // + // Note that command is not executed in a shell. To do so, we suggest + // pointing the command to a shell script. + // + // The following rules will apply to processing commands: + // - If platformCommand is present, it will be searched first + // - If both OS and Arch match the current platform, search will stop and the command will be executed + // - If OS matches and there is no more specific match, the command will be executed + // - If no OS/Arch match is found, the default command will be executed + // - If no command is present and no matches are found in platformCommand, Helm will exit with an error + PlatformCommand []PlatformCommand `json:"platformCommand"` + Command string `json:"command"` + + // IgnoreFlags ignores any flags passed in from Helm + // + // For example, if the plugin is invoked as `helm --debug myplugin`, if this + // is false, `--debug` will be appended to `--command`. If this is true, + // the `--debug` flag will be discarded. + IgnoreFlags bool `json:"ignoreFlags"` + + // Hooks are commands that will run on events. + Hooks Hooks + + // Downloaders field is used if the plugin supply downloader mechanism + // for special protocols. + Downloaders []Downloaders `json:"downloaders"` + + // UseTunnelDeprecated indicates that this command needs a tunnel. + // Setting this will cause a number of side effects, such as the + // automatic setting of HELM_HOST. + // DEPRECATED and unused, but retained for backwards compatibility with Helm 2 plugins. Remove in Helm 4 + UseTunnelDeprecated bool `json:"useTunnel,omitempty"` +} + +// Plugin represents a plugin. +type Plugin struct { + // Metadata is a parsed representation of a plugin.yaml + Metadata *Metadata + // Dir is the string path to the directory that holds the plugin. + Dir string +} + +// The following rules will apply to processing the Plugin.PlatformCommand.Command: +// - If both OS and Arch match the current platform, search will stop and the command will be prepared for execution +// - If OS matches and there is no more specific match, the command will be prepared for execution +// - If no OS/Arch match is found, return nil +func getPlatformCommand(cmds []PlatformCommand) []string { + var command []string + eq := strings.EqualFold + for _, c := range cmds { + if eq(c.OperatingSystem, runtime.GOOS) { + command = strings.Split(c.Command, " ") + } + if eq(c.OperatingSystem, runtime.GOOS) && eq(c.Architecture, runtime.GOARCH) { + return strings.Split(c.Command, " ") + } + } + return command +} + +// PrepareCommand takes a Plugin.PlatformCommand.Command, a Plugin.Command and will applying the following processing: +// - If platformCommand is present, it will be searched first +// - If both OS and Arch match the current platform, search will stop and the command will be prepared for execution +// - If OS matches and there is no more specific match, the command will be prepared for execution +// - If no OS/Arch match is found, the default command will be prepared for execution +// - If no command is present and no matches are found in platformCommand, will exit with an error +// +// It merges extraArgs into any arguments supplied in the plugin. It +// returns the name of the command and an args array. +// +// The result is suitable to pass to exec.Command. +func (p *Plugin) PrepareCommand(extraArgs []string) (string, []string, error) { + var parts []string + platCmdLen := len(p.Metadata.PlatformCommand) + if platCmdLen > 0 { + parts = getPlatformCommand(p.Metadata.PlatformCommand) + } + if platCmdLen == 0 || parts == nil { + parts = strings.Split(p.Metadata.Command, " ") + } + if len(parts) == 0 || parts[0] == "" { + return "", nil, fmt.Errorf("no plugin command is applicable") + } + + main := os.ExpandEnv(parts[0]) + baseArgs := []string{} + if len(parts) > 1 { + for _, cmdpart := range parts[1:] { + cmdexp := os.ExpandEnv(cmdpart) + baseArgs = append(baseArgs, cmdexp) + } + } + if !p.Metadata.IgnoreFlags { + baseArgs = append(baseArgs, extraArgs...) + } + return main, baseArgs, nil +} + +// validPluginName is a regular expression that validates plugin names. +// +// Plugin names can only contain the ASCII characters a-z, A-Z, 0-9, ​_​ and ​-. +var validPluginName = regexp.MustCompile("^[A-Za-z0-9_-]+$") + +// validatePluginData validates a plugin's YAML data. +func validatePluginData(plug *Plugin, filepath string) error { + // When metadata section missing, initialize with no data + if plug.Metadata == nil { + plug.Metadata = &Metadata{} + } + if !validPluginName.MatchString(plug.Metadata.Name) { + return fmt.Errorf("invalid plugin name at %q", filepath) + } + plug.Metadata.Usage = sanitizeString(plug.Metadata.Usage) + + // We could also validate SemVer, executable, and other fields should we so choose. + return nil +} + +// sanitizeString normalize spaces and removes non-printable characters. +func sanitizeString(str string) string { + return strings.Map(func(r rune) rune { + if unicode.IsSpace(r) { + return ' ' + } + if unicode.IsPrint(r) { + return r + } + return -1 + }, str) +} + +func detectDuplicates(plugs []*Plugin) error { + names := map[string]string{} + + for _, plug := range plugs { + if oldpath, ok := names[plug.Metadata.Name]; ok { + return fmt.Errorf( + "two plugins claim the name %q at %q and %q", + plug.Metadata.Name, + oldpath, + plug.Dir, + ) + } + names[plug.Metadata.Name] = plug.Dir + } + + return nil +} + +// LoadDir loads a plugin from the given directory. +func LoadDir(dirname string) (*Plugin, error) { + pluginfile := filepath.Join(dirname, PluginFileName) + data, err := os.ReadFile(pluginfile) + if err != nil { + return nil, errors.Wrapf(err, "failed to read plugin at %q", pluginfile) + } + + plug := &Plugin{Dir: dirname} + if err := yaml.UnmarshalStrict(data, &plug.Metadata); err != nil { + return nil, errors.Wrapf(err, "failed to load plugin at %q", pluginfile) + } + return plug, validatePluginData(plug, pluginfile) +} + +// LoadAll loads all plugins found beneath the base directory. +// +// This scans only one directory level. +func LoadAll(basedir string) ([]*Plugin, error) { + plugins := []*Plugin{} + // We want basedir/*/plugin.yaml + scanpath := filepath.Join(basedir, "*", PluginFileName) + matches, err := filepath.Glob(scanpath) + if err != nil { + return plugins, errors.Wrapf(err, "failed to find plugins in %q", scanpath) + } + + if matches == nil { + return plugins, nil + } + + for _, yaml := range matches { + dir := filepath.Dir(yaml) + p, err := LoadDir(dir) + if err != nil { + return plugins, err + } + plugins = append(plugins, p) + } + return plugins, detectDuplicates(plugins) +} + +// FindPlugins returns a list of YAML files that describe plugins. +func FindPlugins(plugdirs string) ([]*Plugin, error) { + found := []*Plugin{} + // Let's get all UNIXy and allow path separators + for _, p := range filepath.SplitList(plugdirs) { + matches, err := LoadAll(p) + if err != nil { + return matches, err + } + found = append(found, matches...) + } + return found, nil +} + +// SetupPluginEnv prepares os.Env for plugins. It operates on os.Env because +// the plugin subsystem itself needs access to the environment variables +// created here. +func SetupPluginEnv(settings *cli.EnvSettings, name, base string) { + env := settings.EnvVars() + env["HELM_PLUGIN_NAME"] = name + env["HELM_PLUGIN_DIR"] = base + for key, val := range env { + os.Setenv(key, val) + } +} diff --git a/pkg/plugin/plugin_test.go b/pkg/plugin/plugin_test.go new file mode 100644 index 00000000..72505234 --- /dev/null +++ b/pkg/plugin/plugin_test.go @@ -0,0 +1,407 @@ +/* +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 plugin // import "helm.sh/helm/v3/pkg/plugin" + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "runtime" + "testing" + + "helm.sh/helm/v3/pkg/cli" +) + +func checkCommand(p *Plugin, extraArgs []string, osStrCmp string, t *testing.T) { + cmd, args, err := p.PrepareCommand(extraArgs) + if err != nil { + t.Fatal(err) + } + if cmd != "echo" { + t.Fatalf("Expected echo, got %q", cmd) + } + + if l := len(args); l != 5 { + t.Fatalf("expected 5 args, got %d", l) + } + + expect := []string{"-n", osStrCmp, "--debug", "--foo", "bar"} + for i := 0; i < len(args); i++ { + if expect[i] != args[i] { + t.Errorf("Expected arg=%q, got %q", expect[i], args[i]) + } + } + + // Test with IgnoreFlags. This should omit --debug, --foo, bar + p.Metadata.IgnoreFlags = true + cmd, args, err = p.PrepareCommand(extraArgs) + if err != nil { + t.Fatal(err) + } + if cmd != "echo" { + t.Fatalf("Expected echo, got %q", cmd) + } + if l := len(args); l != 2 { + t.Fatalf("expected 2 args, got %d", l) + } + expect = []string{"-n", osStrCmp} + for i := 0; i < len(args); i++ { + if expect[i] != args[i] { + t.Errorf("Expected arg=%q, got %q", expect[i], args[i]) + } + } +} + +func TestPrepareCommand(t *testing.T) { + p := &Plugin{ + Dir: "/tmp", // Unused + Metadata: &Metadata{ + Name: "test", + Command: "echo -n foo", + }, + } + argv := []string{"--debug", "--foo", "bar"} + + checkCommand(p, argv, "foo", t) +} + +func TestPlatformPrepareCommand(t *testing.T) { + p := &Plugin{ + Dir: "/tmp", // Unused + Metadata: &Metadata{ + Name: "test", + Command: "echo -n os-arch", + PlatformCommand: []PlatformCommand{ + {OperatingSystem: "linux", Architecture: "386", Command: "echo -n linux-386"}, + {OperatingSystem: "linux", Architecture: "amd64", Command: "echo -n linux-amd64"}, + {OperatingSystem: "linux", Architecture: "arm64", Command: "echo -n linux-arm64"}, + {OperatingSystem: "linux", Architecture: "ppc64le", Command: "echo -n linux-ppc64le"}, + {OperatingSystem: "linux", Architecture: "s390x", Command: "echo -n linux-s390x"}, + {OperatingSystem: "linux", Architecture: "riscv64", Command: "echo -n linux-riscv64"}, + {OperatingSystem: "windows", Architecture: "amd64", Command: "echo -n win-64"}, + }, + }, + } + var osStrCmp string + os := runtime.GOOS + arch := runtime.GOARCH + if os == "linux" && arch == "386" { + osStrCmp = "linux-386" + } else if os == "linux" && arch == "amd64" { + osStrCmp = "linux-amd64" + } else if os == "linux" && arch == "arm64" { + osStrCmp = "linux-arm64" + } else if os == "linux" && arch == "ppc64le" { + osStrCmp = "linux-ppc64le" + } else if os == "linux" && arch == "s390x" { + osStrCmp = "linux-s390x" + } else if os == "linux" && arch == "riscv64" { + osStrCmp = "linux-riscv64" + } else if os == "windows" && arch == "amd64" { + osStrCmp = "win-64" + } else { + osStrCmp = "os-arch" + } + + argv := []string{"--debug", "--foo", "bar"} + checkCommand(p, argv, osStrCmp, t) +} + +func TestPartialPlatformPrepareCommand(t *testing.T) { + p := &Plugin{ + Dir: "/tmp", // Unused + Metadata: &Metadata{ + Name: "test", + Command: "echo -n os-arch", + PlatformCommand: []PlatformCommand{ + {OperatingSystem: "linux", Architecture: "386", Command: "echo -n linux-386"}, + {OperatingSystem: "windows", Architecture: "amd64", Command: "echo -n win-64"}, + }, + }, + } + var osStrCmp string + os := runtime.GOOS + arch := runtime.GOARCH + if os == "linux" { + osStrCmp = "linux-386" + } else if os == "windows" && arch == "amd64" { + osStrCmp = "win-64" + } else { + osStrCmp = "os-arch" + } + + argv := []string{"--debug", "--foo", "bar"} + checkCommand(p, argv, osStrCmp, t) +} + +func TestNoPrepareCommand(t *testing.T) { + p := &Plugin{ + Dir: "/tmp", // Unused + Metadata: &Metadata{ + Name: "test", + }, + } + argv := []string{"--debug", "--foo", "bar"} + + _, _, err := p.PrepareCommand(argv) + if err == nil { + t.Fatalf("Expected error to be returned") + } +} + +func TestNoMatchPrepareCommand(t *testing.T) { + p := &Plugin{ + Dir: "/tmp", // Unused + Metadata: &Metadata{ + Name: "test", + PlatformCommand: []PlatformCommand{ + {OperatingSystem: "no-os", Architecture: "amd64", Command: "echo -n linux-386"}, + }, + }, + } + argv := []string{"--debug", "--foo", "bar"} + + if _, _, err := p.PrepareCommand(argv); err == nil { + t.Fatalf("Expected error to be returned") + } +} + +func TestLoadDir(t *testing.T) { + dirname := "testdata/plugdir/good/hello" + plug, err := LoadDir(dirname) + if err != nil { + t.Fatalf("error loading Hello plugin: %s", err) + } + + if plug.Dir != dirname { + t.Fatalf("Expected dir %q, got %q", dirname, plug.Dir) + } + + expect := &Metadata{ + Name: "hello", + Version: "0.1.0", + Usage: "usage", + Description: "description", + Command: "$HELM_PLUGIN_DIR/hello.sh", + IgnoreFlags: true, + Hooks: map[string]string{ + Install: "echo installing...", + }, + } + + if !reflect.DeepEqual(expect, plug.Metadata) { + t.Fatalf("Expected plugin metadata %v, got %v", expect, plug.Metadata) + } +} + +func TestLoadDirDuplicateEntries(t *testing.T) { + dirname := "testdata/plugdir/bad/duplicate-entries" + if _, err := LoadDir(dirname); err == nil { + t.Errorf("successfully loaded plugin with duplicate entries when it should've failed") + } +} + +func TestDownloader(t *testing.T) { + dirname := "testdata/plugdir/good/downloader" + plug, err := LoadDir(dirname) + if err != nil { + t.Fatalf("error loading Hello plugin: %s", err) + } + + if plug.Dir != dirname { + t.Fatalf("Expected dir %q, got %q", dirname, plug.Dir) + } + + expect := &Metadata{ + Name: "downloader", + Version: "1.2.3", + Usage: "usage", + Description: "download something", + Command: "echo Hello", + Downloaders: []Downloaders{ + { + Protocols: []string{"myprotocol", "myprotocols"}, + Command: "echo Download", + }, + }, + } + + if !reflect.DeepEqual(expect, plug.Metadata) { + t.Fatalf("Expected metadata %v, got %v", expect, plug.Metadata) + } +} + +func TestLoadAll(t *testing.T) { + + // Verify that empty dir loads: + if plugs, err := LoadAll("testdata"); err != nil { + t.Fatalf("error loading dir with no plugins: %s", err) + } else if len(plugs) > 0 { + t.Fatalf("expected empty dir to have 0 plugins") + } + + basedir := "testdata/plugdir/good" + plugs, err := LoadAll(basedir) + if err != nil { + t.Fatalf("Could not load %q: %s", basedir, err) + } + + if l := len(plugs); l != 3 { + t.Fatalf("expected 3 plugins, found %d", l) + } + + if plugs[0].Metadata.Name != "downloader" { + t.Errorf("Expected first plugin to be echo, got %q", plugs[0].Metadata.Name) + } + if plugs[1].Metadata.Name != "echo" { + t.Errorf("Expected first plugin to be echo, got %q", plugs[0].Metadata.Name) + } + if plugs[2].Metadata.Name != "hello" { + t.Errorf("Expected second plugin to be hello, got %q", plugs[1].Metadata.Name) + } +} + +func TestFindPlugins(t *testing.T) { + cases := []struct { + name string + plugdirs string + expected int + }{ + { + name: "plugdirs is empty", + plugdirs: "", + expected: 0, + }, + { + name: "plugdirs isn't dir", + plugdirs: "./plugin_test.go", + expected: 0, + }, + { + name: "plugdirs doesn't have plugin", + plugdirs: ".", + expected: 0, + }, + { + name: "normal", + plugdirs: "./testdata/plugdir/good", + expected: 3, + }, + } + for _, c := range cases { + t.Run(t.Name(), func(t *testing.T) { + plugin, _ := FindPlugins(c.plugdirs) + if len(plugin) != c.expected { + t.Errorf("expected: %v, got: %v", c.expected, len(plugin)) + } + }) + } +} + +func TestSetupEnv(t *testing.T) { + name := "pequod" + base := filepath.Join("testdata/helmhome/helm/plugins", name) + + s := cli.New() + s.PluginsDirectory = "testdata/helmhome/helm/plugins" + + SetupPluginEnv(s, name, base) + for _, tt := range []struct { + name, expect string + }{ + {"HELM_PLUGIN_NAME", name}, + {"HELM_PLUGIN_DIR", base}, + } { + if got := os.Getenv(tt.name); got != tt.expect { + t.Errorf("Expected $%s=%q, got %q", tt.name, tt.expect, got) + } + } +} + +func TestSetupEnvWithSpace(t *testing.T) { + name := "sureshdsk" + base := filepath.Join("testdata/helm home/helm/plugins", name) + + s := cli.New() + s.PluginsDirectory = "testdata/helm home/helm/plugins" + + SetupPluginEnv(s, name, base) + for _, tt := range []struct { + name, expect string + }{ + {"HELM_PLUGIN_NAME", name}, + {"HELM_PLUGIN_DIR", base}, + } { + if got := os.Getenv(tt.name); got != tt.expect { + t.Errorf("Expected $%s=%q, got %q", tt.name, tt.expect, got) + } + } +} + +func TestValidatePluginData(t *testing.T) { + // A mock plugin missing any metadata. + mockMissingMeta := &Plugin{ + Dir: "no-such-dir", + } + + for i, item := range []struct { + pass bool + plug *Plugin + }{ + {true, mockPlugin("abcdefghijklmnopqrstuvwxyz0123456789_-ABC")}, + {true, mockPlugin("foo-bar-FOO-BAR_1234")}, + {false, mockPlugin("foo -bar")}, + {false, mockPlugin("$foo -bar")}, // Test leading chars + {false, mockPlugin("foo -bar ")}, // Test trailing chars + {false, mockPlugin("foo\nbar")}, // Test newline + {false, mockMissingMeta}, // Test if the metadata section missing + } { + err := validatePluginData(item.plug, fmt.Sprintf("test-%d", i)) + if item.pass && err != nil { + t.Errorf("failed to validate case %d: %s", i, err) + } else if !item.pass && err == nil { + t.Errorf("expected case %d to fail", i) + } + } +} + +func TestDetectDuplicates(t *testing.T) { + plugs := []*Plugin{ + mockPlugin("foo"), + mockPlugin("bar"), + } + if err := detectDuplicates(plugs); err != nil { + t.Error("no duplicates in the first set") + } + plugs = append(plugs, mockPlugin("foo")) + if err := detectDuplicates(plugs); err == nil { + t.Error("duplicates in the second set") + } +} + +func mockPlugin(name string) *Plugin { + return &Plugin{ + Metadata: &Metadata{ + Name: name, + Version: "v0.1.2", + Usage: "Mock plugin", + Description: "Mock plugin for testing", + Command: "echo mock plugin", + }, + Dir: "no-such-dir", + } +} diff --git a/pkg/plugin/testdata/plugdir/bad/duplicate-entries/plugin.yaml b/pkg/plugin/testdata/plugdir/bad/duplicate-entries/plugin.yaml new file mode 100644 index 00000000..66498be9 --- /dev/null +++ b/pkg/plugin/testdata/plugdir/bad/duplicate-entries/plugin.yaml @@ -0,0 +1,11 @@ +name: "duplicate-entries" +version: "0.1.0" +usage: "usage" +description: |- + description +command: "echo hello" +ignoreFlags: true +hooks: + install: "echo installing..." +hooks: + install: "echo installing something different" diff --git a/pkg/plugin/testdata/plugdir/good/downloader/plugin.yaml b/pkg/plugin/testdata/plugdir/good/downloader/plugin.yaml new file mode 100644 index 00000000..c0b90379 --- /dev/null +++ b/pkg/plugin/testdata/plugdir/good/downloader/plugin.yaml @@ -0,0 +1,11 @@ +name: "downloader" +version: "1.2.3" +usage: "usage" +description: |- + download something +command: "echo Hello" +downloaders: + - protocols: + - "myprotocol" + - "myprotocols" + command: "echo Download" diff --git a/pkg/plugin/testdata/plugdir/good/echo/plugin.yaml b/pkg/plugin/testdata/plugdir/good/echo/plugin.yaml new file mode 100644 index 00000000..8baa35b6 --- /dev/null +++ b/pkg/plugin/testdata/plugdir/good/echo/plugin.yaml @@ -0,0 +1,8 @@ +name: "echo" +version: "1.2.3" +usage: "echo something" +description: |- + This is a testing fixture. +command: "echo Hello" +hooks: + install: "echo Installing" diff --git a/pkg/plugin/testdata/plugdir/good/hello/hello.sh b/pkg/plugin/testdata/plugdir/good/hello/hello.sh new file mode 100755 index 00000000..dcfd5887 --- /dev/null +++ b/pkg/plugin/testdata/plugdir/good/hello/hello.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +echo "Hello from a Helm plugin" + +echo "PARAMS" +echo $* + +$HELM_BIN ls --all + diff --git a/pkg/plugin/testdata/plugdir/good/hello/plugin.yaml b/pkg/plugin/testdata/plugdir/good/hello/plugin.yaml new file mode 100644 index 00000000..b857b55e --- /dev/null +++ b/pkg/plugin/testdata/plugdir/good/hello/plugin.yaml @@ -0,0 +1,9 @@ +name: "hello" +version: "0.1.0" +usage: "usage" +description: |- + description +command: "$HELM_PLUGIN_DIR/hello.sh" +ignoreFlags: true +hooks: + install: "echo installing..." diff --git a/pkg/postrender/exec.go b/pkg/postrender/exec.go new file mode 100644 index 00000000..167e737d --- /dev/null +++ b/pkg/postrender/exec.go @@ -0,0 +1,109 @@ +/* +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 postrender + +import ( + "bytes" + "io" + "os/exec" + "path/filepath" + + "github.com/pkg/errors" +) + +type execRender struct { + binaryPath string + args []string +} + +// NewExec returns a PostRenderer implementation that calls the provided binary. +// It returns an error if the binary cannot be found. If the path does not +// contain any separators, it will search in $PATH, otherwise it will resolve +// any relative paths to a fully qualified path +func NewExec(binaryPath string, args ...string) (PostRenderer, error) { + fullPath, err := getFullPath(binaryPath) + if err != nil { + return nil, err + } + return &execRender{fullPath, args}, nil +} + +// Run the configured binary for the post render +func (p *execRender) Run(renderedManifests *bytes.Buffer) (*bytes.Buffer, error) { + cmd := exec.Command(p.binaryPath, p.args...) + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + + var postRendered = &bytes.Buffer{} + var stderr = &bytes.Buffer{} + cmd.Stdout = postRendered + cmd.Stderr = stderr + + go func() { + defer stdin.Close() + io.Copy(stdin, renderedManifests) + }() + err = cmd.Run() + if err != nil { + return nil, errors.Wrapf(err, "error while running command %s. error output:\n%s", p.binaryPath, stderr.String()) + } + + return postRendered, nil +} + +// getFullPath returns the full filepath to the binary to execute. If the path +// does not contain any separators, it will search in $PATH, otherwise it will +// resolve any relative paths to a fully qualified path +func getFullPath(binaryPath string) (string, error) { + // NOTE(thomastaylor312): I am leaving this code commented out here. During + // the implementation of post-render, it was brought up that if we are + // relying on plugins, we should actually use the plugin system so it can + // properly handle multiple OSs. This will be a feature add in the future, + // so I left this code for reference. It can be deleted or reused once the + // feature is implemented + + // Manually check the plugin dir first + // if !strings.Contains(binaryPath, string(filepath.Separator)) { + // // First check the plugin dir + // pluginDir := helmpath.DataPath("plugins") // Default location + // // If location for plugins is explicitly set, check there + // if v, ok := os.LookupEnv("HELM_PLUGINS"); ok { + // pluginDir = v + // } + // // The plugins variable can actually contain multiple paths, so loop through those + // for _, p := range filepath.SplitList(pluginDir) { + // _, err := os.Stat(filepath.Join(p, binaryPath)) + // if err != nil && !os.IsNotExist(err) { + // return "", err + // } else if err == nil { + // binaryPath = filepath.Join(p, binaryPath) + // break + // } + // } + // } + + // Now check for the binary using the given path or check if it exists in + // the path and is executable + checkedPath, err := exec.LookPath(binaryPath) + if err != nil { + return "", errors.Wrapf(err, "unable to find binary at %s", binaryPath) + } + + return filepath.Abs(checkedPath) +} diff --git a/pkg/postrender/exec_test.go b/pkg/postrender/exec_test.go new file mode 100644 index 00000000..19a6ec6c --- /dev/null +++ b/pkg/postrender/exec_test.go @@ -0,0 +1,182 @@ +/* +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 postrender + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testingScript = `#!/bin/sh +if [ $# -eq 0 ]; then +sed s/FOOTEST/BARTEST/g <&0 +else +sed s/FOOTEST/"$*"/g <&0 +fi +` + +func TestGetFullPath(t *testing.T) { + is := assert.New(t) + t.Run("full path resolves correctly", func(t *testing.T) { + testpath := setupTestingScript(t) + + fullPath, err := getFullPath(testpath) + is.NoError(err) + is.Equal(testpath, fullPath) + }) + + t.Run("relative path resolves correctly", func(t *testing.T) { + testpath := setupTestingScript(t) + + currentDir, err := os.Getwd() + require.NoError(t, err) + relative, err := filepath.Rel(currentDir, testpath) + require.NoError(t, err) + fullPath, err := getFullPath(relative) + is.NoError(err) + is.Equal(testpath, fullPath) + }) + + t.Run("binary in PATH resolves correctly", func(t *testing.T) { + testpath := setupTestingScript(t) + + realPath := os.Getenv("PATH") + os.Setenv("PATH", filepath.Dir(testpath)) + defer func() { + os.Setenv("PATH", realPath) + }() + + fullPath, err := getFullPath(filepath.Base(testpath)) + is.NoError(err) + is.Equal(testpath, fullPath) + }) + + // NOTE(thomastaylor312): See note in getFullPath for more details why this + // is here + + // t.Run("binary in plugin path resolves correctly", func(t *testing.T) { + // testpath, cleanup := setupTestingScript(t) + // defer cleanup() + + // realPath := os.Getenv("HELM_PLUGINS") + // os.Setenv("HELM_PLUGINS", filepath.Dir(testpath)) + // defer func() { + // os.Setenv("HELM_PLUGINS", realPath) + // }() + + // fullPath, err := getFullPath(filepath.Base(testpath)) + // is.NoError(err) + // is.Equal(testpath, fullPath) + // }) + + // t.Run("binary in multiple plugin paths resolves correctly", func(t *testing.T) { + // testpath, cleanup := setupTestingScript(t) + // defer cleanup() + + // realPath := os.Getenv("HELM_PLUGINS") + // os.Setenv("HELM_PLUGINS", filepath.Dir(testpath)+string(os.PathListSeparator)+"/another/dir") + // defer func() { + // os.Setenv("HELM_PLUGINS", realPath) + // }() + + // fullPath, err := getFullPath(filepath.Base(testpath)) + // is.NoError(err) + // is.Equal(testpath, fullPath) + // }) +} + +func TestExecRun(t *testing.T) { + if runtime.GOOS == "windows" { + // the actual Run test uses a basic sed example, so skip this test on windows + t.Skip("skipping on windows") + } + is := assert.New(t) + testpath := setupTestingScript(t) + + renderer, err := NewExec(testpath) + require.NoError(t, err) + + output, err := renderer.Run(bytes.NewBufferString("FOOTEST")) + is.NoError(err) + is.Contains(output.String(), "BARTEST") +} + +func TestNewExecWithOneArgsRun(t *testing.T) { + if runtime.GOOS == "windows" { + // the actual Run test uses a basic sed example, so skip this test on windows + t.Skip("skipping on windows") + } + is := assert.New(t) + testpath := setupTestingScript(t) + + renderer, err := NewExec(testpath, "ARG1") + require.NoError(t, err) + + output, err := renderer.Run(bytes.NewBufferString("FOOTEST")) + is.NoError(err) + is.Contains(output.String(), "ARG1") +} + +func TestNewExecWithTwoArgsRun(t *testing.T) { + if runtime.GOOS == "windows" { + // the actual Run test uses a basic sed example, so skip this test on windows + t.Skip("skipping on windows") + } + is := assert.New(t) + testpath := setupTestingScript(t) + + renderer, err := NewExec(testpath, "ARG1", "ARG2") + require.NoError(t, err) + + output, err := renderer.Run(bytes.NewBufferString("FOOTEST")) + is.NoError(err) + is.Contains(output.String(), "ARG1 ARG2") +} + +func setupTestingScript(t *testing.T) (filepath string) { + t.Helper() + + tempdir := t.TempDir() + + f, err := os.CreateTemp(tempdir, "post-render-test.sh") + if err != nil { + t.Fatalf("unable to create tempfile for testing: %s", err) + } + + _, err = f.WriteString(testingScript) + if err != nil { + t.Fatalf("unable to write tempfile for testing: %s", err) + } + + err = f.Chmod(0755) + if err != nil { + t.Fatalf("unable to make tempfile executable for testing: %s", err) + } + + err = f.Close() + if err != nil { + t.Fatalf("unable to close tempfile after writing: %s", err) + } + + return f.Name() +} diff --git a/pkg/postrender/postrender.go b/pkg/postrender/postrender.go new file mode 100644 index 00000000..3af38429 --- /dev/null +++ b/pkg/postrender/postrender.go @@ -0,0 +1,29 @@ +/* +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 postrender contains an interface that can be implemented for custom +// post-renderers and an exec implementation that can be used for arbitrary +// binaries and scripts +package postrender + +import "bytes" + +type PostRenderer interface { + // Run expects a single buffer filled with Helm rendered manifests. It + // expects the modified results to be returned on a separate buffer or an + // error if there was an issue or failure while running the post render step + Run(renderedManifests *bytes.Buffer) (modifiedManifests *bytes.Buffer, err error) +} diff --git a/pkg/provenance/doc.go b/pkg/provenance/doc.go new file mode 100644 index 00000000..0c7ae061 --- /dev/null +++ b/pkg/provenance/doc.go @@ -0,0 +1,38 @@ +/* +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 provenance provides tools for establishing the authenticity of a chart. + +In Helm, provenance is established via several factors. The primary factor is the +cryptographic signature of a chart. Chart authors may sign charts, which in turn +provide the necessary metadata to ensure the integrity of the chart file, the +Chart.yaml, and the referenced Docker images. + +A provenance file is clear-signed. This provides cryptographic verification that +a particular block of information (Chart.yaml, archive file, images) have not +been tampered with or altered. To learn more, read the GnuPG documentation on +clear signatures: +https://www.gnupg.org/gph/en/manual/x135.html + +The cryptography used by Helm should be compatible with OpenGPG. For example, +you should be able to verify a signature by importing the desired public key +and using `gpg --verify`, `keybase pgp verify`, or similar: + + $ gpg --verify some.sig + gpg: Signature made Mon Jul 25 17:23:44 2016 MDT using RSA key ID 1FC18762 + gpg: Good signature from "Helm Testing (This key should only be used for testing. DO NOT TRUST.) " [ultimate] +*/ +package provenance // import "helm.sh/helm/v3/pkg/provenance" diff --git a/pkg/provenance/sign.go b/pkg/provenance/sign.go new file mode 100644 index 00000000..7f89ef3f --- /dev/null +++ b/pkg/provenance/sign.go @@ -0,0 +1,427 @@ +/* +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 provenance + +import ( + "bytes" + "crypto" + "encoding/hex" + "io" + "os" + "path/filepath" + "strings" + + "github.com/pkg/errors" + "golang.org/x/crypto/openpgp" //nolint + "golang.org/x/crypto/openpgp/clearsign" //nolint + "golang.org/x/crypto/openpgp/packet" //nolint + "sigs.k8s.io/yaml" + + hapi "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" +) + +var defaultPGPConfig = packet.Config{ + DefaultHash: crypto.SHA512, +} + +// SumCollection represents a collection of file and image checksums. +// +// Files are of the form: +// +// FILENAME: "sha256:SUM" +// +// Images are of the form: +// +// "IMAGE:TAG": "sha256:SUM" +// +// Docker optionally supports sha512, and if this is the case, the hash marker +// will be 'sha512' instead of 'sha256'. +type SumCollection struct { + Files map[string]string `json:"files"` + Images map[string]string `json:"images,omitempty"` +} + +// Verification contains information about a verification operation. +type Verification struct { + // SignedBy contains the entity that signed a chart. + SignedBy *openpgp.Entity + // FileHash is the hash, prepended with the scheme, for the file that was verified. + FileHash string + // FileName is the name of the file that FileHash verifies. + FileName string +} + +// Signatory signs things. +// +// Signatories can be constructed from a PGP private key file using NewFromFiles +// or they can be constructed manually by setting the Entity to a valid +// PGP entity. +// +// The same Signatory can be used to sign or validate multiple charts. +type Signatory struct { + // The signatory for this instance of Helm. This is used for signing. + Entity *openpgp.Entity + // The keyring for this instance of Helm. This is used for verification. + KeyRing openpgp.EntityList +} + +// NewFromFiles constructs a new Signatory from the PGP key in the given filename. +// +// This will emit an error if it cannot find a valid GPG keyfile (entity) at the +// given location. +// +// Note that the keyfile may have just a public key, just a private key, or +// both. The Signatory methods may have different requirements of the keys. For +// example, ClearSign must have a valid `openpgp.Entity.PrivateKey` before it +// can sign something. +func NewFromFiles(keyfile, keyringfile string) (*Signatory, error) { + e, err := loadKey(keyfile) + if err != nil { + return nil, err + } + + ring, err := loadKeyRing(keyringfile) + if err != nil { + return nil, err + } + + return &Signatory{ + Entity: e, + KeyRing: ring, + }, nil +} + +// NewFromKeyring reads a keyring file and creates a Signatory. +// +// If id is not the empty string, this will also try to find an Entity in the +// keyring whose name matches, and set that as the signing entity. It will return +// an error if the id is not empty and also not found. +func NewFromKeyring(keyringfile, id string) (*Signatory, error) { + ring, err := loadKeyRing(keyringfile) + if err != nil { + return nil, err + } + + s := &Signatory{KeyRing: ring} + + // If the ID is empty, we can return now. + if id == "" { + return s, nil + } + + // We're gonna go all GnuPG on this and look for a string that _contains_. If + // two or more keys contain the string and none are a direct match, we error + // out. + var candidate *openpgp.Entity + vague := false + for _, e := range ring { + for n := range e.Identities { + if n == id { + s.Entity = e + return s, nil + } + if strings.Contains(n, id) { + if candidate != nil { + vague = true + } + candidate = e + } + } + } + if vague { + return s, errors.Errorf("more than one key contain the id %q", id) + } + + s.Entity = candidate + return s, nil +} + +// PassphraseFetcher returns a passphrase for decrypting keys. +// +// This is used as a callback to read a passphrase from some other location. The +// given name is the Name field on the key, typically of the form: +// +// USER_NAME (COMMENT) +type PassphraseFetcher func(name string) ([]byte, error) + +// DecryptKey decrypts a private key in the Signatory. +// +// If the key is not encrypted, this will return without error. +// +// If the key does not exist, this will return an error. +// +// If the key exists, but cannot be unlocked with the passphrase returned by +// the PassphraseFetcher, this will return an error. +// +// If the key is successfully unlocked, it will return nil. +func (s *Signatory) DecryptKey(fn PassphraseFetcher) error { + if s.Entity == nil { + return errors.New("private key not found") + } else if s.Entity.PrivateKey == nil { + return errors.New("provided key is not a private key. Try providing a keyring with secret keys") + } + + // Nothing else to do if key is not encrypted. + if !s.Entity.PrivateKey.Encrypted { + return nil + } + + fname := "Unknown" + for i := range s.Entity.Identities { + if i != "" { + fname = i + break + } + } + + p, err := fn(fname) + if err != nil { + return err + } + + return s.Entity.PrivateKey.Decrypt(p) +} + +// ClearSign signs a chart with the given key. +// +// This takes the path to a chart archive file and a key, and it returns a clear signature. +// +// The Signatory must have a valid Entity.PrivateKey for this to work. If it does +// not, an error will be returned. +func (s *Signatory) ClearSign(chartpath string) (string, error) { + if s.Entity == nil { + return "", errors.New("private key not found") + } else if s.Entity.PrivateKey == nil { + return "", errors.New("provided key is not a private key. Try providing a keyring with secret keys") + } + + if fi, err := os.Stat(chartpath); err != nil { + return "", err + } else if fi.IsDir() { + return "", errors.New("cannot sign a directory") + } + + out := bytes.NewBuffer(nil) + + b, err := messageBlock(chartpath) + if err != nil { + return "", err + } + + // Sign the buffer + w, err := clearsign.Encode(out, s.Entity.PrivateKey, &defaultPGPConfig) + if err != nil { + return "", err + } + + _, err = io.Copy(w, b) + + if err != nil { + // NB: We intentionally don't call `w.Close()` here! `w.Close()` is the method which + // actually does the PGP signing, and therefore is the part which uses the private key. + // In other words, if we call Close here, there's a risk that there's an attempt to use the + // private key to sign garbage data (since we know that io.Copy failed, `w` won't contain + // anything useful). + return "", errors.Wrap(err, "failed to write to clearsign encoder") + } + + err = w.Close() + if err != nil { + return "", errors.Wrap(err, "failed to either sign or armor message block") + } + + return out.String(), nil +} + +// Verify checks a signature and verifies that it is legit for a chart. +func (s *Signatory) Verify(chartpath, sigpath string) (*Verification, error) { + ver := &Verification{} + for _, fname := range []string{chartpath, sigpath} { + if fi, err := os.Stat(fname); err != nil { + return ver, err + } else if fi.IsDir() { + return ver, errors.Errorf("%s cannot be a directory", fname) + } + } + + // First verify the signature + sig, err := s.decodeSignature(sigpath) + if err != nil { + return ver, errors.Wrap(err, "failed to decode signature") + } + + by, err := s.verifySignature(sig) + if err != nil { + return ver, err + } + ver.SignedBy = by + + // Second, verify the hash of the tarball. + sum, err := DigestFile(chartpath) + if err != nil { + return ver, err + } + _, sums, err := parseMessageBlock(sig.Plaintext) + if err != nil { + return ver, err + } + + sum = "sha256:" + sum + basename := filepath.Base(chartpath) + if sha, ok := sums.Files[basename]; !ok { + return ver, errors.Errorf("provenance does not contain a SHA for a file named %q", basename) + } else if sha != sum { + return ver, errors.Errorf("sha256 sum does not match for %s: %q != %q", basename, sha, sum) + } + ver.FileHash = sum + ver.FileName = basename + + // TODO: when image signing is added, verify that here. + + return ver, nil +} + +func (s *Signatory) decodeSignature(filename string) (*clearsign.Block, error) { + data, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + + block, _ := clearsign.Decode(data) + if block == nil { + // There was no sig in the file. + return nil, errors.New("signature block not found") + } + + return block, nil +} + +// verifySignature verifies that the given block is validly signed, and returns the signer. +func (s *Signatory) verifySignature(block *clearsign.Block) (*openpgp.Entity, error) { + return openpgp.CheckDetachedSignature( + s.KeyRing, + bytes.NewBuffer(block.Bytes), + block.ArmoredSignature.Body, + ) +} + +func messageBlock(chartpath string) (*bytes.Buffer, error) { + var b *bytes.Buffer + // Checksum the archive + chash, err := DigestFile(chartpath) + if err != nil { + return b, err + } + + base := filepath.Base(chartpath) + sums := &SumCollection{ + Files: map[string]string{ + base: "sha256:" + chash, + }, + } + + // Load the archive into memory. + chart, err := loader.LoadFile(chartpath) + if err != nil { + return b, err + } + + // Buffer a hash + checksums YAML file + data, err := yaml.Marshal(chart.Metadata) + if err != nil { + return b, err + } + + // FIXME: YAML uses ---\n as a file start indicator, but this is not legal in a PGP + // clearsign block. So we use ...\n, which is the YAML document end marker. + // http://yaml.org/spec/1.2/spec.html#id2800168 + b = bytes.NewBuffer(data) + b.WriteString("\n...\n") + + data, err = yaml.Marshal(sums) + if err != nil { + return b, err + } + b.Write(data) + + return b, nil +} + +// parseMessageBlock +func parseMessageBlock(data []byte) (*hapi.Metadata, *SumCollection, error) { + // This sucks. + parts := bytes.Split(data, []byte("\n...\n")) + if len(parts) < 2 { + return nil, nil, errors.New("message block must have at least two parts") + } + + md := &hapi.Metadata{} + sc := &SumCollection{} + + if err := yaml.Unmarshal(parts[0], md); err != nil { + return md, sc, err + } + err := yaml.Unmarshal(parts[1], sc) + return md, sc, err +} + +// loadKey loads a GPG key found at a particular path. +func loadKey(keypath string) (*openpgp.Entity, error) { + f, err := os.Open(keypath) + if err != nil { + return nil, err + } + defer f.Close() + + pr := packet.NewReader(f) + return openpgp.ReadEntity(pr) +} + +func loadKeyRing(ringpath string) (openpgp.EntityList, error) { + f, err := os.Open(ringpath) + if err != nil { + return nil, err + } + defer f.Close() + return openpgp.ReadKeyRing(f) +} + +// DigestFile calculates a SHA256 hash (like Docker) for a given file. +// +// It takes the path to the archive file, and returns a string representation of +// the SHA256 sum. +// +// The intended use of this function is to generate a sum of a chart TGZ file. +func DigestFile(filename string) (string, error) { + f, err := os.Open(filename) + if err != nil { + return "", err + } + defer f.Close() + return Digest(f) +} + +// Digest hashes a reader and returns a SHA256 digest. +// +// Helm uses SHA256 as its default hash for all non-cryptographic applications. +func Digest(in io.Reader) (string, error) { + hash := crypto.SHA256.New() + if _, err := io.Copy(hash, in); err != nil { + return "", nil + } + return hex.EncodeToString(hash.Sum(nil)), nil +} diff --git a/pkg/provenance/sign_test.go b/pkg/provenance/sign_test.go new file mode 100644 index 00000000..17f727ea --- /dev/null +++ b/pkg/provenance/sign_test.go @@ -0,0 +1,344 @@ +/* +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 provenance + +import ( + "crypto" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + + pgperrors "golang.org/x/crypto/openpgp/errors" //nolint +) + +const ( + // testKeyFile is the secret key. + // Generating keys should be done with `gpg --gen-key`. The current key + // was generated to match Go's defaults (RSA/RSA 2048). It has no pass + // phrase. Use `gpg --export-secret-keys helm-test` to export the secret. + testKeyfile = "testdata/helm-test-key.secret" + + // testPasswordKeyFile is a keyfile with a password. + testPasswordKeyfile = "testdata/helm-password-key.secret" + + // testPubfile is the public key file. + // Use `gpg --export helm-test` to export the public key. + testPubfile = "testdata/helm-test-key.pub" + + // Generated name for the PGP key in testKeyFile. + testKeyName = `Helm Testing (This key should only be used for testing. DO NOT TRUST.) ` + + testPasswordKeyName = `password key (fake) ` + + testChartfile = "testdata/hashtest-1.2.3.tgz" + + // testSigBlock points to a signature generated by an external tool. + // This file was generated with GnuPG: + // gpg --clearsign -u helm-test --openpgp testdata/msgblock.yaml + testSigBlock = "testdata/msgblock.yaml.asc" + + // testTamperedSigBlock is a tampered copy of msgblock.yaml.asc + testTamperedSigBlock = "testdata/msgblock.yaml.tampered" + + // testSumfile points to a SHA256 sum generated by an external tool. + // We always want to validate against an external tool's representation to + // verify that we haven't done something stupid. This file was generated + // with shasum. + // shasum -a 256 hashtest-1.2.3.tgz > testdata/hashtest.sha256 + testSumfile = "testdata/hashtest.sha256" +) + +// testMessageBlock represents the expected message block for the testdata/hashtest chart. +const testMessageBlock = `apiVersion: v1 +description: Test chart versioning +name: hashtest +version: 1.2.3 + +... +files: + hashtest-1.2.3.tgz: sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888 +` + +func TestMessageBlock(t *testing.T) { + out, err := messageBlock(testChartfile) + if err != nil { + t.Fatal(err) + } + got := out.String() + + if got != testMessageBlock { + t.Errorf("Expected:\n%q\nGot\n%q\n", testMessageBlock, got) + } +} + +func TestParseMessageBlock(t *testing.T) { + md, sc, err := parseMessageBlock([]byte(testMessageBlock)) + if err != nil { + t.Fatal(err) + } + + if md.Name != "hashtest" { + t.Errorf("Expected name %q, got %q", "hashtest", md.Name) + } + + if lsc := len(sc.Files); lsc != 1 { + t.Errorf("Expected 1 file, got %d", lsc) + } + + if hash, ok := sc.Files["hashtest-1.2.3.tgz"]; !ok { + t.Errorf("hashtest file not found in Files") + } else if hash != "sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888" { + t.Errorf("Unexpected hash: %q", hash) + } +} + +func TestLoadKey(t *testing.T) { + k, err := loadKey(testKeyfile) + if err != nil { + t.Fatal(err) + } + + if _, ok := k.Identities[testKeyName]; !ok { + t.Errorf("Expected to load a key for user %q", testKeyName) + } +} + +func TestLoadKeyRing(t *testing.T) { + k, err := loadKeyRing(testPubfile) + if err != nil { + t.Fatal(err) + } + + if len(k) > 1 { + t.Errorf("Expected 1, got %d", len(k)) + } + + for _, e := range k { + if ii, ok := e.Identities[testKeyName]; !ok { + t.Errorf("Expected %s in %v", testKeyName, ii) + } + } +} + +func TestDigest(t *testing.T) { + f, err := os.Open(testChartfile) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + hash, err := Digest(f) + if err != nil { + t.Fatal(err) + } + + sig, err := readSumFile(testSumfile) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(sig, hash) { + t.Errorf("Expected %s to be in %s", hash, sig) + } +} + +func TestNewFromFiles(t *testing.T) { + s, err := NewFromFiles(testKeyfile, testPubfile) + if err != nil { + t.Fatal(err) + } + + if _, ok := s.Entity.Identities[testKeyName]; !ok { + t.Errorf("Expected to load a key for user %q", testKeyName) + } +} + +func TestDigestFile(t *testing.T) { + hash, err := DigestFile(testChartfile) + if err != nil { + t.Fatal(err) + } + + sig, err := readSumFile(testSumfile) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(sig, hash) { + t.Errorf("Expected %s to be in %s", hash, sig) + } +} + +func TestDecryptKey(t *testing.T) { + k, err := NewFromKeyring(testPasswordKeyfile, testPasswordKeyName) + if err != nil { + t.Fatal(err) + } + + if !k.Entity.PrivateKey.Encrypted { + t.Fatal("Key is not encrypted") + } + + // We give this a simple callback that returns the password. + if err := k.DecryptKey(func(s string) ([]byte, error) { + return []byte("secret"), nil + }); err != nil { + t.Fatal(err) + } + + // Re-read the key (since we already unlocked it) + k, err = NewFromKeyring(testPasswordKeyfile, testPasswordKeyName) + if err != nil { + t.Fatal(err) + } + // Now we give it a bogus password. + if err := k.DecryptKey(func(s string) ([]byte, error) { + return []byte("secrets_and_lies"), nil + }); err == nil { + t.Fatal("Expected an error when giving a bogus passphrase") + } +} + +func TestClearSign(t *testing.T) { + signer, err := NewFromFiles(testKeyfile, testPubfile) + if err != nil { + t.Fatal(err) + } + + sig, err := signer.ClearSign(testChartfile) + if err != nil { + t.Fatal(err) + } + t.Logf("Sig:\n%s", sig) + + if !strings.Contains(sig, testMessageBlock) { + t.Errorf("expected message block to be in sig: %s", sig) + } +} + +// failSigner always fails to sign and returns an error +type failSigner struct{} + +func (s failSigner) Public() crypto.PublicKey { + return nil +} + +func (s failSigner) Sign(_ io.Reader, _ []byte, _ crypto.SignerOpts) ([]byte, error) { + return nil, fmt.Errorf("always fails") +} + +func TestClearSignError(t *testing.T) { + signer, err := NewFromFiles(testKeyfile, testPubfile) + if err != nil { + t.Fatal(err) + } + + // ensure that signing always fails + signer.Entity.PrivateKey.PrivateKey = failSigner{} + + sig, err := signer.ClearSign(testChartfile) + if err == nil { + t.Fatal("didn't get an error from ClearSign but expected one") + } + + if sig != "" { + t.Fatalf("expected an empty signature after failed ClearSign but got %q", sig) + } +} + +func TestDecodeSignature(t *testing.T) { + // Unlike other tests, this does a round-trip test, ensuring that a signature + // generated by the library can also be verified by the library. + + signer, err := NewFromFiles(testKeyfile, testPubfile) + if err != nil { + t.Fatal(err) + } + + sig, err := signer.ClearSign(testChartfile) + if err != nil { + t.Fatal(err) + } + + f, err := os.CreateTemp("", "helm-test-sig-") + if err != nil { + t.Fatal(err) + } + + tname := f.Name() + defer func() { + os.Remove(tname) + }() + f.WriteString(sig) + f.Close() + + sig2, err := signer.decodeSignature(tname) + if err != nil { + t.Fatal(err) + } + + by, err := signer.verifySignature(sig2) + if err != nil { + t.Fatal(err) + } + + if _, ok := by.Identities[testKeyName]; !ok { + t.Errorf("Expected identity %q", testKeyName) + } +} + +func TestVerify(t *testing.T) { + signer, err := NewFromFiles(testKeyfile, testPubfile) + if err != nil { + t.Fatal(err) + } + + if ver, err := signer.Verify(testChartfile, testSigBlock); err != nil { + t.Errorf("Failed to pass verify. Err: %s", err) + } else if len(ver.FileHash) == 0 { + t.Error("Verification is missing hash.") + } else if ver.SignedBy == nil { + t.Error("No SignedBy field") + } else if ver.FileName != filepath.Base(testChartfile) { + t.Errorf("FileName is unexpectedly %q", ver.FileName) + } + + if _, err = signer.Verify(testChartfile, testTamperedSigBlock); err == nil { + t.Errorf("Expected %s to fail.", testTamperedSigBlock) + } + + switch err.(type) { + case pgperrors.SignatureError: + t.Logf("Tampered sig block error: %s (%T)", err, err) + default: + t.Errorf("Expected invalid signature error, got %q (%T)", err, err) + } +} + +// readSumFile reads a file containing a sum generated by the UNIX shasum tool. +func readSumFile(sumfile string) (string, error) { + data, err := os.ReadFile(sumfile) + if err != nil { + return "", err + } + + sig := string(data) + parts := strings.SplitN(sig, " ", 2) + return parts[0], nil +} diff --git a/pkg/provenance/testdata/hashtest-1.2.3.tgz b/pkg/provenance/testdata/hashtest-1.2.3.tgz new file mode 100644 index 0000000000000000000000000000000000000000..7bbc533cae13f1b34f023d7d19f0f30214be2768 GIT binary patch literal 399 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 F^>pMLK0g2e literal 0 HcmV?d00001 diff --git a/pkg/provenance/testdata/helm-test-key.secret b/pkg/provenance/testdata/helm-test-key.secret new file mode 100644 index 0000000000000000000000000000000000000000..a966aef93ed97d01d764f29940738df6df2d9d24 GIT binary patch literal 2545 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 hashtest.sha256 diff --git a/pkg/pusher/doc.go b/pkg/pusher/doc.go new file mode 100644 index 00000000..df89ab11 --- /dev/null +++ b/pkg/pusher/doc.go @@ -0,0 +1,21 @@ +/* +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 pusher provides a generalized tool for uploading data by scheme. +This provides a method by which the plugin system can load arbitrary protocol +handlers based upon a URL scheme. +*/ +package pusher diff --git a/pkg/pusher/ocipusher.go b/pkg/pusher/ocipusher.go new file mode 100644 index 00000000..94154d38 --- /dev/null +++ b/pkg/pusher/ocipusher.go @@ -0,0 +1,152 @@ +/* +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 pusher + +import ( + "fmt" + "net" + "net/http" + "os" + "path" + "strings" + "time" + + "github.com/pkg/errors" + + "helm.sh/helm/v3/internal/tlsutil" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/registry" +) + +// OCIPusher is the default OCI backend handler +type OCIPusher struct { + opts options +} + +// Push performs a Push from repo.Pusher. +func (pusher *OCIPusher) Push(chartRef, href string, options ...Option) error { + for _, opt := range options { + opt(&pusher.opts) + } + return pusher.push(chartRef, href) +} + +func (pusher *OCIPusher) push(chartRef, href string) error { + stat, err := os.Stat(chartRef) + if err != nil { + if os.IsNotExist(err) { + return errors.Errorf("%s: no such file", chartRef) + } + return err + } + if stat.IsDir() { + return errors.New("cannot push directory, must provide chart archive (.tgz)") + } + + meta, err := loader.Load(chartRef) + if err != nil { + return err + } + + client := pusher.opts.registryClient + if client == nil { + c, err := pusher.newRegistryClient() + if err != nil { + return err + } + client = c + } + + chartBytes, err := os.ReadFile(chartRef) + if err != nil { + return err + } + + var pushOpts []registry.PushOption + provRef := fmt.Sprintf("%s.prov", chartRef) + if _, err := os.Stat(provRef); err == nil { + provBytes, err := os.ReadFile(provRef) + if err != nil { + return err + } + pushOpts = append(pushOpts, registry.PushOptProvData(provBytes)) + } + + ref := fmt.Sprintf("%s:%s", + path.Join(strings.TrimPrefix(href, fmt.Sprintf("%s://", registry.OCIScheme)), meta.Metadata.Name), + meta.Metadata.Version) + + _, err = client.Push(chartBytes, ref, pushOpts...) + return err +} + +// NewOCIPusher constructs a valid OCI client as a Pusher +func NewOCIPusher(ops ...Option) (Pusher, error) { + var client OCIPusher + + for _, opt := range ops { + opt(&client.opts) + } + + return &client, nil +} + +func (pusher *OCIPusher) newRegistryClient() (*registry.Client, error) { + if (pusher.opts.certFile != "" && pusher.opts.keyFile != "") || pusher.opts.caFile != "" || pusher.opts.insecureSkipTLSverify { + tlsConf, err := tlsutil.NewClientTLS(pusher.opts.certFile, pusher.opts.keyFile, pusher.opts.caFile, pusher.opts.insecureSkipTLSverify) + if err != nil { + return nil, errors.Wrap(err, "can't create TLS config for client") + } + + registryClient, err := registry.NewClient( + registry.ClientOptHTTPClient(&http.Client{ + // From https://github.com/google/go-containerregistry/blob/31786c6cbb82d6ec4fb8eb79cd9387905130534e/pkg/v1/remote/options.go#L87 + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + // By default we wrap the transport in retries, so reduce the + // default dial timeout to 5s to avoid 5x 30s of connection + // timeouts when doing the "ping" on certain http registries. + Timeout: 5 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + TLSClientConfig: tlsConf, + }, + }), + registry.ClientOptEnableCache(true), + ) + if err != nil { + return nil, err + } + return registryClient, nil + } + + opts := []registry.ClientOption{registry.ClientOptEnableCache(true)} + if pusher.opts.plainHTTP { + opts = append(opts, registry.ClientOptPlainHTTP()) + } + + registryClient, err := registry.NewClient(opts...) + if err != nil { + return nil, err + } + return registryClient, nil +} diff --git a/pkg/pusher/ocipusher_test.go b/pkg/pusher/ocipusher_test.go new file mode 100644 index 00000000..11842b4a --- /dev/null +++ b/pkg/pusher/ocipusher_test.go @@ -0,0 +1,96 @@ +/* +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 pusher + +import ( + "path/filepath" + "testing" + + "helm.sh/helm/v3/pkg/registry" +) + +func TestNewOCIPusher(t *testing.T) { + p, err := NewOCIPusher() + if err != nil { + t.Fatal(err) + } + + if _, ok := p.(*OCIPusher); !ok { + t.Fatal("Expected NewOCIPusher to produce an *OCIPusher") + } + + cd := "../../testdata" + join := filepath.Join + ca, pub, priv := join(cd, "rootca.crt"), join(cd, "crt.pem"), join(cd, "key.pem") + insecureSkipTLSverify := false + plainHTTP := false + + // Test with options + p, err = NewOCIPusher( + WithTLSClientConfig(pub, priv, ca), + WithInsecureSkipTLSVerify(insecureSkipTLSverify), + WithPlainHTTP(plainHTTP), + ) + if err != nil { + t.Fatal(err) + } + + op, ok := p.(*OCIPusher) + if !ok { + t.Fatal("Expected NewOCIPusher to produce an *OCIPusher") + } + + if op.opts.certFile != pub { + t.Errorf("Expected NewOCIPusher to contain %q as the public key file, got %q", pub, op.opts.certFile) + } + + if op.opts.keyFile != priv { + t.Errorf("Expected NewOCIPusher to contain %q as the private key file, got %q", priv, op.opts.keyFile) + } + + if op.opts.caFile != ca { + t.Errorf("Expected NewOCIPusher to contain %q as the CA file, got %q", ca, op.opts.caFile) + } + + if op.opts.plainHTTP != plainHTTP { + t.Errorf("Expected NewOCIPusher to have plainHTTP as %t, got %t", plainHTTP, op.opts.plainHTTP) + } + + if op.opts.insecureSkipTLSverify != insecureSkipTLSverify { + t.Errorf("Expected NewOCIPusher to have insecureSkipVerifyTLS as %t, got %t", insecureSkipTLSverify, op.opts.insecureSkipTLSverify) + } + + // Test if setting registryClient is being passed to the ops + registryClient, err := registry.NewClient() + if err != nil { + t.Fatal(err) + } + + p, err = NewOCIPusher( + WithRegistryClient(registryClient), + ) + if err != nil { + t.Fatal(err) + } + op, ok = p.(*OCIPusher) + if !ok { + t.Fatal("expected NewOCIPusher to produce an *OCIPusher") + } + + if op.opts.registryClient != registryClient { + t.Errorf("Expected NewOCIPusher to contain %p as RegistryClient, got %p", registryClient, op.opts.registryClient) + } +} diff --git a/pkg/pusher/pusher.go b/pkg/pusher/pusher.go new file mode 100644 index 00000000..5b8a9160 --- /dev/null +++ b/pkg/pusher/pusher.go @@ -0,0 +1,122 @@ +/* +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 pusher + +import ( + "github.com/pkg/errors" + + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/registry" +) + +// options are generic parameters to be provided to the pusher during instantiation. +// +// Pushers may or may not ignore these parameters as they are passed in. +type options struct { + registryClient *registry.Client + certFile string + keyFile string + caFile string + insecureSkipTLSverify bool + plainHTTP bool +} + +// Option allows specifying various settings configurable by the user for overriding the defaults +// used when performing Push operations with the Pusher. +type Option func(*options) + +// WithRegistryClient sets the registryClient option. +func WithRegistryClient(client *registry.Client) Option { + return func(opts *options) { + opts.registryClient = client + } +} + +// WithTLSClientConfig sets the client auth with the provided credentials. +func WithTLSClientConfig(certFile, keyFile, caFile string) Option { + return func(opts *options) { + opts.certFile = certFile + opts.keyFile = keyFile + opts.caFile = caFile + } +} + +// WithInsecureSkipTLSVerify determines if a TLS Certificate will be checked +func WithInsecureSkipTLSVerify(insecureSkipTLSVerify bool) Option { + return func(opts *options) { + opts.insecureSkipTLSverify = insecureSkipTLSVerify + } +} + +func WithPlainHTTP(plainHTTP bool) Option { + return func(opts *options) { + opts.plainHTTP = plainHTTP + } +} + +// Pusher is an interface to support upload to the specified URL. +type Pusher interface { + // Push file content by url string + Push(chartRef, url string, options ...Option) error +} + +// Constructor is the function for every pusher which creates a specific instance +// according to the configuration +type Constructor func(options ...Option) (Pusher, error) + +// Provider represents any pusher and the schemes that it supports. +type Provider struct { + Schemes []string + New Constructor +} + +// Provides returns true if the given scheme is supported by this Provider. +func (p Provider) Provides(scheme string) bool { + for _, i := range p.Schemes { + if i == scheme { + return true + } + } + return false +} + +// Providers is a collection of Provider objects. +type Providers []Provider + +// ByScheme returns a Provider that handles the given scheme. +// +// If no provider handles this scheme, this will return an error. +func (p Providers) ByScheme(scheme string) (Pusher, error) { + for _, pp := range p { + if pp.Provides(scheme) { + return pp.New() + } + } + return nil, errors.Errorf("scheme %q not supported", scheme) +} + +var ociProvider = Provider{ + Schemes: []string{registry.OCIScheme}, + New: NewOCIPusher, +} + +// All finds all of the registered pushers as a list of Provider instances. +// Currently, just the built-in pushers are collected. +func All(_ *cli.EnvSettings) Providers { + result := Providers{ociProvider} + return result +} diff --git a/pkg/pusher/pusher_test.go b/pkg/pusher/pusher_test.go new file mode 100644 index 00000000..d43e6c9e --- /dev/null +++ b/pkg/pusher/pusher_test.go @@ -0,0 +1,68 @@ +/* +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 pusher + +import ( + "testing" + + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/registry" +) + +func TestProvider(t *testing.T) { + p := Provider{ + []string{"one", "three"}, + func(_ ...Option) (Pusher, error) { return nil, nil }, + } + + if !p.Provides("three") { + t.Error("Expected provider to provide three") + } +} + +func TestProviders(t *testing.T) { + ps := Providers{ + {[]string{"one", "three"}, func(_ ...Option) (Pusher, error) { return nil, nil }}, + {[]string{"two", "four"}, func(_ ...Option) (Pusher, error) { return nil, nil }}, + } + + if _, err := ps.ByScheme("one"); err != nil { + t.Error(err) + } + if _, err := ps.ByScheme("four"); err != nil { + t.Error(err) + } + + if _, err := ps.ByScheme("five"); err == nil { + t.Error("Did not expect handler for five") + } +} + +func TestAll(t *testing.T) { + env := cli.New() + all := All(env) + if len(all) != 1 { + t.Errorf("expected 1 provider (OCI), got %d", len(all)) + } +} + +func TestByScheme(t *testing.T) { + env := cli.New() + g := All(env) + if _, err := g.ByScheme(registry.OCIScheme); err != nil { + t.Error(err) + } +} diff --git a/pkg/registry/client.go b/pkg/registry/client.go new file mode 100644 index 00000000..7538cf69 --- /dev/null +++ b/pkg/registry/client.go @@ -0,0 +1,703 @@ +/* +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 registry // import "helm.sh/helm/v3/pkg/registry" + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/containerd/containerd/remotes" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" + "oras.land/oras-go/pkg/auth" + dockerauth "oras.land/oras-go/pkg/auth/docker" + "oras.land/oras-go/pkg/content" + "oras.land/oras-go/pkg/oras" + "oras.land/oras-go/pkg/registry" + registryremote "oras.land/oras-go/pkg/registry/remote" + registryauth "oras.land/oras-go/pkg/registry/remote/auth" + + "helm.sh/helm/v3/internal/version" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/helmpath" +) + +// See https://github.com/helm/helm/issues/10166 +const registryUnderscoreMessage = ` +OCI artifact references (e.g. tags) do not support the plus sign (+). To support +storing semantic versions, Helm adopts the convention of changing plus (+) to +an underscore (_) in chart version tags when pushing to a registry and back to +a plus (+) when pulling from a registry.` + +type ( + // Client works with OCI-compliant registries + Client struct { + debug bool + enableCache bool + // path to repository config file e.g. ~/.docker/config.json + credentialsFile string + out io.Writer + authorizer auth.Client + registryAuthorizer *registryauth.Client + resolver func(ref registry.Reference) (remotes.Resolver, error) + httpClient *http.Client + plainHTTP bool + } + + // ClientOption allows specifying various settings configurable by the user for overriding the defaults + // used when creating a new default client + ClientOption func(*Client) +) + +// NewClient returns a new registry client with config +func NewClient(options ...ClientOption) (*Client, error) { + client := &Client{ + out: io.Discard, + } + for _, option := range options { + option(client) + } + if client.credentialsFile == "" { + client.credentialsFile = helmpath.ConfigPath(CredentialsFileBasename) + } + if client.authorizer == nil { + authClient, err := dockerauth.NewClientWithDockerFallback(client.credentialsFile) + if err != nil { + return nil, err + } + client.authorizer = authClient + } + + resolverFn := client.resolver // copy for avoiding recursive call + client.resolver = func(ref registry.Reference) (remotes.Resolver, error) { + if resolverFn != nil { + // validate if the resolverFn returns a valid resolver + if resolver, err := resolverFn(ref); resolver != nil && err == nil { + return resolver, nil + } + } + headers := http.Header{} + headers.Set("User-Agent", version.GetUserAgent()) + opts := []auth.ResolverOption{auth.WithResolverHeaders(headers)} + if client.httpClient != nil { + opts = append(opts, auth.WithResolverClient(client.httpClient)) + } + if client.plainHTTP { + opts = append(opts, auth.WithResolverPlainHTTP()) + } + resolver, err := client.authorizer.ResolverWithOpts(opts...) + if err != nil { + return nil, err + } + return resolver, nil + } + + // allocate a cache if option is set + var cache registryauth.Cache + if client.enableCache { + cache = registryauth.DefaultCache + } + if client.registryAuthorizer == nil { + client.registryAuthorizer = ®istryauth.Client{ + Client: client.httpClient, + Header: http.Header{ + "User-Agent": {version.GetUserAgent()}, + }, + Cache: cache, + Credential: func(ctx context.Context, reg string) (registryauth.Credential, error) { + dockerClient, ok := client.authorizer.(*dockerauth.Client) + if !ok { + return registryauth.EmptyCredential, errors.New("unable to obtain docker client") + } + + username, password, err := dockerClient.Credential(reg) + if err != nil { + return registryauth.EmptyCredential, errors.New("unable to retrieve credentials") + } + + // A blank returned username and password value is a bearer token + if username == "" && password != "" { + return registryauth.Credential{ + RefreshToken: password, + }, nil + } + + return registryauth.Credential{ + Username: username, + Password: password, + }, nil + + }, + } + + } + return client, nil +} + +// ClientOptDebug returns a function that sets the debug setting on client options set +func ClientOptDebug(debug bool) ClientOption { + return func(client *Client) { + client.debug = debug + } +} + +// ClientOptEnableCache returns a function that sets the enableCache setting on a client options set +func ClientOptEnableCache(enableCache bool) ClientOption { + return func(client *Client) { + client.enableCache = enableCache + } +} + +// ClientOptWriter returns a function that sets the writer setting on client options set +func ClientOptWriter(out io.Writer) ClientOption { + return func(client *Client) { + client.out = out + } +} + +// ClientOptCredentialsFile returns a function that sets the credentialsFile setting on a client options set +func ClientOptCredentialsFile(credentialsFile string) ClientOption { + return func(client *Client) { + client.credentialsFile = credentialsFile + } +} + +// ClientOptHTTPClient returns a function that sets the httpClient setting on a client options set +func ClientOptHTTPClient(httpClient *http.Client) ClientOption { + return func(client *Client) { + client.httpClient = httpClient + } +} + +func ClientOptPlainHTTP() ClientOption { + return func(c *Client) { + c.plainHTTP = true + } +} + +// ClientOptResolver returns a function that sets the resolver setting on a client options set +func ClientOptResolver(resolver remotes.Resolver) ClientOption { + return func(client *Client) { + client.resolver = func(ref registry.Reference) (remotes.Resolver, error) { + return resolver, nil + } + } +} + +type ( + // LoginOption allows specifying various settings on login + LoginOption func(*loginOperation) + + loginOperation struct { + username string + password string + insecure bool + certFile string + keyFile string + caFile string + } +) + +// Login logs into a registry +func (c *Client) Login(host string, options ...LoginOption) error { + operation := &loginOperation{} + for _, option := range options { + option(operation) + } + authorizerLoginOpts := []auth.LoginOption{ + auth.WithLoginContext(ctx(c.out, c.debug)), + auth.WithLoginHostname(host), + auth.WithLoginUsername(operation.username), + auth.WithLoginSecret(operation.password), + auth.WithLoginUserAgent(version.GetUserAgent()), + auth.WithLoginTLS(operation.certFile, operation.keyFile, operation.caFile), + } + if operation.insecure { + authorizerLoginOpts = append(authorizerLoginOpts, auth.WithLoginInsecure()) + } + if err := c.authorizer.LoginWithOpts(authorizerLoginOpts...); err != nil { + return err + } + fmt.Fprintln(c.out, "Login Succeeded") + return nil +} + +// LoginOptBasicAuth returns a function that sets the username/password settings on login +func LoginOptBasicAuth(username string, password string) LoginOption { + return func(operation *loginOperation) { + operation.username = username + operation.password = password + } +} + +// LoginOptInsecure returns a function that sets the insecure setting on login +func LoginOptInsecure(insecure bool) LoginOption { + return func(operation *loginOperation) { + operation.insecure = insecure + } +} + +// LoginOptTLSClientConfig returns a function that sets the TLS settings on login. +func LoginOptTLSClientConfig(certFile, keyFile, caFile string) LoginOption { + return func(operation *loginOperation) { + operation.certFile = certFile + operation.keyFile = keyFile + operation.caFile = caFile + } +} + +type ( + // LogoutOption allows specifying various settings on logout + LogoutOption func(*logoutOperation) + + logoutOperation struct{} +) + +// Logout logs out of a registry +func (c *Client) Logout(host string, opts ...LogoutOption) error { + operation := &logoutOperation{} + for _, opt := range opts { + opt(operation) + } + if err := c.authorizer.Logout(ctx(c.out, c.debug), host); err != nil { + return err + } + fmt.Fprintf(c.out, "Removing login credentials for %s\n", host) + return nil +} + +type ( + // PullOption allows specifying various settings on pull + PullOption func(*pullOperation) + + // PullResult is the result returned upon successful pull. + PullResult struct { + Manifest *DescriptorPullSummary `json:"manifest"` + Config *DescriptorPullSummary `json:"config"` + Chart *DescriptorPullSummaryWithMeta `json:"chart"` + Prov *DescriptorPullSummary `json:"prov"` + Ref string `json:"ref"` + } + + DescriptorPullSummary struct { + Data []byte `json:"-"` + Digest string `json:"digest"` + Size int64 `json:"size"` + } + + DescriptorPullSummaryWithMeta struct { + DescriptorPullSummary + Meta *chart.Metadata `json:"meta"` + } + + pullOperation struct { + withChart bool + withProv bool + ignoreMissingProv bool + } +) + +// Pull downloads a chart from a registry +func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) { + parsedRef, err := parseReference(ref) + if err != nil { + return nil, err + } + + operation := &pullOperation{ + withChart: true, // By default, always download the chart layer + } + for _, option := range options { + option(operation) + } + if !operation.withChart && !operation.withProv { + return nil, errors.New( + "must specify at least one layer to pull (chart/prov)") + } + memoryStore := content.NewMemory() + allowedMediaTypes := []string{ + ConfigMediaType, + } + minNumDescriptors := 1 // 1 for the config + if operation.withChart { + minNumDescriptors++ + allowedMediaTypes = append(allowedMediaTypes, ChartLayerMediaType, LegacyChartLayerMediaType) + } + if operation.withProv { + if !operation.ignoreMissingProv { + minNumDescriptors++ + } + allowedMediaTypes = append(allowedMediaTypes, ProvLayerMediaType) + } + + var descriptors, layers []ocispec.Descriptor + remotesResolver, err := c.resolver(parsedRef) + if err != nil { + return nil, err + } + registryStore := content.Registry{Resolver: remotesResolver} + + manifest, err := oras.Copy(ctx(c.out, c.debug), registryStore, parsedRef.String(), memoryStore, "", + oras.WithPullEmptyNameAllowed(), + oras.WithAllowedMediaTypes(allowedMediaTypes), + oras.WithLayerDescriptors(func(l []ocispec.Descriptor) { + layers = l + })) + if err != nil { + return nil, err + } + + descriptors = append(descriptors, manifest) + descriptors = append(descriptors, layers...) + + numDescriptors := len(descriptors) + if numDescriptors < minNumDescriptors { + return nil, fmt.Errorf("manifest does not contain minimum number of descriptors (%d), descriptors found: %d", + minNumDescriptors, numDescriptors) + } + var configDescriptor *ocispec.Descriptor + var chartDescriptor *ocispec.Descriptor + var provDescriptor *ocispec.Descriptor + for _, descriptor := range descriptors { + d := descriptor + switch d.MediaType { + case ConfigMediaType: + configDescriptor = &d + case ChartLayerMediaType: + chartDescriptor = &d + case ProvLayerMediaType: + provDescriptor = &d + case LegacyChartLayerMediaType: + chartDescriptor = &d + fmt.Fprintf(c.out, "Warning: chart media type %s is deprecated\n", LegacyChartLayerMediaType) + } + } + if configDescriptor == nil { + return nil, fmt.Errorf("could not load config with mediatype %s", ConfigMediaType) + } + if operation.withChart && chartDescriptor == nil { + return nil, fmt.Errorf("manifest does not contain a layer with mediatype %s", + ChartLayerMediaType) + } + var provMissing bool + if operation.withProv && provDescriptor == nil { + if operation.ignoreMissingProv { + provMissing = true + } else { + return nil, fmt.Errorf("manifest does not contain a layer with mediatype %s", + ProvLayerMediaType) + } + } + result := &PullResult{ + Manifest: &DescriptorPullSummary{ + Digest: manifest.Digest.String(), + Size: manifest.Size, + }, + Config: &DescriptorPullSummary{ + Digest: configDescriptor.Digest.String(), + Size: configDescriptor.Size, + }, + Chart: &DescriptorPullSummaryWithMeta{}, + Prov: &DescriptorPullSummary{}, + Ref: parsedRef.String(), + } + var getManifestErr error + if _, manifestData, ok := memoryStore.Get(manifest); !ok { + getManifestErr = errors.Errorf("Unable to retrieve blob with digest %s", manifest.Digest) + } else { + result.Manifest.Data = manifestData + } + if getManifestErr != nil { + return nil, getManifestErr + } + var getConfigDescriptorErr error + if _, configData, ok := memoryStore.Get(*configDescriptor); !ok { + getConfigDescriptorErr = errors.Errorf("Unable to retrieve blob with digest %s", configDescriptor.Digest) + } else { + result.Config.Data = configData + var meta *chart.Metadata + if err := json.Unmarshal(configData, &meta); err != nil { + return nil, err + } + result.Chart.Meta = meta + } + if getConfigDescriptorErr != nil { + return nil, getConfigDescriptorErr + } + if operation.withChart { + var getChartDescriptorErr error + if _, chartData, ok := memoryStore.Get(*chartDescriptor); !ok { + getChartDescriptorErr = errors.Errorf("Unable to retrieve blob with digest %s", chartDescriptor.Digest) + } else { + result.Chart.Data = chartData + result.Chart.Digest = chartDescriptor.Digest.String() + result.Chart.Size = chartDescriptor.Size + } + if getChartDescriptorErr != nil { + return nil, getChartDescriptorErr + } + } + if operation.withProv && !provMissing { + var getProvDescriptorErr error + if _, provData, ok := memoryStore.Get(*provDescriptor); !ok { + getProvDescriptorErr = errors.Errorf("Unable to retrieve blob with digest %s", provDescriptor.Digest) + } else { + result.Prov.Data = provData + result.Prov.Digest = provDescriptor.Digest.String() + result.Prov.Size = provDescriptor.Size + } + if getProvDescriptorErr != nil { + return nil, getProvDescriptorErr + } + } + + fmt.Fprintf(c.out, "Pulled: %s\n", result.Ref) + fmt.Fprintf(c.out, "Digest: %s\n", result.Manifest.Digest) + + if strings.Contains(result.Ref, "_") { + fmt.Fprintf(c.out, "%s contains an underscore.\n", result.Ref) + fmt.Fprint(c.out, registryUnderscoreMessage+"\n") + } + + return result, nil +} + +// PullOptWithChart returns a function that sets the withChart setting on pull +func PullOptWithChart(withChart bool) PullOption { + return func(operation *pullOperation) { + operation.withChart = withChart + } +} + +// PullOptWithProv returns a function that sets the withProv setting on pull +func PullOptWithProv(withProv bool) PullOption { + return func(operation *pullOperation) { + operation.withProv = withProv + } +} + +// PullOptIgnoreMissingProv returns a function that sets the ignoreMissingProv setting on pull +func PullOptIgnoreMissingProv(ignoreMissingProv bool) PullOption { + return func(operation *pullOperation) { + operation.ignoreMissingProv = ignoreMissingProv + } +} + +type ( + // PushOption allows specifying various settings on push + PushOption func(*pushOperation) + + // PushResult is the result returned upon successful push. + PushResult struct { + Manifest *descriptorPushSummary `json:"manifest"` + Config *descriptorPushSummary `json:"config"` + Chart *descriptorPushSummaryWithMeta `json:"chart"` + Prov *descriptorPushSummary `json:"prov"` + Ref string `json:"ref"` + } + + descriptorPushSummary struct { + Digest string `json:"digest"` + Size int64 `json:"size"` + } + + descriptorPushSummaryWithMeta struct { + descriptorPushSummary + Meta *chart.Metadata `json:"meta"` + } + + pushOperation struct { + provData []byte + strictMode bool + test bool + } +) + +// Push uploads a chart to a registry. +func (c *Client) Push(data []byte, ref string, options ...PushOption) (*PushResult, error) { + parsedRef, err := parseReference(ref) + if err != nil { + return nil, err + } + + operation := &pushOperation{ + strictMode: true, // By default, enable strict mode + } + for _, option := range options { + option(operation) + } + meta, err := extractChartMeta(data) + if err != nil { + return nil, err + } + if operation.strictMode { + if !strings.HasSuffix(ref, fmt.Sprintf("/%s:%s", meta.Name, meta.Version)) { + return nil, errors.New( + "strict mode enabled, ref basename and tag must match the chart name and version") + } + } + memoryStore := content.NewMemory() + chartDescriptor, err := memoryStore.Add("", ChartLayerMediaType, data) + if err != nil { + return nil, err + } + + configData, err := json.Marshal(meta) + if err != nil { + return nil, err + } + + configDescriptor, err := memoryStore.Add("", ConfigMediaType, configData) + if err != nil { + return nil, err + } + + descriptors := []ocispec.Descriptor{chartDescriptor} + var provDescriptor ocispec.Descriptor + if operation.provData != nil { + provDescriptor, err = memoryStore.Add("", ProvLayerMediaType, operation.provData) + if err != nil { + return nil, err + } + + descriptors = append(descriptors, provDescriptor) + } + + ociAnnotations := generateOCIAnnotations(meta, operation.test) + + manifestData, manifest, err := content.GenerateManifest(&configDescriptor, ociAnnotations, descriptors...) + if err != nil { + return nil, err + } + + if err := memoryStore.StoreManifest(parsedRef.String(), manifest, manifestData); err != nil { + return nil, err + } + + remotesResolver, err := c.resolver(parsedRef) + if err != nil { + return nil, err + } + registryStore := content.Registry{Resolver: remotesResolver} + _, err = oras.Copy(ctx(c.out, c.debug), memoryStore, parsedRef.String(), registryStore, "", + oras.WithNameValidation(nil)) + if err != nil { + return nil, err + } + chartSummary := &descriptorPushSummaryWithMeta{ + Meta: meta, + } + chartSummary.Digest = chartDescriptor.Digest.String() + chartSummary.Size = chartDescriptor.Size + result := &PushResult{ + Manifest: &descriptorPushSummary{ + Digest: manifest.Digest.String(), + Size: manifest.Size, + }, + Config: &descriptorPushSummary{ + Digest: configDescriptor.Digest.String(), + Size: configDescriptor.Size, + }, + Chart: chartSummary, + Prov: &descriptorPushSummary{}, // prevent nil references + Ref: parsedRef.String(), + } + if operation.provData != nil { + result.Prov = &descriptorPushSummary{ + Digest: provDescriptor.Digest.String(), + Size: provDescriptor.Size, + } + } + fmt.Fprintf(c.out, "Pushed: %s\n", result.Ref) + fmt.Fprintf(c.out, "Digest: %s\n", result.Manifest.Digest) + if strings.Contains(parsedRef.Reference, "_") { + fmt.Fprintf(c.out, "%s contains an underscore.\n", result.Ref) + fmt.Fprint(c.out, registryUnderscoreMessage+"\n") + } + + return result, err +} + +// PushOptProvData returns a function that sets the prov bytes setting on push +func PushOptProvData(provData []byte) PushOption { + return func(operation *pushOperation) { + operation.provData = provData + } +} + +// PushOptStrictMode returns a function that sets the strictMode setting on push +func PushOptStrictMode(strictMode bool) PushOption { + return func(operation *pushOperation) { + operation.strictMode = strictMode + } +} + +// PushOptTest returns a function that sets whether test setting on push +func PushOptTest(test bool) PushOption { + return func(operation *pushOperation) { + operation.test = test + } +} + +// Tags provides a sorted list all semver compliant tags for a given repository +func (c *Client) Tags(ref string) ([]string, error) { + parsedReference, err := registry.ParseReference(ref) + if err != nil { + return nil, err + } + + repository := registryremote.Repository{ + Reference: parsedReference, + Client: c.registryAuthorizer, + PlainHTTP: c.plainHTTP, + } + + var registryTags []string + + registryTags, err = registry.Tags(ctx(c.out, c.debug), &repository) + if err != nil { + return nil, err + } + + var tagVersions []*semver.Version + for _, tag := range registryTags { + // Change underscore (_) back to plus (+) for Helm + // See https://github.com/helm/helm/issues/10166 + tagVersion, err := semver.StrictNewVersion(strings.ReplaceAll(tag, "_", "+")) + if err == nil { + tagVersions = append(tagVersions, tagVersion) + } + } + + // Sort the collection + sort.Sort(sort.Reverse(semver.Collection(tagVersions))) + + tags := make([]string, len(tagVersions)) + + for iTv, tv := range tagVersions { + tags[iTv] = tv.String() + } + + return tags, nil + +} diff --git a/pkg/registry/client_http_test.go b/pkg/registry/client_http_test.go new file mode 100644 index 00000000..872d19fc --- /dev/null +++ b/pkg/registry/client_http_test.go @@ -0,0 +1,68 @@ +/* +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 registry + +import ( + "fmt" + "os" + "testing" + + "github.com/containerd/containerd/errdefs" + "github.com/stretchr/testify/suite" +) + +type HTTPRegistryClientTestSuite struct { + TestSuite +} + +func (suite *HTTPRegistryClientTestSuite) SetupSuite() { + // init test client + dockerRegistry := setup(&suite.TestSuite, false, false) + + // Start Docker registry + go dockerRegistry.ListenAndServe() +} + +func (suite *HTTPRegistryClientTestSuite) TearDownSuite() { + teardown(&suite.TestSuite) + os.RemoveAll(suite.WorkspaceDir) +} + +func (suite *HTTPRegistryClientTestSuite) Test_1_Push() { + testPush(&suite.TestSuite) +} + +func (suite *HTTPRegistryClientTestSuite) Test_2_Pull() { + testPull(&suite.TestSuite) +} + +func (suite *HTTPRegistryClientTestSuite) Test_3_Tags() { + testTags(&suite.TestSuite) +} + +func (suite *HTTPRegistryClientTestSuite) Test_4_ManInTheMiddle() { + ref := fmt.Sprintf("%s/testrepo/supposedlysafechart:9.9.9", suite.CompromisedRegistryHost) + + // returns content that does not match the expected digest + _, err := suite.RegistryClient.Pull(ref) + suite.NotNil(err) + suite.True(errdefs.IsFailedPrecondition(err)) +} + +func TestHTTPRegistryClientTestSuite(t *testing.T) { + suite.Run(t, new(HTTPRegistryClientTestSuite)) +} diff --git a/pkg/registry/client_insecure_tls_test.go b/pkg/registry/client_insecure_tls_test.go new file mode 100644 index 00000000..5ba79b2e --- /dev/null +++ b/pkg/registry/client_insecure_tls_test.go @@ -0,0 +1,77 @@ +/* +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 registry + +import ( + "os" + "testing" + + "github.com/stretchr/testify/suite" +) + +type InsecureTLSRegistryClientTestSuite struct { + TestSuite +} + +func (suite *InsecureTLSRegistryClientTestSuite) SetupSuite() { + // init test client + dockerRegistry := setup(&suite.TestSuite, true, true) + + // Start Docker registry + go dockerRegistry.ListenAndServe() +} + +func (suite *InsecureTLSRegistryClientTestSuite) TearDownSuite() { + teardown(&suite.TestSuite) + os.RemoveAll(suite.WorkspaceDir) +} + +func (suite *InsecureTLSRegistryClientTestSuite) Test_0_Login() { + err := suite.RegistryClient.Login(suite.DockerRegistryHost, + LoginOptBasicAuth("badverybad", "ohsobad"), + LoginOptInsecure(true)) + suite.NotNil(err, "error logging into registry with bad credentials") + + err = suite.RegistryClient.Login(suite.DockerRegistryHost, + LoginOptBasicAuth(testUsername, testPassword), + LoginOptInsecure(true)) + suite.Nil(err, "no error logging into registry with good credentials") +} + +func (suite *InsecureTLSRegistryClientTestSuite) Test_1_Push() { + testPush(&suite.TestSuite) +} + +func (suite *InsecureTLSRegistryClientTestSuite) Test_2_Pull() { + testPull(&suite.TestSuite) +} + +func (suite *InsecureTLSRegistryClientTestSuite) Test_3_Tags() { + testTags(&suite.TestSuite) +} + +func (suite *InsecureTLSRegistryClientTestSuite) Test_4_Logout() { + err := suite.RegistryClient.Logout("this-host-aint-real:5000") + suite.NotNil(err, "error logging out of registry that has no entry") + + err = suite.RegistryClient.Logout(suite.DockerRegistryHost) + suite.Nil(err, "no error logging out of registry") +} + +func TestInsecureTLSRegistryClientTestSuite(t *testing.T) { + suite.Run(t, new(InsecureTLSRegistryClientTestSuite)) +} diff --git a/pkg/registry/client_tls_test.go b/pkg/registry/client_tls_test.go new file mode 100644 index 00000000..518cfced --- /dev/null +++ b/pkg/registry/client_tls_test.go @@ -0,0 +1,77 @@ +/* +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 registry + +import ( + "os" + "testing" + + "github.com/stretchr/testify/suite" +) + +type TLSRegistryClientTestSuite struct { + TestSuite +} + +func (suite *TLSRegistryClientTestSuite) SetupSuite() { + // init test client + dockerRegistry := setup(&suite.TestSuite, true, false) + + // Start Docker registry + go dockerRegistry.ListenAndServe() +} + +func (suite *TLSRegistryClientTestSuite) TearDownSuite() { + teardown(&suite.TestSuite) + os.RemoveAll(suite.WorkspaceDir) +} + +func (suite *TLSRegistryClientTestSuite) Test_0_Login() { + err := suite.RegistryClient.Login(suite.DockerRegistryHost, + LoginOptBasicAuth("badverybad", "ohsobad"), + LoginOptTLSClientConfig(tlsCert, tlsKey, tlsCA)) + suite.NotNil(err, "error logging into registry with bad credentials") + + err = suite.RegistryClient.Login(suite.DockerRegistryHost, + LoginOptBasicAuth(testUsername, testPassword), + LoginOptTLSClientConfig(tlsCert, tlsKey, tlsCA)) + suite.Nil(err, "no error logging into registry with good credentials") +} + +func (suite *TLSRegistryClientTestSuite) Test_1_Push() { + testPush(&suite.TestSuite) +} + +func (suite *TLSRegistryClientTestSuite) Test_2_Pull() { + testPull(&suite.TestSuite) +} + +func (suite *TLSRegistryClientTestSuite) Test_3_Tags() { + testTags(&suite.TestSuite) +} + +func (suite *TLSRegistryClientTestSuite) Test_4_Logout() { + err := suite.RegistryClient.Logout("this-host-aint-real:5000") + suite.NotNil(err, "error logging out of registry that has no entry") + + err = suite.RegistryClient.Logout(suite.DockerRegistryHost) + suite.Nil(err, "no error logging out of registry") +} + +func TestTLSRegistryClientTestSuite(t *testing.T) { + suite.Run(t, new(TLSRegistryClientTestSuite)) +} diff --git a/pkg/registry/constants.go b/pkg/registry/constants.go new file mode 100644 index 00000000..570b6f0d --- /dev/null +++ b/pkg/registry/constants.go @@ -0,0 +1,37 @@ +/* +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 registry // import "helm.sh/helm/v3/pkg/registry" + +const ( + // OCIScheme is the URL scheme for OCI-based requests + OCIScheme = "oci" + + // CredentialsFileBasename is the filename for auth credentials file + CredentialsFileBasename = "registry/config.json" + + // ConfigMediaType is the reserved media type for the Helm chart manifest config + ConfigMediaType = "application/vnd.cncf.helm.config.v1+json" + + // ChartLayerMediaType is the reserved media type for Helm chart package content + ChartLayerMediaType = "application/vnd.cncf.helm.chart.content.v1.tar+gzip" + + // ProvLayerMediaType is the reserved media type for Helm chart provenance files + ProvLayerMediaType = "application/vnd.cncf.helm.chart.provenance.v1.prov" + + // LegacyChartLayerMediaType is the legacy reserved media type for Helm chart package content. + LegacyChartLayerMediaType = "application/tar+gzip" +) diff --git a/pkg/registry/testdata/tls/ca.crt b/pkg/registry/testdata/tls/ca.crt new file mode 100644 index 00000000..d5b845ac --- /dev/null +++ b/pkg/registry/testdata/tls/ca.crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDhzCCAm+gAwIBAgIUEtjKXd8LxpkQf3C5LgdzM1++R3swDQYJKoZIhvcNAQEL +BQAwUzELMAkGA1UEBhMCQ04xCzAJBgNVBAgMAkdEMQswCQYDVQQHDAJTWjETMBEG +A1UECgwKQWNtZSwgSW5jLjEVMBMGA1UEAwwMQWNtZSBSb290IENBMB4XDTIzMDYw +ODEwNDkzOFoXDTI0MDYwNzEwNDkzOFowUzELMAkGA1UEBhMCQ04xCzAJBgNVBAgM +AkdEMQswCQYDVQQHDAJTWjETMBEGA1UECgwKQWNtZSwgSW5jLjEVMBMGA1UEAwwM +QWNtZSBSb290IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApgrX +Lv3k3trxje2JEoqusYN67Z3byZg69djRatfdboS3JKoTIHtcY7MMLdfhjAK97/wv +BaIMuVNgueu4qH6bea7FCP8XWz2BYBrH2GcKjVrBMkUrlIzjG9gnohkeknJQvQvl +oVbqLgZJn0HQcZtsPDnLwfjWDZrNkFBtvPSIMaRQbmtOFdSqAQjLKezbwlznBCJ5 +qpLsgc67ttDW5QAS+GszWPmypUlw8Ih7m8J95eT9aUESP0DbdraeUktWJQTdqukd +NflLaA2ZoV+uTX+wVE4yyXgSjD3Sd93+XhoSSzDzkzRnLsocRutxrTiNC/1S+qhb +Z72XLk0bvNwQhJjHDQIDAQABo1MwUTAdBgNVHQ4EFgQUoSKAVvuJDGszE361K7IF +RXOVj2YwHwYDVR0jBBgwFoAUoSKAVvuJDGszE361K7IFRXOVj2YwDwYDVR0TAQH/ +BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAOqH/JFuT1sqY/zVxCsATE1ze85/o +r6yPw3AuXsFzWtHe/XOFJzvbfOBWfocVLXTDc5933f1Ws/+PcxQKEQCwnUHrEAso +jLPzy+igHc07pi9PqHJ21Sn8FF5JVv+Y6CcZKaF5aEzUISsVjbF2vGK8FotMS9rs +Jw//dDfKhHjO9MHPBdkhOrM31LV6gwYPepno/YYygrJwHGQ5V9sdY8ifRBG6lX2a +xK4N2bl5q3Cpz+iERLNGP2c8OVQwLfSYLpFRSbHS8UiN4z6WqfgYHG7YurvbiMiJ +/AFkUatVJQ5YLmfCz4FMAiaxNtEOkZh5cvL1eCLK7nzvgAPCI33mEp6eoA== +-----END CERTIFICATE----- diff --git a/pkg/registry/testdata/tls/client.crt b/pkg/registry/testdata/tls/client.crt new file mode 100644 index 00000000..5b1daf27 --- /dev/null +++ b/pkg/registry/testdata/tls/client.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDWzCCAkOgAwIBAgIUdJ6uRYm6RYesJ3CRoLokemFFgX8wDQYJKoZIhvcNAQEL +BQAwUzELMAkGA1UEBhMCQ04xCzAJBgNVBAgMAkdEMQswCQYDVQQHDAJTWjETMBEG +A1UECgwKQWNtZSwgSW5jLjEVMBMGA1UEAwwMQWNtZSBSb290IENBMB4XDTIzMDYw +ODEwNTA0OFoXDTI0MDYwNzEwNTA0OFowWTELMAkGA1UEBhMCQ04xCzAJBgNVBAgM +AkdEMQswCQYDVQQHDAJTWjETMBEGA1UECgwKQWNtZSwgSW5jLjEbMBkGA1UEAwwS +aGVsbS10ZXN0LXJlZ2lzdHJ5MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEAxuVrOJyfUO71wlqe/ae8pNVf3z+6b7aCYRrKJ4l66RKMPz9uP5lHD9QImCTU +LddER48iRr5nzaUKqNUsPn4tTcdaH9EEra+PDp+YeToyZARO+coxCq8yt1NxXrlb +E/q9Ie9QUlruhthrgr+5DC+qogZA8kcVPOs2+ObqeCCO6QGpECxROO2ysXHyjy2b +nwGCzZRz90M4z0ifXcey9RLzbmEsYymq6RbaeQvdzevgXhzIANktILuB0D3wJ2ae +WWP2CfBrjaPbOBtzdDhyl4T1aqLiUpDELUJLVpf/h6xCh52Q0svpsGVGtyO+npPe +kZ1LSVAnVGS6JlWWhs7RL0eaPwIDAQABoyEwHzAdBgNVHREEFjAUghJoZWxtLXRl +c3QtcmVnaXN0cnkwDQYJKoZIhvcNAQELBQADggEBABbxtODFOAeTJg4Q3SXqJ8Gq +zh3/1DaAEnMGHILYuS9tK5lisTLiUerqeQaHKR6U90HK/P1vVxe7PvwfHBrVsGkR +4YC6nivf8LMySKBQmsPUHjdotNZZ8O1pqd+CMqZe2ZuvzLZ4pPdw25lKjhZ7qI+t +hQ8yotiJALzEUWLJSgP5Y8k4hFfRGSso1oAC+WppQeW6ITqDo1MrzH7gpjnp+CJG +NWM1oAQCB1qIdo6gY386w6yLyUhfHtAVa3vviQ0dkRLiK95He5xZcO11rlDNdmgF +cF6lElkci8gPuH8UkKAT5bP9dAEbHPSjAIvg5O9NviknLiNAdFRKeTri+hqNLhE= +-----END CERTIFICATE----- diff --git a/pkg/registry/testdata/tls/client.key b/pkg/registry/testdata/tls/client.key new file mode 100644 index 00000000..2f6a8aa1 --- /dev/null +++ b/pkg/registry/testdata/tls/client.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDG5Ws4nJ9Q7vXC +Wp79p7yk1V/fP7pvtoJhGsoniXrpEow/P24/mUcP1AiYJNQt10RHjyJGvmfNpQqo +1Sw+fi1Nx1of0QStr48On5h5OjJkBE75yjEKrzK3U3FeuVsT+r0h71BSWu6G2GuC +v7kML6qiBkDyRxU86zb45up4II7pAakQLFE47bKxcfKPLZufAYLNlHP3QzjPSJ9d +x7L1EvNuYSxjKarpFtp5C93N6+BeHMgA2S0gu4HQPfAnZp5ZY/YJ8GuNo9s4G3N0 +OHKXhPVqouJSkMQtQktWl/+HrEKHnZDSy+mwZUa3I76ek96RnUtJUCdUZLomVZaG +ztEvR5o/AgMBAAECggEBAKTaovRZXPOIHMrqsb0sun8lHEG+YJkXfRlfSw9aNDXa +2cPSn163fN7xr+3rGLKmKkHlsVNRnlgk46Dsj698hbBh+6FDbc1IJhrIzWgthHbB +23PO0rc4X6Dz2JParlLxELJ/2ONp2yqJVxMYNhiTqaqB5HLr1/6WNwo220CWO92D +vLz3rBHO5Vw5b5Y6Kt6MN6ciIHB2k+obhh4GQRJjUhvmmKCzbk1/R1PFYNwhhMN0 +Av6BdwFgngvNzJ8KMxGia7WJSvDYUk0++RRZ1esiZqwWRVCFFkm4Hj+gKJq6Xnz0 +a2nSvlC9k4GJvD9yY9VcDTJY+WsNN3Ny29gIFUeU9IECgYEA4norD3XakMthgOQk +3NE3HSvpZ22xtVgN9uN0b/JXbg7CLlYzn3tabpbQM/4uI6VG3Mk5Pk83QfKnr4W1 +aYO3YTEQ9B4g0eu3t4zfQOibY2+/Jb7Yfv/fH+pjkI26zYDQn61gsFdV9uxF7Pgu +NGNVe/eY+RkxEWsTtb40jcrbCgsCgYEA4NLWAdlrGKWZP5nLvM1hVB8r4WS82c0e +Orfyv2NhiqfRasARC1lQCqwbmCjb0c/eQiW7lJ7iSECc/8xW3HrJBYpG/tCxi9+m +SWxZXzRXDL8bmuoVvYeA/hFZayef5qCc8eiTYGQp6N5ozQHLXuPbNu7n6YSwvoU4 +ANrVBDRXxR0CgYEAmwbfhPS6iVT+yFjjNthrrqdJXQhElgrRfEfUg3DTEj4+A7P0 +IF4y1/KaUIzUjofrSuTfL1zQSW9OA6M2PCTymTAaF9CrzKZbGuTuSaMwAtASe0b5 +MW37EQDD6MZrsZJUvIjU38DY0m6Hqx9zmV7JvFMPPqxU30R5uHWbyderOmMCgYA5 +P3afIe3TaNeNCmyGtwWBli5mRnCQRVrdONnnQjckR3db52xvp15qWUjthfnzgyrl +TRZm0c5s94cC29WCbwGhF4Tcfee35ktBhwV66KkB5efxmonOqSJ/j4tlbcGZyGwu +bTqZ4OeLFJc7HKncj8jSRCNpoxAec22/SfnUCEARQQKBgAnwaN6kmGqIW2EsNOwB +DXCvG4HI9np5xN5Wo2dz7wqGtrt0TVtJ/PNBL3iadDLyPHahwoEVceFrQwqxjPsV +AoSwVDTdX96PKM/v/2ysw1JLf7UMT59mpxFoYiXCPn5Do4D1/25UfMOsJSmFo1Ij +Hkw1bqG8QneuME16BnDQfY3b +-----END PRIVATE KEY----- diff --git a/pkg/registry/testdata/tls/server.crt b/pkg/registry/testdata/tls/server.crt new file mode 100644 index 00000000..5fae09bb --- /dev/null +++ b/pkg/registry/testdata/tls/server.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDWzCCAkOgAwIBAgIUdJ6uRYm6RYesJ3CRoLokemFFgX4wDQYJKoZIhvcNAQEL +BQAwUzELMAkGA1UEBhMCQ04xCzAJBgNVBAgMAkdEMQswCQYDVQQHDAJTWjETMBEG +A1UECgwKQWNtZSwgSW5jLjEVMBMGA1UEAwwMQWNtZSBSb290IENBMB4XDTIzMDYw +ODEwNTAzM1oXDTI0MDYwNzEwNTAzM1owWTELMAkGA1UEBhMCQ04xCzAJBgNVBAgM +AkdEMQswCQYDVQQHDAJTWjETMBEGA1UECgwKQWNtZSwgSW5jLjEbMBkGA1UEAwwS +aGVsbS10ZXN0LXJlZ2lzdHJ5MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEA59jg4ml82uyvrg+tXf/0S8WHuayl5fB3k1lIPtOrTt5KBNh6z5XHZDogsQ3m +UEko4gVUvKL0Einm1i5c3C6KFFj0RNib0QpOZtxu54mx2Rxazkge0yjoTMwl/P1o +pvRI6qfRri8LdlqWwU9wBIYmKqEM8jPjxKcCOaR0WyQmEJ6KbayTzsVNHaQxG/f3 +aIDCkp3tFl+LaTJHjGdZN7tvJsZ1wXlQy6gXTJIPXHDTS/uh3Xp8jgqhlnQPIr44 +HikiAp9DMnOBGO4u4cZjCr04cQnLS9knsBAQCjja9J9DnZ5vKatBHF3nOVAtGoBM +o69HcYoX5F10Qg8YOa7QwIYjpQIDAQABoyEwHzAdBgNVHREEFjAUghJoZWxtLXRl +c3QtcmVnaXN0cnkwDQYJKoZIhvcNAQELBQADggEBABMYICc/rzijGhFPFOeSrXyk +xFX9SSrGMl0CzV44sxzJFJ89BrW9bUWf4rLuc2ugqWp78kRKGMKgaytDrmGGuZKy +Qy+xl3DTAoc9FYOBphtcH1QndWdbpKSc2sTKvdeV6SslKwWXlAvcqIain80fWAkn +J+9Fd/rq3sJxCYsYhEf17pDjHDnG5ZUsBAWWzN+YjtSAe4PzT1KdljUPCC1GbF+H +1dx+MwapV+atftzlGjld8H73MXrKRNUSZM5lEFvzCZz48J1Ml6UVnYO+QCybeJtQ +lBT3/wclJ86e0eNkZJI0WTmrqlaNS/J7mbZ+4BhfjuO5PyZbLg8DcWmaKeNtT8M= +-----END CERTIFICATE----- diff --git a/pkg/registry/testdata/tls/server.key b/pkg/registry/testdata/tls/server.key new file mode 100644 index 00000000..da44121a --- /dev/null +++ b/pkg/registry/testdata/tls/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDn2ODiaXza7K+u +D61d//RLxYe5rKXl8HeTWUg+06tO3koE2HrPlcdkOiCxDeZQSSjiBVS8ovQSKebW +LlzcLooUWPRE2JvRCk5m3G7nibHZHFrOSB7TKOhMzCX8/Wim9Ejqp9GuLwt2WpbB +T3AEhiYqoQzyM+PEpwI5pHRbJCYQnoptrJPOxU0dpDEb9/dogMKSne0WX4tpMkeM +Z1k3u28mxnXBeVDLqBdMkg9ccNNL+6HdenyOCqGWdA8ivjgeKSICn0Myc4EY7i7h +xmMKvThxCctL2SewEBAKONr0n0Odnm8pq0EcXec5UC0agEyjr0dxihfkXXRCDxg5 +rtDAhiOlAgMBAAECggEBAJ6kfFzwqYpz4lJMT+i+Nz+RzilyxaHtRSUCNrkmxVWW +LTfbmU1pw6IFVFFSnYHaTas60pyxNCkpmtZ7qvbOsZTyuVJSlWwYjUU9GHY+df+F +s2zrVIxQtYO3PVc7Xty+0xYd9xAlCMbXfciQvqmZ0Yvh36Xrc7MgRBmFOkkTFyjO +xaT70D5jwK0QKU8sMY+b9XvvaX59jbRmYAHL0wNcke/E7J4NKEAYfRI+x7kuFhP4 +yDbs9YE0u51cHYAGV4EujZhnv2AwvDnAWs0yHqIbVOIWI9+JRYKmPScr7b1bJfd/ +yy24GXvBu7Ss4TkfsJ/FdGXESr0Gj0ZIPIneDn/vrQECgYEA9jHu4FjTbRff+4tV +3zJJe88+yByjC6Hhj223JmRpCXQrXl2WLAYXl94p7M5NFdkD5QG7jsNUogLb73dV +ekUjuQl7IhJZYcRAXcnlkF+8pKt1duA0uRa22VtlR2wyn8oSnLV/9088Moh35sCP +MjWQDlZ/BW7YUPrOtB14eUCvMjECgYEA8RSpmXZVQdGnIIm6gC3rEhtfHQqAoBn0 +JRvnRXC/LKeVSgVF3ijeT9P/0JQuM9uxubV314nY+fhXsM5kkMZUoXMMSoxE+xPw +cgArpzwsleMn7BQ/UF3GLpdkUgNFI8bolZFbIa54F7YSFNto0NBp3mkceCJwoWmZ +BPIoo4zpV7UCgYEAviK2L8GqF5jWvPhRK300z0+xVu725ObywsijKB1oGYsEa26v +qfRSiFFl46M4WWUu4tBBv/IPDMhUf06UT0fSXPd7h0bQjPb6FvT0PFoT4MEiiNqD +HWbzdE5nm49uUYXIdgqed6tT/Fr07ttMPCStysT2eIWwvmnU9bnE7zALniECgYAr +HM7XqtnEU4HXx8macpu/OTXhM6ec+gc3O644NNl7WtzPx/GesSBQllEBM/6vN3Kp +C1LLMNOkoEzOSZqiaVVpKfHgwwTzAbXWLUGhPpmalGznQxevf5WZb2l5YSxUIZYm +aUAq3dCMLPs+z54G+b51D8cPlNkfhIrg34108hYooQKBgQDWMbc6wY6frvJCmesx +i7F/JHJweqcQdW649RCvtK8M/O062/3vvSNTxqEjPaJOGiD4Cn+D5pYchVujqlTM +8DK77N97NzQvpHm81lpKVIg5sObarvT3RnCSRpOumbX5SCBoBUs+nVC01/zZz79c +AJFLAeHI1RjhB0AFpRDCvZZk6w== +-----END PRIVATE KEY----- diff --git a/pkg/registry/util.go b/pkg/registry/util.go new file mode 100644 index 00000000..8baf0852 --- /dev/null +++ b/pkg/registry/util.go @@ -0,0 +1,247 @@ +/* +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 registry // import "helm.sh/helm/v3/pkg/registry" + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + helmtime "helm.sh/helm/v3/pkg/time" + + "github.com/Masterminds/semver/v3" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + orascontext "oras.land/oras-go/pkg/context" + "oras.land/oras-go/pkg/registry" + + "helm.sh/helm/v3/internal/tlsutil" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" +) + +var immutableOciAnnotations = []string{ + ocispec.AnnotationVersion, + ocispec.AnnotationTitle, +} + +// IsOCI determines whether or not a URL is to be treated as an OCI URL +func IsOCI(url string) bool { + return strings.HasPrefix(url, fmt.Sprintf("%s://", OCIScheme)) +} + +// ContainsTag determines whether a tag is found in a provided list of tags +func ContainsTag(tags []string, tag string) bool { + for _, t := range tags { + if tag == t { + return true + } + } + return false +} + +func GetTagMatchingVersionOrConstraint(tags []string, versionString string) (string, error) { + var constraint *semver.Constraints + if versionString == "" { + // If string is empty, set wildcard constraint + constraint, _ = semver.NewConstraint("*") + } else { + // when customer input exact version, check whether have exact match + // one first + for _, v := range tags { + if versionString == v { + return v, nil + } + } + + // Otherwise set constraint to the string given + var err error + constraint, err = semver.NewConstraint(versionString) + if err != nil { + return "", err + } + } + + // Otherwise try to find the first available version matching the string, + // in case it is a constraint + for _, v := range tags { + test, err := semver.NewVersion(v) + if err != nil { + continue + } + if constraint.Check(test) { + return v, nil + } + } + + return "", errors.Errorf("Could not locate a version matching provided version string %s", versionString) +} + +// extractChartMeta is used to extract a chart metadata from a byte array +func extractChartMeta(chartData []byte) (*chart.Metadata, error) { + ch, err := loader.LoadArchive(bytes.NewReader(chartData)) + if err != nil { + return nil, err + } + return ch.Metadata, nil +} + +// ctx retrieves a fresh context. +// disable verbose logging coming from ORAS (unless debug is enabled) +func ctx(out io.Writer, debug bool) context.Context { + if !debug { + return orascontext.Background() + } + ctx := orascontext.WithLoggerFromWriter(context.Background(), out) + orascontext.GetLogger(ctx).Logger.SetLevel(logrus.DebugLevel) + return ctx +} + +// parseReference will parse and validate the reference, and clean tags when +// applicable tags are only cleaned when plus (+) signs are present, and are +// converted to underscores (_) before pushing +// See https://github.com/helm/helm/issues/10166 +func parseReference(raw string) (registry.Reference, error) { + // The sole possible reference modification is replacing plus (+) signs + // present in tags with underscores (_). To do this properly, we first + // need to identify a tag, and then pass it on to the reference parser + // NOTE: Passing immediately to the reference parser will fail since (+) + // signs are an invalid tag character, and simply replacing all plus (+) + // occurrences could invalidate other portions of the URI + parts := strings.Split(raw, ":") + if len(parts) > 1 && !strings.Contains(parts[len(parts)-1], "/") { + tag := parts[len(parts)-1] + + if tag != "" { + // Replace any plus (+) signs with known underscore (_) conversion + newTag := strings.ReplaceAll(tag, "+", "_") + raw = strings.ReplaceAll(raw, tag, newTag) + } + } + + return registry.ParseReference(raw) +} + +// NewRegistryClientWithTLS is a helper function to create a new registry client with TLS enabled. +func NewRegistryClientWithTLS(out io.Writer, certFile, keyFile, caFile string, insecureSkipTLSverify bool, registryConfig string, debug bool) (*Client, error) { + tlsConf, err := tlsutil.NewClientTLS(certFile, keyFile, caFile, insecureSkipTLSverify) + if err != nil { + return nil, fmt.Errorf("can't create TLS config for client: %s", err) + } + // Create a new registry client + registryClient, err := NewClient( + ClientOptDebug(debug), + ClientOptEnableCache(true), + ClientOptWriter(out), + ClientOptCredentialsFile(registryConfig), + ClientOptHTTPClient(&http.Client{ + Transport: &http.Transport{ + TLSClientConfig: tlsConf, + }, + }), + ) + if err != nil { + return nil, err + } + return registryClient, nil +} + +// generateOCIAnnotations will generate OCI annotations to include within the OCI manifest +func generateOCIAnnotations(meta *chart.Metadata, test bool) map[string]string { + + // Get annotations from Chart attributes + ociAnnotations := generateChartOCIAnnotations(meta, test) + + // Copy Chart annotations +annotations: + for chartAnnotationKey, chartAnnotationValue := range meta.Annotations { + + // Avoid overriding key properties + for _, immutableOciKey := range immutableOciAnnotations { + if immutableOciKey == chartAnnotationKey { + continue annotations + } + } + + // Add chart annotation + ociAnnotations[chartAnnotationKey] = chartAnnotationValue + } + + return ociAnnotations +} + +// getChartOCIAnnotations will generate OCI annotations from the provided chart +func generateChartOCIAnnotations(meta *chart.Metadata, test bool) map[string]string { + chartOCIAnnotations := map[string]string{} + + chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationDescription, meta.Description) + chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationTitle, meta.Name) + chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationVersion, meta.Version) + chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationURL, meta.Home) + + if !test { + chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationCreated, helmtime.Now().UTC().Format(time.RFC3339)) + } + + if len(meta.Sources) > 0 { + chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationSource, meta.Sources[0]) + } + + if meta.Maintainers != nil && len(meta.Maintainers) > 0 { + var maintainerSb strings.Builder + + for maintainerIdx, maintainer := range meta.Maintainers { + + if len(maintainer.Name) > 0 { + maintainerSb.WriteString(maintainer.Name) + } + + if len(maintainer.Email) > 0 { + maintainerSb.WriteString(" (") + maintainerSb.WriteString(maintainer.Email) + maintainerSb.WriteString(")") + } + + if maintainerIdx < len(meta.Maintainers)-1 { + maintainerSb.WriteString(", ") + } + + } + + chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationAuthors, maintainerSb.String()) + + } + + return chartOCIAnnotations +} + +// addToMap takes an existing map and adds an item if the value is not empty +func addToMap(inputMap map[string]string, newKey string, newValue string) map[string]string { + + // Add item to map if its + if len(strings.TrimSpace(newValue)) > 0 { + inputMap[newKey] = newValue + } + + return inputMap + +} diff --git a/pkg/registry/util_test.go b/pkg/registry/util_test.go new file mode 100644 index 00000000..fdf09360 --- /dev/null +++ b/pkg/registry/util_test.go @@ -0,0 +1,240 @@ +/* +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 registry // import "helm.sh/helm/v3/pkg/registry" + +import ( + "reflect" + "testing" + "time" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "helm.sh/helm/v3/pkg/chart" + helmtime "helm.sh/helm/v3/pkg/time" +) + +func TestGenerateOCIChartAnnotations(t *testing.T) { + + tests := []struct { + name string + chart *chart.Metadata + expect map[string]string + }{ + { + "Baseline chart", + &chart.Metadata{ + Name: "oci", + Version: "0.0.1", + }, + map[string]string{ + "org.opencontainers.image.title": "oci", + "org.opencontainers.image.version": "0.0.1", + }, + }, + { + "Simple chart values", + &chart.Metadata{ + Name: "oci", + Version: "0.0.1", + Description: "OCI Helm Chart", + Home: "https://helm.sh", + }, + map[string]string{ + "org.opencontainers.image.title": "oci", + "org.opencontainers.image.version": "0.0.1", + "org.opencontainers.image.description": "OCI Helm Chart", + "org.opencontainers.image.url": "https://helm.sh", + }, + }, + { + "Maintainer without email", + &chart.Metadata{ + Name: "oci", + Version: "0.0.1", + Description: "OCI Helm Chart", + Home: "https://helm.sh", + Maintainers: []*chart.Maintainer{ + { + Name: "John Snow", + }, + }, + }, + map[string]string{ + "org.opencontainers.image.title": "oci", + "org.opencontainers.image.version": "0.0.1", + "org.opencontainers.image.description": "OCI Helm Chart", + "org.opencontainers.image.url": "https://helm.sh", + "org.opencontainers.image.authors": "John Snow", + }, + }, + { + "Maintainer with email", + &chart.Metadata{ + Name: "oci", + Version: "0.0.1", + Description: "OCI Helm Chart", + Home: "https://helm.sh", + Maintainers: []*chart.Maintainer{ + {Name: "John Snow", Email: "john@winterfell.com"}, + }, + }, + map[string]string{ + "org.opencontainers.image.title": "oci", + "org.opencontainers.image.version": "0.0.1", + "org.opencontainers.image.description": "OCI Helm Chart", + "org.opencontainers.image.url": "https://helm.sh", + "org.opencontainers.image.authors": "John Snow (john@winterfell.com)", + }, + }, + { + "Multiple Maintainers", + &chart.Metadata{ + Name: "oci", + Version: "0.0.1", + Description: "OCI Helm Chart", + Home: "https://helm.sh", + Maintainers: []*chart.Maintainer{ + {Name: "John Snow", Email: "john@winterfell.com"}, + {Name: "Jane Snow"}, + }, + }, + map[string]string{ + "org.opencontainers.image.title": "oci", + "org.opencontainers.image.version": "0.0.1", + "org.opencontainers.image.description": "OCI Helm Chart", + "org.opencontainers.image.url": "https://helm.sh", + "org.opencontainers.image.authors": "John Snow (john@winterfell.com), Jane Snow", + }, + }, + { + "Chart with Sources", + &chart.Metadata{ + Name: "oci", + Version: "0.0.1", + Description: "OCI Helm Chart", + Sources: []string{ + "https://github.com/helm/helm", + }, + }, + map[string]string{ + "org.opencontainers.image.title": "oci", + "org.opencontainers.image.version": "0.0.1", + "org.opencontainers.image.description": "OCI Helm Chart", + "org.opencontainers.image.source": "https://github.com/helm/helm", + }, + }, + } + + for _, tt := range tests { + + result := generateChartOCIAnnotations(tt.chart, true) + + if !reflect.DeepEqual(tt.expect, result) { + t.Errorf("%s: expected map %v, got %v", tt.name, tt.expect, result) + } + + } +} + +func TestGenerateOCIAnnotations(t *testing.T) { + + tests := []struct { + name string + chart *chart.Metadata + expect map[string]string + }{ + { + "Baseline chart", + &chart.Metadata{ + Name: "oci", + Version: "0.0.1", + }, + map[string]string{ + "org.opencontainers.image.title": "oci", + "org.opencontainers.image.version": "0.0.1", + }, + }, + { + "Simple chart values with custom Annotations", + &chart.Metadata{ + Name: "oci", + Version: "0.0.1", + Description: "OCI Helm Chart", + Annotations: map[string]string{ + "extrakey": "extravlue", + "anotherkey": "anothervalue", + }, + }, + map[string]string{ + "org.opencontainers.image.title": "oci", + "org.opencontainers.image.version": "0.0.1", + "org.opencontainers.image.description": "OCI Helm Chart", + "extrakey": "extravlue", + "anotherkey": "anothervalue", + }, + }, + { + "Verify Chart Name and Version cannot be overridden from annotations", + &chart.Metadata{ + Name: "oci", + Version: "0.0.1", + Description: "OCI Helm Chart", + Annotations: map[string]string{ + "org.opencontainers.image.title": "badchartname", + "org.opencontainers.image.version": "1.0.0", + "extrakey": "extravlue", + }, + }, + map[string]string{ + "org.opencontainers.image.title": "oci", + "org.opencontainers.image.version": "0.0.1", + "org.opencontainers.image.description": "OCI Helm Chart", + "extrakey": "extravlue", + }, + }, + } + + for _, tt := range tests { + + result := generateOCIAnnotations(tt.chart, true) + + if !reflect.DeepEqual(tt.expect, result) { + t.Errorf("%s: expected map %v, got %v", tt.name, tt.expect, result) + } + + } +} + +func TestGenerateOCICreatedAnnotations(t *testing.T) { + chart := &chart.Metadata{ + Name: "oci", + Version: "0.0.1", + } + + result := generateOCIAnnotations(chart, false) + + // Check that created annotation exists + if _, ok := result[ocispec.AnnotationCreated]; !ok { + t.Errorf("%s annotation not created", ocispec.AnnotationCreated) + } + + // Verify value of created artifact in RFC3339 format + if _, err := helmtime.Parse(time.RFC3339, result[ocispec.AnnotationCreated]); err != nil { + t.Errorf("%s annotation with value '%s' not in RFC3339 format", ocispec.AnnotationCreated, result[ocispec.AnnotationCreated]) + } + +} diff --git a/pkg/registry/utils_test.go b/pkg/registry/utils_test.go new file mode 100644 index 00000000..74aa0dbc --- /dev/null +++ b/pkg/registry/utils_test.go @@ -0,0 +1,393 @@ +/* +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 registry + +import ( + "bytes" + "context" + "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/distribution/distribution/v3/configuration" + "github.com/distribution/distribution/v3/registry" + _ "github.com/distribution/distribution/v3/registry/auth/htpasswd" + _ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory" + "github.com/foxcpp/go-mockdns" + "github.com/phayes/freeport" + "github.com/stretchr/testify/suite" + "golang.org/x/crypto/bcrypt" + + "helm.sh/helm/v3/internal/tlsutil" +) + +const ( + tlsServerKey = "./testdata/tls/server.key" + tlsServerCert = "./testdata/tls/server.crt" + tlsCA = "./testdata/tls/ca.crt" + tlsKey = "./testdata/tls/client.key" + tlsCert = "./testdata/tls/client.crt" +) + +var ( + testWorkspaceDir = "helm-registry-test" + testHtpasswdFileBasename = "authtest.htpasswd" + testUsername = "myuser" + testPassword = "mypass" +) + +type TestSuite struct { + suite.Suite + Out io.Writer + DockerRegistryHost string + CompromisedRegistryHost string + WorkspaceDir string + RegistryClient *Client + + // A mock DNS server needed for TLS connection testing. + srv *mockdns.Server +} + +func setup(suite *TestSuite, tlsEnabled, insecure bool) *registry.Registry { + suite.WorkspaceDir = testWorkspaceDir + os.RemoveAll(suite.WorkspaceDir) + os.Mkdir(suite.WorkspaceDir, 0700) + + var ( + out bytes.Buffer + err error + ) + suite.Out = &out + credentialsFile := filepath.Join(suite.WorkspaceDir, CredentialsFileBasename) + + // init test client + opts := []ClientOption{ + ClientOptDebug(true), + ClientOptEnableCache(true), + ClientOptWriter(suite.Out), + ClientOptCredentialsFile(credentialsFile), + ClientOptResolver(nil), + } + + if tlsEnabled { + var tlsConf *tls.Config + if insecure { + tlsConf, err = tlsutil.NewClientTLS("", "", "", true) + } else { + tlsConf, err = tlsutil.NewClientTLS(tlsCert, tlsKey, tlsCA, false) + } + httpClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: tlsConf, + }, + } + suite.Nil(err, "no error loading tls config") + opts = append(opts, ClientOptHTTPClient(httpClient)) + } else { + opts = append(opts, ClientOptPlainHTTP()) + } + + suite.RegistryClient, err = NewClient(opts...) + suite.Nil(err, "no error creating registry client") + + // create htpasswd file (w BCrypt, which is required) + pwBytes, err := bcrypt.GenerateFromPassword([]byte(testPassword), bcrypt.DefaultCost) + suite.Nil(err, "no error generating bcrypt password for test htpasswd file") + htpasswdPath := filepath.Join(suite.WorkspaceDir, testHtpasswdFileBasename) + err = os.WriteFile(htpasswdPath, []byte(fmt.Sprintf("%s:%s\n", testUsername, string(pwBytes))), 0644) + suite.Nil(err, "no error creating test htpasswd file") + + // Registry config + config := &configuration.Configuration{} + port, err := freeport.GetFreePort() + suite.Nil(err, "no error finding free port for test registry") + + // Change the registry host to another host which is not localhost. + // This is required because Docker enforces HTTP if the registry + // host is localhost/127.0.0.1. + suite.DockerRegistryHost = fmt.Sprintf("helm-test-registry:%d", port) + suite.srv, _ = mockdns.NewServer(map[string]mockdns.Zone{ + "helm-test-registry.": { + A: []string{"127.0.0.1"}, + }, + }, false) + suite.srv.PatchNet(net.DefaultResolver) + + config.HTTP.Addr = fmt.Sprintf(":%d", port) + config.HTTP.DrainTimeout = time.Duration(10) * time.Second + config.Storage = map[string]configuration.Parameters{"inmemory": map[string]interface{}{}} + + // Basic auth is not possible if we are serving HTTP. + if tlsEnabled { + config.Auth = configuration.Auth{ + "htpasswd": configuration.Parameters{ + "realm": "localhost", + "path": htpasswdPath, + }, + } + } + + // config tls + if tlsEnabled { + // TLS config + // this set tlsConf.ClientAuth = tls.RequireAndVerifyClientCert in the + // server tls config + config.HTTP.TLS.Certificate = tlsServerCert + config.HTTP.TLS.Key = tlsServerKey + // Skip client authentication if the registry is insecure. + if !insecure { + config.HTTP.TLS.ClientCAs = []string{tlsCA} + } + } + dockerRegistry, err := registry.NewRegistry(context.Background(), config) + suite.Nil(err, "no error creating test registry") + + suite.CompromisedRegistryHost = initCompromisedRegistryTestServer() + return dockerRegistry +} + +func teardown(suite *TestSuite) { + if suite.srv != nil { + mockdns.UnpatchNet(net.DefaultResolver) + suite.srv.Close() + } +} + +func initCompromisedRegistryTestServer() string { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "manifests") { + w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") + w.WriteHeader(200) + + // layers[0] is the blob []byte("a") + w.Write([]byte( + fmt.Sprintf(`{ "schemaVersion": 2, "config": { + "mediaType": "%s", + "digest": "sha256:a705ee2789ab50a5ba20930f246dbd5cc01ff9712825bb98f57ee8414377f133", + "size": 181 + }, + "layers": [ + { + "mediaType": "%s", + "digest": "sha256:ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", + "size": 1 + } + ] +}`, ConfigMediaType, ChartLayerMediaType))) + } else if r.URL.Path == "/v2/testrepo/supposedlysafechart/blobs/sha256:a705ee2789ab50a5ba20930f246dbd5cc01ff9712825bb98f57ee8414377f133" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + w.Write([]byte("{\"name\":\"mychart\",\"version\":\"0.1.0\",\"description\":\"A Helm chart for Kubernetes\\n" + + "an 'application' or a 'library' chart.\",\"apiVersion\":\"v2\",\"appVersion\":\"1.16.0\",\"type\":" + + "\"application\"}")) + } else if r.URL.Path == "/v2/testrepo/supposedlysafechart/blobs/sha256:ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb" { + w.Header().Set("Content-Type", ChartLayerMediaType) + w.WriteHeader(200) + w.Write([]byte("b")) + } else { + w.WriteHeader(500) + } + })) + + u, _ := url.Parse(s.URL) + return fmt.Sprintf("localhost:%s", u.Port()) +} + +func testPush(suite *TestSuite) { + // Bad bytes + ref := fmt.Sprintf("%s/testrepo/testchart:1.2.3", suite.DockerRegistryHost) + _, err := suite.RegistryClient.Push([]byte("hello"), ref, PushOptTest(true)) + suite.NotNil(err, "error pushing non-chart bytes") + + // Load a test chart + chartData, err := os.ReadFile("../repo/repotest/testdata/examplechart-0.1.0.tgz") + suite.Nil(err, "no error loading test chart") + meta, err := extractChartMeta(chartData) + suite.Nil(err, "no error extracting chart meta") + + // non-strict ref (chart name) + ref = fmt.Sprintf("%s/testrepo/boop:%s", suite.DockerRegistryHost, meta.Version) + _, err = suite.RegistryClient.Push(chartData, ref, PushOptTest(true)) + suite.NotNil(err, "error pushing non-strict ref (bad basename)") + + // non-strict ref (chart name), with strict mode disabled + _, err = suite.RegistryClient.Push(chartData, ref, PushOptStrictMode(false), PushOptTest(true)) + suite.Nil(err, "no error pushing non-strict ref (bad basename), with strict mode disabled") + + // non-strict ref (chart version) + ref = fmt.Sprintf("%s/testrepo/%s:latest", suite.DockerRegistryHost, meta.Name) + _, err = suite.RegistryClient.Push(chartData, ref, PushOptTest(true)) + suite.NotNil(err, "error pushing non-strict ref (bad tag)") + + // non-strict ref (chart version), with strict mode disabled + _, err = suite.RegistryClient.Push(chartData, ref, PushOptStrictMode(false), PushOptTest(true)) + suite.Nil(err, "no error pushing non-strict ref (bad tag), with strict mode disabled") + + // basic push, good ref + chartData, err = os.ReadFile("../downloader/testdata/local-subchart-0.1.0.tgz") + suite.Nil(err, "no error loading test chart") + meta, err = extractChartMeta(chartData) + suite.Nil(err, "no error extracting chart meta") + ref = fmt.Sprintf("%s/testrepo/%s:%s", suite.DockerRegistryHost, meta.Name, meta.Version) + _, err = suite.RegistryClient.Push(chartData, ref, PushOptTest(true)) + suite.Nil(err, "no error pushing good ref") + + _, err = suite.RegistryClient.Pull(ref) + suite.Nil(err, "no error pulling a simple chart") + + // Load another test chart + chartData, err = os.ReadFile("../downloader/testdata/signtest-0.1.0.tgz") + suite.Nil(err, "no error loading test chart") + meta, err = extractChartMeta(chartData) + suite.Nil(err, "no error extracting chart meta") + + // Load prov file + provData, err := os.ReadFile("../downloader/testdata/signtest-0.1.0.tgz.prov") + suite.Nil(err, "no error loading test prov") + + // push with prov + ref = fmt.Sprintf("%s/testrepo/%s:%s", suite.DockerRegistryHost, meta.Name, meta.Version) + result, err := suite.RegistryClient.Push(chartData, ref, PushOptProvData(provData), PushOptTest(true)) + suite.Nil(err, "no error pushing good ref with prov") + + _, err = suite.RegistryClient.Pull(ref) + suite.Nil(err, "no error pulling a simple chart") + + // Validate the output + // Note: these digests/sizes etc may change if the test chart/prov files are modified, + // or if the format of the OCI manifest changes + suite.Equal(ref, result.Ref) + suite.Equal(meta.Name, result.Chart.Meta.Name) + suite.Equal(meta.Version, result.Chart.Meta.Version) + suite.Equal(int64(684), result.Manifest.Size) + suite.Equal(int64(99), result.Config.Size) + suite.Equal(int64(973), result.Chart.Size) + suite.Equal(int64(695), result.Prov.Size) + suite.Equal( + "sha256:b57e8ffd938c43253f30afedb3c209136288e6b3af3b33473e95ea3b805888e6", + result.Manifest.Digest) + suite.Equal( + "sha256:8d17cb6bf6ccd8c29aace9a658495cbd5e2e87fc267876e86117c7db681c9580", + result.Config.Digest) + suite.Equal( + "sha256:e5ef611620fb97704d8751c16bab17fedb68883bfb0edc76f78a70e9173f9b55", + result.Chart.Digest) + suite.Equal( + "sha256:b0a02b7412f78ae93324d48df8fcc316d8482e5ad7827b5b238657a29a22f256", + result.Prov.Digest) +} + +func testPull(suite *TestSuite) { + // bad/missing ref + ref := fmt.Sprintf("%s/testrepo/no-existy:1.2.3", suite.DockerRegistryHost) + _, err := suite.RegistryClient.Pull(ref) + suite.NotNil(err, "error on bad/missing ref") + + // Load test chart (to build ref pushed in previous test) + chartData, err := os.ReadFile("../downloader/testdata/local-subchart-0.1.0.tgz") + suite.Nil(err, "no error loading test chart") + meta, err := extractChartMeta(chartData) + suite.Nil(err, "no error extracting chart meta") + ref = fmt.Sprintf("%s/testrepo/%s:%s", suite.DockerRegistryHost, meta.Name, meta.Version) + + // Simple pull, chart only + _, err = suite.RegistryClient.Pull(ref) + suite.Nil(err, "no error pulling a simple chart") + + // Simple pull with prov (no prov uploaded) + _, err = suite.RegistryClient.Pull(ref, PullOptWithProv(true)) + suite.NotNil(err, "error pulling a chart with prov when no prov exists") + + // Simple pull with prov, ignoring missing prov + _, err = suite.RegistryClient.Pull(ref, + PullOptWithProv(true), + PullOptIgnoreMissingProv(true)) + suite.Nil(err, + "no error pulling a chart with prov when no prov exists, ignoring missing") + + // Load test chart (to build ref pushed in previous test) + chartData, err = os.ReadFile("../downloader/testdata/signtest-0.1.0.tgz") + suite.Nil(err, "no error loading test chart") + meta, err = extractChartMeta(chartData) + suite.Nil(err, "no error extracting chart meta") + ref = fmt.Sprintf("%s/testrepo/%s:%s", suite.DockerRegistryHost, meta.Name, meta.Version) + + // Load prov file + provData, err := os.ReadFile("../downloader/testdata/signtest-0.1.0.tgz.prov") + suite.Nil(err, "no error loading test prov") + + // no chart and no prov causes error + _, err = suite.RegistryClient.Pull(ref, + PullOptWithChart(false), + PullOptWithProv(false)) + suite.NotNil(err, "error on both no chart and no prov") + + // full pull with chart and prov + result, err := suite.RegistryClient.Pull(ref, PullOptWithProv(true)) + suite.Nil(err, "no error pulling a chart with prov") + + // Validate the output + // Note: these digests/sizes etc may change if the test chart/prov files are modified, + // or if the format of the OCI manifest changes + suite.Equal(ref, result.Ref) + suite.Equal(meta.Name, result.Chart.Meta.Name) + suite.Equal(meta.Version, result.Chart.Meta.Version) + suite.Equal(int64(684), result.Manifest.Size) + suite.Equal(int64(99), result.Config.Size) + suite.Equal(int64(973), result.Chart.Size) + suite.Equal(int64(695), result.Prov.Size) + suite.Equal( + "sha256:b57e8ffd938c43253f30afedb3c209136288e6b3af3b33473e95ea3b805888e6", + result.Manifest.Digest) + suite.Equal( + "sha256:8d17cb6bf6ccd8c29aace9a658495cbd5e2e87fc267876e86117c7db681c9580", + result.Config.Digest) + suite.Equal( + "sha256:e5ef611620fb97704d8751c16bab17fedb68883bfb0edc76f78a70e9173f9b55", + result.Chart.Digest) + suite.Equal( + "sha256:b0a02b7412f78ae93324d48df8fcc316d8482e5ad7827b5b238657a29a22f256", + result.Prov.Digest) + suite.Equal("{\"schemaVersion\":2,\"config\":{\"mediaType\":\"application/vnd.cncf.helm.config.v1+json\",\"digest\":\"sha256:8d17cb6bf6ccd8c29aace9a658495cbd5e2e87fc267876e86117c7db681c9580\",\"size\":99},\"layers\":[{\"mediaType\":\"application/vnd.cncf.helm.chart.provenance.v1.prov\",\"digest\":\"sha256:b0a02b7412f78ae93324d48df8fcc316d8482e5ad7827b5b238657a29a22f256\",\"size\":695},{\"mediaType\":\"application/vnd.cncf.helm.chart.content.v1.tar+gzip\",\"digest\":\"sha256:e5ef611620fb97704d8751c16bab17fedb68883bfb0edc76f78a70e9173f9b55\",\"size\":973}],\"annotations\":{\"org.opencontainers.image.description\":\"A Helm chart for Kubernetes\",\"org.opencontainers.image.title\":\"signtest\",\"org.opencontainers.image.version\":\"0.1.0\"}}", + string(result.Manifest.Data)) + suite.Equal("{\"name\":\"signtest\",\"version\":\"0.1.0\",\"description\":\"A Helm chart for Kubernetes\",\"apiVersion\":\"v1\"}", + string(result.Config.Data)) + suite.Equal(chartData, result.Chart.Data) + suite.Equal(provData, result.Prov.Data) +} + +func testTags(suite *TestSuite) { + // Load test chart (to build ref pushed in previous test) + chartData, err := os.ReadFile("../downloader/testdata/local-subchart-0.1.0.tgz") + suite.Nil(err, "no error loading test chart") + meta, err := extractChartMeta(chartData) + suite.Nil(err, "no error extracting chart meta") + ref := fmt.Sprintf("%s/testrepo/%s", suite.DockerRegistryHost, meta.Name) + + // Query for tags and validate length + tags, err := suite.RegistryClient.Tags(ref) + suite.Nil(err, "no error retrieving tags") + suite.Equal(1, len(tags)) +} diff --git a/pkg/release/hook.go b/pkg/release/hook.go new file mode 100644 index 00000000..cb995558 --- /dev/null +++ b/pkg/release/hook.go @@ -0,0 +1,106 @@ +/* +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 release + +import ( + "helm.sh/helm/v3/pkg/time" +) + +// HookEvent specifies the hook event +type HookEvent string + +// Hook event types +const ( + HookPreInstall HookEvent = "pre-install" + HookPostInstall HookEvent = "post-install" + HookPreDelete HookEvent = "pre-delete" + HookPostDelete HookEvent = "post-delete" + HookPreUpgrade HookEvent = "pre-upgrade" + HookPostUpgrade HookEvent = "post-upgrade" + HookPreRollback HookEvent = "pre-rollback" + HookPostRollback HookEvent = "post-rollback" + HookTest HookEvent = "test" +) + +func (x HookEvent) String() string { return string(x) } + +// HookDeletePolicy specifies the hook delete policy +type HookDeletePolicy string + +// Hook delete policy types +const ( + HookSucceeded HookDeletePolicy = "hook-succeeded" + HookFailed HookDeletePolicy = "hook-failed" + HookBeforeHookCreation HookDeletePolicy = "before-hook-creation" +) + +func (x HookDeletePolicy) String() string { return string(x) } + +// HookAnnotation is the label name for a hook +const HookAnnotation = "helm.sh/hook" + +// HookWeightAnnotation is the label name for a hook weight +const HookWeightAnnotation = "helm.sh/hook-weight" + +// HookDeleteAnnotation is the label name for the delete policy for a hook +const HookDeleteAnnotation = "helm.sh/hook-delete-policy" + +// Hook defines a hook object. +type Hook struct { + Name string `json:"name,omitempty"` + // Kind is the Kubernetes kind. + Kind string `json:"kind,omitempty"` + // Path is the chart-relative path to the template. + Path string `json:"path,omitempty"` + // Manifest is the manifest contents. + Manifest string `json:"manifest,omitempty"` + // Events are the events that this hook fires on. + Events []HookEvent `json:"events,omitempty"` + // LastRun indicates the date/time this was last run. + LastRun HookExecution `json:"last_run,omitempty"` + // Weight indicates the sort order for execution among similar Hook type + Weight int `json:"weight,omitempty"` + // DeletePolicies are the policies that indicate when to delete the hook + DeletePolicies []HookDeletePolicy `json:"delete_policies,omitempty"` +} + +// A HookExecution records the result for the last execution of a hook for a given release. +type HookExecution struct { + // StartedAt indicates the date/time this hook was started + StartedAt time.Time `json:"started_at,omitempty"` + // CompletedAt indicates the date/time this hook was completed. + CompletedAt time.Time `json:"completed_at,omitempty"` + // Phase indicates whether the hook completed successfully + Phase HookPhase `json:"phase"` +} + +// A HookPhase indicates the state of a hook execution +type HookPhase string + +const ( + // HookPhaseUnknown indicates that a hook is in an unknown state + HookPhaseUnknown HookPhase = "Unknown" + // HookPhaseRunning indicates that a hook is currently executing + HookPhaseRunning HookPhase = "Running" + // HookPhaseSucceeded indicates that hook execution succeeded + HookPhaseSucceeded HookPhase = "Succeeded" + // HookPhaseFailed indicates that hook execution failed + HookPhaseFailed HookPhase = "Failed" +) + +// String converts a hook phase to a printable string +func (x HookPhase) String() string { return string(x) } diff --git a/pkg/release/info.go b/pkg/release/info.go new file mode 100644 index 00000000..b030a8a5 --- /dev/null +++ b/pkg/release/info.go @@ -0,0 +1,40 @@ +/* +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 release + +import ( + "k8s.io/apimachinery/pkg/runtime" + + "helm.sh/helm/v3/pkg/time" +) + +// Info describes release information. +type Info struct { + // FirstDeployed is when the release was first deployed. + FirstDeployed time.Time `json:"first_deployed,omitempty"` + // LastDeployed is when the release was last deployed. + LastDeployed time.Time `json:"last_deployed,omitempty"` + // Deleted tracks when this object was deleted. + Deleted time.Time `json:"deleted"` + // Description is human-friendly "log entry" about this release. + Description string `json:"description,omitempty"` + // Status is the current state of the release + Status Status `json:"status,omitempty"` + // Contains the rendered templates/NOTES.txt if available + Notes string `json:"notes,omitempty"` + // Contains the deployed resources information + Resources map[string][]runtime.Object `json:"resources,omitempty"` +} diff --git a/pkg/release/mock.go b/pkg/release/mock.go new file mode 100644 index 00000000..a28e1dc1 --- /dev/null +++ b/pkg/release/mock.go @@ -0,0 +1,116 @@ +/* +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 release + +import ( + "fmt" + "math/rand" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/time" +) + +// MockHookTemplate is the hook template used for all mock release objects. +var MockHookTemplate = `apiVersion: v1 +kind: Job +metadata: + annotations: + "helm.sh/hook": pre-install +` + +// MockManifest is the manifest used for all mock release objects. +var MockManifest = `apiVersion: v1 +kind: Secret +metadata: + name: fixture +` + +// MockReleaseOptions allows for user-configurable options on mock release objects. +type MockReleaseOptions struct { + Name string + Version int + Chart *chart.Chart + Status Status + Namespace string +} + +// Mock creates a mock release object based on options set by MockReleaseOptions. This function should typically not be used outside of testing. +func Mock(opts *MockReleaseOptions) *Release { + date := time.Unix(242085845, 0).UTC() + + name := opts.Name + if name == "" { + name = "testrelease-" + fmt.Sprint(rand.Intn(100)) + } + + version := 1 + if opts.Version != 0 { + version = opts.Version + } + + namespace := opts.Namespace + if namespace == "" { + namespace = "default" + } + + ch := opts.Chart + if opts.Chart == nil { + ch = &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "foo", + Version: "0.1.0-beta.1", + AppVersion: "1.0", + }, + Templates: []*chart.File{ + {Name: "templates/foo.tpl", Data: []byte(MockManifest)}, + }, + } + } + + scode := StatusDeployed + if len(opts.Status) > 0 { + scode = opts.Status + } + + info := &Info{ + FirstDeployed: date, + LastDeployed: date, + Status: scode, + Description: "Release mock", + Notes: "Some mock release notes!", + } + + return &Release{ + Name: name, + Info: info, + Chart: ch, + Config: map[string]interface{}{"name": "value"}, + Version: version, + Namespace: namespace, + Hooks: []*Hook{ + { + Name: "pre-install-hook", + Kind: "Job", + Path: "pre-install-hook.yaml", + Manifest: MockHookTemplate, + LastRun: HookExecution{}, + Events: []HookEvent{HookPreInstall}, + }, + }, + Manifest: MockManifest, + } +} diff --git a/pkg/release/release.go b/pkg/release/release.go new file mode 100644 index 00000000..b9061287 --- /dev/null +++ b/pkg/release/release.go @@ -0,0 +1,49 @@ +/* +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 release + +import "helm.sh/helm/v3/pkg/chart" + +// Release describes a deployment of a chart, together with the chart +// and the variables used to deploy that chart. +type Release struct { + // Name is the name of the release + Name string `json:"name,omitempty"` + // Info provides information about a release + Info *Info `json:"info,omitempty"` + // Chart is the chart that was released. + Chart *chart.Chart `json:"chart,omitempty"` + // Config is the set of extra Values added to the chart. + // These values override the default values inside of the chart. + Config map[string]interface{} `json:"config,omitempty"` + // Manifest is the string representation of the rendered template. + Manifest string `json:"manifest,omitempty"` + // Hooks are all of the hooks declared for this release. + Hooks []*Hook `json:"hooks,omitempty"` + // Version is an int which represents the revision of the release. + Version int `json:"version,omitempty"` + // Namespace is the kubernetes namespace of the release. + Namespace string `json:"namespace,omitempty"` + // Labels of the release. + // Disabled encoding into Json cause labels are stored in storage driver metadata field. + Labels map[string]string `json:"-"` +} + +// SetStatus is a helper for setting the status on a release. +func (r *Release) SetStatus(status Status, msg string) { + r.Info.Status = status + r.Info.Description = msg +} diff --git a/pkg/release/responses.go b/pkg/release/responses.go new file mode 100644 index 00000000..7ee1fc2e --- /dev/null +++ b/pkg/release/responses.go @@ -0,0 +1,24 @@ +/* +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 release + +// UninstallReleaseResponse represents a successful response to an uninstall request. +type UninstallReleaseResponse struct { + // Release is the release that was marked deleted. + Release *Release `json:"release,omitempty"` + // Info is an uninstall message + Info string `json:"info,omitempty"` +} diff --git a/pkg/release/status.go b/pkg/release/status.go new file mode 100644 index 00000000..e0e3ed62 --- /dev/null +++ b/pkg/release/status.go @@ -0,0 +1,49 @@ +/* +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 release + +// Status is the status of a release +type Status string + +// Describe the status of a release +// NOTE: Make sure to update cmd/helm/status.go when adding or modifying any of these statuses. +const ( + // StatusUnknown indicates that a release is in an uncertain state. + StatusUnknown Status = "unknown" + // StatusDeployed indicates that the release has been pushed to Kubernetes. + StatusDeployed Status = "deployed" + // StatusUninstalled indicates that a release has been uninstalled from Kubernetes. + StatusUninstalled Status = "uninstalled" + // StatusSuperseded indicates that this release object is outdated and a newer one exists. + StatusSuperseded Status = "superseded" + // StatusFailed indicates that the release was not successfully deployed. + StatusFailed Status = "failed" + // StatusUninstalling indicates that a uninstall operation is underway. + StatusUninstalling Status = "uninstalling" + // StatusPendingInstall indicates that an install operation is underway. + StatusPendingInstall Status = "pending-install" + // StatusPendingUpgrade indicates that an upgrade operation is underway. + StatusPendingUpgrade Status = "pending-upgrade" + // StatusPendingRollback indicates that an rollback operation is underway. + StatusPendingRollback Status = "pending-rollback" +) + +func (x Status) String() string { return string(x) } + +// IsPending determines if this status is a state or a transition. +func (x Status) IsPending() bool { + return x == StatusPendingInstall || x == StatusPendingUpgrade || x == StatusPendingRollback +} diff --git a/pkg/releaseutil/filter.go b/pkg/releaseutil/filter.go new file mode 100644 index 00000000..dbd0df8e --- /dev/null +++ b/pkg/releaseutil/filter.go @@ -0,0 +1,78 @@ +/* +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 releaseutil // import "helm.sh/helm/v3/pkg/releaseutil" + +import rspb "helm.sh/helm/v3/pkg/release" + +// FilterFunc returns true if the release object satisfies +// the predicate of the underlying filter func. +type FilterFunc func(*rspb.Release) bool + +// Check applies the FilterFunc to the release object. +func (fn FilterFunc) Check(rls *rspb.Release) bool { + if rls == nil { + return false + } + return fn(rls) +} + +// Filter applies the filter(s) to the list of provided releases +// returning the list that satisfies the filtering predicate. +func (fn FilterFunc) Filter(rels []*rspb.Release) (rets []*rspb.Release) { + for _, rel := range rels { + if fn.Check(rel) { + rets = append(rets, rel) + } + } + return +} + +// Any returns a FilterFunc that filters a list of releases +// determined by the predicate 'f0 || f1 || ... || fn'. +func Any(filters ...FilterFunc) FilterFunc { + return func(rls *rspb.Release) bool { + for _, filter := range filters { + if filter(rls) { + return true + } + } + return false + } +} + +// All returns a FilterFunc that filters a list of releases +// determined by the predicate 'f0 && f1 && ... && fn'. +func All(filters ...FilterFunc) FilterFunc { + return func(rls *rspb.Release) bool { + for _, filter := range filters { + if !filter(rls) { + return false + } + } + return true + } +} + +// StatusFilter filters a set of releases by status code. +func StatusFilter(status rspb.Status) FilterFunc { + return FilterFunc(func(rls *rspb.Release) bool { + if rls == nil { + return true + } + return rls.Info.Status == status + }) +} diff --git a/pkg/releaseutil/filter_test.go b/pkg/releaseutil/filter_test.go new file mode 100644 index 00000000..31ac306f --- /dev/null +++ b/pkg/releaseutil/filter_test.go @@ -0,0 +1,59 @@ +/* +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 releaseutil // import "helm.sh/helm/v3/pkg/releaseutil" + +import ( + "testing" + + rspb "helm.sh/helm/v3/pkg/release" +) + +func TestFilterAny(t *testing.T) { + ls := Any(StatusFilter(rspb.StatusUninstalled)).Filter(releases) + if len(ls) != 2 { + t.Fatalf("expected 2 results, got '%d'", len(ls)) + } + + r0, r1 := ls[0], ls[1] + switch { + case r0.Info.Status != rspb.StatusUninstalled: + t.Fatalf("expected UNINSTALLED result, got '%s'", r1.Info.Status.String()) + case r1.Info.Status != rspb.StatusUninstalled: + t.Fatalf("expected UNINSTALLED result, got '%s'", r1.Info.Status.String()) + } +} + +func TestFilterAll(t *testing.T) { + fn := FilterFunc(func(rls *rspb.Release) bool { + // true if not uninstalled and version < 4 + v0 := !StatusFilter(rspb.StatusUninstalled).Check(rls) + v1 := rls.Version < 4 + return v0 && v1 + }) + + ls := All(fn).Filter(releases) + if len(ls) != 1 { + t.Fatalf("expected 1 result, got '%d'", len(ls)) + } + + switch r0 := ls[0]; { + case r0.Version == 4: + t.Fatal("got release with status revision 4") + case r0.Info.Status == rspb.StatusUninstalled: + t.Fatal("got release with status UNINSTALLED") + } +} diff --git a/pkg/releaseutil/kind_sorter.go b/pkg/releaseutil/kind_sorter.go new file mode 100644 index 00000000..bb8e84dd --- /dev/null +++ b/pkg/releaseutil/kind_sorter.go @@ -0,0 +1,160 @@ +/* +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 releaseutil + +import ( + "sort" + + "helm.sh/helm/v3/pkg/release" +) + +// KindSortOrder is an ordering of Kinds. +type KindSortOrder []string + +// InstallOrder is the order in which manifests should be installed (by Kind). +// +// Those occurring earlier in the list get installed before those occurring later in the list. +var InstallOrder KindSortOrder = []string{ + "PriorityClass", + "Namespace", + "NetworkPolicy", + "ResourceQuota", + "LimitRange", + "PodSecurityPolicy", + "PodDisruptionBudget", + "ServiceAccount", + "Secret", + "SecretList", + "ConfigMap", + "StorageClass", + "PersistentVolume", + "PersistentVolumeClaim", + "CustomResourceDefinition", + "ClusterRole", + "ClusterRoleList", + "ClusterRoleBinding", + "ClusterRoleBindingList", + "Role", + "RoleList", + "RoleBinding", + "RoleBindingList", + "Service", + "DaemonSet", + "Pod", + "ReplicationController", + "ReplicaSet", + "Deployment", + "HorizontalPodAutoscaler", + "StatefulSet", + "Job", + "CronJob", + "IngressClass", + "Ingress", + "APIService", +} + +// UninstallOrder is the order in which manifests should be uninstalled (by Kind). +// +// Those occurring earlier in the list get uninstalled before those occurring later in the list. +var UninstallOrder KindSortOrder = []string{ + "APIService", + "Ingress", + "IngressClass", + "Service", + "CronJob", + "Job", + "StatefulSet", + "HorizontalPodAutoscaler", + "Deployment", + "ReplicaSet", + "ReplicationController", + "Pod", + "DaemonSet", + "RoleBindingList", + "RoleBinding", + "RoleList", + "Role", + "ClusterRoleBindingList", + "ClusterRoleBinding", + "ClusterRoleList", + "ClusterRole", + "CustomResourceDefinition", + "PersistentVolumeClaim", + "PersistentVolume", + "StorageClass", + "ConfigMap", + "SecretList", + "Secret", + "ServiceAccount", + "PodDisruptionBudget", + "PodSecurityPolicy", + "LimitRange", + "ResourceQuota", + "NetworkPolicy", + "Namespace", + "PriorityClass", +} + +// sort manifests by kind. +// +// Results are sorted by 'ordering', keeping order of items with equal kind/priority +func sortManifestsByKind(manifests []Manifest, ordering KindSortOrder) []Manifest { + sort.SliceStable(manifests, func(i, j int) bool { + return lessByKind(manifests[i], manifests[j], manifests[i].Head.Kind, manifests[j].Head.Kind, ordering) + }) + + return manifests +} + +// sort hooks by kind, using an out-of-place sort to preserve the input parameters. +// +// Results are sorted by 'ordering', keeping order of items with equal kind/priority +func sortHooksByKind(hooks []*release.Hook, ordering KindSortOrder) []*release.Hook { + h := hooks + sort.SliceStable(h, func(i, j int) bool { + return lessByKind(h[i], h[j], h[i].Kind, h[j].Kind, ordering) + }) + + return h +} + +func lessByKind(_ interface{}, _ interface{}, kindA string, kindB string, o KindSortOrder) bool { + ordering := make(map[string]int, len(o)) + for v, k := range o { + ordering[k] = v + } + + first, aok := ordering[kindA] + second, bok := ordering[kindB] + + if !aok && !bok { + // if both are unknown then sort alphabetically by kind, keep original order if same kind + if kindA != kindB { + return kindA < kindB + } + return first < second + } + // unknown kind is last + if !aok { + return false + } + if !bok { + return true + } + // sort different kinds, keep original order if same priority + return first < second +} diff --git a/pkg/releaseutil/kind_sorter_test.go b/pkg/releaseutil/kind_sorter_test.go new file mode 100644 index 00000000..9e24c439 --- /dev/null +++ b/pkg/releaseutil/kind_sorter_test.go @@ -0,0 +1,339 @@ +/* +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 releaseutil + +import ( + "bytes" + "testing" + + "helm.sh/helm/v3/pkg/release" +) + +func TestKindSorter(t *testing.T) { + manifests := []Manifest{ + { + Name: "U", + Head: &SimpleHead{Kind: "IngressClass"}, + }, + { + Name: "E", + Head: &SimpleHead{Kind: "SecretList"}, + }, + { + Name: "i", + Head: &SimpleHead{Kind: "ClusterRole"}, + }, + { + Name: "I", + Head: &SimpleHead{Kind: "ClusterRoleList"}, + }, + { + Name: "j", + Head: &SimpleHead{Kind: "ClusterRoleBinding"}, + }, + { + Name: "J", + Head: &SimpleHead{Kind: "ClusterRoleBindingList"}, + }, + { + Name: "f", + Head: &SimpleHead{Kind: "ConfigMap"}, + }, + { + Name: "u", + Head: &SimpleHead{Kind: "CronJob"}, + }, + { + Name: "2", + Head: &SimpleHead{Kind: "CustomResourceDefinition"}, + }, + { + Name: "n", + Head: &SimpleHead{Kind: "DaemonSet"}, + }, + { + Name: "r", + Head: &SimpleHead{Kind: "Deployment"}, + }, + { + Name: "!", + Head: &SimpleHead{Kind: "HonkyTonkSet"}, + }, + { + Name: "v", + Head: &SimpleHead{Kind: "Ingress"}, + }, + { + Name: "t", + Head: &SimpleHead{Kind: "Job"}, + }, + { + Name: "c", + Head: &SimpleHead{Kind: "LimitRange"}, + }, + { + Name: "a", + Head: &SimpleHead{Kind: "Namespace"}, + }, + { + Name: "A", + Head: &SimpleHead{Kind: "NetworkPolicy"}, + }, + { + Name: "g", + Head: &SimpleHead{Kind: "PersistentVolume"}, + }, + { + Name: "h", + Head: &SimpleHead{Kind: "PersistentVolumeClaim"}, + }, + { + Name: "o", + Head: &SimpleHead{Kind: "Pod"}, + }, + { + Name: "3", + Head: &SimpleHead{Kind: "PodDisruptionBudget"}, + }, + { + Name: "C", + Head: &SimpleHead{Kind: "PodSecurityPolicy"}, + }, + { + Name: "q", + Head: &SimpleHead{Kind: "ReplicaSet"}, + }, + { + Name: "p", + Head: &SimpleHead{Kind: "ReplicationController"}, + }, + { + Name: "b", + Head: &SimpleHead{Kind: "ResourceQuota"}, + }, + { + Name: "k", + Head: &SimpleHead{Kind: "Role"}, + }, + { + Name: "K", + Head: &SimpleHead{Kind: "RoleList"}, + }, + { + Name: "l", + Head: &SimpleHead{Kind: "RoleBinding"}, + }, + { + Name: "L", + Head: &SimpleHead{Kind: "RoleBindingList"}, + }, + { + Name: "e", + Head: &SimpleHead{Kind: "Secret"}, + }, + { + Name: "m", + Head: &SimpleHead{Kind: "Service"}, + }, + { + Name: "d", + Head: &SimpleHead{Kind: "ServiceAccount"}, + }, + { + Name: "s", + Head: &SimpleHead{Kind: "StatefulSet"}, + }, + { + Name: "1", + Head: &SimpleHead{Kind: "StorageClass"}, + }, + { + Name: "w", + Head: &SimpleHead{Kind: "APIService"}, + }, + { + Name: "x", + Head: &SimpleHead{Kind: "HorizontalPodAutoscaler"}, + }, + { + Name: "F", + Head: &SimpleHead{Kind: "PriorityClass"}, + }, + } + + for _, test := range []struct { + description string + order KindSortOrder + expected string + }{ + {"install", InstallOrder, "FaAbcC3deEf1gh2iIjJkKlLmnopqrxstuUvw!"}, + {"uninstall", UninstallOrder, "wvUmutsxrqponLlKkJjIi2hg1fEed3CcbAaF!"}, + } { + var buf bytes.Buffer + t.Run(test.description, func(t *testing.T) { + if got, want := len(test.expected), len(manifests); got != want { + t.Fatalf("Expected %d names in order, got %d", want, got) + } + defer buf.Reset() + orig := manifests + for _, r := range sortManifestsByKind(manifests, test.order) { + buf.WriteString(r.Name) + } + if got := buf.String(); got != test.expected { + t.Errorf("Expected %q, got %q", test.expected, got) + } + for i, manifest := range orig { + if manifest != manifests[i] { + t.Fatal("Expected input to sortManifestsByKind to stay the same") + } + } + }) + } +} + +// TestKindSorterKeepOriginalOrder verifies manifests of same kind are kept in original order +func TestKindSorterKeepOriginalOrder(t *testing.T) { + manifests := []Manifest{ + { + Name: "a", + Head: &SimpleHead{Kind: "ClusterRole"}, + }, + { + Name: "A", + Head: &SimpleHead{Kind: "ClusterRole"}, + }, + { + Name: "0", + Head: &SimpleHead{Kind: "ConfigMap"}, + }, + { + Name: "1", + Head: &SimpleHead{Kind: "ConfigMap"}, + }, + { + Name: "z", + Head: &SimpleHead{Kind: "ClusterRoleBinding"}, + }, + { + Name: "!", + Head: &SimpleHead{Kind: "ClusterRoleBinding"}, + }, + { + Name: "u2", + Head: &SimpleHead{Kind: "Unknown"}, + }, + { + Name: "u1", + Head: &SimpleHead{Kind: "Unknown"}, + }, + { + Name: "t3", + Head: &SimpleHead{Kind: "Unknown2"}, + }, + } + for _, test := range []struct { + description string + order KindSortOrder + expected string + }{ + // expectation is sorted by kind (unknown is last) and within each group of same kind, the order is kept + {"cm,clusterRole,clusterRoleBinding,Unknown,Unknown2", InstallOrder, "01aAz!u2u1t3"}, + } { + var buf bytes.Buffer + t.Run(test.description, func(t *testing.T) { + defer buf.Reset() + for _, r := range sortManifestsByKind(manifests, test.order) { + buf.WriteString(r.Name) + } + if got := buf.String(); got != test.expected { + t.Errorf("Expected %q, got %q", test.expected, got) + } + }) + } +} + +func TestKindSorterNamespaceAgainstUnknown(t *testing.T) { + unknown := Manifest{ + Name: "a", + Head: &SimpleHead{Kind: "Unknown"}, + } + namespace := Manifest{ + Name: "b", + Head: &SimpleHead{Kind: "Namespace"}, + } + + manifests := []Manifest{unknown, namespace} + manifests = sortManifestsByKind(manifests, InstallOrder) + + expectedOrder := []Manifest{namespace, unknown} + for i, manifest := range manifests { + if expectedOrder[i].Name != manifest.Name { + t.Errorf("Expected %s, got %s", expectedOrder[i].Name, manifest.Name) + } + } +} + +// test hook sorting with a small subset of kinds, since it uses the same algorithm as sortManifestsByKind +func TestKindSorterForHooks(t *testing.T) { + hooks := []*release.Hook{ + { + Name: "i", + Kind: "ClusterRole", + }, + { + Name: "j", + Kind: "ClusterRoleBinding", + }, + { + Name: "c", + Kind: "LimitRange", + }, + { + Name: "a", + Kind: "Namespace", + }, + } + + for _, test := range []struct { + description string + order KindSortOrder + expected string + }{ + {"install", InstallOrder, "acij"}, + {"uninstall", UninstallOrder, "jica"}, + } { + var buf bytes.Buffer + t.Run(test.description, func(t *testing.T) { + if got, want := len(test.expected), len(hooks); got != want { + t.Fatalf("Expected %d names in order, got %d", want, got) + } + defer buf.Reset() + orig := hooks + for _, r := range sortHooksByKind(hooks, test.order) { + buf.WriteString(r.Name) + } + for i, hook := range orig { + if hook != hooks[i] { + t.Fatal("Expected input to sortHooksByKind to stay the same") + } + } + if got := buf.String(); got != test.expected { + t.Errorf("Expected %q, got %q", test.expected, got) + } + }) + } +} diff --git a/pkg/releaseutil/manifest.go b/pkg/releaseutil/manifest.go new file mode 100644 index 00000000..0b04a459 --- /dev/null +++ b/pkg/releaseutil/manifest.go @@ -0,0 +1,72 @@ +/* +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 releaseutil + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +// SimpleHead defines what the structure of the head of a manifest file +type SimpleHead struct { + Version string `json:"apiVersion"` + Kind string `json:"kind,omitempty"` + Metadata *struct { + Name string `json:"name"` + Annotations map[string]string `json:"annotations"` + } `json:"metadata,omitempty"` +} + +var sep = regexp.MustCompile("(?:^|\\s*\n)---\\s*") + +// SplitManifests takes a string of manifest and returns a map contains individual manifests +func SplitManifests(bigFile string) map[string]string { + // Basically, we're quickly splitting a stream of YAML documents into an + // array of YAML docs. The file name is just a place holder, but should be + // integer-sortable so that manifests get output in the same order as the + // input (see `BySplitManifestsOrder`). + tpl := "manifest-%d" + res := map[string]string{} + // Making sure that any extra whitespace in YAML stream doesn't interfere in splitting documents correctly. + bigFileTmp := strings.TrimSpace(bigFile) + docs := sep.Split(bigFileTmp, -1) + var count int + for _, d := range docs { + if d == "" { + continue + } + + d = strings.TrimSpace(d) + res[fmt.Sprintf(tpl, count)] = d + count = count + 1 + } + return res +} + +// BySplitManifestsOrder sorts by in-file manifest order, as provided in function `SplitManifests` +type BySplitManifestsOrder []string + +func (a BySplitManifestsOrder) Len() int { return len(a) } +func (a BySplitManifestsOrder) Less(i, j int) bool { + // Split `manifest-%d` + anum, _ := strconv.ParseInt(a[i][len("manifest-"):], 10, 0) + bnum, _ := strconv.ParseInt(a[j][len("manifest-"):], 10, 0) + return anum < bnum +} +func (a BySplitManifestsOrder) Swap(i, j int) { a[i], a[j] = a[j], a[i] } diff --git a/pkg/releaseutil/manifest_sorter.go b/pkg/releaseutil/manifest_sorter.go new file mode 100644 index 00000000..413de30e --- /dev/null +++ b/pkg/releaseutil/manifest_sorter.go @@ -0,0 +1,233 @@ +/* +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 releaseutil + +import ( + "log" + "path" + "sort" + "strconv" + "strings" + + "github.com/pkg/errors" + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/release" +) + +// Manifest represents a manifest file, which has a name and some content. +type Manifest struct { + Name string + Content string + Head *SimpleHead +} + +// manifestFile represents a file that contains a manifest. +type manifestFile struct { + entries map[string]string + path string + apis chartutil.VersionSet +} + +// result is an intermediate structure used during sorting. +type result struct { + hooks []*release.Hook + generic []Manifest +} + +// TODO: Refactor this out. It's here because naming conventions were not followed through. +// So fix the Test hook names and then remove this. +var events = map[string]release.HookEvent{ + release.HookPreInstall.String(): release.HookPreInstall, + release.HookPostInstall.String(): release.HookPostInstall, + release.HookPreDelete.String(): release.HookPreDelete, + release.HookPostDelete.String(): release.HookPostDelete, + release.HookPreUpgrade.String(): release.HookPreUpgrade, + release.HookPostUpgrade.String(): release.HookPostUpgrade, + release.HookPreRollback.String(): release.HookPreRollback, + release.HookPostRollback.String(): release.HookPostRollback, + release.HookTest.String(): release.HookTest, + // Support test-success for backward compatibility with Helm 2 tests + "test-success": release.HookTest, +} + +// SortManifests takes a map of filename/YAML contents, splits the file +// by manifest entries, and sorts the entries into hook types. +// +// The resulting hooks struct will be populated with all of the generated hooks. +// Any file that does not declare one of the hook types will be placed in the +// 'generic' bucket. +// +// Files that do not parse into the expected format are simply placed into a map and +// returned. +func SortManifests(files map[string]string, apis chartutil.VersionSet, ordering KindSortOrder) ([]*release.Hook, []Manifest, error) { + result := &result{} + + var sortedFilePaths []string + for filePath := range files { + sortedFilePaths = append(sortedFilePaths, filePath) + } + sort.Strings(sortedFilePaths) + + for _, filePath := range sortedFilePaths { + content := files[filePath] + + // Skip partials. We could return these as a separate map, but there doesn't + // seem to be any need for that at this time. + if strings.HasPrefix(path.Base(filePath), "_") { + continue + } + // Skip empty files and log this. + if strings.TrimSpace(content) == "" { + continue + } + + manifestFile := &manifestFile{ + entries: SplitManifests(content), + path: filePath, + apis: apis, + } + + if err := manifestFile.sort(result); err != nil { + return result.hooks, result.generic, err + } + } + + return sortHooksByKind(result.hooks, ordering), sortManifestsByKind(result.generic, ordering), nil +} + +// sort takes a manifestFile object which may contain multiple resource definition +// entries and sorts each entry by hook types, and saves the resulting hooks and +// generic manifests (or non-hooks) to the result struct. +// +// To determine hook type, it looks for a YAML structure like this: +// +// kind: SomeKind +// apiVersion: v1 +// metadata: +// annotations: +// helm.sh/hook: pre-install +// +// To determine the policy to delete the hook, it looks for a YAML structure like this: +// +// kind: SomeKind +// apiVersion: v1 +// metadata: +// annotations: +// helm.sh/hook-delete-policy: hook-succeeded +func (file *manifestFile) sort(result *result) error { + // Go through manifests in order found in file (function `SplitManifests` creates integer-sortable keys) + var sortedEntryKeys []string + for entryKey := range file.entries { + sortedEntryKeys = append(sortedEntryKeys, entryKey) + } + sort.Sort(BySplitManifestsOrder(sortedEntryKeys)) + + for _, entryKey := range sortedEntryKeys { + m := file.entries[entryKey] + + var entry SimpleHead + if err := yaml.Unmarshal([]byte(m), &entry); err != nil { + return errors.Wrapf(err, "YAML parse error on %s", file.path) + } + + if !hasAnyAnnotation(entry) { + result.generic = append(result.generic, Manifest{ + Name: file.path, + Content: m, + Head: &entry, + }) + continue + } + + hookTypes, ok := entry.Metadata.Annotations[release.HookAnnotation] + if !ok { + result.generic = append(result.generic, Manifest{ + Name: file.path, + Content: m, + Head: &entry, + }) + continue + } + + hw := calculateHookWeight(entry) + + h := &release.Hook{ + Name: entry.Metadata.Name, + Kind: entry.Kind, + Path: file.path, + Manifest: m, + Events: []release.HookEvent{}, + Weight: hw, + DeletePolicies: []release.HookDeletePolicy{}, + } + + isUnknownHook := false + for _, hookType := range strings.Split(hookTypes, ",") { + hookType = strings.ToLower(strings.TrimSpace(hookType)) + e, ok := events[hookType] + if !ok { + isUnknownHook = true + break + } + h.Events = append(h.Events, e) + } + + if isUnknownHook { + log.Printf("info: skipping unknown hook: %q", hookTypes) + continue + } + + result.hooks = append(result.hooks, h) + + operateAnnotationValues(entry, release.HookDeleteAnnotation, func(value string) { + h.DeletePolicies = append(h.DeletePolicies, release.HookDeletePolicy(value)) + }) + } + + return nil +} + +// hasAnyAnnotation returns true if the given entry has any annotations at all. +func hasAnyAnnotation(entry SimpleHead) bool { + return entry.Metadata != nil && + entry.Metadata.Annotations != nil && + len(entry.Metadata.Annotations) != 0 +} + +// calculateHookWeight finds the weight in the hook weight annotation. +// +// If no weight is found, the assigned weight is 0 +func calculateHookWeight(entry SimpleHead) int { + hws := entry.Metadata.Annotations[release.HookWeightAnnotation] + hw, err := strconv.Atoi(hws) + if err != nil { + hw = 0 + } + return hw +} + +// operateAnnotationValues finds the given annotation and runs the operate function with the value of that annotation +func operateAnnotationValues(entry SimpleHead, annotation string, operate func(p string)) { + if dps, ok := entry.Metadata.Annotations[annotation]; ok { + for _, dp := range strings.Split(dps, ",") { + dp = strings.ToLower(strings.TrimSpace(dp)) + operate(dp) + } + } +} diff --git a/pkg/releaseutil/manifest_sorter_test.go b/pkg/releaseutil/manifest_sorter_test.go new file mode 100644 index 00000000..20d80931 --- /dev/null +++ b/pkg/releaseutil/manifest_sorter_test.go @@ -0,0 +1,228 @@ +/* +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 releaseutil + +import ( + "reflect" + "testing" + + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/release" +) + +func TestSortManifests(t *testing.T) { + + data := []struct { + name []string + path string + kind []string + hooks map[string][]release.HookEvent + manifest string + }{ + { + name: []string{"first"}, + path: "one", + kind: []string{"Job"}, + hooks: map[string][]release.HookEvent{"first": {release.HookPreInstall}}, + manifest: `apiVersion: v1 +kind: Job +metadata: + name: first + labels: + doesnot: matter + annotations: + "helm.sh/hook": pre-install +`, + }, + { + name: []string{"second"}, + path: "two", + kind: []string{"ReplicaSet"}, + hooks: map[string][]release.HookEvent{"second": {release.HookPostInstall}}, + manifest: `kind: ReplicaSet +apiVersion: v1beta1 +metadata: + name: second + annotations: + "helm.sh/hook": post-install +`, + }, { + name: []string{"third"}, + path: "three", + kind: []string{"ReplicaSet"}, + hooks: map[string][]release.HookEvent{"third": nil}, + manifest: `kind: ReplicaSet +apiVersion: v1beta1 +metadata: + name: third + annotations: + "helm.sh/hook": no-such-hook +`, + }, { + name: []string{"fourth"}, + path: "four", + kind: []string{"Pod"}, + hooks: map[string][]release.HookEvent{"fourth": nil}, + manifest: `kind: Pod +apiVersion: v1 +metadata: + name: fourth + annotations: + nothing: here`, + }, { + name: []string{"fifth"}, + path: "five", + kind: []string{"ReplicaSet"}, + hooks: map[string][]release.HookEvent{"fifth": {release.HookPostDelete, release.HookPostInstall}}, + manifest: `kind: ReplicaSet +apiVersion: v1beta1 +metadata: + name: fifth + annotations: + "helm.sh/hook": post-delete, post-install +`, + }, { + // Regression test: files with an underscore in the base name should be skipped. + name: []string{"sixth"}, + path: "six/_six", + kind: []string{"ReplicaSet"}, + hooks: map[string][]release.HookEvent{"sixth": nil}, + manifest: `invalid manifest`, // This will fail if partial is not skipped. + }, { + // Regression test: files with no content should be skipped. + name: []string{"seventh"}, + path: "seven", + kind: []string{"ReplicaSet"}, + hooks: map[string][]release.HookEvent{"seventh": nil}, + manifest: "", + }, + { + name: []string{"eighth", "example-test"}, + path: "eight", + kind: []string{"ConfigMap", "Pod"}, + hooks: map[string][]release.HookEvent{"eighth": nil, "example-test": {release.HookTest}}, + manifest: `kind: ConfigMap +apiVersion: v1 +metadata: + name: eighth +data: + name: value +--- +apiVersion: v1 +kind: Pod +metadata: + name: example-test + annotations: + "helm.sh/hook": test +`, + }, + } + + manifests := make(map[string]string, len(data)) + for _, o := range data { + manifests[o.path] = o.manifest + } + + hs, generic, err := SortManifests(manifests, chartutil.VersionSet{"v1", "v1beta1"}, InstallOrder) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + // This test will fail if 'six' or 'seven' was added. + if len(generic) != 2 { + t.Errorf("Expected 2 generic manifests, got %d", len(generic)) + } + + if len(hs) != 4 { + t.Errorf("Expected 4 hooks, got %d", len(hs)) + } + + for _, out := range hs { + found := false + for _, expect := range data { + if out.Path == expect.path { + found = true + if out.Path != expect.path { + t.Errorf("Expected path %s, got %s", expect.path, out.Path) + } + nameFound := false + for _, expectedName := range expect.name { + if out.Name == expectedName { + nameFound = true + } + } + if !nameFound { + t.Errorf("Got unexpected name %s", out.Name) + } + kindFound := false + for _, expectedKind := range expect.kind { + if out.Kind == expectedKind { + kindFound = true + } + } + if !kindFound { + t.Errorf("Got unexpected kind %s", out.Kind) + } + + expectedHooks := expect.hooks[out.Name] + if !reflect.DeepEqual(expectedHooks, out.Events) { + t.Errorf("expected events: %v but got: %v", expectedHooks, out.Events) + } + + } + } + if !found { + t.Errorf("Result not found: %v", out) + } + } + + // Verify the sort order + sorted := []Manifest{} + for _, s := range data { + manifests := SplitManifests(s.manifest) + + for _, m := range manifests { + var sh SimpleHead + if err := yaml.Unmarshal([]byte(m), &sh); err != nil { + // This is expected for manifests that are corrupt or empty. + t.Log(err) + continue + } + + name := sh.Metadata.Name + + // only keep track of non-hook manifests + if s.hooks[name] == nil { + another := Manifest{ + Content: m, + Name: name, + Head: &sh, + } + sorted = append(sorted, another) + } + } + } + + sorted = sortManifestsByKind(sorted, InstallOrder) + for i, m := range generic { + if m.Content != sorted[i].Content { + t.Errorf("Expected %q, got %q", m.Content, sorted[i].Content) + } + } +} diff --git a/pkg/releaseutil/manifest_test.go b/pkg/releaseutil/manifest_test.go new file mode 100644 index 00000000..8664d20e --- /dev/null +++ b/pkg/releaseutil/manifest_test.go @@ -0,0 +1,61 @@ +/* +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 releaseutil // import "helm.sh/helm/v3/pkg/releaseutil" + +import ( + "reflect" + "testing" +) + +const mockManifestFile = ` + +--- +apiVersion: v1 +kind: Pod +metadata: + name: finding-nemo, + annotations: + "helm.sh/hook": test +spec: + containers: + - name: nemo-test + image: fake-image + cmd: fake-command +` + +const expectedManifest = `apiVersion: v1 +kind: Pod +metadata: + name: finding-nemo, + annotations: + "helm.sh/hook": test +spec: + containers: + - name: nemo-test + image: fake-image + cmd: fake-command` + +func TestSplitManifest(t *testing.T) { + manifests := SplitManifests(mockManifestFile) + if len(manifests) != 1 { + t.Errorf("Expected 1 manifest, got %v", len(manifests)) + } + expected := map[string]string{"manifest-0": expectedManifest} + if !reflect.DeepEqual(manifests, expected) { + t.Errorf("Expected %v, got %v", expected, manifests) + } +} diff --git a/pkg/releaseutil/sorter.go b/pkg/releaseutil/sorter.go new file mode 100644 index 00000000..1a8aa78a --- /dev/null +++ b/pkg/releaseutil/sorter.go @@ -0,0 +1,78 @@ +/* +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 releaseutil // import "helm.sh/helm/v3/pkg/releaseutil" + +import ( + "sort" + + rspb "helm.sh/helm/v3/pkg/release" +) + +type list []*rspb.Release + +func (s list) Len() int { return len(s) } +func (s list) Swap(i, j int) { s[i], s[j] = s[j], s[i] } + +// ByName sorts releases by name +type ByName struct{ list } + +// Less compares to releases +func (s ByName) Less(i, j int) bool { return s.list[i].Name < s.list[j].Name } + +// ByDate sorts releases by date +type ByDate struct{ list } + +// Less compares to releases +func (s ByDate) Less(i, j int) bool { + ti := s.list[i].Info.LastDeployed.Unix() + tj := s.list[j].Info.LastDeployed.Unix() + return ti < tj +} + +// ByRevision sorts releases by revision number +type ByRevision struct{ list } + +// Less compares to releases +func (s ByRevision) Less(i, j int) bool { + return s.list[i].Version < s.list[j].Version +} + +// Reverse reverses the list of releases sorted by the sort func. +func Reverse(list []*rspb.Release, sortFn func([]*rspb.Release)) { + sortFn(list) + for i, j := 0, len(list)-1; i < j; i, j = i+1, j-1 { + list[i], list[j] = list[j], list[i] + } +} + +// SortByName returns the list of releases sorted +// in lexicographical order. +func SortByName(list []*rspb.Release) { + sort.Sort(ByName{list}) +} + +// SortByDate returns the list of releases sorted by a +// release's last deployed time (in seconds). +func SortByDate(list []*rspb.Release) { + sort.Sort(ByDate{list}) +} + +// SortByRevision returns the list of releases sorted by a +// release's revision number (release.Version). +func SortByRevision(list []*rspb.Release) { + sort.Sort(ByRevision{list}) +} diff --git a/pkg/releaseutil/sorter_test.go b/pkg/releaseutil/sorter_test.go new file mode 100644 index 00000000..9544d201 --- /dev/null +++ b/pkg/releaseutil/sorter_test.go @@ -0,0 +1,108 @@ +/* +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 releaseutil // import "helm.sh/helm/v3/pkg/releaseutil" + +import ( + "testing" + "time" + + rspb "helm.sh/helm/v3/pkg/release" + helmtime "helm.sh/helm/v3/pkg/time" +) + +// note: this test data is shared with filter_test.go. + +var releases = []*rspb.Release{ + tsRelease("quiet-bear", 2, 2000, rspb.StatusSuperseded), + tsRelease("angry-bird", 4, 3000, rspb.StatusDeployed), + tsRelease("happy-cats", 1, 4000, rspb.StatusUninstalled), + tsRelease("vocal-dogs", 3, 6000, rspb.StatusUninstalled), +} + +func tsRelease(name string, vers int, dur time.Duration, status rspb.Status) *rspb.Release { + info := &rspb.Info{Status: status, LastDeployed: helmtime.Now().Add(dur)} + return &rspb.Release{ + Name: name, + Version: vers, + Info: info, + } +} + +func check(t *testing.T, by string, fn func(int, int) bool) { + for i := len(releases) - 1; i > 0; i-- { + if fn(i, i-1) { + t.Errorf("release at positions '(%d,%d)' not sorted by %s", i-1, i, by) + } + } +} + +func TestSortByName(t *testing.T) { + SortByName(releases) + + check(t, "ByName", func(i, j int) bool { + ni := releases[i].Name + nj := releases[j].Name + return ni < nj + }) +} + +func TestSortByDate(t *testing.T) { + SortByDate(releases) + + check(t, "ByDate", func(i, j int) bool { + ti := releases[i].Info.LastDeployed.Second() + tj := releases[j].Info.LastDeployed.Second() + return ti < tj + }) +} + +func TestSortByRevision(t *testing.T) { + SortByRevision(releases) + + check(t, "ByRevision", func(i, j int) bool { + vi := releases[i].Version + vj := releases[j].Version + return vi < vj + }) +} + +func TestReverseSortByName(t *testing.T) { + Reverse(releases, SortByName) + check(t, "ByName", func(i, j int) bool { + ni := releases[i].Name + nj := releases[j].Name + return ni > nj + }) +} + +func TestReverseSortByDate(t *testing.T) { + Reverse(releases, SortByDate) + check(t, "ByDate", func(i, j int) bool { + ti := releases[i].Info.LastDeployed.Second() + tj := releases[j].Info.LastDeployed.Second() + return ti > tj + }) +} + +func TestReverseSortByRevision(t *testing.T) { + Reverse(releases, SortByRevision) + check(t, "ByRevision", func(i, j int) bool { + vi := releases[i].Version + vj := releases[j].Version + return vi > vj + }) +} diff --git a/pkg/repo/chartrepo.go b/pkg/repo/chartrepo.go new file mode 100644 index 00000000..d9022ee6 --- /dev/null +++ b/pkg/repo/chartrepo.go @@ -0,0 +1,317 @@ +/* +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 repo // import "helm.sh/helm/v3/pkg/repo" + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/pkg/errors" + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/helmpath" + "helm.sh/helm/v3/pkg/provenance" +) + +// Entry represents a collection of parameters for chart repository +type Entry struct { + Name string `json:"name"` + URL string `json:"url"` + Username string `json:"username"` + Password string `json:"password"` + CertFile string `json:"certFile"` + KeyFile string `json:"keyFile"` + CAFile string `json:"caFile"` + InsecureSkipTLSverify bool `json:"insecure_skip_tls_verify"` + PassCredentialsAll bool `json:"pass_credentials_all"` +} + +// ChartRepository represents a chart repository +type ChartRepository struct { + Config *Entry + ChartPaths []string + IndexFile *IndexFile + Client getter.Getter + CachePath string +} + +// NewChartRepository constructs ChartRepository +func NewChartRepository(cfg *Entry, getters getter.Providers) (*ChartRepository, error) { + u, err := url.Parse(cfg.URL) + if err != nil { + return nil, errors.Errorf("invalid chart URL format: %s", cfg.URL) + } + + client, err := getters.ByScheme(u.Scheme) + if err != nil { + return nil, errors.Errorf("could not find protocol handler for: %s", u.Scheme) + } + + return &ChartRepository{ + Config: cfg, + IndexFile: NewIndexFile(), + Client: client, + CachePath: helmpath.CachePath("repository"), + }, nil +} + +// Load loads a directory of charts as if it were a repository. +// +// It requires the presence of an index.yaml file in the directory. +// +// Deprecated: remove in Helm 4. +func (r *ChartRepository) Load() error { + dirInfo, err := os.Stat(r.Config.Name) + if err != nil { + return err + } + if !dirInfo.IsDir() { + return errors.Errorf("%q is not a directory", r.Config.Name) + } + + // FIXME: Why are we recursively walking directories? + // FIXME: Why are we not reading the repositories.yaml to figure out + // what repos to use? + filepath.Walk(r.Config.Name, func(path string, f os.FileInfo, err error) error { + if !f.IsDir() { + if strings.Contains(f.Name(), "-index.yaml") { + i, err := LoadIndexFile(path) + if err != nil { + return err + } + r.IndexFile = i + } else if strings.HasSuffix(f.Name(), ".tgz") { + r.ChartPaths = append(r.ChartPaths, path) + } + } + return nil + }) + return nil +} + +// DownloadIndexFile fetches the index from a repository. +func (r *ChartRepository) DownloadIndexFile() (string, error) { + indexURL, err := ResolveReferenceURL(r.Config.URL, "index.yaml") + if err != nil { + return "", err + } + + resp, err := r.Client.Get(indexURL, + getter.WithURL(r.Config.URL), + getter.WithInsecureSkipVerifyTLS(r.Config.InsecureSkipTLSverify), + getter.WithTLSClientConfig(r.Config.CertFile, r.Config.KeyFile, r.Config.CAFile), + getter.WithBasicAuth(r.Config.Username, r.Config.Password), + getter.WithPassCredentialsAll(r.Config.PassCredentialsAll), + ) + if err != nil { + return "", err + } + + index, err := io.ReadAll(resp) + if err != nil { + return "", err + } + + indexFile, err := loadIndex(index, r.Config.URL) + if err != nil { + return "", err + } + + // Create the chart list file in the cache directory + var charts strings.Builder + for name := range indexFile.Entries { + fmt.Fprintln(&charts, name) + } + chartsFile := filepath.Join(r.CachePath, helmpath.CacheChartsFile(r.Config.Name)) + os.MkdirAll(filepath.Dir(chartsFile), 0755) + os.WriteFile(chartsFile, []byte(charts.String()), 0644) + + // Create the index file in the cache directory + fname := filepath.Join(r.CachePath, helmpath.CacheIndexFile(r.Config.Name)) + os.MkdirAll(filepath.Dir(fname), 0755) + return fname, os.WriteFile(fname, index, 0644) +} + +// Index generates an index for the chart repository and writes an index.yaml file. +func (r *ChartRepository) Index() error { + err := r.generateIndex() + if err != nil { + return err + } + return r.saveIndexFile() +} + +func (r *ChartRepository) saveIndexFile() error { + index, err := yaml.Marshal(r.IndexFile) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(r.Config.Name, indexPath), index, 0644) +} + +func (r *ChartRepository) generateIndex() error { + for _, path := range r.ChartPaths { + ch, err := loader.Load(path) + if err != nil { + return err + } + + digest, err := provenance.DigestFile(path) + if err != nil { + return err + } + + if !r.IndexFile.Has(ch.Name(), ch.Metadata.Version) { + if err := r.IndexFile.MustAdd(ch.Metadata, path, r.Config.URL, digest); err != nil { + return errors.Wrapf(err, "failed adding to %s to index", path) + } + } + // TODO: If a chart exists, but has a different Digest, should we error? + } + r.IndexFile.SortEntries() + return nil +} + +// FindChartInRepoURL finds chart in chart repository pointed by repoURL +// without adding repo to repositories +func FindChartInRepoURL(repoURL, chartName, chartVersion, certFile, keyFile, caFile string, getters getter.Providers) (string, error) { + return FindChartInAuthRepoURL(repoURL, "", "", chartName, chartVersion, certFile, keyFile, caFile, getters) +} + +// FindChartInAuthRepoURL finds chart in chart repository pointed by repoURL +// without adding repo to repositories, like FindChartInRepoURL, +// but it also receives credentials for the chart repository. +func FindChartInAuthRepoURL(repoURL, username, password, chartName, chartVersion, certFile, keyFile, caFile string, getters getter.Providers) (string, error) { + return FindChartInAuthAndTLSRepoURL(repoURL, username, password, chartName, chartVersion, certFile, keyFile, caFile, false, getters) +} + +// FindChartInAuthAndTLSRepoURL finds chart in chart repository pointed by repoURL +// without adding repo to repositories, like FindChartInRepoURL, +// but it also receives credentials and TLS verify flag for the chart repository. +// TODO Helm 4, FindChartInAuthAndTLSRepoURL should be integrated into FindChartInAuthRepoURL. +func FindChartInAuthAndTLSRepoURL(repoURL, username, password, chartName, chartVersion, certFile, keyFile, caFile string, insecureSkipTLSverify bool, getters getter.Providers) (string, error) { + return FindChartInAuthAndTLSAndPassRepoURL(repoURL, username, password, chartName, chartVersion, certFile, keyFile, caFile, insecureSkipTLSverify, false, getters) +} + +// FindChartInAuthAndTLSAndPassRepoURL finds chart in chart repository pointed by repoURL +// without adding repo to repositories, like FindChartInRepoURL, +// but it also receives credentials, TLS verify flag, and if credentials should +// be passed on to other domains. +// TODO Helm 4, FindChartInAuthAndTLSAndPassRepoURL should be integrated into FindChartInAuthRepoURL. +func FindChartInAuthAndTLSAndPassRepoURL(repoURL, username, password, chartName, chartVersion, certFile, keyFile, caFile string, insecureSkipTLSverify, passCredentialsAll bool, getters getter.Providers) (string, error) { + + // Download and write the index file to a temporary location + buf := make([]byte, 20) + rand.Read(buf) + name := strings.ReplaceAll(base64.StdEncoding.EncodeToString(buf), "/", "-") + + c := Entry{ + URL: repoURL, + Username: username, + Password: password, + PassCredentialsAll: passCredentialsAll, + CertFile: certFile, + KeyFile: keyFile, + CAFile: caFile, + Name: name, + InsecureSkipTLSverify: insecureSkipTLSverify, + } + r, err := NewChartRepository(&c, getters) + if err != nil { + return "", err + } + idx, err := r.DownloadIndexFile() + if err != nil { + return "", errors.Wrapf(err, "looks like %q is not a valid chart repository or cannot be reached", repoURL) + } + defer func() { + os.RemoveAll(filepath.Join(r.CachePath, helmpath.CacheChartsFile(r.Config.Name))) + os.RemoveAll(filepath.Join(r.CachePath, helmpath.CacheIndexFile(r.Config.Name))) + }() + + // Read the index file for the repository to get chart information and return chart URL + repoIndex, err := LoadIndexFile(idx) + if err != nil { + return "", err + } + + errMsg := fmt.Sprintf("chart %q", chartName) + if chartVersion != "" { + errMsg = fmt.Sprintf("%s version %q", errMsg, chartVersion) + } + cv, err := repoIndex.Get(chartName, chartVersion) + if err != nil { + return "", errors.Errorf("%s not found in %s repository", errMsg, repoURL) + } + + if len(cv.URLs) == 0 { + return "", errors.Errorf("%s has no downloadable URLs", errMsg) + } + + chartURL := cv.URLs[0] + + absoluteChartURL, err := ResolveReferenceURL(repoURL, chartURL) + if err != nil { + return "", errors.Wrap(err, "failed to make chart URL absolute") + } + + return absoluteChartURL, nil +} + +// ResolveReferenceURL resolves refURL relative to baseURL. +// If refURL is absolute, it simply returns refURL. +func ResolveReferenceURL(baseURL, refURL string) (string, error) { + parsedRefURL, err := url.Parse(refURL) + if err != nil { + return "", errors.Wrapf(err, "failed to parse %s as URL", refURL) + } + + if parsedRefURL.IsAbs() { + return refURL, nil + } + + parsedBaseURL, err := url.Parse(baseURL) + if err != nil { + return "", errors.Wrapf(err, "failed to parse %s as URL", baseURL) + } + + // We need a trailing slash for ResolveReference to work, but make sure there isn't already one + parsedBaseURL.RawPath = strings.TrimSuffix(parsedBaseURL.RawPath, "/") + "/" + parsedBaseURL.Path = strings.TrimSuffix(parsedBaseURL.Path, "/") + "/" + + resolvedURL := parsedBaseURL.ResolveReference(parsedRefURL) + resolvedURL.RawQuery = parsedBaseURL.RawQuery + return resolvedURL.String(), nil +} + +func (e *Entry) String() string { + buf, err := json.Marshal(e) + if err != nil { + log.Panic(err) + } + return string(buf) +} diff --git a/pkg/repo/chartrepo_test.go b/pkg/repo/chartrepo_test.go new file mode 100644 index 00000000..343d5852 --- /dev/null +++ b/pkg/repo/chartrepo_test.go @@ -0,0 +1,402 @@ +/* +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 repo + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + "time" + + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/getter" +) + +const ( + testRepository = "testdata/repository" + testURL = "http://example-charts.com" +) + +func TestLoadChartRepository(t *testing.T) { + r, err := NewChartRepository(&Entry{ + Name: testRepository, + URL: testURL, + }, getter.All(&cli.EnvSettings{})) + if err != nil { + t.Errorf("Problem creating chart repository from %s: %v", testRepository, err) + } + + if err := r.Load(); err != nil { + t.Errorf("Problem loading chart repository from %s: %v", testRepository, err) + } + + paths := []string{ + filepath.Join(testRepository, "frobnitz-1.2.3.tgz"), + filepath.Join(testRepository, "sprocket-1.1.0.tgz"), + filepath.Join(testRepository, "sprocket-1.2.0.tgz"), + filepath.Join(testRepository, "universe/zarthal-1.0.0.tgz"), + } + + if r.Config.Name != testRepository { + t.Errorf("Expected %s as Name but got %s", testRepository, r.Config.Name) + } + + if !reflect.DeepEqual(r.ChartPaths, paths) { + t.Errorf("Expected %#v but got %#v\n", paths, r.ChartPaths) + } + + if r.Config.URL != testURL { + t.Errorf("Expected url for chart repository to be %s but got %s", testURL, r.Config.URL) + } +} + +func TestIndex(t *testing.T) { + r, err := NewChartRepository(&Entry{ + Name: testRepository, + URL: testURL, + }, getter.All(&cli.EnvSettings{})) + if err != nil { + t.Errorf("Problem creating chart repository from %s: %v", testRepository, err) + } + + if err := r.Load(); err != nil { + t.Errorf("Problem loading chart repository from %s: %v", testRepository, err) + } + + err = r.Index() + if err != nil { + t.Errorf("Error performing index: %v\n", err) + } + + tempIndexPath := filepath.Join(testRepository, indexPath) + actual, err := LoadIndexFile(tempIndexPath) + defer os.Remove(tempIndexPath) // clean up + if err != nil { + t.Errorf("Error loading index file %v", err) + } + verifyIndex(t, actual) + + // Re-index and test again. + err = r.Index() + if err != nil { + t.Errorf("Error performing re-index: %s\n", err) + } + second, err := LoadIndexFile(tempIndexPath) + if err != nil { + t.Errorf("Error re-loading index file %v", err) + } + verifyIndex(t, second) +} + +type CustomGetter struct { + repoUrls []string +} + +func (g *CustomGetter) Get(href string, _ ...getter.Option) (*bytes.Buffer, error) { + index := &IndexFile{ + APIVersion: "v1", + Generated: time.Now(), + } + indexBytes, err := yaml.Marshal(index) + if err != nil { + return nil, err + } + g.repoUrls = append(g.repoUrls, href) + return bytes.NewBuffer(indexBytes), nil +} + +func TestIndexCustomSchemeDownload(t *testing.T) { + repoName := "gcs-repo" + repoURL := "gs://some-gcs-bucket" + myCustomGetter := &CustomGetter{} + customGetterConstructor := func(options ...getter.Option) (getter.Getter, error) { + return myCustomGetter, nil + } + providers := getter.Providers{{ + Schemes: []string{"gs"}, + New: customGetterConstructor, + }} + repo, err := NewChartRepository(&Entry{ + Name: repoName, + URL: repoURL, + }, providers) + if err != nil { + t.Fatalf("Problem loading chart repository from %s: %v", repoURL, err) + } + repo.CachePath = t.TempDir() + + tempIndexFile, err := os.CreateTemp("", "test-repo") + if err != nil { + t.Fatalf("Failed to create temp index file: %v", err) + } + defer os.Remove(tempIndexFile.Name()) + + idx, err := repo.DownloadIndexFile() + if err != nil { + t.Fatalf("Failed to download index file to %s: %v", idx, err) + } + + if len(myCustomGetter.repoUrls) != 1 { + t.Fatalf("Custom Getter.Get should be called once") + } + + expectedRepoIndexURL := repoURL + "/index.yaml" + if myCustomGetter.repoUrls[0] != expectedRepoIndexURL { + t.Fatalf("Custom Getter.Get should be called with %s", expectedRepoIndexURL) + } +} + +func verifyIndex(t *testing.T, actual *IndexFile) { + var empty time.Time + if actual.Generated.Equal(empty) { + t.Errorf("Generated should be greater than 0: %s", actual.Generated) + } + + if actual.APIVersion != APIVersionV1 { + t.Error("Expected v1 API") + } + + entries := actual.Entries + if numEntries := len(entries); numEntries != 3 { + t.Errorf("Expected 3 charts to be listed in index file but got %v", numEntries) + } + + expects := map[string]ChartVersions{ + "frobnitz": { + { + Metadata: &chart.Metadata{ + Name: "frobnitz", + Version: "1.2.3", + }, + }, + }, + "sprocket": { + { + Metadata: &chart.Metadata{ + Name: "sprocket", + Version: "1.2.0", + }, + }, + { + Metadata: &chart.Metadata{ + Name: "sprocket", + Version: "1.1.0", + }, + }, + }, + "zarthal": { + { + Metadata: &chart.Metadata{ + Name: "zarthal", + Version: "1.0.0", + }, + }, + }, + } + + for name, versions := range expects { + got, ok := entries[name] + if !ok { + t.Errorf("Could not find %q entry", name) + continue + } + if len(versions) != len(got) { + t.Errorf("Expected %d versions, got %d", len(versions), len(got)) + continue + } + for i, e := range versions { + g := got[i] + if e.Name != g.Name { + t.Errorf("Expected %q, got %q", e.Name, g.Name) + } + if e.Version != g.Version { + t.Errorf("Expected %q, got %q", e.Version, g.Version) + } + if len(g.Keywords) != 3 { + t.Error("Expected 3 keywords.") + } + if len(g.Maintainers) != 2 { + t.Error("Expected 2 maintainers.") + } + if g.Created.Equal(empty) { + t.Error("Expected created to be non-empty") + } + if g.Description == "" { + t.Error("Expected description to be non-empty") + } + if g.Home == "" { + t.Error("Expected home to be non-empty") + } + if g.Digest == "" { + t.Error("Expected digest to be non-empty") + } + if len(g.URLs) != 1 { + t.Error("Expected exactly 1 URL") + } + } + } +} + +// startLocalServerForTests Start the local helm server +func startLocalServerForTests(handler http.Handler) (*httptest.Server, error) { + if handler == nil { + fileBytes, err := os.ReadFile("testdata/local-index.yaml") + if err != nil { + return nil, err + } + handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(fileBytes) + }) + } + + return httptest.NewServer(handler), nil +} + +// startLocalTLSServerForTests Start the local helm server with TLS +func startLocalTLSServerForTests(handler http.Handler) (*httptest.Server, error) { + if handler == nil { + fileBytes, err := os.ReadFile("testdata/local-index.yaml") + if err != nil { + return nil, err + } + handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(fileBytes) + }) + } + + return httptest.NewTLSServer(handler), nil +} + +func TestFindChartInAuthAndTLSAndPassRepoURL(t *testing.T) { + srv, err := startLocalTLSServerForTests(nil) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + + chartURL, err := FindChartInAuthAndTLSAndPassRepoURL(srv.URL, "", "", "nginx", "", "", "", "", true, false, getter.All(&cli.EnvSettings{})) + if err != nil { + t.Fatalf("%v", err) + } + if chartURL != "https://charts.helm.sh/stable/nginx-0.2.0.tgz" { + t.Errorf("%s is not the valid URL", chartURL) + } + + // If the insecureSkipTLsverify is false, it will return an error that contains "x509: certificate signed by unknown authority". + _, err = FindChartInAuthAndTLSAndPassRepoURL(srv.URL, "", "", "nginx", "0.1.0", "", "", "", false, false, getter.All(&cli.EnvSettings{})) + // Go communicates with the platform and different platforms return different messages. Go itself tests darwin + // differently for its message. On newer versions of Darwin the message includes the "Acme Co" portion while older + // versions of Darwin do not. As there are people developing Helm using both old and new versions of Darwin we test + // for both messages. + if runtime.GOOS == "darwin" { + if !strings.Contains(err.Error(), "x509: “Acme Co” certificate is not trusted") && !strings.Contains(err.Error(), "x509: certificate signed by unknown authority") { + t.Errorf("Expected TLS error for function FindChartInAuthAndTLSAndPassRepoURL not found, but got a different error (%v)", err) + } + } else if !strings.Contains(err.Error(), "x509: certificate signed by unknown authority") { + t.Errorf("Expected TLS error for function FindChartInAuthAndTLSAndPassRepoURL not found, but got a different error (%v)", err) + } +} + +func TestFindChartInRepoURL(t *testing.T) { + srv, err := startLocalServerForTests(nil) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + + chartURL, err := FindChartInRepoURL(srv.URL, "nginx", "", "", "", "", getter.All(&cli.EnvSettings{})) + if err != nil { + t.Fatalf("%v", err) + } + if chartURL != "https://charts.helm.sh/stable/nginx-0.2.0.tgz" { + t.Errorf("%s is not the valid URL", chartURL) + } + + chartURL, err = FindChartInRepoURL(srv.URL, "nginx", "0.1.0", "", "", "", getter.All(&cli.EnvSettings{})) + if err != nil { + t.Errorf("%s", err) + } + if chartURL != "https://charts.helm.sh/stable/nginx-0.1.0.tgz" { + t.Errorf("%s is not the valid URL", chartURL) + } +} + +func TestErrorFindChartInRepoURL(t *testing.T) { + + g := getter.All(&cli.EnvSettings{ + RepositoryCache: t.TempDir(), + }) + + if _, err := FindChartInRepoURL("http://someserver/something", "nginx", "", "", "", "", g); err == nil { + t.Errorf("Expected error for bad chart URL, but did not get any errors") + } else if !strings.Contains(err.Error(), `looks like "http://someserver/something" is not a valid chart repository or cannot be reached`) { + t.Errorf("Expected error for bad chart URL, but got a different error (%v)", err) + } + + srv, err := startLocalServerForTests(nil) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + + if _, err = FindChartInRepoURL(srv.URL, "nginx1", "", "", "", "", g); err == nil { + t.Errorf("Expected error for chart not found, but did not get any errors") + } else if err.Error() != `chart "nginx1" not found in `+srv.URL+` repository` { + t.Errorf("Expected error for chart not found, but got a different error (%v)", err) + } + + if _, err = FindChartInRepoURL(srv.URL, "nginx1", "0.1.0", "", "", "", g); err == nil { + t.Errorf("Expected error for chart not found, but did not get any errors") + } else if err.Error() != `chart "nginx1" version "0.1.0" not found in `+srv.URL+` repository` { + t.Errorf("Expected error for chart not found, but got a different error (%v)", err) + } + + if _, err = FindChartInRepoURL(srv.URL, "chartWithNoURL", "", "", "", "", g); err == nil { + t.Errorf("Expected error for no chart URLs available, but did not get any errors") + } else if err.Error() != `chart "chartWithNoURL" has no downloadable URLs` { + t.Errorf("Expected error for chart not found, but got a different error (%v)", err) + } +} + +func TestResolveReferenceURL(t *testing.T) { + for _, tt := range []struct { + baseURL, refURL, chartURL string + }{ + {"http://localhost:8123/charts/", "nginx-0.2.0.tgz", "http://localhost:8123/charts/nginx-0.2.0.tgz"}, + {"http://localhost:8123/charts-with-no-trailing-slash", "nginx-0.2.0.tgz", "http://localhost:8123/charts-with-no-trailing-slash/nginx-0.2.0.tgz"}, + {"http://localhost:8123", "https://charts.helm.sh/stable/nginx-0.2.0.tgz", "https://charts.helm.sh/stable/nginx-0.2.0.tgz"}, + {"http://localhost:8123/charts%2fwith%2fescaped%2fslash", "nginx-0.2.0.tgz", "http://localhost:8123/charts%2fwith%2fescaped%2fslash/nginx-0.2.0.tgz"}, + {"http://localhost:8123/charts?with=queryparameter", "nginx-0.2.0.tgz", "http://localhost:8123/charts/nginx-0.2.0.tgz?with=queryparameter"}, + } { + chartURL, err := ResolveReferenceURL(tt.baseURL, tt.refURL) + if err != nil { + t.Errorf("unexpected error in ResolveReferenceURL(%q, %q): %s", tt.baseURL, tt.refURL, err) + } + if chartURL != tt.chartURL { + t.Errorf("expected ResolveReferenceURL(%q, %q) to equal %q, got %q", tt.baseURL, tt.refURL, tt.chartURL, chartURL) + } + } +} diff --git a/pkg/repo/doc.go b/pkg/repo/doc.go new file mode 100644 index 00000000..fc54bbf7 --- /dev/null +++ b/pkg/repo/doc.go @@ -0,0 +1,94 @@ +/* +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 repo implements the Helm Chart Repository. + +A chart repository is an HTTP server that provides information on charts. A local +repository cache is an on-disk representation of a chart repository. + +There are two important file formats for chart repositories. + +The first is the 'index.yaml' format, which is expressed like this: + + apiVersion: v1 + entries: + frobnitz: + - created: 2016-09-29T12:14:34.830161306-06:00 + description: This is a frobnitz. + digest: 587bd19a9bd9d2bc4a6d25ab91c8c8e7042c47b4ac246e37bf8e1e74386190f4 + home: http://example.com + keywords: + - frobnitz + - sprocket + - dodad + maintainers: + - email: helm@example.com + name: The Helm Team + - email: nobody@example.com + name: Someone Else + name: frobnitz + urls: + - http://example-charts.com/testdata/repository/frobnitz-1.2.3.tgz + version: 1.2.3 + sprocket: + - created: 2016-09-29T12:14:34.830507606-06:00 + description: This is a sprocket" + digest: 8505ff813c39502cc849a38e1e4a8ac24b8e6e1dcea88f4c34ad9b7439685ae6 + home: http://example.com + keywords: + - frobnitz + - sprocket + - dodad + maintainers: + - email: helm@example.com + name: The Helm Team + - email: nobody@example.com + name: Someone Else + name: sprocket + urls: + - http://example-charts.com/testdata/repository/sprocket-1.2.0.tgz + version: 1.2.0 + generated: 2016-09-29T12:14:34.829721375-06:00 + +An index.yaml file contains the necessary descriptive information about what +charts are available in a repository, and how to get them. + +The second file format is the repositories.yaml file format. This file is for +facilitating local cached copies of one or more chart repositories. + +The format of a repository.yaml file is: + + apiVersion: v1 + generated: TIMESTAMP + repositories: + - name: stable + url: http://example.com/charts + cache: stable-index.yaml + - name: incubator + url: http://example.com/incubator + cache: incubator-index.yaml + +This file maps three bits of information about a repository: + + - The name the user uses to refer to it + - The fully qualified URL to the repository (index.yaml will be appended) + - The name of the local cachefile + +The format for both files was changed after Helm v2.0.0-Alpha.4. Helm is not +backwards compatible with those earlier versions. +*/ +package repo diff --git a/pkg/repo/index.go b/pkg/repo/index.go new file mode 100644 index 00000000..40b11c5c --- /dev/null +++ b/pkg/repo/index.go @@ -0,0 +1,414 @@ +/* +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 repo + +import ( + "bytes" + "encoding/json" + "log" + "os" + "path" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/pkg/errors" + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/internal/fileutil" + "helm.sh/helm/v3/internal/urlutil" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/provenance" +) + +var indexPath = "index.yaml" + +// APIVersionV1 is the v1 API version for index and repository files. +const APIVersionV1 = "v1" + +var ( + // ErrNoAPIVersion indicates that an API version was not specified. + ErrNoAPIVersion = errors.New("no API version specified") + // ErrNoChartVersion indicates that a chart with the given version is not found. + ErrNoChartVersion = errors.New("no chart version found") + // ErrNoChartName indicates that a chart with the given name is not found. + ErrNoChartName = errors.New("no chart name found") + // ErrEmptyIndexYaml indicates that the content of index.yaml is empty. + ErrEmptyIndexYaml = errors.New("empty index.yaml file") +) + +// ChartVersions is a list of versioned chart references. +// Implements a sorter on Version. +type ChartVersions []*ChartVersion + +// Len returns the length. +func (c ChartVersions) Len() int { return len(c) } + +// Swap swaps the position of two items in the versions slice. +func (c ChartVersions) Swap(i, j int) { c[i], c[j] = c[j], c[i] } + +// Less returns true if the version of entry a is less than the version of entry b. +func (c ChartVersions) Less(a, b int) bool { + // Failed parse pushes to the back. + i, err := semver.NewVersion(c[a].Version) + if err != nil { + return true + } + j, err := semver.NewVersion(c[b].Version) + if err != nil { + return false + } + return i.LessThan(j) +} + +// IndexFile represents the index file in a chart repository +type IndexFile struct { + // This is used ONLY for validation against chartmuseum's index files and is discarded after validation. + ServerInfo map[string]interface{} `json:"serverInfo,omitempty"` + APIVersion string `json:"apiVersion"` + Generated time.Time `json:"generated"` + Entries map[string]ChartVersions `json:"entries"` + PublicKeys []string `json:"publicKeys,omitempty"` + + // Annotations are additional mappings uninterpreted by Helm. They are made available for + // other applications to add information to the index file. + Annotations map[string]string `json:"annotations,omitempty"` +} + +// NewIndexFile initializes an index. +func NewIndexFile() *IndexFile { + return &IndexFile{ + APIVersion: APIVersionV1, + Generated: time.Now(), + Entries: map[string]ChartVersions{}, + PublicKeys: []string{}, + } +} + +// LoadIndexFile takes a file at the given path and returns an IndexFile object +func LoadIndexFile(path string) (*IndexFile, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + i, err := loadIndex(b, path) + if err != nil { + return nil, errors.Wrapf(err, "error loading %s", path) + } + return i, nil +} + +// MustAdd adds a file to the index +// This can leave the index in an unsorted state +func (i IndexFile) MustAdd(md *chart.Metadata, filename, baseURL, digest string) error { + if i.Entries == nil { + return errors.New("entries not initialized") + } + + if md.APIVersion == "" { + md.APIVersion = chart.APIVersionV1 + } + if err := md.Validate(); err != nil { + return errors.Wrapf(err, "validate failed for %s", filename) + } + + u := filename + if baseURL != "" { + _, file := filepath.Split(filename) + var err error + u, err = urlutil.URLJoin(baseURL, file) + if err != nil { + u = path.Join(baseURL, file) + } + } + cr := &ChartVersion{ + URLs: []string{u}, + Metadata: md, + Digest: digest, + Created: time.Now(), + } + ee := i.Entries[md.Name] + i.Entries[md.Name] = append(ee, cr) + return nil +} + +// Add adds a file to the index and logs an error. +// +// Deprecated: Use index.MustAdd instead. +func (i IndexFile) Add(md *chart.Metadata, filename, baseURL, digest string) { + if err := i.MustAdd(md, filename, baseURL, digest); err != nil { + log.Printf("skipping loading invalid entry for chart %q %q from %s: %s", md.Name, md.Version, filename, err) + } +} + +// Has returns true if the index has an entry for a chart with the given name and exact version. +func (i IndexFile) Has(name, version string) bool { + _, err := i.Get(name, version) + return err == nil +} + +// SortEntries sorts the entries by version in descending order. +// +// In canonical form, the individual version records should be sorted so that +// the most recent release for every version is in the 0th slot in the +// Entries.ChartVersions array. That way, tooling can predict the newest +// version without needing to parse SemVers. +func (i IndexFile) SortEntries() { + for _, versions := range i.Entries { + sort.Sort(sort.Reverse(versions)) + } +} + +// Get returns the ChartVersion for the given name. +// +// If version is empty, this will return the chart with the latest stable version, +// prerelease versions will be skipped. +func (i IndexFile) Get(name, version string) (*ChartVersion, error) { + vs, ok := i.Entries[name] + if !ok { + return nil, ErrNoChartName + } + if len(vs) == 0 { + return nil, ErrNoChartVersion + } + + var constraint *semver.Constraints + if version == "" { + constraint, _ = semver.NewConstraint("*") + } else { + var err error + constraint, err = semver.NewConstraint(version) + if err != nil { + return nil, err + } + } + + // when customer input exact version, check whether have exact match one first + if len(version) != 0 { + for _, ver := range vs { + if version == ver.Version { + return ver, nil + } + } + } + + for _, ver := range vs { + test, err := semver.NewVersion(ver.Version) + if err != nil { + continue + } + + if constraint.Check(test) { + return ver, nil + } + } + return nil, errors.Errorf("no chart version found for %s-%s", name, version) +} + +// WriteFile writes an index file to the given destination path. +// +// The mode on the file is set to 'mode'. +func (i IndexFile) WriteFile(dest string, mode os.FileMode) error { + b, err := yaml.Marshal(i) + if err != nil { + return err + } + return fileutil.AtomicWriteFile(dest, bytes.NewReader(b), mode) +} + +// WriteJSONFile writes an index file in JSON format to the given destination +// path. +// +// The mode on the file is set to 'mode'. +func (i IndexFile) WriteJSONFile(dest string, mode os.FileMode) error { + b, err := json.MarshalIndent(i, "", " ") + if err != nil { + return err + } + return fileutil.AtomicWriteFile(dest, bytes.NewReader(b), mode) +} + +// Merge merges the given index file into this index. +// +// This merges by name and version. +// +// If one of the entries in the given index does _not_ already exist, it is added. +// In all other cases, the existing record is preserved. +// +// This can leave the index in an unsorted state +func (i *IndexFile) Merge(f *IndexFile) { + for _, cvs := range f.Entries { + for _, cv := range cvs { + if !i.Has(cv.Name, cv.Version) { + e := i.Entries[cv.Name] + i.Entries[cv.Name] = append(e, cv) + } + } + } +} + +// ChartVersion represents a chart entry in the IndexFile +type ChartVersion struct { + *chart.Metadata + URLs []string `json:"urls"` + Created time.Time `json:"created,omitempty"` + Removed bool `json:"removed,omitempty"` + Digest string `json:"digest,omitempty"` + + // ChecksumDeprecated is deprecated in Helm 3, and therefore ignored. Helm 3 replaced + // this with Digest. However, with a strict YAML parser enabled, a field must be + // present on the struct for backwards compatibility. + ChecksumDeprecated string `json:"checksum,omitempty"` + + // EngineDeprecated is deprecated in Helm 3, and therefore ignored. However, with a strict + // YAML parser enabled, this field must be present. + EngineDeprecated string `json:"engine,omitempty"` + + // TillerVersionDeprecated is deprecated in Helm 3, and therefore ignored. However, with a strict + // YAML parser enabled, this field must be present. + TillerVersionDeprecated string `json:"tillerVersion,omitempty"` + + // URLDeprecated is deprecated in Helm 3, superseded by URLs. It is ignored. However, + // with a strict YAML parser enabled, this must be present on the struct. + URLDeprecated string `json:"url,omitempty"` +} + +// IndexDirectory reads a (flat) directory and generates an index. +// +// It indexes only charts that have been packaged (*.tgz). +// +// The index returned will be in an unsorted state +func IndexDirectory(dir, baseURL string) (*IndexFile, error) { + archives, err := filepath.Glob(filepath.Join(dir, "*.tgz")) + if err != nil { + return nil, err + } + moreArchives, err := filepath.Glob(filepath.Join(dir, "**/*.tgz")) + if err != nil { + return nil, err + } + archives = append(archives, moreArchives...) + + index := NewIndexFile() + for _, arch := range archives { + fname, err := filepath.Rel(dir, arch) + if err != nil { + return index, err + } + + var parentDir string + parentDir, fname = filepath.Split(fname) + // filepath.Split appends an extra slash to the end of parentDir. We want to strip that out. + parentDir = strings.TrimSuffix(parentDir, string(os.PathSeparator)) + parentURL, err := urlutil.URLJoin(baseURL, parentDir) + if err != nil { + parentURL = path.Join(baseURL, parentDir) + } + + c, err := loader.Load(arch) + if err != nil { + // Assume this is not a chart. + continue + } + hash, err := provenance.DigestFile(arch) + if err != nil { + return index, err + } + if err := index.MustAdd(c.Metadata, fname, parentURL, hash); err != nil { + return index, errors.Wrapf(err, "failed adding to %s to index", fname) + } + } + return index, nil +} + +// loadIndex loads an index file and does minimal validity checking. +// +// The source parameter is only used for logging. +// This will fail if API Version is not set (ErrNoAPIVersion) or if the unmarshal fails. +func loadIndex(data []byte, source string) (*IndexFile, error) { + i := &IndexFile{} + + if len(data) == 0 { + return i, ErrEmptyIndexYaml + } + + if err := jsonOrYamlUnmarshal(data, i); err != nil { + return i, err + } + + for name, cvs := range i.Entries { + for idx := len(cvs) - 1; idx >= 0; idx-- { + if cvs[idx] == nil { + log.Printf("skipping loading invalid entry for chart %q from %s: empty entry", name, source) + continue + } + // When metadata section missing, initialize with no data + if cvs[idx].Metadata == nil { + cvs[idx].Metadata = &chart.Metadata{} + } + if cvs[idx].APIVersion == "" { + cvs[idx].APIVersion = chart.APIVersionV1 + } + if err := cvs[idx].Validate(); ignoreSkippableChartValidationError(err) != nil { + log.Printf("skipping loading invalid entry for chart %q %q from %s: %s", name, cvs[idx].Version, source, err) + cvs = append(cvs[:idx], cvs[idx+1:]...) + } + } + } + i.SortEntries() + if i.APIVersion == "" { + return i, ErrNoAPIVersion + } + return i, nil +} + +// jsonOrYamlUnmarshal unmarshals the given byte slice containing JSON or YAML +// into the provided interface. +// +// It automatically detects whether the data is in JSON or YAML format by +// checking its validity as JSON. If the data is valid JSON, it will use the +// `encoding/json` package to unmarshal it. Otherwise, it will use the +// `sigs.k8s.io/yaml` package to unmarshal the YAML data. +func jsonOrYamlUnmarshal(b []byte, i interface{}) error { + if json.Valid(b) { + return json.Unmarshal(b, i) + } + return yaml.UnmarshalStrict(b, i) +} + +// ignoreSkippableChartValidationError inspect the given error and returns nil if +// the error isn't important for index loading +// +// In particular, charts may introduce validations that don't impact repository indexes +// And repository indexes may be generated by older/non-complient software, which doesn't +// conform to all validations. +func ignoreSkippableChartValidationError(err error) error { + verr, ok := err.(chart.ValidationError) + if !ok { + return err + } + + // https://github.com/helm/helm/issues/12748 (JFrog repository strips alias field) + if strings.HasPrefix(verr.Error(), "validation: more than one dependency with name or alias") { + return nil + } + + return err +} diff --git a/pkg/repo/index_test.go b/pkg/repo/index_test.go new file mode 100644 index 00000000..91486670 --- /dev/null +++ b/pkg/repo/index_test.go @@ -0,0 +1,646 @@ +/* +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 repo + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/helmpath" +) + +const ( + testfile = "testdata/local-index.yaml" + annotationstestfile = "testdata/local-index-annotations.yaml" + chartmuseumtestfile = "testdata/chartmuseum-index.yaml" + unorderedTestfile = "testdata/local-index-unordered.yaml" + jsonTestfile = "testdata/local-index.json" + testRepo = "test-repo" + indexWithDuplicates = ` +apiVersion: v1 +entries: + nginx: + - urls: + - https://charts.helm.sh/stable/nginx-0.2.0.tgz + name: nginx + description: string + version: 0.2.0 + home: https://github.com/something/else + digest: "sha256:1234567890abcdef" + nginx: + - urls: + - https://charts.helm.sh/stable/alpine-1.0.0.tgz + - http://storage2.googleapis.com/kubernetes-charts/alpine-1.0.0.tgz + name: alpine + description: string + version: 1.0.0 + home: https://github.com/something + digest: "sha256:1234567890abcdef" +` + indexWithEmptyEntry = ` +apiVersion: v1 +entries: + grafana: + - apiVersion: v2 + name: grafana + foo: + - + bar: + - digest: "sha256:1234567890abcdef" + urls: + - https://charts.helm.sh/stable/alpine-1.0.0.tgz +` +) + +func TestIndexFile(t *testing.T) { + i := NewIndexFile() + for _, x := range []struct { + md *chart.Metadata + filename string + baseURL string + digest string + }{ + {&chart.Metadata{APIVersion: "v2", Name: "clipper", Version: "0.1.0"}, "clipper-0.1.0.tgz", "http://example.com/charts", "sha256:1234567890"}, + {&chart.Metadata{APIVersion: "v2", Name: "cutter", Version: "0.1.1"}, "cutter-0.1.1.tgz", "http://example.com/charts", "sha256:1234567890abc"}, + {&chart.Metadata{APIVersion: "v2", Name: "cutter", Version: "0.1.0"}, "cutter-0.1.0.tgz", "http://example.com/charts", "sha256:1234567890abc"}, + {&chart.Metadata{APIVersion: "v2", Name: "cutter", Version: "0.2.0"}, "cutter-0.2.0.tgz", "http://example.com/charts", "sha256:1234567890abc"}, + {&chart.Metadata{APIVersion: "v2", Name: "setter", Version: "0.1.9+alpha"}, "setter-0.1.9+alpha.tgz", "http://example.com/charts", "sha256:1234567890abc"}, + {&chart.Metadata{APIVersion: "v2", Name: "setter", Version: "0.1.9+beta"}, "setter-0.1.9+beta.tgz", "http://example.com/charts", "sha256:1234567890abc"}, + {&chart.Metadata{APIVersion: "v2", Name: "setter", Version: "0.1.8"}, "setter-0.1.8.tgz", "http://example.com/charts", "sha256:1234567890abc"}, + {&chart.Metadata{APIVersion: "v2", Name: "setter", Version: "0.1.8+beta"}, "setter-0.1.8+beta.tgz", "http://example.com/charts", "sha256:1234567890abc"}, + } { + if err := i.MustAdd(x.md, x.filename, x.baseURL, x.digest); err != nil { + t.Errorf("unexpected error adding to index: %s", err) + } + } + + i.SortEntries() + + if i.APIVersion != APIVersionV1 { + t.Error("Expected API version v1") + } + + if len(i.Entries) != 3 { + t.Errorf("Expected 3 charts. Got %d", len(i.Entries)) + } + + if i.Entries["clipper"][0].Name != "clipper" { + t.Errorf("Expected clipper, got %s", i.Entries["clipper"][0].Name) + } + + if len(i.Entries["cutter"]) != 3 { + t.Error("Expected three cutters.") + } + + // Test that the sort worked. 0.2 should be at the first index for Cutter. + if v := i.Entries["cutter"][0].Version; v != "0.2.0" { + t.Errorf("Unexpected first version: %s", v) + } + + cv, err := i.Get("setter", "0.1.9") + if err == nil && !strings.Contains(cv.Metadata.Version, "0.1.9") { + t.Errorf("Unexpected version: %s", cv.Metadata.Version) + } + + cv, err = i.Get("setter", "0.1.9+alpha") + if err != nil || cv.Metadata.Version != "0.1.9+alpha" { + t.Errorf("Expected version: 0.1.9+alpha") + } + + cv, err = i.Get("setter", "0.1.8") + if err != nil || cv.Metadata.Version != "0.1.8" { + t.Errorf("Expected version: 0.1.8") + } +} + +func TestLoadIndex(t *testing.T) { + + tests := []struct { + Name string + Filename string + }{ + { + Name: "regular index file", + Filename: testfile, + }, + { + Name: "chartmuseum index file", + Filename: chartmuseumtestfile, + }, + { + Name: "JSON index file", + Filename: jsonTestfile, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + i, err := LoadIndexFile(tc.Filename) + if err != nil { + t.Fatal(err) + } + verifyLocalIndex(t, i) + }) + } +} + +// TestLoadIndex_Duplicates is a regression to make sure that we don't non-deterministically allow duplicate packages. +func TestLoadIndex_Duplicates(t *testing.T) { + if _, err := loadIndex([]byte(indexWithDuplicates), "indexWithDuplicates"); err == nil { + t.Errorf("Expected an error when duplicate entries are present") + } +} + +func TestLoadIndex_EmptyEntry(t *testing.T) { + if _, err := loadIndex([]byte(indexWithEmptyEntry), "indexWithEmptyEntry"); err != nil { + t.Errorf("unexpected error: %s", err) + } +} + +func TestLoadIndex_Empty(t *testing.T) { + if _, err := loadIndex([]byte(""), "indexWithEmpty"); err == nil { + t.Errorf("Expected an error when index.yaml is empty.") + } +} + +func TestLoadIndexFileAnnotations(t *testing.T) { + i, err := LoadIndexFile(annotationstestfile) + if err != nil { + t.Fatal(err) + } + verifyLocalIndex(t, i) + + if len(i.Annotations) != 1 { + t.Fatalf("Expected 1 annotation but got %d", len(i.Annotations)) + } + if i.Annotations["helm.sh/test"] != "foo bar" { + t.Error("Did not get expected value for helm.sh/test annotation") + } +} + +func TestLoadUnorderedIndex(t *testing.T) { + i, err := LoadIndexFile(unorderedTestfile) + if err != nil { + t.Fatal(err) + } + verifyLocalIndex(t, i) +} + +func TestMerge(t *testing.T) { + ind1 := NewIndexFile() + + if err := ind1.MustAdd(&chart.Metadata{APIVersion: "v2", Name: "dreadnought", Version: "0.1.0"}, "dreadnought-0.1.0.tgz", "http://example.com", "aaaa"); err != nil { + t.Fatalf("unexpected error: %s", err) + } + + ind2 := NewIndexFile() + + for _, x := range []struct { + md *chart.Metadata + filename string + baseURL string + digest string + }{ + {&chart.Metadata{APIVersion: "v2", Name: "dreadnought", Version: "0.2.0"}, "dreadnought-0.2.0.tgz", "http://example.com", "aaaabbbb"}, + {&chart.Metadata{APIVersion: "v2", Name: "doughnut", Version: "0.2.0"}, "doughnut-0.2.0.tgz", "http://example.com", "ccccbbbb"}, + } { + if err := ind2.MustAdd(x.md, x.filename, x.baseURL, x.digest); err != nil { + t.Errorf("unexpected error: %s", err) + } + } + + ind1.Merge(ind2) + + if len(ind1.Entries) != 2 { + t.Errorf("Expected 2 entries, got %d", len(ind1.Entries)) + } + + vs := ind1.Entries["dreadnought"] + if len(vs) != 2 { + t.Errorf("Expected 2 versions, got %d", len(vs)) + } + + if v := vs[1]; v.Version != "0.2.0" { + t.Errorf("Expected %q version to be 0.2.0, got %s", v.Name, v.Version) + } + +} + +func TestDownloadIndexFile(t *testing.T) { + t.Run("should download index file", func(t *testing.T) { + srv, err := startLocalServerForTests(nil) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + + r, err := NewChartRepository(&Entry{ + Name: testRepo, + URL: srv.URL, + }, getter.All(&cli.EnvSettings{})) + if err != nil { + t.Errorf("Problem creating chart repository from %s: %v", testRepo, err) + } + + idx, err := r.DownloadIndexFile() + if err != nil { + t.Fatalf("Failed to download index file to %s: %#v", idx, err) + } + + if _, err := os.Stat(idx); err != nil { + t.Fatalf("error finding created index file: %#v", err) + } + + i, err := LoadIndexFile(idx) + if err != nil { + t.Fatalf("Index %q failed to parse: %s", testfile, err) + } + verifyLocalIndex(t, i) + + // Check that charts file is also created + idx = filepath.Join(r.CachePath, helmpath.CacheChartsFile(r.Config.Name)) + if _, err := os.Stat(idx); err != nil { + t.Fatalf("error finding created charts file: %#v", err) + } + + b, err := os.ReadFile(idx) + if err != nil { + t.Fatalf("error reading charts file: %#v", err) + } + verifyLocalChartsFile(t, b, i) + }) + + t.Run("should not decode the path in the repo url while downloading index", func(t *testing.T) { + chartRepoURLPath := "/some%2Fpath/test" + fileBytes, err := os.ReadFile("testdata/local-index.yaml") + if err != nil { + t.Fatal(err) + } + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.RawPath == chartRepoURLPath+"/index.yaml" { + w.Write(fileBytes) + } + }) + srv, err := startLocalServerForTests(handler) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + + r, err := NewChartRepository(&Entry{ + Name: testRepo, + URL: srv.URL + chartRepoURLPath, + }, getter.All(&cli.EnvSettings{})) + if err != nil { + t.Errorf("Problem creating chart repository from %s: %v", testRepo, err) + } + + idx, err := r.DownloadIndexFile() + if err != nil { + t.Fatalf("Failed to download index file to %s: %#v", idx, err) + } + + if _, err := os.Stat(idx); err != nil { + t.Fatalf("error finding created index file: %#v", err) + } + + i, err := LoadIndexFile(idx) + if err != nil { + t.Fatalf("Index %q failed to parse: %s", testfile, err) + } + verifyLocalIndex(t, i) + + // Check that charts file is also created + idx = filepath.Join(r.CachePath, helmpath.CacheChartsFile(r.Config.Name)) + if _, err := os.Stat(idx); err != nil { + t.Fatalf("error finding created charts file: %#v", err) + } + + b, err := os.ReadFile(idx) + if err != nil { + t.Fatalf("error reading charts file: %#v", err) + } + verifyLocalChartsFile(t, b, i) + }) +} + +func verifyLocalIndex(t *testing.T, i *IndexFile) { + numEntries := len(i.Entries) + if numEntries != 3 { + t.Errorf("Expected 3 entries in index file but got %d", numEntries) + } + + alpine, ok := i.Entries["alpine"] + if !ok { + t.Fatalf("'alpine' section not found.") + } + + if l := len(alpine); l != 1 { + t.Fatalf("'alpine' should have 1 chart, got %d", l) + } + + nginx, ok := i.Entries["nginx"] + if !ok || len(nginx) != 2 { + t.Fatalf("Expected 2 nginx entries") + } + + expects := []*ChartVersion{ + { + Metadata: &chart.Metadata{ + APIVersion: "v2", + Name: "alpine", + Description: "string", + Version: "1.0.0", + Keywords: []string{"linux", "alpine", "small", "sumtin"}, + Home: "https://github.com/something", + }, + URLs: []string{ + "https://charts.helm.sh/stable/alpine-1.0.0.tgz", + "http://storage2.googleapis.com/kubernetes-charts/alpine-1.0.0.tgz", + }, + Digest: "sha256:1234567890abcdef", + }, + { + Metadata: &chart.Metadata{ + APIVersion: "v2", + Name: "nginx", + Description: "string", + Version: "0.2.0", + Keywords: []string{"popular", "web server", "proxy"}, + Home: "https://github.com/something/else", + }, + URLs: []string{ + "https://charts.helm.sh/stable/nginx-0.2.0.tgz", + }, + Digest: "sha256:1234567890abcdef", + }, + { + Metadata: &chart.Metadata{ + APIVersion: "v2", + Name: "nginx", + Description: "string", + Version: "0.1.0", + Keywords: []string{"popular", "web server", "proxy"}, + Home: "https://github.com/something", + }, + URLs: []string{ + "https://charts.helm.sh/stable/nginx-0.1.0.tgz", + }, + Digest: "sha256:1234567890abcdef", + }, + } + tests := []*ChartVersion{alpine[0], nginx[0], nginx[1]} + + for i, tt := range tests { + expect := expects[i] + if tt.Name != expect.Name { + t.Errorf("Expected name %q, got %q", expect.Name, tt.Name) + } + if tt.Description != expect.Description { + t.Errorf("Expected description %q, got %q", expect.Description, tt.Description) + } + if tt.Version != expect.Version { + t.Errorf("Expected version %q, got %q", expect.Version, tt.Version) + } + if tt.Digest != expect.Digest { + t.Errorf("Expected digest %q, got %q", expect.Digest, tt.Digest) + } + if tt.Home != expect.Home { + t.Errorf("Expected home %q, got %q", expect.Home, tt.Home) + } + + for i, url := range tt.URLs { + if url != expect.URLs[i] { + t.Errorf("Expected URL %q, got %q", expect.URLs[i], url) + } + } + for i, kw := range tt.Keywords { + if kw != expect.Keywords[i] { + t.Errorf("Expected keywords %q, got %q", expect.Keywords[i], kw) + } + } + } +} + +func verifyLocalChartsFile(t *testing.T, chartsContent []byte, indexContent *IndexFile) { + var expected, reald []string + for chart := range indexContent.Entries { + expected = append(expected, chart) + } + sort.Strings(expected) + + scanner := bufio.NewScanner(bytes.NewReader(chartsContent)) + for scanner.Scan() { + reald = append(reald, scanner.Text()) + } + sort.Strings(reald) + + if strings.Join(expected, " ") != strings.Join(reald, " ") { + t.Errorf("Cached charts file content unexpected. Expected:\n%s\ngot:\n%s", expected, reald) + } +} + +func TestIndexDirectory(t *testing.T) { + dir := "testdata/repository" + index, err := IndexDirectory(dir, "http://localhost:8080") + if err != nil { + t.Fatal(err) + } + + if l := len(index.Entries); l != 3 { + t.Fatalf("Expected 3 entries, got %d", l) + } + + // Other things test the entry generation more thoroughly. We just test a + // few fields. + + corpus := []struct{ chartName, downloadLink string }{ + {"frobnitz", "http://localhost:8080/frobnitz-1.2.3.tgz"}, + {"zarthal", "http://localhost:8080/universe/zarthal-1.0.0.tgz"}, + } + + for _, test := range corpus { + cname := test.chartName + frobs, ok := index.Entries[cname] + if !ok { + t.Fatalf("Could not read chart %s", cname) + } + + frob := frobs[0] + if frob.Digest == "" { + t.Errorf("Missing digest of file %s.", frob.Name) + } + if frob.URLs[0] != test.downloadLink { + t.Errorf("Unexpected URLs: %v", frob.URLs) + } + if frob.Name != cname { + t.Errorf("Expected %q, got %q", cname, frob.Name) + } + } +} + +func TestIndexAdd(t *testing.T) { + i := NewIndexFile() + + for _, x := range []struct { + md *chart.Metadata + filename string + baseURL string + digest string + }{ + + {&chart.Metadata{APIVersion: "v2", Name: "clipper", Version: "0.1.0"}, "clipper-0.1.0.tgz", "http://example.com/charts", "sha256:1234567890"}, + {&chart.Metadata{APIVersion: "v2", Name: "alpine", Version: "0.1.0"}, "/home/charts/alpine-0.1.0.tgz", "http://example.com/charts", "sha256:1234567890"}, + {&chart.Metadata{APIVersion: "v2", Name: "deis", Version: "0.1.0"}, "/home/charts/deis-0.1.0.tgz", "http://example.com/charts/", "sha256:1234567890"}, + } { + if err := i.MustAdd(x.md, x.filename, x.baseURL, x.digest); err != nil { + t.Errorf("unexpected error adding to index: %s", err) + } + } + + if i.Entries["clipper"][0].URLs[0] != "http://example.com/charts/clipper-0.1.0.tgz" { + t.Errorf("Expected http://example.com/charts/clipper-0.1.0.tgz, got %s", i.Entries["clipper"][0].URLs[0]) + } + if i.Entries["alpine"][0].URLs[0] != "http://example.com/charts/alpine-0.1.0.tgz" { + t.Errorf("Expected http://example.com/charts/alpine-0.1.0.tgz, got %s", i.Entries["alpine"][0].URLs[0]) + } + if i.Entries["deis"][0].URLs[0] != "http://example.com/charts/deis-0.1.0.tgz" { + t.Errorf("Expected http://example.com/charts/deis-0.1.0.tgz, got %s", i.Entries["deis"][0].URLs[0]) + } + + // test error condition + if err := i.MustAdd(&chart.Metadata{}, "error-0.1.0.tgz", "", ""); err == nil { + t.Fatal("expected error adding to index") + } +} + +func TestIndexWrite(t *testing.T) { + i := NewIndexFile() + if err := i.MustAdd(&chart.Metadata{APIVersion: "v2", Name: "clipper", Version: "0.1.0"}, "clipper-0.1.0.tgz", "http://example.com/charts", "sha256:1234567890"); err != nil { + t.Fatalf("unexpected error: %s", err) + } + dir := t.TempDir() + testpath := filepath.Join(dir, "test") + i.WriteFile(testpath, 0600) + + got, err := os.ReadFile(testpath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), "clipper-0.1.0.tgz") { + t.Fatal("Index files doesn't contain expected content") + } +} + +func TestIndexJSONWrite(t *testing.T) { + i := NewIndexFile() + if err := i.MustAdd(&chart.Metadata{APIVersion: "v2", Name: "clipper", Version: "0.1.0"}, "clipper-0.1.0.tgz", "http://example.com/charts", "sha256:1234567890"); err != nil { + t.Fatalf("unexpected error: %s", err) + } + dir := t.TempDir() + testpath := filepath.Join(dir, "test") + i.WriteJSONFile(testpath, 0600) + + got, err := os.ReadFile(testpath) + if err != nil { + t.Fatal(err) + } + if !json.Valid(got) { + t.Fatal("Index files doesn't contain valid JSON") + } + if !strings.Contains(string(got), "clipper-0.1.0.tgz") { + t.Fatal("Index files doesn't contain expected content") + } +} + +func TestAddFileIndexEntriesNil(t *testing.T) { + i := NewIndexFile() + i.APIVersion = chart.APIVersionV1 + i.Entries = nil + for _, x := range []struct { + md *chart.Metadata + filename string + baseURL string + digest string + }{ + {&chart.Metadata{APIVersion: "v2", Name: " ", Version: "8033-5.apinie+s.r"}, "setter-0.1.9+beta.tgz", "http://example.com/charts", "sha256:1234567890abc"}, + } { + if err := i.MustAdd(x.md, x.filename, x.baseURL, x.digest); err == nil { + t.Errorf("expected err to be non-nil when entries not initialized") + } + } +} + +func TestIgnoreSkippableChartValidationError(t *testing.T) { + type TestCase struct { + Input error + ErrorSkipped bool + } + testCases := map[string]TestCase{ + "nil": { + Input: nil, + }, + "generic_error": { + Input: fmt.Errorf("foo"), + }, + "non_skipped_validation_error": { + Input: chart.ValidationError("chart.metadata.type must be application or library"), + }, + "skipped_validation_error": { + Input: chart.ValidationErrorf("more than one dependency with name or alias %q", "foo"), + ErrorSkipped: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + result := ignoreSkippableChartValidationError(tc.Input) + + if tc.Input == nil { + if result != nil { + t.Error("expected nil result for nil input") + } + return + } + + if tc.ErrorSkipped { + if result != nil { + t.Error("expected nil result for skipped error") + } + return + } + + if tc.Input != result { + t.Error("expected the result equal to input") + } + + }) + } +} diff --git a/pkg/repo/repo.go b/pkg/repo/repo.go new file mode 100644 index 00000000..834d554b --- /dev/null +++ b/pkg/repo/repo.go @@ -0,0 +1,125 @@ +/* +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 repo // import "helm.sh/helm/v3/pkg/repo" + +import ( + "os" + "path/filepath" + "time" + + "github.com/pkg/errors" + "sigs.k8s.io/yaml" +) + +// File represents the repositories.yaml file +type File struct { + APIVersion string `json:"apiVersion"` + Generated time.Time `json:"generated"` + Repositories []*Entry `json:"repositories"` +} + +// NewFile generates an empty repositories file. +// +// Generated and APIVersion are automatically set. +func NewFile() *File { + return &File{ + APIVersion: APIVersionV1, + Generated: time.Now(), + Repositories: []*Entry{}, + } +} + +// LoadFile takes a file at the given path and returns a File object +func LoadFile(path string) (*File, error) { + r := new(File) + b, err := os.ReadFile(path) + if err != nil { + return r, errors.Wrapf(err, "couldn't load repositories file (%s)", path) + } + + err = yaml.Unmarshal(b, r) + return r, err +} + +// Add adds one or more repo entries to a repo file. +func (r *File) Add(re ...*Entry) { + r.Repositories = append(r.Repositories, re...) +} + +// Update attempts to replace one or more repo entries in a repo file. If an +// entry with the same name doesn't exist in the repo file it will add it. +func (r *File) Update(re ...*Entry) { + for _, target := range re { + r.update(target) + } +} + +func (r *File) update(e *Entry) { + for j, repo := range r.Repositories { + if repo.Name == e.Name { + r.Repositories[j] = e + return + } + } + r.Add(e) +} + +// Has returns true if the given name is already a repository name. +func (r *File) Has(name string) bool { + entry := r.Get(name) + return entry != nil +} + +// Get returns an entry with the given name if it exists, otherwise returns nil +func (r *File) Get(name string) *Entry { + for _, entry := range r.Repositories { + if entry.Name == name { + return entry + } + } + return nil +} + +// Remove removes the entry from the list of repositories. +func (r *File) Remove(name string) bool { + cp := []*Entry{} + found := false + for _, rf := range r.Repositories { + if rf == nil { + continue + } + if rf.Name == name { + found = true + continue + } + cp = append(cp, rf) + } + r.Repositories = cp + return found +} + +// WriteFile writes a repositories file to the given path. +func (r *File) WriteFile(path string, perm os.FileMode) error { + data, err := yaml.Marshal(r) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + return os.WriteFile(path, data, perm) +} diff --git a/pkg/repo/repo_test.go b/pkg/repo/repo_test.go new file mode 100644 index 00000000..c2087ebb --- /dev/null +++ b/pkg/repo/repo_test.go @@ -0,0 +1,257 @@ +/* +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 repo + +import ( + "os" + "strings" + "testing" +) + +const testRepositoriesFile = "testdata/repositories.yaml" + +func TestFile(t *testing.T) { + rf := NewFile() + rf.Add( + &Entry{ + Name: "stable", + URL: "https://example.com/stable/charts", + }, + &Entry{ + Name: "incubator", + URL: "https://example.com/incubator", + }, + ) + + if len(rf.Repositories) != 2 { + t.Fatal("Expected 2 repositories") + } + + if rf.Has("nosuchrepo") { + t.Error("Found nonexistent repo") + } + if !rf.Has("incubator") { + t.Error("incubator repo is missing") + } + + stable := rf.Repositories[0] + if stable.Name != "stable" { + t.Error("stable is not named stable") + } + if stable.URL != "https://example.com/stable/charts" { + t.Error("Wrong URL for stable") + } +} + +func TestNewFile(t *testing.T) { + expects := NewFile() + expects.Add( + &Entry{ + Name: "stable", + URL: "https://example.com/stable/charts", + }, + &Entry{ + Name: "incubator", + URL: "https://example.com/incubator", + }, + ) + + file, err := LoadFile(testRepositoriesFile) + if err != nil { + t.Errorf("%q could not be loaded: %s", testRepositoriesFile, err) + } + + if len(expects.Repositories) != len(file.Repositories) { + t.Fatalf("Unexpected repo data: %#v", file.Repositories) + } + + for i, expect := range expects.Repositories { + got := file.Repositories[i] + if expect.Name != got.Name { + t.Errorf("Expected name %q, got %q", expect.Name, got.Name) + } + if expect.URL != got.URL { + t.Errorf("Expected url %q, got %q", expect.URL, got.URL) + } + } +} + +func TestRepoFile_Get(t *testing.T) { + repo := NewFile() + repo.Add( + &Entry{ + Name: "first", + URL: "https://example.com/first", + }, + &Entry{ + Name: "second", + URL: "https://example.com/second", + }, + &Entry{ + Name: "third", + URL: "https://example.com/third", + }, + &Entry{ + Name: "fourth", + URL: "https://example.com/fourth", + }, + ) + + name := "second" + + entry := repo.Get(name) + if entry == nil { //nolint:staticcheck + t.Fatalf("Expected repo entry %q to be found", name) + } + + if entry.URL != "https://example.com/second" { //nolint:staticcheck + t.Errorf("Expected repo URL to be %q but got %q", "https://example.com/second", entry.URL) + } + + entry = repo.Get("nonexistent") + if entry != nil { + t.Errorf("Got unexpected entry %+v", entry) + } +} + +func TestRemoveRepository(t *testing.T) { + sampleRepository := NewFile() + sampleRepository.Add( + &Entry{ + Name: "stable", + URL: "https://example.com/stable/charts", + }, + &Entry{ + Name: "incubator", + URL: "https://example.com/incubator", + }, + ) + + removeRepository := "stable" + found := sampleRepository.Remove(removeRepository) + if !found { + t.Errorf("expected repository %s not found", removeRepository) + } + + found = sampleRepository.Has(removeRepository) + if found { + t.Errorf("repository %s not deleted", removeRepository) + } +} + +func TestUpdateRepository(t *testing.T) { + sampleRepository := NewFile() + sampleRepository.Add( + &Entry{ + Name: "stable", + URL: "https://example.com/stable/charts", + }, + &Entry{ + Name: "incubator", + URL: "https://example.com/incubator", + }, + ) + newRepoName := "sample" + sampleRepository.Update(&Entry{Name: newRepoName, + URL: "https://example.com/sample", + }) + + if !sampleRepository.Has(newRepoName) { + t.Errorf("expected repository %s not found", newRepoName) + } + repoCount := len(sampleRepository.Repositories) + + sampleRepository.Update(&Entry{Name: newRepoName, + URL: "https://example.com/sample", + }) + + if repoCount != len(sampleRepository.Repositories) { + t.Errorf("invalid number of repositories found %d, expected number of repositories %d", len(sampleRepository.Repositories), repoCount) + } +} + +func TestWriteFile(t *testing.T) { + sampleRepository := NewFile() + sampleRepository.Add( + &Entry{ + Name: "stable", + URL: "https://example.com/stable/charts", + }, + &Entry{ + Name: "incubator", + URL: "https://example.com/incubator", + }, + ) + + file, err := os.CreateTemp("", "helm-repo") + if err != nil { + t.Errorf("failed to create test-file (%v)", err) + } + defer os.Remove(file.Name()) + if err := sampleRepository.WriteFile(file.Name(), 0600); err != nil { + t.Errorf("failed to write file (%v)", err) + } + + repos, err := LoadFile(file.Name()) + if err != nil { + t.Errorf("failed to load file (%v)", err) + } + for _, repo := range sampleRepository.Repositories { + if !repos.Has(repo.Name) { + t.Errorf("expected repository %s not found", repo.Name) + } + } +} + +func TestRepoNotExists(t *testing.T) { + if _, err := LoadFile("/this/path/does/not/exist.yaml"); err == nil { + t.Errorf("expected err to be non-nil when path does not exist") + } else if !strings.Contains(err.Error(), "couldn't load repositories file") { + t.Errorf("expected prompt `couldn't load repositories file`") + } +} + +func TestRemoveRepositoryInvalidEntries(t *testing.T) { + sampleRepository := NewFile() + sampleRepository.Add( + &Entry{ + Name: "stable", + URL: "https://example.com/stable/charts", + }, + &Entry{ + Name: "incubator", + URL: "https://example.com/incubator", + }, + &Entry{}, + nil, + &Entry{ + Name: "test", + URL: "https://example.com/test", + }, + ) + + removeRepository := "stable" + found := sampleRepository.Remove(removeRepository) + if !found { + t.Errorf("expected repository %s not found", removeRepository) + } + + found = sampleRepository.Has(removeRepository) + if found { + t.Errorf("repository %s not deleted", removeRepository) + } +} diff --git a/pkg/repo/repotest/doc.go b/pkg/repo/repotest/doc.go new file mode 100644 index 00000000..c01daad6 --- /dev/null +++ b/pkg/repo/repotest/doc.go @@ -0,0 +1,21 @@ +/* +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 repotest provides utilities for testing. + +The server provides a testing server that can be set up and torn down quickly. +*/ +package repotest diff --git a/pkg/repo/repotest/server.go b/pkg/repo/repotest/server.go new file mode 100644 index 00000000..d9a5201a --- /dev/null +++ b/pkg/repo/repotest/server.go @@ -0,0 +1,426 @@ +/* +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 repotest + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/distribution/distribution/v3/configuration" + "github.com/distribution/distribution/v3/registry" + _ "github.com/distribution/distribution/v3/registry/auth/htpasswd" // used for docker test registry + _ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory" // used for docker test registry + "github.com/phayes/freeport" + "golang.org/x/crypto/bcrypt" + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/internal/tlsutil" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/chartutil" + ociRegistry "helm.sh/helm/v3/pkg/registry" + "helm.sh/helm/v3/pkg/repo" +) + +// NewTempServerWithCleanup creates a server inside of a temp dir. +// +// If the passed in string is not "", it will be treated as a shell glob, and files +// will be copied from that path to the server's docroot. +// +// The caller is responsible for stopping the server. +// The temp dir will be removed by testing package automatically when test finished. +func NewTempServerWithCleanup(t *testing.T, glob string) (*Server, error) { + srv, err := NewTempServer(glob) + t.Cleanup(func() { os.RemoveAll(srv.docroot) }) + return srv, err +} + +// Set up a fake repo with basic auth enabled +func NewTempServerWithCleanupAndBasicAuth(t *testing.T, glob string) *Server { + srv, err := NewTempServerWithCleanup(t, glob) + srv.Stop() + if err != nil { + t.Fatal(err) + } + srv.WithMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != "username" || password != "password" { + t.Errorf("Expected request to use basic auth and for username == 'username' and password == 'password', got '%v', '%s', '%s'", ok, username, password) + } + })) + srv.Start() + return srv +} + +type OCIServer struct { + *registry.Registry + RegistryURL string + Dir string + TestUsername string + TestPassword string + Client *ociRegistry.Client +} + +type OCIServerRunConfig struct { + DependingChart *chart.Chart +} + +type OCIServerOpt func(config *OCIServerRunConfig) + +func WithDependingChart(c *chart.Chart) OCIServerOpt { + return func(config *OCIServerRunConfig) { + config.DependingChart = c + } +} + +func NewOCIServer(t *testing.T, dir string) (*OCIServer, error) { + testHtpasswdFileBasename := "authtest.htpasswd" + testUsername, testPassword := "username", "password" + + pwBytes, err := bcrypt.GenerateFromPassword([]byte(testPassword), bcrypt.DefaultCost) + if err != nil { + t.Fatal("error generating bcrypt password for test htpasswd file") + } + htpasswdPath := filepath.Join(dir, testHtpasswdFileBasename) + err = os.WriteFile(htpasswdPath, []byte(fmt.Sprintf("%s:%s\n", testUsername, string(pwBytes))), 0644) + if err != nil { + t.Fatalf("error creating test htpasswd file") + } + + // Registry config + config := &configuration.Configuration{} + port, err := freeport.GetFreePort() + if err != nil { + t.Fatalf("error finding free port for test registry") + } + + config.HTTP.Addr = fmt.Sprintf(":%d", port) + config.HTTP.DrainTimeout = time.Duration(10) * time.Second + config.Storage = map[string]configuration.Parameters{"inmemory": map[string]interface{}{}} + config.Auth = configuration.Auth{ + "htpasswd": configuration.Parameters{ + "realm": "localhost", + "path": htpasswdPath, + }, + } + + registryURL := fmt.Sprintf("localhost:%d", port) + + r, err := registry.NewRegistry(context.Background(), config) + if err != nil { + t.Fatal(err) + } + + return &OCIServer{ + Registry: r, + RegistryURL: registryURL, + TestUsername: testUsername, + TestPassword: testPassword, + Dir: dir, + }, nil +} + +func (srv *OCIServer) Run(t *testing.T, opts ...OCIServerOpt) { + cfg := &OCIServerRunConfig{} + for _, fn := range opts { + fn(cfg) + } + + go srv.ListenAndServe() + + credentialsFile := filepath.Join(srv.Dir, "config.json") + + // init test client + registryClient, err := ociRegistry.NewClient( + ociRegistry.ClientOptDebug(true), + ociRegistry.ClientOptEnableCache(true), + ociRegistry.ClientOptWriter(os.Stdout), + ociRegistry.ClientOptCredentialsFile(credentialsFile), + ) + if err != nil { + t.Fatalf("error creating registry client") + } + + err = registryClient.Login( + srv.RegistryURL, + ociRegistry.LoginOptBasicAuth(srv.TestUsername, srv.TestPassword), + ociRegistry.LoginOptInsecure(false)) + if err != nil { + t.Fatalf("error logging into registry with good credentials") + } + + ref := fmt.Sprintf("%s/u/ocitestuser/oci-dependent-chart:0.1.0", srv.RegistryURL) + + err = chartutil.ExpandFile(srv.Dir, filepath.Join(srv.Dir, "oci-dependent-chart-0.1.0.tgz")) + if err != nil { + t.Fatal(err) + } + + // valid chart + ch, err := loader.LoadDir(filepath.Join(srv.Dir, "oci-dependent-chart")) + if err != nil { + t.Fatal("error loading chart") + } + + err = os.RemoveAll(filepath.Join(srv.Dir, "oci-dependent-chart")) + if err != nil { + t.Fatal("error removing chart before push") + } + + // save it back to disk.. + absPath, err := chartutil.Save(ch, srv.Dir) + if err != nil { + t.Fatal("could not create chart archive") + } + + // load it into memory... + contentBytes, err := os.ReadFile(absPath) + if err != nil { + t.Fatal("could not load chart into memory") + } + + result, err := registryClient.Push(contentBytes, ref) + if err != nil { + t.Fatalf("error pushing dependent chart: %s", err) + } + t.Logf("Manifest.Digest: %s, Manifest.Size: %d, "+ + "Config.Digest: %s, Config.Size: %d, "+ + "Chart.Digest: %s, Chart.Size: %d", + result.Manifest.Digest, result.Manifest.Size, + result.Config.Digest, result.Config.Size, + result.Chart.Digest, result.Chart.Size) + + srv.Client = registryClient + c := cfg.DependingChart + if c == nil { + return + } + + dependingRef := fmt.Sprintf("%s/u/ocitestuser/%s:%s", + srv.RegistryURL, c.Metadata.Name, c.Metadata.Version) + + // load it into memory... + absPath = filepath.Join(srv.Dir, + fmt.Sprintf("%s-%s.tgz", c.Metadata.Name, c.Metadata.Version)) + contentBytes, err = os.ReadFile(absPath) + if err != nil { + t.Fatal("could not load chart into memory") + } + + result, err = registryClient.Push(contentBytes, dependingRef) + if err != nil { + t.Fatalf("error pushing depending chart: %s", err) + } + t.Logf("Manifest.Digest: %s, Manifest.Size: %d, "+ + "Config.Digest: %s, Config.Size: %d, "+ + "Chart.Digest: %s, Chart.Size: %d", + result.Manifest.Digest, result.Manifest.Size, + result.Config.Digest, result.Config.Size, + result.Chart.Digest, result.Chart.Size) +} + +// NewTempServer creates a server inside of a temp dir. +// +// If the passed in string is not "", it will be treated as a shell glob, and files +// will be copied from that path to the server's docroot. +// +// The caller is responsible for destroying the temp directory as well as stopping +// the server. +// +// Deprecated: use NewTempServerWithCleanup +func NewTempServer(glob string) (*Server, error) { + tdir, err := os.MkdirTemp("", "helm-repotest-") + if err != nil { + return nil, err + } + srv := NewServer(tdir) + + if glob != "" { + if _, err := srv.CopyCharts(glob); err != nil { + srv.Stop() + return srv, err + } + } + + return srv, nil +} + +// NewServer creates a repository server for testing. +// +// docroot should be a temp dir managed by the caller. +// +// This will start the server, serving files off of the docroot. +// +// Use CopyCharts to move charts into the repository and then index them +// for service. +func NewServer(docroot string) *Server { + root, err := filepath.Abs(docroot) + if err != nil { + panic(err) + } + srv := &Server{ + docroot: root, + } + srv.Start() + // Add the testing repository as the only repo. + if err := setTestingRepository(srv.URL(), filepath.Join(root, "repositories.yaml")); err != nil { + panic(err) + } + return srv +} + +// Server is an implementation of a repository server for testing. +type Server struct { + docroot string + srv *httptest.Server + middleware http.HandlerFunc +} + +// WithMiddleware injects middleware in front of the server. This can be used to inject +// additional functionality like layering in an authentication frontend. +func (s *Server) WithMiddleware(middleware http.HandlerFunc) { + s.middleware = middleware +} + +// Root gets the docroot for the server. +func (s *Server) Root() string { + return s.docroot +} + +// CopyCharts takes a glob expression and copies those charts to the server root. +func (s *Server) CopyCharts(origin string) ([]string, error) { + files, err := filepath.Glob(origin) + if err != nil { + return []string{}, err + } + copied := make([]string, len(files)) + for i, f := range files { + base := filepath.Base(f) + newname := filepath.Join(s.docroot, base) + data, err := os.ReadFile(f) + if err != nil { + return []string{}, err + } + if err := os.WriteFile(newname, data, 0644); err != nil { + return []string{}, err + } + copied[i] = newname + } + + err = s.CreateIndex() + return copied, err +} + +// CreateIndex will read docroot and generate an index.yaml file. +func (s *Server) CreateIndex() error { + // generate the index + index, err := repo.IndexDirectory(s.docroot, s.URL()) + if err != nil { + return err + } + + d, err := yaml.Marshal(index) + if err != nil { + return err + } + + ifile := filepath.Join(s.docroot, "index.yaml") + return os.WriteFile(ifile, d, 0644) +} + +func (s *Server) Start() { + s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.middleware != nil { + s.middleware.ServeHTTP(w, r) + } + http.FileServer(http.Dir(s.docroot)).ServeHTTP(w, r) + })) +} + +func (s *Server) StartTLS() { + cd := "../../testdata" + ca, pub, priv := filepath.Join(cd, "rootca.crt"), filepath.Join(cd, "crt.pem"), filepath.Join(cd, "key.pem") + insecure := false + + s.srv = httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.middleware != nil { + s.middleware.ServeHTTP(w, r) + } + http.FileServer(http.Dir(s.Root())).ServeHTTP(w, r) + })) + tlsConf, err := tlsutil.NewClientTLS(pub, priv, ca, insecure) + if err != nil { + panic(err) + } + tlsConf.ServerName = "helm.sh" + s.srv.TLS = tlsConf + s.srv.StartTLS() + + // Set up repositories config with ca file + repoConfig := filepath.Join(s.Root(), "repositories.yaml") + + r := repo.NewFile() + r.Add(&repo.Entry{ + Name: "test", + URL: s.URL(), + CAFile: filepath.Join("../../testdata", "rootca.crt"), + }) + + if err := r.WriteFile(repoConfig, 0600); err != nil { + panic(err) + } +} + +// Stop stops the server and closes all connections. +// +// It should be called explicitly. +func (s *Server) Stop() { + s.srv.Close() +} + +// URL returns the URL of the server. +// +// Example: +// +// http://localhost:1776 +func (s *Server) URL() string { + return s.srv.URL +} + +// LinkIndices links the index created with CreateIndex and makes a symbolic link to the cache index. +// +// This makes it possible to simulate a local cache of a repository. +func (s *Server) LinkIndices() error { + lstart := filepath.Join(s.docroot, "index.yaml") + ldest := filepath.Join(s.docroot, "test-index.yaml") + return os.Symlink(lstart, ldest) +} + +// setTestingRepository sets up a testing repository.yaml with only the given URL. +func setTestingRepository(url, fname string) error { + r := repo.NewFile() + r.Add(&repo.Entry{ + Name: "test", + URL: url, + }) + return r.WriteFile(fname, 0640) +} diff --git a/pkg/repo/repotest/server_test.go b/pkg/repo/repotest/server_test.go new file mode 100644 index 00000000..a7d7f5b9 --- /dev/null +++ b/pkg/repo/repotest/server_test.go @@ -0,0 +1,116 @@ +/* +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 repotest + +import ( + "io" + "net/http" + "path/filepath" + "testing" + + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/internal/test/ensure" + "helm.sh/helm/v3/pkg/repo" +) + +// Young'n, in these here parts, we test our tests. + +func TestServer(t *testing.T) { + ensure.HelmHome(t) + + rootDir := t.TempDir() + + srv := NewServer(rootDir) + defer srv.Stop() + + c, err := srv.CopyCharts("testdata/*.tgz") + if err != nil { + // Some versions of Go don't correctly fire defer on Fatal. + t.Fatal(err) + } + + if len(c) != 1 { + t.Errorf("Unexpected chart count: %d", len(c)) + } + + if filepath.Base(c[0]) != "examplechart-0.1.0.tgz" { + t.Errorf("Unexpected chart: %s", c[0]) + } + + res, err := http.Get(srv.URL() + "/examplechart-0.1.0.tgz") + res.Body.Close() + if err != nil { + t.Fatal(err) + } + + if res.ContentLength < 500 { + t.Errorf("Expected at least 500 bytes of data, got %d", res.ContentLength) + } + + res, err = http.Get(srv.URL() + "/index.yaml") + if err != nil { + t.Fatal(err) + } + + data, err := io.ReadAll(res.Body) + res.Body.Close() + if err != nil { + t.Fatal(err) + } + + m := repo.NewIndexFile() + if err := yaml.Unmarshal(data, m); err != nil { + t.Fatal(err) + } + + if l := len(m.Entries); l != 1 { + t.Fatalf("Expected 1 entry, got %d", l) + } + + expect := "examplechart" + if !m.Has(expect, "0.1.0") { + t.Errorf("missing %q", expect) + } + + res, err = http.Get(srv.URL() + "/index.yaml-nosuchthing") + res.Body.Close() + if err != nil { + t.Fatal(err) + } + if res.StatusCode != 404 { + t.Fatalf("Expected 404, got %d", res.StatusCode) + } +} + +func TestNewTempServer(t *testing.T) { + ensure.HelmHome(t) + + srv, err := NewTempServerWithCleanup(t, "testdata/examplechart-0.1.0.tgz") + if err != nil { + t.Fatal(err) + } + defer srv.Stop() + + res, err := http.Head(srv.URL() + "/examplechart-0.1.0.tgz") + res.Body.Close() + if err != nil { + t.Error(err) + } + if res.StatusCode != 200 { + t.Errorf("Expected 200, got %d", res.StatusCode) + } +} diff --git a/pkg/repo/repotest/testdata/examplechart-0.1.0.tgz b/pkg/repo/repotest/testdata/examplechart-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..c5ea741eb90fec6a94653cfde780d697f33c4439 GIT binary patch literal 500 zcmVgiwG0|00000|0w_~VMtOiV@ORlOnEsqVl!4SWK%V1T2nbTPgYhoO;>Dc zVQyr3R8em|NM&qo0PL3EYui2$!1JuX;=cIhyD((AX*%fJNCz8x*dQaX<7{7SHTuO# zcG`{YZy#~U)@F=Q=*hoFXr>1RZ1PV zs+gU9e;)G1>_S~kK%KOTU(m&bJW+o<*YV<>2pIH=IBM@Mp)W*Dv0@$4(0>W9$ygZn z2CO~9+pZ*blqd>^mM-D$?h*ZA93=~>#NS@o$nS8^{~k?8@#F`My6?_jl9LajLp+31uH zabLcW|Oq2pc1Q17htUoMKP6e8^lzWxDivCY!K70B7+sO_FaTG qSFfUwf&DdANl0B2=KJ^9Uymp0$jHc3;#&X!0RR7Fckh`15C8yY4Da3m literal 0 HcmV?d00001 diff --git a/pkg/repo/repotest/testdata/examplechart/.helmignore b/pkg/repo/repotest/testdata/examplechart/.helmignore new file mode 100644 index 00000000..f0c13194 --- /dev/null +++ b/pkg/repo/repotest/testdata/examplechart/.helmignore @@ -0,0 +1,21 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj diff --git a/pkg/repo/repotest/testdata/examplechart/Chart.yaml b/pkg/repo/repotest/testdata/examplechart/Chart.yaml new file mode 100644 index 00000000..a7d29728 --- /dev/null +++ b/pkg/repo/repotest/testdata/examplechart/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: A Helm chart for Kubernetes +name: examplechart +version: 0.1.0 diff --git a/pkg/repo/repotest/testdata/examplechart/values.yaml b/pkg/repo/repotest/testdata/examplechart/values.yaml new file mode 100644 index 00000000..5170c61e --- /dev/null +++ b/pkg/repo/repotest/testdata/examplechart/values.yaml @@ -0,0 +1,4 @@ +# Default values for examplechart. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name: value diff --git a/pkg/repo/testdata/chartmuseum-index.yaml b/pkg/repo/testdata/chartmuseum-index.yaml new file mode 100644 index 00000000..349a529a --- /dev/null +++ b/pkg/repo/testdata/chartmuseum-index.yaml @@ -0,0 +1,54 @@ +serverInfo: + contextPath: /v1/helm +apiVersion: v1 +entries: + nginx: + - urls: + - https://charts.helm.sh/stable/nginx-0.2.0.tgz + name: nginx + description: string + version: 0.2.0 + home: https://github.com/something/else + digest: "sha256:1234567890abcdef" + keywords: + - popular + - web server + - proxy + apiVersion: v2 + - urls: + - https://charts.helm.sh/stable/nginx-0.1.0.tgz + name: nginx + description: string + version: 0.1.0 + home: https://github.com/something + digest: "sha256:1234567890abcdef" + keywords: + - popular + - web server + - proxy + apiVersion: v2 + alpine: + - urls: + - https://charts.helm.sh/stable/alpine-1.0.0.tgz + - http://storage2.googleapis.com/kubernetes-charts/alpine-1.0.0.tgz + name: alpine + description: string + version: 1.0.0 + home: https://github.com/something + keywords: + - linux + - alpine + - small + - sumtin + digest: "sha256:1234567890abcdef" + apiVersion: v2 + chartWithNoURL: + - name: chartWithNoURL + description: string + version: 1.0.0 + home: https://github.com/something + keywords: + - small + - sumtin + digest: "sha256:1234567890abcdef" + apiVersion: v2 diff --git a/pkg/repo/testdata/local-index-annotations.yaml b/pkg/repo/testdata/local-index-annotations.yaml new file mode 100644 index 00000000..833ab854 --- /dev/null +++ b/pkg/repo/testdata/local-index-annotations.yaml @@ -0,0 +1,54 @@ +apiVersion: v1 +entries: + nginx: + - urls: + - https://charts.helm.sh/stable/nginx-0.2.0.tgz + name: nginx + description: string + version: 0.2.0 + home: https://github.com/something/else + digest: "sha256:1234567890abcdef" + keywords: + - popular + - web server + - proxy + apiVersion: v2 + - urls: + - https://charts.helm.sh/stable/nginx-0.1.0.tgz + name: nginx + description: string + version: 0.1.0 + home: https://github.com/something + digest: "sha256:1234567890abcdef" + keywords: + - popular + - web server + - proxy + apiVersion: v2 + alpine: + - urls: + - https://charts.helm.sh/stable/alpine-1.0.0.tgz + - http://storage2.googleapis.com/kubernetes-charts/alpine-1.0.0.tgz + name: alpine + description: string + version: 1.0.0 + home: https://github.com/something + keywords: + - linux + - alpine + - small + - sumtin + digest: "sha256:1234567890abcdef" + apiVersion: v2 + chartWithNoURL: + - name: chartWithNoURL + description: string + version: 1.0.0 + home: https://github.com/something + keywords: + - small + - sumtin + digest: "sha256:1234567890abcdef" + apiVersion: v2 +annotations: + helm.sh/test: foo bar diff --git a/pkg/repo/testdata/local-index-unordered.yaml b/pkg/repo/testdata/local-index-unordered.yaml new file mode 100644 index 00000000..cdfaa7f2 --- /dev/null +++ b/pkg/repo/testdata/local-index-unordered.yaml @@ -0,0 +1,52 @@ +apiVersion: v1 +entries: + nginx: + - urls: + - https://charts.helm.sh/stable/nginx-0.1.0.tgz + name: nginx + description: string + version: 0.1.0 + home: https://github.com/something + digest: "sha256:1234567890abcdef" + keywords: + - popular + - web server + - proxy + apiVersion: v2 + - urls: + - https://charts.helm.sh/stable/nginx-0.2.0.tgz + name: nginx + description: string + version: 0.2.0 + home: https://github.com/something/else + digest: "sha256:1234567890abcdef" + keywords: + - popular + - web server + - proxy + apiVersion: v2 + alpine: + - urls: + - https://charts.helm.sh/stable/alpine-1.0.0.tgz + - http://storage2.googleapis.com/kubernetes-charts/alpine-1.0.0.tgz + name: alpine + description: string + version: 1.0.0 + home: https://github.com/something + keywords: + - linux + - alpine + - small + - sumtin + digest: "sha256:1234567890abcdef" + apiVersion: v2 + chartWithNoURL: + - name: chartWithNoURL + description: string + version: 1.0.0 + home: https://github.com/something + keywords: + - small + - sumtin + digest: "sha256:1234567890abcdef" + apiVersion: v2 diff --git a/pkg/repo/testdata/local-index.json b/pkg/repo/testdata/local-index.json new file mode 100644 index 00000000..25296d5c --- /dev/null +++ b/pkg/repo/testdata/local-index.json @@ -0,0 +1,53 @@ +{ + "apiVersion": "v1", + "entries": { + "nginx": [ + { + "urls": ["https://charts.helm.sh/stable/nginx-0.2.0.tgz"], + "name": "nginx", + "description": "string", + "version": "0.2.0", + "home": "https://github.com/something/else", + "digest": "sha256:1234567890abcdef", + "keywords": ["popular", "web server", "proxy"], + "apiVersion": "v2" + }, + { + "urls": ["https://charts.helm.sh/stable/nginx-0.1.0.tgz"], + "name": "nginx", + "description": "string", + "version": "0.1.0", + "home": "https://github.com/something", + "digest": "sha256:1234567890abcdef", + "keywords": ["popular", "web server", "proxy"], + "apiVersion": "v2" + } + ], + "alpine": [ + { + "urls": [ + "https://charts.helm.sh/stable/alpine-1.0.0.tgz", + "http://storage2.googleapis.com/kubernetes-charts/alpine-1.0.0.tgz" + ], + "name": "alpine", + "description": "string", + "version": "1.0.0", + "home": "https://github.com/something", + "keywords": ["linux", "alpine", "small", "sumtin"], + "digest": "sha256:1234567890abcdef", + "apiVersion": "v2" + } + ], + "chartWithNoURL": [ + { + "name": "chartWithNoURL", + "description": "string", + "version": "1.0.0", + "home": "https://github.com/something", + "keywords": ["small", "sumtin"], + "digest": "sha256:1234567890abcdef", + "apiVersion": "v2" + } + ] + } +} diff --git a/pkg/repo/testdata/local-index.yaml b/pkg/repo/testdata/local-index.yaml new file mode 100644 index 00000000..d61f40dd --- /dev/null +++ b/pkg/repo/testdata/local-index.yaml @@ -0,0 +1,52 @@ +apiVersion: v1 +entries: + nginx: + - urls: + - https://charts.helm.sh/stable/nginx-0.2.0.tgz + name: nginx + description: string + version: 0.2.0 + home: https://github.com/something/else + digest: "sha256:1234567890abcdef" + keywords: + - popular + - web server + - proxy + apiVersion: v2 + - urls: + - https://charts.helm.sh/stable/nginx-0.1.0.tgz + name: nginx + description: string + version: 0.1.0 + home: https://github.com/something + digest: "sha256:1234567890abcdef" + keywords: + - popular + - web server + - proxy + apiVersion: v2 + alpine: + - urls: + - https://charts.helm.sh/stable/alpine-1.0.0.tgz + - http://storage2.googleapis.com/kubernetes-charts/alpine-1.0.0.tgz + name: alpine + description: string + version: 1.0.0 + home: https://github.com/something + keywords: + - linux + - alpine + - small + - sumtin + digest: "sha256:1234567890abcdef" + apiVersion: v2 + chartWithNoURL: + - name: chartWithNoURL + description: string + version: 1.0.0 + home: https://github.com/something + keywords: + - small + - sumtin + digest: "sha256:1234567890abcdef" + apiVersion: v2 diff --git a/pkg/repo/testdata/old-repositories.yaml b/pkg/repo/testdata/old-repositories.yaml new file mode 100644 index 00000000..3fb55b06 --- /dev/null +++ b/pkg/repo/testdata/old-repositories.yaml @@ -0,0 +1,3 @@ +best-charts-ever: http://best-charts-ever.com +okay-charts: http://okay-charts.org +example123: http://examplecharts.net/charts/123 diff --git a/pkg/repo/testdata/repositories.yaml b/pkg/repo/testdata/repositories.yaml new file mode 100644 index 00000000..a28c48ea --- /dev/null +++ b/pkg/repo/testdata/repositories.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +repositories: + - name: stable + url: https://example.com/stable/charts + cache: stable-index.yaml + - name: incubator + url: https://example.com/incubator + cache: incubator-index.yaml diff --git a/pkg/repo/testdata/repository/frobnitz-1.2.3.tgz b/pkg/repo/testdata/repository/frobnitz-1.2.3.tgz new file mode 100644 index 0000000000000000000000000000000000000000..8731dce02cc9603e7813a07670a7d057129a8b20 GIT binary patch literal 3485 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 LkUR>*0C)fZJ*e(i literal 0 HcmV?d00001 diff --git a/pkg/repo/testdata/repository/sprocket-1.1.0.tgz b/pkg/repo/testdata/repository/sprocket-1.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..48d65f491545e344400446040aae6b2c855a6a68 GIT binary patch literal 414 zcmV;P0b%|hiwG0|00000|0w_~VMtOiV@ORlOnEsqVl!4SWK%V1T2nbTPgYhoO;>Dc zVQyr3R8em|NM&qo0PK{_j@mE~fI0gqM%)Qb0*#bYrK%FAUW$4j;z6yE6i>6F zm<)j5J(DY}eLy$hZ$7JE+!p~G`G=gB)|9YGnIK;|*@X6eUP}+s1H$>13|Vb8l~9M! zl;bh|McpV$Dy!Lwc3bO&FWCr|&Mu4$|JbP8$wnZoK*2Oh6Hpp*?NZVxRS7jI{r&al z01O^an%4v~QX1xj`m)~Gg%!K2JAPYD))4$up4chWC9ye|nkH$&*+>8UPeAAY2GyGU zyEK4X{_}5n&;LBjqW=%!;tc#kP0Ul(%HQd9jDj@BCUBaH);trP|ABO)Fp%$_lF;0z z72NcHlJ)$b<^_-bKY|%4WnsThTF?K3pe3$VfZ^6UC5DGX)IAc3M4pnD00030|F3AL I;{Xr<07fvz=l}o! literal 0 HcmV?d00001 diff --git a/pkg/repo/testdata/repository/sprocket-1.2.0.tgz b/pkg/repo/testdata/repository/sprocket-1.2.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..6fdc73c2b5c1153b7f22d2a13a5fd98de4b4cc7d GIT binary patch literal 413 zcmV;O0b>3iiwG0|00000|0w_~VMtOiV@ORlOnEsqVl!4SWK%V1T2nbTPgYhoO;>Dc zVQyr3R8em|NM&qo0PK{_ZsITyfI0gqM%)P|fsK^YN~@JP?PXE#Lp&%J{)ub@rIdHC z2qIJw5{FXSB7YYrV>_OS{Ee@jEteFAe=2k#*`ijxTf;f$`DD_Job&F;v%H-<#z$$M zk9)vxp2-DPKA`LI51-X7?uUT2d?M$iH3h6kOpq^~tV8oYtE30%0pWB@`mC~=3aCP; zi{X%-QP+x+(rUJ(?Z!Ic3pRj-vvVWE3mddM*#LwUD40fR0!l-!SxOqEDxe~zf4~16 zfWgB_vx;C!O2dp$pVu2Zw_J_fI|Khv6Z2Fy^0zu2q9Dzo2^^=QHIGE=e;{2e4CLFVB-FQQ z1y}taryc(%SDc zVQyr3R8em|NM&qo0PK~`ZsITyfI0gqM%)PwSwPBZrPWHDdMWCCz=LAdKap*qKza9y zl!Pj+#G#b7$lt}z#Exg8e3LD@P@wAnE6{~xgIaYi4(FVYhr?>*oVQ1wjnigc#nZuv zXC2^|Pvt#JAJBF9r_X8^_e;Q1K9KX$njBUs6XfSkmZ7?y7Se-8fv^Gv(0?UkOmNrx1u?>MD2edT`COZn^_Xd3$=o? z{{I=Y{2vbD|NlBnNGS`uT~c}e41yN8Q~|nc>y+s3_fhvqBoetvUIG9B|NpK_u)qKi F001%(!)E{h literal 0 HcmV?d00001 diff --git a/pkg/repo/testdata/server/index.yaml b/pkg/repo/testdata/server/index.yaml new file mode 100644 index 00000000..d627928b --- /dev/null +++ b/pkg/repo/testdata/server/index.yaml @@ -0,0 +1,39 @@ +apiVersion: v1 +entries: + nginx: + - urls: + - https://charts.helm.sh/stable/nginx-0.1.0.tgz + name: nginx + description: string + version: 0.1.0 + home: https://github.com/something + digest: "sha256:1234567890abcdef" + keywords: + - popular + - web server + - proxy + - urls: + - https://charts.helm.sh/stable/nginx-0.2.0.tgz + name: nginx + description: string + version: 0.2.0 + home: https://github.com/something/else + digest: "sha256:1234567890abcdef" + keywords: + - popular + - web server + - proxy + alpine: + - urls: + - https://charts.helm.sh/stable/alpine-1.0.0.tgz + - http://storage2.googleapis.com/kubernetes-charts/alpine-1.0.0.tgz + name: alpine + description: string + version: 1.0.0 + home: https://github.com/something + keywords: + - linux + - alpine + - small + - sumtin + digest: "sha256:1234567890abcdef" diff --git a/pkg/repo/testdata/server/test.txt b/pkg/repo/testdata/server/test.txt new file mode 100644 index 00000000..557db03d --- /dev/null +++ b/pkg/repo/testdata/server/test.txt @@ -0,0 +1 @@ +Hello World diff --git a/pkg/storage/driver/cfgmaps.go b/pkg/storage/driver/cfgmaps.go new file mode 100644 index 00000000..5fd64ea5 --- /dev/null +++ b/pkg/storage/driver/cfgmaps.go @@ -0,0 +1,263 @@ +/* +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 driver // import "helm.sh/helm/v3/pkg/storage/driver" + +import ( + "context" + "strconv" + "strings" + "time" + + "github.com/pkg/errors" + v1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kblabels "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/util/validation" + corev1 "k8s.io/client-go/kubernetes/typed/core/v1" + + rspb "helm.sh/helm/v3/pkg/release" +) + +var _ Driver = (*ConfigMaps)(nil) + +// ConfigMapsDriverName is the string name of the driver. +const ConfigMapsDriverName = "ConfigMap" + +// ConfigMaps is a wrapper around an implementation of a kubernetes +// ConfigMapsInterface. +type ConfigMaps struct { + impl corev1.ConfigMapInterface + Log func(string, ...interface{}) +} + +// NewConfigMaps initializes a new ConfigMaps wrapping an implementation of +// the kubernetes ConfigMapsInterface. +func NewConfigMaps(impl corev1.ConfigMapInterface) *ConfigMaps { + return &ConfigMaps{ + impl: impl, + Log: func(_ string, _ ...interface{}) {}, + } +} + +// Name returns the name of the driver. +func (cfgmaps *ConfigMaps) Name() string { + return ConfigMapsDriverName +} + +// Get fetches the release named by key. The corresponding release is returned +// or error if not found. +func (cfgmaps *ConfigMaps) Get(key string) (*rspb.Release, error) { + // fetch the configmap holding the release named by key + obj, err := cfgmaps.impl.Get(context.Background(), key, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, ErrReleaseNotFound + } + + cfgmaps.Log("get: failed to get %q: %s", key, err) + return nil, err + } + // found the configmap, decode the base64 data string + r, err := decodeRelease(obj.Data["release"]) + if err != nil { + cfgmaps.Log("get: failed to decode data %q: %s", key, err) + return nil, err + } + r.Labels = filterSystemLabels(obj.ObjectMeta.Labels) + // return the release object + return r, nil +} + +// List fetches all releases and returns the list releases such +// that filter(release) == true. An error is returned if the +// configmap fails to retrieve the releases. +func (cfgmaps *ConfigMaps) List(filter func(*rspb.Release) bool) ([]*rspb.Release, error) { + lsel := kblabels.Set{"owner": "helm"}.AsSelector() + opts := metav1.ListOptions{LabelSelector: lsel.String()} + + list, err := cfgmaps.impl.List(context.Background(), opts) + if err != nil { + cfgmaps.Log("list: failed to list: %s", err) + return nil, err + } + + var results []*rspb.Release + + // iterate over the configmaps object list + // and decode each release + for _, item := range list.Items { + rls, err := decodeRelease(item.Data["release"]) + if err != nil { + cfgmaps.Log("list: failed to decode release: %v: %s", item, err) + continue + } + + rls.Labels = item.ObjectMeta.Labels + + if filter(rls) { + results = append(results, rls) + } + } + return results, nil +} + +// Query fetches all releases that match the provided map of labels. +// An error is returned if the configmap fails to retrieve the releases. +func (cfgmaps *ConfigMaps) Query(labels map[string]string) ([]*rspb.Release, error) { + ls := kblabels.Set{} + for k, v := range labels { + if errs := validation.IsValidLabelValue(v); len(errs) != 0 { + return nil, errors.Errorf("invalid label value: %q: %s", v, strings.Join(errs, "; ")) + } + ls[k] = v + } + + opts := metav1.ListOptions{LabelSelector: ls.AsSelector().String()} + + list, err := cfgmaps.impl.List(context.Background(), opts) + if err != nil { + cfgmaps.Log("query: failed to query with labels: %s", err) + return nil, err + } + + if len(list.Items) == 0 { + return nil, ErrReleaseNotFound + } + + var results []*rspb.Release + for _, item := range list.Items { + rls, err := decodeRelease(item.Data["release"]) + if err != nil { + cfgmaps.Log("query: failed to decode release: %s", err) + continue + } + rls.Labels = item.ObjectMeta.Labels + results = append(results, rls) + } + return results, nil +} + +// Create creates a new ConfigMap holding the release. If the +// ConfigMap already exists, ErrReleaseExists is returned. +func (cfgmaps *ConfigMaps) Create(key string, rls *rspb.Release) error { + // set labels for configmaps object meta data + var lbs labels + + lbs.init() + lbs.fromMap(rls.Labels) + lbs.set("createdAt", strconv.Itoa(int(time.Now().Unix()))) + + // create a new configmap to hold the release + obj, err := newConfigMapsObject(key, rls, lbs) + if err != nil { + cfgmaps.Log("create: failed to encode release %q: %s", rls.Name, err) + return err + } + // push the configmap object out into the kubiverse + if _, err := cfgmaps.impl.Create(context.Background(), obj, metav1.CreateOptions{}); err != nil { + if apierrors.IsAlreadyExists(err) { + return ErrReleaseExists + } + + cfgmaps.Log("create: failed to create: %s", err) + return err + } + return nil +} + +// Update updates the ConfigMap holding the release. If not found +// the ConfigMap is created to hold the release. +func (cfgmaps *ConfigMaps) Update(key string, rls *rspb.Release) error { + // set labels for configmaps object meta data + var lbs labels + + lbs.init() + lbs.fromMap(rls.Labels) + lbs.set("modifiedAt", strconv.Itoa(int(time.Now().Unix()))) + + // create a new configmap object to hold the release + obj, err := newConfigMapsObject(key, rls, lbs) + if err != nil { + cfgmaps.Log("update: failed to encode release %q: %s", rls.Name, err) + return err + } + // push the configmap object out into the kubiverse + _, err = cfgmaps.impl.Update(context.Background(), obj, metav1.UpdateOptions{}) + if err != nil { + cfgmaps.Log("update: failed to update: %s", err) + return err + } + return nil +} + +// Delete deletes the ConfigMap holding the release named by key. +func (cfgmaps *ConfigMaps) Delete(key string) (rls *rspb.Release, err error) { + // fetch the release to check existence + if rls, err = cfgmaps.Get(key); err != nil { + return nil, err + } + // delete the release + if err = cfgmaps.impl.Delete(context.Background(), key, metav1.DeleteOptions{}); err != nil { + return rls, err + } + return rls, nil +} + +// newConfigMapsObject constructs a kubernetes ConfigMap object +// to store a release. Each configmap data entry is the base64 +// encoded gzipped string of a release. +// +// The following labels are used within each configmap: +// +// "modifiedAt" - timestamp indicating when this configmap was last modified. (set in Update) +// "createdAt" - timestamp indicating when this configmap was created. (set in Create) +// "version" - version of the release. +// "status" - status of the release (see pkg/release/status.go for variants) +// "owner" - owner of the configmap, currently "helm". +// "name" - name of the release. +func newConfigMapsObject(key string, rls *rspb.Release, lbs labels) (*v1.ConfigMap, error) { + const owner = "helm" + + // encode the release + s, err := encodeRelease(rls) + if err != nil { + return nil, err + } + + if lbs == nil { + lbs.init() + } + + // apply custom labels + lbs.fromMap(rls.Labels) + + // apply labels + lbs.set("name", rls.Name) + lbs.set("owner", owner) + lbs.set("status", rls.Info.Status.String()) + lbs.set("version", strconv.Itoa(rls.Version)) + + // create and return configmap object + return &v1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: key, + Labels: lbs.toMap(), + }, + Data: map[string]string{"release": s}, + }, nil +} diff --git a/pkg/storage/driver/cfgmaps_test.go b/pkg/storage/driver/cfgmaps_test.go new file mode 100644 index 00000000..9a539d6e --- /dev/null +++ b/pkg/storage/driver/cfgmaps_test.go @@ -0,0 +1,251 @@ +/* +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 driver + +import ( + "encoding/base64" + "encoding/json" + "reflect" + "testing" + + v1 "k8s.io/api/core/v1" + + rspb "helm.sh/helm/v3/pkg/release" +) + +func TestConfigMapName(t *testing.T) { + c := newTestFixtureCfgMaps(t) + if c.Name() != ConfigMapsDriverName { + t.Errorf("Expected name to be %q, got %q", ConfigMapsDriverName, c.Name()) + } +} + +func TestConfigMapGet(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + + cfgmaps := newTestFixtureCfgMaps(t, []*rspb.Release{rel}...) + + // get release with key + got, err := cfgmaps.Get(key) + if err != nil { + t.Fatalf("Failed to get release: %s", err) + } + // compare fetched release with original + if !reflect.DeepEqual(rel, got) { + t.Errorf("Expected {%v}, got {%v}", rel, got) + } +} + +func TestUncompressedConfigMapGet(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + + // Create a test fixture which contains an uncompressed release + cfgmap, err := newConfigMapsObject(key, rel, nil) + if err != nil { + t.Fatalf("Failed to create configmap: %s", err) + } + b, err := json.Marshal(rel) + if err != nil { + t.Fatalf("Failed to marshal release: %s", err) + } + cfgmap.Data["release"] = base64.StdEncoding.EncodeToString(b) + var mock MockConfigMapsInterface + mock.objects = map[string]*v1.ConfigMap{key: cfgmap} + cfgmaps := NewConfigMaps(&mock) + + // get release with key + got, err := cfgmaps.Get(key) + if err != nil { + t.Fatalf("Failed to get release: %s", err) + } + // compare fetched release with original + if !reflect.DeepEqual(rel, got) { + t.Errorf("Expected {%v}, got {%v}", rel, got) + } +} + +func TestConfigMapList(t *testing.T) { + cfgmaps := newTestFixtureCfgMaps(t, []*rspb.Release{ + releaseStub("key-1", 1, "default", rspb.StatusUninstalled), + releaseStub("key-2", 1, "default", rspb.StatusUninstalled), + releaseStub("key-3", 1, "default", rspb.StatusDeployed), + releaseStub("key-4", 1, "default", rspb.StatusDeployed), + releaseStub("key-5", 1, "default", rspb.StatusSuperseded), + releaseStub("key-6", 1, "default", rspb.StatusSuperseded), + }...) + + // list all deleted releases + del, err := cfgmaps.List(func(rel *rspb.Release) bool { + return rel.Info.Status == rspb.StatusUninstalled + }) + // check + if err != nil { + t.Errorf("Failed to list deleted: %s", err) + } + if len(del) != 2 { + t.Errorf("Expected 2 deleted, got %d:\n%v\n", len(del), del) + } + + // list all deployed releases + dpl, err := cfgmaps.List(func(rel *rspb.Release) bool { + return rel.Info.Status == rspb.StatusDeployed + }) + // check + if err != nil { + t.Errorf("Failed to list deployed: %s", err) + } + if len(dpl) != 2 { + t.Errorf("Expected 2 deployed, got %d", len(dpl)) + } + + // list all superseded releases + ssd, err := cfgmaps.List(func(rel *rspb.Release) bool { + return rel.Info.Status == rspb.StatusSuperseded + }) + // check + if err != nil { + t.Errorf("Failed to list superseded: %s", err) + } + if len(ssd) != 2 { + t.Errorf("Expected 2 superseded, got %d", len(ssd)) + } + // Check if release having both system and custom labels, this is needed to ensure that selector filtering would work. + rls := ssd[0] + _, ok := rls.Labels["name"] + if !ok { + t.Fatalf("Expected 'name' label in results, actual %v", rls.Labels) + } + _, ok = rls.Labels["key1"] + if !ok { + t.Fatalf("Expected 'key1' label in results, actual %v", rls.Labels) + } +} + +func TestConfigMapQuery(t *testing.T) { + cfgmaps := newTestFixtureCfgMaps(t, []*rspb.Release{ + releaseStub("key-1", 1, "default", rspb.StatusUninstalled), + releaseStub("key-2", 1, "default", rspb.StatusUninstalled), + releaseStub("key-3", 1, "default", rspb.StatusDeployed), + releaseStub("key-4", 1, "default", rspb.StatusDeployed), + releaseStub("key-5", 1, "default", rspb.StatusSuperseded), + releaseStub("key-6", 1, "default", rspb.StatusSuperseded), + }...) + + rls, err := cfgmaps.Query(map[string]string{"status": "deployed"}) + if err != nil { + t.Errorf("Failed to query: %s", err) + } + if len(rls) != 2 { + t.Errorf("Expected 2 results, got %d", len(rls)) + } + + _, err = cfgmaps.Query(map[string]string{"name": "notExist"}) + if err != ErrReleaseNotFound { + t.Errorf("Expected {%v}, got {%v}", ErrReleaseNotFound, err) + } +} + +func TestConfigMapCreate(t *testing.T) { + cfgmaps := newTestFixtureCfgMaps(t) + + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + + // store the release in a configmap + if err := cfgmaps.Create(key, rel); err != nil { + t.Fatalf("Failed to create release with key %q: %s", key, err) + } + + // get the release back + got, err := cfgmaps.Get(key) + if err != nil { + t.Fatalf("Failed to get release with key %q: %s", key, err) + } + + // compare created release with original + if !reflect.DeepEqual(rel, got) { + t.Errorf("Expected {%v}, got {%v}", rel, got) + } +} + +func TestConfigMapUpdate(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + + cfgmaps := newTestFixtureCfgMaps(t, []*rspb.Release{rel}...) + + // modify release status code + rel.Info.Status = rspb.StatusSuperseded + + // perform the update + if err := cfgmaps.Update(key, rel); err != nil { + t.Fatalf("Failed to update release: %s", err) + } + + // fetch the updated release + got, err := cfgmaps.Get(key) + if err != nil { + t.Fatalf("Failed to get release with key %q: %s", key, err) + } + + // check release has actually been updated by comparing modified fields + if rel.Info.Status != got.Info.Status { + t.Errorf("Expected status %s, got status %s", rel.Info.Status.String(), got.Info.Status.String()) + } +} + +func TestConfigMapDelete(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + + cfgmaps := newTestFixtureCfgMaps(t, []*rspb.Release{rel}...) + + // perform the delete on a non-existent release + _, err := cfgmaps.Delete("nonexistent") + if err != ErrReleaseNotFound { + t.Fatalf("Expected ErrReleaseNotFound: got {%v}", err) + } + + // perform the delete + rls, err := cfgmaps.Delete(key) + if err != nil { + t.Fatalf("Failed to delete release with key %q: %s", key, err) + } + if !reflect.DeepEqual(rel, rls) { + t.Errorf("Expected {%v}, got {%v}", rel, rls) + } + + // fetch the deleted release + _, err = cfgmaps.Get(key) + if !reflect.DeepEqual(ErrReleaseNotFound, err) { + t.Errorf("Expected {%v}, got {%v}", ErrReleaseNotFound, err) + } +} diff --git a/pkg/storage/driver/driver.go b/pkg/storage/driver/driver.go new file mode 100644 index 00000000..9c01f376 --- /dev/null +++ b/pkg/storage/driver/driver.go @@ -0,0 +1,105 @@ +/* +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 driver // import "helm.sh/helm/v3/pkg/storage/driver" + +import ( + "fmt" + + "github.com/pkg/errors" + + rspb "helm.sh/helm/v3/pkg/release" +) + +var ( + // ErrReleaseNotFound indicates that a release is not found. + ErrReleaseNotFound = errors.New("release: not found") + // ErrReleaseExists indicates that a release already exists. + ErrReleaseExists = errors.New("release: already exists") + // ErrInvalidKey indicates that a release key could not be parsed. + ErrInvalidKey = errors.New("release: invalid key") + // ErrNoDeployedReleases indicates that there are no releases with the given key in the deployed state + ErrNoDeployedReleases = errors.New("has no deployed releases") +) + +// StorageDriverError records an error and the release name that caused it +type StorageDriverError struct { + ReleaseName string + Err error +} + +func (e *StorageDriverError) Error() string { + return fmt.Sprintf("%q %s", e.ReleaseName, e.Err.Error()) +} + +func (e *StorageDriverError) Unwrap() error { return e.Err } + +func NewErrNoDeployedReleases(releaseName string) error { + return &StorageDriverError{ + ReleaseName: releaseName, + Err: ErrNoDeployedReleases, + } +} + +// Creator is the interface that wraps the Create method. +// +// Create stores the release or returns ErrReleaseExists +// if an identical release already exists. +type Creator interface { + Create(key string, rls *rspb.Release) error +} + +// Updator is the interface that wraps the Update method. +// +// Update updates an existing release or returns +// ErrReleaseNotFound if the release does not exist. +type Updator interface { + Update(key string, rls *rspb.Release) error +} + +// Deletor is the interface that wraps the Delete method. +// +// Delete deletes the release named by key or returns +// ErrReleaseNotFound if the release does not exist. +type Deletor interface { + Delete(key string) (*rspb.Release, error) +} + +// Queryor is the interface that wraps the Get and List methods. +// +// Get returns the release named by key or returns ErrReleaseNotFound +// if the release does not exist. +// +// List returns the set of all releases that satisfy the filter predicate. +// +// Query returns the set of all releases that match the provided label set. +type Queryor interface { + Get(key string) (*rspb.Release, error) + List(filter func(*rspb.Release) bool) ([]*rspb.Release, error) + Query(labels map[string]string) ([]*rspb.Release, error) +} + +// Driver is the interface composed of Creator, Updator, Deletor, and Queryor +// interfaces. It defines the behavior for storing, updating, deleted, +// and retrieving Helm releases from some underlying storage mechanism, +// e.g. memory, configmaps. +type Driver interface { + Creator + Updator + Deletor + Queryor + Name() string +} diff --git a/pkg/storage/driver/labels.go b/pkg/storage/driver/labels.go new file mode 100644 index 00000000..eb7118fe --- /dev/null +++ b/pkg/storage/driver/labels.go @@ -0,0 +1,48 @@ +/* +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 driver + +// labels is a map of key value pairs to be included as metadata in a configmap object. +type labels map[string]string + +func (lbs *labels) init() { *lbs = labels(make(map[string]string)) } +func (lbs labels) get(key string) string { return lbs[key] } +func (lbs labels) set(key, val string) { lbs[key] = val } + +func (lbs labels) keys() (ls []string) { + for key := range lbs { + ls = append(ls, key) + } + return +} + +func (lbs labels) match(set labels) bool { + for _, key := range set.keys() { + if lbs.get(key) != set.get(key) { + return false + } + } + return true +} + +func (lbs labels) toMap() map[string]string { return lbs } + +func (lbs *labels) fromMap(kvs map[string]string) { + for k, v := range kvs { + lbs.set(k, v) + } +} diff --git a/pkg/storage/driver/labels_test.go b/pkg/storage/driver/labels_test.go new file mode 100644 index 00000000..bfd80911 --- /dev/null +++ b/pkg/storage/driver/labels_test.go @@ -0,0 +1,49 @@ +/* +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 driver // import "helm.sh/helm/v3/pkg/storage/driver" + +import ( + "testing" +) + +func TestLabelsMatch(t *testing.T) { + var tests = []struct { + desc string + set1 labels + set2 labels + expect bool + }{ + { + "equal labels sets", + labels(map[string]string{"KEY_A": "VAL_A", "KEY_B": "VAL_B"}), + labels(map[string]string{"KEY_A": "VAL_A", "KEY_B": "VAL_B"}), + true, + }, + { + "disjoint label sets", + labels(map[string]string{"KEY_C": "VAL_C", "KEY_D": "VAL_D"}), + labels(map[string]string{"KEY_A": "VAL_A", "KEY_B": "VAL_B"}), + false, + }, + } + + for _, tt := range tests { + if !tt.set1.match(tt.set2) && tt.expect { + t.Fatalf("Expected match '%s'\n", tt.desc) + } + } +} diff --git a/pkg/storage/driver/memory.go b/pkg/storage/driver/memory.go new file mode 100644 index 00000000..91378f58 --- /dev/null +++ b/pkg/storage/driver/memory.go @@ -0,0 +1,240 @@ +/* +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 driver + +import ( + "strconv" + "strings" + "sync" + + rspb "helm.sh/helm/v3/pkg/release" +) + +var _ Driver = (*Memory)(nil) + +const ( + // MemoryDriverName is the string name of this driver. + MemoryDriverName = "Memory" + + defaultNamespace = "default" +) + +// A map of release names to list of release records +type memReleases map[string]records + +// Memory is the in-memory storage driver implementation. +type Memory struct { + sync.RWMutex + namespace string + // A map of namespaces to releases + cache map[string]memReleases +} + +// NewMemory initializes a new memory driver. +func NewMemory() *Memory { + return &Memory{cache: map[string]memReleases{}, namespace: "default"} +} + +// SetNamespace sets a specific namespace in which releases will be accessed. +// An empty string indicates all namespaces (for the list operation) +func (mem *Memory) SetNamespace(ns string) { + mem.namespace = ns +} + +// Name returns the name of the driver. +func (mem *Memory) Name() string { + return MemoryDriverName +} + +// Get returns the release named by key or returns ErrReleaseNotFound. +func (mem *Memory) Get(key string) (*rspb.Release, error) { + defer unlock(mem.rlock()) + + keyWithoutPrefix := strings.TrimPrefix(key, "sh.helm.release.v1.") + switch elems := strings.Split(keyWithoutPrefix, ".v"); len(elems) { + case 2: + name, ver := elems[0], elems[1] + if _, err := strconv.Atoi(ver); err != nil { + return nil, ErrInvalidKey + } + if recs, ok := mem.cache[mem.namespace][name]; ok { + if r := recs.Get(key); r != nil { + return r.rls, nil + } + } + return nil, ErrReleaseNotFound + default: + return nil, ErrInvalidKey + } +} + +// List returns the list of all releases such that filter(release) == true +func (mem *Memory) List(filter func(*rspb.Release) bool) ([]*rspb.Release, error) { + defer unlock(mem.rlock()) + + var ls []*rspb.Release + for namespace := range mem.cache { + if mem.namespace != "" { + // Should only list releases of this namespace + namespace = mem.namespace + } + for _, recs := range mem.cache[namespace] { + recs.Iter(func(_ int, rec *record) bool { + if filter(rec.rls) { + ls = append(ls, rec.rls) + } + return true + }) + } + if mem.namespace != "" { + // Should only list releases of this namespace + break + } + } + return ls, nil +} + +// Query returns the set of releases that match the provided set of labels +func (mem *Memory) Query(keyvals map[string]string) ([]*rspb.Release, error) { + defer unlock(mem.rlock()) + + var lbs labels + + lbs.init() + lbs.fromMap(keyvals) + + var ls []*rspb.Release + for namespace := range mem.cache { + if mem.namespace != "" { + // Should only query releases of this namespace + namespace = mem.namespace + } + for _, recs := range mem.cache[namespace] { + recs.Iter(func(_ int, rec *record) bool { + // A query for a release name that doesn't exist (has been deleted) + // can cause rec to be nil. + if rec == nil { + return false + } + if rec.lbs.match(lbs) { + ls = append(ls, rec.rls) + } + return true + }) + } + if mem.namespace != "" { + // Should only query releases of this namespace + break + } + } + + if len(ls) == 0 { + return nil, ErrReleaseNotFound + } + + return ls, nil +} + +// Create creates a new release or returns ErrReleaseExists. +func (mem *Memory) Create(key string, rls *rspb.Release) error { + defer unlock(mem.wlock()) + + // For backwards compatibility, we protect against an unset namespace + namespace := rls.Namespace + if namespace == "" { + namespace = defaultNamespace + } + mem.SetNamespace(namespace) + + if _, ok := mem.cache[namespace]; !ok { + mem.cache[namespace] = memReleases{} + } + + if recs, ok := mem.cache[namespace][rls.Name]; ok { + if err := recs.Add(newRecord(key, rls)); err != nil { + return err + } + mem.cache[namespace][rls.Name] = recs + return nil + } + mem.cache[namespace][rls.Name] = records{newRecord(key, rls)} + return nil +} + +// Update updates a release or returns ErrReleaseNotFound. +func (mem *Memory) Update(key string, rls *rspb.Release) error { + defer unlock(mem.wlock()) + + // For backwards compatibility, we protect against an unset namespace + namespace := rls.Namespace + if namespace == "" { + namespace = defaultNamespace + } + mem.SetNamespace(namespace) + + if _, ok := mem.cache[namespace]; ok { + if rs, ok := mem.cache[namespace][rls.Name]; ok && rs.Exists(key) { + rs.Replace(key, newRecord(key, rls)) + return nil + } + } + return ErrReleaseNotFound +} + +// Delete deletes a release or returns ErrReleaseNotFound. +func (mem *Memory) Delete(key string) (*rspb.Release, error) { + defer unlock(mem.wlock()) + + keyWithoutPrefix := strings.TrimPrefix(key, "sh.helm.release.v1.") + elems := strings.Split(keyWithoutPrefix, ".v") + + if len(elems) != 2 { + return nil, ErrInvalidKey + } + + name, ver := elems[0], elems[1] + if _, err := strconv.Atoi(ver); err != nil { + return nil, ErrInvalidKey + } + if _, ok := mem.cache[mem.namespace]; ok { + if recs, ok := mem.cache[mem.namespace][name]; ok { + if r := recs.Remove(key); r != nil { + // recs.Remove changes the slice reference, so we have to re-assign it. + mem.cache[mem.namespace][name] = recs + return r.rls, nil + } + } + } + return nil, ErrReleaseNotFound +} + +// wlock locks mem for writing +func (mem *Memory) wlock() func() { + mem.Lock() + return func() { mem.Unlock() } +} + +// rlock locks mem for reading +func (mem *Memory) rlock() func() { + mem.RLock() + return func() { mem.RUnlock() } +} + +// unlock calls fn which reverses a mem.rlock or mem.wlock. e.g: +// ```defer unlock(mem.rlock())```, locks mem for reading at the +// call point of defer and unlocks upon exiting the block. +func unlock(fn func()) { fn() } diff --git a/pkg/storage/driver/memory_test.go b/pkg/storage/driver/memory_test.go new file mode 100644 index 00000000..7a2e8578 --- /dev/null +++ b/pkg/storage/driver/memory_test.go @@ -0,0 +1,289 @@ +/* +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 driver + +import ( + "fmt" + "reflect" + "testing" + + rspb "helm.sh/helm/v3/pkg/release" +) + +func TestMemoryName(t *testing.T) { + if mem := NewMemory(); mem.Name() != MemoryDriverName { + t.Errorf("Expected name to be %q, got %q", MemoryDriverName, mem.Name()) + } +} + +func TestMemoryCreate(t *testing.T) { + var tests = []struct { + desc string + rls *rspb.Release + err bool + }{ + { + "create should succeed", + releaseStub("rls-c", 1, "default", rspb.StatusDeployed), + false, + }, + { + "create should fail (release already exists)", + releaseStub("rls-a", 1, "default", rspb.StatusDeployed), + true, + }, + { + "create in namespace should succeed", + releaseStub("rls-a", 1, "mynamespace", rspb.StatusDeployed), + false, + }, + { + "create in other namespace should fail (release already exists)", + releaseStub("rls-c", 1, "mynamespace", rspb.StatusDeployed), + true, + }, + } + + ts := tsFixtureMemory(t) + for _, tt := range tests { + key := testKey(tt.rls.Name, tt.rls.Version) + rls := tt.rls + + if err := ts.Create(key, rls); err != nil { + if !tt.err { + t.Fatalf("failed to create %q: %s", tt.desc, err) + } + } else if tt.err { + t.Fatalf("Did not get expected error for %q\n", tt.desc) + } + } +} + +func TestMemoryGet(t *testing.T) { + var tests = []struct { + desc string + key string + namespace string + err bool + }{ + {"release key should exist", "rls-a.v1", "default", false}, + {"release key should not exist", "rls-a.v5", "default", true}, + {"release key in namespace should exist", "rls-c.v1", "mynamespace", false}, + {"release key in namespace should not exist", "rls-a.v1", "mynamespace", true}, + } + + ts := tsFixtureMemory(t) + for _, tt := range tests { + ts.SetNamespace(tt.namespace) + if _, err := ts.Get(tt.key); err != nil { + if !tt.err { + t.Fatalf("Failed %q to get '%s': %q\n", tt.desc, tt.key, err) + } + } else if tt.err { + t.Fatalf("Did not get expected error for %q '%s'\n", tt.desc, tt.key) + } + } +} + +func TestMemoryList(t *testing.T) { + ts := tsFixtureMemory(t) + ts.SetNamespace("default") + + // list all deployed releases + dpl, err := ts.List(func(rel *rspb.Release) bool { + return rel.Info.Status == rspb.StatusDeployed + }) + // check + if err != nil { + t.Errorf("Failed to list deployed releases: %s", err) + } + if len(dpl) != 2 { + t.Errorf("Expected 2 deployed, got %d", len(dpl)) + } + + // list all superseded releases + ssd, err := ts.List(func(rel *rspb.Release) bool { + return rel.Info.Status == rspb.StatusSuperseded + }) + // check + if err != nil { + t.Errorf("Failed to list superseded releases: %s", err) + } + if len(ssd) != 6 { + t.Errorf("Expected 6 superseded, got %d", len(ssd)) + } + + // list all deleted releases + del, err := ts.List(func(rel *rspb.Release) bool { + return rel.Info.Status == rspb.StatusUninstalled + }) + // check + if err != nil { + t.Errorf("Failed to list deleted releases: %s", err) + } + if len(del) != 0 { + t.Errorf("Expected 0 deleted, got %d", len(del)) + } +} + +func TestMemoryQuery(t *testing.T) { + var tests = []struct { + desc string + xlen int + namespace string + lbs map[string]string + }{ + { + "should be 2 query results", + 2, + "default", + map[string]string{"status": "deployed"}, + }, + { + "should be 1 query result", + 1, + "mynamespace", + map[string]string{"status": "deployed"}, + }, + } + + ts := tsFixtureMemory(t) + for _, tt := range tests { + ts.SetNamespace(tt.namespace) + l, err := ts.Query(tt.lbs) + if err != nil { + t.Fatalf("Failed to query: %s\n", err) + } + + if tt.xlen != len(l) { + t.Fatalf("Expected %d results, actual %d\n", tt.xlen, len(l)) + } + } +} + +func TestMemoryUpdate(t *testing.T) { + var tests = []struct { + desc string + key string + rls *rspb.Release + err bool + }{ + { + "update release status", + "rls-a.v4", + releaseStub("rls-a", 4, "default", rspb.StatusSuperseded), + false, + }, + { + "update release does not exist", + "rls-c.v1", + releaseStub("rls-c", 1, "default", rspb.StatusUninstalled), + true, + }, + { + "update release status in namespace", + "rls-c.v4", + releaseStub("rls-c", 4, "mynamespace", rspb.StatusSuperseded), + false, + }, + { + "update release in namespace does not exist", + "rls-a.v1", + releaseStub("rls-a", 1, "mynamespace", rspb.StatusUninstalled), + true, + }, + } + + ts := tsFixtureMemory(t) + for _, tt := range tests { + if err := ts.Update(tt.key, tt.rls); err != nil { + if !tt.err { + t.Fatalf("Failed %q: %s\n", tt.desc, err) + } + continue + } else if tt.err { + t.Fatalf("Did not get expected error for %q '%s'\n", tt.desc, tt.key) + } + + ts.SetNamespace(tt.rls.Namespace) + r, err := ts.Get(tt.key) + if err != nil { + t.Fatalf("Failed to get: %s\n", err) + } + + if !reflect.DeepEqual(r, tt.rls) { + t.Fatalf("Expected %v, actual %v\n", tt.rls, r) + } + } +} + +func TestMemoryDelete(t *testing.T) { + var tests = []struct { + desc string + key string + namespace string + err bool + }{ + {"release key should exist", "rls-a.v4", "default", false}, + {"release key should not exist", "rls-a.v5", "default", true}, + {"release key from other namespace should not exist", "rls-c.v4", "default", true}, + {"release key from namespace should exist", "rls-c.v4", "mynamespace", false}, + {"release key from namespace should not exist", "rls-c.v5", "mynamespace", true}, + {"release key from namespace2 should not exist", "rls-a.v4", "mynamespace", true}, + } + + ts := tsFixtureMemory(t) + ts.SetNamespace("") + start, err := ts.Query(map[string]string{"status": "deployed"}) + if err != nil { + t.Errorf("Query failed: %s", err) + } + startLen := len(start) + for _, tt := range tests { + ts.SetNamespace(tt.namespace) + if rel, err := ts.Delete(tt.key); err != nil { + if !tt.err { + t.Fatalf("Failed %q to get '%s': %q\n", tt.desc, tt.key, err) + } + continue + } else if tt.err { + t.Fatalf("Did not get expected error for %q '%s'\n", tt.desc, tt.key) + } else if fmt.Sprintf("%s.v%d", rel.Name, rel.Version) != tt.key { + t.Fatalf("Asked for delete on %s, but deleted %d", tt.key, rel.Version) + } + _, err := ts.Get(tt.key) + if err == nil { + t.Errorf("Expected an error when asking for a deleted key") + } + } + + // Make sure that the deleted records are gone. + ts.SetNamespace("") + end, err := ts.Query(map[string]string{"status": "deployed"}) + if err != nil { + t.Errorf("Query failed: %s", err) + } + endLen := len(end) + + if startLen-2 != endLen { + t.Errorf("expected end to be %d instead of %d", startLen-2, endLen) + for _, ee := range end { + t.Logf("Name: %s, Version: %d", ee.Name, ee.Version) + } + } + +} diff --git a/pkg/storage/driver/mock_test.go b/pkg/storage/driver/mock_test.go new file mode 100644 index 00000000..1c13e1db --- /dev/null +++ b/pkg/storage/driver/mock_test.go @@ -0,0 +1,269 @@ +/* +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 driver // import "helm.sh/helm/v3/pkg/storage/driver" + +import ( + "context" + "fmt" + "testing" + + sqlmock "github.com/DATA-DOG/go-sqlmock" + sq "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" + + v1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kblabels "k8s.io/apimachinery/pkg/labels" + corev1 "k8s.io/client-go/kubernetes/typed/core/v1" + + rspb "helm.sh/helm/v3/pkg/release" +) + +func releaseStub(name string, vers int, namespace string, status rspb.Status) *rspb.Release { + return &rspb.Release{ + Name: name, + Version: vers, + Namespace: namespace, + Info: &rspb.Info{Status: status}, + Labels: map[string]string{ + "key1": "val1", + "key2": "val2", + }, + } +} + +func testKey(name string, vers int) string { + return fmt.Sprintf("%s.v%d", name, vers) +} + +func tsFixtureMemory(t *testing.T) *Memory { + hs := []*rspb.Release{ + // rls-a + releaseStub("rls-a", 4, "default", rspb.StatusDeployed), + releaseStub("rls-a", 1, "default", rspb.StatusSuperseded), + releaseStub("rls-a", 3, "default", rspb.StatusSuperseded), + releaseStub("rls-a", 2, "default", rspb.StatusSuperseded), + // rls-b + releaseStub("rls-b", 4, "default", rspb.StatusDeployed), + releaseStub("rls-b", 1, "default", rspb.StatusSuperseded), + releaseStub("rls-b", 3, "default", rspb.StatusSuperseded), + releaseStub("rls-b", 2, "default", rspb.StatusSuperseded), + // rls-c in other namespace + releaseStub("rls-c", 4, "mynamespace", rspb.StatusDeployed), + releaseStub("rls-c", 1, "mynamespace", rspb.StatusSuperseded), + releaseStub("rls-c", 3, "mynamespace", rspb.StatusSuperseded), + releaseStub("rls-c", 2, "mynamespace", rspb.StatusSuperseded), + } + + mem := NewMemory() + for _, tt := range hs { + err := mem.Create(testKey(tt.Name, tt.Version), tt) + if err != nil { + t.Fatalf("Test setup failed to create: %s\n", err) + } + } + return mem +} + +// newTestFixture initializes a MockConfigMapsInterface. +// ConfigMaps are created for each release provided. +func newTestFixtureCfgMaps(t *testing.T, releases ...*rspb.Release) *ConfigMaps { + var mock MockConfigMapsInterface + mock.Init(t, releases...) + + return NewConfigMaps(&mock) +} + +// MockConfigMapsInterface mocks a kubernetes ConfigMapsInterface +type MockConfigMapsInterface struct { + corev1.ConfigMapInterface + + objects map[string]*v1.ConfigMap +} + +// Init initializes the MockConfigMapsInterface with the set of releases. +func (mock *MockConfigMapsInterface) Init(t *testing.T, releases ...*rspb.Release) { + mock.objects = map[string]*v1.ConfigMap{} + + for _, rls := range releases { + objkey := testKey(rls.Name, rls.Version) + + cfgmap, err := newConfigMapsObject(objkey, rls, nil) + if err != nil { + t.Fatalf("Failed to create configmap: %s", err) + } + mock.objects[objkey] = cfgmap + } +} + +// Get returns the ConfigMap by name. +func (mock *MockConfigMapsInterface) Get(_ context.Context, name string, _ metav1.GetOptions) (*v1.ConfigMap, error) { + object, ok := mock.objects[name] + if !ok { + return nil, apierrors.NewNotFound(v1.Resource("tests"), name) + } + return object, nil +} + +// List returns the a of ConfigMaps. +func (mock *MockConfigMapsInterface) List(_ context.Context, opts metav1.ListOptions) (*v1.ConfigMapList, error) { + var list v1.ConfigMapList + + labelSelector, err := kblabels.Parse(opts.LabelSelector) + if err != nil { + return nil, err + } + + for _, cfgmap := range mock.objects { + if labelSelector.Matches(kblabels.Set(cfgmap.ObjectMeta.Labels)) { + list.Items = append(list.Items, *cfgmap) + } + } + return &list, nil +} + +// Create creates a new ConfigMap. +func (mock *MockConfigMapsInterface) Create(_ context.Context, cfgmap *v1.ConfigMap, _ metav1.CreateOptions) (*v1.ConfigMap, error) { + name := cfgmap.ObjectMeta.Name + if object, ok := mock.objects[name]; ok { + return object, apierrors.NewAlreadyExists(v1.Resource("tests"), name) + } + mock.objects[name] = cfgmap + return cfgmap, nil +} + +// Update updates a ConfigMap. +func (mock *MockConfigMapsInterface) Update(_ context.Context, cfgmap *v1.ConfigMap, _ metav1.UpdateOptions) (*v1.ConfigMap, error) { + name := cfgmap.ObjectMeta.Name + if _, ok := mock.objects[name]; !ok { + return nil, apierrors.NewNotFound(v1.Resource("tests"), name) + } + mock.objects[name] = cfgmap + return cfgmap, nil +} + +// Delete deletes a ConfigMap by name. +func (mock *MockConfigMapsInterface) Delete(_ context.Context, name string, _ metav1.DeleteOptions) error { + if _, ok := mock.objects[name]; !ok { + return apierrors.NewNotFound(v1.Resource("tests"), name) + } + delete(mock.objects, name) + return nil +} + +// newTestFixture initializes a MockSecretsInterface. +// Secrets are created for each release provided. +func newTestFixtureSecrets(t *testing.T, releases ...*rspb.Release) *Secrets { + var mock MockSecretsInterface + mock.Init(t, releases...) + + return NewSecrets(&mock) +} + +// MockSecretsInterface mocks a kubernetes SecretsInterface +type MockSecretsInterface struct { + corev1.SecretInterface + + objects map[string]*v1.Secret +} + +// Init initializes the MockSecretsInterface with the set of releases. +func (mock *MockSecretsInterface) Init(t *testing.T, releases ...*rspb.Release) { + mock.objects = map[string]*v1.Secret{} + + for _, rls := range releases { + objkey := testKey(rls.Name, rls.Version) + + secret, err := newSecretsObject(objkey, rls, nil) + if err != nil { + t.Fatalf("Failed to create secret: %s", err) + } + mock.objects[objkey] = secret + } +} + +// Get returns the Secret by name. +func (mock *MockSecretsInterface) Get(_ context.Context, name string, _ metav1.GetOptions) (*v1.Secret, error) { + object, ok := mock.objects[name] + if !ok { + return nil, apierrors.NewNotFound(v1.Resource("tests"), name) + } + return object, nil +} + +// List returns the a of Secret. +func (mock *MockSecretsInterface) List(_ context.Context, opts metav1.ListOptions) (*v1.SecretList, error) { + var list v1.SecretList + + labelSelector, err := kblabels.Parse(opts.LabelSelector) + if err != nil { + return nil, err + } + + for _, secret := range mock.objects { + if labelSelector.Matches(kblabels.Set(secret.ObjectMeta.Labels)) { + list.Items = append(list.Items, *secret) + } + } + return &list, nil +} + +// Create creates a new Secret. +func (mock *MockSecretsInterface) Create(_ context.Context, secret *v1.Secret, _ metav1.CreateOptions) (*v1.Secret, error) { + name := secret.ObjectMeta.Name + if object, ok := mock.objects[name]; ok { + return object, apierrors.NewAlreadyExists(v1.Resource("tests"), name) + } + mock.objects[name] = secret + return secret, nil +} + +// Update updates a Secret. +func (mock *MockSecretsInterface) Update(_ context.Context, secret *v1.Secret, _ metav1.UpdateOptions) (*v1.Secret, error) { + name := secret.ObjectMeta.Name + if _, ok := mock.objects[name]; !ok { + return nil, apierrors.NewNotFound(v1.Resource("tests"), name) + } + mock.objects[name] = secret + return secret, nil +} + +// Delete deletes a Secret by name. +func (mock *MockSecretsInterface) Delete(_ context.Context, name string, _ metav1.DeleteOptions) error { + if _, ok := mock.objects[name]; !ok { + return apierrors.NewNotFound(v1.Resource("tests"), name) + } + delete(mock.objects, name) + return nil +} + +// newTestFixtureSQL mocks the SQL database (for testing purposes) +func newTestFixtureSQL(t *testing.T, _ ...*rspb.Release) (*SQL, sqlmock.Sqlmock) { + sqlDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("error when opening stub database connection: %v", err) + } + + sqlxDB := sqlx.NewDb(sqlDB, "sqlmock") + return &SQL{ + db: sqlxDB, + Log: func(a string, b ...interface{}) {}, + namespace: "default", + statementBuilder: sq.StatementBuilder.PlaceholderFormat(sq.Dollar), + }, mock +} diff --git a/pkg/storage/driver/records.go b/pkg/storage/driver/records.go new file mode 100644 index 00000000..9df17338 --- /dev/null +++ b/pkg/storage/driver/records.go @@ -0,0 +1,124 @@ +/* +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 driver // import "helm.sh/helm/v3/pkg/storage/driver" + +import ( + "sort" + "strconv" + + rspb "helm.sh/helm/v3/pkg/release" +) + +// records holds a list of in-memory release records +type records []*record + +func (rs records) Len() int { return len(rs) } +func (rs records) Swap(i, j int) { rs[i], rs[j] = rs[j], rs[i] } +func (rs records) Less(i, j int) bool { return rs[i].rls.Version < rs[j].rls.Version } + +func (rs *records) Add(r *record) error { + if r == nil { + return nil + } + + if rs.Exists(r.key) { + return ErrReleaseExists + } + + *rs = append(*rs, r) + sort.Sort(*rs) + + return nil +} + +func (rs records) Get(key string) *record { + if i, ok := rs.Index(key); ok { + return rs[i] + } + return nil +} + +func (rs *records) Iter(fn func(int, *record) bool) { + cp := make([]*record, len(*rs)) + copy(cp, *rs) + + for i, r := range cp { + if !fn(i, r) { + return + } + } +} + +func (rs *records) Index(key string) (int, bool) { + for i, r := range *rs { + if r.key == key { + return i, true + } + } + return -1, false +} + +func (rs records) Exists(key string) bool { + _, ok := rs.Index(key) + return ok +} + +func (rs *records) Remove(key string) (r *record) { + if i, ok := rs.Index(key); ok { + return rs.removeAt(i) + } + return nil +} + +func (rs *records) Replace(key string, rec *record) *record { + if i, ok := rs.Index(key); ok { + old := (*rs)[i] + (*rs)[i] = rec + return old + } + return nil +} + +func (rs *records) removeAt(index int) *record { + r := (*rs)[index] + (*rs)[index] = nil + copy((*rs)[index:], (*rs)[index+1:]) + *rs = (*rs)[:len(*rs)-1] + return r +} + +// record is the data structure used to cache releases +// for the in-memory storage driver +type record struct { + key string + lbs labels + rls *rspb.Release +} + +// newRecord creates a new in-memory release record +func newRecord(key string, rls *rspb.Release) *record { + var lbs labels + + lbs.init() + lbs.set("name", rls.Name) + lbs.set("owner", "helm") + lbs.set("status", rls.Info.Status.String()) + lbs.set("version", strconv.Itoa(rls.Version)) + + // return &record{key: key, lbs: lbs, rls: proto.Clone(rls).(*rspb.Release)} + return &record{key: key, lbs: lbs, rls: rls} +} diff --git a/pkg/storage/driver/records_test.go b/pkg/storage/driver/records_test.go new file mode 100644 index 00000000..0a27839c --- /dev/null +++ b/pkg/storage/driver/records_test.go @@ -0,0 +1,240 @@ +/* +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 driver // import "helm.sh/helm/v3/pkg/storage/driver" + +import ( + "reflect" + "testing" + + rspb "helm.sh/helm/v3/pkg/release" +) + +func TestRecordsAdd(t *testing.T) { + rs := records([]*record{ + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", rspb.StatusDeployed)), + }) + + var tests = []struct { + desc string + key string + ok bool + rec *record + }{ + { + "add valid key", + "rls-a.v3", + false, + newRecord("rls-a.v3", releaseStub("rls-a", 3, "default", rspb.StatusSuperseded)), + }, + { + "add already existing key", + "rls-a.v1", + true, + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusDeployed)), + }, + } + + for _, tt := range tests { + if err := rs.Add(tt.rec); err != nil { + if !tt.ok { + t.Fatalf("failed: %q: %s\n", tt.desc, err) + } + } + } +} + +func TestRecordsRemove(t *testing.T) { + var tests = []struct { + desc string + key string + ok bool + }{ + {"remove valid key", "rls-a.v1", false}, + {"remove invalid key", "rls-a.v", true}, + {"remove non-existent key", "rls-z.v1", true}, + } + + rs := records([]*record{ + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", rspb.StatusDeployed)), + }) + + startLen := rs.Len() + + for _, tt := range tests { + if r := rs.Remove(tt.key); r == nil { + if !tt.ok { + t.Fatalf("Failed to %q (key = %s). Expected nil, got %v", + tt.desc, + tt.key, + r, + ) + } + } + } + + // We expect the total number of records will be less now than there were + // when we started. + endLen := rs.Len() + if endLen >= startLen { + t.Errorf("expected ending length %d to be less than starting length %d", endLen, startLen) + } +} + +func TestRecordsRemoveAt(t *testing.T) { + rs := records([]*record{ + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", rspb.StatusDeployed)), + }) + + if len(rs) != 2 { + t.Fatal("Expected len=2 for mock") + } + + rs.Remove("rls-a.v1") + if len(rs) != 1 { + t.Fatalf("Expected length of rs to be 1, got %d", len(rs)) + } +} + +func TestRecordsGet(t *testing.T) { + rs := records([]*record{ + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", rspb.StatusDeployed)), + }) + + var tests = []struct { + desc string + key string + rec *record + }{ + { + "get valid key", + "rls-a.v1", + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusSuperseded)), + }, + { + "get invalid key", + "rls-a.v3", + nil, + }, + } + + for _, tt := range tests { + got := rs.Get(tt.key) + if !reflect.DeepEqual(tt.rec, got) { + t.Fatalf("Expected %v, got %v", tt.rec, got) + } + } +} + +func TestRecordsIndex(t *testing.T) { + rs := records([]*record{ + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", rspb.StatusDeployed)), + }) + + var tests = []struct { + desc string + key string + sort int + }{ + { + "get valid key", + "rls-a.v1", + 0, + }, + { + "get invalid key", + "rls-a.v3", + -1, + }, + } + + for _, tt := range tests { + got, _ := rs.Index(tt.key) + if got != tt.sort { + t.Fatalf("Expected %d, got %d", tt.sort, got) + } + } +} + +func TestRecordsExists(t *testing.T) { + rs := records([]*record{ + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", rspb.StatusDeployed)), + }) + + var tests = []struct { + desc string + key string + ok bool + }{ + { + "get valid key", + "rls-a.v1", + true, + }, + { + "get invalid key", + "rls-a.v3", + false, + }, + } + + for _, tt := range tests { + got := rs.Exists(tt.key) + if got != tt.ok { + t.Fatalf("Expected %t, got %t", tt.ok, got) + } + } +} + +func TestRecordsReplace(t *testing.T) { + rs := records([]*record{ + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", rspb.StatusDeployed)), + }) + + var tests = []struct { + desc string + key string + rec *record + expected *record + }{ + { + "replace with existing key", + "rls-a.v2", + newRecord("rls-a.v3", releaseStub("rls-a", 3, "default", rspb.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", rspb.StatusDeployed)), + }, + { + "replace with non existing key", + "rls-a.v4", + newRecord("rls-a.v4", releaseStub("rls-a", 4, "default", rspb.StatusDeployed)), + nil, + }, + } + + for _, tt := range tests { + got := rs.Replace(tt.key, tt.rec) + if !reflect.DeepEqual(tt.expected, got) { + t.Fatalf("Expected %v, got %v", tt.expected, got) + } + } +} diff --git a/pkg/storage/driver/secrets.go b/pkg/storage/driver/secrets.go new file mode 100644 index 00000000..9c2f805f --- /dev/null +++ b/pkg/storage/driver/secrets.go @@ -0,0 +1,256 @@ +/* +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 driver // import "helm.sh/helm/v3/pkg/storage/driver" + +import ( + "context" + "strconv" + "strings" + "time" + + "github.com/pkg/errors" + v1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kblabels "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/util/validation" + corev1 "k8s.io/client-go/kubernetes/typed/core/v1" + + rspb "helm.sh/helm/v3/pkg/release" +) + +var _ Driver = (*Secrets)(nil) + +// SecretsDriverName is the string name of the driver. +const SecretsDriverName = "Secret" + +// Secrets is a wrapper around an implementation of a kubernetes +// SecretsInterface. +type Secrets struct { + impl corev1.SecretInterface + Log func(string, ...interface{}) +} + +// NewSecrets initializes a new Secrets wrapping an implementation of +// the kubernetes SecretsInterface. +func NewSecrets(impl corev1.SecretInterface) *Secrets { + return &Secrets{ + impl: impl, + Log: func(_ string, _ ...interface{}) {}, + } +} + +// Name returns the name of the driver. +func (secrets *Secrets) Name() string { + return SecretsDriverName +} + +// Get fetches the release named by key. The corresponding release is returned +// or error if not found. +func (secrets *Secrets) Get(key string) (*rspb.Release, error) { + // fetch the secret holding the release named by key + obj, err := secrets.impl.Get(context.Background(), key, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, ErrReleaseNotFound + } + return nil, errors.Wrapf(err, "get: failed to get %q", key) + } + // found the secret, decode the base64 data string + r, err := decodeRelease(string(obj.Data["release"])) + r.Labels = filterSystemLabels(obj.ObjectMeta.Labels) + return r, errors.Wrapf(err, "get: failed to decode data %q", key) +} + +// List fetches all releases and returns the list releases such +// that filter(release) == true. An error is returned if the +// secret fails to retrieve the releases. +func (secrets *Secrets) List(filter func(*rspb.Release) bool) ([]*rspb.Release, error) { + lsel := kblabels.Set{"owner": "helm"}.AsSelector() + opts := metav1.ListOptions{LabelSelector: lsel.String()} + + list, err := secrets.impl.List(context.Background(), opts) + if err != nil { + return nil, errors.Wrap(err, "list: failed to list") + } + + var results []*rspb.Release + + // iterate over the secrets object list + // and decode each release + for _, item := range list.Items { + rls, err := decodeRelease(string(item.Data["release"])) + if err != nil { + secrets.Log("list: failed to decode release: %v: %s", item, err) + continue + } + + rls.Labels = item.ObjectMeta.Labels + + if filter(rls) { + results = append(results, rls) + } + } + return results, nil +} + +// Query fetches all releases that match the provided map of labels. +// An error is returned if the secret fails to retrieve the releases. +func (secrets *Secrets) Query(labels map[string]string) ([]*rspb.Release, error) { + ls := kblabels.Set{} + for k, v := range labels { + if errs := validation.IsValidLabelValue(v); len(errs) != 0 { + return nil, errors.Errorf("invalid label value: %q: %s", v, strings.Join(errs, "; ")) + } + ls[k] = v + } + + opts := metav1.ListOptions{LabelSelector: ls.AsSelector().String()} + + list, err := secrets.impl.List(context.Background(), opts) + if err != nil { + return nil, errors.Wrap(err, "query: failed to query with labels") + } + + if len(list.Items) == 0 { + return nil, ErrReleaseNotFound + } + + var results []*rspb.Release + for _, item := range list.Items { + rls, err := decodeRelease(string(item.Data["release"])) + if err != nil { + secrets.Log("query: failed to decode release: %s", err) + continue + } + rls.Labels = item.ObjectMeta.Labels + results = append(results, rls) + } + return results, nil +} + +// Create creates a new Secret holding the release. If the +// Secret already exists, ErrReleaseExists is returned. +func (secrets *Secrets) Create(key string, rls *rspb.Release) error { + // set labels for secrets object meta data + var lbs labels + + lbs.init() + lbs.fromMap(rls.Labels) + lbs.set("createdAt", strconv.Itoa(int(time.Now().Unix()))) + + // create a new secret to hold the release + obj, err := newSecretsObject(key, rls, lbs) + if err != nil { + return errors.Wrapf(err, "create: failed to encode release %q", rls.Name) + } + // push the secret object out into the kubiverse + if _, err := secrets.impl.Create(context.Background(), obj, metav1.CreateOptions{}); err != nil { + if apierrors.IsAlreadyExists(err) { + return ErrReleaseExists + } + + return errors.Wrap(err, "create: failed to create") + } + return nil +} + +// Update updates the Secret holding the release. If not found +// the Secret is created to hold the release. +func (secrets *Secrets) Update(key string, rls *rspb.Release) error { + // set labels for secrets object meta data + var lbs labels + + lbs.init() + lbs.fromMap(rls.Labels) + lbs.set("modifiedAt", strconv.Itoa(int(time.Now().Unix()))) + + // create a new secret object to hold the release + obj, err := newSecretsObject(key, rls, lbs) + if err != nil { + return errors.Wrapf(err, "update: failed to encode release %q", rls.Name) + } + // push the secret object out into the kubiverse + _, err = secrets.impl.Update(context.Background(), obj, metav1.UpdateOptions{}) + return errors.Wrap(err, "update: failed to update") +} + +// Delete deletes the Secret holding the release named by key. +func (secrets *Secrets) Delete(key string) (rls *rspb.Release, err error) { + // fetch the release to check existence + if rls, err = secrets.Get(key); err != nil { + return nil, err + } + // delete the release + err = secrets.impl.Delete(context.Background(), key, metav1.DeleteOptions{}) + return rls, err +} + +// newSecretsObject constructs a kubernetes Secret object +// to store a release. Each secret data entry is the base64 +// encoded gzipped string of a release. +// +// The following labels are used within each secret: +// +// "modifiedAt" - timestamp indicating when this secret was last modified. (set in Update) +// "createdAt" - timestamp indicating when this secret was created. (set in Create) +// "version" - version of the release. +// "status" - status of the release (see pkg/release/status.go for variants) +// "owner" - owner of the secret, currently "helm". +// "name" - name of the release. +func newSecretsObject(key string, rls *rspb.Release, lbs labels) (*v1.Secret, error) { + const owner = "helm" + + // encode the release + s, err := encodeRelease(rls) + if err != nil { + return nil, err + } + + if lbs == nil { + lbs.init() + } + + // apply custom labels + lbs.fromMap(rls.Labels) + + // apply labels + lbs.set("name", rls.Name) + lbs.set("owner", owner) + lbs.set("status", rls.Info.Status.String()) + lbs.set("version", strconv.Itoa(rls.Version)) + + // create and return secret object. + // Helm 3 introduced setting the 'Type' field + // in the Kubernetes storage object. + // Helm defines the field content as follows: + // /.v + // Type field for Helm 3: helm.sh/release.v1 + // Note: Version starts at 'v1' for Helm 3 and + // should be incremented if the release object + // metadata is modified. + // This would potentially be a breaking change + // and should only happen between major versions. + return &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: key, + Labels: lbs.toMap(), + }, + Type: "helm.sh/release.v1", + Data: map[string][]byte{"release": []byte(s)}, + }, nil +} diff --git a/pkg/storage/driver/secrets_test.go b/pkg/storage/driver/secrets_test.go new file mode 100644 index 00000000..b4bf61d5 --- /dev/null +++ b/pkg/storage/driver/secrets_test.go @@ -0,0 +1,251 @@ +/* +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 driver + +import ( + "encoding/base64" + "encoding/json" + "reflect" + "testing" + + v1 "k8s.io/api/core/v1" + + rspb "helm.sh/helm/v3/pkg/release" +) + +func TestSecretName(t *testing.T) { + c := newTestFixtureSecrets(t) + if c.Name() != SecretsDriverName { + t.Errorf("Expected name to be %q, got %q", SecretsDriverName, c.Name()) + } +} + +func TestSecretGet(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + + secrets := newTestFixtureSecrets(t, []*rspb.Release{rel}...) + + // get release with key + got, err := secrets.Get(key) + if err != nil { + t.Fatalf("Failed to get release: %s", err) + } + // compare fetched release with original + if !reflect.DeepEqual(rel, got) { + t.Errorf("Expected {%v}, got {%v}", rel, got) + } +} + +func TestUNcompressedSecretGet(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + + // Create a test fixture which contains an uncompressed release + secret, err := newSecretsObject(key, rel, nil) + if err != nil { + t.Fatalf("Failed to create secret: %s", err) + } + b, err := json.Marshal(rel) + if err != nil { + t.Fatalf("Failed to marshal release: %s", err) + } + secret.Data["release"] = []byte(base64.StdEncoding.EncodeToString(b)) + var mock MockSecretsInterface + mock.objects = map[string]*v1.Secret{key: secret} + secrets := NewSecrets(&mock) + + // get release with key + got, err := secrets.Get(key) + if err != nil { + t.Fatalf("Failed to get release: %s", err) + } + // compare fetched release with original + if !reflect.DeepEqual(rel, got) { + t.Errorf("Expected {%v}, got {%v}", rel, got) + } +} + +func TestSecretList(t *testing.T) { + secrets := newTestFixtureSecrets(t, []*rspb.Release{ + releaseStub("key-1", 1, "default", rspb.StatusUninstalled), + releaseStub("key-2", 1, "default", rspb.StatusUninstalled), + releaseStub("key-3", 1, "default", rspb.StatusDeployed), + releaseStub("key-4", 1, "default", rspb.StatusDeployed), + releaseStub("key-5", 1, "default", rspb.StatusSuperseded), + releaseStub("key-6", 1, "default", rspb.StatusSuperseded), + }...) + + // list all deleted releases + del, err := secrets.List(func(rel *rspb.Release) bool { + return rel.Info.Status == rspb.StatusUninstalled + }) + // check + if err != nil { + t.Errorf("Failed to list deleted: %s", err) + } + if len(del) != 2 { + t.Errorf("Expected 2 deleted, got %d:\n%v\n", len(del), del) + } + + // list all deployed releases + dpl, err := secrets.List(func(rel *rspb.Release) bool { + return rel.Info.Status == rspb.StatusDeployed + }) + // check + if err != nil { + t.Errorf("Failed to list deployed: %s", err) + } + if len(dpl) != 2 { + t.Errorf("Expected 2 deployed, got %d", len(dpl)) + } + + // list all superseded releases + ssd, err := secrets.List(func(rel *rspb.Release) bool { + return rel.Info.Status == rspb.StatusSuperseded + }) + // check + if err != nil { + t.Errorf("Failed to list superseded: %s", err) + } + if len(ssd) != 2 { + t.Errorf("Expected 2 superseded, got %d", len(ssd)) + } + // Check if release having both system and custom labels, this is needed to ensure that selector filtering would work. + rls := ssd[0] + _, ok := rls.Labels["name"] + if !ok { + t.Fatalf("Expected 'name' label in results, actual %v", rls.Labels) + } + _, ok = rls.Labels["key1"] + if !ok { + t.Fatalf("Expected 'key1' label in results, actual %v", rls.Labels) + } +} + +func TestSecretQuery(t *testing.T) { + secrets := newTestFixtureSecrets(t, []*rspb.Release{ + releaseStub("key-1", 1, "default", rspb.StatusUninstalled), + releaseStub("key-2", 1, "default", rspb.StatusUninstalled), + releaseStub("key-3", 1, "default", rspb.StatusDeployed), + releaseStub("key-4", 1, "default", rspb.StatusDeployed), + releaseStub("key-5", 1, "default", rspb.StatusSuperseded), + releaseStub("key-6", 1, "default", rspb.StatusSuperseded), + }...) + + rls, err := secrets.Query(map[string]string{"status": "deployed"}) + if err != nil { + t.Fatalf("Failed to query: %s", err) + } + if len(rls) != 2 { + t.Fatalf("Expected 2 results, actual %d", len(rls)) + } + + _, err = secrets.Query(map[string]string{"name": "notExist"}) + if err != ErrReleaseNotFound { + t.Errorf("Expected {%v}, got {%v}", ErrReleaseNotFound, err) + } +} + +func TestSecretCreate(t *testing.T) { + secrets := newTestFixtureSecrets(t) + + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + + // store the release in a secret + if err := secrets.Create(key, rel); err != nil { + t.Fatalf("Failed to create release with key %q: %s", key, err) + } + + // get the release back + got, err := secrets.Get(key) + if err != nil { + t.Fatalf("Failed to get release with key %q: %s", key, err) + } + + // compare created release with original + if !reflect.DeepEqual(rel, got) { + t.Errorf("Expected {%v}, got {%v}", rel, got) + } +} + +func TestSecretUpdate(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + + secrets := newTestFixtureSecrets(t, []*rspb.Release{rel}...) + + // modify release status code + rel.Info.Status = rspb.StatusSuperseded + + // perform the update + if err := secrets.Update(key, rel); err != nil { + t.Fatalf("Failed to update release: %s", err) + } + + // fetch the updated release + got, err := secrets.Get(key) + if err != nil { + t.Fatalf("Failed to get release with key %q: %s", key, err) + } + + // check release has actually been updated by comparing modified fields + if rel.Info.Status != got.Info.Status { + t.Errorf("Expected status %s, got status %s", rel.Info.Status.String(), got.Info.Status.String()) + } +} + +func TestSecretDelete(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + + secrets := newTestFixtureSecrets(t, []*rspb.Release{rel}...) + + // perform the delete on a non-existing release + _, err := secrets.Delete("nonexistent") + if err != ErrReleaseNotFound { + t.Fatalf("Expected ErrReleaseNotFound, got: {%v}", err) + } + + // perform the delete + rls, err := secrets.Delete(key) + if err != nil { + t.Fatalf("Failed to delete release with key %q: %s", key, err) + } + if !reflect.DeepEqual(rel, rls) { + t.Errorf("Expected {%v}, got {%v}", rel, rls) + } + + // fetch the deleted release + _, err = secrets.Get(key) + if !reflect.DeepEqual(ErrReleaseNotFound, err) { + t.Errorf("Expected {%v}, got {%v}", ErrReleaseNotFound, err) + } +} diff --git a/pkg/storage/driver/sql.go b/pkg/storage/driver/sql.go new file mode 100644 index 00000000..8f5714f1 --- /dev/null +++ b/pkg/storage/driver/sql.go @@ -0,0 +1,697 @@ +/* +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 driver // import "helm.sh/helm/v3/pkg/storage/driver" + +import ( + "fmt" + "sort" + "strconv" + "time" + + "github.com/jmoiron/sqlx" + migrate "github.com/rubenv/sql-migrate" + + sq "github.com/Masterminds/squirrel" + + // Import pq for postgres dialect + _ "github.com/lib/pq" + + rspb "helm.sh/helm/v3/pkg/release" +) + +var _ Driver = (*SQL)(nil) + +var labelMap = map[string]struct{}{ + "modifiedAt": {}, + "createdAt": {}, + "version": {}, + "status": {}, + "owner": {}, + "name": {}, +} + +const postgreSQLDialect = "postgres" + +// SQLDriverName is the string name of this driver. +const SQLDriverName = "SQL" + +const sqlReleaseTableName = "releases_v1" +const sqlCustomLabelsTableName = "custom_labels_v1" + +const ( + sqlReleaseTableKeyColumn = "key" + sqlReleaseTableTypeColumn = "type" + sqlReleaseTableBodyColumn = "body" + sqlReleaseTableNameColumn = "name" + sqlReleaseTableNamespaceColumn = "namespace" + sqlReleaseTableVersionColumn = "version" + sqlReleaseTableStatusColumn = "status" + sqlReleaseTableOwnerColumn = "owner" + sqlReleaseTableCreatedAtColumn = "createdAt" + sqlReleaseTableModifiedAtColumn = "modifiedAt" + + sqlCustomLabelsTableReleaseKeyColumn = "releaseKey" + sqlCustomLabelsTableReleaseNamespaceColumn = "releaseNamespace" + sqlCustomLabelsTableKeyColumn = "key" + sqlCustomLabelsTableValueColumn = "value" +) + +// Following limits based on k8s labels limits - https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set +const ( + sqlCustomLabelsTableKeyMaxLenght = 253 + 1 + 63 + sqlCustomLabelsTableValueMaxLenght = 63 +) + +const ( + sqlReleaseDefaultOwner = "helm" + sqlReleaseDefaultType = "helm.sh/release.v1" +) + +// SQL is the sql storage driver implementation. +type SQL struct { + db *sqlx.DB + namespace string + statementBuilder sq.StatementBuilderType + + Log func(string, ...interface{}) +} + +// Name returns the name of the driver. +func (s *SQL) Name() string { + return SQLDriverName +} + +// Check if all migrations al +func (s *SQL) checkAlreadyApplied(migrations []*migrate.Migration) bool { + // make map (set) of ids for fast search + migrationsIds := make(map[string]struct{}) + for _, migration := range migrations { + migrationsIds[migration.Id] = struct{}{} + } + + // get list of applied migrations + migrate.SetDisableCreateTable(true) + records, err := migrate.GetMigrationRecords(s.db.DB, postgreSQLDialect) + migrate.SetDisableCreateTable(false) + if err != nil { + s.Log("checkAlreadyApplied: failed to get migration records: %v", err) + return false + } + + for _, record := range records { + if _, ok := migrationsIds[record.Id]; ok { + s.Log("checkAlreadyApplied: found previous migration (Id: %v) applied at %v", record.Id, record.AppliedAt) + delete(migrationsIds, record.Id) + } + } + + // check if all migrations appliyed + if len(migrationsIds) != 0 { + for id := range migrationsIds { + s.Log("checkAlreadyApplied: find unapplied migration (id: %v)", id) + } + return false + } + return true +} + +func (s *SQL) ensureDBSetup() error { + + migrations := &migrate.MemoryMigrationSource{ + Migrations: []*migrate.Migration{ + { + Id: "init", + Up: []string{ + fmt.Sprintf(` + CREATE TABLE %s ( + %s VARCHAR(90), + %s VARCHAR(64) NOT NULL, + %s TEXT NOT NULL, + %s VARCHAR(64) NOT NULL, + %s VARCHAR(64) NOT NULL, + %s INTEGER NOT NULL, + %s TEXT NOT NULL, + %s TEXT NOT NULL, + %s INTEGER NOT NULL, + %s INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(%s, %s) + ); + CREATE INDEX ON %s (%s, %s); + CREATE INDEX ON %s (%s); + CREATE INDEX ON %s (%s); + CREATE INDEX ON %s (%s); + CREATE INDEX ON %s (%s); + CREATE INDEX ON %s (%s); + + GRANT ALL ON %s TO PUBLIC; + + ALTER TABLE %s ENABLE ROW LEVEL SECURITY; + `, + sqlReleaseTableName, + sqlReleaseTableKeyColumn, + sqlReleaseTableTypeColumn, + sqlReleaseTableBodyColumn, + sqlReleaseTableNameColumn, + sqlReleaseTableNamespaceColumn, + sqlReleaseTableVersionColumn, + sqlReleaseTableStatusColumn, + sqlReleaseTableOwnerColumn, + sqlReleaseTableCreatedAtColumn, + sqlReleaseTableModifiedAtColumn, + sqlReleaseTableKeyColumn, + sqlReleaseTableNamespaceColumn, + sqlReleaseTableName, + sqlReleaseTableKeyColumn, + sqlReleaseTableNamespaceColumn, + sqlReleaseTableName, + sqlReleaseTableVersionColumn, + sqlReleaseTableName, + sqlReleaseTableStatusColumn, + sqlReleaseTableName, + sqlReleaseTableOwnerColumn, + sqlReleaseTableName, + sqlReleaseTableCreatedAtColumn, + sqlReleaseTableName, + sqlReleaseTableModifiedAtColumn, + sqlReleaseTableName, + sqlReleaseTableName, + ), + }, + Down: []string{ + fmt.Sprintf(` + DROP TABLE %s; + `, sqlReleaseTableName), + }, + }, + { + Id: "custom_labels", + Up: []string{ + fmt.Sprintf(` + CREATE TABLE %s ( + %s VARCHAR(64), + %s VARCHAR(67), + %s VARCHAR(%d), + %s VARCHAR(%d) + ); + CREATE INDEX ON %s (%s, %s); + + GRANT ALL ON %s TO PUBLIC; + ALTER TABLE %s ENABLE ROW LEVEL SECURITY; + `, + sqlCustomLabelsTableName, + sqlCustomLabelsTableReleaseKeyColumn, + sqlCustomLabelsTableReleaseNamespaceColumn, + sqlCustomLabelsTableKeyColumn, + sqlCustomLabelsTableKeyMaxLenght, + sqlCustomLabelsTableValueColumn, + sqlCustomLabelsTableValueMaxLenght, + sqlCustomLabelsTableName, + sqlCustomLabelsTableReleaseKeyColumn, + sqlCustomLabelsTableReleaseNamespaceColumn, + sqlCustomLabelsTableName, + sqlCustomLabelsTableName, + ), + }, + Down: []string{ + fmt.Sprintf(` + DELETE TABLE %s; + `, sqlCustomLabelsTableName), + }, + }, + }, + } + + // Check that init migration already applied + if s.checkAlreadyApplied(migrations.Migrations) { + return nil + } + + // Populate the database with the relations we need if they don't exist yet + _, err := migrate.Exec(s.db.DB, postgreSQLDialect, migrations, migrate.Up) + return err +} + +// SQLReleaseWrapper describes how Helm releases are stored in an SQL database +type SQLReleaseWrapper struct { + // The primary key, made of {release-name}.{release-version} + Key string `db:"key"` + + // See https://github.com/helm/helm/blob/c9fe3d118caec699eb2565df9838673af379ce12/pkg/storage/driver/secrets.go#L231 + Type string `db:"type"` + + // The rspb.Release body, as a base64-encoded string + Body string `db:"body"` + + // Release "labels" that can be used as filters in the storage.Query(labels map[string]string) + // we implemented. Note that allowing Helm users to filter against new dimensions will require a + // new migration to be added, and the Create and/or update functions to be updated accordingly. + Name string `db:"name"` + Namespace string `db:"namespace"` + Version int `db:"version"` + Status string `db:"status"` + Owner string `db:"owner"` + CreatedAt int `db:"createdAt"` + ModifiedAt int `db:"modifiedAt"` +} + +type SQLReleaseCustomLabelWrapper struct { + ReleaseKey string `db:"release_key"` + ReleaseNamespace string `db:"release_namespace"` + Key string `db:"key"` + Value string `db:"value"` +} + +// NewSQL initializes a new sql driver. +func NewSQL(connectionString string, logger func(string, ...interface{}), namespace string) (*SQL, error) { + db, err := sqlx.Connect(postgreSQLDialect, connectionString) + if err != nil { + return nil, err + } + + driver := &SQL{ + db: db, + Log: logger, + statementBuilder: sq.StatementBuilder.PlaceholderFormat(sq.Dollar), + } + + if err := driver.ensureDBSetup(); err != nil { + return nil, err + } + + driver.namespace = namespace + + return driver, nil +} + +// Get returns the release named by key. +func (s *SQL) Get(key string) (*rspb.Release, error) { + var record SQLReleaseWrapper + + qb := s.statementBuilder. + Select(sqlReleaseTableBodyColumn). + From(sqlReleaseTableName). + Where(sq.Eq{sqlReleaseTableKeyColumn: key}). + Where(sq.Eq{sqlReleaseTableNamespaceColumn: s.namespace}) + + query, args, err := qb.ToSql() + if err != nil { + s.Log("failed to build query: %v", err) + return nil, err + } + + // Get will return an error if the result is empty + if err := s.db.Get(&record, query, args...); err != nil { + s.Log("got SQL error when getting release %s: %v", key, err) + return nil, ErrReleaseNotFound + } + + release, err := decodeRelease(record.Body) + if err != nil { + s.Log("get: failed to decode data %q: %v", key, err) + return nil, err + } + + if release.Labels, err = s.getReleaseCustomLabels(key, s.namespace); err != nil { + s.Log("failed to get release %s/%s custom labels: %v", s.namespace, key, err) + return nil, err + } + + return release, nil +} + +// List returns the list of all releases such that filter(release) == true +func (s *SQL) List(filter func(*rspb.Release) bool) ([]*rspb.Release, error) { + sb := s.statementBuilder. + Select(sqlReleaseTableKeyColumn, sqlReleaseTableNamespaceColumn, sqlReleaseTableBodyColumn). + From(sqlReleaseTableName). + Where(sq.Eq{sqlReleaseTableOwnerColumn: sqlReleaseDefaultOwner}) + + // If a namespace was specified, we only list releases from that namespace + if s.namespace != "" { + sb = sb.Where(sq.Eq{sqlReleaseTableNamespaceColumn: s.namespace}) + } + + query, args, err := sb.ToSql() + if err != nil { + s.Log("failed to build query: %v", err) + return nil, err + } + + var records = []SQLReleaseWrapper{} + if err := s.db.Select(&records, query, args...); err != nil { + s.Log("list: failed to list: %v", err) + return nil, err + } + + var releases []*rspb.Release + for _, record := range records { + release, err := decodeRelease(record.Body) + if err != nil { + s.Log("list: failed to decode release: %v: %v", record, err) + continue + } + + if release.Labels, err = s.getReleaseCustomLabels(record.Key, record.Namespace); err != nil { + s.Log("failed to get release %s/%s custom labels: %v", record.Namespace, record.Key, err) + return nil, err + } + for k, v := range getReleaseSystemLabels(release) { + release.Labels[k] = v + } + + if filter(release) { + releases = append(releases, release) + } + } + + return releases, nil +} + +// Query returns the set of releases that match the provided set of labels. +func (s *SQL) Query(labels map[string]string) ([]*rspb.Release, error) { + sb := s.statementBuilder. + Select(sqlReleaseTableKeyColumn, sqlReleaseTableNamespaceColumn, sqlReleaseTableBodyColumn). + From(sqlReleaseTableName) + + keys := make([]string, 0, len(labels)) + for key := range labels { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + if _, ok := labelMap[key]; ok { + sb = sb.Where(sq.Eq{key: labels[key]}) + } else { + s.Log("unknown label %s", key) + return nil, fmt.Errorf("unknown label %s", key) + } + } + + // If a namespace was specified, we only list releases from that namespace + if s.namespace != "" { + sb = sb.Where(sq.Eq{sqlReleaseTableNamespaceColumn: s.namespace}) + } + + // Build our query + query, args, err := sb.ToSql() + if err != nil { + s.Log("failed to build query: %v", err) + return nil, err + } + + var records = []SQLReleaseWrapper{} + if err := s.db.Select(&records, query, args...); err != nil { + s.Log("list: failed to query with labels: %v", err) + return nil, err + } + + if len(records) == 0 { + return nil, ErrReleaseNotFound + } + + var releases []*rspb.Release + for _, record := range records { + release, err := decodeRelease(record.Body) + if err != nil { + s.Log("list: failed to decode release: %v: %v", record, err) + continue + } + + if release.Labels, err = s.getReleaseCustomLabels(record.Key, record.Namespace); err != nil { + s.Log("failed to get release %s/%s custom labels: %v", record.Namespace, record.Key, err) + return nil, err + } + + releases = append(releases, release) + } + + if len(releases) == 0 { + return nil, ErrReleaseNotFound + } + + return releases, nil +} + +// Create creates a new release. +func (s *SQL) Create(key string, rls *rspb.Release) error { + namespace := rls.Namespace + if namespace == "" { + namespace = defaultNamespace + } + s.namespace = namespace + + body, err := encodeRelease(rls) + if err != nil { + s.Log("failed to encode release: %v", err) + return err + } + + transaction, err := s.db.Beginx() + if err != nil { + s.Log("failed to start SQL transaction: %v", err) + return fmt.Errorf("error beginning transaction: %v", err) + } + + insertQuery, args, err := s.statementBuilder. + Insert(sqlReleaseTableName). + Columns( + sqlReleaseTableKeyColumn, + sqlReleaseTableTypeColumn, + sqlReleaseTableBodyColumn, + sqlReleaseTableNameColumn, + sqlReleaseTableNamespaceColumn, + sqlReleaseTableVersionColumn, + sqlReleaseTableStatusColumn, + sqlReleaseTableOwnerColumn, + sqlReleaseTableCreatedAtColumn, + ). + Values( + key, + sqlReleaseDefaultType, + body, + rls.Name, + namespace, + int(rls.Version), + rls.Info.Status.String(), + sqlReleaseDefaultOwner, + int(time.Now().Unix()), + ).ToSql() + if err != nil { + s.Log("failed to build insert query: %v", err) + return err + } + + if _, err := transaction.Exec(insertQuery, args...); err != nil { + defer transaction.Rollback() + + selectQuery, args, buildErr := s.statementBuilder. + Select(sqlReleaseTableKeyColumn). + From(sqlReleaseTableName). + Where(sq.Eq{sqlReleaseTableKeyColumn: key}). + Where(sq.Eq{sqlReleaseTableNamespaceColumn: s.namespace}). + ToSql() + if buildErr != nil { + s.Log("failed to build select query: %v", buildErr) + return err + } + + var record SQLReleaseWrapper + if err := transaction.Get(&record, selectQuery, args...); err == nil { + s.Log("release %s already exists", key) + return ErrReleaseExists + } + + s.Log("failed to store release %s in SQL database: %v", key, err) + return err + } + + // Filtering labels before insert cause in SQL storage driver system releases are stored in separate columns of release table + for k, v := range filterSystemLabels(rls.Labels) { + insertLabelsQuery, args, err := s.statementBuilder. + Insert(sqlCustomLabelsTableName). + Columns( + sqlCustomLabelsTableReleaseKeyColumn, + sqlCustomLabelsTableReleaseNamespaceColumn, + sqlCustomLabelsTableKeyColumn, + sqlCustomLabelsTableValueColumn, + ). + Values( + key, + namespace, + k, + v, + ).ToSql() + + if err != nil { + defer transaction.Rollback() + s.Log("failed to build insert query: %v", err) + return err + } + + if _, err := transaction.Exec(insertLabelsQuery, args...); err != nil { + defer transaction.Rollback() + s.Log("failed to write Labels: %v", err) + return err + } + } + defer transaction.Commit() + + return nil +} + +// Update updates a release. +func (s *SQL) Update(key string, rls *rspb.Release) error { + namespace := rls.Namespace + if namespace == "" { + namespace = defaultNamespace + } + s.namespace = namespace + + body, err := encodeRelease(rls) + if err != nil { + s.Log("failed to encode release: %v", err) + return err + } + + query, args, err := s.statementBuilder. + Update(sqlReleaseTableName). + Set(sqlReleaseTableBodyColumn, body). + Set(sqlReleaseTableNameColumn, rls.Name). + Set(sqlReleaseTableVersionColumn, int(rls.Version)). + Set(sqlReleaseTableStatusColumn, rls.Info.Status.String()). + Set(sqlReleaseTableOwnerColumn, sqlReleaseDefaultOwner). + Set(sqlReleaseTableModifiedAtColumn, int(time.Now().Unix())). + Where(sq.Eq{sqlReleaseTableKeyColumn: key}). + Where(sq.Eq{sqlReleaseTableNamespaceColumn: namespace}). + ToSql() + + if err != nil { + s.Log("failed to build update query: %v", err) + return err + } + + if _, err := s.db.Exec(query, args...); err != nil { + s.Log("failed to update release %s in SQL database: %v", key, err) + return err + } + + return nil +} + +// Delete deletes a release or returns ErrReleaseNotFound. +func (s *SQL) Delete(key string) (*rspb.Release, error) { + transaction, err := s.db.Beginx() + if err != nil { + s.Log("failed to start SQL transaction: %v", err) + return nil, fmt.Errorf("error beginning transaction: %v", err) + } + + selectQuery, args, err := s.statementBuilder. + Select(sqlReleaseTableBodyColumn). + From(sqlReleaseTableName). + Where(sq.Eq{sqlReleaseTableKeyColumn: key}). + Where(sq.Eq{sqlReleaseTableNamespaceColumn: s.namespace}). + ToSql() + if err != nil { + s.Log("failed to build select query: %v", err) + return nil, err + } + + var record SQLReleaseWrapper + err = transaction.Get(&record, selectQuery, args...) + if err != nil { + s.Log("release %s not found: %v", key, err) + return nil, ErrReleaseNotFound + } + + release, err := decodeRelease(record.Body) + if err != nil { + s.Log("failed to decode release %s: %v", key, err) + transaction.Rollback() + return nil, err + } + defer transaction.Commit() + + deleteQuery, args, err := s.statementBuilder. + Delete(sqlReleaseTableName). + Where(sq.Eq{sqlReleaseTableKeyColumn: key}). + Where(sq.Eq{sqlReleaseTableNamespaceColumn: s.namespace}). + ToSql() + if err != nil { + s.Log("failed to build delete query: %v", err) + return nil, err + } + + _, err = transaction.Exec(deleteQuery, args...) + if err != nil { + s.Log("failed perform delete query: %v", err) + return release, err + } + + if release.Labels, err = s.getReleaseCustomLabels(key, s.namespace); err != nil { + s.Log("failed to get release %s/%s custom labels: %v", s.namespace, key, err) + return nil, err + } + + deleteCustomLabelsQuery, args, err := s.statementBuilder. + Delete(sqlCustomLabelsTableName). + Where(sq.Eq{sqlCustomLabelsTableReleaseKeyColumn: key}). + Where(sq.Eq{sqlCustomLabelsTableReleaseNamespaceColumn: s.namespace}). + ToSql() + + if err != nil { + s.Log("failed to build delete Labels query: %v", err) + return nil, err + } + _, err = transaction.Exec(deleteCustomLabelsQuery, args...) + return release, err +} + +// Get release custom labels from database +func (s *SQL) getReleaseCustomLabels(key string, _ string) (map[string]string, error) { + query, args, err := s.statementBuilder. + Select(sqlCustomLabelsTableKeyColumn, sqlCustomLabelsTableValueColumn). + From(sqlCustomLabelsTableName). + Where(sq.Eq{sqlCustomLabelsTableReleaseKeyColumn: key, + sqlCustomLabelsTableReleaseNamespaceColumn: s.namespace}). + ToSql() + if err != nil { + return nil, err + } + + var labelsList = []SQLReleaseCustomLabelWrapper{} + if err := s.db.Select(&labelsList, query, args...); err != nil { + return nil, err + } + + labelsMap := make(map[string]string) + for _, i := range labelsList { + labelsMap[i.Key] = i.Value + } + + return filterSystemLabels(labelsMap), nil +} + +// Rebuild system labels from release object +func getReleaseSystemLabels(rls *rspb.Release) map[string]string { + return map[string]string{ + "name": rls.Name, + "owner": sqlReleaseDefaultOwner, + "status": rls.Info.Status.String(), + "version": strconv.Itoa(rls.Version), + } +} diff --git a/pkg/storage/driver/sql_test.go b/pkg/storage/driver/sql_test.go new file mode 100644 index 00000000..0dfa85e3 --- /dev/null +++ b/pkg/storage/driver/sql_test.go @@ -0,0 +1,592 @@ +/* +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 driver + +import ( + "fmt" + "reflect" + "regexp" + "testing" + "time" + + sqlmock "github.com/DATA-DOG/go-sqlmock" + migrate "github.com/rubenv/sql-migrate" + + rspb "helm.sh/helm/v3/pkg/release" +) + +func TestSQLName(t *testing.T) { + sqlDriver, _ := newTestFixtureSQL(t) + if sqlDriver.Name() != SQLDriverName { + t.Errorf("Expected name to be %s, got %s", SQLDriverName, sqlDriver.Name()) + } +} + +func TestSQLGet(t *testing.T) { + vers := int(1) + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + + body, _ := encodeRelease(rel) + + sqlDriver, mock := newTestFixtureSQL(t) + + query := fmt.Sprintf( + regexp.QuoteMeta("SELECT %s FROM %s WHERE %s = $1 AND %s = $2"), + sqlReleaseTableBodyColumn, + sqlReleaseTableName, + sqlReleaseTableKeyColumn, + sqlReleaseTableNamespaceColumn, + ) + + mock. + ExpectQuery(query). + WithArgs(key, namespace). + WillReturnRows( + mock.NewRows([]string{ + sqlReleaseTableBodyColumn, + }).AddRow( + body, + ), + ).RowsWillBeClosed() + + mockGetReleaseCustomLabels(mock, key, namespace, rel.Labels) + + got, err := sqlDriver.Get(key) + if err != nil { + t.Fatalf("Failed to get release: %v", err) + } + + if !reflect.DeepEqual(rel, got) { + t.Errorf("Expected release {%v}, got {%v}", rel, got) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sql expectations weren't met: %v", err) + } +} + +func TestSQLList(t *testing.T) { + releases := []*rspb.Release{} + releases = append(releases, releaseStub("key-1", 1, "default", rspb.StatusUninstalled)) + releases = append(releases, releaseStub("key-2", 1, "default", rspb.StatusUninstalled)) + releases = append(releases, releaseStub("key-3", 1, "default", rspb.StatusDeployed)) + releases = append(releases, releaseStub("key-4", 1, "default", rspb.StatusDeployed)) + releases = append(releases, releaseStub("key-5", 1, "default", rspb.StatusSuperseded)) + releases = append(releases, releaseStub("key-6", 1, "default", rspb.StatusSuperseded)) + + sqlDriver, mock := newTestFixtureSQL(t) + + for i := 0; i < 3; i++ { + query := fmt.Sprintf( + "SELECT %s, %s, %s FROM %s WHERE %s = $1 AND %s = $2", + sqlReleaseTableKeyColumn, + sqlReleaseTableNamespaceColumn, + sqlReleaseTableBodyColumn, + sqlReleaseTableName, + sqlReleaseTableOwnerColumn, + sqlReleaseTableNamespaceColumn, + ) + + rows := mock.NewRows([]string{ + sqlReleaseTableBodyColumn, + }) + for _, r := range releases { + body, _ := encodeRelease(r) + rows.AddRow(body) + } + mock. + ExpectQuery(regexp.QuoteMeta(query)). + WithArgs(sqlReleaseDefaultOwner, sqlDriver.namespace). + WillReturnRows(rows).RowsWillBeClosed() + + for _, r := range releases { + mockGetReleaseCustomLabels(mock, "", r.Namespace, r.Labels) + } + } + + // list all deleted releases + del, err := sqlDriver.List(func(rel *rspb.Release) bool { + return rel.Info.Status == rspb.StatusUninstalled + }) + // check + if err != nil { + t.Errorf("Failed to list deleted: %v", err) + } + if len(del) != 2 { + t.Errorf("Expected 2 deleted, got %d:\n%v\n", len(del), del) + } + + // list all deployed releases + dpl, err := sqlDriver.List(func(rel *rspb.Release) bool { + return rel.Info.Status == rspb.StatusDeployed + }) + // check + if err != nil { + t.Errorf("Failed to list deployed: %v", err) + } + if len(dpl) != 2 { + t.Errorf("Expected 2 deployed, got %d:\n%v\n", len(dpl), dpl) + } + + // list all superseded releases + ssd, err := sqlDriver.List(func(rel *rspb.Release) bool { + return rel.Info.Status == rspb.StatusSuperseded + }) + // check + if err != nil { + t.Errorf("Failed to list superseded: %v", err) + } + if len(ssd) != 2 { + t.Errorf("Expected 2 superseded, got %d:\n%v\n", len(ssd), ssd) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sql expectations weren't met: %v", err) + } + + // Check if release having both system and custom labels, this is needed to ensure that selector filtering would work. + rls := ssd[0] + _, ok := rls.Labels["name"] + if !ok { + t.Fatalf("Expected 'name' label in results, actual %v", rls.Labels) + } + _, ok = rls.Labels["key1"] + if !ok { + t.Fatalf("Expected 'key1' label in results, actual %v", rls.Labels) + } +} + +func TestSqlCreate(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + + sqlDriver, mock := newTestFixtureSQL(t) + body, _ := encodeRelease(rel) + + query := fmt.Sprintf( + "INSERT INTO %s (%s,%s,%s,%s,%s,%s,%s,%s,%s) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)", + sqlReleaseTableName, + sqlReleaseTableKeyColumn, + sqlReleaseTableTypeColumn, + sqlReleaseTableBodyColumn, + sqlReleaseTableNameColumn, + sqlReleaseTableNamespaceColumn, + sqlReleaseTableVersionColumn, + sqlReleaseTableStatusColumn, + sqlReleaseTableOwnerColumn, + sqlReleaseTableCreatedAtColumn, + ) + + mock.ExpectBegin() + mock. + ExpectExec(regexp.QuoteMeta(query)). + WithArgs(key, sqlReleaseDefaultType, body, rel.Name, rel.Namespace, int(rel.Version), rel.Info.Status.String(), sqlReleaseDefaultOwner, int(time.Now().Unix())). + WillReturnResult(sqlmock.NewResult(1, 1)) + + labelsQuery := fmt.Sprintf( + "INSERT INTO %s (%s,%s,%s,%s) VALUES ($1,$2,$3,$4)", + sqlCustomLabelsTableName, + sqlCustomLabelsTableReleaseKeyColumn, + sqlCustomLabelsTableReleaseNamespaceColumn, + sqlCustomLabelsTableKeyColumn, + sqlCustomLabelsTableValueColumn, + ) + + mock.MatchExpectationsInOrder(false) + for k, v := range filterSystemLabels(rel.Labels) { + mock. + ExpectExec(regexp.QuoteMeta(labelsQuery)). + WithArgs(key, rel.Namespace, k, v). + WillReturnResult(sqlmock.NewResult(1, 1)) + } + mock.ExpectCommit() + + if err := sqlDriver.Create(key, rel); err != nil { + t.Fatalf("failed to create release with key %s: %v", key, err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sql expectations weren't met: %v", err) + } +} + +func TestSqlCreateAlreadyExists(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + + sqlDriver, mock := newTestFixtureSQL(t) + body, _ := encodeRelease(rel) + + insertQuery := fmt.Sprintf( + "INSERT INTO %s (%s,%s,%s,%s,%s,%s,%s,%s,%s) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)", + sqlReleaseTableName, + sqlReleaseTableKeyColumn, + sqlReleaseTableTypeColumn, + sqlReleaseTableBodyColumn, + sqlReleaseTableNameColumn, + sqlReleaseTableNamespaceColumn, + sqlReleaseTableVersionColumn, + sqlReleaseTableStatusColumn, + sqlReleaseTableOwnerColumn, + sqlReleaseTableCreatedAtColumn, + ) + + // Insert fails (primary key already exists) + mock.ExpectBegin() + mock. + ExpectExec(regexp.QuoteMeta(insertQuery)). + WithArgs(key, sqlReleaseDefaultType, body, rel.Name, rel.Namespace, int(rel.Version), rel.Info.Status.String(), sqlReleaseDefaultOwner, int(time.Now().Unix())). + WillReturnError(fmt.Errorf("dialect dependent SQL error")) + + selectQuery := fmt.Sprintf( + regexp.QuoteMeta("SELECT %s FROM %s WHERE %s = $1 AND %s = $2"), + sqlReleaseTableKeyColumn, + sqlReleaseTableName, + sqlReleaseTableKeyColumn, + sqlReleaseTableNamespaceColumn, + ) + + // Let's check that we do make sure the error is due to a release already existing + mock. + ExpectQuery(selectQuery). + WithArgs(key, namespace). + WillReturnRows( + mock.NewRows([]string{ + sqlReleaseTableKeyColumn, + }).AddRow( + key, + ), + ).RowsWillBeClosed() + mock.ExpectRollback() + + if err := sqlDriver.Create(key, rel); err == nil { + t.Fatalf("failed to create release with key %s: %v", key, err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sql expectations weren't met: %v", err) + } +} + +func TestSqlUpdate(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + + sqlDriver, mock := newTestFixtureSQL(t) + body, _ := encodeRelease(rel) + + query := fmt.Sprintf( + "UPDATE %s SET %s = $1, %s = $2, %s = $3, %s = $4, %s = $5, %s = $6 WHERE %s = $7 AND %s = $8", + sqlReleaseTableName, + sqlReleaseTableBodyColumn, + sqlReleaseTableNameColumn, + sqlReleaseTableVersionColumn, + sqlReleaseTableStatusColumn, + sqlReleaseTableOwnerColumn, + sqlReleaseTableModifiedAtColumn, + sqlReleaseTableKeyColumn, + sqlReleaseTableNamespaceColumn, + ) + + mock. + ExpectExec(regexp.QuoteMeta(query)). + WithArgs(body, rel.Name, int(rel.Version), rel.Info.Status.String(), sqlReleaseDefaultOwner, int(time.Now().Unix()), key, namespace). + WillReturnResult(sqlmock.NewResult(0, 1)) + + if err := sqlDriver.Update(key, rel); err != nil { + t.Fatalf("failed to update release with key %s: %v", key, err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sql expectations weren't met: %v", err) + } +} + +func TestSqlQuery(t *testing.T) { + // Reflect actual use cases in ../storage.go + labelSetUnknown := map[string]string{ + "name": "smug-pigeon", + "owner": sqlReleaseDefaultOwner, + "status": "unknown", + } + labelSetDeployed := map[string]string{ + "name": "smug-pigeon", + "owner": sqlReleaseDefaultOwner, + "status": "deployed", + } + labelSetAll := map[string]string{ + "name": "smug-pigeon", + "owner": sqlReleaseDefaultOwner, + } + + supersededRelease := releaseStub("smug-pigeon", 1, "default", rspb.StatusSuperseded) + supersededReleaseBody, _ := encodeRelease(supersededRelease) + deployedRelease := releaseStub("smug-pigeon", 2, "default", rspb.StatusDeployed) + deployedReleaseBody, _ := encodeRelease(deployedRelease) + + // Let's actually start our test + sqlDriver, mock := newTestFixtureSQL(t) + + query := fmt.Sprintf( + "SELECT %s, %s, %s FROM %s WHERE %s = $1 AND %s = $2 AND %s = $3 AND %s = $4", + sqlReleaseTableKeyColumn, + sqlReleaseTableNamespaceColumn, + sqlReleaseTableBodyColumn, + sqlReleaseTableName, + sqlReleaseTableNameColumn, + sqlReleaseTableOwnerColumn, + sqlReleaseTableStatusColumn, + sqlReleaseTableNamespaceColumn, + ) + + mock. + ExpectQuery(regexp.QuoteMeta(query)). + WithArgs("smug-pigeon", sqlReleaseDefaultOwner, "unknown", "default"). + WillReturnRows( + mock.NewRows([]string{ + sqlReleaseTableBodyColumn, + }), + ).RowsWillBeClosed() + + mock. + ExpectQuery(regexp.QuoteMeta(query)). + WithArgs("smug-pigeon", sqlReleaseDefaultOwner, "deployed", "default"). + WillReturnRows( + mock.NewRows([]string{ + sqlReleaseTableBodyColumn, + }).AddRow( + deployedReleaseBody, + ), + ).RowsWillBeClosed() + + mockGetReleaseCustomLabels(mock, "", deployedRelease.Namespace, deployedRelease.Labels) + + query = fmt.Sprintf( + "SELECT %s, %s, %s FROM %s WHERE %s = $1 AND %s = $2 AND %s = $3", + sqlReleaseTableKeyColumn, + sqlReleaseTableNamespaceColumn, + sqlReleaseTableBodyColumn, + sqlReleaseTableName, + sqlReleaseTableNameColumn, + sqlReleaseTableOwnerColumn, + sqlReleaseTableNamespaceColumn, + ) + + mock. + ExpectQuery(regexp.QuoteMeta(query)). + WithArgs("smug-pigeon", sqlReleaseDefaultOwner, "default"). + WillReturnRows( + mock.NewRows([]string{ + sqlReleaseTableBodyColumn, + }).AddRow( + supersededReleaseBody, + ).AddRow( + deployedReleaseBody, + ), + ).RowsWillBeClosed() + + mockGetReleaseCustomLabels(mock, "", supersededRelease.Namespace, supersededRelease.Labels) + mockGetReleaseCustomLabels(mock, "", deployedRelease.Namespace, deployedRelease.Labels) + + _, err := sqlDriver.Query(labelSetUnknown) + if err == nil { + t.Errorf("Expected error {%v}, got nil", ErrReleaseNotFound) + } else if err != ErrReleaseNotFound { + t.Fatalf("failed to query for unknown smug-pigeon release: %v", err) + } + + results, err := sqlDriver.Query(labelSetDeployed) + if err != nil { + t.Fatalf("failed to query for deployed smug-pigeon release: %v", err) + } + + for _, res := range results { + if !reflect.DeepEqual(res, deployedRelease) { + t.Errorf("Expected release {%v}, got {%v}", deployedRelease, res) + } + } + + results, err = sqlDriver.Query(labelSetAll) + if err != nil { + t.Fatalf("failed to query release history for smug-pigeon: %v", err) + } + + if len(results) != 2 { + t.Errorf("expected a resultset of size 2, got %d", len(results)) + } + + for _, res := range results { + if !reflect.DeepEqual(res, deployedRelease) && !reflect.DeepEqual(res, supersededRelease) { + t.Errorf("Expected release {%v} or {%v}, got {%v}", deployedRelease, supersededRelease, res) + } + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sql expectations weren't met: %v", err) + } +} + +func TestSqlDelete(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + + body, _ := encodeRelease(rel) + + sqlDriver, mock := newTestFixtureSQL(t) + + selectQuery := fmt.Sprintf( + "SELECT %s FROM %s WHERE %s = $1 AND %s = $2", + sqlReleaseTableBodyColumn, + sqlReleaseTableName, + sqlReleaseTableKeyColumn, + sqlReleaseTableNamespaceColumn, + ) + + mock.ExpectBegin() + mock. + ExpectQuery(regexp.QuoteMeta(selectQuery)). + WithArgs(key, namespace). + WillReturnRows( + mock.NewRows([]string{ + sqlReleaseTableBodyColumn, + }).AddRow( + body, + ), + ).RowsWillBeClosed() + + deleteQuery := fmt.Sprintf( + "DELETE FROM %s WHERE %s = $1 AND %s = $2", + sqlReleaseTableName, + sqlReleaseTableKeyColumn, + sqlReleaseTableNamespaceColumn, + ) + + mock. + ExpectExec(regexp.QuoteMeta(deleteQuery)). + WithArgs(key, namespace). + WillReturnResult(sqlmock.NewResult(0, 1)) + + mockGetReleaseCustomLabels(mock, key, namespace, rel.Labels) + + deleteLabelsQuery := fmt.Sprintf( + "DELETE FROM %s WHERE %s = $1 AND %s = $2", + sqlCustomLabelsTableName, + sqlCustomLabelsTableReleaseKeyColumn, + sqlCustomLabelsTableReleaseNamespaceColumn, + ) + mock. + ExpectExec(regexp.QuoteMeta(deleteLabelsQuery)). + WithArgs(key, namespace). + WillReturnResult(sqlmock.NewResult(0, 1)) + + mock.ExpectCommit() + + deletedRelease, err := sqlDriver.Delete(key) + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sql expectations weren't met: %v", err) + } + if err != nil { + t.Fatalf("failed to delete release with key %q: %v", key, err) + } + + if !reflect.DeepEqual(rel, deletedRelease) { + t.Errorf("Expected release {%v}, got {%v}", rel, deletedRelease) + } +} + +func mockGetReleaseCustomLabels(mock sqlmock.Sqlmock, key string, namespace string, labels map[string]string) { + query := fmt.Sprintf( + regexp.QuoteMeta("SELECT %s, %s FROM %s WHERE %s = $1 AND %s = $2"), + sqlCustomLabelsTableKeyColumn, + sqlCustomLabelsTableValueColumn, + sqlCustomLabelsTableName, + sqlCustomLabelsTableReleaseKeyColumn, + sqlCustomLabelsTableReleaseNamespaceColumn, + ) + + eq := mock.ExpectQuery(query). + WithArgs(key, namespace) + + returnRows := mock.NewRows([]string{ + sqlCustomLabelsTableKeyColumn, + sqlCustomLabelsTableValueColumn, + }) + for k, v := range labels { + returnRows.AddRow(k, v) + } + eq.WillReturnRows(returnRows).RowsWillBeClosed() +} + +func TestSqlChechkAppliedMigrations(t *testing.T) { + cases := []struct { + migrationsToApply []*migrate.Migration + appliedMigrationsIds []string + expectedResult bool + errorExplanation string + }{ + { + migrationsToApply: []*migrate.Migration{{Id: "init1"}, {Id: "init2"}, {Id: "init3"}}, + appliedMigrationsIds: []string{"1", "2", "init1", "3", "init2", "4", "5"}, + expectedResult: false, + errorExplanation: "Has found one migration id \"init3\" as applied, that was not applied", + }, + { + migrationsToApply: []*migrate.Migration{{Id: "init1"}, {Id: "init2"}, {Id: "init3"}}, + appliedMigrationsIds: []string{"1", "2", "init1", "3", "init2", "4", "init3", "5"}, + expectedResult: true, + errorExplanation: "Has not found one or more migration ids, that was applied", + }, + { + migrationsToApply: []*migrate.Migration{{Id: "init"}}, + appliedMigrationsIds: []string{"1", "2", "3", "inits", "4", "tinit", "5"}, + expectedResult: false, + errorExplanation: "Has found single \"init\", that was not applied", + }, + { + migrationsToApply: []*migrate.Migration{{Id: "init"}}, + appliedMigrationsIds: []string{"1", "2", "init", "3", "init2", "4", "init3", "5"}, + expectedResult: true, + errorExplanation: "Has not found single migration id \"init\", that was applied", + }, + } + for i, c := range cases { + sqlDriver, mock := newTestFixtureSQL(t) + rows := sqlmock.NewRows([]string{"id", "applied_at"}) + for _, id := range c.appliedMigrationsIds { + rows.AddRow(id, time.Time{}) + } + mock. + ExpectQuery(""). + WillReturnRows(rows) + mock.ExpectCommit() + if sqlDriver.checkAlreadyApplied(c.migrationsToApply) != c.expectedResult { + t.Errorf("Test case: %v, Expected: %v, Have: %v, Explanation: %v", i, c.expectedResult, !c.expectedResult, c.errorExplanation) + } + } +} diff --git a/pkg/storage/driver/util.go b/pkg/storage/driver/util.go new file mode 100644 index 00000000..7bda5ec9 --- /dev/null +++ b/pkg/storage/driver/util.go @@ -0,0 +1,122 @@ +/* +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 driver // import "helm.sh/helm/v3/pkg/storage/driver" + +import ( + "bytes" + "compress/gzip" + "encoding/base64" + "encoding/json" + "io" + + rspb "helm.sh/helm/v3/pkg/release" +) + +var b64 = base64.StdEncoding + +var magicGzip = []byte{0x1f, 0x8b, 0x08} + +var systemLabels = []string{"name", "owner", "status", "version", "createdAt", "modifiedAt"} + +// encodeRelease encodes a release returning a base64 encoded +// gzipped string representation, or error. +func encodeRelease(rls *rspb.Release) (string, error) { + b, err := json.Marshal(rls) + if err != nil { + return "", err + } + var buf bytes.Buffer + w, err := gzip.NewWriterLevel(&buf, gzip.BestCompression) + if err != nil { + return "", err + } + if _, err = w.Write(b); err != nil { + return "", err + } + w.Close() + + return b64.EncodeToString(buf.Bytes()), nil +} + +// decodeRelease decodes the bytes of data into a release +// type. Data must contain a base64 encoded gzipped string of a +// valid release, otherwise an error is returned. +func decodeRelease(data string) (*rspb.Release, error) { + // base64 decode string + b, err := b64.DecodeString(data) + if err != nil { + return nil, err + } + + // For backwards compatibility with releases that were stored before + // compression was introduced we skip decompression if the + // gzip magic header is not found + if len(b) > 3 && bytes.Equal(b[0:3], magicGzip) { + r, err := gzip.NewReader(bytes.NewReader(b)) + if err != nil { + return nil, err + } + defer r.Close() + b2, err := io.ReadAll(r) + if err != nil { + return nil, err + } + b = b2 + } + + var rls rspb.Release + // unmarshal release object bytes + if err := json.Unmarshal(b, &rls); err != nil { + return nil, err + } + return &rls, nil +} + +// Checks if label is system +func isSystemLabel(key string) bool { + for _, v := range GetSystemLabels() { + if key == v { + return true + } + } + return false +} + +// Removes system labels from labels map +func filterSystemLabels(lbs map[string]string) map[string]string { + result := make(map[string]string) + for k, v := range lbs { + if !isSystemLabel(k) { + result[k] = v + } + } + return result +} + +// Checks if labels array contains system labels +func ContainsSystemLabels(lbs map[string]string) bool { + for k := range lbs { + if isSystemLabel(k) { + return true + } + } + return false +} + +func GetSystemLabels() []string { + return systemLabels +} diff --git a/pkg/storage/driver/util_test.go b/pkg/storage/driver/util_test.go new file mode 100644 index 00000000..d1604392 --- /dev/null +++ b/pkg/storage/driver/util_test.go @@ -0,0 +1,108 @@ +/* +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 driver + +import ( + "reflect" + "testing" +) + +func TestGetSystemLabel(t *testing.T) { + if output := GetSystemLabels(); !reflect.DeepEqual(systemLabels, output) { + t.Errorf("Expected {%v}, got {%v}", systemLabels, output) + } +} + +func TestIsSystemLabel(t *testing.T) { + tests := map[string]bool{ + "name": true, + "owner": true, + "test": false, + "NaMe": false, + } + for label, result := range tests { + if output := isSystemLabel(label); output != result { + t.Errorf("Output %t not equal to expected %t", output, result) + } + } +} + +func TestFilterSystemLabels(t *testing.T) { + var tests = [][2]map[string]string{ + {nil, map[string]string{}}, + {map[string]string{}, map[string]string{}}, + {map[string]string{ + "name": "name", + "owner": "owner", + "status": "status", + "version": "version", + "createdAt": "createdAt", + "modifiedAt": "modifiedAt", + }, map[string]string{}}, + {map[string]string{ + "StaTus": "status", + "name": "name", + "owner": "owner", + "key": "value", + }, map[string]string{ + "StaTus": "status", + "key": "value", + }}, + {map[string]string{ + "key1": "value1", + "key2": "value2", + }, map[string]string{ + "key1": "value1", + "key2": "value2", + }}, + } + for _, test := range tests { + if output := filterSystemLabels(test[0]); !reflect.DeepEqual(test[1], output) { + t.Errorf("Expected {%v}, got {%v}", test[1], output) + } + } +} + +func TestContainsSystemLabels(t *testing.T) { + var tests = []struct { + input map[string]string + output bool + }{ + {nil, false}, + {map[string]string{}, false}, + {map[string]string{ + "name": "name", + "owner": "owner", + "status": "status", + "version": "version", + "createdAt": "createdAt", + "modifiedAt": "modifiedAt", + }, true}, + {map[string]string{ + "StaTus": "status", + "name": "name", + "owner": "owner", + "key": "value", + }, true}, + {map[string]string{ + "key1": "value1", + "key2": "value2", + }, false}, + } + for _, test := range tests { + if output := ContainsSystemLabels(test.input); !reflect.DeepEqual(test.output, output) { + t.Errorf("Expected {%v}, got {%v}", test.output, output) + } + } +} diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go new file mode 100644 index 00000000..0a18b34a --- /dev/null +++ b/pkg/storage/storage.go @@ -0,0 +1,266 @@ +/* +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 storage // import "helm.sh/helm/v3/pkg/storage" + +import ( + "fmt" + "strings" + + "github.com/pkg/errors" + + rspb "helm.sh/helm/v3/pkg/release" + relutil "helm.sh/helm/v3/pkg/releaseutil" + "helm.sh/helm/v3/pkg/storage/driver" +) + +// HelmStorageType is the type field of the Kubernetes storage object which stores the Helm release +// version. It is modified slightly replacing the '/': sh.helm/release.v1 +// Note: The version 'v1' is incremented if the release object metadata is +// modified between major releases. +// This constant is used as a prefix for the Kubernetes storage object name. +const HelmStorageType = "sh.helm.release.v1" + +// Storage represents a storage engine for a Release. +type Storage struct { + driver.Driver + + // MaxHistory specifies the maximum number of historical releases that will + // be retained, including the most recent release. Values of 0 or less are + // ignored (meaning no limits are imposed). + MaxHistory int + + Log func(string, ...interface{}) +} + +// Get retrieves the release from storage. An error is returned +// if the storage driver failed to fetch the release, or the +// release identified by the key, version pair does not exist. +func (s *Storage) Get(name string, version int) (*rspb.Release, error) { + s.Log("getting release %q", makeKey(name, version)) + return s.Driver.Get(makeKey(name, version)) +} + +// Create creates a new storage entry holding the release. An +// error is returned if the storage driver fails to store the +// release, or a release with an identical key already exists. +func (s *Storage) Create(rls *rspb.Release) error { + s.Log("creating release %q", makeKey(rls.Name, rls.Version)) + if s.MaxHistory > 0 { + // Want to make space for one more release. + if err := s.removeLeastRecent(rls.Name, s.MaxHistory-1); err != nil && + !errors.Is(err, driver.ErrReleaseNotFound) { + return err + } + } + return s.Driver.Create(makeKey(rls.Name, rls.Version), rls) +} + +// Update updates the release in storage. An error is returned if the +// storage backend fails to update the release or if the release +// does not exist. +func (s *Storage) Update(rls *rspb.Release) error { + s.Log("updating release %q", makeKey(rls.Name, rls.Version)) + return s.Driver.Update(makeKey(rls.Name, rls.Version), rls) +} + +// Delete deletes the release from storage. An error is returned if +// the storage backend fails to delete the release or if the release +// does not exist. +func (s *Storage) Delete(name string, version int) (*rspb.Release, error) { + s.Log("deleting release %q", makeKey(name, version)) + return s.Driver.Delete(makeKey(name, version)) +} + +// ListReleases returns all releases from storage. An error is returned if the +// storage backend fails to retrieve the releases. +func (s *Storage) ListReleases() ([]*rspb.Release, error) { + s.Log("listing all releases in storage") + return s.Driver.List(func(_ *rspb.Release) bool { return true }) +} + +// ListUninstalled returns all releases with Status == UNINSTALLED. An error is returned +// if the storage backend fails to retrieve the releases. +func (s *Storage) ListUninstalled() ([]*rspb.Release, error) { + s.Log("listing uninstalled releases in storage") + return s.Driver.List(func(rls *rspb.Release) bool { + return relutil.StatusFilter(rspb.StatusUninstalled).Check(rls) + }) +} + +// ListDeployed returns all releases with Status == DEPLOYED. An error is returned +// if the storage backend fails to retrieve the releases. +func (s *Storage) ListDeployed() ([]*rspb.Release, error) { + s.Log("listing all deployed releases in storage") + return s.Driver.List(func(rls *rspb.Release) bool { + return relutil.StatusFilter(rspb.StatusDeployed).Check(rls) + }) +} + +// Deployed returns the last deployed release with the provided release name, or +// returns ErrReleaseNotFound if not found. +func (s *Storage) Deployed(name string) (*rspb.Release, error) { + ls, err := s.DeployedAll(name) + if err != nil { + return nil, err + } + + if len(ls) == 0 { + return nil, driver.NewErrNoDeployedReleases(name) + } + + // If executed concurrently, Helm's database gets corrupted + // and multiple releases are DEPLOYED. Take the latest. + relutil.Reverse(ls, relutil.SortByRevision) + + return ls[0], nil +} + +// DeployedAll returns all deployed releases with the provided name, or +// returns ErrReleaseNotFound if not found. +func (s *Storage) DeployedAll(name string) ([]*rspb.Release, error) { + s.Log("getting deployed releases from %q history", name) + + ls, err := s.Driver.Query(map[string]string{ + "name": name, + "owner": "helm", + "status": "deployed", + }) + if err == nil { + return ls, nil + } + if strings.Contains(err.Error(), "not found") { + return nil, driver.NewErrNoDeployedReleases(name) + } + return nil, err +} + +// History returns the revision history for the release with the provided name, or +// returns ErrReleaseNotFound if no such release name exists. +func (s *Storage) History(name string) ([]*rspb.Release, error) { + s.Log("getting release history for %q", name) + + return s.Driver.Query(map[string]string{"name": name, "owner": "helm"}) +} + +// removeLeastRecent removes items from history until the length number of releases +// does not exceed max. +// +// We allow max to be set explicitly so that calling functions can "make space" +// for the new records they are going to write. +func (s *Storage) removeLeastRecent(name string, max int) error { + if max < 0 { + return nil + } + h, err := s.History(name) + if err != nil { + return err + } + if len(h) <= max { + return nil + } + + // We want oldest to newest + relutil.SortByRevision(h) + + lastDeployed, err := s.Deployed(name) + if err != nil && !errors.Is(err, driver.ErrNoDeployedReleases) { + return err + } + + var toDelete []*rspb.Release + for _, rel := range h { + // once we have enough releases to delete to reach the max, stop + if len(h)-len(toDelete) == max { + break + } + if lastDeployed != nil { + if rel.Version != lastDeployed.Version { + toDelete = append(toDelete, rel) + } + } else { + toDelete = append(toDelete, rel) + } + } + + // Delete as many as possible. In the case of API throughput limitations, + // multiple invocations of this function will eventually delete them all. + errs := []error{} + for _, rel := range toDelete { + err = s.deleteReleaseVersion(name, rel.Version) + if err != nil { + errs = append(errs, err) + } + } + + s.Log("Pruned %d record(s) from %s with %d error(s)", len(toDelete), name, len(errs)) + switch c := len(errs); c { + case 0: + return nil + case 1: + return errs[0] + default: + return errors.Errorf("encountered %d deletion errors. First is: %s", c, errs[0]) + } +} + +func (s *Storage) deleteReleaseVersion(name string, version int) error { + key := makeKey(name, version) + _, err := s.Delete(name, version) + if err != nil { + s.Log("error pruning %s from release history: %s", key, err) + return err + } + return nil +} + +// Last fetches the last revision of the named release. +func (s *Storage) Last(name string) (*rspb.Release, error) { + s.Log("getting last revision of %q", name) + h, err := s.History(name) + if err != nil { + return nil, err + } + if len(h) == 0 { + return nil, errors.Errorf("no revision for release %q", name) + } + + relutil.Reverse(h, relutil.SortByRevision) + return h[0], nil +} + +// makeKey concatenates the Kubernetes storage object type, a release name and version +// into a string with format:```..v```. +// The storage type is prepended to keep name uniqueness between different +// release storage types. An example of clash when not using the type: +// https://github.com/helm/helm/issues/6435. +// This key is used to uniquely identify storage objects. +func makeKey(rlsname string, version int) string { + return fmt.Sprintf("%s.%s.v%d", HelmStorageType, rlsname, version) +} + +// Init initializes a new storage backend with the driver d. +// If d is nil, the default in-memory driver is used. +func Init(d driver.Driver) *Storage { + // default driver is in memory + if d == nil { + d = driver.NewMemory() + } + return &Storage{ + Driver: d, + Log: func(_ string, _ ...interface{}) {}, + } +} diff --git a/pkg/storage/storage_test.go b/pkg/storage/storage_test.go new file mode 100644 index 00000000..d50e3fbf --- /dev/null +++ b/pkg/storage/storage_test.go @@ -0,0 +1,560 @@ +/* +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 storage // import "helm.sh/helm/v3/pkg/storage" + +import ( + "fmt" + "reflect" + "testing" + + "github.com/pkg/errors" + + rspb "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/storage/driver" +) + +func TestStorageCreate(t *testing.T) { + // initialize storage + storage := Init(driver.NewMemory()) + + // create fake release + rls := ReleaseTestData{ + Name: "angry-beaver", + Version: 1, + }.ToRelease() + + assertErrNil(t.Fatal, storage.Create(rls), "StoreRelease") + + // fetch the release + res, err := storage.Get(rls.Name, rls.Version) + assertErrNil(t.Fatal, err, "QueryRelease") + + // verify the fetched and created release are the same + if !reflect.DeepEqual(rls, res) { + t.Fatalf("Expected %v, got %v", rls, res) + } +} + +func TestStorageUpdate(t *testing.T) { + // initialize storage + storage := Init(driver.NewMemory()) + + // create fake release + rls := ReleaseTestData{ + Name: "angry-beaver", + Version: 1, + Status: rspb.StatusDeployed, + }.ToRelease() + + assertErrNil(t.Fatal, storage.Create(rls), "StoreRelease") + + // modify the release + rls.Info.Status = rspb.StatusUninstalled + assertErrNil(t.Fatal, storage.Update(rls), "UpdateRelease") + + // retrieve the updated release + res, err := storage.Get(rls.Name, rls.Version) + assertErrNil(t.Fatal, err, "QueryRelease") + + // verify updated and fetched releases are the same. + if !reflect.DeepEqual(rls, res) { + t.Fatalf("Expected %v, got %v", rls, res) + } +} + +func TestStorageDelete(t *testing.T) { + // initialize storage + storage := Init(driver.NewMemory()) + + // create fake release + rls := ReleaseTestData{ + Name: "angry-beaver", + Version: 1, + }.ToRelease() + rls2 := ReleaseTestData{ + Name: "angry-beaver", + Version: 2, + }.ToRelease() + + assertErrNil(t.Fatal, storage.Create(rls), "StoreRelease") + assertErrNil(t.Fatal, storage.Create(rls2), "StoreRelease") + + // delete the release + res, err := storage.Delete(rls.Name, rls.Version) + assertErrNil(t.Fatal, err, "DeleteRelease") + + // verify updated and fetched releases are the same. + if !reflect.DeepEqual(rls, res) { + t.Fatalf("Expected %v, got %v", rls, res) + } + + hist, err := storage.History(rls.Name) + if err != nil { + t.Errorf("unexpected error: %s", err) + } + + // We have now deleted one of the two records. + if len(hist) != 1 { + t.Errorf("expected 1 record for deleted release version, got %d", len(hist)) + } + + if hist[0].Version != 2 { + t.Errorf("Expected version to be 2, got %d", hist[0].Version) + } +} + +func TestStorageList(t *testing.T) { + // initialize storage + storage := Init(driver.NewMemory()) + + // setup storage with test releases + setup := func() { + // release records + rls0 := ReleaseTestData{Name: "happy-catdog", Status: rspb.StatusSuperseded}.ToRelease() + rls1 := ReleaseTestData{Name: "livid-human", Status: rspb.StatusSuperseded}.ToRelease() + rls2 := ReleaseTestData{Name: "relaxed-cat", Status: rspb.StatusSuperseded}.ToRelease() + rls3 := ReleaseTestData{Name: "hungry-hippo", Status: rspb.StatusDeployed}.ToRelease() + rls4 := ReleaseTestData{Name: "angry-beaver", Status: rspb.StatusDeployed}.ToRelease() + rls5 := ReleaseTestData{Name: "opulent-frog", Status: rspb.StatusUninstalled}.ToRelease() + rls6 := ReleaseTestData{Name: "happy-liger", Status: rspb.StatusUninstalled}.ToRelease() + + // create the release records in the storage + assertErrNil(t.Fatal, storage.Create(rls0), "Storing release 'rls0'") + assertErrNil(t.Fatal, storage.Create(rls1), "Storing release 'rls1'") + assertErrNil(t.Fatal, storage.Create(rls2), "Storing release 'rls2'") + assertErrNil(t.Fatal, storage.Create(rls3), "Storing release 'rls3'") + assertErrNil(t.Fatal, storage.Create(rls4), "Storing release 'rls4'") + assertErrNil(t.Fatal, storage.Create(rls5), "Storing release 'rls5'") + assertErrNil(t.Fatal, storage.Create(rls6), "Storing release 'rls6'") + } + + var listTests = []struct { + Description string + NumExpected int + ListFunc func() ([]*rspb.Release, error) + }{ + {"ListDeployed", 2, storage.ListDeployed}, + {"ListReleases", 7, storage.ListReleases}, + {"ListUninstalled", 2, storage.ListUninstalled}, + } + + setup() + + for _, tt := range listTests { + list, err := tt.ListFunc() + assertErrNil(t.Fatal, err, tt.Description) + // verify the count of releases returned + if len(list) != tt.NumExpected { + t.Errorf("ListReleases(%s): expected %d, actual %d", + tt.Description, + tt.NumExpected, + len(list)) + } + } +} + +func TestStorageDeployed(t *testing.T) { + storage := Init(driver.NewMemory()) + + const name = "angry-bird" + const vers = 4 + + // setup storage with test releases + setup := func() { + // release records + rls0 := ReleaseTestData{Name: name, Version: 1, Status: rspb.StatusSuperseded}.ToRelease() + rls1 := ReleaseTestData{Name: name, Version: 2, Status: rspb.StatusSuperseded}.ToRelease() + rls2 := ReleaseTestData{Name: name, Version: 3, Status: rspb.StatusSuperseded}.ToRelease() + rls3 := ReleaseTestData{Name: name, Version: 4, Status: rspb.StatusDeployed}.ToRelease() + + // create the release records in the storage + assertErrNil(t.Fatal, storage.Create(rls0), "Storing release 'angry-bird' (v1)") + assertErrNil(t.Fatal, storage.Create(rls1), "Storing release 'angry-bird' (v2)") + assertErrNil(t.Fatal, storage.Create(rls2), "Storing release 'angry-bird' (v3)") + assertErrNil(t.Fatal, storage.Create(rls3), "Storing release 'angry-bird' (v4)") + } + + setup() + + rls, err := storage.Last(name) + if err != nil { + t.Fatalf("Failed to query for deployed release: %s\n", err) + } + + switch { + case rls == nil: + t.Fatalf("Release is nil") + case rls.Name != name: + t.Fatalf("Expected release name %q, actual %q\n", name, rls.Name) + case rls.Version != vers: + t.Fatalf("Expected release version %d, actual %d\n", vers, rls.Version) + case rls.Info.Status != rspb.StatusDeployed: + t.Fatalf("Expected release status 'DEPLOYED', actual %s\n", rls.Info.Status.String()) + } +} + +func TestStorageDeployedWithCorruption(t *testing.T) { + storage := Init(driver.NewMemory()) + + const name = "angry-bird" + const vers = int(4) + + // setup storage with test releases + setup := func() { + // release records (notice odd order and corruption) + rls0 := ReleaseTestData{Name: name, Version: 1, Status: rspb.StatusSuperseded}.ToRelease() + rls1 := ReleaseTestData{Name: name, Version: 4, Status: rspb.StatusDeployed}.ToRelease() + rls2 := ReleaseTestData{Name: name, Version: 3, Status: rspb.StatusSuperseded}.ToRelease() + rls3 := ReleaseTestData{Name: name, Version: 2, Status: rspb.StatusDeployed}.ToRelease() + + // create the release records in the storage + assertErrNil(t.Fatal, storage.Create(rls0), "Storing release 'angry-bird' (v1)") + assertErrNil(t.Fatal, storage.Create(rls1), "Storing release 'angry-bird' (v2)") + assertErrNil(t.Fatal, storage.Create(rls2), "Storing release 'angry-bird' (v3)") + assertErrNil(t.Fatal, storage.Create(rls3), "Storing release 'angry-bird' (v4)") + } + + setup() + + rls, err := storage.Deployed(name) + if err != nil { + t.Fatalf("Failed to query for deployed release: %s\n", err) + } + + switch { + case rls == nil: + t.Fatalf("Release is nil") + case rls.Name != name: + t.Fatalf("Expected release name %q, actual %q\n", name, rls.Name) + case rls.Version != vers: + t.Fatalf("Expected release version %d, actual %d\n", vers, rls.Version) + case rls.Info.Status != rspb.StatusDeployed: + t.Fatalf("Expected release status 'DEPLOYED', actual %s\n", rls.Info.Status.String()) + } +} + +func TestStorageHistory(t *testing.T) { + storage := Init(driver.NewMemory()) + + const name = "angry-bird" + + // setup storage with test releases + setup := func() { + // release records + rls0 := ReleaseTestData{Name: name, Version: 1, Status: rspb.StatusSuperseded}.ToRelease() + rls1 := ReleaseTestData{Name: name, Version: 2, Status: rspb.StatusSuperseded}.ToRelease() + rls2 := ReleaseTestData{Name: name, Version: 3, Status: rspb.StatusSuperseded}.ToRelease() + rls3 := ReleaseTestData{Name: name, Version: 4, Status: rspb.StatusDeployed}.ToRelease() + + // create the release records in the storage + assertErrNil(t.Fatal, storage.Create(rls0), "Storing release 'angry-bird' (v1)") + assertErrNil(t.Fatal, storage.Create(rls1), "Storing release 'angry-bird' (v2)") + assertErrNil(t.Fatal, storage.Create(rls2), "Storing release 'angry-bird' (v3)") + assertErrNil(t.Fatal, storage.Create(rls3), "Storing release 'angry-bird' (v4)") + } + + setup() + + h, err := storage.History(name) + if err != nil { + t.Fatalf("Failed to query for release history (%q): %s\n", name, err) + } + if len(h) != 4 { + t.Fatalf("Release history (%q) is empty\n", name) + } +} + +var errMaxHistoryMockDriverSomethingHappened = errors.New("something happened") + +type MaxHistoryMockDriver struct { + Driver driver.Driver +} + +func NewMaxHistoryMockDriver(d driver.Driver) *MaxHistoryMockDriver { + return &MaxHistoryMockDriver{Driver: d} +} +func (d *MaxHistoryMockDriver) Create(key string, rls *rspb.Release) error { + return d.Driver.Create(key, rls) +} +func (d *MaxHistoryMockDriver) Update(key string, rls *rspb.Release) error { + return d.Driver.Update(key, rls) +} +func (d *MaxHistoryMockDriver) Delete(_ string) (*rspb.Release, error) { + return nil, errMaxHistoryMockDriverSomethingHappened +} +func (d *MaxHistoryMockDriver) Get(key string) (*rspb.Release, error) { + return d.Driver.Get(key) +} +func (d *MaxHistoryMockDriver) List(filter func(*rspb.Release) bool) ([]*rspb.Release, error) { + return d.Driver.List(filter) +} +func (d *MaxHistoryMockDriver) Query(labels map[string]string) ([]*rspb.Release, error) { + return d.Driver.Query(labels) +} +func (d *MaxHistoryMockDriver) Name() string { + return d.Driver.Name() +} + +func TestMaxHistoryErrorHandling(t *testing.T) { + //func TestStorageRemoveLeastRecentWithError(t *testing.T) { + storage := Init(NewMaxHistoryMockDriver(driver.NewMemory())) + storage.Log = t.Logf + + storage.MaxHistory = 1 + + const name = "angry-bird" + + // setup storage with test releases + setup := func() { + // release records + rls1 := ReleaseTestData{Name: name, Version: 1, Status: rspb.StatusSuperseded}.ToRelease() + + // create the release records in the storage + assertErrNil(t.Fatal, storage.Driver.Create(makeKey(rls1.Name, rls1.Version), rls1), "Storing release 'angry-bird' (v1)") + } + setup() + + rls2 := ReleaseTestData{Name: name, Version: 2, Status: rspb.StatusSuperseded}.ToRelease() + wantErr := errMaxHistoryMockDriverSomethingHappened + gotErr := storage.Create(rls2) + if !errors.Is(gotErr, wantErr) { + t.Fatalf("Storing release 'angry-bird' (v2) should return the error %#v, but returned %#v", wantErr, gotErr) + } +} + +func TestStorageRemoveLeastRecent(t *testing.T) { + storage := Init(driver.NewMemory()) + storage.Log = t.Logf + + // Make sure that specifying this at the outset doesn't cause any bugs. + storage.MaxHistory = 10 + + const name = "angry-bird" + + // setup storage with test releases + setup := func() { + // release records + rls0 := ReleaseTestData{Name: name, Version: 1, Status: rspb.StatusSuperseded}.ToRelease() + rls1 := ReleaseTestData{Name: name, Version: 2, Status: rspb.StatusSuperseded}.ToRelease() + rls2 := ReleaseTestData{Name: name, Version: 3, Status: rspb.StatusSuperseded}.ToRelease() + rls3 := ReleaseTestData{Name: name, Version: 4, Status: rspb.StatusDeployed}.ToRelease() + + // create the release records in the storage + assertErrNil(t.Fatal, storage.Create(rls0), "Storing release 'angry-bird' (v1)") + assertErrNil(t.Fatal, storage.Create(rls1), "Storing release 'angry-bird' (v2)") + assertErrNil(t.Fatal, storage.Create(rls2), "Storing release 'angry-bird' (v3)") + assertErrNil(t.Fatal, storage.Create(rls3), "Storing release 'angry-bird' (v4)") + } + setup() + + // Because we have not set a limit, we expect 4. + expect := 4 + if hist, err := storage.History(name); err != nil { + t.Fatal(err) + } else if len(hist) != expect { + t.Fatalf("expected %d items in history, got %d", expect, len(hist)) + } + + storage.MaxHistory = 3 + rls5 := ReleaseTestData{Name: name, Version: 5, Status: rspb.StatusDeployed}.ToRelease() + assertErrNil(t.Fatal, storage.Create(rls5), "Storing release 'angry-bird' (v5)") + + // On inserting the 5th record, we expect two records to be pruned from history. + hist, err := storage.History(name) + if err != nil { + t.Fatal(err) + } else if len(hist) != storage.MaxHistory { + for _, item := range hist { + t.Logf("%s %v", item.Name, item.Version) + } + t.Fatalf("expected %d items in history, got %d", storage.MaxHistory, len(hist)) + } + + // We expect the existing records to be 3, 4, and 5. + for i, item := range hist { + v := item.Version + if expect := i + 3; v != expect { + t.Errorf("Expected release %d, got %d", expect, v) + } + } +} + +func TestStorageDoNotDeleteDeployed(t *testing.T) { + storage := Init(driver.NewMemory()) + storage.Log = t.Logf + storage.MaxHistory = 3 + + const name = "angry-bird" + + // setup storage with test releases + setup := func() { + // release records + rls0 := ReleaseTestData{Name: name, Version: 1, Status: rspb.StatusSuperseded}.ToRelease() + rls1 := ReleaseTestData{Name: name, Version: 2, Status: rspb.StatusDeployed}.ToRelease() + rls2 := ReleaseTestData{Name: name, Version: 3, Status: rspb.StatusFailed}.ToRelease() + rls3 := ReleaseTestData{Name: name, Version: 4, Status: rspb.StatusFailed}.ToRelease() + + // create the release records in the storage + assertErrNil(t.Fatal, storage.Create(rls0), "Storing release 'angry-bird' (v1)") + assertErrNil(t.Fatal, storage.Create(rls1), "Storing release 'angry-bird' (v2)") + assertErrNil(t.Fatal, storage.Create(rls2), "Storing release 'angry-bird' (v3)") + assertErrNil(t.Fatal, storage.Create(rls3), "Storing release 'angry-bird' (v4)") + } + setup() + + rls5 := ReleaseTestData{Name: name, Version: 5, Status: rspb.StatusFailed}.ToRelease() + assertErrNil(t.Fatal, storage.Create(rls5), "Storing release 'angry-bird' (v5)") + + // On inserting the 5th record, we expect a total of 3 releases, but we expect version 2 + // (the only deployed release), to still exist + hist, err := storage.History(name) + if err != nil { + t.Fatal(err) + } else if len(hist) != storage.MaxHistory { + for _, item := range hist { + t.Logf("%s %v", item.Name, item.Version) + } + t.Fatalf("expected %d items in history, got %d", storage.MaxHistory, len(hist)) + } + + expectedVersions := map[int]bool{ + 2: true, + 4: true, + 5: true, + } + + for _, item := range hist { + if !expectedVersions[item.Version] { + t.Errorf("Release version %d, found when not expected", item.Version) + } + } +} + +func TestStorageLast(t *testing.T) { + storage := Init(driver.NewMemory()) + + const name = "angry-bird" + + // Set up storage with test releases. + setup := func() { + // release records + rls0 := ReleaseTestData{Name: name, Version: 1, Status: rspb.StatusSuperseded}.ToRelease() + rls1 := ReleaseTestData{Name: name, Version: 2, Status: rspb.StatusSuperseded}.ToRelease() + rls2 := ReleaseTestData{Name: name, Version: 3, Status: rspb.StatusSuperseded}.ToRelease() + rls3 := ReleaseTestData{Name: name, Version: 4, Status: rspb.StatusFailed}.ToRelease() + + // create the release records in the storage + assertErrNil(t.Fatal, storage.Create(rls0), "Storing release 'angry-bird' (v1)") + assertErrNil(t.Fatal, storage.Create(rls1), "Storing release 'angry-bird' (v2)") + assertErrNil(t.Fatal, storage.Create(rls2), "Storing release 'angry-bird' (v3)") + assertErrNil(t.Fatal, storage.Create(rls3), "Storing release 'angry-bird' (v4)") + } + + setup() + + h, err := storage.Last(name) + if err != nil { + t.Fatalf("Failed to query for release history (%q): %s\n", name, err) + } + + if h.Version != 4 { + t.Errorf("Expected revision 4, got %d", h.Version) + } +} + +// TestUpgradeInitiallyFailedRelease tests a case when there are no deployed release yet, but history limit has been +// reached: the has-no-deployed-releases error should not occur in such case. +func TestUpgradeInitiallyFailedReleaseWithHistoryLimit(t *testing.T) { + storage := Init(driver.NewMemory()) + storage.MaxHistory = 4 + + const name = "angry-bird" + + // setup storage with test releases + setup := func() { + // release records + rls0 := ReleaseTestData{Name: name, Version: 1, Status: rspb.StatusFailed}.ToRelease() + rls1 := ReleaseTestData{Name: name, Version: 2, Status: rspb.StatusFailed}.ToRelease() + rls2 := ReleaseTestData{Name: name, Version: 3, Status: rspb.StatusFailed}.ToRelease() + rls3 := ReleaseTestData{Name: name, Version: 4, Status: rspb.StatusFailed}.ToRelease() + + // create the release records in the storage + assertErrNil(t.Fatal, storage.Create(rls0), "Storing release 'angry-bird' (v1)") + assertErrNil(t.Fatal, storage.Create(rls1), "Storing release 'angry-bird' (v2)") + assertErrNil(t.Fatal, storage.Create(rls2), "Storing release 'angry-bird' (v3)") + assertErrNil(t.Fatal, storage.Create(rls3), "Storing release 'angry-bird' (v4)") + + hist, err := storage.History(name) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + wantHistoryLen := 4 + if len(hist) != wantHistoryLen { + t.Fatalf("expected history of release %q to contain %d releases, got %d", name, wantHistoryLen, len(hist)) + } + } + + setup() + + rls5 := ReleaseTestData{Name: name, Version: 5, Status: rspb.StatusFailed}.ToRelease() + err := storage.Create(rls5) + if err != nil { + t.Fatalf("Failed to create a new release version: %s", err) + } + + hist, err := storage.History(name) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + for i, rel := range hist { + wantVersion := i + 2 + if rel.Version != wantVersion { + t.Fatalf("Expected history release %d version to equal %d, got %d", i+1, wantVersion, rel.Version) + } + + wantStatus := rspb.StatusFailed + if rel.Info.Status != wantStatus { + t.Fatalf("Expected history release %d status to equal %q, got %q", i+1, wantStatus, rel.Info.Status) + } + } +} + +type ReleaseTestData struct { + Name string + Version int + Manifest string + Namespace string + Status rspb.Status +} + +func (test ReleaseTestData) ToRelease() *rspb.Release { + return &rspb.Release{ + Name: test.Name, + Version: test.Version, + Manifest: test.Manifest, + Namespace: test.Namespace, + Info: &rspb.Info{Status: test.Status}, + } +} + +func assertErrNil(eh func(args ...interface{}), err error, message string) { + if err != nil { + eh(fmt.Sprintf("%s: %q", message, err)) + } +} diff --git a/pkg/strvals/doc.go b/pkg/strvals/doc.go new file mode 100644 index 00000000..e9931300 --- /dev/null +++ b/pkg/strvals/doc.go @@ -0,0 +1,33 @@ +/* +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 strvals provides tools for working with strval lines. + +Helm supports a compressed format for YAML settings which we call strvals. +The format is roughly like this: + + name=value,topname.subname=value + +The above is equivalent to the YAML document + + name: value + topname: + subname: value + +This package provides a parser and utilities for converting the strvals format +to other formats. +*/ +package strvals diff --git a/pkg/strvals/literal_parser.go b/pkg/strvals/literal_parser.go new file mode 100644 index 00000000..f7565581 --- /dev/null +++ b/pkg/strvals/literal_parser.go @@ -0,0 +1,244 @@ +/* +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 strvals + +import ( + "bytes" + "fmt" + "io" + "strconv" + + "github.com/pkg/errors" +) + +// ParseLiteral parses a set line interpreting the value as a literal string. +// +// A set line is of the form name1=value1 +func ParseLiteral(s string) (map[string]interface{}, error) { + vals := map[string]interface{}{} + scanner := bytes.NewBufferString(s) + t := newLiteralParser(scanner, vals) + err := t.parse() + return vals, err +} + +// ParseLiteralInto parses a strvals line and merges the result into dest. +// The value is interpreted as a literal string. +// +// If the strval string has a key that exists in dest, it overwrites the +// dest version. +func ParseLiteralInto(s string, dest map[string]interface{}) error { + scanner := bytes.NewBufferString(s) + t := newLiteralParser(scanner, dest) + return t.parse() +} + +// literalParser is a simple parser that takes a strvals line and parses +// it into a map representation. +// +// Values are interpreted as a literal string. +// +// where sc is the source of the original data being parsed +// where data is the final parsed data from the parses with correct types +type literalParser struct { + sc *bytes.Buffer + data map[string]interface{} +} + +func newLiteralParser(sc *bytes.Buffer, data map[string]interface{}) *literalParser { + return &literalParser{sc: sc, data: data} +} + +func (t *literalParser) parse() error { + for { + err := t.key(t.data, 0) + if err == nil { + continue + } + if err == io.EOF { + return nil + } + return err + } +} + +func runesUntilLiteral(in io.RuneReader, stop map[rune]bool) ([]rune, rune, error) { + v := []rune{} + for { + switch r, _, e := in.ReadRune(); { + case e != nil: + return v, r, e + case inMap(r, stop): + return v, r, nil + default: + v = append(v, r) + } + } +} + +func (t *literalParser) key(data map[string]interface{}, nestedNameLevel int) (reterr error) { + defer func() { + if r := recover(); r != nil { + reterr = fmt.Errorf("unable to parse key: %s", r) + } + }() + stop := runeSet([]rune{'=', '[', '.'}) + for { + switch key, lastRune, err := runesUntilLiteral(t.sc, stop); { + case err != nil: + if len(key) == 0 { + return err + } + return errors.Errorf("key %q has no value", string(key)) + + case lastRune == '=': + // found end of key: swallow the '=' and get the value + value, err := t.val() + if err == nil && err != io.EOF { + return err + } + set(data, string(key), string(value)) + return nil + + case lastRune == '.': + // Check value name is within the maximum nested name level + nestedNameLevel++ + if nestedNameLevel > MaxNestedNameLevel { + return fmt.Errorf("value name nested level is greater than maximum supported nested level of %d", MaxNestedNameLevel) + } + + // first, create or find the target map in the given data + inner := map[string]interface{}{} + if _, ok := data[string(key)]; ok { + inner = data[string(key)].(map[string]interface{}) + } + + // recurse on sub-tree with remaining data + err := t.key(inner, nestedNameLevel) + if err == nil && len(inner) == 0 { + return errors.Errorf("key map %q has no value", string(key)) + } + if len(inner) != 0 { + set(data, string(key), inner) + } + return err + + case lastRune == '[': + // We are in a list index context, so we need to set an index. + i, err := t.keyIndex() + if err != nil { + return errors.Wrap(err, "error parsing index") + } + kk := string(key) + + // find or create target list + list := []interface{}{} + if _, ok := data[kk]; ok { + list = data[kk].([]interface{}) + } + + // now we need to get the value after the ] + list, err = t.listItem(list, i, nestedNameLevel) + set(data, kk, list) + return err + } + } +} + +func (t *literalParser) keyIndex() (int, error) { + // First, get the key. + stop := runeSet([]rune{']'}) + v, _, err := runesUntilLiteral(t.sc, stop) + if err != nil { + return 0, err + } + + // v should be the index + return strconv.Atoi(string(v)) +} + +func (t *literalParser) listItem(list []interface{}, i, nestedNameLevel int) ([]interface{}, error) { + if i < 0 { + return list, fmt.Errorf("negative %d index not allowed", i) + } + stop := runeSet([]rune{'[', '.', '='}) + + switch key, lastRune, err := runesUntilLiteral(t.sc, stop); { + case len(key) > 0: + return list, errors.Errorf("unexpected data at end of array index: %q", key) + + case err != nil: + return list, err + + case lastRune == '=': + value, err := t.val() + if err != nil && err != io.EOF { + return list, err + } + return setIndex(list, i, string(value)) + + case lastRune == '.': + // we have a nested object. Send to t.key + inner := map[string]interface{}{} + if len(list) > i { + var ok bool + inner, ok = list[i].(map[string]interface{}) + if !ok { + // We have indices out of order. Initialize empty value. + list[i] = map[string]interface{}{} + inner = list[i].(map[string]interface{}) + } + } + + // recurse + err := t.key(inner, nestedNameLevel) + if err != nil { + return list, err + } + return setIndex(list, i, inner) + + case lastRune == '[': + // now we have a nested list. Read the index and handle. + nextI, err := t.keyIndex() + if err != nil { + return list, errors.Wrap(err, "error parsing index") + } + var crtList []interface{} + if len(list) > i { + // If nested list already exists, take the value of list to next cycle. + existed := list[i] + if existed != nil { + crtList = list[i].([]interface{}) + } + } + + // Now we need to get the value after the ]. + list2, err := t.listItem(crtList, nextI, nestedNameLevel) + if err != nil { + return list, err + } + return setIndex(list, i, list2) + + default: + return nil, errors.Errorf("parse error: unexpected token %v", lastRune) + } +} + +func (t *literalParser) val() ([]rune, error) { + stop := runeSet([]rune{}) + v, _, err := runesUntilLiteral(t.sc, stop) + return v, err +} diff --git a/pkg/strvals/literal_parser_test.go b/pkg/strvals/literal_parser_test.go new file mode 100644 index 00000000..4e74423d --- /dev/null +++ b/pkg/strvals/literal_parser_test.go @@ -0,0 +1,480 @@ +/* +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 strvals + +import ( + "fmt" + "testing" + + "sigs.k8s.io/yaml" +) + +func TestParseLiteral(t *testing.T) { + cases := []struct { + str string + expect map[string]interface{} + err bool + }{ + { + str: "name", + err: true, + }, + { + str: "name=", + expect: map[string]interface{}{"name": ""}, + }, + { + str: "name=value", + expect: map[string]interface{}{"name": "value"}, + err: false, + }, + { + str: "long_int_string=1234567890", + expect: map[string]interface{}{"long_int_string": "1234567890"}, + err: false, + }, + { + str: "boolean=true", + expect: map[string]interface{}{"boolean": "true"}, + err: false, + }, + { + str: "is_null=null", + expect: map[string]interface{}{"is_null": "null"}, + err: false, + }, + { + str: "zero=0", + expect: map[string]interface{}{"zero": "0"}, + err: false, + }, + { + str: "name1=null,name2=value2", + expect: map[string]interface{}{"name1": "null,name2=value2"}, + err: false, + }, + { + str: "name1=value,,,tail", + expect: map[string]interface{}{"name1": "value,,,tail"}, + err: false, + }, + { + str: "leading_zeros=00009", + expect: map[string]interface{}{"leading_zeros": "00009"}, + err: false, + }, + { + str: "name=one two three", + expect: map[string]interface{}{"name": "one two three"}, + err: false, + }, + { + str: "outer.inner=value", + expect: map[string]interface{}{"outer": map[string]interface{}{"inner": "value"}}, + err: false, + }, + { + str: "outer.middle.inner=value", + expect: map[string]interface{}{"outer": map[string]interface{}{"middle": map[string]interface{}{"inner": "value"}}}, + err: false, + }, + { + str: "name1.name2", + err: true, + }, + { + str: "name1.name2=", + expect: map[string]interface{}{"name1": map[string]interface{}{"name2": ""}}, + err: false, + }, + { + str: "name1.=name2", + err: true, + }, + { + str: "name1.,name2", + err: true, + }, + { + str: "name1={value1,value2}", + expect: map[string]interface{}{"name1": "{value1,value2}"}, + }, + + // List support + { + str: "list[0]=foo", + expect: map[string]interface{}{"list": []string{"foo"}}, + err: false, + }, + { + str: "list[0].foo=bar", + expect: map[string]interface{}{ + "list": []interface{}{ + map[string]interface{}{"foo": "bar"}, + }, + }, + err: false, + }, + { + str: "list[-30].hello=world", + err: true, + }, + { + str: "list[3]=bar", + expect: map[string]interface{}{"list": []interface{}{nil, nil, nil, "bar"}}, + err: false, + }, + { + str: "illegal[0]name.foo=bar", + err: true, + }, + { + str: "noval[0]", + expect: map[string]interface{}{"noval": []interface{}{}}, + err: false, + }, + { + str: "noval[0]=", + expect: map[string]interface{}{"noval": []interface{}{""}}, + err: false, + }, + { + str: "nested[0][0]=1", + expect: map[string]interface{}{"nested": []interface{}{[]interface{}{"1"}}}, + err: false, + }, + { + str: "nested[1][1]=1", + expect: map[string]interface{}{"nested": []interface{}{nil, []interface{}{nil, "1"}}}, + err: false, + }, + { + str: "name1.name2[0].foo=bar", + expect: map[string]interface{}{ + "name1": map[string]interface{}{ + "name2": []map[string]interface{}{{"foo": "bar"}}, + }, + }, + }, + { + str: "name1.name2[1].foo=bar", + expect: map[string]interface{}{ + "name1": map[string]interface{}{ + "name2": []map[string]interface{}{nil, {"foo": "bar"}}, + }, + }, + }, + { + str: "name1.name2[1].foo=bar", + expect: map[string]interface{}{ + "name1": map[string]interface{}{ + "name2": []map[string]interface{}{nil, {"foo": "bar"}}, + }, + }, + }, + { + str: "]={}].", + expect: map[string]interface{}{"]": "{}]."}, + err: false, + }, + + // issue test cases: , = $ ( ) { } . \ \\ + { + str: "name=val,val", + expect: map[string]interface{}{"name": "val,val"}, + err: false, + }, + { + str: "name=val.val", + expect: map[string]interface{}{"name": "val.val"}, + err: false, + }, + { + str: "name=val=val", + expect: map[string]interface{}{"name": "val=val"}, + err: false, + }, + { + str: "name=val$val", + expect: map[string]interface{}{"name": "val$val"}, + err: false, + }, + { + str: "name=(value", + expect: map[string]interface{}{"name": "(value"}, + err: false, + }, + { + str: "name=value)", + expect: map[string]interface{}{"name": "value)"}, + err: false, + }, + { + str: "name=(value)", + expect: map[string]interface{}{"name": "(value)"}, + err: false, + }, + { + str: "name={value", + expect: map[string]interface{}{"name": "{value"}, + err: false, + }, + { + str: "name=value}", + expect: map[string]interface{}{"name": "value}"}, + err: false, + }, + { + str: "name={value}", + expect: map[string]interface{}{"name": "{value}"}, + err: false, + }, + { + str: "name={value1,value2}", + expect: map[string]interface{}{"name": "{value1,value2}"}, + err: false, + }, + { + str: `name=val\val`, + expect: map[string]interface{}{"name": `val\val`}, + err: false, + }, + { + str: `name=val\\val`, + expect: map[string]interface{}{"name": `val\\val`}, + err: false, + }, + { + str: `name=val\\\val`, + expect: map[string]interface{}{"name": `val\\\val`}, + err: false, + }, + { + str: `name={val,.?*v\0a!l)some`, + expect: map[string]interface{}{"name": `{val,.?*v\0a!l)some`}, + err: false, + }, + { + str: `name=em%GT)tqUDqz,i-\h+Mbqs-!:.m\\rE=mkbM#rR}@{-k@`, + expect: map[string]interface{}{"name": `em%GT)tqUDqz,i-\h+Mbqs-!:.m\\rE=mkbM#rR}@{-k@`}, + }, + } + + for _, tt := range cases { + got, err := ParseLiteral(tt.str) + if err != nil { + if !tt.err { + t.Fatalf("%s: %s", tt.str, err) + } + continue + } + + if tt.err { + t.Errorf("%s: Expected error. Got nil", tt.str) + } + + y1, err := yaml.Marshal(tt.expect) + if err != nil { + t.Fatal(err) + } + + y2, err := yaml.Marshal(got) + if err != nil { + t.Fatalf("Error serializing parsed value: %s", err) + } + + if string(y1) != string(y2) { + t.Errorf("%s: Expected:\n%s\nGot:\n%s", tt.str, y1, y2) + } + } +} + +func TestParseLiteralInto(t *testing.T) { + tests := []struct { + input string + input2 string + got map[string]interface{} + expect map[string]interface{} + err bool + }{ + { + input: "outer.inner1=value1,outer.inner3=value3,outer.inner4=4", + got: map[string]interface{}{ + "outer": map[string]interface{}{ + "inner1": "overwrite", + "inner2": "value2", + }, + }, + expect: map[string]interface{}{ + "outer": map[string]interface{}{ + "inner1": "value1,outer.inner3=value3,outer.inner4=4", + "inner2": "value2", + }}, + err: false, + }, + { + input: "listOuter[0][0].type=listValue", + input2: "listOuter[0][0].status=alive", + got: map[string]interface{}{}, + expect: map[string]interface{}{ + "listOuter": [][]interface{}{{map[string]string{ + "type": "listValue", + "status": "alive", + }}}, + }, + err: false, + }, + { + input: "listOuter[0][0].type=listValue", + input2: "listOuter[1][0].status=alive", + got: map[string]interface{}{}, + expect: map[string]interface{}{ + "listOuter": [][]interface{}{ + { + map[string]string{"type": "listValue"}, + }, + { + map[string]string{"status": "alive"}, + }, + }, + }, + err: false, + }, + { + input: "listOuter[0][1][0].type=listValue", + input2: "listOuter[0][0][1].status=alive", + got: map[string]interface{}{ + "listOuter": []interface{}{ + []interface{}{ + []interface{}{ + map[string]string{"exited": "old"}, + }, + }, + }, + }, + expect: map[string]interface{}{ + "listOuter": [][][]interface{}{ + { + { + map[string]string{"exited": "old"}, + map[string]string{"status": "alive"}, + }, + { + map[string]string{"type": "listValue"}, + }, + }, + }, + }, + err: false, + }, + } + + for _, tt := range tests { + if err := ParseLiteralInto(tt.input, tt.got); err != nil { + t.Fatal(err) + } + if tt.err { + t.Errorf("%s: Expected error. Got nil", tt.input) + } + + if tt.input2 != "" { + if err := ParseLiteralInto(tt.input2, tt.got); err != nil { + t.Fatal(err) + } + if tt.err { + t.Errorf("%s: Expected error. Got nil", tt.input2) + } + } + + y1, err := yaml.Marshal(tt.expect) + if err != nil { + t.Fatal(err) + } + + y2, err := yaml.Marshal(tt.got) + if err != nil { + t.Fatalf("Error serializing parsed value: %s", err) + } + + if string(y1) != string(y2) { + t.Errorf("%s: Expected:\n%s\nGot:\n%s", tt.input, y1, y2) + } + } +} + +func TestParseLiteralNestedLevels(t *testing.T) { + var keyMultipleNestedLevels string + + for i := 1; i <= MaxNestedNameLevel+2; i++ { + tmpStr := fmt.Sprintf("name%d", i) + if i <= MaxNestedNameLevel+1 { + tmpStr = tmpStr + "." + } + keyMultipleNestedLevels += tmpStr + } + + tests := []struct { + str string + expect map[string]interface{} + err bool + errStr string + }{ + { + "outer.middle.inner=value", + map[string]interface{}{"outer": map[string]interface{}{"middle": map[string]interface{}{"inner": "value"}}}, + false, + "", + }, + { + str: keyMultipleNestedLevels + "=value", + err: true, + errStr: fmt.Sprintf("value name nested level is greater than maximum supported nested level of %d", MaxNestedNameLevel), + }, + } + + for _, tt := range tests { + got, err := ParseLiteral(tt.str) + if err != nil { + if tt.err { + if tt.errStr != "" { + if err.Error() != tt.errStr { + t.Errorf("Expected error: %s. Got error: %s", tt.errStr, err.Error()) + } + } + continue + } + t.Fatalf("%s: %s", tt.str, err) + } + + if tt.err { + t.Errorf("%s: Expected error. Got nil", tt.str) + } + + y1, err := yaml.Marshal(tt.expect) + if err != nil { + t.Fatal(err) + } + + y2, err := yaml.Marshal(got) + if err != nil { + t.Fatalf("Error serializing parsed value: %s", err) + } + + if string(y1) != string(y2) { + t.Errorf("%s: Expected:\n%s\nGot:\n%s", tt.str, y1, y2) + } + } +} diff --git a/pkg/strvals/parser.go b/pkg/strvals/parser.go new file mode 100644 index 00000000..2828f20c --- /dev/null +++ b/pkg/strvals/parser.go @@ -0,0 +1,559 @@ +/* +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 strvals + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "unicode" + + "github.com/pkg/errors" + "sigs.k8s.io/yaml" +) + +// ErrNotList indicates that a non-list was treated as a list. +var ErrNotList = errors.New("not a list") + +// MaxIndex is the maximum index that will be allowed by setIndex. +// The default value 65536 = 1024 * 64 +var MaxIndex = 65536 + +// MaxNestedNameLevel is the maximum level of nesting for a value name that +// will be allowed. +var MaxNestedNameLevel = 30 + +// ToYAML takes a string of arguments and converts to a YAML document. +func ToYAML(s string) (string, error) { + m, err := Parse(s) + if err != nil { + return "", err + } + d, err := yaml.Marshal(m) + return strings.TrimSuffix(string(d), "\n"), err +} + +// Parse parses a set line. +// +// A set line is of the form name1=value1,name2=value2 +func Parse(s string) (map[string]interface{}, error) { + vals := map[string]interface{}{} + scanner := bytes.NewBufferString(s) + t := newParser(scanner, vals, false) + err := t.parse() + return vals, err +} + +// ParseString parses a set line and forces a string value. +// +// A set line is of the form name1=value1,name2=value2 +func ParseString(s string) (map[string]interface{}, error) { + vals := map[string]interface{}{} + scanner := bytes.NewBufferString(s) + t := newParser(scanner, vals, true) + err := t.parse() + return vals, err +} + +// ParseInto parses a strvals line and merges the result into dest. +// +// If the strval string has a key that exists in dest, it overwrites the +// dest version. +func ParseInto(s string, dest map[string]interface{}) error { + scanner := bytes.NewBufferString(s) + t := newParser(scanner, dest, false) + return t.parse() +} + +// ParseFile parses a set line, but its final value is loaded from the file at the path specified by the original value. +// +// A set line is of the form name1=path1,name2=path2 +// +// When the files at path1 and path2 contained "val1" and "val2" respectively, the set line is consumed as +// name1=val1,name2=val2 +func ParseFile(s string, reader RunesValueReader) (map[string]interface{}, error) { + vals := map[string]interface{}{} + scanner := bytes.NewBufferString(s) + t := newFileParser(scanner, vals, reader) + err := t.parse() + return vals, err +} + +// ParseIntoString parses a strvals line and merges the result into dest. +// +// This method always returns a string as the value. +func ParseIntoString(s string, dest map[string]interface{}) error { + scanner := bytes.NewBufferString(s) + t := newParser(scanner, dest, true) + return t.parse() +} + +// ParseJSON parses a string with format key1=val1, key2=val2, ... +// where values are json strings (null, or scalars, or arrays, or objects). +// An empty val is treated as null. +// +// If a key exists in dest, the new value overwrites the dest version. +func ParseJSON(s string, dest map[string]interface{}) error { + scanner := bytes.NewBufferString(s) + t := newJSONParser(scanner, dest) + return t.parse() +} + +// ParseIntoFile parses a filevals line and merges the result into dest. +// +// This method always returns a string as the value. +func ParseIntoFile(s string, dest map[string]interface{}, reader RunesValueReader) error { + scanner := bytes.NewBufferString(s) + t := newFileParser(scanner, dest, reader) + return t.parse() +} + +// RunesValueReader is a function that takes the given value (a slice of runes) +// and returns the parsed value +type RunesValueReader func([]rune) (interface{}, error) + +// parser is a simple parser that takes a strvals line and parses it into a +// map representation. +// +// where sc is the source of the original data being parsed +// where data is the final parsed data from the parses with correct types +type parser struct { + sc *bytes.Buffer + data map[string]interface{} + reader RunesValueReader + isjsonval bool +} + +func newParser(sc *bytes.Buffer, data map[string]interface{}, stringBool bool) *parser { + stringConverter := func(rs []rune) (interface{}, error) { + return typedVal(rs, stringBool), nil + } + return &parser{sc: sc, data: data, reader: stringConverter} +} + +func newJSONParser(sc *bytes.Buffer, data map[string]interface{}) *parser { + return &parser{sc: sc, data: data, reader: nil, isjsonval: true} +} + +func newFileParser(sc *bytes.Buffer, data map[string]interface{}, reader RunesValueReader) *parser { + return &parser{sc: sc, data: data, reader: reader} +} + +func (t *parser) parse() error { + for { + err := t.key(t.data, 0) + if err == nil { + continue + } + if err == io.EOF { + return nil + } + return err + } +} + +func runeSet(r []rune) map[rune]bool { + s := make(map[rune]bool, len(r)) + for _, rr := range r { + s[rr] = true + } + return s +} + +func (t *parser) key(data map[string]interface{}, nestedNameLevel int) (reterr error) { + defer func() { + if r := recover(); r != nil { + reterr = fmt.Errorf("unable to parse key: %s", r) + } + }() + stop := runeSet([]rune{'=', '[', ',', '.'}) + for { + switch k, last, err := runesUntil(t.sc, stop); { + case err != nil: + if len(k) == 0 { + return err + } + return errors.Errorf("key %q has no value", string(k)) + //set(data, string(k), "") + //return err + case last == '[': + // We are in a list index context, so we need to set an index. + i, err := t.keyIndex() + if err != nil { + return errors.Wrap(err, "error parsing index") + } + kk := string(k) + // Find or create target list + list := []interface{}{} + if _, ok := data[kk]; ok { + list = data[kk].([]interface{}) + } + + // Now we need to get the value after the ]. + list, err = t.listItem(list, i, nestedNameLevel) + set(data, kk, list) + return err + case last == '=': + if t.isjsonval { + empval, err := t.emptyVal() + if err != nil { + return err + } + if empval { + set(data, string(k), nil) + return nil + } + // parse jsonvals by using Go’s JSON standard library + // Decode is preferred to Unmarshal in order to parse just the json parts of the list key1=jsonval1,key2=jsonval2,... + // Since Decode has its own buffer that consumes more characters (from underlying t.sc) than the ones actually decoded, + // we invoke Decode on a separate reader built with a copy of what is left in t.sc. After Decode is executed, we + // discard in t.sc the chars of the decoded json value (the number of those characters is returned by InputOffset). + var jsonval interface{} + dec := json.NewDecoder(strings.NewReader(t.sc.String())) + if err = dec.Decode(&jsonval); err != nil { + return err + } + set(data, string(k), jsonval) + if _, err = io.CopyN(io.Discard, t.sc, dec.InputOffset()); err != nil { + return err + } + // skip possible blanks and comma + _, err = t.emptyVal() + return err + } + //End of key. Consume =, Get value. + // FIXME: Get value list first + vl, e := t.valList() + switch e { + case nil: + set(data, string(k), vl) + return nil + case io.EOF: + set(data, string(k), "") + return e + case ErrNotList: + rs, e := t.val() + if e != nil && e != io.EOF { + return e + } + v, e := t.reader(rs) + set(data, string(k), v) + return e + default: + return e + } + case last == ',': + // No value given. Set the value to empty string. Return error. + set(data, string(k), "") + return errors.Errorf("key %q has no value (cannot end with ,)", string(k)) + case last == '.': + // Check value name is within the maximum nested name level + nestedNameLevel++ + if nestedNameLevel > MaxNestedNameLevel { + return fmt.Errorf("value name nested level is greater than maximum supported nested level of %d", MaxNestedNameLevel) + } + + // First, create or find the target map. + inner := map[string]interface{}{} + if _, ok := data[string(k)]; ok { + inner = data[string(k)].(map[string]interface{}) + } + + // Recurse + e := t.key(inner, nestedNameLevel) + if e == nil && len(inner) == 0 { + return errors.Errorf("key map %q has no value", string(k)) + } + if len(inner) != 0 { + set(data, string(k), inner) + } + return e + } + } +} + +func set(data map[string]interface{}, key string, val interface{}) { + // If key is empty, don't set it. + if len(key) == 0 { + return + } + data[key] = val +} + +func setIndex(list []interface{}, index int, val interface{}) (l2 []interface{}, err error) { + // There are possible index values that are out of range on a target system + // causing a panic. This will catch the panic and return an error instead. + // The value of the index that causes a panic varies from system to system. + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("error processing index %d: %s", index, r) + } + }() + + if index < 0 { + return list, fmt.Errorf("negative %d index not allowed", index) + } + if index > MaxIndex { + return list, fmt.Errorf("index of %d is greater than maximum supported index of %d", index, MaxIndex) + } + if len(list) <= index { + newlist := make([]interface{}, index+1) + copy(newlist, list) + list = newlist + } + list[index] = val + return list, nil +} + +func (t *parser) keyIndex() (int, error) { + // First, get the key. + stop := runeSet([]rune{']'}) + v, _, err := runesUntil(t.sc, stop) + if err != nil { + return 0, err + } + // v should be the index + return strconv.Atoi(string(v)) + +} +func (t *parser) listItem(list []interface{}, i, nestedNameLevel int) ([]interface{}, error) { + if i < 0 { + return list, fmt.Errorf("negative %d index not allowed", i) + } + stop := runeSet([]rune{'[', '.', '='}) + switch k, last, err := runesUntil(t.sc, stop); { + case len(k) > 0: + return list, errors.Errorf("unexpected data at end of array index: %q", k) + case err != nil: + return list, err + case last == '=': + if t.isjsonval { + empval, err := t.emptyVal() + if err != nil { + return list, err + } + if empval { + return setIndex(list, i, nil) + } + // parse jsonvals by using Go’s JSON standard library + // Decode is preferred to Unmarshal in order to parse just the json parts of the list key1=jsonval1,key2=jsonval2,... + // Since Decode has its own buffer that consumes more characters (from underlying t.sc) than the ones actually decoded, + // we invoke Decode on a separate reader built with a copy of what is left in t.sc. After Decode is executed, we + // discard in t.sc the chars of the decoded json value (the number of those characters is returned by InputOffset). + var jsonval interface{} + dec := json.NewDecoder(strings.NewReader(t.sc.String())) + if err = dec.Decode(&jsonval); err != nil { + return list, err + } + if list, err = setIndex(list, i, jsonval); err != nil { + return list, err + } + if _, err = io.CopyN(io.Discard, t.sc, dec.InputOffset()); err != nil { + return list, err + } + // skip possible blanks and comma + _, err = t.emptyVal() + return list, err + } + vl, e := t.valList() + switch e { + case nil: + return setIndex(list, i, vl) + case io.EOF: + return setIndex(list, i, "") + case ErrNotList: + rs, e := t.val() + if e != nil && e != io.EOF { + return list, e + } + v, e := t.reader(rs) + if e != nil { + return list, e + } + return setIndex(list, i, v) + default: + return list, e + } + case last == '[': + // now we have a nested list. Read the index and handle. + nextI, err := t.keyIndex() + if err != nil { + return list, errors.Wrap(err, "error parsing index") + } + var crtList []interface{} + if len(list) > i { + // If nested list already exists, take the value of list to next cycle. + existed := list[i] + if existed != nil { + crtList = list[i].([]interface{}) + } + } + // Now we need to get the value after the ]. + list2, err := t.listItem(crtList, nextI, nestedNameLevel) + if err != nil { + return list, err + } + return setIndex(list, i, list2) + case last == '.': + // We have a nested object. Send to t.key + inner := map[string]interface{}{} + if len(list) > i { + var ok bool + inner, ok = list[i].(map[string]interface{}) + if !ok { + // We have indices out of order. Initialize empty value. + list[i] = map[string]interface{}{} + inner = list[i].(map[string]interface{}) + } + } + + // Recurse + e := t.key(inner, nestedNameLevel) + if e != nil { + return list, e + } + return setIndex(list, i, inner) + default: + return nil, errors.Errorf("parse error: unexpected token %v", last) + } +} + +// check for an empty value +// read and consume optional spaces until comma or EOF (empty val) or any other char (not empty val) +// comma and spaces are consumed, while any other char is not cosumed +func (t *parser) emptyVal() (bool, error) { + for { + r, _, e := t.sc.ReadRune() + if e == io.EOF { + return true, nil + } + if e != nil { + return false, e + } + if r == ',' { + return true, nil + } + if !unicode.IsSpace(r) { + t.sc.UnreadRune() + return false, nil + } + } +} + +func (t *parser) val() ([]rune, error) { + stop := runeSet([]rune{','}) + v, _, err := runesUntil(t.sc, stop) + return v, err +} + +func (t *parser) valList() ([]interface{}, error) { + r, _, e := t.sc.ReadRune() + if e != nil { + return []interface{}{}, e + } + + if r != '{' { + t.sc.UnreadRune() + return []interface{}{}, ErrNotList + } + + list := []interface{}{} + stop := runeSet([]rune{',', '}'}) + for { + switch rs, last, err := runesUntil(t.sc, stop); { + case err != nil: + if err == io.EOF { + err = errors.New("list must terminate with '}'") + } + return list, err + case last == '}': + // If this is followed by ',', consume it. + if r, _, e := t.sc.ReadRune(); e == nil && r != ',' { + t.sc.UnreadRune() + } + v, e := t.reader(rs) + list = append(list, v) + return list, e + case last == ',': + v, e := t.reader(rs) + if e != nil { + return list, e + } + list = append(list, v) + } + } +} + +func runesUntil(in io.RuneReader, stop map[rune]bool) ([]rune, rune, error) { + v := []rune{} + for { + switch r, _, e := in.ReadRune(); { + case e != nil: + return v, r, e + case inMap(r, stop): + return v, r, nil + case r == '\\': + next, _, e := in.ReadRune() + if e != nil { + return v, next, e + } + v = append(v, next) + default: + v = append(v, r) + } + } +} + +func inMap(k rune, m map[rune]bool) bool { + _, ok := m[k] + return ok +} + +func typedVal(v []rune, st bool) interface{} { + val := string(v) + + if st { + return val + } + + if strings.EqualFold(val, "true") { + return true + } + + if strings.EqualFold(val, "false") { + return false + } + + if strings.EqualFold(val, "null") { + return nil + } + + if strings.EqualFold(val, "0") { + return int64(0) + } + + // If this value does not start with zero, try parsing it to an int + if len(val) != 0 && val[0] != '0' { + if iv, err := strconv.ParseInt(val, 10, 64); err == nil { + return iv + } + } + + return val +} diff --git a/pkg/strvals/parser_test.go b/pkg/strvals/parser_test.go new file mode 100644 index 00000000..925aa97c --- /dev/null +++ b/pkg/strvals/parser_test.go @@ -0,0 +1,818 @@ +/* +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 strvals + +import ( + "fmt" + "testing" + + "sigs.k8s.io/yaml" +) + +func TestSetIndex(t *testing.T) { + tests := []struct { + name string + initial []interface{} + expect []interface{} + add int + val int + err bool + }{ + { + name: "short", + initial: []interface{}{0, 1}, + expect: []interface{}{0, 1, 2}, + add: 2, + val: 2, + err: false, + }, + { + name: "equal", + initial: []interface{}{0, 1}, + expect: []interface{}{0, 2}, + add: 1, + val: 2, + err: false, + }, + { + name: "long", + initial: []interface{}{0, 1, 2, 3, 4, 5}, + expect: []interface{}{0, 1, 2, 4, 4, 5}, + add: 3, + val: 4, + err: false, + }, + { + name: "negative", + initial: []interface{}{0, 1, 2, 3, 4, 5}, + expect: []interface{}{0, 1, 2, 3, 4, 5}, + add: -1, + val: 4, + err: true, + }, + { + name: "large", + initial: []interface{}{0, 1, 2, 3, 4, 5}, + expect: []interface{}{0, 1, 2, 3, 4, 5}, + add: MaxIndex + 1, + val: 4, + err: true, + }, + } + + for _, tt := range tests { + got, err := setIndex(tt.initial, tt.add, tt.val) + + if err != nil && tt.err == false { + t.Fatalf("%s: Expected no error but error returned", tt.name) + } else if err == nil && tt.err == true { + t.Fatalf("%s: Expected error but no error returned", tt.name) + } + + if len(got) != len(tt.expect) { + t.Fatalf("%s: Expected length %d, got %d", tt.name, len(tt.expect), len(got)) + } + + if !tt.err { + if gg := got[tt.add].(int); gg != tt.val { + t.Errorf("%s, Expected value %d, got %d", tt.name, tt.val, gg) + } + } + + for k, v := range got { + if v != tt.expect[k] { + t.Errorf("%s, Expected value %d, got %d", tt.name, tt.expect[k], v) + } + } + } +} + +func TestParseSet(t *testing.T) { + testsString := []struct { + str string + expect map[string]interface{} + err bool + }{ + { + str: "long_int_string=1234567890", + expect: map[string]interface{}{"long_int_string": "1234567890"}, + err: false, + }, + { + str: "boolean=true", + expect: map[string]interface{}{"boolean": "true"}, + err: false, + }, + { + str: "is_null=null", + expect: map[string]interface{}{"is_null": "null"}, + err: false, + }, + { + str: "zero=0", + expect: map[string]interface{}{"zero": "0"}, + err: false, + }, + } + tests := []struct { + str string + expect map[string]interface{} + err bool + }{ + { + "name1=null,f=false,t=true", + map[string]interface{}{"name1": nil, "f": false, "t": true}, + false, + }, + { + "name1=value1", + map[string]interface{}{"name1": "value1"}, + false, + }, + { + "name1=value1,name2=value2", + map[string]interface{}{"name1": "value1", "name2": "value2"}, + false, + }, + { + "name1=value1,name2=value2,", + map[string]interface{}{"name1": "value1", "name2": "value2"}, + false, + }, + { + str: "name1=value1,,,,name2=value2,", + err: true, + }, + { + str: "name1=,name2=value2", + expect: map[string]interface{}{"name1": "", "name2": "value2"}, + }, + { + str: "leading_zeros=00009", + expect: map[string]interface{}{"leading_zeros": "00009"}, + }, + { + str: "zero_int=0", + expect: map[string]interface{}{"zero_int": 0}, + }, + { + str: "long_int=1234567890", + expect: map[string]interface{}{"long_int": 1234567890}, + }, + { + str: "boolean=true", + expect: map[string]interface{}{"boolean": true}, + }, + { + str: "is_null=null", + expect: map[string]interface{}{"is_null": nil}, + err: false, + }, + { + str: "name1,name2=", + err: true, + }, + { + str: "name1,name2=value2", + err: true, + }, + { + str: "name1,name2=value2\\", + err: true, + }, + { + str: "name1,name2", + err: true, + }, + { + "name1=one\\,two,name2=three\\,four", + map[string]interface{}{"name1": "one,two", "name2": "three,four"}, + false, + }, + { + "name1=one\\=two,name2=three\\=four", + map[string]interface{}{"name1": "one=two", "name2": "three=four"}, + false, + }, + { + "name1=one two three,name2=three two one", + map[string]interface{}{"name1": "one two three", "name2": "three two one"}, + false, + }, + { + "outer.inner=value", + map[string]interface{}{"outer": map[string]interface{}{"inner": "value"}}, + false, + }, + { + "outer.middle.inner=value", + map[string]interface{}{"outer": map[string]interface{}{"middle": map[string]interface{}{"inner": "value"}}}, + false, + }, + { + "outer.inner1=value,outer.inner2=value2", + map[string]interface{}{"outer": map[string]interface{}{"inner1": "value", "inner2": "value2"}}, + false, + }, + { + "outer.inner1=value,outer.middle.inner=value", + map[string]interface{}{ + "outer": map[string]interface{}{ + "inner1": "value", + "middle": map[string]interface{}{ + "inner": "value", + }, + }, + }, + false, + }, + { + str: "name1.name2", + err: true, + }, + { + str: "name1.name2,name1.name3", + err: true, + }, + { + str: "name1.name2=", + expect: map[string]interface{}{"name1": map[string]interface{}{"name2": ""}}, + }, + { + str: "name1.=name2", + err: true, + }, + { + str: "name1.,name2", + err: true, + }, + { + "name1={value1,value2}", + map[string]interface{}{"name1": []string{"value1", "value2"}}, + false, + }, + { + "name1={value1,value2},name2={value1,value2}", + map[string]interface{}{ + "name1": []string{"value1", "value2"}, + "name2": []string{"value1", "value2"}, + }, + false, + }, + { + "name1={1021,902}", + map[string]interface{}{"name1": []int{1021, 902}}, + false, + }, + { + "name1.name2={value1,value2}", + map[string]interface{}{"name1": map[string]interface{}{"name2": []string{"value1", "value2"}}}, + false, + }, + { + str: "name1={1021,902", + err: true, + }, + // List support + { + str: "list[0]=foo", + expect: map[string]interface{}{"list": []string{"foo"}}, + }, + { + str: "list[0].foo=bar", + expect: map[string]interface{}{ + "list": []interface{}{ + map[string]interface{}{"foo": "bar"}, + }, + }, + }, + { + str: "list[0].foo=bar,list[0].hello=world", + expect: map[string]interface{}{ + "list": []interface{}{ + map[string]interface{}{"foo": "bar", "hello": "world"}, + }, + }, + }, + { + str: "list[0].foo=bar,list[-30].hello=world", + err: true, + }, + { + str: "list[0]=foo,list[1]=bar", + expect: map[string]interface{}{"list": []string{"foo", "bar"}}, + }, + { + str: "list[0]=foo,list[1]=bar,", + expect: map[string]interface{}{"list": []string{"foo", "bar"}}, + }, + { + str: "list[0]=foo,list[3]=bar", + expect: map[string]interface{}{"list": []interface{}{"foo", nil, nil, "bar"}}, + }, + { + str: "list[0]=foo,list[-20]=bar", + err: true, + }, + { + str: "illegal[0]name.foo=bar", + err: true, + }, + { + str: "noval[0]", + expect: map[string]interface{}{"noval": []interface{}{}}, + }, + { + str: "noval[0]=", + expect: map[string]interface{}{"noval": []interface{}{""}}, + }, + { + str: "nested[0][0]=1", + expect: map[string]interface{}{"nested": []interface{}{[]interface{}{1}}}, + }, + { + str: "nested[1][1]=1", + expect: map[string]interface{}{"nested": []interface{}{nil, []interface{}{nil, 1}}}, + }, + { + str: "name1.name2[0].foo=bar,name1.name2[1].foo=bar", + expect: map[string]interface{}{ + "name1": map[string]interface{}{ + "name2": []map[string]interface{}{{"foo": "bar"}, {"foo": "bar"}}, + }, + }, + }, + { + str: "name1.name2[1].foo=bar,name1.name2[0].foo=bar", + expect: map[string]interface{}{ + "name1": map[string]interface{}{ + "name2": []map[string]interface{}{{"foo": "bar"}, {"foo": "bar"}}, + }, + }, + }, + { + str: "name1.name2[1].foo=bar", + expect: map[string]interface{}{ + "name1": map[string]interface{}{ + "name2": []map[string]interface{}{nil, {"foo": "bar"}}, + }, + }, + }, + { + str: "]={}].", + err: true, + }, + } + + for _, tt := range tests { + got, err := Parse(tt.str) + if err != nil { + if tt.err { + continue + } + t.Fatalf("%s: %s", tt.str, err) + } + if tt.err { + t.Errorf("%s: Expected error. Got nil", tt.str) + } + + y1, err := yaml.Marshal(tt.expect) + if err != nil { + t.Fatal(err) + } + y2, err := yaml.Marshal(got) + if err != nil { + t.Fatalf("Error serializing parsed value: %s", err) + } + + if string(y1) != string(y2) { + t.Errorf("%s: Expected:\n%s\nGot:\n%s", tt.str, y1, y2) + } + } + for _, tt := range testsString { + got, err := ParseString(tt.str) + if err != nil { + if tt.err { + continue + } + t.Fatalf("%s: %s", tt.str, err) + } + if tt.err { + t.Errorf("%s: Expected error. Got nil", tt.str) + } + + y1, err := yaml.Marshal(tt.expect) + if err != nil { + t.Fatal(err) + } + y2, err := yaml.Marshal(got) + if err != nil { + t.Fatalf("Error serializing parsed value: %s", err) + } + + if string(y1) != string(y2) { + t.Errorf("%s: Expected:\n%s\nGot:\n%s", tt.str, y1, y2) + } + } +} + +func TestParseInto(t *testing.T) { + tests := []struct { + input string + input2 string + got map[string]interface{} + expect map[string]interface{} + err bool + }{ + { + input: "outer.inner1=value1,outer.inner3=value3,outer.inner4=4", + got: map[string]interface{}{ + "outer": map[string]interface{}{ + "inner1": "overwrite", + "inner2": "value2", + }, + }, + expect: map[string]interface{}{ + "outer": map[string]interface{}{ + "inner1": "value1", + "inner2": "value2", + "inner3": "value3", + "inner4": 4, + }}, + err: false, + }, + { + input: "listOuter[0][0].type=listValue", + input2: "listOuter[0][0].status=alive", + got: map[string]interface{}{}, + expect: map[string]interface{}{ + "listOuter": [][]interface{}{{map[string]string{ + "type": "listValue", + "status": "alive", + }}}, + }, + err: false, + }, + { + input: "listOuter[0][0].type=listValue", + input2: "listOuter[1][0].status=alive", + got: map[string]interface{}{}, + expect: map[string]interface{}{ + "listOuter": [][]interface{}{ + { + map[string]string{"type": "listValue"}, + }, + { + map[string]string{"status": "alive"}, + }, + }, + }, + err: false, + }, + { + input: "listOuter[0][1][0].type=listValue", + input2: "listOuter[0][0][1].status=alive", + got: map[string]interface{}{ + "listOuter": []interface{}{ + []interface{}{ + []interface{}{ + map[string]string{"exited": "old"}, + }, + }, + }, + }, + expect: map[string]interface{}{ + "listOuter": [][][]interface{}{ + { + { + map[string]string{"exited": "old"}, + map[string]string{"status": "alive"}, + }, + { + map[string]string{"type": "listValue"}, + }, + }, + }, + }, + err: false, + }, + } + for _, tt := range tests { + if err := ParseInto(tt.input, tt.got); err != nil { + t.Fatal(err) + } + if tt.err { + t.Errorf("%s: Expected error. Got nil", tt.input) + } + + if tt.input2 != "" { + if err := ParseInto(tt.input2, tt.got); err != nil { + t.Fatal(err) + } + if tt.err { + t.Errorf("%s: Expected error. Got nil", tt.input2) + } + } + + y1, err := yaml.Marshal(tt.expect) + if err != nil { + t.Fatal(err) + } + y2, err := yaml.Marshal(tt.got) + if err != nil { + t.Fatalf("Error serializing parsed value: %s", err) + } + + if string(y1) != string(y2) { + t.Errorf("%s: Expected:\n%s\nGot:\n%s", tt.input, y1, y2) + } + } +} + +func TestParseIntoString(t *testing.T) { + got := map[string]interface{}{ + "outer": map[string]interface{}{ + "inner1": "overwrite", + "inner2": "value2", + }, + } + input := "outer.inner1=1,outer.inner3=3" + expect := map[string]interface{}{ + "outer": map[string]interface{}{ + "inner1": "1", + "inner2": "value2", + "inner3": "3", + }, + } + + if err := ParseIntoString(input, got); err != nil { + t.Fatal(err) + } + + y1, err := yaml.Marshal(expect) + if err != nil { + t.Fatal(err) + } + y2, err := yaml.Marshal(got) + if err != nil { + t.Fatalf("Error serializing parsed value: %s", err) + } + + if string(y1) != string(y2) { + t.Errorf("%s: Expected:\n%s\nGot:\n%s", input, y1, y2) + } +} + +func TestParseJSON(t *testing.T) { + tests := []struct { + input string + got map[string]interface{} + expect map[string]interface{} + err bool + }{ + { // set json scalars values, and replace one existing key + input: "outer.inner1=\"1\",outer.inner3=3,outer.inner4=true,outer.inner5=\"true\"", + got: map[string]interface{}{ + "outer": map[string]interface{}{ + "inner1": "overwrite", + "inner2": "value2", + }, + }, + expect: map[string]interface{}{ + "outer": map[string]interface{}{ + "inner1": "1", + "inner2": "value2", + "inner3": 3, + "inner4": true, + "inner5": "true", + }, + }, + err: false, + }, + { // set json objects and arrays, and replace one existing key + input: "outer.inner1={\"a\":\"1\",\"b\":2,\"c\":[1,2,3]},outer.inner3=[\"new value 1\",\"new value 2\"],outer.inner4={\"aa\":\"1\",\"bb\":2,\"cc\":[1,2,3]},outer.inner5=[{\"A\":\"1\",\"B\":2,\"C\":[1,2,3]}]", + got: map[string]interface{}{ + "outer": map[string]interface{}{ + "inner1": map[string]interface{}{ + "x": "overwrite", + }, + "inner2": "value2", + "inner3": []interface{}{ + "overwrite", + }, + }, + }, + expect: map[string]interface{}{ + "outer": map[string]interface{}{ + "inner1": map[string]interface{}{"a": "1", "b": 2, "c": []interface{}{1, 2, 3}}, + "inner2": "value2", + "inner3": []interface{}{"new value 1", "new value 2"}, + "inner4": map[string]interface{}{"aa": "1", "bb": 2, "cc": []interface{}{1, 2, 3}}, + "inner5": []interface{}{map[string]interface{}{"A": "1", "B": 2, "C": []interface{}{1, 2, 3}}}, + }, + }, + err: false, + }, + { // null assigment, and no value assigned (equivalent to null) + input: "outer.inner1=,outer.inner3={\"aa\":\"1\",\"bb\":2,\"cc\":[1,2,3]},outer.inner3.cc[1]=null", + got: map[string]interface{}{ + "outer": map[string]interface{}{ + "inner1": map[string]interface{}{ + "x": "overwrite", + }, + "inner2": "value2", + }, + }, + expect: map[string]interface{}{ + "outer": map[string]interface{}{ + "inner1": nil, + "inner2": "value2", + "inner3": map[string]interface{}{"aa": "1", "bb": 2, "cc": []interface{}{1, nil, 3}}, + }, + }, + err: false, + }, + { // syntax error + input: "outer.inner1={\"a\":\"1\",\"b\":2,\"c\":[1,2,3]},outer.inner3=[\"new value 1\",\"new value 2\"],outer.inner4={\"aa\":\"1\",\"bb\":2,\"cc\":[1,2,3]},outer.inner5={\"A\":\"1\",\"B\":2,\"C\":[1,2,3]}]", + got: nil, + expect: nil, + err: true, + }, + } + for _, tt := range tests { + if err := ParseJSON(tt.input, tt.got); err != nil { + if tt.err { + continue + } + t.Fatalf("%s: %s", tt.input, err) + } + if tt.err { + t.Fatalf("%s: Expected error. Got nil", tt.input) + } + y1, err := yaml.Marshal(tt.expect) + if err != nil { + t.Fatalf("Error serializing expected value: %s", err) + } + y2, err := yaml.Marshal(tt.got) + if err != nil { + t.Fatalf("Error serializing parsed value: %s", err) + } + + if string(y1) != string(y2) { + t.Errorf("%s: Expected:\n%s\nGot:\n%s", tt.input, y1, y2) + } + } +} + +func TestParseFile(t *testing.T) { + input := "name1=path1" + expect := map[string]interface{}{ + "name1": "value1", + } + rs2v := func(rs []rune) (interface{}, error) { + v := string(rs) + if v != "path1" { + t.Errorf("%s: runesToVal: Expected value path1, got %s", input, v) + return "", nil + } + return "value1", nil + } + + got, err := ParseFile(input, rs2v) + if err != nil { + t.Fatal(err) + } + + y1, err := yaml.Marshal(expect) + if err != nil { + t.Fatal(err) + } + y2, err := yaml.Marshal(got) + if err != nil { + t.Fatalf("Error serializing parsed value: %s", err) + } + + if string(y1) != string(y2) { + t.Errorf("%s: Expected:\n%s\nGot:\n%s", input, y1, y2) + } +} + +func TestParseIntoFile(t *testing.T) { + got := map[string]interface{}{} + input := "name1=path1" + expect := map[string]interface{}{ + "name1": "value1", + } + rs2v := func(rs []rune) (interface{}, error) { + v := string(rs) + if v != "path1" { + t.Errorf("%s: runesToVal: Expected value path1, got %s", input, v) + return "", nil + } + return "value1", nil + } + + if err := ParseIntoFile(input, got, rs2v); err != nil { + t.Fatal(err) + } + + y1, err := yaml.Marshal(expect) + if err != nil { + t.Fatal(err) + } + y2, err := yaml.Marshal(got) + if err != nil { + t.Fatalf("Error serializing parsed value: %s", err) + } + + if string(y1) != string(y2) { + t.Errorf("%s: Expected:\n%s\nGot:\n%s", input, y1, y2) + } +} + +func TestToYAML(t *testing.T) { + // The TestParse does the hard part. We just verify that YAML formatting is + // happening. + o, err := ToYAML("name=value") + if err != nil { + t.Fatal(err) + } + expect := "name: value" + if o != expect { + t.Errorf("Expected %q, got %q", expect, o) + } +} + +func TestParseSetNestedLevels(t *testing.T) { + var keyMultipleNestedLevels string + for i := 1; i <= MaxNestedNameLevel+2; i++ { + tmpStr := fmt.Sprintf("name%d", i) + if i <= MaxNestedNameLevel+1 { + tmpStr = tmpStr + "." + } + keyMultipleNestedLevels += tmpStr + } + tests := []struct { + str string + expect map[string]interface{} + err bool + errStr string + }{ + { + "outer.middle.inner=value", + map[string]interface{}{"outer": map[string]interface{}{"middle": map[string]interface{}{"inner": "value"}}}, + false, + "", + }, + { + str: keyMultipleNestedLevels + "=value", + err: true, + errStr: fmt.Sprintf("value name nested level is greater than maximum supported nested level of %d", + MaxNestedNameLevel), + }, + } + + for _, tt := range tests { + got, err := Parse(tt.str) + if err != nil { + if tt.err { + if tt.errStr != "" { + if err.Error() != tt.errStr { + t.Errorf("Expected error: %s. Got error: %s", tt.errStr, err.Error()) + } + } + continue + } + t.Fatalf("%s: %s", tt.str, err) + } + if tt.err { + t.Errorf("%s: Expected error. Got nil", tt.str) + } + + y1, err := yaml.Marshal(tt.expect) + if err != nil { + t.Fatal(err) + } + y2, err := yaml.Marshal(got) + if err != nil { + t.Fatalf("Error serializing parsed value: %s", err) + } + + if string(y1) != string(y2) { + t.Errorf("%s: Expected:\n%s\nGot:\n%s", tt.str, y1, y2) + } + } +} diff --git a/pkg/time/time.go b/pkg/time/time.go new file mode 100644 index 00000000..44f3fedf --- /dev/null +++ b/pkg/time/time.go @@ -0,0 +1,91 @@ +/* +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 time contains a wrapper for time.Time in the standard library and +// associated methods. This package mainly exists to workaround an issue in Go +// where the serializer doesn't omit an empty value for time: +// https://github.com/golang/go/issues/11939. As such, this can be removed if a +// proposal is ever accepted for Go +package time + +import ( + "bytes" + "time" +) + +// emptyString contains an empty JSON string value to be used as output +var emptyString = `""` + +// Time is a convenience wrapper around stdlib time, but with different +// marshalling and unmarshaling for zero values +type Time struct { + time.Time +} + +// Now returns the current time. It is a convenience wrapper around time.Now() +func Now() Time { + return Time{time.Now()} +} + +func (t Time) MarshalJSON() ([]byte, error) { + if t.Time.IsZero() { + return []byte(emptyString), nil + } + + return t.Time.MarshalJSON() +} + +func (t *Time) UnmarshalJSON(b []byte) error { + if bytes.Equal(b, []byte("null")) { + return nil + } + // If it is empty, we don't have to set anything since time.Time is not a + // pointer and will be set to the zero value + if bytes.Equal([]byte(emptyString), b) { + return nil + } + + return t.Time.UnmarshalJSON(b) +} + +func Parse(layout, value string) (Time, error) { + t, err := time.Parse(layout, value) + return Time{Time: t}, err +} +func ParseInLocation(layout, value string, loc *time.Location) (Time, error) { + t, err := time.ParseInLocation(layout, value, loc) + return Time{Time: t}, err +} + +func Date(year int, month time.Month, day, hour, min, sec, nsec int, loc *time.Location) Time { + return Time{Time: time.Date(year, month, day, hour, min, sec, nsec, loc)} +} + +func Unix(sec int64, nsec int64) Time { return Time{Time: time.Unix(sec, nsec)} } + +func (t Time) Add(d time.Duration) Time { return Time{Time: t.Time.Add(d)} } +func (t Time) AddDate(years int, months int, days int) Time { + return Time{Time: t.Time.AddDate(years, months, days)} +} +func (t Time) After(u Time) bool { return t.Time.After(u.Time) } +func (t Time) Before(u Time) bool { return t.Time.Before(u.Time) } +func (t Time) Equal(u Time) bool { return t.Time.Equal(u.Time) } +func (t Time) In(loc *time.Location) Time { return Time{Time: t.Time.In(loc)} } +func (t Time) Local() Time { return Time{Time: t.Time.Local()} } +func (t Time) Round(d time.Duration) Time { return Time{Time: t.Time.Round(d)} } +func (t Time) Sub(u Time) time.Duration { return t.Time.Sub(u.Time) } +func (t Time) Truncate(d time.Duration) Time { return Time{Time: t.Time.Truncate(d)} } +func (t Time) UTC() Time { return Time{Time: t.Time.UTC()} } diff --git a/pkg/time/time_test.go b/pkg/time/time_test.go new file mode 100644 index 00000000..20f0f8e2 --- /dev/null +++ b/pkg/time/time_test.go @@ -0,0 +1,83 @@ +/* +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 time + +import ( + "encoding/json" + "testing" + "time" +) + +var ( + testingTime, _ = Parse(time.RFC3339, "1977-09-02T22:04:05Z") + testingTimeString = `"1977-09-02T22:04:05Z"` +) + +func TestNonZeroValueMarshal(t *testing.T) { + res, err := json.Marshal(testingTime) + if err != nil { + t.Fatal(err) + } + if testingTimeString != string(res) { + t.Errorf("expected a marshaled value of %s, got %s", testingTimeString, res) + } +} + +func TestZeroValueMarshal(t *testing.T) { + res, err := json.Marshal(Time{}) + if err != nil { + t.Fatal(err) + } + if string(res) != emptyString { + t.Errorf("expected zero value to marshal to empty string, got %s", res) + } +} + +func TestNonZeroValueUnmarshal(t *testing.T) { + var myTime Time + err := json.Unmarshal([]byte(testingTimeString), &myTime) + if err != nil { + t.Fatal(err) + } + if !myTime.Equal(testingTime) { + t.Errorf("expected time to be equal to %v, got %v", testingTime, myTime) + } +} + +func TestEmptyStringUnmarshal(t *testing.T) { + var myTime Time + err := json.Unmarshal([]byte(emptyString), &myTime) + if err != nil { + t.Fatal(err) + } + if !myTime.IsZero() { + t.Errorf("expected time to be equal to zero value, got %v", myTime) + } +} + +func TestZeroValueUnmarshal(t *testing.T) { + // This test ensures that we can unmarshal any time value that was output + // with the current go default value of "0001-01-01T00:00:00Z" + var myTime Time + err := json.Unmarshal([]byte(`"0001-01-01T00:00:00Z"`), &myTime) + if err != nil { + t.Fatal(err) + } + if !myTime.IsZero() { + t.Errorf("expected time to be equal to zero value, got %v", myTime) + } +} diff --git a/pkg/uploader/chart_uploader.go b/pkg/uploader/chart_uploader.go new file mode 100644 index 00000000..d7e94040 --- /dev/null +++ b/pkg/uploader/chart_uploader.go @@ -0,0 +1,58 @@ +/* +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 uploader + +import ( + "fmt" + "io" + "net/url" + + "github.com/pkg/errors" + + "helm.sh/helm/v3/pkg/pusher" + "helm.sh/helm/v3/pkg/registry" +) + +// ChartUploader handles uploading a chart. +type ChartUploader struct { + // Out is the location to write warning and info messages. + Out io.Writer + // Pusher collection for the operation + Pushers pusher.Providers + // Options provide parameters to be passed along to the Pusher being initialized. + Options []pusher.Option + // RegistryClient is a client for interacting with registries. + RegistryClient *registry.Client +} + +// UploadTo uploads a chart. Depending on the settings, it may also upload a provenance file. +func (c *ChartUploader) UploadTo(ref, remote string) error { + u, err := url.Parse(remote) + if err != nil { + return errors.Errorf("invalid chart URL format: %s", remote) + } + + if u.Scheme == "" { + return fmt.Errorf("scheme prefix missing from remote (e.g. \"%s://\")", registry.OCIScheme) + } + + p, err := c.Pushers.ByScheme(u.Scheme) + if err != nil { + return err + } + + return p.Push(ref, u.String(), c.Options...) +} diff --git a/pkg/uploader/doc.go b/pkg/uploader/doc.go new file mode 100644 index 00000000..112ddbf2 --- /dev/null +++ b/pkg/uploader/doc.go @@ -0,0 +1,21 @@ +/* +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 uploader provides a library for uploading charts. + +This package contains tools for uploading charts to registries. +*/ +package uploader diff --git a/scripts/coverage.sh b/scripts/coverage.sh new file mode 100755 index 00000000..2d825886 --- /dev/null +++ b/scripts/coverage.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +# 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. + +set -euo pipefail + +covermode=${COVERMODE:-atomic} +coverdir=$(mktemp -d /tmp/coverage.XXXXXXXXXX) +profile="${coverdir}/cover.out" + +pushd / +hash goveralls 2>/dev/null || go install github.com/mattn/goveralls@v0.0.11 +popd + +generate_cover_data() { + for d in $(go list ./...) ; do + ( + local output="${coverdir}/${d//\//-}.cover" + go test -coverprofile="${output}" -covermode="$covermode" "$d" + ) + done + + echo "mode: $covermode" >"$profile" + grep -h -v "^mode:" "$coverdir"/*.cover >>"$profile" +} + +push_to_coveralls() { + goveralls -coverprofile="${profile}" -service=github +} + +generate_cover_data +go tool cover -func "${profile}" + +case "${1-}" in + --html) + go tool cover -html "${profile}" + ;; + --coveralls) + push_to_coveralls + ;; +esac + diff --git a/scripts/get b/scripts/get new file mode 100755 index 00000000..a65540e0 --- /dev/null +++ b/scripts/get @@ -0,0 +1,242 @@ +#!/usr/bin/env bash + +# 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. + +# The install script is based off of the MIT-licensed script from glide, +# the package manager for Go: https://github.com/Masterminds/glide.sh/blob/master/get + +PROJECT_NAME="helm" +TILLER_NAME="tiller" + +: ${USE_SUDO:="true"} +: ${HELM_INSTALL_DIR:="/usr/local/bin"} + +# initArch discovers the architecture for this system. +initArch() { + ARCH=$(uname -m) + case $ARCH in + armv5*) ARCH="armv5";; + armv6*) ARCH="armv6";; + armv7*) ARCH="arm";; + aarch64) ARCH="arm64";; + x86) ARCH="386";; + x86_64) ARCH="amd64";; + i686) ARCH="386";; + i386) ARCH="386";; + esac +} + +# initOS discovers the operating system for this system. +initOS() { + OS=$(echo `uname`|tr '[:upper:]' '[:lower:]') + + case "$OS" in + # Minimalist GNU for Windows + mingw*) OS='windows';; + esac +} + +# runs the given command as root (detects if we are root already) +runAsRoot() { + if [ $EUID -ne 0 -a "$USE_SUDO" = "true" ]; then + sudo "${@}" + else + "${@}" + fi +} + +# verifySupported checks that the os/arch combination is supported for +# binary builds. +verifySupported() { + local supported="darwin-amd64\nlinux-386\nlinux-amd64\nlinux-arm\nlinux-arm64\nlinux-ppc64le\nlinux-s390x\nlinux-riscv64\nwindows-amd64" + if ! echo "${supported}" | grep -q "${OS}-${ARCH}"; then + echo "No prebuilt binary for ${OS}-${ARCH}." + echo "To build from source, go to https://github.com/helm/helm" + exit 1 + fi + + if ! type "curl" > /dev/null && ! type "wget" > /dev/null; then + echo "Either curl or wget is required" + exit 1 + fi +} + +# checkDesiredVersion checks if the desired version is available. +checkDesiredVersion() { + if [ "x$DESIRED_VERSION" == "x" ]; then + # Pinning tag to v2.17.0 as per https://github.com/helm/helm/issues/9607 + TAG=v2.17.0 + else + TAG=$DESIRED_VERSION + fi +} + +# checkHelmInstalledVersion checks which version of helm is installed and +# if it needs to be changed. +checkHelmInstalledVersion() { + if [[ -f "${HELM_INSTALL_DIR}/${PROJECT_NAME}" ]]; then + local version=$("${HELM_INSTALL_DIR}/${PROJECT_NAME}" version -c | grep '^Client' | cut -d'"' -f2) + if [[ "$version" == "$TAG" ]]; then + echo "Helm ${version} is already ${DESIRED_VERSION:-latest}" + return 0 + else + echo "Helm ${TAG} is available. Changing from version ${version}." + return 1 + fi + else + return 1 + fi +} + +# downloadFile downloads the latest binary package and also the checksum +# for that binary. +downloadFile() { + HELM_DIST="helm-$TAG-$OS-$ARCH.tar.gz" + DOWNLOAD_URL="https://get.helm.sh/$HELM_DIST" + CHECKSUM_URL="$DOWNLOAD_URL.sha256" + HELM_TMP_ROOT="$(mktemp -dt helm-installer-XXXXXX)" + HELM_TMP_FILE="$HELM_TMP_ROOT/$HELM_DIST" + HELM_SUM_FILE="$HELM_TMP_ROOT/$HELM_DIST.sha256" + echo "Downloading $DOWNLOAD_URL" + if type "curl" > /dev/null; then + curl -SsL "$CHECKSUM_URL" -o "$HELM_SUM_FILE" + elif type "wget" > /dev/null; then + wget -q -O "$HELM_SUM_FILE" "$CHECKSUM_URL" + fi + if type "curl" > /dev/null; then + curl -SsL "$DOWNLOAD_URL" -o "$HELM_TMP_FILE" + elif type "wget" > /dev/null; then + wget -q -O "$HELM_TMP_FILE" "$DOWNLOAD_URL" + fi +} + +# installFile verifies the SHA256 for the file, then unpacks and +# installs it. +installFile() { + HELM_TMP="$HELM_TMP_ROOT/$PROJECT_NAME" + local sum=$(openssl sha1 -sha256 ${HELM_TMP_FILE} | awk '{print $2}') + local expected_sum=$(cat ${HELM_SUM_FILE}) + if [ "$sum" != "$expected_sum" ]; then + echo "SHA sum of ${HELM_TMP_FILE} does not match. Aborting." + exit 1 + fi + + mkdir -p "$HELM_TMP" + tar xf "$HELM_TMP_FILE" -C "$HELM_TMP" + HELM_TMP_BIN="$HELM_TMP/$OS-$ARCH/$PROJECT_NAME" + TILLER_TMP_BIN="$HELM_TMP/$OS-$ARCH/$TILLER_NAME" + echo "Preparing to install $PROJECT_NAME and $TILLER_NAME into ${HELM_INSTALL_DIR}" + runAsRoot cp "$HELM_TMP_BIN" "$HELM_INSTALL_DIR/$PROJECT_NAME" + echo "$PROJECT_NAME installed into $HELM_INSTALL_DIR/$PROJECT_NAME" + if [ -x "$TILLER_TMP_BIN" ]; then + runAsRoot cp "$TILLER_TMP_BIN" "$HELM_INSTALL_DIR/$TILLER_NAME" + echo "$TILLER_NAME installed into $HELM_INSTALL_DIR/$TILLER_NAME" + else + echo "info: $TILLER_NAME binary was not found in this release; skipping $TILLER_NAME installation" + fi +} + +# fail_trap is executed if an error occurs. +fail_trap() { + result=$? + if [ "$result" != "0" ]; then + if [[ -n "$INPUT_ARGUMENTS" ]]; then + echo "Failed to install $PROJECT_NAME with the arguments provided: $INPUT_ARGUMENTS" + help + else + echo "Failed to install $PROJECT_NAME" + fi + echo -e "\tFor support, go to https://github.com/helm/helm." + fi + cleanup + exit $result +} + +# testVersion tests the installed client to make sure it is working. +testVersion() { + set +e + HELM="$(command -v $PROJECT_NAME)" + if [ "$?" = "1" ]; then + echo "$PROJECT_NAME not found. Is $HELM_INSTALL_DIR on your "'$PATH?' + exit 1 + fi + set -e + echo "Run '$PROJECT_NAME init' to configure $PROJECT_NAME." +} + +# help provides possible cli installation arguments +help () { + echo "Accepted cli arguments are:" + echo -e "\t[--help|-h ] ->> prints this help" + echo -e "\t[--version|-v ]" + echo -e "\te.g. --version v2.4.0 or -v latest" + echo -e "\t[--no-sudo] ->> install without sudo" +} + +# cleanup temporary files to avoid https://github.com/helm/helm/issues/2977 +cleanup() { + if [[ -d "${HELM_TMP_ROOT:-}" ]]; then + rm -rf "$HELM_TMP_ROOT" + fi +} + +# Execution + +#Stop execution on any error +trap "fail_trap" EXIT +set -e + +# Parsing input arguments (if any) +export INPUT_ARGUMENTS="${@}" +set -u +while [[ $# -gt 0 ]]; do + case $1 in + '--version'|-v) + shift + if [[ $# -ne 0 ]]; then + export DESIRED_VERSION="${1}" + if [[ "$1" != "v"* ]]; then + echo "Expected version arg ('${DESIRED_VERSION}') to begin with 'v', fixing..." + export DESIRED_VERSION="v${1}" + fi + else + echo -e "Please provide the desired version. e.g. --version v2.4.0 or -v latest" + exit 0 + fi + ;; + '--no-sudo') + USE_SUDO="false" + ;; + '--help'|-h) + help + exit 0 + ;; + *) exit 1 + ;; + esac + shift +done +set +u + +initArch +initOS +verifySupported +checkDesiredVersion +if ! checkHelmInstalledVersion; then + downloadFile + installFile +fi +testVersion +cleanup diff --git a/scripts/get-helm-3 b/scripts/get-helm-3 new file mode 100755 index 00000000..31678a17 --- /dev/null +++ b/scripts/get-helm-3 @@ -0,0 +1,341 @@ +#!/usr/bin/env bash + +# 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. + +# The install script is based off of the MIT-licensed script from glide, +# the package manager for Go: https://github.com/Masterminds/glide.sh/blob/master/get + +: ${BINARY_NAME:="helm"} +: ${USE_SUDO:="true"} +: ${DEBUG:="false"} +: ${VERIFY_CHECKSUM:="true"} +: ${VERIFY_SIGNATURES:="false"} +: ${HELM_INSTALL_DIR:="/usr/local/bin"} +: ${GPG_PUBRING:="pubring.kbx"} + +HAS_CURL="$(type "curl" &> /dev/null && echo true || echo false)" +HAS_WGET="$(type "wget" &> /dev/null && echo true || echo false)" +HAS_OPENSSL="$(type "openssl" &> /dev/null && echo true || echo false)" +HAS_GPG="$(type "gpg" &> /dev/null && echo true || echo false)" +HAS_GIT="$(type "git" &> /dev/null && echo true || echo false)" + +# initArch discovers the architecture for this system. +initArch() { + ARCH=$(uname -m) + case $ARCH in + armv5*) ARCH="armv5";; + armv6*) ARCH="armv6";; + armv7*) ARCH="arm";; + aarch64) ARCH="arm64";; + x86) ARCH="386";; + x86_64) ARCH="amd64";; + i686) ARCH="386";; + i386) ARCH="386";; + esac +} + +# initOS discovers the operating system for this system. +initOS() { + OS=$(echo `uname`|tr '[:upper:]' '[:lower:]') + + case "$OS" in + # Minimalist GNU for Windows + mingw*|cygwin*) OS='windows';; + esac +} + +# runs the given command as root (detects if we are root already) +runAsRoot() { + if [ $EUID -ne 0 -a "$USE_SUDO" = "true" ]; then + sudo "${@}" + else + "${@}" + fi +} + +# verifySupported checks that the os/arch combination is supported for +# binary builds, as well whether or not necessary tools are present. +verifySupported() { + local supported="darwin-amd64\ndarwin-arm64\nlinux-386\nlinux-amd64\nlinux-arm\nlinux-arm64\nlinux-ppc64le\nlinux-s390x\nlinux-riscv64\nwindows-amd64" + if ! echo "${supported}" | grep -q "${OS}-${ARCH}"; then + echo "No prebuilt binary for ${OS}-${ARCH}." + echo "To build from source, go to https://github.com/helm/helm" + exit 1 + fi + + if [ "${HAS_CURL}" != "true" ] && [ "${HAS_WGET}" != "true" ]; then + echo "Either curl or wget is required" + exit 1 + fi + + if [ "${VERIFY_CHECKSUM}" == "true" ] && [ "${HAS_OPENSSL}" != "true" ]; then + echo "In order to verify checksum, openssl must first be installed." + echo "Please install openssl or set VERIFY_CHECKSUM=false in your environment." + exit 1 + fi + + if [ "${VERIFY_SIGNATURES}" == "true" ]; then + if [ "${HAS_GPG}" != "true" ]; then + echo "In order to verify signatures, gpg must first be installed." + echo "Please install gpg or set VERIFY_SIGNATURES=false in your environment." + exit 1 + fi + if [ "${OS}" != "linux" ]; then + echo "Signature verification is currently only supported on Linux." + echo "Please set VERIFY_SIGNATURES=false or verify the signatures manually." + exit 1 + fi + fi + + if [ "${HAS_GIT}" != "true" ]; then + echo "[WARNING] Could not find git. It is required for plugin installation." + fi +} + +# checkDesiredVersion checks if the desired version is available. +checkDesiredVersion() { + if [ "x$DESIRED_VERSION" == "x" ]; then + # Get tag from release URL + local latest_release_url="https://get.helm.sh/helm-latest-version" + local latest_release_response="" + if [ "${HAS_CURL}" == "true" ]; then + latest_release_response=$( curl -L --silent --show-error --fail "$latest_release_url" 2>&1 || true ) + elif [ "${HAS_WGET}" == "true" ]; then + latest_release_response=$( wget "$latest_release_url" -q -O - 2>&1 || true ) + fi + TAG=$( echo "$latest_release_response" | grep '^v[0-9]' ) + if [ "x$TAG" == "x" ]; then + printf "Could not retrieve the latest release tag information from %s: %s\n" "${latest_release_url}" "${latest_release_response}" + exit 1 + fi + else + TAG=$DESIRED_VERSION + fi +} + +# checkHelmInstalledVersion checks which version of helm is installed and +# if it needs to be changed. +checkHelmInstalledVersion() { + if [[ -f "${HELM_INSTALL_DIR}/${BINARY_NAME}" ]]; then + local version=$("${HELM_INSTALL_DIR}/${BINARY_NAME}" version --template="{{ .Version }}") + if [[ "$version" == "$TAG" ]]; then + echo "Helm ${version} is already ${DESIRED_VERSION:-latest}" + return 0 + else + echo "Helm ${TAG} is available. Changing from version ${version}." + return 1 + fi + else + return 1 + fi +} + +# downloadFile downloads the latest binary package and also the checksum +# for that binary. +downloadFile() { + HELM_DIST="helm-$TAG-$OS-$ARCH.tar.gz" + DOWNLOAD_URL="https://get.helm.sh/$HELM_DIST" + CHECKSUM_URL="$DOWNLOAD_URL.sha256" + HELM_TMP_ROOT="$(mktemp -dt helm-installer-XXXXXX)" + HELM_TMP_FILE="$HELM_TMP_ROOT/$HELM_DIST" + HELM_SUM_FILE="$HELM_TMP_ROOT/$HELM_DIST.sha256" + echo "Downloading $DOWNLOAD_URL" + if [ "${HAS_CURL}" == "true" ]; then + curl -SsL "$CHECKSUM_URL" -o "$HELM_SUM_FILE" + curl -SsL "$DOWNLOAD_URL" -o "$HELM_TMP_FILE" + elif [ "${HAS_WGET}" == "true" ]; then + wget -q -O "$HELM_SUM_FILE" "$CHECKSUM_URL" + wget -q -O "$HELM_TMP_FILE" "$DOWNLOAD_URL" + fi +} + +# verifyFile verifies the SHA256 checksum of the binary package +# and the GPG signatures for both the package and checksum file +# (depending on settings in environment). +verifyFile() { + if [ "${VERIFY_CHECKSUM}" == "true" ]; then + verifyChecksum + fi + if [ "${VERIFY_SIGNATURES}" == "true" ]; then + verifySignatures + fi +} + +# installFile installs the Helm binary. +installFile() { + HELM_TMP="$HELM_TMP_ROOT/$BINARY_NAME" + mkdir -p "$HELM_TMP" + tar xf "$HELM_TMP_FILE" -C "$HELM_TMP" + HELM_TMP_BIN="$HELM_TMP/$OS-$ARCH/helm" + echo "Preparing to install $BINARY_NAME into ${HELM_INSTALL_DIR}" + runAsRoot cp "$HELM_TMP_BIN" "$HELM_INSTALL_DIR/$BINARY_NAME" + echo "$BINARY_NAME installed into $HELM_INSTALL_DIR/$BINARY_NAME" +} + +# verifyChecksum verifies the SHA256 checksum of the binary package. +verifyChecksum() { + printf "Verifying checksum... " + local sum=$(openssl sha1 -sha256 ${HELM_TMP_FILE} | awk '{print $2}') + local expected_sum=$(cat ${HELM_SUM_FILE}) + if [ "$sum" != "$expected_sum" ]; then + echo "SHA sum of ${HELM_TMP_FILE} does not match. Aborting." + exit 1 + fi + echo "Done." +} + +# verifySignatures obtains the latest KEYS file from GitHub main branch +# as well as the signature .asc files from the specific GitHub release, +# then verifies that the release artifacts were signed by a maintainer's key. +verifySignatures() { + printf "Verifying signatures... " + local keys_filename="KEYS" + local github_keys_url="https://raw.githubusercontent.com/helm/helm/main/${keys_filename}" + if [ "${HAS_CURL}" == "true" ]; then + curl -SsL "${github_keys_url}" -o "${HELM_TMP_ROOT}/${keys_filename}" + elif [ "${HAS_WGET}" == "true" ]; then + wget -q -O "${HELM_TMP_ROOT}/${keys_filename}" "${github_keys_url}" + fi + local gpg_keyring="${HELM_TMP_ROOT}/keyring.gpg" + local gpg_homedir="${HELM_TMP_ROOT}/gnupg" + mkdir -p -m 0700 "${gpg_homedir}" + local gpg_stderr_device="/dev/null" + if [ "${DEBUG}" == "true" ]; then + gpg_stderr_device="/dev/stderr" + fi + gpg --batch --quiet --homedir="${gpg_homedir}" --import "${HELM_TMP_ROOT}/${keys_filename}" 2> "${gpg_stderr_device}" + gpg --batch --no-default-keyring --keyring "${gpg_homedir}/${GPG_PUBRING}" --export > "${gpg_keyring}" + local github_release_url="https://github.com/helm/helm/releases/download/${TAG}" + if [ "${HAS_CURL}" == "true" ]; then + curl -SsL "${github_release_url}/helm-${TAG}-${OS}-${ARCH}.tar.gz.sha256.asc" -o "${HELM_TMP_ROOT}/helm-${TAG}-${OS}-${ARCH}.tar.gz.sha256.asc" + curl -SsL "${github_release_url}/helm-${TAG}-${OS}-${ARCH}.tar.gz.asc" -o "${HELM_TMP_ROOT}/helm-${TAG}-${OS}-${ARCH}.tar.gz.asc" + elif [ "${HAS_WGET}" == "true" ]; then + wget -q -O "${HELM_TMP_ROOT}/helm-${TAG}-${OS}-${ARCH}.tar.gz.sha256.asc" "${github_release_url}/helm-${TAG}-${OS}-${ARCH}.tar.gz.sha256.asc" + wget -q -O "${HELM_TMP_ROOT}/helm-${TAG}-${OS}-${ARCH}.tar.gz.asc" "${github_release_url}/helm-${TAG}-${OS}-${ARCH}.tar.gz.asc" + fi + local error_text="If you think this might be a potential security issue," + error_text="${error_text}\nplease see here: https://github.com/helm/community/blob/master/SECURITY.md" + local num_goodlines_sha=$(gpg --verify --keyring="${gpg_keyring}" --status-fd=1 "${HELM_TMP_ROOT}/helm-${TAG}-${OS}-${ARCH}.tar.gz.sha256.asc" 2> "${gpg_stderr_device}" | grep -c -E '^\[GNUPG:\] (GOODSIG|VALIDSIG)') + if [[ ${num_goodlines_sha} -lt 2 ]]; then + echo "Unable to verify the signature of helm-${TAG}-${OS}-${ARCH}.tar.gz.sha256!" + echo -e "${error_text}" + exit 1 + fi + local num_goodlines_tar=$(gpg --verify --keyring="${gpg_keyring}" --status-fd=1 "${HELM_TMP_ROOT}/helm-${TAG}-${OS}-${ARCH}.tar.gz.asc" 2> "${gpg_stderr_device}" | grep -c -E '^\[GNUPG:\] (GOODSIG|VALIDSIG)') + if [[ ${num_goodlines_tar} -lt 2 ]]; then + echo "Unable to verify the signature of helm-${TAG}-${OS}-${ARCH}.tar.gz!" + echo -e "${error_text}" + exit 1 + fi + echo "Done." +} + +# fail_trap is executed if an error occurs. +fail_trap() { + result=$? + if [ "$result" != "0" ]; then + if [[ -n "$INPUT_ARGUMENTS" ]]; then + echo "Failed to install $BINARY_NAME with the arguments provided: $INPUT_ARGUMENTS" + help + else + echo "Failed to install $BINARY_NAME" + fi + echo -e "\tFor support, go to https://github.com/helm/helm." + fi + cleanup + exit $result +} + +# testVersion tests the installed client to make sure it is working. +testVersion() { + set +e + HELM="$(command -v $BINARY_NAME)" + if [ "$?" = "1" ]; then + echo "$BINARY_NAME not found. Is $HELM_INSTALL_DIR on your "'$PATH?' + exit 1 + fi + set -e +} + +# help provides possible cli installation arguments +help () { + echo "Accepted cli arguments are:" + echo -e "\t[--help|-h ] ->> prints this help" + echo -e "\t[--version|-v ] . When not defined it fetches the latest release from GitHub" + echo -e "\te.g. --version v3.0.0 or -v canary" + echo -e "\t[--no-sudo] ->> install without sudo" +} + +# cleanup temporary files to avoid https://github.com/helm/helm/issues/2977 +cleanup() { + if [[ -d "${HELM_TMP_ROOT:-}" ]]; then + rm -rf "$HELM_TMP_ROOT" + fi +} + +# Execution + +#Stop execution on any error +trap "fail_trap" EXIT +set -e + +# Set debug if desired +if [ "${DEBUG}" == "true" ]; then + set -x +fi + +# Parsing input arguments (if any) +export INPUT_ARGUMENTS="${@}" +set -u +while [[ $# -gt 0 ]]; do + case $1 in + '--version'|-v) + shift + if [[ $# -ne 0 ]]; then + export DESIRED_VERSION="${1}" + if [[ "$1" != "v"* ]]; then + echo "Expected version arg ('${DESIRED_VERSION}') to begin with 'v', fixing..." + export DESIRED_VERSION="v${1}" + fi + else + echo -e "Please provide the desired version. e.g. --version v3.0.0 or -v canary" + exit 0 + fi + ;; + '--no-sudo') + USE_SUDO="false" + ;; + '--help'|-h) + help + exit 0 + ;; + *) exit 1 + ;; + esac + shift +done +set +u + +initArch +initOS +verifySupported +checkDesiredVersion +if ! checkHelmInstalledVersion; then + downloadFile + verifyFile + installFile +fi +testVersion +cleanup diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh new file mode 100755 index 00000000..08c4a8b1 --- /dev/null +++ b/scripts/release-notes.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash + +# 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. + +RELEASE=${RELEASE:-$2} +PREVIOUS_RELEASE=${PREVIOUS_RELEASE:-$1} + +## Ensure Correct Usage +if [[ -z "${PREVIOUS_RELEASE}" || -z "${RELEASE}" ]]; then + echo Usage: + echo ./scripts/release-notes.sh v3.0.0 v3.1.0 + echo or + echo PREVIOUS_RELEASE=v3.0.0 + echo RELEASE=v3.1.0 + echo ./scripts/release-notes.sh + exit 1 +fi + +## validate git tags +for tag in $RELEASE $PREVIOUS_RELEASE; do + OK=$(git tag -l ${tag} | wc -l) + if [[ "$OK" == "0" ]]; then + echo ${tag} is not a valid release version + exit 1 + fi +done + +## Check for hints that checksum files were downloaded +## from `make fetch-dist` +if [[ ! -e "./_dist/helm-${RELEASE}-darwin-amd64.tar.gz.sha256sum" ]]; then + echo "checksum file ./_dist/helm-${RELEASE}-darwin-amd64.tar.gz.sha256sum not found in ./_dist/" + echo "Did you forget to run \`make fetch-dist\` first ?" + exit 1 +fi + +## Generate CHANGELOG from git log +CHANGELOG=$(git log --no-merges --pretty=format:'- %s %H (%aN)' ${PREVIOUS_RELEASE}..${RELEASE}) +if [[ ! $? -eq 0 ]]; then + echo "Error creating changelog" + echo "try running \`git log --no-merges --pretty=format:'- %s %H (%aN)' ${PREVIOUS_RELEASE}..${RELEASE}\`" + exit 1 +fi + +## guess at MAJOR / MINOR / PATCH versions +MAJOR=$(echo ${RELEASE} | sed 's/^v//' | cut -f1 -d.) +MINOR=$(echo ${RELEASE} | sed 's/^v//' | cut -f2 -d.) +PATCH=$(echo ${RELEASE} | sed 's/^v//' | cut -f3 -d.) + +## Print release notes to stdout +cat <. Users are encouraged to upgrade for the best experience. + +The community keeps growing, and we'd love to see you there! + +- Join the discussion in [Kubernetes Slack](https://kubernetes.slack.com): + - `#helm-users` for questions and just to hang out + - `#helm-dev` for discussing PRs, code, and bugs +- Hang out at the Public Developer Call: Thursday, 9:30 Pacific via [Zoom](https://zoom.us/j/696660622) +- Test, debug, and contribute charts: [ArtifactHub/packages](https://artifacthub.io/packages/search?kind=0) + +## Notable Changes + +- Add list of +- notable changes here + +## Installation and Upgrading + +Download Helm ${RELEASE}. The common platform binaries are here: + +- [MacOS amd64](https://get.helm.sh/helm-${RELEASE}-darwin-amd64.tar.gz) ([checksum](https://get.helm.sh/helm-${RELEASE}-darwin-amd64.tar.gz.sha256sum) / $(cat _dist/helm-${RELEASE}-darwin-amd64.tar.gz.sha256)) +- [MacOS arm64](https://get.helm.sh/helm-${RELEASE}-darwin-arm64.tar.gz) ([checksum](https://get.helm.sh/helm-${RELEASE}-darwin-arm64.tar.gz.sha256sum) / $(cat _dist/helm-${RELEASE}-darwin-arm64.tar.gz.sha256)) +- [Linux amd64](https://get.helm.sh/helm-${RELEASE}-linux-amd64.tar.gz) ([checksum](https://get.helm.sh/helm-${RELEASE}-linux-amd64.tar.gz.sha256sum) / $(cat _dist/helm-${RELEASE}-linux-amd64.tar.gz.sha256)) +- [Linux arm](https://get.helm.sh/helm-${RELEASE}-linux-arm.tar.gz) ([checksum](https://get.helm.sh/helm-${RELEASE}-linux-arm.tar.gz.sha256sum) / $(cat _dist/helm-${RELEASE}-linux-arm.tar.gz.sha256)) +- [Linux arm64](https://get.helm.sh/helm-${RELEASE}-linux-arm64.tar.gz) ([checksum](https://get.helm.sh/helm-${RELEASE}-linux-arm64.tar.gz.sha256sum) / $(cat _dist/helm-${RELEASE}-linux-arm64.tar.gz.sha256)) +- [Linux i386](https://get.helm.sh/helm-${RELEASE}-linux-386.tar.gz) ([checksum](https://get.helm.sh/helm-${RELEASE}-linux-386.tar.gz.sha256sum) / $(cat _dist/helm-${RELEASE}-linux-386.tar.gz.sha256)) +- [Linux ppc64le](https://get.helm.sh/helm-${RELEASE}-linux-ppc64le.tar.gz) ([checksum](https://get.helm.sh/helm-${RELEASE}-linux-ppc64le.tar.gz.sha256sum) / $(cat _dist/helm-${RELEASE}-linux-ppc64le.tar.gz.sha256)) +- [Linux s390x](https://get.helm.sh/helm-${RELEASE}-linux-s390x.tar.gz) ([checksum](https://get.helm.sh/helm-${RELEASE}-linux-s390x.tar.gz.sha256sum) / $(cat _dist/helm-${RELEASE}-linux-s390x.tar.gz.sha256)) +- [Linux riscv64](https://get.helm.sh/helm-${RELEASE}-linux-riscv64.tar.gz) ([checksum](https://get.helm.sh/helm-${RELEASE}-linux-riscv64.tar.gz.sha256sum) / $(cat _dist/helm-${RELEASE}-linux-riscv64.tar.gz.sha256)) +- [Windows amd64](https://get.helm.sh/helm-${RELEASE}-windows-amd64.zip) ([checksum](https://get.helm.sh/helm-${RELEASE}-windows-amd64.zip.sha256sum) / $(cat _dist/helm-${RELEASE}-windows-amd64.zip.sha256)) + +The [Quickstart Guide](https://helm.sh/docs/intro/quickstart/) will get you going from there. For **upgrade instructions** or detailed installation notes, check the [install guide](https://helm.sh/docs/intro/install/). You can also use a [script to install](https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3) on any system with \`bash\`. + +## What's Next + +- ${MAJOR}.${MINOR}.$(expr ${PATCH} + 1) will contain only bug fixes. +- ${MAJOR}.$(expr ${MINOR} + 1).${PATCH} is the next feature release. This release will focus on ... + +## Changelog + +${CHANGELOG} +EOF diff --git a/scripts/sync-repo.sh b/scripts/sync-repo.sh new file mode 100755 index 00000000..45310207 --- /dev/null +++ b/scripts/sync-repo.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash + +# 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. + +# Bash 'Strict Mode' +# http://redsymbol.net/articles/unofficial-bash-strict-mode +set -euo pipefail +IFS=$'\n\t' + +# Helper Functions ------------------------------------------------------------- + +# Display error message and exit +error_exit() { + echo "error: ${1:-"unknown error"}" 1>&2 + exit 1 +} + +# Checks if a command exists. Returns 1 or 0 +command_exists() { + hash "${1}" 2>/dev/null +} + +# Program Functions ------------------------------------------------------------ + +verify_prereqs() { + echo "Verifying Prerequisites...." + if command_exists gsutil; then + echo "Thumbs up! Looks like you have gsutil. Let's continue." + else + error_exit "Couldn't find gsutil. Bailing out." + fi +} + +confirm() { + case $response in + [yY][eE][sS]|[yY]) + true + ;; + *) + false + ;; + esac +} + +# Main ------------------------------------------------------------------------- + +main() { + if [ "$#" -ne 2 ]; then + error_exit "Illegal number of parameters. You must pass in local directory path and a GCS bucket name" + fi + + echo "Getting ready to sync your local directory ($1) to a remote repository at gs://$2" + + verify_prereqs + + # dry run of the command + gsutil rsync -d -n $1 gs://$2 + + read -p "Are you sure you would like to continue with these changes? [y/N]} " confirm + if [[ $confirm =~ [yY](es)* ]]; then + gsutil rsync -d $1 gs://$2 + else + error_exit "Discontinuing sync process." + fi + + echo "Your remote chart repository now matches the contents of the $1 directory!" + +} + +main "${@:-}" diff --git a/scripts/util.sh b/scripts/util.sh new file mode 100644 index 00000000..c1e6c375 --- /dev/null +++ b/scripts/util.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash + +# 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. + +set -euo pipefail + +# Example: kube::util::trap_add 'echo "in trap DEBUG"' DEBUG +# See: http://stackoverflow.com/questions/3338030/multiple-bash-traps-for-the-same-signal +kube::util::trap_add() { + local trap_add_cmd + trap_add_cmd=$1 + shift + + for trap_add_name in "$@"; do + local existing_cmd + local new_cmd + + # Grab the currently defined trap commands for this trap + existing_cmd=`trap -p "${trap_add_name}" | awk -F"'" '{print $2}'` + + if [[ -z "${existing_cmd}" ]]; then + new_cmd="${trap_add_cmd}" + else + new_cmd="${existing_cmd};${trap_add_cmd}" + fi + + # Assign the test + trap "${new_cmd}" "${trap_add_name}" + done +} + +# Opposite of kube::util::ensure-temp-dir() +kube::util::cleanup-temp-dir() { + rm -rf "${KUBE_TEMP}" +} + +# Create a temp dir that'll be deleted at the end of this bash session. +# +# Vars set: +# KUBE_TEMP +kube::util::ensure-temp-dir() { + if [[ -z ${KUBE_TEMP-} ]]; then + KUBE_TEMP=$(mktemp -d 2>/dev/null || mktemp -d -t kubernetes.XXXXXX) + kube::util::trap_add kube::util::cleanup-temp-dir EXIT + fi +} diff --git a/scripts/validate-license.sh b/scripts/validate-license.sh new file mode 100755 index 00000000..dc247436 --- /dev/null +++ b/scripts/validate-license.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +# 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. +set -euo pipefail +IFS=$'\n\t' + +find_files() { + find . -not \( \ + \( \ + -wholename './vendor' \ + -o -wholename '*testdata*' \ + -o -wholename '*third_party*' \ + \) -prune \ + \) \ + \( -name '*.go' -o -name '*.sh' \) +} + +# Use "|| :" to ignore the error code when grep returns empty +failed_license_header=($(find_files | xargs grep -L 'Licensed under the Apache License, Version 2.0 (the "License")' || :)) +if (( ${#failed_license_header[@]} > 0 )); then + echo "Some source files are missing license headers." + printf '%s\n' "${failed_license_header[@]}" + exit 1 +fi + +# Use "|| :" to ignore the error code when grep returns empty +failed_copyright_header=($(find_files | xargs grep -L 'Copyright The Helm Authors.' || :)) +if (( ${#failed_copyright_header[@]} > 0 )); then + echo "Some source files are missing the copyright header." + printf '%s\n' "${failed_copyright_header[@]}" + exit 1 +fi diff --git a/testdata/crt.pem b/testdata/crt.pem new file mode 100644 index 00000000..c4c47132 --- /dev/null +++ b/testdata/crt.pem @@ -0,0 +1,73 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: + 48:5a:94:94:51:de:97:11:3b:62:54:dd:ac:85:63:e6:40:5c:4c:f6 + Signature Algorithm: sha256WithRSAEncryption + Issuer: C=US, ST=CO, L=Boulder, O=Helm, CN=helm.sh + Validity + Not Before: Aug 24 18:07:59 2022 GMT + Not After : Aug 21 18:07:59 2032 GMT + Subject: C=US, ST=CO, L=Boulder, O=Helm, CN=helm.sh + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + RSA Public-Key: (2048 bit) + Modulus: + 00:c8:89:55:0d:0b:f1:da:e6:c0:70:7d:d3:27:cd: + b8:a8:81:8b:7c:a4:89:e5:d1:b1:78:01:1d:df:44: + 88:0b:fc:d6:81:35:3d:d1:3b:5e:8f:bb:93:b3:7e: + 28:db:ed:ff:a0:13:3a:70:a3:fe:94:6b:0b:fe:fb: + 63:00:b0:cb:dc:81:cd:80:dc:d0:2f:bf:b2:4f:9a: + 81:d4:22:dc:97:c8:8f:27:86:59:91:fa:92:05:75: + c4:cc:6b:f5:a9:6b:74:1e:f5:db:a9:f8:bf:8c:a2: + 25:fd:a0:cc:79:f4:25:57:74:a9:23:9b:e2:b7:22: + 7a:14:7a:3d:ea:f1:7e:32:6b:57:6c:2e:c6:4f:75: + 54:f9:6b:54:d2:ca:eb:54:1c:af:39:15:9b:d0:7c: + 0f:f8:55:51:04:ea:da:fa:7b:8b:63:0f:ac:39:b1: + f6:4b:8e:4e:f6:ea:e9:7b:e6:ba:5e:5a:8e:91:ef: + dc:b1:7d:52:3f:73:83:52:46:83:48:49:ff:f2:2d: + ca:54:f2:36:bb:49:cc:59:99:c0:9e:cf:8e:78:55: + 6c:ed:7d:7e:83:b8:59:2c:7d:f8:1a:81:f0:7d:f5: + 27:f2:db:ae:d4:31:54:38:fe:47:b2:ee:16:20:0f: + f1:db:2d:28:bf:6f:38:eb:11:bb:9a:d4:b2:5a:3a: + 4a:7f + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Subject Alternative Name: + DNS:helm.sh, IP Address:127.0.0.1 + Signature Algorithm: sha256WithRSAEncryption + d9:95:3b:98:01:6c:cb:a2:92:d8:f7:a7:52:2c:00:c1:04:cd: + ef:1b:d8:fa:71:71:29:7d:1d:29:42:ea:03:ce:15:c6:d5:ee: + 2d:25:51:7e:96:8b:44:2e:d9:19:1b:95:a6:9c:92:52:2b:88: + d8:76:6e:1b:87:36:8e:3a:b1:c6:aa:a4:7a:4e:a9:8b:8d:c0: + 3c:77:95:81:db:9a:50:f4:fb:cc:62:21:36:36:91:3b:6c:6e: + 37:a8:fa:cc:21:56:f4:31:6f:07:2b:29:0e:1a:06:6c:10:87: + fa:6c:be:e1:29:8c:b9:84:b2:ea:4d:07:e8:2b:ff:f6:24:e6: + a6:95:72:c7:d8:02:53:c2:c0:68:d3:fc:e9:72:a5:da:6c:39: + 5a:6b:17:71:86:40:96:ac:94:dd:21:45:9e:aa:85:8a:73:4c: + 8c:3f:0d:2b:d0:8b:04:ef:61:bb:8e:06:6b:86:46:30:a3:64: + 6b:97:01:8b:46:56:7d:42:33:f5:e0:ea:fd:80:b4:8a:50:a8: + 20:2c:f9:ad:61:05:da:ff:b9:b5:da:9c:d6:0e:47:44:0c:9a: + 8f:11:e0:66:f8:76:0c:0f:43:99:6b:af:44:3c:5c:cb:30:98: + 6a:24:f7:ea:23:db:cf:23:35:dd:6c:2e:9d:0a:b0:82:77:b8: + dc:90:5f:78 +-----BEGIN CERTIFICATE----- +MIIDRDCCAiygAwIBAgIUSFqUlFHelxE7YlTdrIVj5kBcTPYwDQYJKoZIhvcNAQEL +BQAwTTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNPMRAwDgYDVQQHDAdCb3VsZGVy +MQ0wCwYDVQQKDARIZWxtMRAwDgYDVQQDDAdoZWxtLnNoMB4XDTIyMDgyNDE4MDc1 +OVoXDTMyMDgyMTE4MDc1OVowTTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNPMRAw +DgYDVQQHDAdCb3VsZGVyMQ0wCwYDVQQKDARIZWxtMRAwDgYDVQQDDAdoZWxtLnNo +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyIlVDQvx2ubAcH3TJ824 +qIGLfKSJ5dGxeAEd30SIC/zWgTU90Ttej7uTs34o2+3/oBM6cKP+lGsL/vtjALDL +3IHNgNzQL7+yT5qB1CLcl8iPJ4ZZkfqSBXXEzGv1qWt0HvXbqfi/jKIl/aDMefQl +V3SpI5vityJ6FHo96vF+MmtXbC7GT3VU+WtU0srrVByvORWb0HwP+FVRBOra+nuL +Yw+sObH2S45O9urpe+a6XlqOke/csX1SP3ODUkaDSEn/8i3KVPI2u0nMWZnAns+O +eFVs7X1+g7hZLH34GoHwffUn8tuu1DFUOP5Hsu4WIA/x2y0ov2846xG7mtSyWjpK +fwIDAQABoxwwGjAYBgNVHREEETAPggdoZWxtLnNohwR/AAABMA0GCSqGSIb3DQEB +CwUAA4IBAQDZlTuYAWzLopLY96dSLADBBM3vG9j6cXEpfR0pQuoDzhXG1e4tJVF+ +lotELtkZG5WmnJJSK4jYdm4bhzaOOrHGqqR6TqmLjcA8d5WB25pQ9PvMYiE2NpE7 +bG43qPrMIVb0MW8HKykOGgZsEIf6bL7hKYy5hLLqTQfoK//2JOamlXLH2AJTwsBo +0/zpcqXabDlaaxdxhkCWrJTdIUWeqoWKc0yMPw0r0IsE72G7jgZrhkYwo2RrlwGL +RlZ9QjP14Or9gLSKUKggLPmtYQXa/7m12pzWDkdEDJqPEeBm+HYMD0OZa69EPFzL +MJhqJPfqI9vPIzXdbC6dCrCCd7jckF94 +-----END CERTIFICATE----- diff --git a/testdata/generate.sh b/testdata/generate.sh new file mode 100755 index 00000000..9751ef30 --- /dev/null +++ b/testdata/generate.sh @@ -0,0 +1,4 @@ +#!/bin/sh + +openssl req -new -config openssl.conf -key key.pem -out key.csr +openssl ca -config openssl.conf -create_serial -batch -in key.csr -out crt.pem -key rootca.key -cert rootca.crt diff --git a/testdata/key.pem b/testdata/key.pem new file mode 100644 index 00000000..691e5508 --- /dev/null +++ b/testdata/key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpgIBAAKCAQEAyIlVDQvx2ubAcH3TJ824qIGLfKSJ5dGxeAEd30SIC/zWgTU9 +0Ttej7uTs34o2+3/oBM6cKP+lGsL/vtjALDL3IHNgNzQL7+yT5qB1CLcl8iPJ4ZZ +kfqSBXXEzGv1qWt0HvXbqfi/jKIl/aDMefQlV3SpI5vityJ6FHo96vF+MmtXbC7G +T3VU+WtU0srrVByvORWb0HwP+FVRBOra+nuLYw+sObH2S45O9urpe+a6XlqOke/c +sX1SP3ODUkaDSEn/8i3KVPI2u0nMWZnAns+OeFVs7X1+g7hZLH34GoHwffUn8tuu +1DFUOP5Hsu4WIA/x2y0ov2846xG7mtSyWjpKfwIDAQABAoIBAQC/XB1m58EQzCVS +sx7t2qedVJEQjcpxHdql0xr4VOMl3U2r2mx03pxrt+lH3NmMlN3bmL2pgzSJ2GSI +Gsbsf8jpUIwTraKUDe9PevbswZ+Sz3Wbl96dKGhzAWCcWWEBHGKgsKe+2Hmg75Il +Jm446btAaziDnFuJukKYi9XN/kgYPxi914O8yz2KtCIVHEHHkl1FcSqjpghPtzU3 +hm1Nv/7tW2r5IrxCGRNJQTg6l4A4mdqif1u75ZUMcbp8dTaJ2/iYBIKIsh7sFMqy +TG6ZN0p3G92ijo7rtznxXS9rIE2rcg6qhusdK8eqhV0KHOqH2nkB4jWbw1NwKFzV +2jXm4S5RAoGBAPIExNBpE30c++Wl4ITuzODd99CczFj527ZBxUdT/H/IszR7adtJ +gHnayzzycul3GnCVMEGBUBp7q09OkcacA7MqS3/Zjn2zrpViz2iluP6jl0qfs2Sp +HaePLBKz9oFVi5m17ZYYnG7etSPVzcLaEi23ws5286HToXeqfUuGd+DlAoGBANQf +FJzQ0EbNu5QcNnQqwfAahvSqc+imPL0HuQWKEMvN3UXXU7Nn8bqba/JGVhgD7/5u +3g2DyyIou6gnocN669CqY8hm0jEboggD4pC8LVj+Iot25UzoNeNuHfqeu7wAlWWL +zjfC3UpSbh1O4H8i5chpFxe9N7syzOXBI5IVPBuTAoGBAITrrZSxQSzj8E0uj2Mz +LH8MKgD/PRRZFhzBfrIwJGuiNRpL9dWkRtWmHx14IziqW3Ed3wT7Gp2Q8oN6KYIl +SbrrLdAoEqRjPS16uWNGMZZZDszDbWmJoGnYrmIPSQG7lBJ14uke1zvlQSNPV9T+ +pCFL3cg7eI+WhgYNMwd58PkpAoGBAKTXFlyaxRAQtrFtjz+NLrMY2kFt6K8l6FN5 +meXdGhpW+5pXsBreLvK17xgSYrs87BbML1FPVt9Pyiztx36ymmjI0MweYz94Wt1h +r4KMSa07qLq6hYzTc3Uu0Ks/CWMbDP4hu/qHOxKTpjCuaDVEeE7ao/B1wcZ+vs3Y +3nyadeBzAoGBAJAZl50nHPwXpEIsHO3nC1ff51cVoV3+gpcCgQ270rLEa2Uv8+Zc +8rXD/LgcLzZ6Fvp0I3jv1mXlN8W0OruZS71lCM/zBd++E04HMxcvuv4lfqzcW+3E +V0ZBn2ErSTF9yKvGedRJk+vbCi7cy38WaA+z59ct/gpiw2Z3q6w85jlF +-----END RSA PRIVATE KEY----- diff --git a/testdata/openssl.conf b/testdata/openssl.conf new file mode 100644 index 00000000..9b27e445 --- /dev/null +++ b/testdata/openssl.conf @@ -0,0 +1,42 @@ +[ca] +default_ca = CA_default + +[CA_default] +dir = ./ +database = $dir/index.txt +new_certs_dir = ./ +serial = $dir/serial +private_key = ./rootca.key +certificate = ./rootca.crt +default_days = 3650 +default_md = sha256 +policy = policy_anything +copy_extensions = copyall + +[policy_anything] +countryName = optional +stateOrProvinceName = optional +localityName = optional +organizationName = optional +organizationalUnitName = optional +commonName = supplied +emailAddress = optional + +[ req ] +default_bits = 2048 +distinguished_name = req_distinguished_name +req_extensions = v3_req + +[ req_distinguished_name ] +countryName = Country Name (2 letter code) +stateOrProvinceName = State or Province Name (full name) +localityName = Locality Name (eg, city) +organizationName = Organization Name (eg, company) +commonName = Common Name (e.g. server FQDN or YOUR name) + +[ v3_req ] +subjectAltName = @alternate_names + +[alternate_names] +DNS.1 = helm.sh +IP.1 = 127.0.0.1 diff --git a/testdata/releases.yaml b/testdata/releases.yaml new file mode 100644 index 00000000..e960e815 --- /dev/null +++ b/testdata/releases.yaml @@ -0,0 +1,43 @@ +# This file can be used as input to create test releases: +# HELM_MEMORY_DRIVER_DATA=./testdata/releases.yaml HELM_DRIVER=memory helm list --all-namespaces +- name: athos + version: 1 + namespace: default + info: + status: deployed + chart: + metadata: + name: athos-chart + version: 1.0.0 + appversion: 1.1.0 +- name: porthos + version: 2 + namespace: default + info: + status: deployed + chart: + metadata: + name: porthos-chart + version: 0.2.0 + appversion: 0.2.2 +- name: aramis + version: 3 + namespace: default + info: + status: deployed + chart: + metadata: + name: aramis-chart + version: 0.0.3 + appversion: 3.0.3 +- name: dartagnan + version: 4 + namespace: gascony + info: + status: deployed + chart: + metadata: + name: dartagnan-chart + version: 0.4.4 + appversion: 4.4.4 + diff --git a/testdata/rootca.crt b/testdata/rootca.crt new file mode 100644 index 00000000..874cdbc1 --- /dev/null +++ b/testdata/rootca.crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDezCCAmOgAwIBAgIUQTwAoToO0ZxUZZCSWuJI4/ROB+4wDQYJKoZIhvcNAQEL +BQAwTTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNPMRAwDgYDVQQHDAdCb3VsZGVy +MQ0wCwYDVQQKDARIZWxtMRAwDgYDVQQDDAdoZWxtLnNoMB4XDTIyMDgyNDE4MDYx +MVoXDTI4MDQwMjE4MDYxMVowTTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNPMRAw +DgYDVQQHDAdCb3VsZGVyMQ0wCwYDVQQKDARIZWxtMRAwDgYDVQQDDAdoZWxtLnNo +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4Z4zHBdV+ID8PdPYRpZp +I8QXhDiMV/kgUSWTqfWMxW9n9X7Tg2jTnypKqX3aIxiHBi3+/VryWRTosZReZI6t +Xv1iuIDbyJuoWskZlZowwsRNA6n7IBFVmUZvRWJk3ThOgXRcOetojH9HG3LnRjtf +HPqmBxq3ZAwDjYw3YzbN3UO2CkXjIc8eEXo/UaUtPFWCuwJNSKAgYTS12Rr1/Ydx +9q9u5+fKZoS9WWdRhxu3sHRshs9ekkr1vIhaS06n7YCAO6TCngo+UDi+JG53kqEc +LV9R31sbc3618QLZTSa6NKMzdu/bnZ15ID0c2HNSUTHExa8XE85mEc87HgMKoZy2 +hQIDAQABo1MwUTAdBgNVHQ4EFgQUicAFxDIXaZuRdpc3D265zOceBDQwHwYDVR0j +BBgwFoAUicAFxDIXaZuRdpc3D265zOceBDQwDwYDVR0TAQH/BAUwAwEB/zANBgkq +hkiG9w0BAQsFAAOCAQEAyIndA2vsHWhn+PqxAnaCai0xAJ6awye7CAWKsLmT3rC2 +zR+EI5dCJgPJ0zrltQyngWz1IgUGoC4klgj/37lY5cG8/HYBJ37IAPya+pVukQuL +qqe2RCWqi4XZUPFRHjbJbHoM3AMsFeYOWJy+bTCMKyyYqUO0S7OM77ID9k7gcJFj +TZ6fvWvRqWFQCLJpQh95kt5wOkAKyttPf5Qkh37fLHtyrwkpbJCj+Yv3kcdKBYpw +kYLbK6DqqbgIKJHRbpu5xGOhKZ0/jnHJRvGAE6g6OKOXJQ/ydIZauoXKQ7hpcV43 +UAIXGjdbKVoPyLNgMueviW8+64GKqllWONPbBai5jQ== +-----END CERTIFICATE----- diff --git a/testdata/rootca.key b/testdata/rootca.key new file mode 100644 index 00000000..14a2a0c0 --- /dev/null +++ b/testdata/rootca.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEA4Z4zHBdV+ID8PdPYRpZpI8QXhDiMV/kgUSWTqfWMxW9n9X7T +g2jTnypKqX3aIxiHBi3+/VryWRTosZReZI6tXv1iuIDbyJuoWskZlZowwsRNA6n7 +IBFVmUZvRWJk3ThOgXRcOetojH9HG3LnRjtfHPqmBxq3ZAwDjYw3YzbN3UO2CkXj +Ic8eEXo/UaUtPFWCuwJNSKAgYTS12Rr1/Ydx9q9u5+fKZoS9WWdRhxu3sHRshs9e +kkr1vIhaS06n7YCAO6TCngo+UDi+JG53kqEcLV9R31sbc3618QLZTSa6NKMzdu/b +nZ15ID0c2HNSUTHExa8XE85mEc87HgMKoZy2hQIDAQABAoIBACFgRNFQBnDHrAj9 +cM4obA9Vb+EoeGJ/QS+f7nNDFvsSGv/vLh0PgdbW68qdCosMktTwMvuJ27Yf6Lh0 +aW5YyP73XwZKUbkghcxAWZ+O+s2lOntjRvocdlxBVi6eeqtbLAnsi8QptgKqxXsj +CWGTYOOplKwSYLTVLiVfa8YqklO77HHKQCMpCU7KsDbNpvhpme345nrAkAGX4Sd+ +STNTM3jdmyzC4jFycMz2eaSbJZjFefn9OkiAL+RNlm4dFo/l9sJIAaIZ5gPV3Jzl ++uDRFO0eW5oE/mHmfS450yOMPwl/mf4GxRbq2JNTBFSroYaz+n/p3Ii+3U5oWmi3 +D9C/EkECgYEA9CiCM5Vc5yPyq4UWjxRD6vedv0Ihur7x7bo1zxTdMBc6feRnJFp2 +HTz33gTY+mhyjstVshj+58rmIR7Ns0bLBJ5v0GyorxhnqhgfsWn9fiKR0lb79DpS +0APrnMdsz0/5NbK45b7qui6p4aDfRxr+EsUlwTUfbEjISn9/YgBk+rECgYEA7I9+ +S1sXBkRuBEyga8X77m/ZyF0ucqyJGxpXfsvR3udgWB3uyV5mEs4pnpLm0SPowuRl +8RUGBF9IUfMwvqcQkGN9qy+f0fpSZmLm0nFOyKD2aE/7A3JlMhY0KsSj2odUotzU +rTXqtlS87zsQl7t028B3r1Cw+y10qLcw3Se0BhUCgYAP5oN0MIn4U5L+MJCjiMJT +jwSq6/eeXckLnlDax5UQCLM6d6Fv8KQ4izvpLY+j3yF2wy81hgMzvTb3eTYUMswN +5POLM0hY/tHhdei6eRiVGlM8y4VlBldWTKsPbr1bUu373UPFUoWe0mMl2oAv9UYO +muA2kOsW9jZ1A5CcJUJuQQKBgDEnuASMjwI8Yef+zC7Y2vq2vzhFNIubknnRRXER +hTCeP4TP43hwZyFtOXS77b5zicBFmXE4/yEVc3+j2vMi3+xA4DIcGUeWjly8HF6K +MOa7m7gdNnmG4cRAnOJuLeYQzONyo7bCR11PylqjmVUOHMA1BCmnyL7IuT79oeey +glPpAoGAICOwp+bh1nqPt+nINO1q/zCCdl9hVakGVkQkuCiDK8wLW3R/vNrBtTf+ +PDM87BasvZkzA2VBcTgtDCcnP/aNDLyy2FDKIUyVtcpfheHgxjlT1txGHBUXJf6z +rS1fGWIYbpMb3RSCtGJTa1hyDJdN424nYUD3phL4SPx2Cn5eAPs= +-----END RSA PRIVATE KEY----- From 961ca04a485a0f9b2d2ace0bb92c2f8b0f5bd93f Mon Sep 17 00:00:00 2001 From: Ilya Lesikov Date: Sat, 28 Mar 2026 00:11:58 +0300 Subject: [PATCH 02/85] feat!: new major release 2.0 Signed-off-by: Ilya Lesikov --- Taskfile.dist.yaml | 51 +- cmd/nelm/chart_dependency_download.go | 7 +- cmd/nelm/chart_dependency_update.go | 7 +- cmd/nelm/chart_download.go | 7 +- cmd/nelm/chart_init.go | 4 +- cmd/nelm/chart_lint.go | 4 +- cmd/nelm/chart_pack.go | 7 +- cmd/nelm/chart_render.go | 2 +- cmd/nelm/chart_secret_file_decrypt.go | 2 +- cmd/nelm/chart_secret_file_edit.go | 4 +- cmd/nelm/chart_secret_file_encrypt.go | 2 +- cmd/nelm/chart_secret_key_create.go | 2 +- cmd/nelm/chart_secret_key_rotate.go | 4 +- cmd/nelm/chart_secret_values_file_decrypt.go | 2 +- cmd/nelm/chart_secret_values_file_edit.go | 4 +- cmd/nelm/chart_secret_values_file_encrypt.go | 2 +- cmd/nelm/chart_upload.go | 7 +- cmd/nelm/main.go | 6 +- cmd/nelm/release_get.go | 2 +- cmd/nelm/release_history.go | 7 +- cmd/nelm/release_install.go | 4 +- cmd/nelm/release_list.go | 2 +- cmd/nelm/release_plan_install.go | 4 +- cmd/nelm/release_plan_show.go | 4 +- cmd/nelm/release_rollback.go | 4 +- cmd/nelm/release_uninstall.go | 4 +- cmd/nelm/repo_add.go | 7 +- cmd/nelm/repo_login.go | 7 +- cmd/nelm/repo_logout.go | 7 +- cmd/nelm/repo_remove.go | 7 +- cmd/nelm/repo_update.go | 7 +- cmd/nelm/version.go | 2 +- go.mod | 313 +-- go.sum | 758 ++++--- pkg/action/chart_lint.go | 10 +- pkg/action/chart_render.go | 7 +- pkg/action/common.go | 24 +- pkg/action/release_get.go | 9 +- pkg/action/release_install.go | 20 +- pkg/action/release_list.go | 8 +- pkg/action/release_plan_install.go | 10 +- pkg/action/release_rollback.go | 15 +- pkg/action/release_uninstall.go | 8 +- pkg/{log/init.go => action/setup_logging.go} | 27 +- pkg/chart/chart_download.go | 23 +- pkg/chart/chart_render.go | 311 ++- pkg/common/common.go | 74 +- pkg/common/legacy.go | 45 + pkg/helm/cmd/helm/exports.go | 7 - pkg/helm/cmd/helm/flags_test.go | 95 - pkg/helm/cmd/helm/helm.go | 165 -- pkg/helm/cmd/helm/history_test.go | 124 -- pkg/helm/cmd/helm/list.go | 82 - pkg/helm/cmd/helm/repo_list.go | 61 - pkg/helm/cmd/helm/root_unix.go | 58 - pkg/helm/cmd/helm/root_unix_test.go | 82 - pkg/helm/cmd/helm/root_windows.go | 22 - .../helm/plugins/fullenv/completion.yaml | 19 - .../helm/plugins/fullenv/plugin.yaml | 4 - .../helmhome/helm/plugins/args/args.sh | 2 - .../helmhome/helm/plugins/args/plugin.yaml | 4 - .../helmhome/helm/plugins/echo/plugin.yaml | 4 - .../helmhome/helm/plugins/env/completion.yaml | 13 - .../helmhome/helm/plugins/env/plugin.yaml | 4 - .../helm/plugins/exitwith/exitwith.sh | 2 - .../helm/plugins/exitwith/plugin.yaml | 4 - .../helm/plugins/fullenv/completion.yaml | 19 - .../helmhome/helm/plugins/fullenv/fullenv.sh | 7 - .../helmhome/helm/plugins/fullenv/plugin.yaml | 4 - .../testdata/helmhome/helm/repositories.yaml | 6 - .../testdata/output/empty_nofile_comp.txt | 2 - .../helm/testdata/output/get-metadata.json | 1 - .../helm/testdata/output/get-metadata.yaml | 8 - .../output/lint-chart-with-bad-subcharts.txt | 7 - .../output/rollback-wrong-args-comp.txt | 2 - .../output/status-with-resources.json | 1 - .../output/status-wrong-args-comp.txt | 2 - pkg/helm/cmd/helm/testdata/output/status.json | 1 - .../output/version-client-shorthand.txt | 1 - .../helm/testdata/output/version-client.txt | 1 - .../helm/testdata/output/version-short.txt | 1 - .../helm/testdata/output/version-template.txt | 1 - pkg/helm/cmd/helm/testdata/output/version.txt | 1 - pkg/helm/cmd/helm/testdata/plugins.yaml | 3 - .../testcharts/issue-7233/requirements.yaml | 4 - .../testcharts/upgradetest/values.yaml | 1 - .../cmd/helm/testdata/testplugin/plugin.yaml | 4 - pkg/helm/intern/chart/v3/chart.go | 184 ++ pkg/helm/intern/chart/v3/chart_test.go | 229 ++ pkg/helm/intern/chart/v3/dependency.go | 82 + .../chart/v3}/dependency_test.go | 2 +- pkg/helm/intern/chart/v3/doc.go | 21 + .../{pkg/chart => intern/chart/v3}/errors.go | 2 +- pkg/helm/intern/chart/v3/fuzz_test.go | 48 + pkg/helm/intern/chart/v3/lint/lint.go | 66 + pkg/helm/intern/chart/v3/lint/lint_test.go | 243 ++ .../intern/chart/v3/lint/rules/chartfile.go | 225 ++ .../chart/v3/lint/rules/chartfile_test.go | 278 +++ pkg/helm/intern/chart/v3/lint/rules/crds.go | 116 + .../intern/chart/v3/lint/rules/crds_test.go | 66 + .../chart/v3/lint/rules/dependencies.go | 102 + .../chart/v3/lint/rules/dependencies_test.go | 157 ++ .../chart/v3/lint/rules/deprecations.go | 94 + .../chart/v3/lint/rules/deprecations_test.go | 41 + .../intern/chart/v3/lint/rules/template.go | 354 +++ .../chart/v3/lint/rules/template_test.go | 467 ++++ .../lint/rules/testdata/albatross/Chart.yaml | 5 + .../testdata/albatross/templates/_helpers.tpl | 16 + .../testdata/albatross/templates/fail.yaml | 1 + .../testdata/albatross/templates/svc.yaml | 19 + .../lint/rules/testdata/albatross/values.yaml | 1 + .../testdata/anotherbadchartfile/Chart.yaml | 15 + .../rules/testdata/badchartfile/Chart.yaml | 11 + .../rules/testdata/badchartfile/values.yaml | 1 + .../rules/testdata/badchartname/Chart.yaml | 5 + .../rules/testdata/badchartname/values.yaml | 1 + .../lint/rules/testdata/badcrdfile/Chart.yaml | 6 + .../badcrdfile/crds/bad-apiversion.yaml | 2 + .../testdata/badcrdfile/crds/bad-crd.yaml | 2 + .../testdata/badcrdfile/templates}/.gitkeep | 0 .../rules/testdata/badcrdfile/values.yaml | 1 + .../rules/testdata/badvaluesfile/Chart.yaml | 6 + .../templates/badvaluesfile.yaml | 2 + .../rules/testdata/badvaluesfile/values.yaml | 2 + .../v3/lint/rules/testdata/goodone/Chart.yaml | 5 + .../testdata/goodone}/crds/test-crd.yaml | 4 +- .../testdata/goodone/templates/goodone.yaml | 2 + .../lint/rules/testdata/goodone/values.yaml | 1 + .../testdata/invalidchartfile/Chart.yaml | 6 + .../testdata/invalidchartfile}/values.yaml | 0 .../rules/testdata/invalidcrdsdir/Chart.yaml | 6 + .../lint/rules/testdata/invalidcrdsdir/crds} | 0 .../rules/testdata/invalidcrdsdir/values.yaml | 1 + .../testdata/malformed-template}/.helmignore | 0 .../testdata/malformed-template/Chart.yaml | 25 + .../malformed-template/templates/bad.yaml | 1 + .../testdata/malformed-template/values.yaml | 82 + .../testdata/multi-template-fail/Chart.yaml | 21 + .../templates/multi-fail.yaml | 13 + .../v3/lint/rules/testdata/v3-fail/Chart.yaml | 21 + .../testdata/v3-fail/templates/_helpers.tpl | 63 + .../v3-fail/templates/deployment.yaml | 56 + .../testdata/v3-fail/templates/ingress.yaml | 62 + .../testdata/v3-fail/templates/service.yaml | 17 + .../lint/rules/testdata/v3-fail/values.yaml | 66 + .../rules/testdata/withsubchart/Chart.yaml | 16 + .../withsubchart/charts/subchart/Chart.yaml | 6 + .../charts/subchart/templates/subchart.yaml | 2 + .../withsubchart/charts/subchart/values.yaml | 2 + .../withsubchart/templates/mainchart.yaml | 2 + .../rules/testdata/withsubchart}/values.yaml | 0 pkg/helm/intern/chart/v3/lint/rules/values.go | 84 + .../intern/chart/v3/lint/rules/values_test.go | 183 ++ pkg/helm/intern/chart/v3/lint/support/doc.go | 23 + .../intern/chart/v3/lint/support/message.go | 76 + .../chart/v3/lint/support/message_test.go | 79 + pkg/helm/intern/chart/v3/loader/archive.go | 75 + .../intern/chart/v3/loader/chart_metadata.go | 37 + pkg/helm/intern/chart/v3/loader/directory.go | 123 ++ pkg/helm/intern/chart/v3/loader/load.go | 353 +++ pkg/helm/intern/chart/v3/loader/load_test.go | 727 ++++++ .../chart/v3}/loader/testdata/LICENSE | 0 .../v3}/loader/testdata/albatross/Chart.yaml | 0 .../v3}/loader/testdata/albatross/values.yaml | 0 .../v3/loader/testdata/frobnitz-1.2.3.tgz | Bin 0 -> 3420 bytes .../testdata/frobnitz.v3.reqs}/.helmignore | 0 .../testdata/frobnitz.v3.reqs/Chart.yaml | 27 + .../testdata/frobnitz.v3.reqs}/INSTALL.txt | 0 .../loader/testdata/frobnitz.v3.reqs}/LICENSE | 0 .../testdata/frobnitz.v3.reqs}/README.md | 0 .../frobnitz.v3.reqs}/charts/_ignore_me | 0 .../frobnitz.v3.reqs/charts/alpine/Chart.yaml | 5 + .../frobnitz.v3.reqs}/charts/alpine/README.md | 0 .../charts/alpine/charts/mast1/Chart.yaml | 5 + .../charts/alpine/charts/mast1/values.yaml | 0 .../charts/alpine/charts/mast2-0.1.0.tgz | Bin .../charts/alpine/templates/alpine-pod.yaml | 0 .../charts/alpine/values.yaml | 0 .../charts/mariner-4.3.2.tgz | Bin .../testdata/frobnitz.v3.reqs}/docs/README.md | 0 .../testdata/frobnitz.v3.reqs}/icon.svg | 0 .../testdata/frobnitz.v3.reqs}/ignore/me.txt | 0 .../frobnitz.v3.reqs}/templates/template.tpl | 0 .../testdata/frobnitz.v3.reqs}/values.yaml | 0 .../v3/loader/testdata/frobnitz}/.helmignore | 0 .../v3/loader/testdata/frobnitz}/Chart.lock | 0 .../v3/loader/testdata/frobnitz/Chart.yaml | 27 + .../v3/loader/testdata/frobnitz}/INSTALL.txt | 0 .../v3/loader/testdata/frobnitz}/LICENSE | 0 .../v3/loader/testdata/frobnitz}/README.md | 0 .../testdata/frobnitz}/charts/_ignore_me | 0 .../frobnitz/charts/alpine/Chart.yaml | 5 + .../frobnitz}/charts/alpine/README.md | 0 .../charts/alpine/charts/mast1/Chart.yaml | 5 + .../charts/alpine/charts/mast1/values.yaml | 0 .../charts/alpine/charts/mast2-0.1.0.tgz | Bin .../charts/alpine/templates/alpine-pod.yaml | 0 .../frobnitz}/charts/alpine/values.yaml | 0 .../frobnitz/charts/mariner-4.3.2.tgz | Bin 0 -> 910 bytes .../loader/testdata/frobnitz}/docs/README.md | 0 .../v3/loader/testdata/frobnitz}/icon.svg | 0 .../loader/testdata/frobnitz}/ignore/me.txt | 0 .../testdata/frobnitz}/templates/template.tpl | 0 .../v3/loader/testdata/frobnitz}/values.yaml | 0 .../testdata/frobnitz_backslash-1.2.3.tgz | Bin 0 -> 3434 bytes .../testdata/frobnitz_backslash}/.helmignore | 0 .../testdata/frobnitz_backslash}/Chart.lock | 0 .../testdata/frobnitz_backslash/Chart.yaml | 27 + .../testdata/frobnitz_backslash}/INSTALL.txt | 0 .../testdata/frobnitz_backslash}/LICENSE | 0 .../testdata/frobnitz_backslash}/README.md | 0 .../frobnitz_backslash}/charts/_ignore_me | 0 .../charts/alpine/Chart.yaml | 5 + .../charts/alpine/README.md | 0 .../charts/alpine/charts/mast1/Chart.yaml | 5 + .../charts/alpine/charts/mast1/values.yaml | 0 .../charts/alpine/charts/mast2-0.1.0.tgz | Bin .../charts/alpine/templates/alpine-pod.yaml | 0 .../charts/alpine/values.yaml | 0 .../charts/mariner-4.3.2.tgz | Bin 0 -> 910 bytes .../frobnitz_backslash}/docs/README.md | 0 .../testdata/frobnitz_backslash}/icon.svg | 0 .../frobnitz_backslash}/ignore/me.txt | 0 .../templates/template.tpl | 0 .../testdata/frobnitz_backslash}/values.yaml | 0 .../v3/loader/testdata/frobnitz_with_bom.tgz | Bin 0 -> 3453 bytes .../testdata/frobnitz_with_bom/.helmignore | 0 .../testdata/frobnitz_with_bom/Chart.lock | 0 .../testdata/frobnitz_with_bom/Chart.yaml | 27 + .../testdata/frobnitz_with_bom/INSTALL.txt | 0 .../loader/testdata/frobnitz_with_bom/LICENSE | 0 .../testdata/frobnitz_with_bom/README.md | 0 .../frobnitz_with_bom/charts/_ignore_me | 0 .../charts/alpine/Chart.yaml | 5 + .../frobnitz_with_bom/charts/alpine/README.md | 0 .../charts/alpine/charts/mast1/Chart.yaml | 5 + .../charts/alpine/charts/mast1/values.yaml | 0 .../charts/alpine/charts/mast2-0.1.0.tgz | Bin .../charts/alpine/templates/alpine-pod.yaml | 0 .../charts/alpine/values.yaml | 0 .../charts/mariner-4.3.2.tgz | Bin 0 -> 910 bytes .../testdata/frobnitz_with_bom/docs/README.md | 0 .../testdata/frobnitz_with_bom}/icon.svg | 0 .../testdata/frobnitz_with_bom}/ignore/me.txt | 0 .../frobnitz_with_bom/templates/template.tpl | 0 .../testdata/frobnitz_with_bom/values.yaml | 0 .../frobnitz_with_dev_null}/.helmignore | 0 .../frobnitz_with_dev_null}/Chart.lock | 0 .../frobnitz_with_dev_null/Chart.yaml | 27 + .../frobnitz_with_dev_null}/INSTALL.txt | 0 .../testdata/frobnitz_with_dev_null}/LICENSE | 0 .../frobnitz_with_dev_null}/README.md | 0 .../frobnitz_with_dev_null}/charts/_ignore_me | 0 .../charts/alpine/Chart.yaml | 5 + .../charts/alpine/README.md | 0 .../charts/alpine/charts/mast1/Chart.yaml | 5 + .../charts/alpine/charts/mast1/values.yaml | 0 .../charts/alpine/charts/mast2-0.1.0.tgz | Bin .../charts/alpine/templates/alpine-pod.yaml | 0 .../charts/alpine/values.yaml | 0 .../charts/mariner-4.3.2.tgz | Bin 0 -> 910 bytes .../frobnitz_with_dev_null}/docs/README.md | 0 .../testdata/frobnitz_with_dev_null}/icon.svg | 0 .../frobnitz_with_dev_null}/ignore/me.txt | 0 .../testdata/frobnitz_with_dev_null/null | 0 .../templates/template.tpl | 0 .../frobnitz_with_dev_null}/values.yaml | 0 .../frobnitz_with_symlink}/.helmignore | 0 .../frobnitz_with_symlink}/Chart.lock | 0 .../testdata/frobnitz_with_symlink/Chart.yaml | 27 + .../frobnitz_with_symlink}/INSTALL.txt | 0 .../testdata/frobnitz_with_symlink}/README.md | 0 .../frobnitz_with_symlink}/charts/_ignore_me | 0 .../charts/alpine/Chart.yaml | 5 + .../charts/alpine/README.md | 0 .../charts/alpine/charts/mast1/Chart.yaml | 5 + .../charts/alpine/charts/mast1/values.yaml | 0 .../charts/alpine/charts/mast2-0.1.0.tgz | Bin .../charts/alpine/templates/alpine-pod.yaml | 0 .../charts/alpine/values.yaml | 0 .../charts/mariner-4.3.2.tgz | Bin 0 -> 910 bytes .../frobnitz_with_symlink}/docs/README.md | 0 .../testdata/frobnitz_with_symlink}/icon.svg | 0 .../frobnitz_with_symlink}/ignore/me.txt | 0 .../templates/template.tpl | 0 .../frobnitz_with_symlink}/values.yaml | 0 .../chart/v3/loader/testdata/genfrob.sh | 18 + .../v3/loader/testdata/mariner/Chart.yaml | 9 + .../mariner/charts/albatross-0.1.0.tgz | Bin 0 -> 282 bytes .../mariner/templates/placeholder.tpl | 0 .../v3}/loader/testdata/mariner/values.yaml | 0 .../chart => intern/chart/v3}/metadata.go | 2 +- pkg/helm/intern/chart/v3/metadata_test.go | 201 ++ pkg/helm/intern/chart/v3/util/chartfile.go | 96 + .../intern/chart/v3/util/chartfile_test.go | 117 + .../chart/v3/util}/compatible.go | 2 +- .../chart/v3/util}/compatible_test.go | 2 +- pkg/helm/intern/chart/v3/util/create.go | 835 +++++++ pkg/helm/intern/chart/v3/util/create_test.go | 173 ++ pkg/helm/intern/chart/v3/util/dependencies.go | 381 ++++ .../chart/v3/util}/dependencies_test.go | 159 +- pkg/helm/intern/chart/v3/util/doc.go | 45 + pkg/helm/intern/chart/v3/util/expand.go | 94 + .../chart/v3/util}/expand_test.go | 6 +- pkg/helm/intern/chart/v3/util/save.go | 257 +++ pkg/helm/intern/chart/v3/util/save_test.go | 358 +++ .../Chart.yaml | 14 + .../charts/child/Chart.yaml | 6 + .../charts/child/charts/grandchild/Chart.yaml | 6 + .../charts/grandchild/templates/dummy.yaml | 7 + .../charts/child/templates/dummy.yaml | 7 + .../values.yaml | 7 + .../Chart.yaml | 20 + .../charts/child/Chart.yaml | 12 + .../charts/child/charts/grandchild/Chart.yaml | 6 + .../child/charts/grandchild/values.yaml | 2 + .../charts/child/templates/dummy.yaml | 7 + .../templates/dummy.yaml | 7 + .../chart/v3/util/testdata/chartfiletest.yaml | 20 + .../chart/v3/util}/testdata/coleridge.yaml | 0 .../dependent-chart-alias}/.helmignore | 0 .../dependent-chart-alias}/Chart.lock | 0 .../testdata/dependent-chart-alias/Chart.yaml | 29 + .../dependent-chart-alias}/INSTALL.txt | 0 .../testdata/dependent-chart-alias}/LICENSE | 0 .../testdata/dependent-chart-alias}/README.md | 0 .../dependent-chart-alias}/charts/_ignore_me | 0 .../charts/alpine/Chart.yaml | 5 + .../charts/alpine/README.md | 0 .../charts/alpine/charts/mast1/Chart.yaml | 5 + .../charts/alpine/charts/mast1/values.yaml | 0 .../charts/alpine/charts/mast2-0.1.0.tgz | Bin .../charts}/alpine/templates/alpine-pod.yaml | 0 .../charts/alpine/values.yaml | 0 .../charts/mariner-4.3.2.tgz | Bin .../dependent-chart-alias}/docs/README.md | 0 .../testdata/dependent-chart-alias}/icon.svg | 0 .../dependent-chart-alias}/ignore/me.txt | 0 .../templates/template.tpl | 0 .../dependent-chart-alias}/values.yaml | 0 .../dependent-chart-helmignore/.helmignore | 0 .../dependent-chart-helmignore/Chart.yaml | 17 + .../charts/.ignore_me | 0 .../charts/_ignore_me | 0 .../charts/alpine/Chart.yaml | 5 + .../charts/alpine/README.md | 0 .../charts/alpine/charts/mast1/Chart.yaml | 5 + .../charts/alpine/charts/mast1/values.yaml | 0 .../charts/alpine/charts/mast2-0.1.0.tgz | Bin .../charts/alpine/templates/alpine-pod.yaml | 0 .../charts/alpine/values.yaml | 0 .../templates/template.tpl | 0 .../dependent-chart-helmignore}/values.yaml | 0 .../.helmignore | 0 .../Chart.yaml | 17 + .../INSTALL.txt | 0 .../LICENSE | 0 .../README.md | 0 .../charts/_ignore_me | 0 .../charts/alpine/Chart.yaml | 5 + .../charts/alpine/README.md | 0 .../charts/alpine/charts/mast1/Chart.yaml | 5 + .../charts/alpine/charts/mast1/values.yaml | 0 .../charts/alpine/charts/mast2-0.1.0.tgz | Bin .../charts/alpine/templates/alpine-pod.yaml | 0 .../charts/alpine/values.yaml | 0 .../charts/mariner-4.3.2.tgz | Bin .../docs/README.md | 0 .../icon.svg | 0 .../ignore/me.txt | 0 .../templates/template.tpl | 0 .../values.yaml | 0 .../.helmignore | 0 .../Chart.yaml | 24 + .../INSTALL.txt | 0 .../LICENSE | 0 .../README.md | 0 .../charts/_ignore_me | 0 .../charts/alpine/Chart.yaml | 5 + .../charts/alpine/README.md | 0 .../charts/alpine/charts/mast1/Chart.yaml | 5 + .../charts/alpine/charts/mast1/values.yaml | 0 .../charts/alpine/charts/mast2-0.1.0.tgz | Bin .../charts/alpine/templates/alpine-pod.yaml | 0 .../charts/alpine/values.yaml | 0 .../charts/mariner-4.3.2.tgz | Bin .../docs/README.md | 0 .../icon.svg | 0 .../ignore/me.txt | 0 .../templates/template.tpl | 0 .../values.yaml | 0 .../.helmignore | 0 .../Chart.yaml | 21 + .../INSTALL.txt | 0 .../LICENSE | 0 .../README.md | 0 .../charts/_ignore_me | 0 .../charts/alpine/Chart.yaml | 5 + .../charts/alpine/README.md | 0 .../charts/alpine/charts/mast1/Chart.yaml | 5 + .../charts/alpine/charts/mast1/values.yaml | 0 .../charts/alpine/charts/mast2-0.1.0.tgz | Bin .../charts/alpine/templates/alpine-pod.yaml | 0 .../charts/alpine/values.yaml | 0 .../charts/mariner-4.3.2.tgz | Bin .../docs/README.md | 0 .../icon.svg | 0 .../ignore/me.txt | 0 .../templates/template.tpl | 0 .../values.yaml | 0 .../v3/util}/testdata/frobnitz-1.2.3.tgz | Bin .../v3/util/testdata/frobnitz}/.helmignore | 0 .../v3/util/testdata/frobnitz}/Chart.lock | 0 .../v3/util/testdata/frobnitz/Chart.yaml | 27 + .../v3/util/testdata/frobnitz}/INSTALL.txt | 0 .../chart/v3/util/testdata/frobnitz}/LICENSE | 0 .../v3/util/testdata/frobnitz}/README.md | 0 .../util/testdata/frobnitz}/charts/_ignore_me | 0 .../frobnitz/charts/alpine/Chart.yaml | 5 + .../frobnitz}/charts/alpine/README.md | 0 .../charts/alpine/charts/mast1/Chart.yaml | 5 + .../charts/alpine/charts/mast1/values.yaml | 0 .../charts/alpine/charts/mast2-0.1.0.tgz | Bin .../charts/alpine/templates/alpine-pod.yaml | 0 .../frobnitz}/charts/alpine/values.yaml | 0 .../frobnitz/charts/mariner/Chart.yaml | 9 + .../mariner/charts/albatross/Chart.yaml | 5 + .../mariner/charts/albatross/values.yaml | 0 .../charts/mariner/templates/placeholder.tpl | 0 .../frobnitz/charts/mariner/values.yaml | 0 .../v3/util/testdata/frobnitz}/docs/README.md | 0 .../chart/v3/util/testdata/frobnitz}/icon.svg | 0 .../v3/util/testdata/frobnitz}/ignore/me.txt | 0 .../testdata/frobnitz}/templates/template.tpl | 0 .../v3/util/testdata/frobnitz}/values.yaml | 0 .../testdata/frobnitz_backslash-1.2.3.tgz | Bin .../chart/v3/util}/testdata/genfrob.sh | 0 .../parent-chart/Chart.lock | 0 .../parent-chart/Chart.yaml | 22 + .../parent-chart/charts/dev-v0.1.0.tgz | Bin .../parent-chart/charts/prod-v0.1.0.tgz | Bin .../parent-chart/envs/dev/Chart.yaml | 4 + .../parent-chart/envs/dev/values.yaml | 0 .../parent-chart/envs/prod/Chart.yaml | 4 + .../parent-chart/envs/prod/values.yaml | 0 .../parent-chart/templates/autoscaler.yaml | 0 .../parent-chart/values.yaml | 2 +- .../chart/v3/util/testdata/joonix/Chart.yaml | 4 + .../v3/util/testdata/joonix/charts/.gitkeep} | 0 .../chart/v3/util/testdata/subpop/Chart.yaml | 41 + .../chart/v3/util}/testdata/subpop/README.md | 0 .../subpop/charts/subchart1/Chart.yaml | 36 + .../subchart1/charts/subchartA/Chart.yaml | 4 + .../charts/subchartA/templates/service.yaml | 0 .../subchart1}/charts/subchartA/values.yaml | 1 + .../subchart1/charts/subchartB/Chart.yaml | 4 + .../charts/subchartB/templates/service.yaml | 0 .../subchart1/charts/subchartB/values.yaml | 3 + .../subpop/charts/subchart1/crds/crdA.yaml | 0 .../charts/subchart1}/templates/NOTES.txt | 0 .../charts/subchart1}/templates/service.yaml | 0 .../subchart1/templates/subdir/role.yaml | 0 .../templates/subdir/rolebinding.yaml | 0 .../templates/subdir/serviceaccount.yaml | 0 .../subpop/charts/subchart1/values.yaml | 26 +- .../subpop/charts/subchart2/Chart.yaml | 19 + .../subchart2/charts/subchartB/Chart.yaml | 4 + .../charts/subchartB/templates/service.yaml | 2 +- .../subchart2/charts/subchartB}/values.yaml | 1 + .../subchart2/charts/subchartC/Chart.yaml | 4 + .../charts/subchartC}/templates/service.yaml | 0 .../subchart2/charts/subchartC}/values.yaml | 1 + .../charts/subchart2}/templates/service.yaml | 0 .../subpop/charts/subchart2}/values.yaml | 1 + .../v3/util/testdata/subpop/noreqs/Chart.yaml | 4 + .../subpop/noreqs}/templates/service.yaml | 0 .../util}/testdata/subpop/noreqs/values.yaml | 1 + .../v3/util}/testdata/subpop/values.yaml | 23 +- .../testdata/test-values-invalid.schema.json | 0 .../util}/testdata/test-values-negative.yaml | 0 .../v3/util}/testdata/test-values.schema.json | 0 .../chart/v3/util}/testdata/test-values.yaml | 0 .../three-level-dependent-chart/README.md | 0 .../umbrella/Chart.yaml | 19 + .../umbrella/charts/app1/Chart.yaml | 11 + .../charts/app1/charts/library/Chart.yaml | 5 + .../charts/library/templates/service.yaml | 0 .../charts/app1/charts/library/values.yaml | 0 .../charts/app1/templates/service.yaml | 0 .../umbrella/charts/app1/values.yaml | 0 .../umbrella/charts/app2/Chart.yaml | 11 + .../charts/app2/charts/library/Chart.yaml | 5 + .../charts/library/templates/service.yaml | 0 .../charts/app2/charts/library/values.yaml | 0 .../charts/app2/templates/service.yaml | 0 .../umbrella/charts/app2/values.yaml | 0 .../umbrella/charts/app3/Chart.yaml | 11 + .../charts/app3/charts/library/Chart.yaml | 5 + .../charts/library/templates/service.yaml | 0 .../charts/app3/charts/library/values.yaml | 0 .../charts/app3/templates/service.yaml | 0 .../umbrella/charts/app3/values.yaml | 0 .../umbrella/charts/app4/Chart.yaml | 9 + .../charts/app4/charts/library/Chart.yaml | 5 + .../charts/library/templates/service.yaml | 0 .../charts/app4/charts/library/values.yaml | 0 .../charts/app4/templates/service.yaml | 0 .../umbrella/charts/app4/values.yaml | 0 .../umbrella/values.yaml | 0 .../chart/v3/util}/validate_name.go | 5 +- .../chart/v3/util}/validate_name_test.go | 4 +- pkg/helm/intern/cli/output/color.go | 67 + pkg/helm/intern/cli/output/color_test.go | 172 ++ .../intern/copystructure/copystructure.go | 128 ++ .../copystructure/copystructure_test.go | 389 ++++ .../{internal => intern}/fileutil/fileutil.go | 2 +- pkg/helm/intern/fileutil/fileutil_test.go | 147 ++ pkg/helm/intern/fileutil/fileutil_unix.go | 32 + pkg/helm/intern/fileutil/fileutil_windows.go | 54 + pkg/helm/intern/logging/logging.go | 125 ++ pkg/helm/intern/logging/logging_test.go | 373 ++++ .../{internal => intern}/monocular/client.go | 6 - .../monocular/client_test.go | 0 .../{internal => intern}/monocular/doc.go | 0 .../{internal => intern}/monocular/search.go | 6 +- .../monocular/search_test.go | 2 +- .../{internal => intern}/resolver/resolver.go | 36 +- .../resolver/resolver_test.go | 5 +- .../testdata/chartpath/base/Chart.yaml | 0 .../charts/localdependency/Chart.yaml | 0 .../repository/kubernetes-charts-index.yaml | 0 .../intern/statusreaders/job_status_reader.go | 121 + .../statusreaders/job_status_reader_test.go | 116 + .../intern/statusreaders/pod_status_reader.go | 104 + .../statusreaders/pod_status_reader_test.go | 111 + pkg/helm/{internal => intern}/sympath/walk.go | 10 +- .../{internal => intern}/sympath/walk_test.go | 17 +- .../test/ensure/ensure.go | 15 +- pkg/helm/{internal => intern}/test/test.go | 9 +- .../third_party/dep/fs/fs.go | 55 +- .../third_party/dep/fs/fs_test.go | 26 +- .../third_party/dep/fs/rename.go | 5 +- .../third_party/dep/fs/rename_windows.go | 5 +- .../dep/fs/testdata/symlinks/file-symlink | 0 .../dep/fs/testdata/symlinks/invalid-symlink | 0 .../fs/testdata/symlinks/windows-file-symlink | 0 .../third_party/dep/fs/testdata/test.file | 0 .../deployment/util/deploymentutil.go | 0 pkg/helm/intern/tlsutil/tls.go | 122 + pkg/helm/intern/tlsutil/tls_test.go | 106 + .../{internal => intern}/urlutil/urlutil.go | 0 .../urlutil/urlutil_test.go | 0 pkg/helm/intern/version/clientgo.go | 44 + pkg/helm/intern/version/clientgo_test.go | 30 + .../{internal => intern}/version/version.go | 51 +- pkg/helm/internal/fileutil/fileutil_test.go | 57 - pkg/helm/internal/tlsutil/cfg.go | 58 - pkg/helm/internal/tlsutil/tls.go | 78 - pkg/helm/internal/tlsutil/tlsutil_test.go | 114 - pkg/helm/pkg/action/action.go | 350 ++- pkg/helm/pkg/action/action_test.go | 918 +++++++- pkg/helm/pkg/action/dependency.go | 47 +- pkg/helm/pkg/action/dependency_test.go | 6 +- pkg/helm/pkg/action/exports.go | 17 - pkg/helm/pkg/action/get_values.go | 37 + pkg/helm/pkg/action/history.go | 10 +- pkg/helm/pkg/action/history_test.go | 108 + pkg/helm/pkg/action/install.go | 175 +- pkg/helm/pkg/action/list.go | 42 +- pkg/helm/pkg/action/list_test.go | 116 +- pkg/helm/pkg/action/package.go | 127 +- pkg/helm/pkg/action/package_test.go | 58 +- pkg/helm/pkg/action/pull.go | 41 +- pkg/helm/pkg/action/pull_test.go | 80 + pkg/helm/pkg/action/push.go | 13 +- pkg/helm/pkg/action/push_test.go | 66 + pkg/helm/pkg/action/registry_login.go | 25 +- pkg/helm/pkg/action/registry_login_test.go | 84 + pkg/helm/pkg/action/registry_logout_test.go | 31 + pkg/helm/pkg/action/show.go | 157 ++ pkg/helm/pkg/action/show_test.go | 182 ++ pkg/helm/pkg/action/status.go | 83 + pkg/helm/pkg/action/status_test.go | 143 ++ .../chart-missing-deps/requirements.yaml | 12 +- .../requirements.yaml | 12 +- .../chart-with-schema-negative/Chart.yaml | 2 +- .../charts/chart-with-schema/Chart.yaml | 2 +- .../Chart.yaml | 20 +- .../charts/mariadb/Chart.yaml | 18 +- .../charts/mariadb/values.yaml | 4 +- .../requirements.yaml | 12 +- .../values.yaml | 42 +- .../charts/decompressedchart/values.yaml | 2 +- .../multiplecharts-lint-chart-1/Chart.yaml | 2 +- .../multiplecharts-lint-chart-1/values.yaml | 2 +- .../multiplecharts-lint-chart-2/Chart.yaml | 2 +- .../multiplecharts-lint-chart-2/values.yaml | 2 +- pkg/helm/pkg/action/uninstall.go | 45 + pkg/helm/pkg/action/validate.go | 60 - pkg/helm/pkg/action/verify.go | 20 +- pkg/helm/pkg/action/verify_test.go | 48 + pkg/helm/pkg/chart/common.go | 243 ++ pkg/helm/pkg/chart/common/capabilities.go | 182 ++ .../common}/capabilities_test.go | 53 +- .../pkg/{chartutil => chart/common}/errors.go | 2 +- .../common}/errors_test.go | 2 +- pkg/helm/pkg/chart/{ => common}/file.go | 6 +- pkg/helm/pkg/chart/common/runtime_data.go | 22 + .../pkg/chart/common/testdata/coleridge.yaml | 12 + .../common/util}/coalesce.go | 150 +- .../common/util}/coalesce_test.go | 103 +- pkg/helm/pkg/chart/common/util/jsonschema.go | 216 ++ .../pkg/chart/common/util/jsonschema_test.go | 391 ++++ .../testdata/test-values-invalid.schema.json | 1 + .../util/testdata/test-values-negative.yaml} | 0 .../util/testdata/test-values.schema.json} | 0 .../common/util/testdata/test-values.yaml} | 0 pkg/helm/pkg/chart/common/util/values.go | 70 + pkg/helm/pkg/chart/common/util/values_test.go | 112 + .../pkg/{chartutil => chart/common}/values.go | 52 +- .../common}/values_test.go | 91 +- pkg/helm/pkg/chart/dependency.go | 102 +- pkg/helm/pkg/chart/interfaces.go | 44 + .../pkg/chart/loader/{ => archive}/archive.go | 159 +- .../loader/{ => archive}/archive_test.go | 8 +- pkg/helm/pkg/chart/loader/directory.go | 215 -- pkg/helm/pkg/chart/loader/load.go | 456 ++-- .../pkg/chart/loader/load_dependencies.go | 157 +- pkg/helm/pkg/chart/loader/load_test.go | 793 +------ pkg/helm/pkg/chart/{ => v2}/chart.go | 35 +- pkg/helm/pkg/chart/{ => v2}/chart_test.go | 84 +- pkg/helm/pkg/chart/v2/dependency.go | 83 + pkg/helm/pkg/chart/v2/dependency_test.go | 44 + pkg/helm/pkg/chart/v2/doc.go | 23 + pkg/helm/pkg/chart/v2/errors.go | 30 + pkg/helm/pkg/chart/v2/fuzz_test.go | 48 + pkg/helm/pkg/chart/v2/lint/lint.go | 71 + pkg/helm/pkg/chart/v2/lint/lint_test.go | 247 +++ pkg/helm/pkg/chart/v2/lint/rules/chartfile.go | 236 ++ .../pkg/chart/v2/lint/rules/chartfile_test.go | 319 +++ pkg/helm/pkg/chart/v2/lint/rules/crds.go | 116 + pkg/helm/pkg/chart/v2/lint/rules/crds_test.go | 66 + .../pkg/chart/v2/lint/rules/dependencies.go | 102 + .../chart/v2/lint/rules/dependencies_test.go | 157 ++ .../pkg/chart/v2/lint/rules/deprecations.go | 94 + .../chart/v2/lint/rules/deprecations_test.go | 41 + pkg/helm/pkg/chart/v2/lint/rules/template.go | 385 ++++ .../pkg/chart/v2/lint/rules/template_test.go | 490 ++++ .../lint/rules/testdata/albatross/Chart.yaml | 5 + .../testdata/albatross/templates/_helpers.tpl | 16 + .../testdata/albatross/templates/fail.yaml | 1 + .../testdata/albatross/templates/svc.yaml | 19 + .../lint/rules/testdata/albatross/values.yaml | 1 + .../testdata/anotherbadchartfile/Chart.yaml | 15 + .../rules/testdata/badchartfile/Chart.yaml | 11 + .../rules/testdata/badchartfile/values.yaml | 1 + .../rules/testdata/badchartname/Chart.yaml | 5 + .../rules/testdata/badchartname/values.yaml | 1 + .../lint/rules/testdata/badcrdfile/Chart.yaml | 6 + .../badcrdfile/crds/bad-apiversion.yaml | 2 + .../testdata/badcrdfile/crds/bad-crd.yaml | 2 + .../testdata/badcrdfile/templates/.gitkeep} | 0 .../rules/testdata/badcrdfile/values.yaml | 1 + .../rules/testdata/badvaluesfile/Chart.yaml | 6 + .../templates/badvaluesfile.yaml | 2 + .../rules/testdata/badvaluesfile/values.yaml | 2 + .../v2/lint/rules/testdata/goodone/Chart.yaml | 5 + .../rules/testdata/goodone/crds/test-crd.yaml | 19 + .../testdata/goodone/templates/goodone.yaml | 2 + .../lint/rules/testdata/goodone/values.yaml | 1 + .../testdata/invalidchartfile/Chart.yaml | 6 + .../testdata/invalidchartfile}/values.yaml | 0 .../rules/testdata/invalidcrdsdir/Chart.yaml | 6 + .../lint/rules/testdata/invalidcrdsdir/crds} | 0 .../rules/testdata/invalidcrdsdir/values.yaml | 1 + .../testdata/malformed-template/.helmignore | 23 + .../testdata/malformed-template/Chart.yaml | 25 + .../malformed-template/templates/bad.yaml | 1 + .../testdata/malformed-template/values.yaml | 82 + .../testdata/multi-template-fail/Chart.yaml | 21 + .../templates/multi-fail.yaml | 13 + .../v2/lint/rules/testdata/v3-fail/Chart.yaml | 21 + .../testdata/v3-fail/templates/_helpers.tpl | 63 + .../v3-fail/templates/deployment.yaml | 56 + .../testdata/v3-fail/templates/ingress.yaml | 62 + .../testdata/v3-fail/templates/service.yaml | 17 + .../lint/rules/testdata/v3-fail/values.yaml | 66 + .../rules/testdata/withsubchart/Chart.yaml | 16 + .../withsubchart/charts/subchart/Chart.yaml | 6 + .../charts/subchart/templates/subchart.yaml | 2 + .../withsubchart/charts/subchart/values.yaml | 2 + .../withsubchart/templates/mainchart.yaml | 2 + .../rules/testdata/withsubchart}/values.yaml | 0 pkg/helm/pkg/chart/v2/lint/rules/values.go | 84 + .../pkg/chart/v2/lint/rules/values_test.go | 183 ++ pkg/helm/pkg/chart/v2/lint/support/doc.go | 23 + pkg/helm/pkg/chart/v2/lint/support/message.go | 76 + .../pkg/chart/v2/lint/support/message_test.go | 79 + pkg/helm/pkg/chart/v2/loader/archive.go | 76 + .../v2/loader}/chart_metadata.go | 8 +- pkg/helm/pkg/chart/v2/loader/directory.go | 159 ++ pkg/helm/pkg/chart/v2/loader/load.go | 382 ++++ pkg/helm/pkg/chart/v2/loader/load_test.go | 780 +++++++ .../v2/loader/testdata}/LICENSE | 0 .../v2/loader/testdata/albatross/Chart.yaml | 4 + .../v2/loader/testdata/albatross/values.yaml | 4 + .../loader/testdata/frobnitz-1.2.3.tgz | Bin .../{ => v2}/loader/testdata/frobnitz.v1.tgz | Bin .../loader/testdata/frobnitz.v1}/.helmignore | 0 .../loader/testdata/frobnitz.v1}/Chart.lock | 0 .../loader/testdata/frobnitz.v1/Chart.yaml | 0 .../loader/testdata/frobnitz.v1}/INSTALL.txt | 0 .../v2/loader/testdata/frobnitz.v1/LICENSE | 1 + .../v2/loader/testdata/frobnitz.v1}/README.md | 0 .../testdata/frobnitz.v1}/charts/_ignore_me | 0 .../frobnitz.v1/charts/alpine/Chart.yaml | 0 .../frobnitz.v1}/charts/alpine/README.md | 0 .../charts/alpine/charts/mast1/Chart.yaml | 0 .../charts/alpine/charts/mast1/values.yaml | 0 .../charts/alpine/charts/mast2-0.1.0.tgz | Bin .../charts/alpine/templates/alpine-pod.yaml | 0 .../frobnitz.v1}/charts/alpine/values.yaml | 0 .../frobnitz.v1}/charts/mariner-4.3.2.tgz | Bin .../testdata/frobnitz.v1}/docs/README.md | 0 .../v2/loader/testdata/frobnitz.v1}/icon.svg | 0 .../testdata/frobnitz.v1}/ignore/me.txt | 0 .../testdata/frobnitz.v1/requirements.yaml | 0 .../frobnitz.v1}/templates/template.tpl | 0 .../loader/testdata/frobnitz.v1}/values.yaml | 0 .../testdata/frobnitz.v2.reqs/.helmignore | 1 + .../testdata/frobnitz.v2.reqs/Chart.yaml | 0 .../testdata/frobnitz.v2.reqs/INSTALL.txt | 1 + .../loader/testdata/frobnitz.v2.reqs/LICENSE | 1 + .../testdata/frobnitz.v2.reqs/README.md | 11 + .../frobnitz.v2.reqs/charts/_ignore_me | 1 + .../frobnitz.v2.reqs/charts/alpine/Chart.yaml | 0 .../frobnitz.v2.reqs/charts/alpine/README.md | 9 + .../charts/alpine/charts/mast1/Chart.yaml | 0 .../charts/alpine/charts/mast1/values.yaml | 4 + .../charts/alpine/charts/mast2-0.1.0.tgz | Bin 0 -> 252 bytes .../charts/alpine/templates/alpine-pod.yaml | 14 + .../charts/alpine/values.yaml | 2 + .../charts/mariner-4.3.2.tgz | Bin .../testdata/frobnitz.v2.reqs/docs/README.md | 1 + .../loader/testdata/frobnitz.v2.reqs/icon.svg | 8 + .../testdata/frobnitz.v2.reqs/ignore/me.txt} | 0 .../frobnitz.v2.reqs/requirements.yaml | 0 .../frobnitz.v2.reqs/templates/template.tpl | 1 + .../testdata/frobnitz.v2.reqs/values.yaml | 6 + .../v2/loader/testdata/frobnitz/.helmignore | 1 + .../v2/loader/testdata/frobnitz/Chart.lock | 8 + .../loader/testdata/frobnitz/Chart.yaml | 0 .../v2/loader/testdata/frobnitz/INSTALL.txt | 1 + .../chart/v2/loader/testdata/frobnitz/LICENSE | 1 + .../v2/loader/testdata/frobnitz/README.md | 11 + .../testdata/frobnitz/charts/_ignore_me | 1 + .../frobnitz/charts/alpine/Chart.yaml | 0 .../testdata/frobnitz/charts/alpine/README.md | 9 + .../charts/alpine/charts/mast1/Chart.yaml | 0 .../charts/alpine/charts/mast1/values.yaml | 4 + .../charts/alpine/charts/mast2-0.1.0.tgz | Bin 0 -> 252 bytes .../charts/alpine/templates/alpine-pod.yaml | 14 + .../frobnitz/charts/alpine/values.yaml | 2 + .../frobnitz}/charts/mariner-4.3.2.tgz | Bin .../loader/testdata/frobnitz/docs/README.md | 1 + .../v2/loader/testdata/frobnitz/icon.svg | 8 + .../loader/testdata/frobnitz/ignore/me.txt} | 0 .../testdata/frobnitz/templates/template.tpl | 1 + .../v2/loader/testdata/frobnitz/values.yaml | 6 + .../testdata/frobnitz_backslash-1.2.3.tgz | Bin .../testdata/frobnitz_backslash/.helmignore | 1 + .../testdata/frobnitz_backslash/Chart.lock | 8 + .../testdata/frobnitz_backslash/Chart.yaml | 0 .../testdata/frobnitz_backslash/INSTALL.txt | 1 + .../testdata/frobnitz_backslash/LICENSE | 1 + .../testdata/frobnitz_backslash/README.md | 11 + .../frobnitz_backslash/charts/_ignore_me | 1 + .../charts/alpine/Chart.yaml | 0 .../charts/alpine/README.md | 9 + .../charts/alpine/charts/mast1/Chart.yaml | 0 .../charts/alpine/charts/mast1/values.yaml | 4 + .../charts/alpine/charts/mast2-0.1.0.tgz | Bin 0 -> 252 bytes .../charts/alpine/templates/alpine-pod.yaml | 14 + .../charts/alpine/values.yaml | 2 + .../charts/mariner-4.3.2.tgz | Bin .../frobnitz_backslash/docs/README.md | 1 + .../testdata/frobnitz_backslash/icon.svg | 8 + .../frobnitz_backslash/ignore/me.txt} | 0 .../frobnitz_backslash/templates/template.tpl | 1 + .../testdata/frobnitz_backslash/values.yaml | 6 + .../loader/testdata/frobnitz_with_bom.tgz | Bin .../testdata/frobnitz_with_bom/.helmignore | 1 + .../testdata/frobnitz_with_bom/Chart.lock | 8 + .../testdata/frobnitz_with_bom/Chart.yaml | 0 .../testdata/frobnitz_with_bom/INSTALL.txt | 1 + .../loader/testdata/frobnitz_with_bom/LICENSE | 1 + .../testdata/frobnitz_with_bom/README.md | 11 + .../frobnitz_with_bom/charts/_ignore_me | 1 + .../charts/alpine/Chart.yaml | 0 .../frobnitz_with_bom/charts/alpine/README.md | 9 + .../charts/alpine/charts/mast1/Chart.yaml | 0 .../charts/alpine/charts/mast1/values.yaml | 4 + .../charts/alpine/charts/mast2-0.1.0.tgz | Bin 0 -> 252 bytes .../charts/alpine/templates/alpine-pod.yaml | 14 + .../charts/alpine/values.yaml | 2 + .../charts/mariner-4.3.2.tgz | Bin .../testdata/frobnitz_with_bom/docs/README.md | 1 + .../testdata/frobnitz_with_bom/icon.svg | 8 + .../testdata/frobnitz_with_bom/ignore/me.txt} | 0 .../frobnitz_with_bom/templates/template.tpl | 1 + .../testdata/frobnitz_with_bom/values.yaml | 6 + .../frobnitz_with_dev_null/.helmignore | 1 + .../frobnitz_with_dev_null/Chart.lock | 8 + .../frobnitz_with_dev_null/Chart.yaml | 0 .../frobnitz_with_dev_null/INSTALL.txt | 1 + .../testdata/frobnitz_with_dev_null/LICENSE | 1 + .../testdata/frobnitz_with_dev_null/README.md | 11 + .../frobnitz_with_dev_null/charts/_ignore_me | 1 + .../charts/alpine/Chart.yaml | 0 .../charts/alpine/README.md | 9 + .../charts/alpine/charts/mast1/Chart.yaml | 0 .../charts/alpine/charts/mast1/values.yaml | 4 + .../charts/alpine/charts/mast2-0.1.0.tgz | Bin 0 -> 252 bytes .../charts/alpine/templates/alpine-pod.yaml | 14 + .../charts/alpine/values.yaml | 2 + .../charts/mariner-4.3.2.tgz | Bin .../frobnitz_with_dev_null/docs/README.md | 1 + .../testdata/frobnitz_with_dev_null/icon.svg | 8 + .../frobnitz_with_dev_null/ignore/me.txt | 0 .../testdata/frobnitz_with_dev_null/null | 1 + .../templates/template.tpl | 1 + .../frobnitz_with_dev_null/values.yaml | 6 + .../frobnitz_with_symlink/.helmignore | 1 + .../testdata/frobnitz_with_symlink/Chart.lock | 8 + .../testdata/frobnitz_with_symlink/Chart.yaml | 0 .../frobnitz_with_symlink/INSTALL.txt | 1 + .../testdata/frobnitz_with_symlink/README.md | 11 + .../frobnitz_with_symlink/charts/_ignore_me | 1 + .../charts/alpine/Chart.yaml | 0 .../charts/alpine/README.md | 9 + .../charts/alpine/charts/mast1/Chart.yaml | 0 .../charts/alpine/charts/mast1/values.yaml | 4 + .../charts/alpine/charts/mast2-0.1.0.tgz | Bin 0 -> 252 bytes .../charts/alpine/templates/alpine-pod.yaml | 14 + .../charts/alpine/values.yaml | 2 + .../charts/mariner-4.3.2.tgz | Bin 0 -> 967 bytes .../frobnitz_with_symlink/docs/README.md | 1 + .../testdata/frobnitz_with_symlink/icon.svg | 8 + .../frobnitz_with_symlink/ignore/me.txt | 0 .../templates/template.tpl | 1 + .../frobnitz_with_symlink/values.yaml | 6 + .../v2/loader}/testdata/genfrob.sh | 0 .../loader/testdata/mariner/Chart.yaml | 0 .../mariner/charts/albatross-0.1.0.tgz | Bin .../mariner/templates/placeholder.tpl | 1 + .../v2/loader/testdata/mariner/values.yaml | 7 + pkg/helm/pkg/chart/v2/metadata.go | 178 ++ pkg/helm/pkg/chart/{ => v2}/metadata_test.go | 2 +- .../{chartutil => chart/v2/util}/chartfile.go | 31 +- .../v2/util}/chartfile_test.go | 6 +- pkg/helm/pkg/chart/v2/util/compatible.go | 34 + pkg/helm/pkg/chart/v2/util/compatible_test.go | 43 + .../{chartutil => chart/v2/util}/create.go | 232 +- .../v2/util}/create_test.go | 11 +- .../v2/util}/dependencies.go | 565 ++--- .../pkg/chart/v2/util/dependencies_test.go | 571 +++++ .../chart/v2/util/deps_migration_ai_test.go | 57 + .../pkg/{chartutil => chart/v2/util}/doc.go | 6 +- .../{chartutil => chart/v2/util}/expand.go | 16 +- pkg/helm/pkg/chart/v2/util/expand_test.go | 124 ++ .../pkg/{chartutil => chart/v2/util}/save.go | 60 +- .../v2/util}/save_extended.go | 4 +- .../{chartutil => chart/v2/util}/save_test.go | 136 +- .../Chart.yaml | 14 + .../charts/child/Chart.yaml | 6 + .../charts/child/charts/grandchild/Chart.yaml | 6 + .../charts/grandchild/templates/dummy.yaml | 7 + .../charts/child/templates/dummy.yaml | 7 + .../values.yaml | 7 + .../Chart.yaml | 20 + .../charts/child/Chart.yaml | 12 + .../charts/child/charts/grandchild/Chart.yaml | 6 + .../child/charts/grandchild/values.yaml | 2 + .../charts/child/templates/dummy.yaml | 7 + .../templates/dummy.yaml | 7 + .../v2/util}/testdata/chartfiletest.yaml | 0 .../dependent-chart-alias/.helmignore | 1 + .../testdata/dependent-chart-alias/Chart.lock | 8 + .../testdata/dependent-chart-alias/Chart.yaml | 0 .../dependent-chart-alias/INSTALL.txt | 1 + .../testdata/dependent-chart-alias/LICENSE | 1 + .../testdata/dependent-chart-alias/README.md | 11 + .../dependent-chart-alias/charts/_ignore_me | 1 + .../charts/alpine/Chart.yaml | 0 .../charts/alpine/README.md | 9 + .../charts/alpine/charts/mast1/Chart.yaml | 0 .../charts/alpine/charts/mast1/values.yaml | 4 + .../charts/alpine/charts/mast2-0.1.0.tgz | Bin 0 -> 252 bytes .../charts/alpine/templates/alpine-pod.yaml | 0 .../charts/alpine/values.yaml | 2 + .../charts/mariner-4.3.2.tgz | Bin 0 -> 967 bytes .../dependent-chart-alias/docs/README.md | 1 + .../testdata/dependent-chart-alias/icon.svg | 8 + .../dependent-chart-alias/ignore/me.txt | 0 .../templates/template.tpl | 1 + .../dependent-chart-alias/values.yaml | 6 + .../dependent-chart-helmignore/.helmignore | 2 + .../dependent-chart-helmignore/Chart.yaml | 0 .../charts/.ignore_me | 0 .../charts/_ignore_me | 1 + .../charts/alpine/Chart.yaml | 0 .../charts/alpine/README.md | 9 + .../charts/alpine/charts/mast1/Chart.yaml | 0 .../charts/alpine/charts/mast1/values.yaml | 4 + .../charts/alpine/charts/mast2-0.1.0.tgz | Bin 0 -> 252 bytes .../charts/alpine/templates/alpine-pod.yaml | 14 + .../charts/alpine/values.yaml | 2 + .../templates/template.tpl | 1 + .../dependent-chart-helmignore/values.yaml | 6 + .../.helmignore | 1 + .../Chart.yaml | 0 .../INSTALL.txt | 1 + .../LICENSE | 1 + .../README.md | 11 + .../charts/_ignore_me | 1 + .../charts/alpine/Chart.yaml | 0 .../charts/alpine/README.md | 9 + .../charts/alpine/charts/mast1/Chart.yaml | 0 .../charts/alpine/charts/mast1/values.yaml | 4 + .../charts/alpine/charts/mast2-0.1.0.tgz | Bin 0 -> 252 bytes .../charts/alpine/templates/alpine-pod.yaml | 14 + .../charts/alpine/values.yaml | 2 + .../charts/mariner-4.3.2.tgz | Bin 0 -> 967 bytes .../docs/README.md | 1 + .../icon.svg | 8 + .../ignore/me.txt | 0 .../templates/template.tpl | 1 + .../values.yaml | 6 + .../.helmignore | 1 + .../Chart.yaml | 0 .../INSTALL.txt | 1 + .../LICENSE | 1 + .../README.md | 11 + .../charts/_ignore_me | 1 + .../charts/alpine/Chart.yaml | 0 .../charts/alpine/README.md | 9 + .../charts/alpine/charts/mast1/Chart.yaml | 0 .../charts/alpine/charts/mast1/values.yaml | 4 + .../charts/alpine/charts/mast2-0.1.0.tgz | Bin 0 -> 252 bytes .../charts/alpine/templates/alpine-pod.yaml | 14 + .../charts/alpine/values.yaml | 2 + .../charts/mariner-4.3.2.tgz | Bin 0 -> 967 bytes .../docs/README.md | 1 + .../icon.svg | 8 + .../ignore/me.txt | 0 .../templates/template.tpl | 1 + .../values.yaml | 6 + .../.helmignore | 1 + .../Chart.yaml | 0 .../INSTALL.txt | 1 + .../LICENSE | 1 + .../README.md | 11 + .../charts/_ignore_me | 1 + .../charts/alpine/Chart.yaml | 0 .../charts/alpine/README.md | 9 + .../charts/alpine/charts/mast1/Chart.yaml | 0 .../charts/alpine/charts/mast1/values.yaml | 4 + .../charts/alpine/charts/mast2-0.1.0.tgz | Bin 0 -> 252 bytes .../charts/alpine/templates/alpine-pod.yaml | 14 + .../charts/alpine/values.yaml | 2 + .../charts/mariner-4.3.2.tgz | Bin 0 -> 967 bytes .../docs/README.md | 1 + .../icon.svg | 8 + .../ignore/me.txt | 0 .../templates/template.tpl | 1 + .../values.yaml | 6 + .../v2/util/testdata}/frobnitz-1.2.3.tgz | Bin .../v2/util/testdata/frobnitz/.helmignore | 1 + .../v2/util/testdata/frobnitz/Chart.lock | 8 + .../v2/util}/testdata/frobnitz/Chart.yaml | 0 .../v2/util/testdata/frobnitz/INSTALL.txt | 1 + .../chart/v2/util/testdata/frobnitz/LICENSE | 1 + .../chart/v2/util/testdata/frobnitz/README.md | 11 + .../util/testdata/frobnitz/charts/_ignore_me | 1 + .../frobnitz/charts/alpine/Chart.yaml | 0 .../testdata/frobnitz/charts/alpine/README.md | 9 + .../charts/alpine/charts/mast1/Chart.yaml | 0 .../charts/alpine/charts/mast1/values.yaml | 4 + .../charts/alpine/charts/mast2-0.1.0.tgz | Bin 0 -> 252 bytes .../charts/alpine/templates/alpine-pod.yaml | 14 + .../frobnitz/charts/alpine/values.yaml | 2 + .../frobnitz/charts/mariner/Chart.yaml | 0 .../mariner/charts/albatross/Chart.yaml | 0 .../mariner/charts/albatross/values.yaml | 4 + .../charts/mariner/templates/placeholder.tpl | 1 + .../frobnitz/charts/mariner/values.yaml | 7 + .../v2/util/testdata/frobnitz/docs/README.md | 1 + .../chart/v2/util/testdata/frobnitz/icon.svg | 8 + .../v2/util/testdata/frobnitz/ignore/me.txt | 0 .../testdata/frobnitz/templates/template.tpl | 1 + .../v2/util/testdata/frobnitz/values.yaml | 6 + .../testdata/frobnitz_backslash-1.2.3.tgz | Bin 0 -> 3496 bytes .../pkg/chart/v2/util/testdata/genfrob.sh | 14 + .../parent-chart/Chart.lock | 9 + .../parent-chart/Chart.yaml | 2 +- .../parent-chart/charts/dev-v0.1.0.tgz | Bin 0 -> 333 bytes .../parent-chart/charts/prod-v0.1.0.tgz | Bin 0 -> 336 bytes .../parent-chart/envs/dev/Chart.yaml | 2 +- .../parent-chart/envs/dev/values.yaml | 9 + .../parent-chart/envs/prod/Chart.yaml | 2 +- .../parent-chart/envs/prod/values.yaml | 9 + .../parent-chart/templates/autoscaler.yaml | 16 + .../parent-chart/values.yaml | 10 + .../v2/util}/testdata/joonix/Chart.yaml | 0 .../v2/util/testdata/joonix/charts/.gitkeep | 0 .../v2/util}/testdata/subpop/Chart.yaml | 10 - .../chart/v2/util/testdata/subpop/README.md | 18 + .../subpop/charts/subchart1/Chart.yaml | 4 - .../subchart1}/charts/subchartA/Chart.yaml | 0 .../charts/subchartA}/templates/service.yaml | 0 .../subchart1/charts/subchartA/values.yaml | 12 - .../subchart1}/charts/subchartB/Chart.yaml | 0 .../charts/subchartB}/templates/service.yaml | 0 .../subchart1/charts/subchartB/values.yaml | 35 + .../subpop/charts/subchart1/crds/crdA.yaml | 13 + .../charts/subchart1/templates/NOTES.txt | 0 .../charts/subchart1/templates/service.yaml | 0 .../subchart1/templates/subdir/role.yaml | 7 + .../templates/subdir/rolebinding.yaml | 0 .../templates/subdir/serviceaccount.yaml | 0 .../subpop/charts/subchart1/values.yaml | 55 + .../subpop/charts/subchart2/Chart.yaml | 0 .../subchart2}/charts/subchartB/Chart.yaml | 0 .../charts/subchartB/templates/service.yaml | 15 + .../subchart2/charts/subchartB/values.yaml | 21 + .../subchart2/charts/subchartC/Chart.yaml | 0 .../charts/subchartC/templates/service.yaml | 15 + .../subchart2/charts/subchartC/values.yaml | 21 + .../charts/subchart2/templates/service.yaml | 15 + .../subpop/charts/subchart2/values.yaml | 21 + .../util}/testdata/subpop/noreqs/Chart.yaml | 0 .../subpop/noreqs/templates/service.yaml | 15 + .../util/testdata/subpop/noreqs/values.yaml | 26 + .../chart/v2/util/testdata/subpop/values.yaml | 45 + .../three-level-dependent-chart/README.md | 16 + .../umbrella/Chart.yaml | 19 + .../umbrella/charts/app1/Chart.yaml | 8 +- .../charts/app1/charts/library/Chart.yaml | 0 .../charts/library/templates/service.yaml | 9 + .../charts/app1/charts/library/values.yaml | 5 + .../charts/app1/templates/service.yaml | 1 + .../umbrella/charts/app1/values.yaml | 3 + .../umbrella/charts/app2/Chart.yaml | 8 +- .../charts/app2/charts/library/Chart.yaml | 0 .../charts/library/templates/service.yaml | 9 + .../charts/app2/charts/library/values.yaml | 5 + .../charts/app2/templates/service.yaml | 1 + .../umbrella/charts/app2/values.yaml | 3 + .../umbrella/charts/app3/Chart.yaml | 8 +- .../charts/app3/charts/library/Chart.yaml | 0 .../charts/library/templates/service.yaml | 9 + .../charts/app3/charts/library/values.yaml | 5 + .../charts/app3/templates/service.yaml | 1 + .../umbrella/charts/app3/values.yaml | 2 + .../umbrella/charts/app4/Chart.yaml | 4 +- .../charts/app4/charts/library/Chart.yaml | 0 .../charts/library/templates/service.yaml | 9 + .../charts/app4/charts/library/values.yaml | 5 + .../charts/app4/templates/service.yaml | 1 + .../umbrella/charts/app4/values.yaml | 3 + .../umbrella/values.yaml | 14 + pkg/helm/pkg/chart/v2/util/validate_name.go | 111 + .../pkg/chart/v2/util/validate_name_test.go | 91 + pkg/helm/pkg/chartutil/capabilities.go | 126 -- pkg/helm/pkg/chartutil/exports.go | 15 - pkg/helm/pkg/chartutil/jsonschema.go | 93 - pkg/helm/pkg/chartutil/jsonschema_test.go | 167 -- .../umbrella/Chart.yaml | 19 - pkg/helm/pkg/cli/environment.go | 61 +- pkg/helm/pkg/cli/environment_test.go | 18 +- pkg/helm/pkg/cli/exports.go | 13 - pkg/helm/pkg/cli/output/output.go | 11 +- pkg/helm/pkg/cli/values/options.go | 87 +- pkg/helm/pkg/cli/values/options_test.go | 386 +++- pkg/helm/{cmd/helm => pkg/cmd}/completion.go | 34 +- .../{cmd/helm => pkg/cmd}/completion_test.go | 11 +- pkg/helm/pkg/cmd/create.go | 113 + pkg/helm/pkg/cmd/create_test.go | 193 ++ pkg/helm/{cmd/helm => pkg/cmd}/dependency.go | 25 +- .../{cmd/helm => pkg/cmd}/dependency_build.go | 33 +- .../helm => pkg/cmd}/dependency_build_test.go | 22 +- .../{cmd/helm => pkg/cmd}/dependency_test.go | 2 +- .../helm => pkg/cmd}/dependency_update.go | 34 +- .../cmd}/dependency_update_test.go | 65 +- pkg/helm/pkg/cmd/env.go | 76 + pkg/helm/pkg/cmd/env_test.go | 35 + pkg/helm/{cmd/helm => pkg/cmd}/flags.go | 150 +- pkg/helm/pkg/cmd/helpers.go | 83 + .../helm_test.go => pkg/cmd/helpers_test.go} | 154 +- pkg/helm/{cmd/helm => pkg/cmd}/history.go | 131 +- pkg/helm/pkg/cmd/history_test.go | 333 +++ pkg/helm/pkg/cmd/list.go | 289 +++ pkg/helm/pkg/cmd/list_test.go | 617 ++++++ pkg/helm/{cmd/helm => pkg/cmd}/package.go | 46 +- .../{cmd/helm => pkg/cmd}/package_test.go | 16 +- pkg/helm/pkg/cmd/printer.go | 30 + pkg/helm/{cmd/helm => pkg/cmd}/pull.go | 19 +- pkg/helm/{cmd/helm => pkg/cmd}/pull_test.go | 334 ++- pkg/helm/{cmd/helm => pkg/cmd}/push.go | 35 +- pkg/helm/{cmd/helm => pkg/cmd}/push_test.go | 2 +- pkg/helm/{cmd/helm => pkg/cmd}/registry.go | 2 +- .../{cmd/helm => pkg/cmd}/registry_login.go | 20 +- .../helm => pkg/cmd}/registry_login_test.go | 2 +- .../{cmd/helm => pkg/cmd}/registry_logout.go | 8 +- .../helm => pkg/cmd}/registry_logout_test.go | 2 +- pkg/helm/{cmd/helm => pkg/cmd}/repo.go | 12 +- pkg/helm/{cmd/helm => pkg/cmd}/repo_add.go | 49 +- .../{cmd/helm => pkg/cmd}/repo_add_test.go | 97 +- pkg/helm/pkg/cmd/repo_index.go | 122 + pkg/helm/pkg/cmd/repo_index_test.go | 194 ++ pkg/helm/pkg/cmd/repo_list.go | 149 ++ pkg/helm/pkg/cmd/repo_list_test.go | 60 + pkg/helm/{cmd/helm => pkg/cmd}/repo_remove.go | 19 +- .../{cmd/helm => pkg/cmd}/repo_remove_test.go | 24 +- pkg/helm/{cmd/helm => pkg/cmd}/repo_test.go | 2 +- pkg/helm/{cmd/helm => pkg/cmd}/repo_update.go | 71 +- .../{cmd/helm => pkg/cmd}/repo_update_test.go | 76 +- .../{cmd/helm => pkg/cmd}/require/args.go | 11 +- .../helm => pkg/cmd}/require/args_test.go | 10 +- pkg/helm/{cmd/helm => pkg/cmd}/root.go | 271 ++- pkg/helm/{cmd/helm => pkg/cmd}/root_test.go | 26 +- pkg/helm/{cmd/helm => pkg/cmd}/search.go | 2 +- .../{cmd/helm => pkg/cmd}/search/search.go | 2 +- .../helm => pkg/cmd}/search/search_test.go | 8 +- pkg/helm/{cmd/helm => pkg/cmd}/search_hub.go | 19 +- .../{cmd/helm => pkg/cmd}/search_hub_test.go | 8 +- pkg/helm/{cmd/helm => pkg/cmd}/search_repo.go | 34 +- .../{cmd/helm => pkg/cmd}/search_repo_test.go | 2 +- pkg/helm/{cmd/helm => pkg/cmd}/search_test.go | 2 +- pkg/helm/pkg/cmd/show.go | 236 ++ pkg/helm/pkg/cmd/show_test.go | 158 ++ pkg/helm/pkg/cmd/status.go | 257 +++ pkg/helm/pkg/cmd/status_test.go | 220 ++ .../helm/plugins/fullenv/completion.yaml | 19 + .../helm/plugins/fullenv/fullenv.sh | 0 .../helm/plugins/fullenv/plugin.yaml | 12 + .../helm/repositories.yaml | 0 .../helm/repository/test-name-charts.txt | 0 .../helm/repository/test-name-index.yaml | 0 .../helm/repository/testing-index.yaml | 0 .../cmd}/testdata/helm-test-key.pub | Bin .../cmd}/testdata/helm-test-key.secret | Bin .../helmhome/helm/plugins/args/args.sh | 2 + .../helm/plugins/args/plugin.complete | 0 .../helmhome/helm/plugins/args/plugin.yaml | 11 + .../helm/plugins/echo/completion.yaml | 0 .../helm/plugins/echo/plugin.complete | 0 .../helmhome/helm/plugins/echo/plugin.yaml | 11 + .../helm/plugins/exitwith/completion.yaml | 4 +- .../helm/plugins/exitwith/exitwith.sh | 2 + .../helm/plugins/exitwith/plugin.yaml | 12 + .../helm/plugins/fullenv/completion.yaml | 19 + .../helmhome/helm/plugins/fullenv/fullenv.sh | 7 + .../helmhome/helm/plugins/fullenv/plugin.yaml | 12 + .../helm/plugins/postrenderer-v1/plugin.yaml | 9 + .../helm/plugins/postrenderer-v1/sed-test.sh | 6 + .../helm/plugins/shortenv/completion.yaml | 13 + .../helm/plugins/shortenv/plugin-name.sh | 3 + .../helm/plugins/shortenv/plugin.yaml | 12 + .../testdata/helmhome/helm/repositories.yaml | 6 + .../helm/repository/test-name-charts.txt | 0 .../helm/repository/test-name-index.yaml | 0 .../helm/repository/testing-index.yaml | 0 .../output/chart-with-subchart-update.txt | 3 +- .../output/dependency-list-archive.txt | 0 .../output/dependency-list-no-chart-linux.txt | 0 .../dependency-list-no-requirements-linux.txt | 0 .../cmd}/testdata/output/dependency-list.txt | 0 .../cmd}/testdata/output/deprecated-chart.txt | 3 +- .../cmd}/testdata/output/docs-type-comp.txt | 0 .../testdata/output/empty_default_comp.txt | 0 .../cmd/testdata/output/empty_nofile_comp.txt | 3 + .../cmd}/testdata/output/env-comp.txt | 1 + .../cmd}/testdata/output/get-all-no-args.txt | 0 .../testdata/output/get-hooks-no-args.txt | 0 .../cmd}/testdata/output/get-hooks.txt | 0 .../testdata/output/get-manifest-no-args.txt | 0 .../cmd}/testdata/output/get-manifest.txt | 0 .../testdata/output/get-metadata-args.txt | 0 .../pkg/cmd/testdata/output/get-metadata.json | 1 + .../cmd}/testdata/output/get-metadata.txt | 4 + .../pkg/cmd/testdata/output/get-metadata.yaml | 23 + .../testdata/output/get-notes-no-args.txt | 0 .../cmd}/testdata/output/get-notes.txt | 0 .../testdata/output/get-release-template.txt | 0 .../cmd}/testdata/output/get-release.txt | 1 + .../cmd}/testdata/output/get-values-all.txt | 0 .../cmd}/testdata/output/get-values-args.txt | 0 .../cmd}/testdata/output/get-values.txt | 0 .../cmd}/testdata/output/history-limit.txt | 0 .../cmd}/testdata/output/history.json | 0 .../cmd}/testdata/output/history.txt | 0 .../cmd}/testdata/output/history.yaml | 0 .../testdata/output/install-and-replace.txt | 3 +- .../output/install-and-take-ownership.txt | 7 + .../output/install-chart-bad-type.txt | 0 .../install-dry-run-with-secret-hidden.txt | 21 + .../output/install-dry-run-with-secret.txt | 26 + .../testdata/output/install-hide-secret.txt | 1 + .../testdata/output/install-lib-chart.txt | 0 .../testdata/output/install-name-template.txt | 3 +- .../cmd}/testdata/output/install-no-args.txt | 0 .../cmd}/testdata/output/install-no-hooks.txt | 3 +- .../install-with-multiple-values-files.txt | 3 +- .../output/install-with-multiple-values.txt | 3 +- .../testdata/output/install-with-timeout.txt | 3 +- .../output/install-with-values-file.txt | 3 +- .../testdata/output/install-with-values.txt | 3 +- .../output/install-with-wait-for-jobs.txt | 3 +- .../testdata/output/install-with-wait.txt | 3 +- .../cmd}/testdata/output/install.txt | 3 +- .../cmd}/testdata/output/issue-9027.txt | 0 ...hart-with-bad-subcharts-with-subcharts.txt | 8 +- .../output/lint-chart-with-bad-subcharts.txt | 7 + ...lint-chart-with-deprecated-api-old-k8s.txt | 0 .../lint-chart-with-deprecated-api-strict.txt | 0 .../output/lint-chart-with-deprecated-api.txt | 0 .../testdata/output/lint-quiet-with-error.txt | 2 +- .../output/lint-quiet-with-warning.txt | 4 + .../pkg/cmd/testdata/output/lint-quiet.txt | 0 .../output/list-all-date-reversed.txt | 9 + .../pkg/cmd/testdata/output/list-all-date.txt | 9 + .../cmd/testdata/output/list-all-max.txt} | 0 .../testdata/output/list-all-no-headers.txt | 8 + .../cmd/testdata/output/list-all-offset.txt | 8 + .../cmd/testdata/output/list-all-reverse.txt | 9 + .../testdata/output/list-all-short-json.txt | 1 + .../testdata/output/list-all-short-yaml.txt | 8 + .../cmd/testdata/output/list-all-short.txt | 8 + .../cmd}/testdata/output/list-all.txt | 0 .../testdata/output/list-date-reversed.txt | 0 .../cmd}/testdata/output/list-date.txt | 0 .../cmd}/testdata/output/list-failed.txt | 0 .../cmd}/testdata/output/list-filter.txt | 0 .../pkg/cmd/testdata/output/list-json.txt | 1 + .../cmd}/testdata/output/list-max.txt | 0 .../cmd}/testdata/output/list-namespace.txt | 0 .../cmd}/testdata/output/list-no-headers.txt | 0 .../cmd}/testdata/output/list-offset.txt | 0 .../cmd}/testdata/output/list-pending.txt | 0 .../cmd}/testdata/output/list-reverse.txt | 0 .../cmd}/testdata/output/list-short-json.txt | 0 .../cmd}/testdata/output/list-short-yaml.txt | 0 .../cmd}/testdata/output/list-short.txt | 0 .../cmd}/testdata/output/list-superseded.txt | 0 .../cmd/testdata/output/list-time-format.txt | 2 + .../cmd}/testdata/output/list-uninstalled.txt | 0 .../cmd/testdata/output/list-uninstalling.txt | 2 + .../pkg/cmd/testdata/output/list-yaml.txt | 7 + .../helm => pkg/cmd}/testdata/output/list.txt | 0 .../cmd}/testdata/output/object-order.txt | 0 .../cmd}/testdata/output/output-comp.txt | 0 .../cmd}/testdata/output/plugin_args_comp.txt | 0 .../testdata/output/plugin_args_flag_comp.txt | 0 .../output/plugin_args_many_args_comp.txt | 0 .../testdata/output/plugin_args_ns_comp.txt | 0 .../output/plugin_echo_no_directive.txt | 0 .../cmd}/testdata/output/plugin_list_comp.txt | 2 +- .../testdata/output/plugin_repeat_comp.txt | 2 +- .../testdata/output/release_list_comp.txt | 0 .../output/release_list_repeat_comp.txt | 0 .../cmd}/testdata/output/repo-add.txt | 0 .../cmd}/testdata/output/repo-add2.txt | 0 .../cmd/testdata/output/repo-list-empty.txt | 1 + .../testdata/output/repo-list-no-headers.txt | 3 + .../pkg/cmd/testdata/output/repo-list.txt | 4 + .../cmd}/testdata/output/repo_list_comp.txt | 0 .../cmd}/testdata/output/repo_repeat_comp.txt | 0 .../cmd}/testdata/output/revision-comp.txt | 0 .../output/revision-wrong-args-comp.txt | 0 .../cmd}/testdata/output/rollback-comp.txt | 0 .../cmd}/testdata/output/rollback-no-args.txt | 0 .../testdata/output/rollback-no-revision.txt | 0 .../output/rollback-non-existent-version.txt | 0 .../cmd}/testdata/output/rollback-timeout.txt | 0 .../output/rollback-wait-for-jobs.txt | 0 .../cmd}/testdata/output/rollback-wait.txt | 0 .../output/rollback-wrong-args-comp.txt | 3 + .../cmd}/testdata/output/rollback.txt | 0 .../testdata/output/schema-negative-cli.txt | 2 +- .../cmd}/testdata/output/schema-negative.txt | 4 +- .../cmd}/testdata/output/schema.txt | 3 +- .../output/search-constraint-single.txt | 0 .../testdata/output/search-constraint.txt | 0 .../output/search-multiple-devel-release.txt | 0 .../output/search-multiple-stable-release.txt | 0 .../search-multiple-versions-constraints.txt | 0 .../output/search-multiple-versions.txt | 0 .../output/search-not-found-error.txt | 0 .../cmd}/testdata/output/search-not-found.txt | 0 .../testdata/output/search-output-json.txt | 0 .../testdata/output/search-output-yaml.txt | 0 .../cmd}/testdata/output/search-regex.txt | 0 .../output/search-versions-constraint.txt | 0 .../cmd}/testdata/output/status-comp.txt | 0 .../cmd}/testdata/output/status-with-desc.txt | 0 .../testdata/output/status-with-notes.txt | 1 + .../output/status-with-resources.json | 1 + .../testdata/output/status-with-resources.txt | 1 + .../output/status-with-test-suite.txt | 1 + .../output/status-wrong-args-comp.txt | 3 + pkg/helm/pkg/cmd/testdata/output/status.json | 1 + .../cmd}/testdata/output/status.txt | 1 + .../output/subchart-schema-cli-negative.txt | 2 +- .../testdata/output/subchart-schema-cli.txt | 3 +- .../output/subchart-schema-negative.txt | 4 +- .../output/template-chart-bad-type.txt | 0 ...te-chart-with-template-lib-archive-dep.txt | 0 .../template-chart-with-template-lib-dep.txt | 0 .../testdata/output/template-lib-chart.txt | 0 .../output/template-name-template.txt | 0 .../cmd}/testdata/output/template-no-args.txt | 0 .../cmd}/testdata/output/template-set.txt | 0 .../output/template-show-only-glob.txt | 0 .../output/template-show-only-multiple.txt | 0 .../output/template-show-only-one.txt | 0 .../testdata/output/template-skip-tests.txt | 0 .../output/template-subchart-cm-set-file.txt | 0 .../output/template-subchart-cm-set.txt | 0 .../testdata/output/template-subchart-cm.txt | 0 .../testdata/output/template-values-files.txt | 0 .../output/template-with-api-version.txt | 1 + .../testdata/output/template-with-crds.txt | 0 .../template-with-invalid-yaml-debug.txt | 0 .../output/template-with-invalid-yaml.txt | 0 .../output/template-with-kube-version.txt | 0 .../cmd}/testdata/output/template.txt | 0 ...install-keep-history-earlier-deployed.txt} | 0 .../output/uninstall-keep-history.txt} | 0 .../testdata/output/uninstall-multiple.txt | 0 .../testdata/output/uninstall-no-args.txt | 0 .../testdata/output/uninstall-no-hooks.txt} | 0 .../testdata/output/uninstall-timeout.txt} | 0 .../cmd/testdata/output/uninstall-wait.txt} | 0 .../pkg/cmd/testdata/output/uninstall.txt | 1 + .../output/upgrade-and-take-ownership.txt | 8 + .../upgrade-uninstalled-with-keep-history.txt | 8 + .../output/upgrade-with-bad-dependencies.txt | 0 ...e-with-bad-or-missing-existing-release.txt | 0 .../output/upgrade-with-dependency-update.txt | 3 +- .../output/upgrade-with-install-timeout.txt | 3 +- .../testdata/output/upgrade-with-install.txt | 3 +- .../upgrade-with-missing-dependencies.txt | 2 +- .../output/upgrade-with-pending-install.txt | 0 .../output/upgrade-with-reset-values.txt | 3 +- .../output/upgrade-with-reset-values2.txt | 3 +- .../testdata/output/upgrade-with-timeout.txt | 3 +- .../output/upgrade-with-wait-for-jobs.txt | 3 +- .../testdata/output/upgrade-with-wait.txt | 3 +- .../cmd}/testdata/output/upgrade.txt | 3 +- .../cmd}/testdata/output/values.json | 0 .../cmd}/testdata/output/values.yaml | 0 .../cmd}/testdata/output/version-comp.txt | 0 .../testdata/output/version-invalid-comp.txt | 0 .../pkg/cmd/testdata/output/version-short.txt | 1 + .../cmd/testdata/output/version-template.txt | 1 + pkg/helm/pkg/cmd/testdata/output/version.txt | 1 + .../{cmd/helm => pkg/cmd}/testdata/password | 0 pkg/helm/pkg/cmd/testdata/plugins.yaml | 3 + .../cmd}/testdata/repositories.yaml | 1 + .../testdata/testcharts/alpine/Chart.yaml | 2 +- .../cmd}/testdata/testcharts/alpine/README.md | 0 .../testcharts/alpine/extra_values.yaml | 0 .../testcharts/alpine/more_values.yaml | 0 .../alpine/templates/alpine-pod.yaml | 0 .../testdata/testcharts/alpine/values.yaml | 0 .../chart-bad-requirements/.helmignore | 0 .../chart-bad-requirements/Chart.yaml | 0 .../charts/reqsubchart/.helmignore | 0 .../charts/reqsubchart/Chart.yaml | 0 .../charts/reqsubchart/values.yaml | 0 .../chart-bad-requirements/values.yaml | 0 .../testcharts/chart-bad-type/Chart.yaml | 0 .../testcharts/chart-bad-type/README.md | 0 .../chart-bad-type/extra_values.yaml | 0 .../chart-bad-type/more_values.yaml | 0 .../chart-bad-type/templates/alpine-pod.yaml | 0 .../testcharts/chart-bad-type/values.yaml | 0 .../testcharts/chart-missing-deps/.helmignore | 0 .../testcharts/chart-missing-deps/Chart.yaml | 0 .../charts/reqsubchart/.helmignore | 0 .../charts/reqsubchart/Chart.yaml | 0 .../charts/reqsubchart/values.yaml | 0 .../testcharts/chart-missing-deps/values.yaml | 0 .../chart-with-bad-subcharts/Chart.yaml | 0 .../charts/bad-subchart/Chart.yaml | 0 .../charts/bad-subchart/values.yaml | 0 .../charts/good-subchart/Chart.yaml | 2 +- .../charts/good-subchart/values.yaml | 0 .../requirements.yaml | 2 +- .../chart-with-bad-subcharts/values.yaml | 0 .../chart-with-deprecated-api/Chart.yaml | 0 .../templates/horizontalpodautoscaler.yaml | 0 .../chart-with-deprecated-api/values.yaml | 0 .../testcharts/chart-with-lib-dep/.helmignore | 0 .../testcharts/chart-with-lib-dep/Chart.yaml | 0 .../charts/common-0.0.5.tgz | Bin .../chart-with-lib-dep/templates/NOTES.txt | 0 .../chart-with-lib-dep/templates/_helpers.tpl | 0 .../templates/deployment.yaml | 0 .../chart-with-lib-dep/templates/ingress.yaml | 0 .../chart-with-lib-dep/templates/service.yaml | 0 .../testcharts/chart-with-lib-dep/values.yaml | 0 .../chart-with-only-crds/.helmignore | 23 + .../chart-with-only-crds/Chart.yaml | 0 .../chart-with-only-crds/crds/test-crd.yaml | 19 + .../chart-with-schema-and-subchart/Chart.yaml | 0 .../charts/subchart-with-schema/Chart.yaml | 0 .../subchart-with-schema/templates/empty.yaml | 0 .../subchart-with-schema/values.schema.json | 0 .../charts/subchart-with-schema/values.yaml | 0 .../templates/empty.yaml | 0 .../values.schema.json | 0 .../values.yaml | 0 .../Chart.yaml | 2 +- .../templates/empty.yaml | 0 .../values.schema.json | 0 .../values.yaml | 14 + .../chart-with-schema-negative/Chart.yaml | 2 +- .../templates/empty.yaml | 0 .../values.schema.json | 67 + .../chart-with-schema-negative/values.yaml | 14 + .../testcharts/chart-with-schema/Chart.yaml | 7 + .../chart-with-schema/extra-values.yaml | 0 .../chart-with-schema}/templates/empty.yaml | 0 .../chart-with-schema/values.schema.json | 67 + .../testcharts/chart-with-schema/values.yaml | 17 + .../testcharts/chart-with-secret/Chart.yaml | 4 + .../templates/configmap.yaml | 6 + .../chart-with-secret/templates/secret.yaml | 6 + .../chart-with-subchart-notes/Chart.yaml | 0 .../charts/subchart-with-notes/Chart.yaml | 0 .../subchart-with-notes/templates/NOTES.txt | 0 .../templates/NOTES.txt | 0 .../chart-with-subchart-update/Chart.lock | 0 .../chart-with-subchart-update/Chart.yaml | 0 .../charts/subchart-with-notes/Chart.yaml | 0 .../subchart-with-notes/templates/NOTES.txt | 0 .../templates/NOTES.txt | 0 .../.helmignore | 0 .../Chart.yaml | 0 .../charts/common-0.0.5.tgz | Bin .../templates/NOTES.txt | 0 .../templates/_helpers.tpl | 0 .../templates/deployment.yaml | 0 .../templates/ingress.yaml | 0 .../templates/service.yaml | 0 .../values.yaml | 0 .../chart-with-template-lib-dep/.helmignore | 0 .../chart-with-template-lib-dep/Chart.yaml | 0 .../charts/common/.helmignore | 0 .../charts/common/Chart.yaml | 8 +- .../charts/common/README.md | 2 +- .../charts/common/templates/_chartref.tpl | 0 .../charts/common/templates/_configmap.yaml | 0 .../charts/common/templates/_container.yaml | 0 .../charts/common/templates/_deployment.yaml | 0 .../charts/common/templates/_envvar.tpl | 0 .../charts/common/templates/_fullname.tpl | 0 .../charts/common/templates/_ingress.yaml | 0 .../charts/common/templates/_metadata.yaml | 0 .../templates/_metadata_annotations.tpl | 0 .../common/templates/_metadata_labels.tpl | 0 .../charts/common/templates/_name.tpl | 0 .../templates/_persistentvolumeclaim.yaml | 0 .../charts/common/templates/_secret.yaml | 0 .../charts/common/templates/_service.yaml | 0 .../charts/common/templates/_util.tpl | 0 .../charts/common/templates/_volume.tpl | 0 .../charts/common/templates/configmap.yaml | 0 .../charts/common/values.yaml | 0 .../templates/NOTES.txt | 0 .../templates/_helpers.tpl | 0 .../templates/deployment.yaml | 0 .../templates/ingress.yaml | 0 .../templates/service.yaml | 0 .../chart-with-template-lib-dep/values.yaml | 0 .../Chart.yaml | 0 .../README.md | 0 .../templates/alpine-pod.yaml | 0 .../values.yaml | 0 .../testcharts/compressedchart-0.1.0.tar.gz | Bin .../testcharts/compressedchart-0.1.0.tgz | Bin .../testcharts/compressedchart-0.2.0.tgz | Bin .../testcharts/compressedchart-0.3.0.tgz | Bin .../compressedchart-with-hyphens-0.1.0.tgz | Bin .../testdata/testcharts/deprecated/Chart.yaml | 0 .../testdata/testcharts/deprecated/README.md | 0 .../cmd}/testdata/testcharts/empty/Chart.yaml | 0 .../cmd}/testdata/testcharts/empty/README.md | 0 .../testcharts/empty/templates/empty.yaml | 1 + .../testdata/testcharts/empty/values.yaml | 0 .../testcharts/issue-7233/.helmignore | 0 .../testdata/testcharts/issue-7233/Chart.yaml | 0 .../issue-7233/charts/alpine-0.1.0.tgz | Bin 0 -> 1166 bytes .../testcharts/issue-7233/requirements.lock | 0 .../testcharts/issue-7233/requirements.yaml | 4 + .../issue-7233/templates/configmap.yaml | 0 .../testcharts/issue-7233/values.yaml | 0 .../testdata/testcharts/issue-9027/Chart.yaml | 0 .../issue-9027/charts/subchart/Chart.yaml | 0 .../charts/subchart/templates/values.yaml | 0 .../issue-9027/charts/subchart/values.yaml | 1 + .../issue-9027/templates/values.yaml | 0 .../testcharts/issue-9027/values.yaml | 0 .../testdata/testcharts/issue1979/Chart.yaml | 0 .../testdata/testcharts/issue1979/README.md | 0 .../testcharts/issue1979/extra_values.yaml | 0 .../testcharts/issue1979/more_values.yaml | 0 .../issue1979/templates/alpine-pod.yaml | 0 .../testdata/testcharts/issue1979/values.yaml | 0 .../testdata/testcharts/lib-chart/.helmignore | 0 .../testdata/testcharts/lib-chart/Chart.yaml | 8 +- .../testdata/testcharts/lib-chart/README.md | 2 +- .../lib-chart/templates/_chartref.tpl | 0 .../lib-chart/templates/_configmap.yaml | 0 .../lib-chart/templates/_container.yaml | 0 .../lib-chart/templates/_deployment.yaml | 0 .../lib-chart/templates/_envvar.tpl | 0 .../lib-chart/templates/_fullname.tpl | 0 .../lib-chart/templates/_ingress.yaml | 0 .../lib-chart/templates/_metadata.yaml | 0 .../templates/_metadata_annotations.tpl | 0 .../lib-chart/templates/_metadata_labels.tpl | 0 .../testcharts/lib-chart/templates/_name.tpl | 0 .../templates/_persistentvolumeclaim.yaml | 0 .../lib-chart/templates/_secret.yaml | 0 .../lib-chart/templates/_service.yaml | 0 .../testcharts/lib-chart/templates/_util.tpl | 0 .../lib-chart/templates/_volume.tpl | 0 .../testdata/testcharts/lib-chart/values.yaml | 0 .../testcharts/object-order/Chart.yaml | 0 .../object-order/templates/01-a.yml | 0 .../object-order/templates/02-b.yml | 0 .../testcharts/object-order/values.yaml | 0 .../testcharts/oci-dependent-chart-0.1.0.tgz | Bin .../pre-release-chart-0.1.0-alpha.tgz | Bin .../testdata/testcharts/reqtest-0.1.0.tgz | Bin .../testdata/testcharts/reqtest/.helmignore | 0 .../testdata/testcharts/reqtest/Chart.lock | 0 .../testdata/testcharts/reqtest/Chart.yaml | 0 .../reqtest/charts/reqsubchart/.helmignore | 0 .../reqtest/charts/reqsubchart/Chart.yaml | 0 .../reqtest/charts/reqsubchart/values.yaml | 0 .../reqtest/charts/reqsubchart2/.helmignore | 0 .../reqtest/charts/reqsubchart2/Chart.yaml | 0 .../reqtest/charts/reqsubchart2/values.yaml | 0 .../reqtest/charts/reqsubchart3-0.2.0.tgz | Bin .../testdata/testcharts/reqtest/values.yaml | 0 .../testdata/testcharts/signtest-0.1.0.tgz | Bin .../testcharts/signtest-0.1.0.tgz.prov | 0 .../testdata/testcharts/signtest/.helmignore | 0 .../testdata/testcharts/signtest/Chart.yaml | 0 .../testcharts/signtest/alpine/Chart.yaml | 2 +- .../testcharts/signtest/alpine/README.md | 0 .../signtest/alpine/templates/alpine-pod.yaml | 14 + .../testcharts/signtest/alpine/values.yaml | 0 .../testcharts/signtest/templates/pod.yaml | 0 .../testdata/testcharts/signtest/values.yaml | 0 .../testdata/testcharts/subchart/Chart.yaml | 0 .../subchart}/charts/subchartA/Chart.yaml | 0 .../charts/subchartA/templates/service.yaml | 15 + .../subchart/charts/subchartA/values.yaml | 17 + .../subchart}/charts/subchartB/Chart.yaml | 0 .../charts/subchartB/templates/service.yaml | 15 + .../subchart/charts/subchartB/values.yaml | 9 +- .../testcharts/subchart/crds/crdA.yaml | 0 .../testcharts/subchart/extra_values.yaml | 2 +- .../testcharts/subchart/templates/NOTES.txt | 1 + .../subchart/templates/service.yaml | 25 + .../subchart/templates/subdir/configmap.yaml | 0 .../subchart/templates/subdir/role.yaml | 0 .../templates/subdir/rolebinding.yaml | 12 + .../templates/subdir/serviceaccount.yaml | 4 + .../subchart/templates/tests/test-config.yaml | 0 .../templates/tests/test-nothing.yaml | 0 .../testdata/testcharts/subchart/values.yaml | 1 + .../cmd/testdata/testcharts/test-0.1.0.tgz | Bin 0 -> 319 bytes .../cmd/testdata/testcharts/test/Chart.yaml | 4 + .../cmd/testdata/testcharts/test/values.yaml | 1 + .../cmd/testdata/testcharts/test1-0.1.0.tgz | Bin 0 -> 327 bytes .../cmd/testdata/testcharts/test1/Chart.yaml | 4 + .../cmd/testdata/testcharts/test1/values.yaml | 2 + .../upgradetest/templates/configmap.yaml | 0 .../testcharts/upgradetest/values.yaml | 1 + .../pkg/cmd/testdata/testplugin/plugin.yaml | 12 + .../cmd}/testdata/testserver/index.yaml | 0 .../testserver/repository/repositories.yaml | 6 +- pkg/helm/{cmd/helm => pkg/cmd}/verify.go | 14 +- pkg/helm/{cmd/helm => pkg/cmd}/verify_test.go | 2 +- pkg/helm/pkg/cmd/version.go | 101 + pkg/helm/pkg/cmd/version_test.go | 41 + pkg/helm/pkg/downloader/cache.go | 89 + pkg/helm/pkg/downloader/cache_test.go | 122 + pkg/helm/pkg/downloader/chart_downloader.go | 356 ++- .../pkg/downloader/chart_downloader_test.go | 209 +- .../chart_downloader_windows_test.go | 131 ++ pkg/helm/pkg/downloader/manager.go | 184 +- pkg/helm/pkg/downloader/manager_test.go | 272 ++- .../repository/testing-relative-index.yaml | 13 + .../testdata/signtest/alpine/Chart.yaml | 2 +- pkg/helm/pkg/engine/doc.go | 2 +- pkg/helm/pkg/engine/engine.go | 573 +++-- .../pkg/engine/engine_migration_ai_test.go | 83 + pkg/helm/pkg/engine/engine_test.go | 601 +++-- pkg/helm/pkg/engine/files.go | 6 +- pkg/helm/pkg/engine/funcs.go | 64 +- pkg/helm/pkg/engine/funcs_test.go | 65 + pkg/helm/pkg/engine/lookup_func.go | 29 +- pkg/helm/pkg/gates/gates_test.go | 3 +- pkg/helm/pkg/getter/exports.go | 6 - pkg/helm/pkg/getter/getter.go | 96 +- pkg/helm/pkg/getter/getter_test.go | 18 + pkg/helm/pkg/getter/httpgetter.go | 37 +- pkg/helm/pkg/getter/httpgetter_test.go | 176 +- pkg/helm/pkg/getter/ocigetter.go | 66 +- pkg/helm/pkg/getter/ocigetter_test.go | 2 +- pkg/helm/pkg/getter/plugingetter.go | 26 + .../getter/testdata/plugins/testgetter/get.sh | 8 - .../testdata/plugins/testgetter/plugin.yaml | 24 +- .../testdata/plugins/testgetter2/get.sh | 8 - .../testdata/plugins/testgetter2/plugin.yaml | 19 +- .../testdata/repository/repositories.yaml | 24 +- pkg/helm/pkg/helmpath/home_unix_test.go | 9 +- pkg/helm/pkg/helmpath/lazypath.go | 2 +- pkg/helm/pkg/helmpath/lazypath_darwin_test.go | 6 +- pkg/helm/pkg/helmpath/lazypath_unix_test.go | 13 +- pkg/helm/pkg/ignore/doc.go | 4 +- pkg/helm/pkg/ignore/rules.go | 27 +- pkg/helm/pkg/kube/client.go | 1340 ++++++----- pkg/helm/pkg/kube/client_extender.go | 9 - pkg/helm/pkg/kube/client_test.go | 1968 ++++++++++++++++- pkg/helm/pkg/kube/config.go | 30 - pkg/helm/pkg/kube/converter.go | 2 +- pkg/helm/pkg/kube/extensions.go | 36 - pkg/helm/pkg/kube/factory.go | 6 +- pkg/helm/pkg/kube/fake/extensions.go | 11 - .../fake/{fake.go => failing_kube_client.go} | 127 +- pkg/helm/pkg/kube/fake/printer.go | 57 +- pkg/helm/pkg/kube/interface.go | 114 +- pkg/helm/pkg/kube/options.go | 82 + pkg/helm/pkg/kube/ready.go | 95 +- pkg/helm/pkg/kube/ready_test.go | 918 +++++++- pkg/helm/pkg/kube/resource.go | 57 +- pkg/helm/pkg/kube/resource_policy.go | 2 +- pkg/helm/pkg/kube/resource_test.go | 41 +- pkg/helm/pkg/kube/resources_waiter.go | 20 - pkg/helm/pkg/{cli => kube}/roundtripper.go | 20 +- pkg/helm/pkg/kube/roundtripper_test.go | 161 ++ pkg/helm/pkg/kube/statuswait.go | 292 +++ pkg/helm/pkg/kube/statuswait_test.go | 1820 +++++++++++++++ pkg/helm/pkg/kube/wait.go | 255 ++- pkg/helm/pkg/kube/wait_test.go | 467 ++++ pkg/helm/pkg/postrender/exec.go | 109 - pkg/helm/pkg/postrender/exec_test.go | 182 -- .../postrenderer.go} | 22 +- .../pkg/postrenderer/postrenderer_test.go | 81 + .../plugins/postrenderer-v1/plugin.yaml | 8 + .../plugins/postrenderer-v1/sed-test.sh | 6 + pkg/helm/pkg/provenance/doc.go | 12 +- pkg/helm/pkg/provenance/sign.go | 164 +- pkg/helm/pkg/provenance/sign_test.go | 170 +- .../testdata/helm-mixed-keyring.pub | Bin 0 -> 1493 bytes .../pkg/provenance/testdata/msgblock.yaml | 2 +- pkg/helm/pkg/pusher/ocipusher.go | 38 +- pkg/helm/pkg/pusher/ocipusher_test.go | 340 ++- pkg/helm/pkg/pusher/pusher.go | 19 +- pkg/helm/pkg/registry/chart.go | 125 ++ .../registry/{util_test.go => chart_test.go} | 50 +- pkg/helm/pkg/registry/client.go | 754 ++++--- pkg/helm/pkg/registry/client_http_test.go | 41 +- .../pkg/registry/client_insecure_tls_test.go | 22 +- pkg/helm/pkg/registry/client_test.go | 168 ++ pkg/helm/pkg/registry/client_tls_test.go | 48 +- pkg/helm/pkg/registry/constants.go | 2 +- pkg/helm/pkg/registry/generic.go | 161 ++ pkg/helm/pkg/registry/main_test.go | 51 + pkg/helm/pkg/registry/plugin.go | 212 ++ pkg/helm/pkg/registry/plugin_test.go | 93 + pkg/helm/pkg/registry/reference.go | 84 + pkg/helm/pkg/registry/reference_test.go | 100 + .../{utils_test.go => registry_test.go} | 303 ++- pkg/helm/pkg/registry/tag.go | 59 + pkg/helm/pkg/registry/tag_test.go | 122 + pkg/helm/pkg/registry/testdata/tls/ca.crt | 36 +- pkg/helm/pkg/registry/testdata/tls/ca.key | 28 + pkg/helm/pkg/registry/testdata/tls/client.crt | 37 +- pkg/helm/pkg/registry/testdata/tls/client.key | 52 +- pkg/helm/pkg/registry/testdata/tls/server.crt | 37 +- pkg/helm/pkg/registry/testdata/tls/server.key | 52 +- pkg/helm/pkg/registry/transport.go | 175 ++ pkg/helm/pkg/registry/transport_test.go | 399 ++++ pkg/helm/pkg/registry/util.go | 248 --- pkg/helm/pkg/release/common.go | 116 + pkg/helm/pkg/release/{ => common}/status.go | 10 +- pkg/helm/pkg/release/common_test.go | 65 + pkg/helm/pkg/release/info.go | 44 - pkg/helm/pkg/release/interfaces.go | 46 + pkg/helm/pkg/release/phase.go | 62 - pkg/helm/pkg/release/report.go | 45 - pkg/helm/pkg/release/responses.go | 2 +- pkg/helm/pkg/release/{ => v1}/hook.go | 96 +- pkg/helm/pkg/release/v1/hook_test.go | 231 ++ pkg/helm/pkg/release/v1/info.go | 126 ++ pkg/helm/pkg/release/v1/info_test.go | 285 +++ pkg/helm/pkg/release/{ => v1}/mock.go | 40 +- pkg/helm/pkg/release/{ => v1}/release.go | 61 +- .../v1/util}/filter.go | 9 +- .../v1/util}/filter_test.go | 15 +- .../v1/util}/kind_sorter.go | 9 +- .../v1/util}/kind_sorter_test.go | 16 +- .../v1/util}/manifest.go | 36 +- .../v1/util}/manifest_sorter.go | 54 +- .../v1/util}/manifest_sorter_test.go | 7 +- .../v1/util}/manifest_test.go | 2 +- .../v1/util}/sorter.go | 51 +- .../v1/util}/sorter_test.go | 19 +- pkg/helm/pkg/releaseutil/validate.go | 152 -- pkg/helm/pkg/releaseutil/validate_test.go | 89 - pkg/helm/pkg/repo/chartrepo.go | 318 --- pkg/helm/pkg/repo/repotest/server_test.go | 116 - pkg/helm/pkg/repo/v1/chartrepo.go | 276 +++ pkg/helm/pkg/repo/{ => v1}/chartrepo_test.go | 240 +- pkg/helm/pkg/repo/{ => v1}/doc.go | 0 pkg/helm/pkg/repo/v1/error.go | 35 + pkg/helm/pkg/repo/{ => v1}/index.go | 45 +- pkg/helm/pkg/repo/{ => v1}/index_test.go | 86 +- pkg/helm/pkg/repo/{ => v1}/repo.go | 6 +- pkg/helm/pkg/repo/{ => v1}/repo_test.go | 2 +- pkg/helm/pkg/repo/{ => v1}/repotest/doc.go | 0 pkg/helm/pkg/repo/{ => v1}/repotest/server.go | 272 ++- pkg/helm/pkg/repo/v1/repotest/server_test.go | 222 ++ .../repotest/testdata/examplechart-0.1.0.tgz | Bin .../testdata/examplechart/.helmignore | 0 .../repotest/testdata/examplechart/Chart.yaml | 0 .../testdata/examplechart/values.yaml | 0 pkg/helm/pkg/repo/v1/repotest/tlsconfig.go | 44 + .../{ => v1}/testdata/chartmuseum-index.yaml | 0 .../testdata/local-index-annotations.yaml | 0 .../testdata/local-index-unordered.yaml | 0 .../repo/{ => v1}/testdata/local-index.json | 0 .../repo/{ => v1}/testdata/local-index.yaml | 0 .../{ => v1}/testdata/old-repositories.yaml | 0 .../repo/{ => v1}/testdata/repositories.yaml | 0 .../v1/testdata/repository/frobnitz-1.2.3.tgz | Bin 0 -> 3485 bytes .../testdata/repository/sprocket-1.1.0.tgz | Bin .../testdata/repository/sprocket-1.2.0.tgz | Bin .../repository/universe/zarthal-1.0.0.tgz | Bin .../repo/{ => v1}/testdata/server/index.yaml | 0 .../repo/{ => v1}/testdata/server/test.txt | 0 pkg/helm/pkg/storage/driver/cfgmaps.go | 89 +- pkg/helm/pkg/storage/driver/cfgmaps_test.go | 80 +- pkg/helm/pkg/storage/driver/driver.go | 35 +- pkg/helm/pkg/storage/driver/labels_test.go | 2 +- pkg/helm/pkg/storage/driver/memory.go | 35 +- pkg/helm/pkg/storage/driver/memory_test.go | 55 +- pkg/helm/pkg/storage/driver/mock_test.go | 58 +- pkg/helm/pkg/storage/driver/records.go | 4 +- pkg/helm/pkg/storage/driver/records_test.go | 44 +- pkg/helm/pkg/storage/driver/secrets.go | 93 +- pkg/helm/pkg/storage/driver/secrets_test.go | 65 +- pkg/helm/pkg/storage/driver/sql.go | 164 +- pkg/helm/pkg/storage/driver/sql_test.go | 97 +- pkg/helm/pkg/storage/driver/util.go | 12 +- pkg/helm/pkg/storage/storage.go | 219 +- pkg/helm/pkg/storage/storage_test.go | 189 +- pkg/helm/pkg/strvals/fuzz_test.go | 26 + pkg/helm/pkg/strvals/literal_parser.go | 19 +- pkg/helm/pkg/strvals/literal_parser_test.go | 7 +- pkg/helm/pkg/strvals/parser.go | 23 +- pkg/helm/pkg/strvals/parser_test.go | 9 +- pkg/helm/pkg/time/time.go | 91 - pkg/helm/pkg/time/time_test.go | 83 - pkg/helm/pkg/uploader/chart_uploader.go | 9 +- pkg/helm/pkg/werf/file/buffered_file.go | 6 - pkg/helm/pkg/werf/file/chart_file_reader.go | 15 - pkg/helm/pkg/werf/file/chart_file_writer.go | 12 - pkg/helm/pkg/werf/helmopts/helmoptions.go | 38 - .../pkg/werf/secrets/chart_secrets_loader.go | 63 - .../pkg/werf/secrets/runtimedata/interface.go | 23 - .../pkg/werf/secrets/secrets_runtime_data.go | 161 -- pkg/helm/testdata/localhost-crt.pem | 73 + pkg/helm/testdata/openssl.conf | 4 + pkg/helm/testdata/releases.yaml | 1 + pkg/kube/config.go | 13 +- pkg/legacy/secret/chart_secrets_loader.go | 60 + pkg/legacy/secret/runtime_data.go | 151 ++ .../secret/template_funcs.go} | 17 +- pkg/log/common.go | 3 + pkg/plan/operation_config.go | 23 +- pkg/plan/plan_artifact.go | 4 +- pkg/plan/plan_build.go | 27 +- pkg/plan/plan_build_test.go | 7 +- pkg/plan/release_info.go | 9 +- pkg/release/history.go | 24 +- pkg/release/release.go | 32 +- pkg/release/release_storage.go | 100 +- pkg/resource/helpers_ai_test.go | 2 +- pkg/resource/kubeconform.go | 3 +- pkg/resource/metadata.go | 18 +- pkg/ts/bundle.go | 15 +- pkg/ts/bundle_ai_test.go | 52 +- pkg/ts/files.go | 7 +- pkg/ts/render.go | 20 +- pkg/ts/render_ai_test.go | 564 ++--- pkg/ts/render_test.go | 136 +- pkg/util/manifest.go | 46 + 1817 files changed, 47595 insertions(+), 11943 deletions(-) rename pkg/{log/init.go => action/setup_logging.go} (85%) create mode 100644 pkg/common/legacy.go delete mode 100644 pkg/helm/cmd/helm/exports.go delete mode 100644 pkg/helm/cmd/helm/flags_test.go delete mode 100644 pkg/helm/cmd/helm/helm.go delete mode 100644 pkg/helm/cmd/helm/history_test.go delete mode 100644 pkg/helm/cmd/helm/list.go delete mode 100644 pkg/helm/cmd/helm/repo_list.go delete mode 100644 pkg/helm/cmd/helm/root_unix.go delete mode 100644 pkg/helm/cmd/helm/root_unix_test.go delete mode 100644 pkg/helm/cmd/helm/root_windows.go delete mode 100644 pkg/helm/cmd/helm/testdata/helm home with space/helm/plugins/fullenv/completion.yaml delete mode 100644 pkg/helm/cmd/helm/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml delete mode 100755 pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/args/args.sh delete mode 100644 pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/args/plugin.yaml delete mode 100644 pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/echo/plugin.yaml delete mode 100644 pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/env/completion.yaml delete mode 100644 pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/env/plugin.yaml delete mode 100755 pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/exitwith/exitwith.sh delete mode 100644 pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/exitwith/plugin.yaml delete mode 100644 pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/fullenv/completion.yaml delete mode 100755 pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/fullenv/fullenv.sh delete mode 100644 pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/fullenv/plugin.yaml delete mode 100644 pkg/helm/cmd/helm/testdata/helmhome/helm/repositories.yaml delete mode 100644 pkg/helm/cmd/helm/testdata/output/empty_nofile_comp.txt delete mode 100644 pkg/helm/cmd/helm/testdata/output/get-metadata.json delete mode 100644 pkg/helm/cmd/helm/testdata/output/get-metadata.yaml delete mode 100644 pkg/helm/cmd/helm/testdata/output/lint-chart-with-bad-subcharts.txt delete mode 100644 pkg/helm/cmd/helm/testdata/output/rollback-wrong-args-comp.txt delete mode 100644 pkg/helm/cmd/helm/testdata/output/status-with-resources.json delete mode 100644 pkg/helm/cmd/helm/testdata/output/status-wrong-args-comp.txt delete mode 100644 pkg/helm/cmd/helm/testdata/output/status.json delete mode 100644 pkg/helm/cmd/helm/testdata/output/version-client-shorthand.txt delete mode 100644 pkg/helm/cmd/helm/testdata/output/version-client.txt delete mode 100644 pkg/helm/cmd/helm/testdata/output/version-short.txt delete mode 100644 pkg/helm/cmd/helm/testdata/output/version-template.txt delete mode 100644 pkg/helm/cmd/helm/testdata/output/version.txt delete mode 100644 pkg/helm/cmd/helm/testdata/plugins.yaml delete mode 100644 pkg/helm/cmd/helm/testdata/testcharts/issue-7233/requirements.yaml delete mode 100644 pkg/helm/cmd/helm/testdata/testcharts/upgradetest/values.yaml delete mode 100644 pkg/helm/cmd/helm/testdata/testplugin/plugin.yaml create mode 100644 pkg/helm/intern/chart/v3/chart.go create mode 100644 pkg/helm/intern/chart/v3/chart_test.go create mode 100644 pkg/helm/intern/chart/v3/dependency.go rename pkg/helm/{pkg/chart => intern/chart/v3}/dependency_test.go (98%) create mode 100644 pkg/helm/intern/chart/v3/doc.go rename pkg/helm/{pkg/chart => intern/chart/v3}/errors.go (98%) create mode 100644 pkg/helm/intern/chart/v3/fuzz_test.go create mode 100644 pkg/helm/intern/chart/v3/lint/lint.go create mode 100644 pkg/helm/intern/chart/v3/lint/lint_test.go create mode 100644 pkg/helm/intern/chart/v3/lint/rules/chartfile.go create mode 100644 pkg/helm/intern/chart/v3/lint/rules/chartfile_test.go create mode 100644 pkg/helm/intern/chart/v3/lint/rules/crds.go create mode 100644 pkg/helm/intern/chart/v3/lint/rules/crds_test.go create mode 100644 pkg/helm/intern/chart/v3/lint/rules/dependencies.go create mode 100644 pkg/helm/intern/chart/v3/lint/rules/dependencies_test.go create mode 100644 pkg/helm/intern/chart/v3/lint/rules/deprecations.go create mode 100644 pkg/helm/intern/chart/v3/lint/rules/deprecations_test.go create mode 100644 pkg/helm/intern/chart/v3/lint/rules/template.go create mode 100644 pkg/helm/intern/chart/v3/lint/rules/template_test.go create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/albatross/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/albatross/templates/_helpers.tpl create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/albatross/templates/fail.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/albatross/templates/svc.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/albatross/values.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/anotherbadchartfile/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/badchartfile/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/badchartfile/values.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/badchartname/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/badchartname/values.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/badcrdfile/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/badcrdfile/crds/bad-apiversion.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/badcrdfile/crds/bad-crd.yaml rename pkg/helm/{pkg/chartutil/testdata/joonix/charts => intern/chart/v3/lint/rules/testdata/badcrdfile/templates}/.gitkeep (100%) create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/badcrdfile/values.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/badvaluesfile/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/badvaluesfile/templates/badvaluesfile.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/badvaluesfile/values.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/goodone/Chart.yaml rename pkg/helm/{cmd/helm/testdata/testcharts/chart-with-only-crds => intern/chart/v3/lint/rules/testdata/goodone}/crds/test-crd.yaml (87%) create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/goodone/templates/goodone.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/goodone/values.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/invalidchartfile/Chart.yaml rename pkg/helm/{cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart => intern/chart/v3/lint/rules/testdata/invalidchartfile}/values.yaml (100%) create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/invalidcrdsdir/Chart.yaml rename pkg/helm/{cmd/helm/testdata/helm home with space/helm/repository/test-name-charts.txt => intern/chart/v3/lint/rules/testdata/invalidcrdsdir/crds} (100%) create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/invalidcrdsdir/values.yaml rename pkg/helm/{cmd/helm/testdata/testcharts/chart-with-only-crds => intern/chart/v3/lint/rules/testdata/malformed-template}/.helmignore (100%) create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/malformed-template/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/malformed-template/templates/bad.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/malformed-template/values.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/multi-template-fail/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/multi-template-fail/templates/multi-fail.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/v3-fail/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/v3-fail/templates/_helpers.tpl create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/v3-fail/templates/deployment.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/v3-fail/templates/ingress.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/v3-fail/templates/service.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/v3-fail/values.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/withsubchart/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/withsubchart/charts/subchart/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/withsubchart/charts/subchart/templates/subchart.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/withsubchart/charts/subchart/values.yaml create mode 100644 pkg/helm/intern/chart/v3/lint/rules/testdata/withsubchart/templates/mainchart.yaml rename pkg/helm/{cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart => intern/chart/v3/lint/rules/testdata/withsubchart}/values.yaml (100%) create mode 100644 pkg/helm/intern/chart/v3/lint/rules/values.go create mode 100644 pkg/helm/intern/chart/v3/lint/rules/values_test.go create mode 100644 pkg/helm/intern/chart/v3/lint/support/doc.go create mode 100644 pkg/helm/intern/chart/v3/lint/support/message.go create mode 100644 pkg/helm/intern/chart/v3/lint/support/message_test.go create mode 100644 pkg/helm/intern/chart/v3/loader/archive.go create mode 100644 pkg/helm/intern/chart/v3/loader/chart_metadata.go create mode 100644 pkg/helm/intern/chart/v3/loader/directory.go create mode 100644 pkg/helm/intern/chart/v3/loader/load.go create mode 100644 pkg/helm/intern/chart/v3/loader/load_test.go rename pkg/helm/{pkg/chart => intern/chart/v3}/loader/testdata/LICENSE (100%) rename pkg/helm/{pkg/chart => intern/chart/v3}/loader/testdata/albatross/Chart.yaml (100%) rename pkg/helm/{pkg/chart => intern/chart/v3}/loader/testdata/albatross/values.yaml (100%) create mode 100644 pkg/helm/intern/chart/v3/loader/testdata/frobnitz-1.2.3.tgz rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v1 => intern/chart/v3/loader/testdata/frobnitz.v3.reqs}/.helmignore (100%) create mode 100644 pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/Chart.yaml rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v1 => intern/chart/v3/loader/testdata/frobnitz.v3.reqs}/INSTALL.txt (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v1 => intern/chart/v3/loader/testdata/frobnitz.v3.reqs}/LICENSE (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v1 => intern/chart/v3/loader/testdata/frobnitz.v3.reqs}/README.md (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v1 => intern/chart/v3/loader/testdata/frobnitz.v3.reqs}/charts/_ignore_me (100%) create mode 100644 pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/Chart.yaml rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v1 => intern/chart/v3/loader/testdata/frobnitz.v3.reqs}/charts/alpine/README.md (100%) create mode 100644 pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/charts/mast1/Chart.yaml rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v1 => intern/chart/v3/loader/testdata/frobnitz.v3.reqs}/charts/alpine/charts/mast1/values.yaml (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v1 => intern/chart/v3/loader/testdata/frobnitz.v3.reqs}/charts/alpine/charts/mast2-0.1.0.tgz (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v1 => intern/chart/v3/loader/testdata/frobnitz.v3.reqs}/charts/alpine/templates/alpine-pod.yaml (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v1 => intern/chart/v3/loader/testdata/frobnitz.v3.reqs}/charts/alpine/values.yaml (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v1 => intern/chart/v3/loader/testdata/frobnitz.v3.reqs}/charts/mariner-4.3.2.tgz (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v1 => intern/chart/v3/loader/testdata/frobnitz.v3.reqs}/docs/README.md (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v1 => intern/chart/v3/loader/testdata/frobnitz.v3.reqs}/icon.svg (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v1 => intern/chart/v3/loader/testdata/frobnitz.v3.reqs}/ignore/me.txt (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v1 => intern/chart/v3/loader/testdata/frobnitz.v3.reqs}/templates/template.tpl (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v1 => intern/chart/v3/loader/testdata/frobnitz.v3.reqs}/values.yaml (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v2.reqs => intern/chart/v3/loader/testdata/frobnitz}/.helmignore (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v1 => intern/chart/v3/loader/testdata/frobnitz}/Chart.lock (100%) create mode 100644 pkg/helm/intern/chart/v3/loader/testdata/frobnitz/Chart.yaml rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v2.reqs => intern/chart/v3/loader/testdata/frobnitz}/INSTALL.txt (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v2.reqs => intern/chart/v3/loader/testdata/frobnitz}/LICENSE (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v2.reqs => intern/chart/v3/loader/testdata/frobnitz}/README.md (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v2.reqs => intern/chart/v3/loader/testdata/frobnitz}/charts/_ignore_me (100%) create mode 100644 pkg/helm/intern/chart/v3/loader/testdata/frobnitz/charts/alpine/Chart.yaml rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v2.reqs => intern/chart/v3/loader/testdata/frobnitz}/charts/alpine/README.md (100%) create mode 100644 pkg/helm/intern/chart/v3/loader/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v2.reqs => intern/chart/v3/loader/testdata/frobnitz}/charts/alpine/charts/mast1/values.yaml (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v2.reqs => intern/chart/v3/loader/testdata/frobnitz}/charts/alpine/charts/mast2-0.1.0.tgz (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v2.reqs => intern/chart/v3/loader/testdata/frobnitz}/charts/alpine/templates/alpine-pod.yaml (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v2.reqs => intern/chart/v3/loader/testdata/frobnitz}/charts/alpine/values.yaml (100%) create mode 100644 pkg/helm/intern/chart/v3/loader/testdata/frobnitz/charts/mariner-4.3.2.tgz rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v2.reqs => intern/chart/v3/loader/testdata/frobnitz}/docs/README.md (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v2.reqs => intern/chart/v3/loader/testdata/frobnitz}/icon.svg (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v2.reqs => intern/chart/v3/loader/testdata/frobnitz}/ignore/me.txt (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v2.reqs => intern/chart/v3/loader/testdata/frobnitz}/templates/template.tpl (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v2.reqs => intern/chart/v3/loader/testdata/frobnitz}/values.yaml (100%) create mode 100644 pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash-1.2.3.tgz rename pkg/helm/{pkg/chart/loader/testdata/frobnitz => intern/chart/v3/loader/testdata/frobnitz_backslash}/.helmignore (100%) mode change 100644 => 100755 rename pkg/helm/{pkg/chart/loader/testdata/frobnitz => intern/chart/v3/loader/testdata/frobnitz_backslash}/Chart.lock (100%) mode change 100644 => 100755 create mode 100755 pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/Chart.yaml rename pkg/helm/{pkg/chart/loader/testdata/frobnitz => intern/chart/v3/loader/testdata/frobnitz_backslash}/INSTALL.txt (100%) mode change 100644 => 100755 rename pkg/helm/{pkg/chart/loader/testdata/frobnitz => intern/chart/v3/loader/testdata/frobnitz_backslash}/LICENSE (100%) mode change 100644 => 100755 rename pkg/helm/{pkg/chart/loader/testdata/frobnitz => intern/chart/v3/loader/testdata/frobnitz_backslash}/README.md (100%) mode change 100644 => 100755 rename pkg/helm/{pkg/chart/loader/testdata/frobnitz => intern/chart/v3/loader/testdata/frobnitz_backslash}/charts/_ignore_me (100%) mode change 100644 => 100755 create mode 100755 pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/Chart.yaml rename pkg/helm/{pkg/chart/loader/testdata/frobnitz => intern/chart/v3/loader/testdata/frobnitz_backslash}/charts/alpine/README.md (100%) mode change 100644 => 100755 create mode 100755 pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/Chart.yaml rename pkg/helm/{pkg/chart/loader/testdata/frobnitz => intern/chart/v3/loader/testdata/frobnitz_backslash}/charts/alpine/charts/mast1/values.yaml (100%) mode change 100644 => 100755 rename pkg/helm/{pkg/chart/loader/testdata/frobnitz => intern/chart/v3/loader/testdata/frobnitz_backslash}/charts/alpine/charts/mast2-0.1.0.tgz (100%) mode change 100644 => 100755 rename pkg/helm/{pkg/chart => intern/chart/v3}/loader/testdata/frobnitz_backslash/charts/alpine/templates/alpine-pod.yaml (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz => intern/chart/v3/loader/testdata/frobnitz_backslash}/charts/alpine/values.yaml (100%) mode change 100644 => 100755 create mode 100755 pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/charts/mariner-4.3.2.tgz rename pkg/helm/{pkg/chart/loader/testdata/frobnitz => intern/chart/v3/loader/testdata/frobnitz_backslash}/docs/README.md (100%) mode change 100644 => 100755 rename pkg/helm/{pkg/chart/loader/testdata/frobnitz => intern/chart/v3/loader/testdata/frobnitz_backslash}/icon.svg (100%) mode change 100644 => 100755 rename pkg/helm/{pkg/chart/loader/testdata/frobnitz => intern/chart/v3/loader/testdata/frobnitz_backslash}/ignore/me.txt (100%) mode change 100644 => 100755 rename pkg/helm/{pkg/chart/loader/testdata/frobnitz => intern/chart/v3/loader/testdata/frobnitz_backslash}/templates/template.tpl (100%) mode change 100644 => 100755 rename pkg/helm/{pkg/chart/loader/testdata/frobnitz => intern/chart/v3/loader/testdata/frobnitz_backslash}/values.yaml (100%) mode change 100644 => 100755 create mode 100644 pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom.tgz rename pkg/helm/{pkg/chart => intern/chart/v3}/loader/testdata/frobnitz_with_bom/.helmignore (100%) rename pkg/helm/{pkg/chart => intern/chart/v3}/loader/testdata/frobnitz_with_bom/Chart.lock (100%) create mode 100644 pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/Chart.yaml rename pkg/helm/{pkg/chart => intern/chart/v3}/loader/testdata/frobnitz_with_bom/INSTALL.txt (100%) rename pkg/helm/{pkg/chart => intern/chart/v3}/loader/testdata/frobnitz_with_bom/LICENSE (100%) rename pkg/helm/{pkg/chart => intern/chart/v3}/loader/testdata/frobnitz_with_bom/README.md (100%) rename pkg/helm/{pkg/chart => intern/chart/v3}/loader/testdata/frobnitz_with_bom/charts/_ignore_me (100%) create mode 100644 pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/Chart.yaml rename pkg/helm/{pkg/chart => intern/chart/v3}/loader/testdata/frobnitz_with_bom/charts/alpine/README.md (100%) create mode 100644 pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/Chart.yaml rename pkg/helm/{pkg/chart => intern/chart/v3}/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/values.yaml (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_backslash => intern/chart/v3/loader/testdata/frobnitz_with_bom}/charts/alpine/charts/mast2-0.1.0.tgz (100%) mode change 100755 => 100644 rename pkg/helm/{pkg/chart => intern/chart/v3}/loader/testdata/frobnitz_with_bom/charts/alpine/templates/alpine-pod.yaml (100%) rename pkg/helm/{pkg/chart => intern/chart/v3}/loader/testdata/frobnitz_with_bom/charts/alpine/values.yaml (100%) create mode 100644 pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/charts/mariner-4.3.2.tgz rename pkg/helm/{pkg/chart => intern/chart/v3}/loader/testdata/frobnitz_with_bom/docs/README.md (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_backslash => intern/chart/v3/loader/testdata/frobnitz_with_bom}/icon.svg (100%) mode change 100755 => 100644 rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_backslash => intern/chart/v3/loader/testdata/frobnitz_with_bom}/ignore/me.txt (100%) mode change 100755 => 100644 rename pkg/helm/{pkg/chart => intern/chart/v3}/loader/testdata/frobnitz_with_bom/templates/template.tpl (100%) rename pkg/helm/{pkg/chart => intern/chart/v3}/loader/testdata/frobnitz_with_bom/values.yaml (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_backslash => intern/chart/v3/loader/testdata/frobnitz_with_dev_null}/.helmignore (100%) mode change 100755 => 100644 rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_backslash => intern/chart/v3/loader/testdata/frobnitz_with_dev_null}/Chart.lock (100%) mode change 100755 => 100644 create mode 100644 pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_dev_null/Chart.yaml rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_backslash => intern/chart/v3/loader/testdata/frobnitz_with_dev_null}/INSTALL.txt (100%) mode change 100755 => 100644 rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_backslash => intern/chart/v3/loader/testdata/frobnitz_with_dev_null}/LICENSE (100%) mode change 100755 => 100644 rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_backslash => intern/chart/v3/loader/testdata/frobnitz_with_dev_null}/README.md (100%) mode change 100755 => 100644 rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_backslash => intern/chart/v3/loader/testdata/frobnitz_with_dev_null}/charts/_ignore_me (100%) mode change 100755 => 100644 create mode 100644 pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_dev_null/charts/alpine/Chart.yaml rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_backslash => intern/chart/v3/loader/testdata/frobnitz_with_dev_null}/charts/alpine/README.md (100%) mode change 100755 => 100644 create mode 100644 pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/Chart.yaml rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_backslash => intern/chart/v3/loader/testdata/frobnitz_with_dev_null}/charts/alpine/charts/mast1/values.yaml (100%) mode change 100755 => 100644 rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_bom => intern/chart/v3/loader/testdata/frobnitz_with_dev_null}/charts/alpine/charts/mast2-0.1.0.tgz (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz => intern/chart/v3/loader/testdata/frobnitz_with_dev_null}/charts/alpine/templates/alpine-pod.yaml (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_backslash => intern/chart/v3/loader/testdata/frobnitz_with_dev_null}/charts/alpine/values.yaml (100%) mode change 100755 => 100644 create mode 100644 pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_dev_null/charts/mariner-4.3.2.tgz rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_backslash => intern/chart/v3/loader/testdata/frobnitz_with_dev_null}/docs/README.md (100%) mode change 100755 => 100644 rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_bom => intern/chart/v3/loader/testdata/frobnitz_with_dev_null}/icon.svg (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_bom => intern/chart/v3/loader/testdata/frobnitz_with_dev_null}/ignore/me.txt (100%) rename pkg/helm/{pkg/chart => intern/chart/v3}/loader/testdata/frobnitz_with_dev_null/null (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_backslash => intern/chart/v3/loader/testdata/frobnitz_with_dev_null}/templates/template.tpl (100%) mode change 100755 => 100644 rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_backslash => intern/chart/v3/loader/testdata/frobnitz_with_dev_null}/values.yaml (100%) mode change 100755 => 100644 rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_dev_null => intern/chart/v3/loader/testdata/frobnitz_with_symlink}/.helmignore (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_dev_null => intern/chart/v3/loader/testdata/frobnitz_with_symlink}/Chart.lock (100%) create mode 100644 pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_symlink/Chart.yaml rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_dev_null => intern/chart/v3/loader/testdata/frobnitz_with_symlink}/INSTALL.txt (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_dev_null => intern/chart/v3/loader/testdata/frobnitz_with_symlink}/README.md (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_dev_null => intern/chart/v3/loader/testdata/frobnitz_with_symlink}/charts/_ignore_me (100%) create mode 100644 pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_symlink/charts/alpine/Chart.yaml rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_dev_null => intern/chart/v3/loader/testdata/frobnitz_with_symlink}/charts/alpine/README.md (100%) create mode 100644 pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/Chart.yaml rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_dev_null => intern/chart/v3/loader/testdata/frobnitz_with_symlink}/charts/alpine/charts/mast1/values.yaml (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_dev_null => intern/chart/v3/loader/testdata/frobnitz_with_symlink}/charts/alpine/charts/mast2-0.1.0.tgz (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_dev_null => intern/chart/v3/loader/testdata/frobnitz_with_symlink}/charts/alpine/templates/alpine-pod.yaml (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_dev_null => intern/chart/v3/loader/testdata/frobnitz_with_symlink}/charts/alpine/values.yaml (100%) create mode 100644 pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_symlink/charts/mariner-4.3.2.tgz rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_dev_null => intern/chart/v3/loader/testdata/frobnitz_with_symlink}/docs/README.md (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_dev_null => intern/chart/v3/loader/testdata/frobnitz_with_symlink}/icon.svg (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_dev_null => intern/chart/v3/loader/testdata/frobnitz_with_symlink}/ignore/me.txt (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_dev_null => intern/chart/v3/loader/testdata/frobnitz_with_symlink}/templates/template.tpl (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_dev_null => intern/chart/v3/loader/testdata/frobnitz_with_symlink}/values.yaml (100%) create mode 100755 pkg/helm/intern/chart/v3/loader/testdata/genfrob.sh create mode 100644 pkg/helm/intern/chart/v3/loader/testdata/mariner/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/loader/testdata/mariner/charts/albatross-0.1.0.tgz rename pkg/helm/{pkg/chart => intern/chart/v3}/loader/testdata/mariner/templates/placeholder.tpl (100%) rename pkg/helm/{pkg/chart => intern/chart/v3}/loader/testdata/mariner/values.yaml (100%) rename pkg/helm/{pkg/chart => intern/chart/v3}/metadata.go (99%) create mode 100644 pkg/helm/intern/chart/v3/metadata_test.go create mode 100644 pkg/helm/intern/chart/v3/util/chartfile.go create mode 100644 pkg/helm/intern/chart/v3/util/chartfile_test.go rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/compatible.go (98%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/compatible_test.go (98%) create mode 100644 pkg/helm/intern/chart/v3/util/create.go create mode 100644 pkg/helm/intern/chart/v3/util/create_test.go create mode 100644 pkg/helm/intern/chart/v3/util/dependencies.go rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/dependencies_test.go (83%) create mode 100644 pkg/helm/intern/chart/v3/util/doc.go create mode 100644 pkg/helm/intern/chart/v3/util/expand.go rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/expand_test.go (95%) create mode 100644 pkg/helm/intern/chart/v3/util/save.go create mode 100644 pkg/helm/intern/chart/v3/util/save_test.go create mode 100644 pkg/helm/intern/chart/v3/util/testdata/chart-with-dependency-aliased-twice/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/templates/dummy.yaml create mode 100644 pkg/helm/intern/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/templates/dummy.yaml create mode 100644 pkg/helm/intern/chart/v3/util/testdata/chart-with-dependency-aliased-twice/values.yaml create mode 100644 pkg/helm/intern/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/values.yaml create mode 100644 pkg/helm/intern/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/templates/dummy.yaml create mode 100644 pkg/helm/intern/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/templates/dummy.yaml create mode 100644 pkg/helm/intern/chart/v3/util/testdata/chartfiletest.yaml rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/coleridge.yaml (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_symlink => intern/chart/v3/util/testdata/dependent-chart-alias}/.helmignore (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_symlink => intern/chart/v3/util/testdata/dependent-chart-alias}/Chart.lock (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/Chart.yaml rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_symlink => intern/chart/v3/util/testdata/dependent-chart-alias}/INSTALL.txt (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_dev_null => intern/chart/v3/util/testdata/dependent-chart-alias}/LICENSE (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_symlink => intern/chart/v3/util/testdata/dependent-chart-alias}/README.md (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_symlink => intern/chart/v3/util/testdata/dependent-chart-alias}/charts/_ignore_me (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/Chart.yaml rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_symlink => intern/chart/v3/util/testdata/dependent-chart-alias}/charts/alpine/README.md (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/charts/mast1/Chart.yaml rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_symlink => intern/chart/v3/util/testdata/dependent-chart-alias}/charts/alpine/charts/mast1/values.yaml (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_symlink => intern/chart/v3/util/testdata/dependent-chart-alias}/charts/alpine/charts/mast2-0.1.0.tgz (100%) rename pkg/helm/{cmd/helm/testdata/testcharts/signtest => intern/chart/v3/util/testdata/dependent-chart-alias/charts}/alpine/templates/alpine-pod.yaml (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_symlink => intern/chart/v3/util/testdata/dependent-chart-alias}/charts/alpine/values.yaml (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz.v2.reqs => intern/chart/v3/util/testdata/dependent-chart-alias}/charts/mariner-4.3.2.tgz (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_symlink => intern/chart/v3/util/testdata/dependent-chart-alias}/docs/README.md (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_symlink => intern/chart/v3/util/testdata/dependent-chart-alias}/icon.svg (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_symlink => intern/chart/v3/util/testdata/dependent-chart-alias}/ignore/me.txt (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_symlink => intern/chart/v3/util/testdata/dependent-chart-alias}/templates/template.tpl (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_symlink => intern/chart/v3/util/testdata/dependent-chart-alias}/values.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/dependent-chart-helmignore/.helmignore (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/Chart.yaml rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/dependent-chart-helmignore/charts/.ignore_me (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-alias => intern/chart/v3/util/testdata/dependent-chart-helmignore}/charts/_ignore_me (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/Chart.yaml rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-alias => intern/chart/v3/util/testdata/dependent-chart-helmignore}/charts/alpine/README.md (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/Chart.yaml rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-alias => intern/chart/v3/util/testdata/dependent-chart-helmignore}/charts/alpine/charts/mast1/values.yaml (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-alias => intern/chart/v3/util/testdata/dependent-chart-helmignore}/charts/alpine/charts/mast2-0.1.0.tgz (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-alias => intern/chart/v3/util/testdata/dependent-chart-helmignore}/charts/alpine/templates/alpine-pod.yaml (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-alias => intern/chart/v3/util/testdata/dependent-chart-helmignore}/charts/alpine/values.yaml (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-alias => intern/chart/v3/util/testdata/dependent-chart-helmignore}/templates/template.tpl (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-alias => intern/chart/v3/util/testdata/dependent-chart-helmignore}/values.yaml (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-alias => intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml}/.helmignore (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/Chart.yaml rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-alias => intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml}/INSTALL.txt (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-alias => intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml}/LICENSE (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-alias => intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml}/README.md (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-helmignore => intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml}/charts/_ignore_me (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/Chart.yaml rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-helmignore => intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml}/charts/alpine/README.md (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-helmignore => intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml}/charts/alpine/charts/mast1/values.yaml (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-helmignore => intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml}/charts/alpine/charts/mast2-0.1.0.tgz (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-helmignore => intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml}/charts/alpine/templates/alpine-pod.yaml (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-helmignore => intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml}/charts/alpine/values.yaml (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz => intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml}/charts/mariner-4.3.2.tgz (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-alias => intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml}/docs/README.md (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-alias => intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml}/icon.svg (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-alias => intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml}/ignore/me.txt (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-helmignore => intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml}/templates/template.tpl (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-helmignore => intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml}/values.yaml (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-no-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml}/.helmignore (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/Chart.yaml rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-no-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml}/INSTALL.txt (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-no-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml}/LICENSE (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-no-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml}/README.md (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-no-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml}/charts/_ignore_me (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/Chart.yaml rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-no-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml}/charts/alpine/README.md (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-no-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml}/charts/alpine/charts/mast1/values.yaml (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-no-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml}/charts/alpine/charts/mast2-0.1.0.tgz (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-no-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml}/charts/alpine/templates/alpine-pod.yaml (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-no-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml}/charts/alpine/values.yaml (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_backslash => intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml}/charts/mariner-4.3.2.tgz (100%) mode change 100755 => 100644 rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-no-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml}/docs/README.md (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-no-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml}/icon.svg (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-no-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml}/ignore/me.txt (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-no-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml}/templates/template.tpl (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-no-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml}/values.yaml (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml}/.helmignore (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/Chart.yaml rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml}/INSTALL.txt (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml}/LICENSE (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml}/README.md (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml}/charts/_ignore_me (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/Chart.yaml rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml}/charts/alpine/README.md (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml}/charts/alpine/charts/mast1/values.yaml (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml}/charts/alpine/charts/mast2-0.1.0.tgz (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml}/charts/alpine/templates/alpine-pod.yaml (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml}/charts/alpine/values.yaml (100%) rename pkg/helm/{pkg/chart/loader/testdata/frobnitz_with_bom => intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml}/charts/mariner-4.3.2.tgz (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml}/docs/README.md (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml}/icon.svg (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml}/ignore/me.txt (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml}/templates/template.tpl (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml => intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml}/values.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/frobnitz-1.2.3.tgz (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml => intern/chart/v3/util/testdata/frobnitz}/.helmignore (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-alias => intern/chart/v3/util/testdata/frobnitz}/Chart.lock (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/frobnitz/Chart.yaml rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml => intern/chart/v3/util/testdata/frobnitz}/INSTALL.txt (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml => intern/chart/v3/util/testdata/frobnitz}/LICENSE (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml => intern/chart/v3/util/testdata/frobnitz}/README.md (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml => intern/chart/v3/util/testdata/frobnitz}/charts/_ignore_me (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/alpine/Chart.yaml rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml => intern/chart/v3/util/testdata/frobnitz}/charts/alpine/README.md (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml => intern/chart/v3/util/testdata/frobnitz}/charts/alpine/charts/mast1/values.yaml (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml => intern/chart/v3/util/testdata/frobnitz}/charts/alpine/charts/mast2-0.1.0.tgz (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml => intern/chart/v3/util/testdata/frobnitz}/charts/alpine/templates/alpine-pod.yaml (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml => intern/chart/v3/util/testdata/frobnitz}/charts/alpine/values.yaml (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/mariner/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/mariner/charts/albatross/Chart.yaml rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/frobnitz/charts/mariner/charts/albatross/values.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/frobnitz/charts/mariner/templates/placeholder.tpl (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/frobnitz/charts/mariner/values.yaml (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml => intern/chart/v3/util/testdata/frobnitz}/docs/README.md (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml => intern/chart/v3/util/testdata/frobnitz}/icon.svg (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml => intern/chart/v3/util/testdata/frobnitz}/ignore/me.txt (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml => intern/chart/v3/util/testdata/frobnitz}/templates/template.tpl (100%) rename pkg/helm/{pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml => intern/chart/v3/util/testdata/frobnitz}/values.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/frobnitz_backslash-1.2.3.tgz (100%) rename pkg/helm/{pkg/chart/loader => intern/chart/v3/util}/testdata/genfrob.sh (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/import-values-from-enabled-subchart/parent-chart/Chart.lock (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/Chart.yaml rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/import-values-from-enabled-subchart/parent-chart/charts/dev-v0.1.0.tgz (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/import-values-from-enabled-subchart/parent-chart/charts/prod-v0.1.0.tgz (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/Chart.yaml rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/values.yaml (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/Chart.yaml rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/values.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/import-values-from-enabled-subchart/parent-chart/templates/autoscaler.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/import-values-from-enabled-subchart/parent-chart/values.yaml (80%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/joonix/Chart.yaml rename pkg/helm/{cmd/helm/testdata/helmhome/helm/plugins/echo/completion.yaml => intern/chart/v3/util/testdata/joonix/charts/.gitkeep} (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/subpop/Chart.yaml rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/subpop/README.md (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartA/Chart.yaml rename pkg/helm/{cmd/helm/testdata/testcharts/subchart => intern/chart/v3/util/testdata/subpop/charts/subchart1}/charts/subchartA/templates/service.yaml (100%) rename pkg/helm/{cmd/helm/testdata/testcharts/subchart => intern/chart/v3/util/testdata/subpop/charts/subchart1}/charts/subchartA/values.yaml (99%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartB/Chart.yaml rename pkg/helm/{cmd/helm/testdata/testcharts/subchart => intern/chart/v3/util/testdata/subpop/charts/subchart1}/charts/subchartB/templates/service.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/subpop/charts/subchart1/charts/subchartB/values.yaml (99%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/subpop/charts/subchart1/crds/crdA.yaml (100%) rename pkg/helm/{cmd/helm/testdata/testcharts/subchart => intern/chart/v3/util/testdata/subpop/charts/subchart1}/templates/NOTES.txt (100%) rename pkg/helm/{cmd/helm/testdata/testcharts/subchart => intern/chart/v3/util/testdata/subpop/charts/subchart1}/templates/service.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/subpop/charts/subchart1/templates/subdir/role.yaml (100%) rename pkg/helm/{cmd/helm/testdata/testcharts/subchart => intern/chart/v3/util/testdata/subpop/charts/subchart1}/templates/subdir/rolebinding.yaml (100%) rename pkg/helm/{cmd/helm/testdata/testcharts/subchart => intern/chart/v3/util/testdata/subpop/charts/subchart1}/templates/subdir/serviceaccount.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/subpop/charts/subchart1/values.yaml (60%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartB/Chart.yaml rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/subpop/charts/subchart2/charts/subchartB/templates/service.yaml (85%) rename pkg/helm/{pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC => intern/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartB}/values.yaml (99%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartC/Chart.yaml rename pkg/helm/{pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA => intern/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartC}/templates/service.yaml (100%) rename pkg/helm/{pkg/chartutil/testdata/subpop/charts/subchart2 => intern/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartC}/values.yaml (99%) rename pkg/helm/{pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB => intern/chart/v3/util/testdata/subpop/charts/subchart2}/templates/service.yaml (100%) rename pkg/helm/{pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB => intern/chart/v3/util/testdata/subpop/charts/subchart2}/values.yaml (99%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/subpop/noreqs/Chart.yaml rename pkg/helm/{pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC => intern/chart/v3/util/testdata/subpop/noreqs}/templates/service.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/subpop/noreqs/values.yaml (99%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/subpop/values.yaml (56%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/test-values-invalid.schema.json (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/test-values-negative.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/test-values.schema.json (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/test-values.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/three-level-dependent-chart/README.md (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/Chart.yaml rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/templates/service.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/values.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/three-level-dependent-chart/umbrella/charts/app1/templates/service.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/three-level-dependent-chart/umbrella/charts/app1/values.yaml (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/Chart.yaml rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/templates/service.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/values.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/three-level-dependent-chart/umbrella/charts/app2/templates/service.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/three-level-dependent-chart/umbrella/charts/app2/values.yaml (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/Chart.yaml rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/templates/service.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/values.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/three-level-dependent-chart/umbrella/charts/app3/templates/service.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/three-level-dependent-chart/umbrella/charts/app3/values.yaml (100%) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/Chart.yaml rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/templates/service.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/values.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/three-level-dependent-chart/umbrella/charts/app4/templates/service.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/three-level-dependent-chart/umbrella/charts/app4/values.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/testdata/three-level-dependent-chart/umbrella/values.yaml (100%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/validate_name.go (98%) rename pkg/helm/{pkg/chartutil => intern/chart/v3/util}/validate_name_test.go (97%) create mode 100644 pkg/helm/intern/cli/output/color.go create mode 100644 pkg/helm/intern/cli/output/color_test.go create mode 100644 pkg/helm/intern/copystructure/copystructure.go create mode 100644 pkg/helm/intern/copystructure/copystructure_test.go rename pkg/helm/{internal => intern}/fileutil/fileutil.go (95%) create mode 100644 pkg/helm/intern/fileutil/fileutil_test.go create mode 100644 pkg/helm/intern/fileutil/fileutil_unix.go create mode 100644 pkg/helm/intern/fileutil/fileutil_windows.go create mode 100644 pkg/helm/intern/logging/logging.go create mode 100644 pkg/helm/intern/logging/logging_test.go rename pkg/helm/{internal => intern}/monocular/client.go (90%) rename pkg/helm/{internal => intern}/monocular/client_test.go (100%) rename pkg/helm/{internal => intern}/monocular/doc.go (100%) rename pkg/helm/{internal => intern}/monocular/search.go (96%) rename pkg/helm/{internal => intern}/monocular/search_test.go (99%) rename pkg/helm/{internal => intern}/resolver/resolver.go (80%) rename pkg/helm/{internal => intern}/resolver/resolver_test.go (98%) rename pkg/helm/{internal => intern}/resolver/testdata/chartpath/base/Chart.yaml (100%) rename pkg/helm/{internal => intern}/resolver/testdata/chartpath/charts/localdependency/Chart.yaml (100%) rename pkg/helm/{internal => intern}/resolver/testdata/repository/kubernetes-charts-index.yaml (100%) create mode 100644 pkg/helm/intern/statusreaders/job_status_reader.go create mode 100644 pkg/helm/intern/statusreaders/job_status_reader_test.go create mode 100644 pkg/helm/intern/statusreaders/pod_status_reader.go create mode 100644 pkg/helm/intern/statusreaders/pod_status_reader_test.go rename pkg/helm/{internal => intern}/sympath/walk.go (89%) rename pkg/helm/{internal => intern}/sympath/walk_test.go (87%) rename pkg/helm/{internal => intern}/test/ensure/ensure.go (81%) rename pkg/helm/{internal => intern}/test/test.go (89%) rename pkg/helm/{internal => intern}/third_party/dep/fs/fs.go (89%) rename pkg/helm/{internal => intern}/third_party/dep/fs/fs_test.go (96%) rename pkg/helm/{internal => intern}/third_party/dep/fs/rename.go (95%) rename pkg/helm/{internal => intern}/third_party/dep/fs/rename_windows.go (96%) rename pkg/helm/{internal => intern}/third_party/dep/fs/testdata/symlinks/file-symlink (100%) rename pkg/helm/{internal => intern}/third_party/dep/fs/testdata/symlinks/invalid-symlink (100%) rename pkg/helm/{internal => intern}/third_party/dep/fs/testdata/symlinks/windows-file-symlink (100%) rename pkg/helm/{internal => intern}/third_party/dep/fs/testdata/test.file (100%) rename pkg/helm/{internal => intern}/third_party/k8s.io/kubernetes/deployment/util/deploymentutil.go (100%) create mode 100644 pkg/helm/intern/tlsutil/tls.go create mode 100644 pkg/helm/intern/tlsutil/tls_test.go rename pkg/helm/{internal => intern}/urlutil/urlutil.go (100%) rename pkg/helm/{internal => intern}/urlutil/urlutil_test.go (100%) create mode 100644 pkg/helm/intern/version/clientgo.go create mode 100644 pkg/helm/intern/version/clientgo_test.go rename pkg/helm/{internal => intern}/version/version.go (61%) delete mode 100644 pkg/helm/internal/fileutil/fileutil_test.go delete mode 100644 pkg/helm/internal/tlsutil/cfg.go delete mode 100644 pkg/helm/internal/tlsutil/tls.go delete mode 100644 pkg/helm/internal/tlsutil/tlsutil_test.go delete mode 100644 pkg/helm/pkg/action/exports.go create mode 100644 pkg/helm/pkg/action/get_values.go create mode 100644 pkg/helm/pkg/action/history_test.go create mode 100644 pkg/helm/pkg/action/pull_test.go create mode 100644 pkg/helm/pkg/action/push_test.go create mode 100644 pkg/helm/pkg/action/registry_login_test.go create mode 100644 pkg/helm/pkg/action/registry_logout_test.go create mode 100644 pkg/helm/pkg/action/show.go create mode 100644 pkg/helm/pkg/action/show_test.go create mode 100644 pkg/helm/pkg/action/status.go create mode 100644 pkg/helm/pkg/action/status_test.go create mode 100644 pkg/helm/pkg/action/uninstall.go delete mode 100644 pkg/helm/pkg/action/validate.go create mode 100644 pkg/helm/pkg/action/verify_test.go create mode 100644 pkg/helm/pkg/chart/common.go create mode 100644 pkg/helm/pkg/chart/common/capabilities.go rename pkg/helm/pkg/{chartutil => chart/common}/capabilities_test.go (59%) rename pkg/helm/pkg/{chartutil => chart/common}/errors.go (98%) rename pkg/helm/pkg/{chartutil => chart/common}/errors_test.go (97%) rename pkg/helm/pkg/chart/{ => common}/file.go (88%) create mode 100644 pkg/helm/pkg/chart/common/runtime_data.go create mode 100644 pkg/helm/pkg/chart/common/testdata/coleridge.yaml rename pkg/helm/pkg/{chartutil => chart/common/util}/coalesce.go (71%) rename pkg/helm/pkg/{chartutil => chart/common/util}/coalesce_test.go (87%) create mode 100644 pkg/helm/pkg/chart/common/util/jsonschema.go create mode 100644 pkg/helm/pkg/chart/common/util/jsonschema_test.go create mode 100644 pkg/helm/pkg/chart/common/util/testdata/test-values-invalid.schema.json rename pkg/helm/{cmd/helm/testdata/testcharts/chart-with-schema-negative/values.yaml => pkg/chart/common/util/testdata/test-values-negative.yaml} (100%) rename pkg/helm/{cmd/helm/testdata/testcharts/chart-with-schema-negative/values.schema.json => pkg/chart/common/util/testdata/test-values.schema.json} (100%) rename pkg/helm/{cmd/helm/testdata/testcharts/chart-with-schema/values.yaml => pkg/chart/common/util/testdata/test-values.yaml} (100%) create mode 100644 pkg/helm/pkg/chart/common/util/values.go create mode 100644 pkg/helm/pkg/chart/common/util/values_test.go rename pkg/helm/pkg/{chartutil => chart/common}/values.go (75%) rename pkg/helm/pkg/{chartutil => chart/common}/values_test.go (66%) create mode 100644 pkg/helm/pkg/chart/interfaces.go rename pkg/helm/pkg/chart/loader/{ => archive}/archive.go (67%) rename pkg/helm/pkg/chart/loader/{ => archive}/archive_test.go (93%) delete mode 100644 pkg/helm/pkg/chart/loader/directory.go rename pkg/helm/pkg/chart/{ => v2}/chart.go (85%) rename pkg/helm/pkg/chart/{ => v2}/chart_test.go (66%) create mode 100644 pkg/helm/pkg/chart/v2/dependency.go create mode 100644 pkg/helm/pkg/chart/v2/dependency_test.go create mode 100644 pkg/helm/pkg/chart/v2/doc.go create mode 100644 pkg/helm/pkg/chart/v2/errors.go create mode 100644 pkg/helm/pkg/chart/v2/fuzz_test.go create mode 100644 pkg/helm/pkg/chart/v2/lint/lint.go create mode 100644 pkg/helm/pkg/chart/v2/lint/lint_test.go create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/chartfile.go create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/chartfile_test.go create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/crds.go create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/crds_test.go create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/dependencies.go create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/dependencies_test.go create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/deprecations.go create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/deprecations_test.go create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/template.go create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/template_test.go create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/albatross/Chart.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/albatross/templates/_helpers.tpl create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/albatross/templates/fail.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/albatross/templates/svc.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/albatross/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/anotherbadchartfile/Chart.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/badchartfile/Chart.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/badchartfile/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/badchartname/Chart.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/badchartname/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/badcrdfile/Chart.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/badcrdfile/crds/bad-apiversion.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/badcrdfile/crds/bad-crd.yaml rename pkg/helm/{cmd/helm/testdata/helmhome/helm/repository/test-name-charts.txt => pkg/chart/v2/lint/rules/testdata/badcrdfile/templates/.gitkeep} (100%) create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/badcrdfile/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/badvaluesfile/Chart.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/badvaluesfile/templates/badvaluesfile.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/badvaluesfile/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/goodone/Chart.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/goodone/crds/test-crd.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/goodone/templates/goodone.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/goodone/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/invalidchartfile/Chart.yaml rename pkg/helm/{cmd/helm/testdata/testcharts/chart-with-bad-subcharts => pkg/chart/v2/lint/rules/testdata/invalidchartfile}/values.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/invalidcrdsdir/Chart.yaml rename pkg/helm/{cmd/helm/testdata/output/lint-quiet-with-warning.txt => pkg/chart/v2/lint/rules/testdata/invalidcrdsdir/crds} (100%) create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/invalidcrdsdir/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/malformed-template/.helmignore create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/malformed-template/Chart.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/malformed-template/templates/bad.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/malformed-template/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/multi-template-fail/Chart.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/multi-template-fail/templates/multi-fail.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/v3-fail/Chart.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/_helpers.tpl create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/deployment.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/ingress.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/service.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/v3-fail/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/withsubchart/Chart.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/withsubchart/charts/subchart/Chart.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/withsubchart/charts/subchart/templates/subchart.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/withsubchart/charts/subchart/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/testdata/withsubchart/templates/mainchart.yaml rename pkg/helm/{cmd/helm/testdata/testcharts/chart-with-deprecated-api => pkg/chart/v2/lint/rules/testdata/withsubchart}/values.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/values.go create mode 100644 pkg/helm/pkg/chart/v2/lint/rules/values_test.go create mode 100644 pkg/helm/pkg/chart/v2/lint/support/doc.go create mode 100644 pkg/helm/pkg/chart/v2/lint/support/message.go create mode 100644 pkg/helm/pkg/chart/v2/lint/support/message_test.go create mode 100644 pkg/helm/pkg/chart/v2/loader/archive.go rename pkg/helm/pkg/{werf/chartextender => chart/v2/loader}/chart_metadata.go (71%) create mode 100644 pkg/helm/pkg/chart/v2/loader/directory.go create mode 100644 pkg/helm/pkg/chart/v2/loader/load.go create mode 100644 pkg/helm/pkg/chart/v2/loader/load_test.go rename pkg/helm/pkg/{chartutil/testdata/frobnitz => chart/v2/loader/testdata}/LICENSE (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/albatross/Chart.yaml create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/albatross/values.yaml rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz-1.2.3.tgz (100%) rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz.v1.tgz (100%) rename pkg/helm/pkg/{chartutil/testdata/frobnitz => chart/v2/loader/testdata/frobnitz.v1}/.helmignore (100%) rename pkg/helm/pkg/{chartutil/testdata/frobnitz => chart/v2/loader/testdata/frobnitz.v1}/Chart.lock (100%) rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz.v1/Chart.yaml (100%) rename pkg/helm/pkg/{chartutil/testdata/frobnitz => chart/v2/loader/testdata/frobnitz.v1}/INSTALL.txt (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/LICENSE rename pkg/helm/pkg/{chartutil/testdata/frobnitz => chart/v2/loader/testdata/frobnitz.v1}/README.md (100%) rename pkg/helm/pkg/{chartutil/testdata/frobnitz => chart/v2/loader/testdata/frobnitz.v1}/charts/_ignore_me (100%) rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz.v1/charts/alpine/Chart.yaml (100%) rename pkg/helm/pkg/{chartutil/testdata/frobnitz => chart/v2/loader/testdata/frobnitz.v1}/charts/alpine/README.md (100%) rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz.v1/charts/alpine/charts/mast1/Chart.yaml (100%) rename pkg/helm/pkg/{chartutil/testdata/frobnitz => chart/v2/loader/testdata/frobnitz.v1}/charts/alpine/charts/mast1/values.yaml (100%) rename pkg/helm/pkg/{chartutil/testdata/frobnitz => chart/v2/loader/testdata/frobnitz.v1}/charts/alpine/charts/mast2-0.1.0.tgz (100%) rename pkg/helm/pkg/chart/{loader/testdata/frobnitz_with_symlink => v2/loader/testdata/frobnitz.v1}/charts/alpine/templates/alpine-pod.yaml (100%) rename pkg/helm/pkg/{chartutil/testdata/frobnitz => chart/v2/loader/testdata/frobnitz.v1}/charts/alpine/values.yaml (100%) rename pkg/helm/pkg/chart/{loader/testdata/frobnitz_with_dev_null => v2/loader/testdata/frobnitz.v1}/charts/mariner-4.3.2.tgz (100%) rename pkg/helm/pkg/{chartutil/testdata/frobnitz => chart/v2/loader/testdata/frobnitz.v1}/docs/README.md (100%) rename pkg/helm/pkg/{chartutil/testdata/frobnitz => chart/v2/loader/testdata/frobnitz.v1}/icon.svg (100%) rename pkg/helm/pkg/{chartutil/testdata/frobnitz => chart/v2/loader/testdata/frobnitz.v1}/ignore/me.txt (100%) rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz.v1/requirements.yaml (100%) rename pkg/helm/pkg/{chartutil/testdata/frobnitz => chart/v2/loader/testdata/frobnitz.v1}/templates/template.tpl (100%) rename pkg/helm/pkg/{chartutil/testdata/frobnitz => chart/v2/loader/testdata/frobnitz.v1}/values.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/.helmignore rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz.v2.reqs/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/INSTALL.txt create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/LICENSE create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/README.md create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/_ignore_me rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz.v2.reqs/charts/alpine/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/README.md rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast2-0.1.0.tgz create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/templates/alpine-pod.yaml create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/values.yaml rename pkg/helm/pkg/chart/{loader/testdata/frobnitz_with_symlink => v2/loader/testdata/frobnitz.v2.reqs}/charts/mariner-4.3.2.tgz (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/docs/README.md create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/icon.svg rename pkg/helm/{cmd/helm/testdata/output/lint-quiet.txt => pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/ignore/me.txt} (100%) rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz.v2.reqs/requirements.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/templates/template.tpl create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/.helmignore create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/Chart.lock rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/INSTALL.txt create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/LICENSE create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/README.md create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/_ignore_me rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz/charts/alpine/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/README.md rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/values.yaml rename pkg/helm/pkg/{chartutil/testdata/dependent-chart-alias => chart/v2/loader/testdata/frobnitz}/charts/mariner-4.3.2.tgz (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/docs/README.md create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/icon.svg rename pkg/helm/{cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.yaml => pkg/chart/v2/loader/testdata/frobnitz/ignore/me.txt} (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/templates/template.tpl create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/values.yaml rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz_backslash-1.2.3.tgz (100%) create mode 100755 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/.helmignore create mode 100755 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/Chart.lock rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz_backslash/Chart.yaml (100%) create mode 100755 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/INSTALL.txt create mode 100755 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/LICENSE create mode 100755 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/README.md create mode 100755 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/_ignore_me rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz_backslash/charts/alpine/Chart.yaml (100%) create mode 100755 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/README.md rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/Chart.yaml (100%) create mode 100755 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/values.yaml create mode 100755 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast2-0.1.0.tgz create mode 100755 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/templates/alpine-pod.yaml create mode 100755 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/values.yaml rename pkg/helm/pkg/{chartutil/testdata/dependent-chart-no-requirements-yaml => chart/v2/loader/testdata/frobnitz_backslash}/charts/mariner-4.3.2.tgz (100%) mode change 100644 => 100755 create mode 100755 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/docs/README.md create mode 100755 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/icon.svg rename pkg/helm/{cmd/helm/testdata/testcharts/object-order/values.yaml => pkg/chart/v2/loader/testdata/frobnitz_backslash/ignore/me.txt} (100%) mode change 100644 => 100755 create mode 100755 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/templates/template.tpl create mode 100755 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/values.yaml rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz_with_bom.tgz (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/.helmignore create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/Chart.lock rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz_with_bom/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/INSTALL.txt create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/LICENSE create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/README.md create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/_ignore_me rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz_with_bom/charts/alpine/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/README.md rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast2-0.1.0.tgz create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/templates/alpine-pod.yaml create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/values.yaml rename pkg/helm/pkg/{chartutil/testdata/dependent-chart-with-all-in-requirements-yaml => chart/v2/loader/testdata/frobnitz_with_bom}/charts/mariner-4.3.2.tgz (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/docs/README.md create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/icon.svg rename pkg/helm/{cmd/helm/testdata/testcharts/signtest/values.yaml => pkg/chart/v2/loader/testdata/frobnitz_with_bom/ignore/me.txt} (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/templates/template.tpl create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/.helmignore create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/Chart.lock rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz_with_dev_null/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/INSTALL.txt create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/LICENSE create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/README.md create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/_ignore_me rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz_with_dev_null/charts/alpine/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/README.md rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast2-0.1.0.tgz create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/templates/alpine-pod.yaml create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/values.yaml rename pkg/helm/pkg/{chartutil/testdata/dependent-chart-with-mixed-requirements-yaml => chart/v2/loader/testdata/frobnitz_with_dev_null}/charts/mariner-4.3.2.tgz (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/docs/README.md create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/icon.svg create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/ignore/me.txt create mode 120000 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/null create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/templates/template.tpl create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/.helmignore create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/Chart.lock rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz_with_symlink/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/INSTALL.txt create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/README.md create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/_ignore_me rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz_with_symlink/charts/alpine/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/README.md rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast2-0.1.0.tgz create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/templates/alpine-pod.yaml create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/mariner-4.3.2.tgz create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/docs/README.md create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/icon.svg create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/ignore/me.txt create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/templates/template.tpl create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/values.yaml rename pkg/helm/pkg/{chartutil => chart/v2/loader}/testdata/genfrob.sh (100%) rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/mariner/Chart.yaml (100%) rename pkg/helm/pkg/chart/{ => v2}/loader/testdata/mariner/charts/albatross-0.1.0.tgz (100%) create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/mariner/templates/placeholder.tpl create mode 100644 pkg/helm/pkg/chart/v2/loader/testdata/mariner/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/metadata.go rename pkg/helm/pkg/chart/{ => v2}/metadata_test.go (99%) rename pkg/helm/pkg/{chartutil => chart/v2/util}/chartfile.go (71%) rename pkg/helm/pkg/{chartutil => chart/v2/util}/chartfile_test.go (97%) create mode 100644 pkg/helm/pkg/chart/v2/util/compatible.go create mode 100644 pkg/helm/pkg/chart/v2/util/compatible_test.go rename pkg/helm/pkg/{chartutil => chart/v2/util}/create.go (72%) rename pkg/helm/pkg/{chartutil => chart/v2/util}/create_test.go (94%) rename pkg/helm/pkg/{chartutil => chart/v2/util}/dependencies.go (54%) create mode 100644 pkg/helm/pkg/chart/v2/util/dependencies_test.go create mode 100644 pkg/helm/pkg/chart/v2/util/deps_migration_ai_test.go rename pkg/helm/pkg/{chartutil => chart/v2/util}/doc.go (86%) rename pkg/helm/pkg/{chartutil => chart/v2/util}/expand.go (81%) create mode 100644 pkg/helm/pkg/chart/v2/util/expand_test.go rename pkg/helm/pkg/{chartutil => chart/v2/util}/save.go (80%) rename pkg/helm/pkg/{chartutil => chart/v2/util}/save_extended.go (84%) rename pkg/helm/pkg/{chartutil => chart/v2/util}/save_test.go (62%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/Chart.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/Chart.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/Chart.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/templates/dummy.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/templates/dummy.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/Chart.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/Chart.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/Chart.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/templates/dummy.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/templates/dummy.yaml rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/chartfiletest.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/.helmignore create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/Chart.lock rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/dependent-chart-alias/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/INSTALL.txt create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/LICENSE create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/README.md create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/_ignore_me rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/dependent-chart-alias/charts/alpine/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/README.md rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/dependent-chart-alias/charts/alpine/charts/mast1/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/charts/mast1/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/charts/mast2-0.1.0.tgz rename pkg/helm/pkg/{chartutil/testdata/frobnitz => chart/v2/util/testdata/dependent-chart-alias}/charts/alpine/templates/alpine-pod.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/mariner-4.3.2.tgz create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/docs/README.md create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/icon.svg create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/ignore/me.txt create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/templates/template.tpl create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/.helmignore rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/dependent-chart-helmignore/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/.ignore_me create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/_ignore_me rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/dependent-chart-helmignore/charts/alpine/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/README.md rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast2-0.1.0.tgz create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/templates/alpine-pod.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/templates/template.tpl create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/.helmignore rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/dependent-chart-no-requirements-yaml/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/INSTALL.txt create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/LICENSE create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/README.md create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/_ignore_me rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/dependent-chart-no-requirements-yaml/charts/alpine/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/README.md rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/templates/alpine-pod.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/mariner-4.3.2.tgz create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/docs/README.md create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/icon.svg create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/ignore/me.txt create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/templates/template.tpl create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/.helmignore rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/dependent-chart-with-all-in-requirements-yaml/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/INSTALL.txt create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/LICENSE create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/README.md create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/_ignore_me rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/README.md rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/templates/alpine-pod.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/mariner-4.3.2.tgz create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/docs/README.md create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/icon.svg create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/ignore/me.txt create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/templates/template.tpl create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/.helmignore rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/dependent-chart-with-mixed-requirements-yaml/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/INSTALL.txt create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/LICENSE create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/README.md create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/_ignore_me rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/README.md rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/templates/alpine-pod.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/mariner-4.3.2.tgz create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/docs/README.md create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/icon.svg create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/ignore/me.txt create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/templates/template.tpl create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/values.yaml rename pkg/helm/pkg/{repo/testdata/repository => chart/v2/util/testdata}/frobnitz-1.2.3.tgz (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/frobnitz/.helmignore create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/frobnitz/Chart.lock rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/frobnitz/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/frobnitz/INSTALL.txt create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/frobnitz/LICENSE create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/frobnitz/README.md create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/_ignore_me rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/frobnitz/charts/alpine/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/README.md rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/values.yaml rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/frobnitz/charts/mariner/Chart.yaml (100%) rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/frobnitz/charts/mariner/charts/albatross/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/charts/albatross/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/templates/placeholder.tpl create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/frobnitz/docs/README.md create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/frobnitz/icon.svg create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/frobnitz/ignore/me.txt create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/frobnitz/templates/template.tpl create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/frobnitz/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/frobnitz_backslash-1.2.3.tgz create mode 100755 pkg/helm/pkg/chart/v2/util/testdata/genfrob.sh create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/Chart.lock rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/import-values-from-enabled-subchart/parent-chart/Chart.yaml (96%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/charts/dev-v0.1.0.tgz create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/charts/prod-v0.1.0.tgz rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/Chart.yaml (68%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/values.yaml rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/Chart.yaml (68%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/templates/autoscaler.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/values.yaml rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/joonix/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/joonix/charts/.gitkeep rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/subpop/Chart.yaml (72%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/subpop/README.md rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/subpop/charts/subchart1/Chart.yaml (86%) rename pkg/helm/{cmd/helm/testdata/testcharts/subchart => pkg/chart/v2/util/testdata/subpop/charts/subchart1}/charts/subchartA/Chart.yaml (100%) rename pkg/helm/pkg/{chartutil/testdata/subpop/charts/subchart2 => chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartA}/templates/service.yaml (100%) rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/subpop/charts/subchart1/charts/subchartA/values.yaml (55%) rename pkg/helm/{cmd/helm/testdata/testcharts/subchart => pkg/chart/v2/util/testdata/subpop/charts/subchart1}/charts/subchartB/Chart.yaml (100%) rename pkg/helm/pkg/{chartutil/testdata/subpop/noreqs => chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartB}/templates/service.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartB/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/crds/crdA.yaml rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/subpop/charts/subchart1/templates/NOTES.txt (100%) rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/subpop/charts/subchart1/templates/service.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/templates/subdir/role.yaml rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/subpop/charts/subchart1/templates/subdir/rolebinding.yaml (100%) rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/subpop/charts/subchart1/templates/subdir/serviceaccount.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/values.yaml rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/subpop/charts/subchart2/Chart.yaml (100%) rename pkg/helm/pkg/{chartutil/testdata/subpop/charts/subchart1 => chart/v2/util/testdata/subpop/charts/subchart2}/charts/subchartB/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartB/templates/service.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartB/values.yaml rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/subpop/charts/subchart2/charts/subchartC/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartC/templates/service.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartC/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/templates/service.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/values.yaml rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/subpop/noreqs/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/subpop/noreqs/templates/service.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/subpop/noreqs/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/subpop/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/README.md create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/Chart.yaml rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/three-level-dependent-chart/umbrella/charts/app1/Chart.yaml (61%) rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/templates/service.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/templates/service.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/values.yaml rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/three-level-dependent-chart/umbrella/charts/app2/Chart.yaml (61%) rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/templates/service.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/templates/service.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/values.yaml rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/three-level-dependent-chart/umbrella/charts/app3/Chart.yaml (61%) rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/templates/service.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/templates/service.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/values.yaml rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/three-level-dependent-chart/umbrella/charts/app4/Chart.yaml (75%) rename pkg/helm/pkg/{chartutil => chart/v2/util}/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/templates/service.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/templates/service.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/values.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/validate_name.go create mode 100644 pkg/helm/pkg/chart/v2/util/validate_name_test.go delete mode 100644 pkg/helm/pkg/chartutil/capabilities.go delete mode 100644 pkg/helm/pkg/chartutil/exports.go delete mode 100644 pkg/helm/pkg/chartutil/jsonschema.go delete mode 100644 pkg/helm/pkg/chartutil/jsonschema_test.go delete mode 100644 pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/Chart.yaml delete mode 100644 pkg/helm/pkg/cli/exports.go rename pkg/helm/{cmd/helm => pkg/cmd}/completion.go (83%) rename pkg/helm/{cmd/helm => pkg/cmd}/completion_test.go (91%) create mode 100644 pkg/helm/pkg/cmd/create.go create mode 100644 pkg/helm/pkg/cmd/create_test.go rename pkg/helm/{cmd/helm => pkg/cmd}/dependency.go (71%) rename pkg/helm/{cmd/helm => pkg/cmd}/dependency_build.go (76%) rename pkg/helm/{cmd/helm => pkg/cmd}/dependency_build_test.go (87%) rename pkg/helm/{cmd/helm => pkg/cmd}/dependency_test.go (99%) rename pkg/helm/{cmd/helm => pkg/cmd}/dependency_update.go (75%) rename pkg/helm/{cmd/helm => pkg/cmd}/dependency_update_test.go (82%) create mode 100644 pkg/helm/pkg/cmd/env.go create mode 100644 pkg/helm/pkg/cmd/env_test.go rename pkg/helm/{cmd/helm => pkg/cmd}/flags.go (69%) create mode 100644 pkg/helm/pkg/cmd/helpers.go rename pkg/helm/{cmd/helm/helm_test.go => pkg/cmd/helpers_test.go} (57%) rename pkg/helm/{cmd/helm => pkg/cmd}/history.go (60%) create mode 100644 pkg/helm/pkg/cmd/history_test.go create mode 100644 pkg/helm/pkg/cmd/list.go create mode 100644 pkg/helm/pkg/cmd/list_test.go rename pkg/helm/{cmd/helm => pkg/cmd}/package.go (70%) rename pkg/helm/{cmd/helm => pkg/cmd}/package_test.go (94%) create mode 100644 pkg/helm/pkg/cmd/printer.go rename pkg/helm/{cmd/helm => pkg/cmd}/pull.go (84%) rename pkg/helm/{cmd/helm => pkg/cmd}/pull_test.go (59%) rename pkg/helm/{cmd/helm => pkg/cmd}/push.go (75%) rename pkg/helm/{cmd/helm => pkg/cmd}/push_test.go (98%) rename pkg/helm/{cmd/helm => pkg/cmd}/registry.go (98%) rename pkg/helm/{cmd/helm => pkg/cmd}/registry_login.go (87%) rename pkg/helm/{cmd/helm => pkg/cmd}/registry_login_test.go (98%) rename pkg/helm/{cmd/helm => pkg/cmd}/registry_logout.go (87%) rename pkg/helm/{cmd/helm => pkg/cmd}/registry_logout_test.go (98%) rename pkg/helm/{cmd/helm => pkg/cmd}/repo.go (85%) rename pkg/helm/{cmd/helm => pkg/cmd}/repo_add.go (81%) rename pkg/helm/{cmd/helm => pkg/cmd}/repo_add_test.go (79%) create mode 100644 pkg/helm/pkg/cmd/repo_index.go create mode 100644 pkg/helm/pkg/cmd/repo_index_test.go create mode 100644 pkg/helm/pkg/cmd/repo_list.go create mode 100644 pkg/helm/pkg/cmd/repo_list_test.go rename pkg/helm/{cmd/helm => pkg/cmd}/repo_remove.go (81%) rename pkg/helm/{cmd/helm => pkg/cmd}/repo_remove_test.go (94%) rename pkg/helm/{cmd/helm => pkg/cmd}/repo_test.go (98%) rename pkg/helm/{cmd/helm => pkg/cmd}/repo_update.go (69%) rename pkg/helm/{cmd/helm => pkg/cmd}/repo_update_test.go (74%) rename pkg/helm/{cmd/helm => pkg/cmd}/require/args.go (94%) rename pkg/helm/{cmd/helm => pkg/cmd}/require/args_test.go (93%) rename pkg/helm/{cmd/helm => pkg/cmd}/root.go (55%) rename pkg/helm/{cmd/helm => pkg/cmd}/root_test.go (85%) rename pkg/helm/{cmd/helm => pkg/cmd}/search.go (98%) rename pkg/helm/{cmd/helm => pkg/cmd}/search/search.go (99%) rename pkg/helm/{cmd/helm => pkg/cmd}/search/search_test.go (97%) rename pkg/helm/{cmd/helm => pkg/cmd}/search_hub.go (93%) rename pkg/helm/{cmd/helm => pkg/cmd}/search_hub_test.go (99%) rename pkg/helm/{cmd/helm => pkg/cmd}/search_repo.go (94%) rename pkg/helm/{cmd/helm => pkg/cmd}/search_repo_test.go (99%) rename pkg/helm/{cmd/helm => pkg/cmd}/search_test.go (98%) create mode 100644 pkg/helm/pkg/cmd/show.go create mode 100644 pkg/helm/pkg/cmd/show_test.go create mode 100644 pkg/helm/pkg/cmd/status.go create mode 100644 pkg/helm/pkg/cmd/status_test.go create mode 100644 pkg/helm/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/completion.yaml rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/helm home with space/helm/plugins/fullenv/fullenv.sh (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/helm home with space/helm/repositories.yaml (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/helm home with space/helm/repository/test-name-charts.txt rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/helm home with space/helm/repository/test-name-index.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/helm home with space/helm/repository/testing-index.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/helm-test-key.pub (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/helm-test-key.secret (100%) create mode 100755 pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/args/args.sh rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/helmhome/helm/plugins/args/plugin.complete (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/args/plugin.yaml create mode 100644 pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/echo/completion.yaml rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/helmhome/helm/plugins/echo/plugin.complete (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/echo/plugin.yaml rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/helmhome/helm/plugins/exitwith/completion.yaml (64%) create mode 100755 pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/exitwith.sh create mode 100644 pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/plugin.yaml create mode 100644 pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/completion.yaml create mode 100755 pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/fullenv.sh create mode 100644 pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/plugin.yaml create mode 100644 pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/plugin.yaml create mode 100755 pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/sed-test.sh create mode 100644 pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/shortenv/completion.yaml create mode 100755 pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/shortenv/plugin-name.sh create mode 100644 pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/shortenv/plugin.yaml create mode 100644 pkg/helm/pkg/cmd/testdata/helmhome/helm/repositories.yaml create mode 100644 pkg/helm/pkg/cmd/testdata/helmhome/helm/repository/test-name-charts.txt rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/helmhome/helm/repository/test-name-index.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/helmhome/helm/repository/testing-index.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/chart-with-subchart-update.txt (80%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/dependency-list-archive.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/dependency-list-no-chart-linux.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/dependency-list-no-requirements-linux.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/dependency-list.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/deprecated-chart.txt (77%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/docs-type-comp.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/empty_default_comp.txt (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/output/empty_nofile_comp.txt rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/env-comp.txt (95%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/get-all-no-args.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/get-hooks-no-args.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/get-hooks.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/get-manifest-no-args.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/get-manifest.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/get-metadata-args.txt (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/output/get-metadata.json rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/get-metadata.txt (51%) create mode 100644 pkg/helm/pkg/cmd/testdata/output/get-metadata.yaml rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/get-notes-no-args.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/get-notes.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/get-release-template.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/get-release.txt (94%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/get-values-all.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/get-values-args.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/get-values.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/history-limit.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/history.json (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/history.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/history.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/install-and-replace.txt (77%) create mode 100644 pkg/helm/pkg/cmd/testdata/output/install-and-take-ownership.txt rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/install-chart-bad-type.txt (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/output/install-dry-run-with-secret-hidden.txt create mode 100644 pkg/helm/pkg/cmd/testdata/output/install-dry-run-with-secret.txt create mode 100644 pkg/helm/pkg/cmd/testdata/output/install-hide-secret.txt rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/install-lib-chart.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/install-name-template.txt (77%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/install-no-args.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/install-no-hooks.txt (77%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/install-with-multiple-values-files.txt (77%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/install-with-multiple-values.txt (77%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/install-with-timeout.txt (77%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/install-with-values-file.txt (77%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/install-with-values.txt (77%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/install-with-wait-for-jobs.txt (77%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/install-with-wait.txt (77%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/install.txt (77%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/issue-9027.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/lint-chart-with-bad-subcharts-with-subcharts.txt (67%) create mode 100644 pkg/helm/pkg/cmd/testdata/output/lint-chart-with-bad-subcharts.txt rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/lint-chart-with-deprecated-api-old-k8s.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/lint-chart-with-deprecated-api-strict.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/lint-chart-with-deprecated-api.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/lint-quiet-with-error.txt (74%) create mode 100644 pkg/helm/pkg/cmd/testdata/output/lint-quiet-with-warning.txt create mode 100644 pkg/helm/pkg/cmd/testdata/output/lint-quiet.txt create mode 100644 pkg/helm/pkg/cmd/testdata/output/list-all-date-reversed.txt create mode 100644 pkg/helm/pkg/cmd/testdata/output/list-all-date.txt rename pkg/helm/{cmd/helm/testdata/output/list-uninstalling.txt => pkg/cmd/testdata/output/list-all-max.txt} (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/output/list-all-no-headers.txt create mode 100644 pkg/helm/pkg/cmd/testdata/output/list-all-offset.txt create mode 100644 pkg/helm/pkg/cmd/testdata/output/list-all-reverse.txt create mode 100644 pkg/helm/pkg/cmd/testdata/output/list-all-short-json.txt create mode 100644 pkg/helm/pkg/cmd/testdata/output/list-all-short-yaml.txt create mode 100644 pkg/helm/pkg/cmd/testdata/output/list-all-short.txt rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/list-all.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/list-date-reversed.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/list-date.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/list-failed.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/list-filter.txt (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/output/list-json.txt rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/list-max.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/list-namespace.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/list-no-headers.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/list-offset.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/list-pending.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/list-reverse.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/list-short-json.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/list-short-yaml.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/list-short.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/list-superseded.txt (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/output/list-time-format.txt rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/list-uninstalled.txt (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/output/list-uninstalling.txt create mode 100644 pkg/helm/pkg/cmd/testdata/output/list-yaml.txt rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/list.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/object-order.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/output-comp.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/plugin_args_comp.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/plugin_args_flag_comp.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/plugin_args_many_args_comp.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/plugin_args_ns_comp.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/plugin_echo_no_directive.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/plugin_list_comp.txt (88%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/plugin_repeat_comp.txt (86%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/release_list_comp.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/release_list_repeat_comp.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/repo-add.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/repo-add2.txt (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/output/repo-list-empty.txt create mode 100644 pkg/helm/pkg/cmd/testdata/output/repo-list-no-headers.txt create mode 100644 pkg/helm/pkg/cmd/testdata/output/repo-list.txt rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/repo_list_comp.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/repo_repeat_comp.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/revision-comp.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/revision-wrong-args-comp.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/rollback-comp.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/rollback-no-args.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/rollback-no-revision.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/rollback-non-existent-version.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/rollback-timeout.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/rollback-wait-for-jobs.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/rollback-wait.txt (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/output/rollback-wrong-args-comp.txt rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/rollback.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/schema-negative-cli.txt (73%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/schema-negative.txt (59%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/schema.txt (77%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/search-constraint-single.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/search-constraint.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/search-multiple-devel-release.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/search-multiple-stable-release.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/search-multiple-versions-constraints.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/search-multiple-versions.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/search-not-found-error.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/search-not-found.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/search-output-json.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/search-output-yaml.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/search-regex.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/search-versions-constraint.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/status-comp.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/status-with-desc.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/status-with-notes.txt (91%) create mode 100644 pkg/helm/pkg/cmd/testdata/output/status-with-resources.json rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/status-with-resources.txt (90%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/status-with-test-suite.txt (96%) create mode 100644 pkg/helm/pkg/cmd/testdata/output/status-wrong-args-comp.txt create mode 100644 pkg/helm/pkg/cmd/testdata/output/status.json rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/status.txt (90%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/subchart-schema-cli-negative.txt (75%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/subchart-schema-cli.txt (77%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/subchart-schema-negative.txt (69%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/template-chart-bad-type.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/template-chart-with-template-lib-archive-dep.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/template-chart-with-template-lib-dep.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/template-lib-chart.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/template-name-template.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/template-no-args.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/template-set.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/template-show-only-glob.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/template-show-only-multiple.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/template-show-only-one.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/template-skip-tests.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/template-subchart-cm-set-file.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/template-subchart-cm-set.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/template-subchart-cm.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/template-values-files.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/template-with-api-version.txt (98%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/template-with-crds.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/template-with-invalid-yaml-debug.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/template-with-invalid-yaml.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/template-with-kube-version.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/template.txt (100%) rename pkg/helm/{cmd/helm/testdata/output/uninstall-keep-history.txt => pkg/cmd/testdata/output/uninstall-keep-history-earlier-deployed.txt} (100%) rename pkg/helm/{cmd/helm/testdata/output/uninstall-no-hooks.txt => pkg/cmd/testdata/output/uninstall-keep-history.txt} (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/uninstall-multiple.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/uninstall-no-args.txt (100%) rename pkg/helm/{cmd/helm/testdata/output/uninstall-timeout.txt => pkg/cmd/testdata/output/uninstall-no-hooks.txt} (100%) rename pkg/helm/{cmd/helm/testdata/output/uninstall-wait.txt => pkg/cmd/testdata/output/uninstall-timeout.txt} (100%) rename pkg/helm/{cmd/helm/testdata/output/uninstall.txt => pkg/cmd/testdata/output/uninstall-wait.txt} (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/output/uninstall.txt create mode 100644 pkg/helm/pkg/cmd/testdata/output/upgrade-and-take-ownership.txt create mode 100644 pkg/helm/pkg/cmd/testdata/output/upgrade-uninstalled-with-keep-history.txt rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/upgrade-with-bad-dependencies.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/upgrade-with-bad-or-missing-existing-release.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/upgrade-with-dependency-update.txt (85%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/upgrade-with-install-timeout.txt (84%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/upgrade-with-install.txt (83%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/upgrade-with-missing-dependencies.txt (69%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/upgrade-with-pending-install.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/upgrade-with-reset-values.txt (84%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/upgrade-with-reset-values2.txt (84%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/upgrade-with-timeout.txt (84%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/upgrade-with-wait-for-jobs.txt (84%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/upgrade-with-wait.txt (84%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/upgrade.txt (84%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/values.json (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/version-comp.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/output/version-invalid-comp.txt (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/output/version-short.txt create mode 100644 pkg/helm/pkg/cmd/testdata/output/version-template.txt create mode 100644 pkg/helm/pkg/cmd/testdata/output/version.txt rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/password (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/plugins.yaml rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/repositories.yaml (99%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/alpine/Chart.yaml (81%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/alpine/README.md (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/alpine/extra_values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/alpine/more_values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/alpine/templates/alpine-pod.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/alpine/values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-bad-requirements/.helmignore (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-bad-requirements/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/.helmignore (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-bad-requirements/values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-bad-type/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-bad-type/README.md (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-bad-type/extra_values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-bad-type/more_values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-bad-type/templates/alpine-pod.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-bad-type/values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-missing-deps/.helmignore (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-missing-deps/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-missing-deps/charts/reqsubchart/.helmignore (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-missing-deps/charts/reqsubchart/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-missing-deps/charts/reqsubchart/values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-missing-deps/values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-bad-subcharts/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart/values.yaml rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/Chart.yaml (80%) create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/values.yaml rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-bad-subcharts/requirements.yaml (80%) create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/values.yaml rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-deprecated-api/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-deprecated-api/templates/horizontalpodautoscaler.yaml (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/values.yaml rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-lib-dep/.helmignore (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-lib-dep/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-lib-dep/charts/common-0.0.5.tgz (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-lib-dep/templates/NOTES.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-lib-dep/templates/_helpers.tpl (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-lib-dep/templates/deployment.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-lib-dep/templates/ingress.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-lib-dep/templates/service.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-lib-dep/values.yaml (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/chart-with-only-crds/.helmignore rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-only-crds/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/chart-with-only-crds/crds/test-crd.yaml rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-schema-and-subchart/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/templates/empty.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.schema.json (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.yaml rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-schema-and-subchart/templates/empty.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-schema-and-subchart/values.schema.json (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-schema-and-subchart/values.yaml (100%) rename pkg/helm/{cmd/helm/testdata/testcharts/chart-with-schema => pkg/cmd/testdata/testcharts/chart-with-schema-negative-skip-validation}/Chart.yaml (73%) rename pkg/helm/{cmd/helm/testdata/testcharts/chart-with-schema-negative => pkg/cmd/testdata/testcharts/chart-with-schema-negative-skip-validation}/templates/empty.yaml (100%) rename pkg/helm/{cmd/helm/testdata/testcharts/chart-with-schema => pkg/cmd/testdata/testcharts/chart-with-schema-negative-skip-validation}/values.schema.json (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-negative-skip-validation/values.yaml rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-schema-negative/Chart.yaml (73%) rename pkg/helm/{cmd/helm/testdata/testcharts/chart-with-schema => pkg/cmd/testdata/testcharts/chart-with-schema-negative}/templates/empty.yaml (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-negative/values.schema.json create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-negative/values.yaml create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema/Chart.yaml rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-schema/extra-values.yaml (100%) rename pkg/helm/{cmd/helm/testdata/testcharts/empty => pkg/cmd/testdata/testcharts/chart-with-schema}/templates/empty.yaml (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema/values.schema.json create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema/values.yaml create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/chart-with-secret/Chart.yaml create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/chart-with-secret/templates/configmap.yaml create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/chart-with-secret/templates/secret.yaml rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-subchart-notes/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/templates/NOTES.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-subchart-notes/templates/NOTES.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-subchart-update/Chart.lock (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-subchart-update/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/templates/NOTES.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-subchart-update/templates/NOTES.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-archive-dep/.helmignore (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-archive-dep/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-archive-dep/charts/common-0.0.5.tgz (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-archive-dep/templates/NOTES.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-archive-dep/templates/_helpers.tpl (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-archive-dep/templates/deployment.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-archive-dep/templates/ingress.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-archive-dep/templates/service.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-archive-dep/values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/.helmignore (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/charts/common/.helmignore (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/charts/common/Chart.yaml (61%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/charts/common/README.md (99%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_chartref.tpl (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_configmap.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_container.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_deployment.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_envvar.tpl (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_fullname.tpl (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_ingress.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata_annotations.tpl (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata_labels.tpl (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_name.tpl (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_persistentvolumeclaim.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_secret.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_service.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_util.tpl (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_volume.tpl (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/configmap.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/charts/common/values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/templates/NOTES.txt (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/templates/_helpers.tpl (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/templates/deployment.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/templates/ingress.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/templates/service.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-lib-dep/values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-with-invalid-yaml/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-with-invalid-yaml/README.md (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-with-invalid-yaml/templates/alpine-pod.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/chart-with-template-with-invalid-yaml/values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/compressedchart-0.1.0.tar.gz (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/compressedchart-0.1.0.tgz (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/compressedchart-0.2.0.tgz (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/compressedchart-0.3.0.tgz (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/compressedchart-with-hyphens-0.1.0.tgz (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/deprecated/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/deprecated/README.md (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/empty/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/empty/README.md (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/empty/templates/empty.yaml rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/empty/values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/issue-7233/.helmignore (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/issue-7233/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/charts/alpine-0.1.0.tgz rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/issue-7233/requirements.lock (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/requirements.yaml rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/issue-7233/templates/configmap.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/issue-7233/values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/issue-9027/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/issue-9027/charts/subchart/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/issue-9027/charts/subchart/templates/values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/issue-9027/charts/subchart/values.yaml (99%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/issue-9027/templates/values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/issue-9027/values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/issue1979/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/issue1979/README.md (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/issue1979/extra_values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/issue1979/more_values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/issue1979/templates/alpine-pod.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/issue1979/values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/lib-chart/.helmignore (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/lib-chart/Chart.yaml (61%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/lib-chart/README.md (99%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/lib-chart/templates/_chartref.tpl (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/lib-chart/templates/_configmap.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/lib-chart/templates/_container.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/lib-chart/templates/_deployment.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/lib-chart/templates/_envvar.tpl (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/lib-chart/templates/_fullname.tpl (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/lib-chart/templates/_ingress.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/lib-chart/templates/_metadata.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/lib-chart/templates/_metadata_annotations.tpl (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/lib-chart/templates/_metadata_labels.tpl (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/lib-chart/templates/_name.tpl (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/lib-chart/templates/_persistentvolumeclaim.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/lib-chart/templates/_secret.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/lib-chart/templates/_service.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/lib-chart/templates/_util.tpl (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/lib-chart/templates/_volume.tpl (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/lib-chart/values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/object-order/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/object-order/templates/01-a.yml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/object-order/templates/02-b.yml (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/object-order/values.yaml rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/oci-dependent-chart-0.1.0.tgz (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/pre-release-chart-0.1.0-alpha.tgz (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/reqtest-0.1.0.tgz (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/reqtest/.helmignore (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/reqtest/Chart.lock (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/reqtest/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/reqtest/charts/reqsubchart/.helmignore (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/reqtest/charts/reqsubchart/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/reqtest/charts/reqsubchart/values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/reqtest/charts/reqsubchart2/.helmignore (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/reqtest/charts/reqsubchart2/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/reqtest/charts/reqsubchart2/values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/reqtest/charts/reqsubchart3-0.2.0.tgz (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/reqtest/values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/signtest-0.1.0.tgz (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/signtest-0.1.0.tgz.prov (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/signtest/.helmignore (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/signtest/Chart.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/signtest/alpine/Chart.yaml (78%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/signtest/alpine/README.md (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/signtest/alpine/templates/alpine-pod.yaml rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/signtest/alpine/values.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/signtest/templates/pod.yaml (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/signtest/values.yaml rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/subchart/Chart.yaml (100%) rename pkg/helm/pkg/{chartutil/testdata/subpop/charts/subchart1 => cmd/testdata/testcharts/subchart}/charts/subchartA/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/subchart/charts/subchartA/templates/service.yaml create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/subchart/charts/subchartA/values.yaml rename pkg/helm/pkg/{chartutil/testdata/subpop/charts/subchart2 => cmd/testdata/testcharts/subchart}/charts/subchartB/Chart.yaml (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/subchart/charts/subchartB/templates/service.yaml rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/subchart/charts/subchartB/values.yaml (93%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/subchart/crds/crdA.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/subchart/extra_values.yaml (86%) create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/subchart/templates/NOTES.txt create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/subchart/templates/service.yaml rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/subchart/templates/subdir/configmap.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/subchart/templates/subdir/role.yaml (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/subchart/templates/subdir/rolebinding.yaml create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/subchart/templates/subdir/serviceaccount.yaml rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/subchart/templates/tests/test-config.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/subchart/templates/tests/test-nothing.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/subchart/values.yaml (99%) create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/test-0.1.0.tgz create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/test/Chart.yaml create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/test/values.yaml create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/test1-0.1.0.tgz create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/test1/Chart.yaml create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/test1/values.yaml rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testcharts/upgradetest/templates/configmap.yaml (100%) create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/upgradetest/values.yaml create mode 100644 pkg/helm/pkg/cmd/testdata/testplugin/plugin.yaml rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testserver/index.yaml (100%) rename pkg/helm/{cmd/helm => pkg/cmd}/testdata/testserver/repository/repositories.yaml (55%) rename pkg/helm/{cmd/helm => pkg/cmd}/verify.go (83%) rename pkg/helm/{cmd/helm => pkg/cmd}/verify_test.go (99%) create mode 100644 pkg/helm/pkg/cmd/version.go create mode 100644 pkg/helm/pkg/cmd/version_test.go create mode 100644 pkg/helm/pkg/downloader/cache.go create mode 100644 pkg/helm/pkg/downloader/cache_test.go create mode 100644 pkg/helm/pkg/downloader/chart_downloader_windows_test.go create mode 100644 pkg/helm/pkg/engine/engine_migration_ai_test.go delete mode 100644 pkg/helm/pkg/getter/exports.go create mode 100644 pkg/helm/pkg/getter/plugingetter.go delete mode 100755 pkg/helm/pkg/getter/testdata/plugins/testgetter/get.sh delete mode 100755 pkg/helm/pkg/getter/testdata/plugins/testgetter2/get.sh delete mode 100644 pkg/helm/pkg/kube/client_extender.go delete mode 100644 pkg/helm/pkg/kube/config.go delete mode 100644 pkg/helm/pkg/kube/extensions.go delete mode 100644 pkg/helm/pkg/kube/fake/extensions.go rename pkg/helm/pkg/kube/fake/{fake.go => failing_kube_client.go} (51%) create mode 100644 pkg/helm/pkg/kube/options.go delete mode 100644 pkg/helm/pkg/kube/resources_waiter.go rename pkg/helm/pkg/{cli => kube}/roundtripper.go (83%) create mode 100644 pkg/helm/pkg/kube/roundtripper_test.go create mode 100644 pkg/helm/pkg/kube/statuswait.go create mode 100644 pkg/helm/pkg/kube/statuswait_test.go create mode 100644 pkg/helm/pkg/kube/wait_test.go delete mode 100644 pkg/helm/pkg/postrender/exec.go delete mode 100644 pkg/helm/pkg/postrender/exec_test.go rename pkg/helm/pkg/{postrender/postrender.go => postrenderer/postrenderer.go} (64%) create mode 100644 pkg/helm/pkg/postrenderer/postrenderer_test.go create mode 100644 pkg/helm/pkg/postrenderer/testdata/plugins/postrenderer-v1/plugin.yaml create mode 100755 pkg/helm/pkg/postrenderer/testdata/plugins/postrenderer-v1/sed-test.sh create mode 100644 pkg/helm/pkg/provenance/testdata/helm-mixed-keyring.pub create mode 100644 pkg/helm/pkg/registry/chart.go rename pkg/helm/pkg/registry/{util_test.go => chart_test.go} (77%) create mode 100644 pkg/helm/pkg/registry/client_test.go create mode 100644 pkg/helm/pkg/registry/generic.go create mode 100644 pkg/helm/pkg/registry/main_test.go create mode 100644 pkg/helm/pkg/registry/plugin.go create mode 100644 pkg/helm/pkg/registry/plugin_test.go create mode 100644 pkg/helm/pkg/registry/reference.go create mode 100644 pkg/helm/pkg/registry/reference_test.go rename pkg/helm/pkg/registry/{utils_test.go => registry_test.go} (56%) create mode 100644 pkg/helm/pkg/registry/tag.go create mode 100644 pkg/helm/pkg/registry/tag_test.go create mode 100644 pkg/helm/pkg/registry/testdata/tls/ca.key create mode 100644 pkg/helm/pkg/registry/transport.go create mode 100644 pkg/helm/pkg/registry/transport_test.go delete mode 100644 pkg/helm/pkg/registry/util.go create mode 100644 pkg/helm/pkg/release/common.go rename pkg/helm/pkg/release/{ => common}/status.go (90%) create mode 100644 pkg/helm/pkg/release/common_test.go delete mode 100644 pkg/helm/pkg/release/info.go create mode 100644 pkg/helm/pkg/release/interfaces.go delete mode 100644 pkg/helm/pkg/release/phase.go delete mode 100644 pkg/helm/pkg/release/report.go rename pkg/helm/pkg/release/{ => v1}/hook.go (59%) create mode 100644 pkg/helm/pkg/release/v1/hook_test.go create mode 100644 pkg/helm/pkg/release/v1/info.go create mode 100644 pkg/helm/pkg/release/v1/info_test.go rename pkg/helm/pkg/release/{ => v1}/mock.go (72%) rename pkg/helm/pkg/release/{ => v1}/release.go (64%) rename pkg/helm/pkg/{releaseutil => release/v1/util}/filter.go (88%) rename pkg/helm/pkg/{releaseutil => release/v1/util}/filter_test.go (74%) rename pkg/helm/pkg/{releaseutil => release/v1/util}/kind_sorter.go (92%) rename pkg/helm/pkg/{releaseutil => release/v1/util}/kind_sorter_test.go (95%) rename pkg/helm/pkg/{releaseutil => release/v1/util}/manifest.go (78%) rename pkg/helm/pkg/{releaseutil => release/v1/util}/manifest_sorter.go (82%) rename pkg/helm/pkg/{releaseutil => release/v1/util}/manifest_sorter_test.go (96%) rename pkg/helm/pkg/{releaseutil => release/v1/util}/manifest_test.go (95%) rename pkg/helm/pkg/{releaseutil => release/v1/util}/sorter.go (57%) rename pkg/helm/pkg/{releaseutil => release/v1/util}/sorter_test.go (80%) delete mode 100644 pkg/helm/pkg/releaseutil/validate.go delete mode 100644 pkg/helm/pkg/releaseutil/validate_test.go delete mode 100644 pkg/helm/pkg/repo/chartrepo.go delete mode 100644 pkg/helm/pkg/repo/repotest/server_test.go create mode 100644 pkg/helm/pkg/repo/v1/chartrepo.go rename pkg/helm/pkg/repo/{ => v1}/chartrepo_test.go (59%) rename pkg/helm/pkg/repo/{ => v1}/doc.go (100%) create mode 100644 pkg/helm/pkg/repo/v1/error.go rename pkg/helm/pkg/repo/{ => v1}/index.go (89%) rename pkg/helm/pkg/repo/{ => v1}/index_test.go (89%) rename pkg/helm/pkg/repo/{ => v1}/repo.go (94%) rename pkg/helm/pkg/repo/{ => v1}/repo_test.go (99%) rename pkg/helm/pkg/repo/{ => v1}/repotest/doc.go (100%) rename pkg/helm/pkg/repo/{ => v1}/repotest/server.go (66%) create mode 100644 pkg/helm/pkg/repo/v1/repotest/server_test.go rename pkg/helm/pkg/repo/{ => v1}/repotest/testdata/examplechart-0.1.0.tgz (100%) rename pkg/helm/pkg/repo/{ => v1}/repotest/testdata/examplechart/.helmignore (100%) rename pkg/helm/pkg/repo/{ => v1}/repotest/testdata/examplechart/Chart.yaml (100%) rename pkg/helm/pkg/repo/{ => v1}/repotest/testdata/examplechart/values.yaml (100%) create mode 100644 pkg/helm/pkg/repo/v1/repotest/tlsconfig.go rename pkg/helm/pkg/repo/{ => v1}/testdata/chartmuseum-index.yaml (100%) rename pkg/helm/pkg/repo/{ => v1}/testdata/local-index-annotations.yaml (100%) rename pkg/helm/pkg/repo/{ => v1}/testdata/local-index-unordered.yaml (100%) rename pkg/helm/pkg/repo/{ => v1}/testdata/local-index.json (100%) rename pkg/helm/pkg/repo/{ => v1}/testdata/local-index.yaml (100%) rename pkg/helm/pkg/repo/{ => v1}/testdata/old-repositories.yaml (100%) rename pkg/helm/pkg/repo/{ => v1}/testdata/repositories.yaml (100%) create mode 100644 pkg/helm/pkg/repo/v1/testdata/repository/frobnitz-1.2.3.tgz rename pkg/helm/pkg/repo/{ => v1}/testdata/repository/sprocket-1.1.0.tgz (100%) rename pkg/helm/pkg/repo/{ => v1}/testdata/repository/sprocket-1.2.0.tgz (100%) rename pkg/helm/pkg/repo/{ => v1}/testdata/repository/universe/zarthal-1.0.0.tgz (100%) rename pkg/helm/pkg/repo/{ => v1}/testdata/server/index.yaml (100%) rename pkg/helm/pkg/repo/{ => v1}/testdata/server/test.txt (100%) create mode 100644 pkg/helm/pkg/strvals/fuzz_test.go delete mode 100644 pkg/helm/pkg/time/time.go delete mode 100644 pkg/helm/pkg/time/time_test.go delete mode 100644 pkg/helm/pkg/werf/file/buffered_file.go delete mode 100644 pkg/helm/pkg/werf/file/chart_file_reader.go delete mode 100644 pkg/helm/pkg/werf/file/chart_file_writer.go delete mode 100644 pkg/helm/pkg/werf/helmopts/helmoptions.go delete mode 100644 pkg/helm/pkg/werf/secrets/chart_secrets_loader.go delete mode 100644 pkg/helm/pkg/werf/secrets/runtimedata/interface.go delete mode 100644 pkg/helm/pkg/werf/secrets/secrets_runtime_data.go create mode 100644 pkg/helm/testdata/localhost-crt.pem create mode 100644 pkg/legacy/secret/chart_secrets_loader.go create mode 100644 pkg/legacy/secret/runtime_data.go rename pkg/{helm/pkg/werf/secrets/gotmplfunctions/go_tmpl_functions.go => legacy/secret/template_funcs.go} (65%) create mode 100644 pkg/log/common.go create mode 100644 pkg/util/manifest.go diff --git a/Taskfile.dist.yaml b/Taskfile.dist.yaml index 0dfed603..81aea66e 100644 --- a/Taskfile.dist.yaml +++ b/Taskfile.dist.yaml @@ -237,7 +237,7 @@ tasks: - task: test:ginkgo vars: paths: '{{.paths | default "./pkg ./cmd"}}' - skipPackage: "pkg/helm{{if .skipPackage}},{{.skipPackage}}{{end}}" + skipPackage: "{{.skipPackage}}" parallel: "{{.parallel}}" verify:binaries:dist:all: @@ -463,6 +463,33 @@ tasks: sg scan --rule "$rewrite_rule" -U "{{.forkSnapshotDir}}" rm -f "$rewrite_rule" + if [ -d "{{.forkSnapshotDir}}/internal" ]; then + mv "{{.forkSnapshotDir}}/internal" "{{.forkSnapshotDir}}/intern" + fi + + intern_rule="$(mktemp)" + cat > "$intern_rule" <<'SGRULE' + id: rewrite-internal-to-intern + language: Go + rule: + pattern: '"$PATH"' + inside: + kind: import_spec + regex: '/internal/' + transform: + NEW_PATH: + replace: + source: $PATH + replace: '/internal/' + by: '/intern/' + fix: '"$NEW_PATH"' + SGRULE + sg scan --rule "$intern_rule" -U "{{.forkSnapshotDir}}" + rm -f "$intern_rule" + + rm -f "{{.forkSnapshotDir}}/cmd/helm/helm.go" + find "{{.forkSnapshotDir}}/cmd/helm" -name "*.go" -exec sed -i 's/^package main$/package helm/' {} + + git -C "{{.forkSnapshotDir}}" add -A git -C "{{.forkSnapshotDir}}" -c user.name="sync" -c user.email="sync@local" \ commit -m "prepare: rewrite imports for nelm subtree merge" @@ -473,13 +500,21 @@ tasks: git fetch "{{.forkTempRemote}}" prepared git checkout -B "{{.forkSyncBranch}}" FETCH_HEAD --no-track - stripped=0 - while IFS= read -r sync_path; do - if ! git -c core.quotePath=false ls-tree --name-only "${original_ref}" -- "{{.forkPrefix}}/${sync_path}" | grep -q .; then - git rm -rf -- "$sync_path" - stripped=$((stripped + 1)) - fi - done < <(git ls-tree --name-only HEAD) + prev_split=$(git log --grep="git-subtree-dir: {{.forkPrefix}}" --format="%b" "${original_ref}" | grep -m1 "^git-subtree-split:" | cut -d' ' -f2) + if [ -z "$prev_split" ]; then + echo "WARNING: no previous subtree split found for {{.forkPrefix}}, skipping file stripping" + else + stripped=0 + while IFS= read -r sync_path; do + if ! git -c core.quotePath=false ls-tree "${original_ref}" -- "{{.forkPrefix}}/${sync_path}" | grep -q .; then + if git -c core.quotePath=false ls-tree "$prev_split" -- "$sync_path" | grep -q .; then + git rm -rf -- "$sync_path" + stripped=$((stripped + 1)) + fi + fi + done < <(git -c core.quotePath=false ls-tree -r --name-only HEAD) + echo "Stripped $stripped files that were removed from our fork" + fi if ! git diff --cached --quiet; then git commit -m "sync: strip paths not kept in {{.forkPrefix}}" diff --git a/cmd/nelm/chart_dependency_download.go b/cmd/nelm/chart_dependency_download.go index 99fbc4e2..62d044d5 100644 --- a/cmd/nelm/chart_dependency_download.go +++ b/cmd/nelm/chart_dependency_download.go @@ -8,8 +8,9 @@ import ( "github.com/spf13/cobra" "github.com/werf/common-go/pkg/cli" - helm_v3 "github.com/werf/nelm/pkg/helm/cmd/helm" + "github.com/werf/nelm/pkg/action" "github.com/werf/nelm/pkg/helm/pkg/chart/loader" + helmcmd "github.com/werf/nelm/pkg/helm/pkg/cmd" "github.com/werf/nelm/pkg/log" ) @@ -31,9 +32,9 @@ func newChartDependencyDownloadCommand(ctx context.Context, afterAllCommandsBuil originalRunE := cmd.RunE cmd.RunE = func(cmd *cobra.Command, args []string) error { - helmSettings := helm_v3.Settings + helmSettings := helmcmd.Settings - ctx = log.SetupLogging(ctx, lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), log.SetupLoggingOptions{}) + ctx = action.SetupLogging(ctx, lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), action.SetupLoggingOptions{}) loader.NoChartLockWarning = "" diff --git a/cmd/nelm/chart_dependency_update.go b/cmd/nelm/chart_dependency_update.go index f6094e8e..bd02be74 100644 --- a/cmd/nelm/chart_dependency_update.go +++ b/cmd/nelm/chart_dependency_update.go @@ -8,8 +8,9 @@ import ( "github.com/spf13/cobra" "github.com/werf/common-go/pkg/cli" - helm_v3 "github.com/werf/nelm/pkg/helm/cmd/helm" + "github.com/werf/nelm/pkg/action" "github.com/werf/nelm/pkg/helm/pkg/chart/loader" + helmcmd "github.com/werf/nelm/pkg/helm/pkg/cmd" "github.com/werf/nelm/pkg/log" ) @@ -29,9 +30,9 @@ func newChartDependencyUpdateCommand(ctx context.Context, afterAllCommandsBuiltF originalRunE := cmd.RunE cmd.RunE = func(cmd *cobra.Command, args []string) error { - helmSettings := helm_v3.Settings + helmSettings := helmcmd.Settings - ctx = log.SetupLogging(ctx, lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), log.SetupLoggingOptions{}) + ctx = action.SetupLogging(ctx, lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), action.SetupLoggingOptions{}) loader.NoChartLockWarning = "" diff --git a/cmd/nelm/chart_download.go b/cmd/nelm/chart_download.go index fea72ec4..b7ff81fc 100644 --- a/cmd/nelm/chart_download.go +++ b/cmd/nelm/chart_download.go @@ -8,8 +8,9 @@ import ( "github.com/spf13/cobra" "github.com/werf/common-go/pkg/cli" - helm_v3 "github.com/werf/nelm/pkg/helm/cmd/helm" + "github.com/werf/nelm/pkg/action" "github.com/werf/nelm/pkg/helm/pkg/chart/loader" + helmcmd "github.com/werf/nelm/pkg/helm/pkg/cmd" "github.com/werf/nelm/pkg/log" ) @@ -26,9 +27,9 @@ func newChartDownloadCommand(ctx context.Context, afterAllCommandsBuiltFuncs map originalRunE := cmd.RunE cmd.RunE = func(cmd *cobra.Command, args []string) error { - helmSettings := helm_v3.Settings + helmSettings := helmcmd.Settings - ctx = log.SetupLogging(ctx, lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), log.SetupLoggingOptions{}) + ctx = action.SetupLogging(ctx, lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), action.SetupLoggingOptions{}) loader.NoChartLockWarning = "" diff --git a/cmd/nelm/chart_init.go b/cmd/nelm/chart_init.go index d5aa8ef2..618ca40d 100644 --- a/cmd/nelm/chart_init.go +++ b/cmd/nelm/chart_init.go @@ -37,9 +37,7 @@ func newChartInitCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co }, }, func(cmd *cobra.Command, args []string) error { - ctx = log.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), log.InfoLevel), log.SetupLoggingOptions{ - ColorMode: cfg.LogColorMode, - }) + ctx = action.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), log.InfoLevel), action.SetupLoggingOptions{ColorMode: cfg.LogColorMode}) if len(args) > 0 { cfg.ChartDirPath = args[0] diff --git a/cmd/nelm/chart_lint.go b/cmd/nelm/chart_lint.go index e34fd2e1..4ede744c 100644 --- a/cmd/nelm/chart_lint.go +++ b/cmd/nelm/chart_lint.go @@ -45,9 +45,7 @@ func newChartLintCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co }, }, func(cmd *cobra.Command, args []string) error { - ctx = log.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultChartLintLogLevel), log.SetupLoggingOptions{ - ColorMode: cfg.LogColorMode, - }) + ctx = action.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultChartLintLogLevel), action.SetupLoggingOptions{ColorMode: cfg.LogColorMode}) if len(args) > 0 { if featgate.FeatGateRemoteCharts.Enabled() || featgate.FeatGatePreviewV2.Enabled() { diff --git a/cmd/nelm/chart_pack.go b/cmd/nelm/chart_pack.go index aab17d90..79c2f481 100644 --- a/cmd/nelm/chart_pack.go +++ b/cmd/nelm/chart_pack.go @@ -9,9 +9,10 @@ import ( "github.com/spf13/cobra" "github.com/werf/common-go/pkg/cli" + "github.com/werf/nelm/pkg/action" "github.com/werf/nelm/pkg/featgate" - helm_v3 "github.com/werf/nelm/pkg/helm/cmd/helm" "github.com/werf/nelm/pkg/helm/pkg/chart/loader" + helmcmd "github.com/werf/nelm/pkg/helm/pkg/cmd" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/ts" ) @@ -30,10 +31,10 @@ func newChartPackCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co originalRunE := cmd.RunE cmd.RunE = func(cmd *cobra.Command, args []string) error { - helmSettings := helm_v3.Settings + helmSettings := helmcmd.Settings // FIXME(major): should we do it like that everywhere, setting the context? - ctx := log.SetupLogging(cmd.Context(), lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), log.SetupLoggingOptions{}) + ctx := action.SetupLogging(cmd.Context(), lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), action.SetupLoggingOptions{}) cmd.SetContext(ctx) loader.NoChartLockWarning = "" diff --git a/cmd/nelm/chart_render.go b/cmd/nelm/chart_render.go index ed5f4f81..3e98a17b 100644 --- a/cmd/nelm/chart_render.go +++ b/cmd/nelm/chart_render.go @@ -45,7 +45,7 @@ func newChartRenderCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[* }, }, func(cmd *cobra.Command, args []string) error { - ctx = log.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultChartRenderLogLevel), log.SetupLoggingOptions{ + ctx = action.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultChartRenderLogLevel), action.SetupLoggingOptions{ ColorMode: cfg.LogColorMode, LogIsParseable: true, }) diff --git a/cmd/nelm/chart_secret_file_decrypt.go b/cmd/nelm/chart_secret_file_decrypt.go index 1ced0602..02e50f53 100644 --- a/cmd/nelm/chart_secret_file_decrypt.go +++ b/cmd/nelm/chart_secret_file_decrypt.go @@ -38,7 +38,7 @@ func newChartSecretFileDecryptCommand(ctx context.Context, afterAllCommandsBuilt }, }, func(cmd *cobra.Command, args []string) error { - ctx = log.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultSecretFileDecryptLogLevel), log.SetupLoggingOptions{ + ctx = action.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultSecretFileDecryptLogLevel), action.SetupLoggingOptions{ ColorMode: cfg.LogColorMode, LogIsParseable: true, }) diff --git a/cmd/nelm/chart_secret_file_edit.go b/cmd/nelm/chart_secret_file_edit.go index bd569666..b5d986c4 100644 --- a/cmd/nelm/chart_secret_file_edit.go +++ b/cmd/nelm/chart_secret_file_edit.go @@ -38,9 +38,7 @@ func newChartSecretFileEditCommand(ctx context.Context, afterAllCommandsBuiltFun }, }, func(cmd *cobra.Command, args []string) error { - ctx = log.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultSecretFileEditLogLevel), log.SetupLoggingOptions{ - ColorMode: cfg.LogColorMode, - }) + ctx = action.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultSecretFileEditLogLevel), action.SetupLoggingOptions{ColorMode: cfg.LogColorMode}) cfg.File = args[0] diff --git a/cmd/nelm/chart_secret_file_encrypt.go b/cmd/nelm/chart_secret_file_encrypt.go index 06e37ad3..be46fed5 100644 --- a/cmd/nelm/chart_secret_file_encrypt.go +++ b/cmd/nelm/chart_secret_file_encrypt.go @@ -38,7 +38,7 @@ func newChartSecretFileEncryptCommand(ctx context.Context, afterAllCommandsBuilt }, }, func(cmd *cobra.Command, args []string) error { - ctx = log.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultSecretFileEncryptLogLevel), log.SetupLoggingOptions{ + ctx = action.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultSecretFileEncryptLogLevel), action.SetupLoggingOptions{ ColorMode: cfg.LogColorMode, LogIsParseable: true, }) diff --git a/cmd/nelm/chart_secret_key_create.go b/cmd/nelm/chart_secret_key_create.go index 0034b34f..f799b8ae 100644 --- a/cmd/nelm/chart_secret_key_create.go +++ b/cmd/nelm/chart_secret_key_create.go @@ -32,7 +32,7 @@ func newChartSecretKeyCreateCommand(ctx context.Context, afterAllCommandsBuiltFu secretCmdGroup, cli.SubCommandOptions{}, func(cmd *cobra.Command, args []string) error { - ctx = log.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultSecretKeyCreateLogLevel), log.SetupLoggingOptions{ + ctx = action.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultSecretKeyCreateLogLevel), action.SetupLoggingOptions{ ColorMode: cfg.LogColorMode, LogIsParseable: true, }) diff --git a/cmd/nelm/chart_secret_key_rotate.go b/cmd/nelm/chart_secret_key_rotate.go index a93a71cf..1233fa56 100644 --- a/cmd/nelm/chart_secret_key_rotate.go +++ b/cmd/nelm/chart_secret_key_rotate.go @@ -37,9 +37,7 @@ func newChartSecretKeyRotateCommand(ctx context.Context, afterAllCommandsBuiltFu }, }, func(cmd *cobra.Command, args []string) error { - ctx = log.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultSecretKeyRotateLogLevel), log.SetupLoggingOptions{ - ColorMode: cfg.LogColorMode, - }) + ctx = action.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultSecretKeyRotateLogLevel), action.SetupLoggingOptions{ColorMode: cfg.LogColorMode}) if len(args) > 0 { cfg.ChartDirPath = args[0] diff --git a/cmd/nelm/chart_secret_values_file_decrypt.go b/cmd/nelm/chart_secret_values_file_decrypt.go index 39960727..a777e097 100644 --- a/cmd/nelm/chart_secret_values_file_decrypt.go +++ b/cmd/nelm/chart_secret_values_file_decrypt.go @@ -38,7 +38,7 @@ func newChartSecretValuesFileDecryptCommand(ctx context.Context, afterAllCommand }, }, func(cmd *cobra.Command, args []string) error { - ctx = log.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultSecretValuesFileDecryptLogLevel), log.SetupLoggingOptions{ + ctx = action.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultSecretValuesFileDecryptLogLevel), action.SetupLoggingOptions{ ColorMode: cfg.LogColorMode, LogIsParseable: true, }) diff --git a/cmd/nelm/chart_secret_values_file_edit.go b/cmd/nelm/chart_secret_values_file_edit.go index dd4bdba3..1be6d539 100644 --- a/cmd/nelm/chart_secret_values_file_edit.go +++ b/cmd/nelm/chart_secret_values_file_edit.go @@ -38,9 +38,7 @@ func newChartSecretValuesFileEditCommand(ctx context.Context, afterAllCommandsBu }, }, func(cmd *cobra.Command, args []string) error { - ctx = log.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultSecretValuesFileEditLogLevel), log.SetupLoggingOptions{ - ColorMode: cfg.LogColorMode, - }) + ctx = action.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultSecretValuesFileEditLogLevel), action.SetupLoggingOptions{ColorMode: cfg.LogColorMode}) cfg.ValuesFile = args[0] diff --git a/cmd/nelm/chart_secret_values_file_encrypt.go b/cmd/nelm/chart_secret_values_file_encrypt.go index b9e3e07a..bc9bcc5f 100644 --- a/cmd/nelm/chart_secret_values_file_encrypt.go +++ b/cmd/nelm/chart_secret_values_file_encrypt.go @@ -38,7 +38,7 @@ func newChartSecretValuesFileEncryptCommand(ctx context.Context, afterAllCommand }, }, func(cmd *cobra.Command, args []string) error { - ctx = log.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultSecretValuesFileEncryptLogLevel), log.SetupLoggingOptions{ + ctx = action.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultSecretValuesFileEncryptLogLevel), action.SetupLoggingOptions{ ColorMode: cfg.LogColorMode, LogIsParseable: true, }) diff --git a/cmd/nelm/chart_upload.go b/cmd/nelm/chart_upload.go index 954e1888..6ba62d4d 100644 --- a/cmd/nelm/chart_upload.go +++ b/cmd/nelm/chart_upload.go @@ -8,8 +8,9 @@ import ( "github.com/spf13/cobra" "github.com/werf/common-go/pkg/cli" - helm_v3 "github.com/werf/nelm/pkg/helm/cmd/helm" + "github.com/werf/nelm/pkg/action" "github.com/werf/nelm/pkg/helm/pkg/chart/loader" + helmcmd "github.com/werf/nelm/pkg/helm/pkg/cmd" "github.com/werf/nelm/pkg/log" ) @@ -26,9 +27,9 @@ func newChartUploadCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[* originalRunE := cmd.RunE cmd.RunE = func(cmd *cobra.Command, args []string) error { - helmSettings := helm_v3.Settings + helmSettings := helmcmd.Settings - ctx = log.SetupLogging(ctx, lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), log.SetupLoggingOptions{}) + ctx = action.SetupLogging(ctx, lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), action.SetupLoggingOptions{}) loader.NoChartLockWarning = "" diff --git a/cmd/nelm/main.go b/cmd/nelm/main.go index 6674442a..ef2da382 100644 --- a/cmd/nelm/main.go +++ b/cmd/nelm/main.go @@ -18,7 +18,7 @@ import ( "github.com/werf/nelm/pkg/action" "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/featgate" - helm_v3 "github.com/werf/nelm/pkg/helm/cmd/helm" + helmcmd "github.com/werf/nelm/pkg/helm/pkg/cmd" "github.com/werf/nelm/pkg/log" ) @@ -53,9 +53,9 @@ func main() { // Needed for embedding original Helm 3 commands. var err error - helmRootCmd, err = helm_v3.Init() + helmRootCmd, err = helmcmd.NewRootCmd(os.Stdout, os.Args[1:], helmcmd.SetupLogging) if err != nil { - abort(ctx, fmt.Errorf("init helm: %w", err), 1) + abort(ctx, fmt.Errorf("new helm root command: %w", err), 1) } rootCmd := NewRootCommand(ctx, afterAllCommandsBuiltFuncs) diff --git a/cmd/nelm/release_get.go b/cmd/nelm/release_get.go index ac8a8997..7f56d423 100644 --- a/cmd/nelm/release_get.go +++ b/cmd/nelm/release_get.go @@ -37,7 +37,7 @@ func newReleaseGetCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*c Args: cobra.MaximumNArgs(1), }, func(cmd *cobra.Command, args []string) error { - ctx = log.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultReleaseGetLogLevel), log.SetupLoggingOptions{ + ctx = action.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultReleaseGetLogLevel), action.SetupLoggingOptions{ ColorMode: cfg.LogColorMode, LogIsParseable: true, }) diff --git a/cmd/nelm/release_history.go b/cmd/nelm/release_history.go index 3c3a1bf8..8398144e 100644 --- a/cmd/nelm/release_history.go +++ b/cmd/nelm/release_history.go @@ -8,8 +8,9 @@ import ( "github.com/spf13/cobra" "github.com/werf/common-go/pkg/cli" - helm_v3 "github.com/werf/nelm/pkg/helm/cmd/helm" + "github.com/werf/nelm/pkg/action" "github.com/werf/nelm/pkg/helm/pkg/chart/loader" + helmcmd "github.com/werf/nelm/pkg/helm/pkg/cmd" "github.com/werf/nelm/pkg/log" ) @@ -25,9 +26,9 @@ func newReleaseHistoryCommand(ctx context.Context, afterAllCommandsBuiltFuncs ma originalRunE := cmd.RunE cmd.RunE = func(cmd *cobra.Command, args []string) error { - helmSettings := helm_v3.Settings + helmSettings := helmcmd.Settings - ctx = log.SetupLogging(ctx, lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), log.SetupLoggingOptions{}) + ctx = action.SetupLogging(ctx, lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), action.SetupLoggingOptions{}) loader.NoChartLockWarning = "" diff --git a/cmd/nelm/release_install.go b/cmd/nelm/release_install.go index 84ba24fc..1cd0970f 100644 --- a/cmd/nelm/release_install.go +++ b/cmd/nelm/release_install.go @@ -47,9 +47,7 @@ func newReleaseInstallCommand(ctx context.Context, afterAllCommandsBuiltFuncs ma }, }, func(cmd *cobra.Command, args []string) error { - ctx = log.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultReleaseInstallLogLevel), log.SetupLoggingOptions{ - ColorMode: cfg.LogColorMode, - }) + ctx = action.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultReleaseInstallLogLevel), action.SetupLoggingOptions{ColorMode: cfg.LogColorMode}) if len(args) > 0 { if featgate.FeatGateRemoteCharts.Enabled() || featgate.FeatGatePreviewV2.Enabled() { diff --git a/cmd/nelm/release_list.go b/cmd/nelm/release_list.go index 17fd8082..1a614569 100644 --- a/cmd/nelm/release_list.go +++ b/cmd/nelm/release_list.go @@ -32,7 +32,7 @@ func newReleaseListCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[* releaseCmdGroup, cli.SubCommandOptions{}, func(cmd *cobra.Command, args []string) error { - ctx = log.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultReleaseListLogLevel), log.SetupLoggingOptions{ + ctx = action.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultReleaseListLogLevel), action.SetupLoggingOptions{ ColorMode: cfg.LogColorMode, LogIsParseable: true, }) diff --git a/cmd/nelm/release_plan_install.go b/cmd/nelm/release_plan_install.go index 7122ddbe..8d1731ac 100644 --- a/cmd/nelm/release_plan_install.go +++ b/cmd/nelm/release_plan_install.go @@ -47,9 +47,7 @@ func newReleasePlanInstallCommand(ctx context.Context, afterAllCommandsBuiltFunc }, }, func(cmd *cobra.Command, args []string) error { - ctx = log.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultReleasePlanInstallLogLevel), log.SetupLoggingOptions{ - ColorMode: cfg.LogColorMode, - }) + ctx = action.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultReleasePlanInstallLogLevel), action.SetupLoggingOptions{ColorMode: cfg.LogColorMode}) if len(args) > 0 { if featgate.FeatGateRemoteCharts.Enabled() || featgate.FeatGatePreviewV2.Enabled() { diff --git a/cmd/nelm/release_plan_show.go b/cmd/nelm/release_plan_show.go index 6942bb25..bf691208 100644 --- a/cmd/nelm/release_plan_show.go +++ b/cmd/nelm/release_plan_show.go @@ -37,9 +37,7 @@ func newReleasePlanShowCommand(ctx context.Context, afterAllCommandsBuiltFuncs m }, }, func(cmd *cobra.Command, args []string) error { - ctx = log.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultReleasePlanShowLogLevel), log.SetupLoggingOptions{ - ColorMode: cfg.LogColorMode, - }) + ctx = action.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultReleasePlanShowLogLevel), action.SetupLoggingOptions{ColorMode: cfg.LogColorMode}) cfg.PlanArtifactPath = args[0] diff --git a/cmd/nelm/release_rollback.go b/cmd/nelm/release_rollback.go index 4dd97d23..dbe9390d 100644 --- a/cmd/nelm/release_rollback.go +++ b/cmd/nelm/release_rollback.go @@ -37,9 +37,7 @@ func newReleaseRollbackCommand(ctx context.Context, afterAllCommandsBuiltFuncs m Args: cobra.MaximumNArgs(1), }, func(cmd *cobra.Command, args []string) error { - ctx = log.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultReleaseRollbackLogLevel), log.SetupLoggingOptions{ - ColorMode: cfg.LogColorMode, - }) + ctx = action.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultReleaseRollbackLogLevel), action.SetupLoggingOptions{ColorMode: cfg.LogColorMode}) if len(args) > 0 { var err error diff --git a/cmd/nelm/release_uninstall.go b/cmd/nelm/release_uninstall.go index 80e65fa6..35200cfa 100644 --- a/cmd/nelm/release_uninstall.go +++ b/cmd/nelm/release_uninstall.go @@ -34,9 +34,7 @@ func newReleaseUninstallCommand(ctx context.Context, afterAllCommandsBuiltFuncs releaseCmdGroup, cli.SubCommandOptions{}, func(cmd *cobra.Command, args []string) error { - ctx = log.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultReleaseUninstallLogLevel), log.SetupLoggingOptions{ - ColorMode: cfg.LogColorMode, - }) + ctx = action.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultReleaseUninstallLogLevel), action.SetupLoggingOptions{ColorMode: cfg.LogColorMode}) if err := action.ReleaseUninstall(ctx, cfg.ReleaseName, cfg.ReleaseNamespace, cfg.ReleaseUninstallOptions); err != nil { return fmt.Errorf("release uninstall: %w", err) diff --git a/cmd/nelm/repo_add.go b/cmd/nelm/repo_add.go index c2ea260c..a9cdef8c 100644 --- a/cmd/nelm/repo_add.go +++ b/cmd/nelm/repo_add.go @@ -8,8 +8,9 @@ import ( "github.com/spf13/cobra" "github.com/werf/common-go/pkg/cli" - helm_v3 "github.com/werf/nelm/pkg/helm/cmd/helm" + "github.com/werf/nelm/pkg/action" "github.com/werf/nelm/pkg/helm/pkg/chart/loader" + helmcmd "github.com/werf/nelm/pkg/helm/pkg/cmd" "github.com/werf/nelm/pkg/log" ) @@ -29,9 +30,9 @@ func newRepoAddCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobr originalRunE := cmd.RunE cmd.RunE = func(cmd *cobra.Command, args []string) error { - helmSettings := helm_v3.Settings + helmSettings := helmcmd.Settings - ctx = log.SetupLogging(ctx, lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), log.SetupLoggingOptions{}) + ctx = action.SetupLogging(ctx, lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), action.SetupLoggingOptions{}) loader.NoChartLockWarning = "" diff --git a/cmd/nelm/repo_login.go b/cmd/nelm/repo_login.go index 8fee821f..67d25d38 100644 --- a/cmd/nelm/repo_login.go +++ b/cmd/nelm/repo_login.go @@ -8,8 +8,9 @@ import ( "github.com/spf13/cobra" "github.com/werf/common-go/pkg/cli" - helm_v3 "github.com/werf/nelm/pkg/helm/cmd/helm" + "github.com/werf/nelm/pkg/action" "github.com/werf/nelm/pkg/helm/pkg/chart/loader" + helmcmd "github.com/werf/nelm/pkg/helm/pkg/cmd" "github.com/werf/nelm/pkg/log" ) @@ -30,9 +31,9 @@ func newRepoLoginCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co originalRunE := cmd.RunE cmd.RunE = func(cmd *cobra.Command, args []string) error { - helmSettings := helm_v3.Settings + helmSettings := helmcmd.Settings - ctx = log.SetupLogging(ctx, lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), log.SetupLoggingOptions{}) + ctx = action.SetupLogging(ctx, lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), action.SetupLoggingOptions{}) loader.NoChartLockWarning = "" diff --git a/cmd/nelm/repo_logout.go b/cmd/nelm/repo_logout.go index a4ea5a61..ced85ccc 100644 --- a/cmd/nelm/repo_logout.go +++ b/cmd/nelm/repo_logout.go @@ -8,8 +8,9 @@ import ( "github.com/spf13/cobra" "github.com/werf/common-go/pkg/cli" - helm_v3 "github.com/werf/nelm/pkg/helm/cmd/helm" + "github.com/werf/nelm/pkg/action" "github.com/werf/nelm/pkg/helm/pkg/chart/loader" + helmcmd "github.com/werf/nelm/pkg/helm/pkg/cmd" "github.com/werf/nelm/pkg/log" ) @@ -30,9 +31,9 @@ func newRepoLogoutCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*c originalRunE := cmd.RunE cmd.RunE = func(cmd *cobra.Command, args []string) error { - helmSettings := helm_v3.Settings + helmSettings := helmcmd.Settings - ctx = log.SetupLogging(ctx, lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), log.SetupLoggingOptions{}) + ctx = action.SetupLogging(ctx, lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), action.SetupLoggingOptions{}) loader.NoChartLockWarning = "" diff --git a/cmd/nelm/repo_remove.go b/cmd/nelm/repo_remove.go index b7458519..025141e4 100644 --- a/cmd/nelm/repo_remove.go +++ b/cmd/nelm/repo_remove.go @@ -8,8 +8,9 @@ import ( "github.com/spf13/cobra" "github.com/werf/common-go/pkg/cli" - helm_v3 "github.com/werf/nelm/pkg/helm/cmd/helm" + "github.com/werf/nelm/pkg/action" "github.com/werf/nelm/pkg/helm/pkg/chart/loader" + helmcmd "github.com/werf/nelm/pkg/helm/pkg/cmd" "github.com/werf/nelm/pkg/log" ) @@ -29,9 +30,9 @@ func newRepoRemoveCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*c originalRunE := cmd.RunE cmd.RunE = func(cmd *cobra.Command, args []string) error { - helmSettings := helm_v3.Settings + helmSettings := helmcmd.Settings - ctx = log.SetupLogging(ctx, lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), log.SetupLoggingOptions{}) + ctx = action.SetupLogging(ctx, lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), action.SetupLoggingOptions{}) loader.NoChartLockWarning = "" diff --git a/cmd/nelm/repo_update.go b/cmd/nelm/repo_update.go index e96ff565..f878644e 100644 --- a/cmd/nelm/repo_update.go +++ b/cmd/nelm/repo_update.go @@ -8,8 +8,9 @@ import ( "github.com/spf13/cobra" "github.com/werf/common-go/pkg/cli" - helm_v3 "github.com/werf/nelm/pkg/helm/cmd/helm" + "github.com/werf/nelm/pkg/action" "github.com/werf/nelm/pkg/helm/pkg/chart/loader" + helmcmd "github.com/werf/nelm/pkg/helm/pkg/cmd" "github.com/werf/nelm/pkg/log" ) @@ -30,9 +31,9 @@ func newRepoUpdateCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*c originalRunE := cmd.RunE cmd.RunE = func(cmd *cobra.Command, args []string) error { - helmSettings := helm_v3.Settings + helmSettings := helmcmd.Settings - ctx = log.SetupLogging(ctx, lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), log.SetupLoggingOptions{}) + ctx = action.SetupLogging(ctx, lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), action.SetupLoggingOptions{}) loader.NoChartLockWarning = "" diff --git a/cmd/nelm/version.go b/cmd/nelm/version.go index d5d7b699..b263cfd5 100644 --- a/cmd/nelm/version.go +++ b/cmd/nelm/version.go @@ -32,7 +32,7 @@ func newVersionCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobr miscCmdGroup, cli.SubCommandOptions{}, func(cmd *cobra.Command, args []string) error { - ctx = log.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultVersionLogLevel), log.SetupLoggingOptions{ + ctx = action.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultVersionLogLevel), action.SetupLoggingOptions{ ColorMode: cfg.LogColorMode, LogIsParseable: true, }) diff --git a/go.mod b/go.mod index 02eeba98..449770ec 100644 --- a/go.mod +++ b/go.mod @@ -1,233 +1,236 @@ module github.com/werf/nelm -go 1.23.1 +go 1.25.0 require ( - github.com/BurntSushi/toml v1.3.2 + github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 + github.com/BurntSushi/toml v1.6.0 github.com/DATA-DOG/go-sqlmock v1.5.2 - github.com/Masterminds/semver/v3 v3.3.1 - github.com/Masterminds/sprig/v3 v3.2.3 + github.com/Masterminds/semver/v3 v3.4.0 + github.com/Masterminds/sprig/v3 v3.3.0 github.com/Masterminds/squirrel v1.5.4 - github.com/Masterminds/vcs v1.13.3 - github.com/alecthomas/chroma/v2 v2.15.0 + github.com/ProtonMail/go-crypto v1.4.1 + github.com/alecthomas/chroma/v2 v2.23.1 github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 - github.com/aymanbagabas/go-udiff v0.2.0 + github.com/aymanbagabas/go-udiff v0.4.1 github.com/chanced/caps v1.0.2 - github.com/containerd/containerd v1.7.14 github.com/containerd/log v0.1.0 - github.com/cyphar/filepath-securejoin v0.2.5 + github.com/cyphar/filepath-securejoin v0.6.1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc - github.com/distribution/distribution/v3 v3.0.0-alpha.1 - github.com/docker/cli v25.0.5+incompatible - github.com/docker/docker v25.0.5+incompatible + github.com/distribution/distribution/v3 v3.0.0 github.com/dominikbraun/graph v0.23.0 - github.com/dop251/goja v0.0.0-20251121114222-56b1242a5f86 - github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf - github.com/evanphx/json-patch v5.8.0+incompatible - github.com/evanw/esbuild v0.27.0 - github.com/foxcpp/go-mockdns v1.0.0 - github.com/go-resty/resty/v2 v2.17.1 + github.com/dop251/goja v0.0.0-20260311135729-065cd970411c + github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14 + github.com/evanphx/json-patch v5.9.11+incompatible + github.com/evanphx/json-patch/v5 v5.9.11 + github.com/evanw/esbuild v0.27.4 + github.com/fatih/color v1.19.0 + github.com/fluxcd/cli-utils v0.37.2-flux.1 + github.com/foxcpp/go-mockdns v1.2.0 + github.com/go-resty/resty/v2 v2.17.2 github.com/gobwas/glob v0.2.3 - github.com/goccy/go-yaml v1.15.23 - github.com/gofrs/flock v0.8.1 - github.com/google/go-cmp v0.6.0 + github.com/goccy/go-yaml v1.19.2 + github.com/gofrs/flock v0.13.0 + github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 - github.com/gookit/color v1.5.4 + github.com/gookit/color v1.6.0 github.com/gosuri/uitable v0.0.4 - github.com/hashicorp/go-multierror v1.1.1 github.com/hofstadter-io/cinful v1.0.0 - github.com/jedib0t/go-pretty/v6 v6.5.5 - github.com/jellydator/ttlcache/v3 v3.1.1 - github.com/jmoiron/sqlx v1.3.5 - github.com/lib/pq v1.10.9 - github.com/looplab/fsm v1.0.2 + github.com/jedib0t/go-pretty/v6 v6.7.8 + github.com/jellydator/ttlcache/v3 v3.4.0 + github.com/jmoiron/sqlx v1.4.0 + github.com/lib/pq v1.12.0 + github.com/looplab/fsm v1.0.3 github.com/mattn/go-shellwords v1.0.12 github.com/mitchellh/copystructure v1.2.0 - github.com/moby/term v0.5.0 - github.com/ohler55/ojg v1.26.7 - github.com/opencontainers/image-spec v1.1.0 - github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 + github.com/moby/term v0.5.2 + github.com/ohler55/ojg v1.28.1 + github.com/opencontainers/image-spec v1.1.1 github.com/pkg/errors v0.9.1 - github.com/rubenv/sql-migrate v1.6.1 - github.com/samber/lo v1.49.1 - github.com/sirupsen/logrus v1.9.3 + github.com/rubenv/sql-migrate v1.8.1 + github.com/samber/lo v1.53.0 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 + github.com/sirupsen/logrus v1.9.4 github.com/sourcegraph/conc v0.3.0 - github.com/spf13/cobra v1.8.0 - github.com/spf13/pflag v1.0.5 - github.com/stretchr/testify v1.10.0 + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 + github.com/stretchr/testify v1.11.1 github.com/tidwall/sjson v1.2.5 - github.com/wI2L/jsondiff v0.5.0 - github.com/werf/common-go v0.0.0-20251113140850-a1a98e909e9b + github.com/wI2L/jsondiff v0.7.0 + github.com/werf/common-go v0.0.0-20260212174520-adf7d95a1579 github.com/werf/kubedog v0.13.1-0.20260320165832-7d97aaf7aab9 github.com/werf/lockgate v0.1.1 github.com/werf/logboek v0.6.1 - github.com/xeipuuv/gojsonschema v1.2.0 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e - github.com/yannh/kubeconform v0.6.7 - golang.org/x/crypto v0.41.0 - golang.org/x/term v0.34.0 - k8s.io/api v0.29.3 - k8s.io/apiextensions-apiserver v0.29.0 - k8s.io/apimachinery v0.29.3 - k8s.io/apiserver v0.29.2 - k8s.io/cli-runtime v0.29.3 - k8s.io/client-go v0.29.3 + github.com/yannh/kubeconform v0.7.0 + golang.org/x/crypto v0.49.0 + golang.org/x/term v0.41.0 + k8s.io/api v0.35.3 + k8s.io/apiextensions-apiserver v0.35.3 + k8s.io/apimachinery v0.35.3 + k8s.io/apiserver v0.35.3 + k8s.io/cli-runtime v0.35.3 + k8s.io/client-go v0.35.3 k8s.io/klog v1.0.0 - k8s.io/klog/v2 v2.120.1 - k8s.io/kubectl v0.29.3 - oras.land/oras-go v1.2.5 - sigs.k8s.io/yaml v1.4.0 + k8s.io/klog/v2 v2.140.0 + k8s.io/kubectl v0.35.3 + oras.land/oras-go/v2 v2.6.0 + sigs.k8s.io/controller-runtime v0.23.3 + sigs.k8s.io/kustomize/kyaml v0.21.1 + sigs.k8s.io/yaml v1.6.0 ) require ( - github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect - github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/MakeNowJust/heredoc v1.0.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect - github.com/Microsoft/hcsshim v0.12.2 // indirect github.com/avelino/slugify v0.0.0-20180501145920-855f152bd774 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect github.com/bshuster-repo/logrus-logstash-hook v1.1.0 // indirect - github.com/cenkalti/backoff/v4 v4.2.1 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect - github.com/chai2010/gettext-go v1.0.2 // indirect - github.com/containerd/cgroups/v3 v3.0.3 // indirect - github.com/containerd/continuity v0.4.3 // indirect - github.com/coreos/go-systemd/v22 v22.5.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chai2010/gettext-go v1.0.3 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/coreos/go-systemd/v22 v22.7.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/distribution/reference v0.5.0 // indirect + github.com/distribution/reference v0.6.0 // indirect github.com/djherbis/buffer v1.2.0 // indirect github.com/djherbis/nio/v3 v3.0.1 // indirect - github.com/dlclark/regexp2 v1.11.4 // indirect - github.com/docker/distribution v2.8.3+incompatible // indirect - github.com/docker/docker-credential-helpers v0.8.1 // indirect - github.com/docker/go-connections v0.5.0 // indirect - github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/docker/docker-credential-helpers v0.9.5 // indirect + github.com/docker/go-events v0.0.0-20250808211157-605354379745 // indirect github.com/docker/go-metrics v0.0.1 // indirect - github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect - github.com/emicklei/go-restful/v3 v3.11.2 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect - github.com/fatih/color v1.16.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fluxcd/flagger v1.36.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-errors/errors v1.5.1 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.21.0 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/jsonreference v0.21.5 // indirect + github.com/go-openapi/swag v0.25.5 // indirect + github.com/go-openapi/swag/cmdutils v0.25.5 // indirect + github.com/go-openapi/swag/conv v0.25.5 // indirect + github.com/go-openapi/swag/fileutils v0.25.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect + github.com/go-openapi/swag/jsonutils v0.25.5 // indirect + github.com/go-openapi/swag/loading v0.25.5 // indirect + github.com/go-openapi/swag/mangling v0.25.5 // indirect + github.com/go-openapi/swag/netutils v0.25.5 // indirect + github.com/go-openapi/swag/stringutils v0.25.5 // indirect + github.com/go-openapi/swag/typeutils v0.25.5 // indirect + github.com/go-openapi/swag/yamlutils v0.25.5 // indirect github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect - github.com/go-sql-driver/mysql v1.7.1 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/protobuf v1.5.4 // indirect - github.com/google/btree v1.1.2 // indirect - github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect - github.com/google/gofuzz v1.2.0 // indirect - github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db // indirect - github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.7.1 // indirect + github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect github.com/gorilla/handlers v1.5.2 // indirect github.com/gorilla/mux v1.8.1 // indirect - github.com/gorilla/websocket v1.5.1 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 // indirect - github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/hashicorp/go-retryablehttp v0.7.7 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/golang-lru/arc/v2 v2.0.7 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/huandu/xstrings v1.4.0 // indirect - github.com/imdario/mergo v0.3.16 // indirect + github.com/huandu/xstrings v1.5.0 // 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/klauspost/compress v1.17.7 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/klauspost/cpuid/v2 v2.2.5 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect - github.com/mailru/easyjson v0.7.7 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.15 // indirect - github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/mattn/go-runewidth v0.0.21 // indirect github.com/miekg/dns v1.1.58 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/moby/locker v1.0.1 // indirect - github.com/moby/spdystream v0.2.0 // indirect - github.com/moby/sys/mountinfo v0.7.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/onsi/gomega v1.39.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.19.0 // indirect - github.com/prometheus/client_model v0.6.0 // indirect - github.com/prometheus/common v0.48.0 // indirect - github.com/prometheus/procfs v0.13.0 // indirect - github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 // indirect - github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 // indirect - github.com/redis/go-redis/v9 v9.4.0 // indirect - github.com/rivo/uniseg v0.4.7 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/otlptranslator v1.0.0 // indirect + github.com/prometheus/procfs v0.20.1 // indirect + github.com/redis/go-redis/extra/rediscmd/v9 v9.18.0 // indirect + github.com/redis/go-redis/extra/redisotel/v9 v9.18.0 // indirect + github.com/redis/go-redis/v9 v9.18.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect - github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect - github.com/shopspring/decimal v1.3.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect - github.com/spf13/cast v1.6.0 // indirect - github.com/tidwall/gjson v1.17.0 // indirect - github.com/tidwall/match v1.1.1 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xlab/treeprint v1.2.0 // indirect - go.opentelemetry.io/contrib/exporters/autoexport v0.46.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect - go.opentelemetry.io/otel v1.24.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.44.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.21.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect - go.opentelemetry.io/otel/exporters/prometheus v0.44.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v0.44.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.21.0 // indirect - go.opentelemetry.io/otel/metric v1.24.0 // indirect - go.opentelemetry.io/otel/sdk v1.24.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.21.0 // indirect - go.opentelemetry.io/otel/trace v1.24.0 // indirect - go.opentelemetry.io/proto/otlp v1.1.0 // indirect - go.starlark.net v0.0.0-20231121155337-90ade8b19d09 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 // indirect + go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/otel v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/prometheus v0.64.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 // indirect + go.opentelemetry.io/otel/log v0.18.0 // indirect + go.opentelemetry.io/otel/metric v1.42.0 // indirect + go.opentelemetry.io/otel/sdk v1.42.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.18.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect + go.opentelemetry.io/otel/trace v1.42.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/mod v0.26.0 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/oauth2 v0.18.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect - golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.35.0 // indirect - google.golang.org/appengine v1.6.8 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20240325203815-454cdb8f5daa // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240325203815-454cdb8f5daa // indirect - google.golang.org/grpc v1.62.1 // indirect - google.golang.org/protobuf v1.35.1 // indirect - gopkg.in/evanphx/json-patch.v5 v5.8.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.42.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - gotest.tools/v3 v3.5.1 // indirect - k8s.io/component-base v0.29.3 // indirect - k8s.io/kube-openapi v0.0.0-20240105020646-a37d4de58910 // indirect - k8s.io/utils v0.0.0-20240310230437-4693a0247e57 // indirect - sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect - sigs.k8s.io/kustomize/api v0.16.0 // indirect - sigs.k8s.io/kustomize/kyaml v0.16.0 // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + k8s.io/component-base v0.35.3 // indirect + k8s.io/kube-openapi v0.0.0-20260319004828-5883c5ee87b9 // indirect + k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/kustomize/api v0.21.1 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect ) replace github.com/spf13/cobra => github.com/andremueller/cobra v0.0.0-20241025091859-0d550c15a8a4 // remove when merged: https://github.com/spf13/cobra/pull/2167 diff --git a/go.sum b/go.sum index 8b665156..05a491a8 100644 --- a/go.sum +++ b/go.sum @@ -1,140 +1,129 @@ -github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= -github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= 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.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= -github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= -github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= -github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= -github.com/Masterminds/vcs v1.13.3 h1:IIA2aBdXvfbIM+yl/eTnL4hb1XwdpvuQLglAix1gweE= -github.com/Masterminds/vcs v1.13.3/go.mod h1:TiE7xuEjl1N4j016moRd6vezp6e6Lz23gypeXfzXeW8= -github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= -github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= -github.com/Microsoft/hcsshim v0.12.2 h1:AcXy+yfRvrx20g9v7qYaJv5Rh+8GaHOS6b8G6Wx/nKs= -github.com/Microsoft/hcsshim v0.12.2/go.mod h1:RZV12pcHCXQ42XnlQ3pz6FZfmrC1C+R4gaOHhRNML1g= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc= -github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio= -github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= -github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= +github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/andremueller/cobra v0.0.0-20241025091859-0d550c15a8a4 h1:behIQV+NveRm5cqXAfvYDEA2AkjwE8rdJjyA7YT5XlM= github.com/andremueller/cobra v0.0.0-20241025091859-0d550c15a8a4/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/avelino/slugify v0.0.0-20180501145920-855f152bd774 h1:HrMVYtly2IVqg9EBooHsakQ256ueojP7QuG32K71X/U= github.com/avelino/slugify v0.0.0-20180501145920-855f152bd774/go.mod h1:5wi5YYOpfuAKwL5XLFYopbgIl/v7NZxaJpa/4X6yFKE= -github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= 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/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bshuster-repo/logrus-logstash-hook v1.1.0 h1:o2FzZifLg+z/DN1OFmzTWzZZx/roaqt8IPZCIVco8r4= github.com/bshuster-repo/logrus-logstash-hook v1.1.0/go.mod h1:Q2aXOe7rNuPgbBtPCOzYyWDvKX7+FpxE5sRdvcPoui0= -github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= -github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= -github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -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/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chai2010/gettext-go v1.0.3 h1:9liNh8t+u26xl5ddmWLmsOsdNLwkdRTg5AG+JnTiM80= +github.com/chai2010/gettext-go v1.0.3/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= github.com/chanced/caps v1.0.2 h1:RELvNN4lZajqSXJGzPaU7z8B4LK2+o2Oc/upeWdgMOA= github.com/chanced/caps v1.0.2/go.mod h1:SJhRzeYLKJ3OmzyQXhdZ7Etj7lqqWoPtQ1zcSJRtQjs= -github.com/containerd/cgroups/v3 v3.0.3 h1:S5ByHZ/h9PMe5IOQoN7E+nMc2UcLEM/V48DGDJ9kip0= -github.com/containerd/cgroups/v3 v3.0.3/go.mod h1:8HBe7V3aWGLFPd/k03swSIsGjZhHI2WzJmticMgVuz0= -github.com/containerd/containerd v1.7.14 h1:H/XLzbnGuenZEGK+v0RkwTdv2u1QFAruMe5N0GNPJwA= -github.com/containerd/containerd v1.7.14/go.mod h1:YMC9Qt5yzNqXx/fO4j/5yYVIHXSRrlB3H7sxkUTvspg= -github.com/containerd/continuity v0.4.3 h1:6HVkalIp+2u1ZLH1J/pYX2oBVXlJZvh1X1A7bEZ9Su8= -github.com/containerd/continuity v0.4.3/go.mod h1:F6PTNCKepoxEaXLQp3wDAjygEnImnZ/7o4JzpodfroQ= -github.com/containerd/errdefs v0.1.0 h1:m0wCRBiu1WJT/Fr+iOoQHMQS/eP5myQ8lCv4Dz5ZURM= -github.com/containerd/errdefs v0.1.0/go.mod h1:YgWiiHtLmSeBrvpw+UfPijzbLaB77mEG1WwJTDETIV0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= -github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= +github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= -github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo= -github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/distribution/distribution/v3 v3.0.0-alpha.1 h1:jn7I1gvjOvmLztH1+1cLiUFud7aeJCIQcgzugtwjyJo= -github.com/distribution/distribution/v3 v3.0.0-alpha.1/go.mod h1:LCp4JZp1ZalYg0W/TN05jarCQu+h4w7xc7ZfQF4Y/cY= -github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0= -github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN6UX90KJc4HjyM= +github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/djherbis/buffer v1.1.0/go.mod h1:VwN8VdFkMY0DCALdY8o00d3IZ6Amz/UNVMWcSaJT44o= github.com/djherbis/buffer v1.2.0 h1:PH5Dd2ss0C7CRRhQCZ2u7MssF+No9ide8Ye71nPHcrQ= github.com/djherbis/buffer v1.2.0/go.mod h1:fjnebbZjCUpPinBRD+TDwXSOeNQ7fPQWLfGQqiAiUyE= github.com/djherbis/nio/v3 v3.0.1 h1:6wxhnuppteMa6RHA4L81Dq7ThkZH8SwnDzXDYy95vB4= github.com/djherbis/nio/v3 v3.0.1/go.mod h1:Ng4h80pbZFMla1yKzm61cF0tqqilXZYrogmWgZxOcmg= -github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= -github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/docker/cli v25.0.5+incompatible h1:3Llw3kcE1gOScEojA247iDD+p1l9hHeC7H3vf3Zd5fk= -github.com/docker/cli v25.0.5+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= -github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/docker v25.0.5+incompatible h1:UmQydMduGkrD5nQde1mecF/YnSbTOaPeFIeP5C4W+DE= -github.com/docker/docker v25.0.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/docker-credential-helpers v0.8.1 h1:j/eKUktUltBtMzKqmfLB0PAgqYyMHOp5vfsD1807oKo= -github.com/docker/docker-credential-helpers v0.8.1/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= -github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= -github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= -github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= -github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY= +github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= +github.com/docker/go-events v0.0.0-20250808211157-605354379745 h1:yOn6Ze6IbYI/KAw2lw/83ELYvZh6hvsygTVkD0dzMC4= +github.com/docker/go-events v0.0.0-20250808211157-605354379745/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= -github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4= -github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo= github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc= -github.com/dop251/goja v0.0.0-20251121114222-56b1242a5f86 h1:iY/kk+Fw7k49PRM4cS2wz9CVxO0jB61+h//XN9bbAS4= -github.com/dop251/goja v0.0.0-20251121114222-56b1242a5f86/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= -github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf h1:gbmvliZnCut4NjaPSNOQlfqBoZ9C5Dpf72mHMMYhgVE= -github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0= -github.com/emicklei/go-restful/v3 v3.11.2 h1:1onLa9DcsMYO9P+CXaL0dStDqQ2EHHXLiz+BtnqkLAU= -github.com/emicklei/go-restful/v3 v3.11.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/evanphx/json-patch v5.8.0+incompatible h1:1Av9pn2FyxPdvrWNQszj1g6D6YthSmvCfcN6SYclTJg= -github.com/evanphx/json-patch v5.8.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= -github.com/evanw/esbuild v0.27.0 h1:1fbrgepqU1rZeu4VPcQRZJpvIfQpbrYqRr1wJdeMkfM= -github.com/evanw/esbuild v0.27.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= +github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk= +github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= +github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14 h1:3U8dTgyNBhEQ/GVw0jZW5q+93Zw2gAZPRWhJ9TwV3rM= +github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= +github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/evanw/esbuild v0.27.4 h1:8opEixKkH9EDsdjxC/aPmpk1KPwQOcyknDo5m5xIFxI= +github.com/evanw/esbuild v0.27.4/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= -github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= -github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fluxcd/cli-utils v0.37.2-flux.1 h1:tQ588ghtRN+E+kHq415FddfqA9v4brn/1WWgrP6rQR0= +github.com/fluxcd/cli-utils v0.37.2-flux.1/go.mod h1:LcWSu1NYET8d8U7O326RhEm5JkQXCMK6ITu4G1CT02c= github.com/fluxcd/flagger v1.36.1 h1:X2PumtNwZz9YSGaOtZLFm2zAKLgHhFkbNv8beg7ifyc= github.com/fluxcd/flagger v1.36.1/go.mod h1:qmtLsxheVDTI8XeCaXUxW5UCmfcSKnY9fizG9NmW/Fk= -github.com/foxcpp/go-mockdns v1.0.0 h1:7jBqxd3WDWwi/6WhDvacvH1XsN3rOLXyHM1uhvIx6FI= -github.com/foxcpp/go-mockdns v1.0.0/go.mod h1:lgRN6+KxQBawyIghpnl5CezHFGS9VLzvtVlwxvzXTQ4= +github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= +github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= @@ -144,91 +133,99 @@ github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9 github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= -github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= -github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= +github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= +github.com/go-openapi/swag v0.25.5 h1:pNkwbUEeGwMtcgxDr+2GBPAk4kT+kJ+AaB+TMKAg+TU= +github.com/go-openapi/swag v0.25.5/go.mod h1:B3RT6l8q7X803JRxa2e59tHOiZlX1t8viplOcs9CwTA= +github.com/go-openapi/swag/cmdutils v0.25.5 h1:yh5hHrpgsw4NwM9KAEtaDTXILYzdXh/I8Whhx9hKj7c= +github.com/go-openapi/swag/cmdutils v0.25.5/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g= +github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k= +github.com/go-openapi/swag/fileutils v0.25.5 h1:B6JTdOcs2c0dBIs9HnkyTW+5gC+8NIhVBUwERkFhMWk= +github.com/go-openapi/swag/fileutils v0.25.5/go.mod h1:V3cT9UdMQIaH4WiTrUc9EPtVA4txS0TOmRURmhGF4kc= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo= +github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo= +github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= +github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= +github.com/go-openapi/swag/mangling v0.25.5 h1:hyrnvbQRS7vKePQPHHDso+k6CGn5ZBs5232UqWZmJZw= +github.com/go-openapi/swag/mangling v0.25.5/go.mod h1:6hadXM/o312N/h98RwByLg088U61TPGiltQn71Iw0NY= +github.com/go-openapi/swag/netutils v0.25.5 h1:LZq2Xc2QI8+7838elRAaPCeqJnHODfSyOa7ZGfxDKlU= +github.com/go-openapi/swag/netutils v0.25.5/go.mod h1:lHbtmj4m57APG/8H7ZcMMSWzNqIQcu0RFiXrPUara14= +github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M= +github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII= +github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E= +github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc= +github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ= +github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM= +github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= +github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= +github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= -github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= -github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= -github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= -github.com/goccy/go-yaml v1.15.23 h1:WS0GAX1uNPDLUvLkNU2vXq6oTnsmfVFocjQ/4qA48qo= -github.com/goccy/go-yaml v1.15.23/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= -github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -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/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.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= -github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 h1:0VpGH+cDhbDtdcweoyCVsF3fhN8kejK6rFe/2FFX2nU= -github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49/go.mod h1:BkkQ4L1KS1xMt2aWSPStnn55ChGC0DPOn2FQYj+f25M= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.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/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= -github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= -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.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw= +github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= -github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= +github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0= +github.com/gookit/assert v0.1.1/go.mod h1:jS5bmIVQZTIwk42uXl4lyj4iaaxx32tqH16CFj0VX2E= +github.com/gookit/color v1.6.0 h1:JjJXBTk1ETNyqyilJhkTXJYYigHG24TM9Xa2M1xAhRA= +github.com/gookit/color v1.6.0/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= -github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY= github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 h1:/c3QmbOGMGTOumP2iT/rCwB7b0QDGLKzqOmktBjT+Is= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= -github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= -github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/golang-lru/arc/v2 v2.0.7 h1:QxkVTxwColcduO+LP7eJO56r2hFiG8zEbfAAzRv52KQ= github.com/hashicorp/golang-lru/arc/v2 v2.0.7/go.mod h1:Pe7gBlGdc8clY5LJ0LpJXMt5AmgmWNH1g+oFFVUHOEc= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -237,114 +234,92 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hofstadter-io/cinful v1.0.0 h1:G/kZ/iwM0EyTyEtdE4UyLNLOVNSSHVs1cW0DC7uoxmE= github.com/hofstadter-io/cinful v1.0.0/go.mod h1:VySLSoBPf5gTFEeumOhl8I2cjspiJAB3/XGgrivpUZI= -github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= -github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= -github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= -github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jedib0t/go-pretty/v6 v6.5.5 h1:PpIU8lOjxvVYGGKule0QxxJfNysUSbC9lggQU2cpZJc= -github.com/jedib0t/go-pretty/v6 v6.5.5/go.mod h1:5LQIxa52oJ/DlDSLv0HEkWOFMDGoWkJb9ss5KqPpJBg= -github.com/jellydator/ttlcache/v3 v3.1.1 h1:RCgYJqo3jgvhl+fEWvjNW8thxGWsgxi+TPhRir1Y9y8= -github.com/jellydator/ttlcache/v3 v3.1.1/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4= -github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= -github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= -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/jedib0t/go-pretty/v6 v6.7.8 h1:BVYrDy5DPBA3Qn9ICT+PokP9cvCv1KaHv2i+Hc8sr5o= +github.com/jedib0t/go-pretty/v6 v6.7.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= +github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= +github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= 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.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -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/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= -github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg= -github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= +github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 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.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -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 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= -github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.12.0 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo= +github.com/lib/pq v1.12.0/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= 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/looplab/fsm v1.0.2 h1:f0kdMzr4CRpXtaKKRUxwLYJ7PirTdwrtNumeLN+mDx8= -github.com/looplab/fsm v1.0.2/go.mod h1:PmD3fFvQEIsjMEfvZdrCDZ6y8VwKTwWNjlpEr6IKPO4= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/looplab/fsm v1.0.3 h1:qtxBsa2onOs0qFOtkqwf5zE0uP0+Te+wlIvXctPKpcw= +github.com/looplab/fsm v1.0.3/go.mod h1:PmD3fFvQEIsjMEfvZdrCDZ6y8VwKTwWNjlpEr6IKPO4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= +github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= -github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/miekg/dns v1.1.25/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= +github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= -github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= -github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= -github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= -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/sys/mountinfo v0.7.1 h1:/tTvQaSJRr2FshkhXiIpux6fQ2Zvc4j7tAhMTStAG2g= -github.com/moby/sys/mountinfo v0.7.1/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI= -github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= -github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= 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/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/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-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/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= -github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= -github.com/ohler55/ojg v1.26.7 h1:yZLS2xlZF/qk5LHM4LFhxxTDyMgZl+46Z6p7wQm8KAU= -github.com/ohler55/ojg v1.26.7/go.mod h1:/Y5dGWkekv9ocnUixuETqiL58f+5pAsUfg5P8e7Pa2o= -github.com/onsi/ginkgo/v2 v2.20.1 h1:YlVIbqct+ZmnEph770q9Q7NVAz4wwIiVNahee6JyUzo= -github.com/onsi/ginkgo/v2 v2.20.1/go.mod h1:lG9ey2Z29hR41WMVthyJBGUBcBhGOtoPF2VFMvBXFCI= -github.com/onsi/gomega v1.36.0 h1:Pb12RlruUtj4XUuPUqeEWc6j5DkVVVA49Uf6YLfC95Y= -github.com/onsi/gomega v1.36.0/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/ohler55/ojg v1.28.1 h1:Xy93DelhLSZNeWv8GPKtP6qMqkUlZlAxBP/AQcC5RfY= +github.com/ohler55/ojg v1.28.1/go.mod h1:/Y5dGWkekv9ocnUixuETqiL58f+5pAsUfg5P8e7Pa2o= +github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= +github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= +github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= +github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= -github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 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/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI= -github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= github.com/pkg/errors v0.8.0/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= @@ -356,91 +331,89 @@ github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjz github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= -github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= -github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= 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.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos= -github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= -github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= -github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= -github.com/prometheus/procfs v0.13.0 h1:GqzLlQyfsPbaEHaQkO7tbDlriv/4o5Hudv6OXHGKX7o= -github.com/prometheus/procfs v0.13.0/go.mod h1:cd4PFCR54QLnGKPaKGA6l+cfuNXtht43ZKY6tow0Y1g= -github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 h1:EaDatTxkdHG+U3Bk4EUr+DZ7fOGwTfezUiUJMaIcaho= -github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5/go.mod h1:fyalQWdtzDBECAQFBJuQe5bzQ02jGd5Qcbgb97Flm7U= -github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 h1:EfpWLLCyXw8PSM2/XNJLjI3Pb27yVE+gIAfeqp8LUCc= -github.com/redis/go-redis/extra/redisotel/v9 v9.0.5/go.mod h1:WZjPDy7VNzn77AAfnAfVjZNvfJTYfPetfZk5yoSTLaQ= -github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk= -github.com/redis/go-redis/v9 v9.4.0 h1:Yzoz33UZw9I/mFhx4MNrB6Fk+XHO1VukNcCa1+lwyKk= -github.com/redis/go-redis/v9 v9.4.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -github.com/rubenv/sql-migrate v1.6.1 h1:bo6/sjsan9HaXAsNxYP/jCEDUGibHp8JmOBw7NTGRos= -github.com/rubenv/sql-migrate v1.6.1/go.mod h1:tPzespupJS0jacLfhbwto/UjSX+8h2FdWB7ar+QlHa0= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/redis/go-redis/extra/rediscmd/v9 v9.18.0 h1:QY4nmPHLFAJjtT5O4OMUEOxP8WVaRNOFpcbmxT2NLZU= +github.com/redis/go-redis/extra/rediscmd/v9 v9.18.0/go.mod h1:WH8cY/0fT41Bsf341qzo8v4nx0GCE8FykAA23IVbVmo= +github.com/redis/go-redis/extra/redisotel/v9 v9.18.0 h1:2dKdoEYBJ0CZCLPiCdvvc7luz3DPwY6hKdzjL6m1eHE= +github.com/redis/go-redis/extra/redisotel/v9 v9.18.0/go.mod h1:WzkrVG9ro9BwCQD0eJOWn6AGL4Z1CleGflM45w1hu10= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rubenv/sql-migrate v1.8.1 h1:EPNwCvjAowHI3TnZ+4fQu3a915OpnQoPAjTXCGOy2U0= +github.com/rubenv/sql-migrate v1.8.1/go.mod h1:BTIKBORjzyxZDS6dzoiw6eAFYJ1iNlGAtjn4LGeVjS8= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= -github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= -github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= -github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= -github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= -github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= -github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= +github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM= -github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/wI2L/jsondiff v0.5.0 h1:RRMTi/mH+R2aXcPe1VYyvGINJqQfC3R+KSEakuU1Ikw= -github.com/wI2L/jsondiff v0.5.0/go.mod h1:qqG6hnK0Lsrz2BpIVCxWiK9ItsBCpIZQiv0izJjOZ9s= -github.com/werf/common-go v0.0.0-20251113140850-a1a98e909e9b h1:58850oFrnw5Jy5YaB8QifXz75qpGotfx6qqZ9Q2my1A= -github.com/werf/common-go v0.0.0-20251113140850-a1a98e909e9b/go.mod h1:MXS0JR9zut+oR9oEM8PEkdXXoEbKDILTmWopt0z1eZs= +github.com/wI2L/jsondiff v0.7.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ= +github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM= +github.com/werf/common-go v0.0.0-20260212174520-adf7d95a1579 h1:LojMRgEoMNrUnfsbDG5GT4M5HXC9LAYH+5DWBY3p4uU= +github.com/werf/common-go v0.0.0-20260212174520-adf7d95a1579/go.mod h1:MXS0JR9zut+oR9oEM8PEkdXXoEbKDILTmWopt0z1eZs= github.com/werf/kubedog v0.13.1-0.20260320165832-7d97aaf7aab9 h1:N+XKTPiXT5pf5lxThhaQQPARLUpZTlYJeMNoNtn+540= github.com/werf/kubedog v0.13.1-0.20260320165832-7d97aaf7aab9/go.mod h1:93L6aIdpj7iIhL30Obkv7bWgUyTeuxas1ijtzjmyb4Q= github.com/werf/lockgate v0.1.1 h1:S400JFYjtWfE4i4LY9FA8zx0fMdfui9DPrBiTciCrx4= github.com/werf/lockgate v0.1.1/go.mod h1:0yIFSLq9ausy6ejNxF5uUBf/Ib6daMAfXuCaTMZJzIE= github.com/werf/logboek v0.6.1 h1:oEe6FkmlKg0z0n80oZjLplj6sXcBeLleCkjfOOZEL2g= github.com/werf/logboek v0.6.1/go.mod h1:Gez5J4bxekyr6MxTmIJyId1F61rpO+0/V4vjCIEIZmk= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= @@ -452,201 +425,220 @@ github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yannh/kubeconform v0.6.7 h1:kIvjeiMSU0+/GY48+U9GmJZdGmoej4dArYvv3BfvlyA= -github.com/yannh/kubeconform v0.6.7/go.mod h1:lcx9py+svwYnKXiy146zVstEToiTuTu4rMzdXXfsyVc= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yannh/kubeconform v0.7.0 h1:ZFfniR8VChrWQxaxTUGnNrxw8RIDkjVBrjdhXSamwjw= +github.com/yannh/kubeconform v0.7.0/go.mod h1:oHO1wjM16sTRW6s41HJUox+tD69qOTE5ZVQ9HeqX+xM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= -go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/contrib/exporters/autoexport v0.46.1 h1:ysCfPZB9AjUlMa1UHYup3c9dAOCMQX/6sxSfPBUoxHw= -go.opentelemetry.io/contrib/exporters/autoexport v0.46.1/go.mod h1:ha0aiYm+DOPsLHjh0zoQ8W8sLT+LJ58J3j47lGpSLrU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= -go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= -go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0 h1:jd0+5t/YynESZqsSyPz+7PAFdEop0dlN0+PkyHYo8oI= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0/go.mod h1:U707O40ee1FpQGyhvqnzmCJm1Wh6OX6GGBVn0E6Uyyk= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.44.0 h1:bflGWrfYyuulcdxf14V6n9+CoQcu5SAAdHmDPAJnlps= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.44.0/go.mod h1:qcTO4xHAxZLaLxPd60TdE88rxtItPHgHWqOhOGRr0as= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.21.0 h1:tIqheXEFWAZ7O8A7m+J0aPTmpJN3YQ7qetUAdkkkKpk= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.21.0/go.mod h1:nUeKExfxAQVbiVFn32YXpXZZHZ61Cc3s3Rn1pDBGAb0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM= -go.opentelemetry.io/otel/exporters/prometheus v0.44.0 h1:08qeJgaPC0YEBu2PQMbqU3rogTlyzpjhCI2b58Yn00w= -go.opentelemetry.io/otel/exporters/prometheus v0.44.0/go.mod h1:ERL2uIeBtg4TxZdojHUwzZfIFlUIjZtxubT5p4h1Gjg= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v0.44.0 h1:dEZWPjVN22urgYCza3PXRUGEyCB++y1sAqm6guWFesk= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v0.44.0/go.mod h1:sTt30Evb7hJB/gEk27qLb1+l9n4Tb8HvHkR0Wx3S6CU= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.21.0 h1:VhlEQAPp9R1ktYfrPk5SOryw1e9LDDTZCbIPFrho0ec= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.21.0/go.mod h1:kB3ufRbfU+CQ4MlUcqtW8Z7YEOBeK2DJ6CmR5rYYF3E= -go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= -go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= -go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw= -go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg= -go.opentelemetry.io/otel/sdk/metric v1.21.0 h1:smhI5oD714d6jHE6Tie36fPx4WDFIg+Y6RfAY4ICcR0= -go.opentelemetry.io/otel/sdk/metric v1.21.0/go.mod h1:FJ8RAsoPGv/wYMgBdUJXOm+6pzFY3YdljnXtv1SBE8Q= -go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= -go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= -go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI= -go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7ej/RNTae6MdY= -go.starlark.net v0.0.0-20231121155337-90ade8b19d09 h1:hzy3LFnSN8kuQK8h9tHl4ndF6UruMj47OqwqsS+/Ai4= -go.starlark.net v0.0.0-20231121155337-90ade8b19d09/go.mod h1:LcLNIzVOMp4oV+uusnpk+VU+SzXaJakUuBjoCSWH5dM= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXvpd09vxvDZsOjx11gjUqLk= +go.opentelemetry.io/contrib/bridges/prometheus v0.67.0/go.mod h1:Z5RIwRkZgauOIfnG5IpidvLpERjhTninpP1dTG2jTl4= +go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 h1:4fnRcNpc6YFtG3zsFw9achKn3XgmxPxuMuqIL5rE8e8= +go.opentelemetry.io/contrib/exporters/autoexport v0.67.0/go.mod h1:qTvIHMFKoxW7HXg02gm6/Wofhq5p3Ib/A/NNt1EoBSQ= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 h1:deI9UQMoGFgrg5iLPgzueqFPHevDl+28YKfSpPTI6rY= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0/go.mod h1:PFx9NgpNUKXdf7J4Q3agRxMs3Y07QhTCVipKmLsMKnU= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 h1:icqq3Z34UrEFk2u+HMhTtRsvo7Ues+eiJVjaJt62njs= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0/go.mod h1:W2m8P+d5Wn5kipj4/xmbt9uMqezEKfBjzVJadfABSBE= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 h1:H7O6RlGOMTizyl3R08Kn5pdM06bnH8oscSj7o11tmLA= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0/go.mod h1:mBFWu/WOVDkWWsR7Tx7h6EpQB8wsv7P0Yrh0Pb7othc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 h1:KJVjPD3rcPb98rIs3HznyJlrfx9ge5oJvxxlGR+P/7s= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0/go.mod h1:K3kRa2ckmHWQaTWQdPRHc7qGXASuVuoEQXzrvlA98Ws= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs= +go.opentelemetry.io/otel/log v0.18.0 h1:XgeQIIBjZZrliksMEbcwMZefoOSMI1hdjiLEiiB0bAg= +go.opentelemetry.io/otel/log v0.18.0/go.mod h1:KEV1kad0NofR3ycsiDH4Yjcoj0+8206I6Ox2QYFSNgI= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= +go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/sdk/log v0.18.0 h1:n8OyZr7t7otkeTnPTbDNom6rW16TBYGtvyy2Gk6buQw= +go.opentelemetry.io/otel/sdk/log v0.18.0/go.mod h1:C0+wxkTwKpOCZLrlJ3pewPiiQwpzycPI/u6W0Z9fuYk= +go.opentelemetry.io/otel/sdk/log/logtest v0.18.0 h1:l3mYuPsuBx6UKE47BVcPrZoZ0q/KER57vbj2qkgDLXA= +go.opentelemetry.io/otel/sdk/log/logtest v0.18.0/go.mod h1:7cHtiVJpZebB3wybTa4NG+FUo5NPe3PROz1FqB0+qdw= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/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-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= -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/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs= +golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= -golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 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-20190923162816-aa69164e4478/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-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI= -golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= 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-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-20201020160332-67f06af15bc9/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.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/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-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-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.2.0/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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/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-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= 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= -google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= -google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= -google.golang.org/genproto/googleapis/api v0.0.0-20240325203815-454cdb8f5daa h1:Jt1XW5PaLXF1/ePZrznsh/aAUvI7Adfc3LY1dAKlzRs= -google.golang.org/genproto/googleapis/api v0.0.0-20240325203815-454cdb8f5daa/go.mod h1:K4kfzHtI0kqWA79gecJarFtDn/Mls+GxQcg3Zox91Ac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240325203815-454cdb8f5daa h1:RBgMaUMP+6soRkik4VoN8ojR2nex2TqZwjSSogic+eo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240325203815-454cdb8f5daa/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= -google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk= -google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= -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.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= -google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI= +google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/evanphx/json-patch.v5 v5.8.0 h1:A8QNKkaxzza4Ubx7N23Yav3OstJhP8KYRZbk98mZsFo= -gopkg.in/evanphx/json-patch.v5 v5.8.0/go.mod h1:/kvTRh1TVm5wuM6OkHxqXtE/1nUZZpihg29RtuIyfvk= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 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.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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= -gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= -k8s.io/api v0.29.3 h1:2ORfZ7+bGC3YJqGpV0KSDDEVf8hdGQ6A03/50vj8pmw= -k8s.io/api v0.29.3/go.mod h1:y2yg2NTyHUUkIoTC+phinTnEa3KFM6RZ3szxt014a80= -k8s.io/apiextensions-apiserver v0.29.0 h1:0VuspFG7Hj+SxyF/Z/2T0uFbI5gb5LRgEyUVE3Q4lV0= -k8s.io/apiextensions-apiserver v0.29.0/go.mod h1:TKmpy3bTS0mr9pylH0nOt/QzQRrW7/h7yLdRForMZwc= -k8s.io/apimachinery v0.29.3 h1:2tbx+5L7RNvqJjn7RIuIKu9XTsIZ9Z5wX2G22XAa5EU= -k8s.io/apimachinery v0.29.3/go.mod h1:hx/S4V2PNW4OMg3WizRrHutyB5la0iCUbZym+W0EQIU= -k8s.io/apiserver v0.29.2 h1:+Z9S0dSNr+CjnVXQePG8TcBWHr3Q7BmAr7NraHvsMiQ= -k8s.io/apiserver v0.29.2/go.mod h1:B0LieKVoyU7ykQvPFm7XSdIHaCHSzCzQWPFa5bqbeMQ= -k8s.io/cli-runtime v0.29.3 h1:r68rephmmytoywkw2MyJ+CxjpasJDQY7AGc3XY2iv1k= -k8s.io/cli-runtime v0.29.3/go.mod h1:aqVUsk86/RhaGJwDhHXH0jcdqBrgdF3bZWk4Z9D4mkM= -k8s.io/client-go v0.29.3 h1:R/zaZbEAxqComZ9FHeQwOh3Y1ZUs7FaHKZdQtIc2WZg= -k8s.io/client-go v0.29.3/go.mod h1:tkDisCvgPfiRpxGnOORfkljmS+UrW+WtXAy2fTvXJB0= -k8s.io/component-base v0.29.3 h1:Oq9/nddUxlnrCuuR2K/jp6aflVvc0uDvxMzAWxnGzAo= -k8s.io/component-base v0.29.3/go.mod h1:Yuj33XXjuOk2BAaHsIGHhCKZQAgYKhqIxIjIr2UXYio= +k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ= +k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4= +k8s.io/apiextensions-apiserver v0.35.3 h1:2fQUhEO7P17sijylbdwt0nBdXP0TvHrHj0KeqHD8FiU= +k8s.io/apiextensions-apiserver v0.35.3/go.mod h1:tK4Kz58ykRpwAEkXUb634HD1ZAegEElktz/B3jgETd8= +k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= +k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/apiserver v0.35.3 h1:D2eIcfJ05hEAEewoSDg+05e0aSRwx8Y4Agvd/wiomUI= +k8s.io/apiserver v0.35.3/go.mod h1:JI0n9bHYzSgIxgIrfe21dbduJ9NHzKJ6RchcsmIKWKY= +k8s.io/cli-runtime v0.35.3 h1:UZq4ipNimtzBmhN7PPKbfAdqo8quK0H0UdGl6qAQnqI= +k8s.io/cli-runtime v0.35.3/go.mod h1:O7MUmCqcKSd5xI+O5X7/pRkB5l0O2NIhOdUVwbHLXu4= +k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg= +k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c= +k8s.io/component-base v0.35.3 h1:mbKbzoIMy7JDWS/wqZobYW1JDVRn/RKRaoMQHP9c4P0= +k8s.io/component-base v0.35.3/go.mod h1:IZ8LEG30kPN4Et5NeC7vjNv5aU73ku5MS15iZyvyMYk= k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8= k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= -k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= -k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20240105020646-a37d4de58910 h1:1Rp/XEKP5uxPs6QrsngEHAxBjaAR78iJRiJq5Fi7LSU= -k8s.io/kube-openapi v0.0.0-20240105020646-a37d4de58910/go.mod h1:Pa1PvrP7ACSkuX6I7KYomY6cmMA0Tx86waBhDUgoKPw= -k8s.io/kubectl v0.29.3 h1:RuwyyIU42MAISRIePaa8Q7A3U74Q9P4MoJbDFz9o3us= -k8s.io/kubectl v0.29.3/go.mod h1:yCxfY1dbwgVdEt2zkJ6d5NNLOhhWgTyrqACIoFhpdd4= -k8s.io/utils v0.0.0-20240310230437-4693a0247e57 h1:gbqbevonBh57eILzModw6mrkbwM0gQBEuevE/AaBsHY= -k8s.io/utils v0.0.0-20240310230437-4693a0247e57/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -oras.land/oras-go v1.2.5 h1:XpYuAwAb0DfQsunIyMfeET92emK8km3W4yEzZvUbsTo= -oras.land/oras-go v1.2.5/go.mod h1:PuAwRShRZCsZb7g8Ar3jKKQR/2A/qN+pkYxIOd/FAoo= -sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= -sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= -sigs.k8s.io/kustomize/api v0.16.0 h1:/zAR4FOQDCkgSDmVzV2uiFbuy9bhu3jEzthrHCuvm1g= -sigs.k8s.io/kustomize/api v0.16.0/go.mod h1:MnFZ7IP2YqVyVwMWoRxPtgl/5hpA+eCCrQR/866cm5c= -sigs.k8s.io/kustomize/kyaml v0.16.0 h1:6J33uKSoATlKZH16unr2XOhDI+otoe2sR3M8PDzW3K0= -sigs.k8s.io/kustomize/kyaml v0.16.0/go.mod h1:xOK/7i+vmE14N2FdFyugIshB8eF6ALpy7jI87Q2nRh4= -sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= -sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260319004828-5883c5ee87b9 h1:Sztf7ESG9tAXRW/ACJZjrj5jhdOUqS2KFRQT+CTvu78= +k8s.io/kube-openapi v0.0.0-20260319004828-5883c5ee87b9/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/kubectl v0.35.3 h1:1KqSYXk/sodU7VeDvK6atX2kAGUZd2QTeR5K7Hb9r9w= +k8s.io/kubectl v0.35.3/go.mod h1:GPHxZqRe+u/i3gTBoVQHeIyq2NilfNPj9hDWeuN3x5s= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= +oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= +sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= +sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/kustomize/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs= +sigs.k8s.io/kustomize/api v0.21.1/go.mod h1:f3wkKByTrgpgltLgySCntrYoq5d3q7aaxveSagwTlwI= +sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI= +sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/pkg/action/chart_lint.go b/pkg/action/chart_lint.go index 02a33793..af09da35 100644 --- a/pkg/action/chart_lint.go +++ b/pkg/action/chart_lint.go @@ -12,7 +12,7 @@ import ( "github.com/werf/nelm/pkg/chart" "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/helm/pkg/registry" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" + helmreleasestatus "github.com/werf/nelm/pkg/helm/pkg/release/common" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/plan" @@ -79,7 +79,7 @@ type ChartLintOptions struct { ForceAdoption bool // LegacyChartType specifies the chart type for legacy compatibility. // Used internally for backward compatibility with werf integration. - LegacyChartType helmopts.ChartType + LegacyChartType common.LegacyChartType // LegacyExtraValues provides additional values programmatically. // Used internally for backward compatibility with werf integration. LegacyExtraValues map[string]interface{} @@ -193,8 +193,8 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { return fmt.Errorf("construct release storage: %w", err) } - helmOptions := helmopts.HelmOptions{ - ChartLoadOpts: helmopts.ChartLoadOptions{ + helmOptions := common.HelmOptions{ + ChartLoadOpts: common.ChartLoadOptions{ ChartAppVersion: opts.ChartAppVersion, ChartType: opts.LegacyChartType, DefaultChartAPIVersion: opts.DefaultChartAPIVersion, @@ -228,7 +228,7 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { if prevRelease != nil { newRevision = prevRelease.Version + 1 - prevReleaseFailed = prevRelease.IsStatusFailed() + prevReleaseFailed = prevRelease.Info.Status == helmreleasestatus.StatusFailed } else { newRevision = 1 } diff --git a/pkg/action/chart_render.go b/pkg/action/chart_render.go index 1ee1737f..658850aa 100644 --- a/pkg/action/chart_render.go +++ b/pkg/action/chart_render.go @@ -18,7 +18,6 @@ import ( "github.com/werf/nelm/pkg/chart" "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/helm/pkg/registry" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/release" @@ -77,7 +76,7 @@ type ChartRenderOptions struct { ForceAdoption bool // LegacyChartType specifies the chart type for legacy compatibility. // Used internally for backward compatibility with werf integration. - LegacyChartType helmopts.ChartType + LegacyChartType common.LegacyChartType // LegacyExtraValues provides additional values programmatically. // Used internally for backward compatibility with werf integration. LegacyExtraValues map[string]interface{} @@ -202,8 +201,8 @@ func ChartRender(ctx context.Context, opts ChartRenderOptions) (*ChartRenderResu return nil, fmt.Errorf("construct release storage: %w", err) } - helmOptions := helmopts.HelmOptions{ - ChartLoadOpts: helmopts.ChartLoadOptions{ + helmOptions := common.HelmOptions{ + ChartLoadOpts: common.ChartLoadOptions{ ChartAppVersion: opts.ChartAppVersion, ChartType: opts.LegacyChartType, DefaultChartAPIVersion: opts.DefaultChartAPIVersion, diff --git a/pkg/action/common.go b/pkg/action/common.go index bc736079..331f4aa5 100644 --- a/pkg/action/common.go +++ b/pkg/action/common.go @@ -21,7 +21,7 @@ import ( kdutil "github.com/werf/kubedog/pkg/dyntracker/util" "github.com/werf/kubedog/pkg/informer" "github.com/werf/nelm/pkg/common" - helmrelease "github.com/werf/nelm/pkg/helm/pkg/release" + helmreleasestatus "github.com/werf/nelm/pkg/helm/pkg/release/common" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/plan" @@ -74,15 +74,15 @@ var syntaxHighlightTheme = fmt.Sprintf(` `, syntaxHighlightThemeName) // TODO(major): Version > APIVersion as string "v3" -type releaseReportV3 struct { - Version int `json:"version,omitempty"` - Release string `json:"release,omitempty"` - Namespace string `json:"namespace,omitempty"` - Revision int `json:"revision,omitempty"` - Status helmrelease.Status `json:"status,omitempty"` - CompletedOperations []string `json:"completedOperations,omitempty"` - CanceledOperations []string `json:"canceledOperations,omitempty"` - FailedOperations []string `json:"failedOperations,omitempty"` +type ReleaseReportV3 struct { + Version int `json:"version,omitempty"` + Release string `json:"release,omitempty"` + Namespace string `json:"namespace,omitempty"` + Revision int `json:"revision,omitempty"` + Status helmreleasestatus.Status `json:"status,omitempty"` + CompletedOperations []string `json:"completedOperations,omitempty"` + CanceledOperations []string `json:"canceledOperations,omitempty"` + FailedOperations []string `json:"failedOperations,omitempty"` } type runFailureInstallPlanOptions struct { @@ -127,7 +127,7 @@ func printNotes(ctx context.Context, notes string) { }) } -func printReport(ctx context.Context, report *releaseReportV3) { +func printReport(ctx context.Context, report *ReleaseReportV3) { if totalOpsLen := len(report.CompletedOperations) + len(report.CanceledOperations) + len(report.FailedOperations); totalOpsLen == 0 { return } @@ -233,7 +233,7 @@ func savePlanAsDot(plan *plan.Plan, path string) error { return nil } -func saveReport(reportPath string, report *releaseReportV3) error { +func saveReport(reportPath string, report *ReleaseReportV3) error { reportByte, err := json.MarshalIndent(report, "", "\t") if err != nil { return fmt.Errorf("marshal report: %w", err) diff --git a/pkg/action/release_get.go b/pkg/action/release_get.go index 2c2f813e..f8fb9e50 100644 --- a/pkg/action/release_get.go +++ b/pkg/action/release_get.go @@ -13,9 +13,10 @@ import ( "github.com/samber/lo" "github.com/werf/nelm/pkg/common" + chartcommonutil "github.com/werf/nelm/pkg/helm/pkg/chart/common/util" "github.com/werf/nelm/pkg/helm/pkg/chart/loader" - "github.com/werf/nelm/pkg/helm/pkg/chartutil" - helmrelease "github.com/werf/nelm/pkg/helm/pkg/release" + helmreleasestatus "github.com/werf/nelm/pkg/helm/pkg/release/common" + helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/release" @@ -73,7 +74,7 @@ type ReleaseGetResultRelease struct { Name string `json:"name"` Namespace string `json:"namespace"` Revision int `json:"revision"` - Status helmrelease.Status `json:"status"` + Status helmreleasestatus.Status `json:"status"` DeployedAt *ReleaseGetResultDeployedAt `json:"deployedAt"` Annotations map[string]string `json:"annotations"` StorageLabels map[string]string `json:"storageLabels"` @@ -155,7 +156,7 @@ func ReleaseGet(ctx context.Context, releaseName, releaseNamespace string, opts } } - values, err := chartutil.CoalesceValues(rel.Chart, rel.Config) + values, err := chartcommonutil.CoalesceValues(rel.Chart, rel.Config) if err != nil { return nil, fmt.Errorf("coalesce release values: %w", err) } diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index 4604fc0d..5dbf4a24 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -21,8 +21,8 @@ import ( "github.com/werf/nelm/pkg/chart" "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/helm/pkg/registry" - helmrelease "github.com/werf/nelm/pkg/helm/pkg/release" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" + helmreleasestatus "github.com/werf/nelm/pkg/helm/pkg/release/common" + helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/legacy/progrep" "github.com/werf/nelm/pkg/lock" @@ -83,7 +83,7 @@ type ReleaseInstallOptions struct { InstallReportPath string // LegacyChartType specifies the chart type for legacy compatibility. // Used internally for backward compatibility with werf integration. - LegacyChartType helmopts.ChartType + LegacyChartType common.LegacyChartType // LegacyExtraValues provides additional values programmatically. // Used internally for backward compatibility with werf integration. LegacyExtraValues map[string]interface{} @@ -309,7 +309,7 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re instResInfos = planArtifact.Data.InstallableResourceInfos relInfos = planArtifact.Data.ReleaseInfos } else { - prevReleaseFailed := prevRelease != nil && prevRelease.IsStatusFailed() + prevReleaseFailed := prevRelease != nil && prevRelease.Info.Status == helmreleasestatus.StatusFailed var deployType common.DeployType if prevDeployedRelease != nil { @@ -320,8 +320,8 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re deployType = common.DeployTypeInitial } - helmOptions := helmopts.HelmOptions{ - ChartLoadOpts: helmopts.ChartLoadOptions{ + helmOptions := common.HelmOptions{ + ChartLoadOpts: common.ChartLoadOptions{ ChartAppVersion: opts.ChartAppVersion, ChartType: opts.LegacyChartType, DefaultChartAPIVersion: opts.DefaultChartAPIVersion, @@ -485,12 +485,12 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re if releaseIsUpToDate && installPlanIsUseless { if opts.InstallReportPath != "" { - if err := saveReport(opts.InstallReportPath, &releaseReportV3{ + if err := saveReport(opts.InstallReportPath, &ReleaseReportV3{ Version: 3, Release: releaseName, Namespace: releaseNamespace, Revision: newRelease.Version, - Status: helmrelease.StatusSkipped, + Status: helmreleasestatus.Status("skipped"), }); err != nil { return fmt.Errorf("save release install report: %w", err) } @@ -622,12 +622,12 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re sort.Strings(reportCanceledOps) sort.Strings(reportFailedOps) - report := &releaseReportV3{ + report := &ReleaseReportV3{ Version: 3, Release: releaseName, Namespace: releaseNamespace, Revision: newRelease.Version, - Status: lo.Ternary(executePlanErr == nil, helmrelease.StatusDeployed, helmrelease.StatusFailed), + Status: lo.Ternary(executePlanErr == nil, helmreleasestatus.StatusDeployed, helmreleasestatus.StatusFailed), CompletedOperations: reportCompletedOps, CanceledOperations: reportCanceledOps, FailedOperations: reportFailedOps, diff --git a/pkg/action/release_list.go b/pkg/action/release_list.go index 358d127f..45c93e3b 100644 --- a/pkg/action/release_list.go +++ b/pkg/action/release_list.go @@ -17,7 +17,7 @@ import ( "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/helm/pkg/chart/loader" - helmrelease "github.com/werf/nelm/pkg/helm/pkg/release" + helmreleasestatus "github.com/werf/nelm/pkg/helm/pkg/release/common" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/release" @@ -65,7 +65,7 @@ type ReleaseListResultRelease struct { Name string `json:"name"` Namespace string `json:"namespace"` Revision int `json:"revision"` - Status helmrelease.Status `json:"status"` + Status helmreleasestatus.Status `json:"status"` DeployedAt *ReleaseListResultDeployedAt `json:"deployedAt"` Annotations map[string]string `json:"annotations"` Chart *ReleaseListResultChart `json:"chart"` @@ -215,9 +215,9 @@ func buildReleaseListOutputTable(ctx context.Context, result *ReleaseListResultV for _, release := range result.Releases { var statusColor color.Color switch release.Status { - case helmrelease.StatusDeployed, helmrelease.StatusSuperseded: + case helmreleasestatus.StatusDeployed, helmreleasestatus.StatusSuperseded: statusColor = color.Green - case helmrelease.StatusFailed: + case helmreleasestatus.StatusFailed: statusColor = color.LightRed default: statusColor = color.LightYellow diff --git a/pkg/action/release_plan_install.go b/pkg/action/release_plan_install.go index 977f50df..b18aa0e7 100644 --- a/pkg/action/release_plan_install.go +++ b/pkg/action/release_plan_install.go @@ -17,7 +17,7 @@ import ( "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/featgate" "github.com/werf/nelm/pkg/helm/pkg/registry" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" + helmreleasestatus "github.com/werf/nelm/pkg/helm/pkg/release/common" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/plan" @@ -79,7 +79,7 @@ type ReleasePlanInstallOptions struct { InstallGraphPath string // LegacyChartType specifies the chart type for legacy compatibility. // Used internally for backward compatibility with werf integration. - LegacyChartType helmopts.ChartType + LegacyChartType common.LegacyChartType // LegacyExtraValues provides additional values programmatically. // Used internally for backward compatibility with werf integration. LegacyExtraValues map[string]interface{} @@ -195,8 +195,8 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc return fmt.Errorf("construct release storage: %w", err) } - helmOptions := helmopts.HelmOptions{ - ChartLoadOpts: helmopts.ChartLoadOptions{ + helmOptions := common.HelmOptions{ + ChartLoadOpts: common.ChartLoadOptions{ ChartAppVersion: opts.ChartAppVersion, ChartType: opts.LegacyChartType, DefaultChartAPIVersion: opts.DefaultChartAPIVersion, @@ -232,7 +232,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc if prevRelease != nil { newRevision = prevRelease.Version + 1 - prevReleaseFailed = prevRelease.IsStatusFailed() + prevReleaseFailed = prevRelease.Info.Status == helmreleasestatus.StatusFailed } else { newRevision = 1 } diff --git a/pkg/action/release_rollback.go b/pkg/action/release_rollback.go index 5bc12ce0..f880a61e 100644 --- a/pkg/action/release_rollback.go +++ b/pkg/action/release_rollback.go @@ -16,7 +16,8 @@ import ( kdutil "github.com/werf/kubedog/pkg/dyntracker/util" "github.com/werf/kubedog/pkg/informer" "github.com/werf/nelm/pkg/common" - helmrelease "github.com/werf/nelm/pkg/helm/pkg/release" + helmreleasestatus "github.com/werf/nelm/pkg/helm/pkg/release/common" + helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/lock" "github.com/werf/nelm/pkg/log" @@ -213,7 +214,7 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r if prevRelease != nil { newRevision = prevRelease.Version + 1 - prevReleaseFailed = prevRelease.IsStatusFailed() + prevReleaseFailed = prevRelease.Info.Status == helmreleasestatus.StatusFailed } else { newRevision = 1 } @@ -228,7 +229,7 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r } newRelease, err := release.NewRelease(releaseName, releaseNamespace, newRevision, deployType, rollbackReleaseResSpecs, rollbackRelease.Chart, rollbackRelease.Config, release.ReleaseOptions{ - InfoAnnotations: lo.Assign(rollbackRelease.Info.Annotations, opts.ReleaseInfoAnnotations), + InfoAnnotations: opts.ReleaseInfoAnnotations, Labels: lo.Assign(rollbackRelease.Labels, opts.ReleaseLabels), Notes: rollbackRelease.Info.Notes, }) @@ -331,12 +332,12 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r if releaseIsUpToDate && installPlanIsUseless { if opts.RollbackReportPath != "" { - if err := saveReport(opts.RollbackReportPath, &releaseReportV3{ + if err := saveReport(opts.RollbackReportPath, &ReleaseReportV3{ Version: 3, Release: releaseName, Namespace: releaseNamespace, Revision: newRelease.Version, - Status: helmrelease.StatusSkipped, + Status: helmreleasestatus.Status("skipped"), }); err != nil { return fmt.Errorf("save release install report: %w", err) } @@ -438,12 +439,12 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r sort.Strings(reportCanceledOps) sort.Strings(reportFailedOps) - report := &releaseReportV3{ + report := &ReleaseReportV3{ Version: 3, Release: releaseName, Namespace: releaseNamespace, Revision: newRelease.Version, - Status: helmrelease.StatusDeployed, + Status: helmreleasestatus.StatusDeployed, CompletedOperations: reportCompletedOps, CanceledOperations: reportCanceledOps, FailedOperations: reportFailedOps, diff --git a/pkg/action/release_uninstall.go b/pkg/action/release_uninstall.go index 3a9993c2..522eb4f5 100644 --- a/pkg/action/release_uninstall.go +++ b/pkg/action/release_uninstall.go @@ -17,7 +17,7 @@ import ( kdutil "github.com/werf/kubedog/pkg/dyntracker/util" "github.com/werf/kubedog/pkg/informer" "github.com/werf/nelm/pkg/common" - helmrelease "github.com/werf/nelm/pkg/helm/pkg/release" + helmreleasestatus "github.com/werf/nelm/pkg/helm/pkg/release/common" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/legacy/progrep" "github.com/werf/nelm/pkg/lock" @@ -182,7 +182,7 @@ func releaseUninstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, } prevRelease := lo.LastOrEmpty(releases) - prevReleaseFailed := prevRelease.IsStatusFailed() + prevReleaseFailed := prevRelease.Info.Status == helmreleasestatus.StatusFailed deployType := common.DeployTypeUninstall log.Default.Debug(ctx, "Convert previous release to resource specs") @@ -334,12 +334,12 @@ func releaseUninstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, sort.Strings(reportCanceledOps) sort.Strings(reportFailedOps) - report := &releaseReportV3{ + report := &ReleaseReportV3{ Version: 3, Release: releaseName, Namespace: releaseNamespace, Revision: prevRelease.Version, - Status: helmrelease.StatusUninstalled, + Status: helmreleasestatus.StatusUninstalled, CompletedOperations: reportCompletedOps, CanceledOperations: reportCanceledOps, FailedOperations: reportFailedOps, diff --git a/pkg/log/init.go b/pkg/action/setup_logging.go similarity index 85% rename from pkg/log/init.go rename to pkg/action/setup_logging.go index feb1184c..f5b02516 100644 --- a/pkg/log/init.go +++ b/pkg/action/setup_logging.go @@ -1,4 +1,4 @@ -package log +package action import ( "context" @@ -21,32 +21,30 @@ import ( kdlog "github.com/werf/kubedog/pkg/log" "github.com/werf/logboek" "github.com/werf/nelm/pkg/helm/pkg/engine" + "github.com/werf/nelm/pkg/log" ) -var Default Logger = NewLogboekLogger() - type SetupLoggingOptions struct { ColorMode string LogIsParseable bool } -// Sets up logging levels, colors, output formats, etc. -func SetupLogging(ctx context.Context, logLevel Level, opts SetupLoggingOptions) context.Context { - if val := ctx.Value(LogboekLoggerCtxKeyName); val == nil { +func SetupLogging(ctx context.Context, logLevel log.Level, opts SetupLoggingOptions) context.Context { + if val := ctx.Value(log.LogboekLoggerCtxKeyName); val == nil { ctx = logboek.NewContext(ctx, logboek.DefaultLogger()) } - Default.SetLevel(ctx, logLevel) + log.Default.SetLevel(ctx, logLevel) spew.Config.DisablePointerAddresses = true spew.Config.DisableCapacities = true switch logLevel { - case SilentLevel, ErrorLevel, WarningLevel, InfoLevel: + case log.SilentLevel, log.ErrorLevel, log.WarningLevel, log.InfoLevel: stdlog.SetOutput(io.Discard) klog.SetOutput(io.Discard) - // From: https://github.com/kubernetes/klog/issues/87#issuecomment-1671820147 + klogFlags := &flag.FlagSet{} klog.InitFlags(klogFlags) lo.Must0(klogFlags.Set("logtostderr", "false")) @@ -54,7 +52,7 @@ func SetupLogging(ctx context.Context, logLevel Level, opts SetupLoggingOptions) lo.Must0(klogFlags.Set("stderrthreshold", "4")) klogv2.SetOutput(io.Discard) - // From: https://github.com/kubernetes/klog/issues/87#issuecomment-1671820147 + klogV2Flags := &flag.FlagSet{} klogv2.InitFlags(klogV2Flags) lo.Must0(klogV2Flags.Set("logtostderr", "false")) @@ -68,7 +66,7 @@ func SetupLogging(ctx context.Context, logLevel Level, opts SetupLoggingOptions) engine.Debug = false kdlog.SetDebug(false) - case DebugLevel: + case log.DebugLevel: stdlog.SetOutput(os.Stdout) klog.SetOutputBySeverity("FATAL", logboek.Context(ctx).ErrStream()) @@ -90,7 +88,7 @@ func SetupLogging(ctx context.Context, logLevel Level, opts SetupLoggingOptions) engine.Debug = true kdlog.SetDebug(true) - case TraceLevel: + case log.TraceLevel: stdlog.SetOutput(os.Stdout) klog.SetOutputBySeverity("FATAL", logboek.Context(ctx).ErrStream()) @@ -126,9 +124,9 @@ func SetupLogging(ctx context.Context, logLevel Level, opts SetupLoggingOptions) func getColorLevel(mode string, logIsParseable bool) terminfo.ColorLevel { switch mode { - case LogColorModeOff: + case log.LogColorModeOff: return terminfo.ColorLevelNone - case LogColorModeOn: + case log.LogColorModeOn: if colorLevel := color.DetectColorLevel(); colorLevel == terminfo.ColorLevelNone { return terminfo.ColorLevelHundreds } else { @@ -149,7 +147,6 @@ func getColorLevel(mode string, logIsParseable bool) terminfo.ColorLevel { return terminfo.ColorLevelNone } else { switch os.Getenv("TERM") { - // From https://github.com/jenkinsci/ansicolor-plugin/tree/e2a42bf6c6acadc46468a6bf75dbd958a4747d0b?tab=readme-ov-file#colormaps case "xterm", "vga", "gnome-terminal", "css": return terminfo.ColorLevelHundreds } diff --git a/pkg/chart/chart_download.go b/pkg/chart/chart_download.go index 6bec0e25..7d3b98ed 100644 --- a/pkg/chart/chart_download.go +++ b/pkg/chart/chart_download.go @@ -9,12 +9,11 @@ import ( "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/featgate" - "github.com/werf/nelm/pkg/helm/pkg/cli" helmdownloader "github.com/werf/nelm/pkg/helm/pkg/downloader" helmgetter "github.com/werf/nelm/pkg/helm/pkg/getter" "github.com/werf/nelm/pkg/helm/pkg/helmpath" helmregistry "github.com/werf/nelm/pkg/helm/pkg/registry" - helmrepo "github.com/werf/nelm/pkg/helm/pkg/repo" + helmrepo "github.com/werf/nelm/pkg/helm/pkg/repo/v1" "github.com/werf/nelm/pkg/log" ) @@ -39,12 +38,12 @@ func downloadChart(ctx context.Context, chartPath string, registryClient *helmre } // TODO(major): get rid of HELM_ env vars support - if err := os.MkdirAll(cli.EnvOr("HELM_REPOSITORY_CACHE", helmpath.CachePath("repository")), 0o755); err != nil { + if err := os.MkdirAll(envOr("HELM_REPOSITORY_CACHE", helmpath.CachePath("repository")), 0o755); err != nil { return "", fmt.Errorf("create repository cache directory: %w", err) } // TODO(major): get rid of HELM_ env vars support - chartPath, _, err = chartDownloader.DownloadTo(chartRef, opts.ChartVersion, cli.EnvOr("HELM_REPOSITORY_CACHE", helmpath.CachePath("repository"))) + chartPath, _, err = chartDownloader.DownloadTo(chartRef, opts.ChartVersion, envOr("HELM_REPOSITORY_CACHE", helmpath.CachePath("repository"))) if err != nil { return "", fmt.Errorf("download chart %q: %w", chartRef, err) } @@ -64,9 +63,9 @@ func newChartDownloader(ctx context.Context, chartRef string, registryClient *he downloader := &helmdownloader.ChartDownloader{ Out: out, - Verify: helmdownloader.VerificationStrategyString(opts.ChartProvenanceStrategy).ToVerificationStrategy(), + Verify: parseVerificationStrategy(opts.ChartProvenanceStrategy), Keyring: opts.ChartProvenanceKeyring, - Getters: helmgetter.Providers{helmgetter.HttpProvider, helmgetter.OCIProvider}, + Getters: helmgetter.Getters(), Options: []helmgetter.Option{ helmgetter.WithPassCredentialsAll(opts.ChartRepoPassCreds), helmgetter.WithTLSClientConfig(opts.ChartRepoCertPath, opts.ChartRepoKeyPath, opts.ChartRepoCAPath), @@ -77,13 +76,19 @@ func newChartDownloader(ctx context.Context, chartRef string, registryClient *he }, RegistryClient: registryClient, // TODO(major): get rid of HELM_ env vars support - RepositoryConfig: cli.EnvOr("HELM_REPOSITORY_CONFIG", helmpath.ConfigPath("repositories.yaml")), + RepositoryConfig: envOr("HELM_REPOSITORY_CONFIG", helmpath.ConfigPath("repositories.yaml")), // TODO(major): get rid of HELM_ env vars support - RepositoryCache: cli.EnvOr("HELM_REPOSITORY_CACHE", helmpath.CachePath("repository")), + RepositoryCache: envOr("HELM_REPOSITORY_CACHE", helmpath.CachePath("repository")), } if opts.ChartRepoURL != "" { - chartURL, err := helmrepo.FindChartInAuthAndTLSAndPassRepoURL(opts.ChartRepoURL, opts.ChartRepoBasicAuthUsername, opts.ChartRepoBasicAuthPassword, chartRef, opts.ChartVersion, opts.ChartRepoCertPath, opts.ChartRepoKeyPath, opts.ChartRepoCAPath, opts.ChartRepoSkipTLSVerify, opts.ChartRepoPassCreds, helmgetter.Providers{helmgetter.HttpProvider, helmgetter.OCIProvider}) + chartURL, err := helmrepo.FindChartInRepoURL(opts.ChartRepoURL, chartRef, helmgetter.Getters(), + helmrepo.WithChartVersion(opts.ChartVersion), + helmrepo.WithUsernamePassword(opts.ChartRepoBasicAuthUsername, opts.ChartRepoBasicAuthPassword), + helmrepo.WithClientTLS(opts.ChartRepoCertPath, opts.ChartRepoKeyPath, opts.ChartRepoCAPath), + helmrepo.WithInsecureSkipTLSVerify(opts.ChartRepoSkipTLSVerify), + helmrepo.WithPassCredentialsAll(opts.ChartRepoPassCreds), + ) if err != nil { return nil, "", fmt.Errorf("get chart URL: %w", err) } diff --git a/pkg/chart/chart_render.go b/pkg/chart/chart_render.go index 5871df88..29746a7b 100644 --- a/pkg/chart/chart_render.go +++ b/pkg/chart/chart_render.go @@ -18,24 +18,28 @@ import ( "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/featgate" + v3chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" + chartv3util "github.com/werf/nelm/pkg/helm/intern/chart/v3/util" "github.com/werf/nelm/pkg/helm/pkg/action" helmchart "github.com/werf/nelm/pkg/helm/pkg/chart" + chartcommon "github.com/werf/nelm/pkg/helm/pkg/chart/common" + chartcommonutil "github.com/werf/nelm/pkg/helm/pkg/chart/common/util" "github.com/werf/nelm/pkg/helm/pkg/chart/loader" - "github.com/werf/nelm/pkg/helm/pkg/chartutil" - "github.com/werf/nelm/pkg/helm/pkg/cli" + v2chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + chartv2util "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" "github.com/werf/nelm/pkg/helm/pkg/cli/values" helmdownloader "github.com/werf/nelm/pkg/helm/pkg/downloader" helmengine "github.com/werf/nelm/pkg/helm/pkg/engine" "github.com/werf/nelm/pkg/helm/pkg/getter" "github.com/werf/nelm/pkg/helm/pkg/helmpath" "github.com/werf/nelm/pkg/helm/pkg/registry" - "github.com/werf/nelm/pkg/helm/pkg/releaseutil" + releaseutil "github.com/werf/nelm/pkg/helm/pkg/release/v1/util" "github.com/werf/nelm/pkg/helm/pkg/strvals" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/resource/spec" "github.com/werf/nelm/pkg/ts" + "github.com/werf/nelm/pkg/util" ) type RenderChartOptions struct { @@ -47,7 +51,7 @@ type RenderChartOptions struct { ChartRepoNoUpdate bool ChartVersion string ExtraAPIVersions []string - HelmOptions helmopts.HelmOptions + HelmOptions common.HelmOptions LocalKubeVersion string NoStandaloneCRDs bool Remote bool @@ -56,7 +60,7 @@ type RenderChartOptions struct { } type RenderChartResult struct { - Chart *helmchart.Chart + Chart *v2chart.Chart Notes string ReleaseConfig map[string]interface{} ResourceSpecs []*spec.ResourceSpec @@ -80,20 +84,23 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s depDownloader := &helmdownloader.Manager{ Out: os.Stdout, ChartPath: chartPath, - Verify: helmdownloader.VerificationStrategyString(opts.ChartProvenanceStrategy).ToVerificationStrategy(), + Verify: parseVerificationStrategy(opts.ChartProvenanceStrategy), Debug: log.Default.AcceptLevel(ctx, log.DebugLevel), Keyring: opts.ChartProvenanceKeyring, SkipUpdate: opts.ChartRepoNoUpdate, - Getters: getter.Providers{getter.HttpProvider, getter.OCIProvider}, + Getters: getter.Getters(), RegistryClient: registryClient, // TODO(major): don't read HELM_REPOSITORY_CONFIG anymore - RepositoryConfig: cli.EnvOr("HELM_REPOSITORY_CONFIG", helmpath.ConfigPath("repositories.yaml")), + RepositoryConfig: envOr("HELM_REPOSITORY_CONFIG", helmpath.ConfigPath("repositories.yaml")), // TODO(major): don't read HELM_REPOSITORY_CACHE anymore - RepositoryCache: cli.EnvOr("HELM_REPOSITORY_CACHE", helmpath.CachePath("repository")), + RepositoryCache: envOr("HELM_REPOSITORY_CACHE", helmpath.CachePath("repository")), + ContentCache: envOr("HELM_CONTENT_CACHE", helmpath.CachePath("content")), AllowMissingRepos: true, } - opts.HelmOptions.ChartLoadOpts.DepDownloader = depDownloader + opts.HelmOptions.ChartLoadOpts.ChartDepsDownloader = depDownloader + + ctx = common.ContextWithHelmOptions(ctx, opts.HelmOptions) overrideValuesOpts := &values.Options{ ValueFiles: opts.ValuesFiles, @@ -107,7 +114,7 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s log.Default.TraceStruct(ctx, overrideValuesOpts, "Override values options:") log.Default.Debug(ctx, "Merging override values for chart at %q", chartPath) - overrideValues, err := overrideValuesOpts.MergeValues(getter.Providers{getter.HttpProvider, getter.OCIProvider}, opts.HelmOptions) + overrideValues, err := overrideValuesOpts.MergeValues(ctx, getter.Getters()) if err != nil { return nil, fmt.Errorf("merge override values for chart at %q: %w", chartPath, err) } @@ -115,37 +122,69 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s log.Default.TraceStruct(ctx, overrideValues, "Merged override values:") log.Default.Debug(ctx, "Loading chart at %q", chartPath) - chart, err := loader.Load(chartPath, opts.HelmOptions) + loadedChart, err := loader.Load(ctx, chartPath) if err != nil { return nil, fmt.Errorf("load chart at %q: %w", chartPath, err) } - if err := validateChart(ctx, chart); err != nil { + var ( + chartV2 *v2chart.Chart + chartV3 *v3chart.Chart + ) + + switch c := loadedChart.(type) { + case *v2chart.Chart: + chartV2 = c + case *v3chart.Chart: + chartV3 = c + default: + return nil, fmt.Errorf("loaded chart has unexpected type %T", loadedChart) + } + + chartAccessor, err := helmchart.NewAccessor(loadedChart) + if err != nil { + return nil, fmt.Errorf("create chart accessor: %w", err) + } + + if err := validateChart(ctx, loadedChart, chartAccessor); err != nil { return nil, fmt.Errorf("validate chart at %q: %w", chartPath, err) } - log.Default.TraceStruct(ctx, chart, "Chart:") + log.Default.TraceStruct(ctx, loadedChart, "Chart:") - if err := chartutil.ProcessDependenciesWithMerge(chart, &overrideValues); err != nil { - return nil, fmt.Errorf("process chart %q dependencies: %w", chart.Name(), err) + if chartV2 != nil { + if err := chartv2util.ProcessDependencies(chartV2, &overrideValues); err != nil { + return nil, fmt.Errorf("process chart %q dependencies: %w", chartV2.Name(), err) + } + } else { + if err := chartv3util.ProcessDependencies(chartV3, overrideValues); err != nil { + return nil, fmt.Errorf("process chart %q dependencies: %w", chartV3.Name(), err) + } } - log.Default.TraceStruct(ctx, chart, "Chart after processing dependencies:") + log.Default.TraceStruct(ctx, loadedChart, "Chart after processing dependencies:") log.Default.TraceStruct(ctx, overrideValues, "Merged override values after processing dependencies:") + var chartKubeVersion string + if chartV2 != nil { + chartKubeVersion = chartV2.Metadata.KubeVersion + } else { + chartKubeVersion = chartV3.Metadata.KubeVersion + } + caps, err := buildChartCapabilities(ctx, clientFactory, buildChartCapabilitiesOptions{ ExtraAPIVersions: opts.ExtraAPIVersions, LocalKubeVersion: opts.LocalKubeVersion, Remote: opts.Remote, }) if err != nil { - return nil, fmt.Errorf("build capabilities for chart %q: %w", chart.Name(), err) + return nil, fmt.Errorf("build capabilities for chart %q: %w", chartAccessor.Name(), err) } log.Default.TraceStruct(ctx, caps, "Capabilities:") - if chart.Metadata.KubeVersion != "" && !chartutil.IsCompatibleRange(chart.Metadata.KubeVersion, caps.KubeVersion.String()) { - return nil, fmt.Errorf("chart requires kubeVersion: %s which is incompatible with Kubernetes %s", chart.Metadata.KubeVersion, caps.KubeVersion.String()) + if chartKubeVersion != "" && !chartv2util.IsCompatibleRange(chartKubeVersion, caps.KubeVersion.String()) { + return nil, fmt.Errorf("chart requires kubeVersion: %s which is incompatible with Kubernetes %s", chartKubeVersion, caps.KubeVersion.String()) } runtime, err := buildContextFromJSONSets(opts.RuntimeSetJSON) @@ -155,12 +194,12 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s log.Default.TraceStruct(ctx, runtime, "Runtime:") - opts.HelmOptions.ChartLoadOpts.DefaultRootContext, err = buildContextFromJSONSets(opts.RootSetJSON) + defaultRootContext, err := buildContextFromJSONSets(opts.RootSetJSON) if err != nil { return nil, fmt.Errorf("build default root context: %w", err) } - log.Default.TraceStruct(ctx, opts.HelmOptions.ChartLoadOpts.DefaultRootContext, "Default root context:") + log.Default.TraceStruct(ctx, defaultRootContext, "Default root context:") var isUpgrade bool @@ -175,15 +214,23 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s log.Default.Debug(ctx, "Rendering values for chart at %q", chartPath) - renderedValues, err := chartutil.ToRenderValues(chart, overrideValues, chartutil.ReleaseOptions{ + renderedValues, err := chartcommonutil.ToRenderValues(loadedChart, overrideValues, chartcommon.ReleaseOptions{ Name: releaseName, Namespace: releaseNamespace, Revision: revision, IsInstall: !isUpgrade, IsUpgrade: isUpgrade, - }, caps, runtime, opts.HelmOptions.ChartLoadOpts.DefaultRootContext) + }, caps) if err != nil { - return nil, fmt.Errorf("build rendered values for chart %q: %w", chart.Name(), err) + return nil, fmt.Errorf("build rendered values for chart %q: %w", chartAccessor.Name(), err) + } + + renderedValues["Runtime"] = runtime + + for k, v := range defaultRootContext { + if _, exists := renderedValues[k]; !exists { + renderedValues[k] = v + } } log.Default.TraceStruct(ctx, renderedValues.AsMap(), "Rendered values:") @@ -202,11 +249,28 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s var resources []*spec.ResourceSpec if !opts.NoStandaloneCRDs { - for _, crd := range chart.CRDObjects() { - for _, manifest := range releaseutil.SplitManifestsToSlice(string(crd.File.Data)) { + type crdRef struct { + data []byte + filename string + } + + var crds []crdRef + + if chartV2 != nil { + for _, crd := range chartV2.CRDObjects() { + crds = append(crds, crdRef{data: crd.File.Data, filename: crd.Filename}) + } + } else { + for _, crd := range chartV3.CRDObjects() { + crds = append(crds, crdRef{data: crd.File.Data, filename: crd.Filename}) + } + } + + for _, crd := range crds { + for _, manifest := range util.SplitManifests(string(crd.data)) { if res, err := spec.NewResourceSpecFromManifest(manifest, releaseNamespace, spec.ResourceSpecOptions{ StoreAs: common.StoreAsNone, - FilePath: crd.Filename, + FilePath: crd.filename, }); err != nil { return nil, fmt.Errorf("construct standalone CRD for chart at %q: %w", chartPath, err) } else { @@ -216,15 +280,23 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s } } - renderedTemplates, err := engine.Render(chart, renderedValues, opts.HelmOptions) + renderedTemplates, err := engine.Render(ctx, loadedChart, renderedValues) if err != nil { - return nil, fmt.Errorf("render resources for chart %q: %w", chart.Name(), err) + return nil, fmt.Errorf("render resources for chart %q: %w", chartAccessor.Name(), err) } if featgate.FeatGateTypescript.Enabled() { - jsRenderedTemplates, err := renderJSTemplates(ctx, chartPath, chart, renderedValues) + var tsChart *v2chart.Chart + if chartV2 != nil { + tsChart = chartV2 + } else { + // TODO(major): refactor to allow native v3 chart handling in TypeScript rendering + tsChart = convertV3ToV2(chartV3) + } + + jsRenderedTemplates, err := renderJSTemplates(ctx, chartPath, tsChart, renderedValues) if err != nil { - return nil, fmt.Errorf("render ts chart templates for chart %q: %w", chart.Name(), err) + return nil, fmt.Errorf("render ts chart templates for chart %q: %w", chartAccessor.Name(), err) } if len(jsRenderedTemplates) > 0 { @@ -240,7 +312,7 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s resources = append(resources, r...) } - notes := buildChartNotes(chart.Name(), renderedTemplates, opts.SubchartNotes) + notes := buildChartNotes(chartAccessor.Name(), renderedTemplates, opts.SubchartNotes) log.Default.TraceStruct(ctx, notes, "Rendered notes:") @@ -248,8 +320,16 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s return spec.ResourceSpecSortHandler(resources[i], resources[j]) }) + var resultChart *v2chart.Chart + if chartV2 != nil { + resultChart = chartV2 + } else { + // TODO(major): refactor to allow native v3 chart handling in nelm + resultChart = convertV3ToV2(chartV3) + } + return &RenderChartResult{ - Chart: chart, + Chart: resultChart, Notes: notes, ReleaseConfig: overrideValues, ResourceSpecs: resources, @@ -257,9 +337,86 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s }, nil } -func buildChartCapabilities(ctx context.Context, clientFactory kube.ClientFactorier, opts buildChartCapabilitiesOptions) (*chartutil.Capabilities, error) { - capabilities := &chartutil.Capabilities{ - HelmVersion: chartutil.DefaultCapabilities.HelmVersion, +func convertV3ToV2(src *v3chart.Chart) *v2chart.Chart { + dst := &v2chart.Chart{ + Raw: src.Raw, + Templates: src.Templates, + Values: src.Values, + Schema: src.Schema, + SchemaModTime: src.SchemaModTime, + Files: src.Files, + ModTime: src.ModTime, + RuntimeFiles: src.RuntimeFiles, + RuntimeDepsFiles: src.RuntimeDepsFiles, + ExtraValues: src.ExtraValues, + SecretsRuntimeData: src.SecretsRuntimeData, + } + + if src.Metadata != nil { + dst.Metadata = convertV3MetadataToV2(src.Metadata) + } + + if src.Lock != nil { + dst.Lock = convertV3LockToV2(src.Lock) + } + + for _, dep := range src.Dependencies() { + dst.AddDependency(convertV3ToV2(dep)) + } + + return dst +} + +func convertV3LockToV2(src *v3chart.Lock) *v2chart.Lock { + dst := &v2chart.Lock{ + Generated: src.Generated, + Digest: src.Digest, + } + + for _, dependency := range src.Dependencies { + dst.Dependencies = append(dst.Dependencies, convertV3DependencyToV2(dependency)) + } + + return dst +} + +func convertV3MetadataToV2(src *v3chart.Metadata) *v2chart.Metadata { + dst := &v2chart.Metadata{ + Name: src.Name, + Home: src.Home, + Sources: src.Sources, + Version: src.Version, + Description: src.Description, + Keywords: src.Keywords, + Icon: src.Icon, + APIVersion: src.APIVersion, + Condition: src.Condition, + Tags: src.Tags, + AppVersion: src.AppVersion, + Deprecated: src.Deprecated, + Annotations: src.Annotations, + KubeVersion: src.KubeVersion, + Type: src.Type, + } + + for _, maintainer := range src.Maintainers { + dst.Maintainers = append(dst.Maintainers, &v2chart.Maintainer{ + Name: maintainer.Name, + Email: maintainer.Email, + URL: maintainer.URL, + }) + } + + for _, dependency := range src.Dependencies { + dst.Dependencies = append(dst.Dependencies, convertV3DependencyToV2(dependency)) + } + + return dst +} + +func buildChartCapabilities(ctx context.Context, clientFactory kube.ClientFactorier, opts buildChartCapabilitiesOptions) (*chartcommon.Capabilities, error) { + capabilities := &chartcommon.Capabilities{ + HelmVersion: chartcommon.DefaultCapabilities.HelmVersion, } if opts.Remote { @@ -270,7 +427,7 @@ func buildChartCapabilities(ctx context.Context, clientFactory kube.ClientFactor return nil, fmt.Errorf("get kubernetes server version: %w", err) } - capabilities.KubeVersion = chartutil.KubeVersion{ + capabilities.KubeVersion = chartcommon.KubeVersion{ Version: kubeVersion.GitVersion, Major: kubeVersion.Major, Minor: kubeVersion.Minor, @@ -288,21 +445,21 @@ func buildChartCapabilities(ctx context.Context, clientFactory kube.ClientFactor capabilities.APIVersions = apiVersions } else { if opts.LocalKubeVersion != "" { - kubeVersion, err := chartutil.ParseKubeVersion(opts.LocalKubeVersion) + kubeVersion, err := chartcommon.ParseKubeVersion(opts.LocalKubeVersion) if err != nil { return nil, fmt.Errorf("parse kube version %q: %w", opts.LocalKubeVersion, err) } capabilities.KubeVersion = *kubeVersion } else { - capabilities.KubeVersion = chartutil.DefaultCapabilities.KubeVersion + capabilities.KubeVersion = chartcommon.DefaultCapabilities.KubeVersion } - capabilities.APIVersions = chartutil.DefaultCapabilities.APIVersions + capabilities.APIVersions = chartcommon.DefaultCapabilities.APIVersions } if opts.ExtraAPIVersions != nil { - capabilities.APIVersions = append(capabilities.APIVersions, chartutil.VersionSet(opts.ExtraAPIVersions)...) + capabilities.APIVersions = append(capabilities.APIVersions, chartcommon.VersionSet(opts.ExtraAPIVersions)...) } return capabilities, nil @@ -312,7 +469,7 @@ func buildChartNotes(chartName string, renderedTemplates map[string]string, rend var resultBuf bytes.Buffer for filePath, fileContent := range renderedTemplates { - if !strings.HasSuffix(filePath, action.NotesFileSuffix) { + if !strings.HasSuffix(filePath, "NOTES.txt") { continue } @@ -321,7 +478,7 @@ func buildChartNotes(chartName string, renderedTemplates map[string]string, rend continue } - isTopLevelNotes := filePath == path.Join(chartName, "templates", action.NotesFileSuffix) + isTopLevelNotes := filePath == path.Join(chartName, "templates", "NOTES.txt") if !isTopLevelNotes && !renderSubchartNotes { continue @@ -349,11 +506,45 @@ func buildContextFromJSONSets(jsonSets []string) (map[string]interface{}, error) return context, nil } +func convertV3DependencyToV2(src *v3chart.Dependency) *v2chart.Dependency { + return &v2chart.Dependency{ + Name: src.Name, + Version: src.Version, + Repository: src.Repository, + Condition: src.Condition, + Tags: src.Tags, + Enabled: src.Enabled, + ImportValues: src.ImportValues, + Alias: src.Alias, + } +} + +func envOr(envVar, defaultVal string) string { + if v := os.Getenv(envVar); v != "" { + return v + } + + return defaultVal +} + func isLocalChart(path string) bool { return filepath.IsAbs(path) || filepath.HasPrefix(path, "..") || filepath.HasPrefix(path, ".") } -func renderJSTemplates(ctx context.Context, chartPath string, chart *helmchart.Chart, renderedValues chartutil.Values) (map[string]string, error) { +func parseVerificationStrategy(s string) helmdownloader.VerificationStrategy { + switch s { + case "verify": + return helmdownloader.VerifyAlways + case "verify-if-possible": + return helmdownloader.VerifyIfPossible + case "later": + return helmdownloader.VerifyLater + default: + return helmdownloader.VerifyNever + } +} + +func renderJSTemplates(ctx context.Context, chartPath string, chart *v2chart.Chart, renderedValues chartcommon.Values) (map[string]string, error) { log.Default.Debug(ctx, "Rendering TypeScript resources for chart %q and its dependencies", chart.Name()) result, err := ts.RenderChart(ctx, chart, renderedValues) @@ -369,12 +560,12 @@ func renderedTemplatesToResourceSpecs(renderedTemplates map[string]string, relea for filePath, fileContent := range renderedTemplates { if strings.HasPrefix(path.Base(filePath), "_") || - strings.HasSuffix(filePath, action.NotesFileSuffix) || + strings.HasSuffix(filePath, "NOTES.txt") || strings.TrimSpace(fileContent) == "" { continue } - manifests := releaseutil.SplitManifestsToSlice(fileContent) + manifests := util.SplitManifests(fileContent) for _, manifest := range manifests { var head releaseutil.SimpleHead @@ -395,23 +586,27 @@ func renderedTemplatesToResourceSpecs(renderedTemplates map[string]string, relea return resources, nil } -func validateChart(ctx context.Context, chart *helmchart.Chart) error { - if chart == nil { - return fmt.Errorf("load chart: %w", action.ErrMissingChart()) +func validateChart(ctx context.Context, chrt helmchart.Charter, acc helmchart.Accessor) error { + if chrt == nil { + return fmt.Errorf("load chart: missing chart") } - if chart.Metadata.Type != "" && chart.Metadata.Type != "application" { - return fmt.Errorf("chart %q of type %q can't be deployed", chart.Name(), chart.Metadata.Type) + meta := acc.MetadataAsMap() + + chartType, _ := meta["Type"].(string) + if chartType != "" && chartType != "application" { + return fmt.Errorf("chart %q of type %q can't be deployed", acc.Name(), chartType) } - if chart.Metadata.Dependencies != nil { - if err := action.CheckDependencies(chart, chart.Metadata.Dependencies); err != nil { - return fmt.Errorf("check chart dependencies for chart %q: %w", chart.Name(), err) + if metaDeps := acc.MetaDependencies(); len(metaDeps) > 0 { + if err := action.CheckDependencies(chrt, metaDeps); err != nil { + return fmt.Errorf("check chart dependencies for chart %q: %w", acc.Name(), err) } } - if chart.Metadata.Deprecated { - log.Default.Warn(ctx, `Chart "%s:%s" is deprecated`, chart.Name(), chart.Metadata.Version) + if acc.Deprecated() { + chartVersion, _ := meta["Version"].(string) + log.Default.Warn(ctx, `Chart "%s:%s" is deprecated`, acc.Name(), chartVersion) } return nil diff --git a/pkg/common/common.go b/pkg/common/common.go index 72dcf8c6..9899a206 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -1,18 +1,19 @@ package common import ( + "context" "fmt" + "os" + "os/user" "path/filepath" "regexp" + "runtime" "time" "github.com/Masterminds/sprig/v3" - "github.com/docker/cli/cli/config" - "github.com/docker/docker/pkg/homedir" "github.com/samber/lo" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/werf/nelm/pkg/helm/pkg/helmpath" "github.com/werf/nelm/pkg/log" ) @@ -77,6 +78,8 @@ const ( StoreAsHook StoreAs = "hook" StoreAsRegular StoreAs = "regular" + CacheDirAPIResourceJSONSchemas = CacheSubdirNelm + "/api-resource-json-schemas" + CacheSubdirNelm = "nelm" // ChartTSEntryPointJS is the JavaScript entry point path. ChartTSEntryPointJS = "src/index.js" // ChartTSEntryPointTS is the TypeScript entry point path. @@ -141,8 +144,9 @@ var ( StagePostPostUninstall, StageFinal, } - OrderedStoreAs = []StoreAs{StoreAsNone, StoreAsHook, StoreAsRegular} - DefaultRegistryCredentialsPath = filepath.Join(homedir.Get(), ".docker", config.ConfigFileName) + OrderedStoreAs = []StoreAs{StoreAsNone, StoreAsHook, StoreAsRegular} + // TODO(major): now it respects DOCKER_CONFIG? Is it a breaking change? Anyways, I feel like it shouldn't be a constant, but a proper option for actions + DefaultRegistryCredentialsPath = filepath.Join(dockerConfigDir(), "config.json") LabelKeyHumanManagedBy = "app.kubernetes.io/managed-by" LabelKeyPatternManagedBy = regexp.MustCompile(`^app.kubernetes.io/managed-by$`) AnnotationKeyHumanReleaseName = "meta.helm.sh/release-name" @@ -220,8 +224,7 @@ var ( "https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/{{ .NormalizedKubernetesVersion }}-standalone{{ .StrictSuffix }}/{{ .ResourceKind }}{{ .KindSuffix }}.json", "https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json", } - DefaultResourceValidationCacheLifetime = 48 * time.Hour - APIResourceValidationJSONSchemasCacheDir = helmpath.CachePath("nelm", "api-resource-json-schemas") + DefaultResourceValidationCacheLifetime = 48 * time.Hour ) // Type of the current operation. @@ -245,6 +248,44 @@ type ResourceState string // How the resource should be stored in the Helm release. type StoreAs string +type HelmOptions struct { + ChartLoadOpts ChartLoadOptions +} + +type ChartLoadOptions struct { + ChartAppVersion string + ChartDepsDownloader ChartDepsDownloader + ChartType LegacyChartType + DefaultChartAPIVersion string + DefaultChartName string + DefaultChartVersion string + DefaultSecretValuesDisable bool + DefaultValuesDisable bool + ExtraValues map[string]interface{} + NoSecrets bool + SecretKeyIgnore bool + SecretValuesFiles []string + SecretWorkDir string +} + +type helmOptionsContextKey struct{} + +func ContextWithHelmOptions(ctx context.Context, opts HelmOptions) context.Context { + return context.WithValue(ctx, helmOptionsContextKey{}, opts) +} + +func HasHelmOptions(ctx context.Context) bool { + _, ok := ctx.Value(helmOptionsContextKey{}).(HelmOptions) + + return ok +} + +func HelmOptionsFromContext(ctx context.Context) HelmOptions { + opts, _ := ctx.Value(helmOptionsContextKey{}).(HelmOptions) + + return opts +} + func StagesSortHandler(stage1, stage2 Stage) bool { index1 := lo.IndexOf(StagesOrdered, stage1) index2 := lo.IndexOf(StagesOrdered, stage2) @@ -255,3 +296,22 @@ func StagesSortHandler(stage1, stage2 Stage) bool { func SubStageWeighted(stage Stage, weight int) Stage { return Stage(fmt.Sprintf("%s/weight:%d", stage, weight)) } + +func dockerConfigDir() string { + if d := os.Getenv("DOCKER_CONFIG"); d != "" { + return d + } + + return filepath.Join(userHomeDir(), ".docker") +} + +func userHomeDir() string { + home, _ := os.UserHomeDir() + if home == "" && runtime.GOOS != "windows" { + if u, err := user.Current(); err == nil { + return u.HomeDir + } + } + + return home +} diff --git a/pkg/common/legacy.go b/pkg/common/legacy.go new file mode 100644 index 00000000..3ded65e8 --- /dev/null +++ b/pkg/common/legacy.go @@ -0,0 +1,45 @@ +package common + +import "context" + +const ( + LegacyChartTypeChart LegacyChartType = "" + LegacyChartTypeBundle LegacyChartType = "bundle" + LegacyChartTypeSubchart LegacyChartType = "subchart" + LegacyChartTypeChartStub LegacyChartType = "chartstub" +) + +var ( + ChartFileReader ChartFileReaderer + + ChartFileWriter ChartFileWriterer + + LegacyCoalesceTablesFunc func(dst, src map[string]interface{}) map[string]interface{} +) + +type LegacyChartType string + +type ChartDepsDownloader interface { + Build(ctx context.Context) error + Update(ctx context.Context) error + UpdateRepositories(ctx context.Context) error + SetChartPath(path string) +} + +type ChartFileReaderer interface { + LocateChart(ctx context.Context, name string) (string, error) + ReadChartFile(ctx context.Context, filePath string) ([]byte, error) + ChartFileExists(ctx context.Context, filePath string) (bool, error) + LoadChartDir(ctx context.Context, dir string) ([]*BufferedFile, error) + ChartIsDir(relPath string) (bool, error) +} + +type ChartFileWriterer interface { + WriteChartFile(ctx context.Context, filePath string, data []byte) error + CreateChartDir(ctx context.Context, dir string) error +} + +type BufferedFile struct { + Data []byte + Name string +} diff --git a/pkg/helm/cmd/helm/exports.go b/pkg/helm/cmd/helm/exports.go deleted file mode 100644 index e80ba186..00000000 --- a/pkg/helm/cmd/helm/exports.go +++ /dev/null @@ -1,7 +0,0 @@ -package helm - -var ( - Settings = settings - - Debug = debug -) diff --git a/pkg/helm/cmd/helm/flags_test.go b/pkg/helm/cmd/helm/flags_test.go deleted file mode 100644 index e5d653a1..00000000 --- a/pkg/helm/cmd/helm/flags_test.go +++ /dev/null @@ -1,95 +0,0 @@ -/* -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 helm - -import ( - "fmt" - "testing" - - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/release" - helmtime "github.com/werf/nelm/pkg/helm/pkg/time" -) - -func outputFlagCompletionTest(t *testing.T, cmdName string) { - releasesMockWithStatus := func(info *release.Info, hooks ...*release.Hook) []*release.Release { - info.LastDeployed = helmtime.Unix(1452902400, 0).UTC() - return []*release.Release{{ - Name: "athos", - Namespace: "default", - Info: info, - Chart: &chart.Chart{}, - Hooks: hooks, - }, { - Name: "porthos", - Namespace: "default", - Info: info, - Chart: &chart.Chart{}, - Hooks: hooks, - }, { - Name: "aramis", - Namespace: "default", - Info: info, - Chart: &chart.Chart{}, - Hooks: hooks, - }, { - Name: "dartagnan", - Namespace: "gascony", - Info: info, - Chart: &chart.Chart{}, - Hooks: hooks, - }} - } - - tests := []cmdTestCase{{ - name: "completion for output flag long and before arg", - cmd: fmt.Sprintf("__complete %s --output ''", cmdName), - golden: "output/output-comp.txt", - rels: releasesMockWithStatus(&release.Info{ - Status: release.StatusDeployed, - }), - }, { - name: "completion for output flag long and after arg", - cmd: fmt.Sprintf("__complete %s aramis --output ''", cmdName), - golden: "output/output-comp.txt", - rels: releasesMockWithStatus(&release.Info{ - Status: release.StatusDeployed, - }), - }, { - name: "completion for output flag short and before arg", - cmd: fmt.Sprintf("__complete %s -o ''", cmdName), - golden: "output/output-comp.txt", - rels: releasesMockWithStatus(&release.Info{ - Status: release.StatusDeployed, - }), - }, { - name: "completion for output flag short and after arg", - cmd: fmt.Sprintf("__complete %s aramis -o ''", cmdName), - golden: "output/output-comp.txt", - rels: releasesMockWithStatus(&release.Info{ - Status: release.StatusDeployed, - }), - }, { - name: "completion for output flag, no filter", - cmd: fmt.Sprintf("__complete %s --output jso", cmdName), - golden: "output/output-comp.txt", - rels: releasesMockWithStatus(&release.Info{ - Status: release.StatusDeployed, - }), - }} - runTestCmd(t, tests) -} diff --git a/pkg/helm/cmd/helm/helm.go b/pkg/helm/cmd/helm/helm.go deleted file mode 100644 index f301d968..00000000 --- a/pkg/helm/cmd/helm/helm.go +++ /dev/null @@ -1,165 +0,0 @@ -/* -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 helm // import "helm.sh/helm/v3/cmd/helm" - -import ( - "fmt" - "io" - "log" - "os" - "strings" - - "github.com/spf13/cobra" - "sigs.k8s.io/yaml" - - // Import to initialize client auth plugins. - _ "k8s.io/client-go/plugin/pkg/client/auth" - - "github.com/werf/nelm/pkg/helm/pkg/action" - "github.com/werf/nelm/pkg/helm/pkg/cli" - "github.com/werf/nelm/pkg/helm/pkg/kube" - kubefake "github.com/werf/nelm/pkg/helm/pkg/kube/fake" - "github.com/werf/nelm/pkg/helm/pkg/release" - "github.com/werf/nelm/pkg/helm/pkg/storage/driver" -) - -var settings = cli.New() - -func init() { - log.SetFlags(log.Lshortfile) -} - -func debug(format string, v ...interface{}) { - if settings.Debug { - format = fmt.Sprintf("[debug] %s\n", format) - log.Output(2, fmt.Sprintf(format, v...)) - } -} - -func warning(format string, v ...interface{}) { - format = fmt.Sprintf("WARNING: %s\n", format) - fmt.Fprintf(os.Stderr, format, v...) -} - -func main() { - // Setting the name of the app for managedFields in the Kubernetes client. - // It is set here to the full name of "helm" so that renaming of helm to - // another name (e.g., helm2 or helm3) does not change the name of the - // manager as picked up by the automated name detection. - kube.ManagedFieldsManager = "helm" - - actionConfig := new(action.Configuration) - cmd, err := newRootCmd(actionConfig, os.Stdout, os.Args[1:]) - if err != nil { - warning("%+v", err) - os.Exit(1) - } - - // run when each command's execute method is called - cobra.OnInitialize(func() { - helmDriver := os.Getenv("HELM_DRIVER") - if err := actionConfig.Init(settings.RESTClientGetter(), settings.Namespace(), helmDriver, debug); err != nil { - log.Fatal(err) - } - if helmDriver == "memory" { - loadReleasesInMemory(actionConfig) - } - }) - - if err := cmd.Execute(); err != nil { - debug("%+v", err) - os.Exit(1) - } -} - -// This function loads releases into the memory storage if the -// environment variable is properly set. -func loadReleasesInMemory(actionConfig *action.Configuration) { - filePaths := strings.Split(os.Getenv("HELM_MEMORY_DRIVER_DATA"), ":") - if len(filePaths) == 0 { - return - } - - store := actionConfig.Releases - mem, ok := store.Driver.(*driver.Memory) - if !ok { - // For an unexpected reason we are not dealing with the memory storage driver. - return - } - - actionConfig.KubeClient = &kubefake.PrintingKubeClient{Out: io.Discard} - - for _, path := range filePaths { - b, err := os.ReadFile(path) - if err != nil { - log.Fatal("Unable to read memory driver data", err) - } - - releases := []*release.Release{} - if err := yaml.Unmarshal(b, &releases); err != nil { - log.Fatal("Unable to unmarshal memory driver data: ", err) - } - - for _, rel := range releases { - if err := store.Create(rel); err != nil { - log.Fatal(err) - } - } - } - // Must reset namespace to the proper one - mem.SetNamespace(settings.Namespace()) -} - -func Init() (*cobra.Command, error) { - kube.ManagedFieldsManager = "helm" - - actionConfig := new(action.Configuration) - cmd, err := newRootCmd(actionConfig, os.Stdout, os.Args[1:]) - if err != nil { - return nil, err - } - - setCmdPreRun(cmd, actionConfig) - - return cmd, nil -} - -func setCmdPreRun(cmd *cobra.Command, actionConfig *action.Configuration) { - originalPersistentPreRunE := cmd.PreRunE - cmd.PreRunE = func(cmd *cobra.Command, args []string) error { - if originalPersistentPreRunE != nil { - if err := originalPersistentPreRunE(cmd, args); err != nil { - return err - } - } - - helmDriver := os.Getenv("HELM_DRIVER") - if err := actionConfig.Init(settings.RESTClientGetter(), settings.Namespace(), helmDriver, debug); err != nil { - return err - } - - if helmDriver == "memory" { - loadReleasesInMemory(actionConfig) - } - - return nil - } - - for _, cmd := range cmd.Commands() { - setCmdPreRun(cmd, actionConfig) - } -} diff --git a/pkg/helm/cmd/helm/history_test.go b/pkg/helm/cmd/helm/history_test.go deleted file mode 100644 index dbc2e0e9..00000000 --- a/pkg/helm/cmd/helm/history_test.go +++ /dev/null @@ -1,124 +0,0 @@ -/* -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 helm - -import ( - "fmt" - "testing" - - "github.com/werf/nelm/pkg/helm/pkg/release" -) - -func TestHistoryCmd(t *testing.T) { - mk := func(name string, vers int, status release.Status) *release.Release { - return release.Mock(&release.MockReleaseOptions{ - Name: name, - Version: vers, - Status: status, - }) - } - - tests := []cmdTestCase{{ - name: "get history for release", - cmd: "history angry-bird", - rels: []*release.Release{ - mk("angry-bird", 4, release.StatusDeployed), - mk("angry-bird", 3, release.StatusSuperseded), - mk("angry-bird", 2, release.StatusSuperseded), - mk("angry-bird", 1, release.StatusSuperseded), - }, - golden: "output/history.txt", - }, { - name: "get history with max limit set", - cmd: "history angry-bird --max 2", - rels: []*release.Release{ - mk("angry-bird", 4, release.StatusDeployed), - mk("angry-bird", 3, release.StatusSuperseded), - }, - golden: "output/history-limit.txt", - }, { - name: "get history with yaml output format", - cmd: "history angry-bird --output yaml", - rels: []*release.Release{ - mk("angry-bird", 4, release.StatusDeployed), - mk("angry-bird", 3, release.StatusSuperseded), - }, - golden: "output/history.yaml", - }, { - name: "get history with json output format", - cmd: "history angry-bird --output json", - rels: []*release.Release{ - mk("angry-bird", 4, release.StatusDeployed), - mk("angry-bird", 3, release.StatusSuperseded), - }, - golden: "output/history.json", - }} - runTestCmd(t, tests) -} - -func TestHistoryOutputCompletion(t *testing.T) { - outputFlagCompletionTest(t, "history") -} - -func revisionFlagCompletionTest(t *testing.T, cmdName string) { - mk := func(name string, vers int, status release.Status) *release.Release { - return release.Mock(&release.MockReleaseOptions{ - Name: name, - Version: vers, - Status: status, - }) - } - - releases := []*release.Release{ - mk("musketeers", 11, release.StatusDeployed), - mk("musketeers", 10, release.StatusSuperseded), - mk("musketeers", 9, release.StatusSuperseded), - mk("musketeers", 8, release.StatusSuperseded), - } - - tests := []cmdTestCase{{ - name: "completion for revision flag", - cmd: fmt.Sprintf("__complete %s musketeers --revision ''", cmdName), - rels: releases, - golden: "output/revision-comp.txt", - }, { - name: "completion for revision flag, no filter", - cmd: fmt.Sprintf("__complete %s musketeers --revision 1", cmdName), - rels: releases, - golden: "output/revision-comp.txt", - }, { - name: "completion for revision flag with too few args", - cmd: fmt.Sprintf("__complete %s --revision ''", cmdName), - rels: releases, - golden: "output/revision-wrong-args-comp.txt", - }, { - name: "completion for revision flag with too many args", - cmd: fmt.Sprintf("__complete %s three musketeers --revision ''", cmdName), - rels: releases, - golden: "output/revision-wrong-args-comp.txt", - }} - runTestCmd(t, tests) -} - -func TestHistoryCompletion(t *testing.T) { - checkReleaseCompletion(t, "history", false) -} - -func TestHistoryFileCompletion(t *testing.T) { - checkFileCompletion(t, "history", false) - checkFileCompletion(t, "history myrelease", false) -} diff --git a/pkg/helm/cmd/helm/list.go b/pkg/helm/cmd/helm/list.go deleted file mode 100644 index 315b7a59..00000000 --- a/pkg/helm/cmd/helm/list.go +++ /dev/null @@ -1,82 +0,0 @@ -/* -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 helm - -import ( - "fmt" - - "github.com/spf13/cobra" - - "github.com/werf/nelm/pkg/helm/pkg/action" - "github.com/werf/nelm/pkg/helm/pkg/release" -) - -// Returns all releases from 'releases', except those with names matching 'ignoredReleases' -func filterReleases(releases []*release.Release, ignoredReleaseNames []string) []*release.Release { - // if ignoredReleaseNames is nil, just return releases - if ignoredReleaseNames == nil { - return releases - } - - var filteredReleases []*release.Release - for _, rel := range releases { - found := false - for _, ignoredName := range ignoredReleaseNames { - if rel.Name == ignoredName { - found = true - break - } - } - if !found { - filteredReleases = append(filteredReleases, rel) - } - } - - return filteredReleases -} - -// Provide dynamic auto-completion for release names -func compListReleases(toComplete string, ignoredReleaseNames []string, cfg *action.Configuration) ([]string, cobra.ShellCompDirective) { - cobra.CompDebugln(fmt.Sprintf("compListReleases with toComplete %s", toComplete), settings.Debug) - - client := action.NewList(cfg) - client.All = true - client.Limit = 0 - // Do not filter so as to get the entire list of releases. - // This will allow zsh and fish to match completion choices - // on other criteria then prefix. For example: - // helm status ingress - // can match - // helm status nginx-ingress - // - // client.Filter = fmt.Sprintf("^%s", toComplete) - - client.SetStateMask() - releases, err := client.Run() - if err != nil { - return nil, cobra.ShellCompDirectiveDefault - } - - var choices []string - filteredReleases := filterReleases(releases, ignoredReleaseNames) - for _, rel := range filteredReleases { - choices = append(choices, - fmt.Sprintf("%s\t%s-%s -> %s", rel.Name, rel.Chart.Metadata.Name, rel.Chart.Metadata.Version, rel.Info.Status.String())) - } - - return choices, cobra.ShellCompDirectiveNoFileComp -} diff --git a/pkg/helm/cmd/helm/repo_list.go b/pkg/helm/cmd/helm/repo_list.go deleted file mode 100644 index df3cba76..00000000 --- a/pkg/helm/cmd/helm/repo_list.go +++ /dev/null @@ -1,61 +0,0 @@ -/* -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 helm - -import ( - "fmt" - - "github.com/werf/nelm/pkg/helm/pkg/repo" -) - -// Returns all repos from repos, except those with names matching ignoredRepoNames -// Inspired by https://stackoverflow.com/a/28701031/893211 -func filterRepos(repos []*repo.Entry, ignoredRepoNames []string) []*repo.Entry { - // if ignoredRepoNames is nil, just return repo - if ignoredRepoNames == nil { - return repos - } - - filteredRepos := make([]*repo.Entry, 0) - - ignored := make(map[string]bool, len(ignoredRepoNames)) - for _, repoName := range ignoredRepoNames { - ignored[repoName] = true - } - - for _, repo := range repos { - if _, removed := ignored[repo.Name]; !removed { - filteredRepos = append(filteredRepos, repo) - } - } - - return filteredRepos -} - -// Provide dynamic auto-completion for repo names -func compListRepos(_ string, ignoredRepoNames []string) []string { - var rNames []string - - f, err := repo.LoadFile(settings.RepositoryConfig) - if err == nil && len(f.Repositories) > 0 { - filteredRepos := filterRepos(f.Repositories, ignoredRepoNames) - for _, repo := range filteredRepos { - rNames = append(rNames, fmt.Sprintf("%s\t%s", repo.Name, repo.URL)) - } - } - return rNames -} diff --git a/pkg/helm/cmd/helm/root_unix.go b/pkg/helm/cmd/helm/root_unix.go deleted file mode 100644 index 9879ec26..00000000 --- a/pkg/helm/cmd/helm/root_unix.go +++ /dev/null @@ -1,58 +0,0 @@ -//go:build !windows - -/* -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 helm - -import ( - "os" - "os/user" - "path/filepath" -) - -func checkPerms() { - // This function MUST NOT FAIL, as it is just a check for a common permissions problem. - // If for some reason the function hits a stopping condition, it may panic. But only if - // we can be sure that it is panicking because Helm cannot proceed. - - kc := settings.KubeConfig - if kc == "" { - kc = os.Getenv("KUBECONFIG") - } - if kc == "" { - u, err := user.Current() - if err != nil { - // No idea where to find KubeConfig, so return silently. Many helm commands - // can proceed happily without a KUBECONFIG, so this is not a fatal error. - return - } - kc = filepath.Join(u.HomeDir, ".kube", "config") - } - fi, err := os.Stat(kc) - if err != nil { - // DO NOT error if no KubeConfig is found. Not all commands require one. - return - } - - perm := fi.Mode().Perm() - if perm&0040 > 0 { - warning("Kubernetes configuration file is group-readable. This is insecure. Location: %s", kc) - } - if perm&0004 > 0 { - warning("Kubernetes configuration file is world-readable. This is insecure. Location: %s", kc) - } -} diff --git a/pkg/helm/cmd/helm/root_unix_test.go b/pkg/helm/cmd/helm/root_unix_test.go deleted file mode 100644 index 4301aa6a..00000000 --- a/pkg/helm/cmd/helm/root_unix_test.go +++ /dev/null @@ -1,82 +0,0 @@ -//go:build !windows - -/* -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 helm - -import ( - "bytes" - "io" - "os" - "path/filepath" - "strings" - "testing" -) - -func checkPermsStderr() (string, error) { - r, w, err := os.Pipe() - if err != nil { - return "", err - } - - stderr := os.Stderr - os.Stderr = w - defer func() { - os.Stderr = stderr - }() - - checkPerms() - w.Close() - - var text bytes.Buffer - io.Copy(&text, r) - return text.String(), nil -} - -func TestCheckPerms(t *testing.T) { - tdir := t.TempDir() - tfile := filepath.Join(tdir, "testconfig") - fh, err := os.OpenFile(tfile, os.O_CREATE|os.O_APPEND|os.O_RDWR, 0440) - if err != nil { - t.Errorf("Failed to create temp file: %s", err) - } - - tconfig := settings.KubeConfig - settings.KubeConfig = tfile - defer func() { settings.KubeConfig = tconfig }() - - text, err := checkPermsStderr() - if err != nil { - t.Fatalf("could not read from stderr: %s", err) - } - expectPrefix := "WARNING: Kubernetes configuration file is group-readable. This is insecure. Location:" - if !strings.HasPrefix(text, expectPrefix) { - t.Errorf("Expected to get a warning for group perms. Got %q", text) - } - - if err := fh.Chmod(0404); err != nil { - t.Errorf("Could not change mode on file: %s", err) - } - text, err = checkPermsStderr() - if err != nil { - t.Fatalf("could not read from stderr: %s", err) - } - expectPrefix = "WARNING: Kubernetes configuration file is world-readable. This is insecure. Location:" - if !strings.HasPrefix(text, expectPrefix) { - t.Errorf("Expected to get a warning for world perms. Got %q", text) - } -} diff --git a/pkg/helm/cmd/helm/root_windows.go b/pkg/helm/cmd/helm/root_windows.go deleted file mode 100644 index 7c3ef7f4..00000000 --- a/pkg/helm/cmd/helm/root_windows.go +++ /dev/null @@ -1,22 +0,0 @@ -/* -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 helm - -func checkPerms() { - // Not yet implemented on Windows. If you know how to do a comprehensive perms - // check on Windows, contributions welcomed! -} diff --git a/pkg/helm/cmd/helm/testdata/helm home with space/helm/plugins/fullenv/completion.yaml b/pkg/helm/cmd/helm/testdata/helm home with space/helm/plugins/fullenv/completion.yaml deleted file mode 100644 index 511f1fda..00000000 --- a/pkg/helm/cmd/helm/testdata/helm home with space/helm/plugins/fullenv/completion.yaml +++ /dev/null @@ -1,19 +0,0 @@ -name: wrongname -commands: - - name: empty - - name: full - commands: - - name: more - validArgs: - - one - - two - flags: - - b - - ball - - name: less - flags: - - a - - all -flags: - - z - - q diff --git a/pkg/helm/cmd/helm/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml b/pkg/helm/cmd/helm/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml deleted file mode 100644 index 63f2f12d..00000000 --- a/pkg/helm/cmd/helm/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml +++ /dev/null @@ -1,4 +0,0 @@ -name: fullenv -usage: "show env vars" -description: "show all env vars" -command: "$HELM_PLUGIN_DIR/fullenv.sh" diff --git a/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/args/args.sh b/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/args/args.sh deleted file mode 100755 index 678b4eff..00000000 --- a/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/args/args.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/bash -echo $* diff --git a/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/args/plugin.yaml b/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/args/plugin.yaml deleted file mode 100644 index 21e28a7c..00000000 --- a/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/args/plugin.yaml +++ /dev/null @@ -1,4 +0,0 @@ -name: args -usage: "echo args" -description: "This echos args" -command: "$HELM_PLUGIN_DIR/args.sh" diff --git a/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/echo/plugin.yaml b/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/echo/plugin.yaml deleted file mode 100644 index 7b9362a0..00000000 --- a/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/echo/plugin.yaml +++ /dev/null @@ -1,4 +0,0 @@ -name: echo -usage: "echo stuff" -description: "This echos stuff" -command: "echo hello" diff --git a/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/env/completion.yaml b/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/env/completion.yaml deleted file mode 100644 index 1a2113b6..00000000 --- a/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/env/completion.yaml +++ /dev/null @@ -1,13 +0,0 @@ -name: env -commands: - - name: list - flags: - - a - - all - - log - - name: remove - validArgs: - - all - - one -flags: - - global diff --git a/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/env/plugin.yaml b/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/env/plugin.yaml deleted file mode 100644 index 52cb7a84..00000000 --- a/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/env/plugin.yaml +++ /dev/null @@ -1,4 +0,0 @@ -name: env -usage: "env stuff" -description: "show the env" -command: "echo $HELM_PLUGIN_NAME" diff --git a/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/exitwith/exitwith.sh b/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/exitwith/exitwith.sh deleted file mode 100755 index ec846965..00000000 --- a/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/exitwith/exitwith.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/bash -exit $* diff --git a/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/exitwith/plugin.yaml b/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/exitwith/plugin.yaml deleted file mode 100644 index 5691d171..00000000 --- a/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/exitwith/plugin.yaml +++ /dev/null @@ -1,4 +0,0 @@ -name: exitwith -usage: "exitwith code" -description: "This exits with the specified exit code" -command: "$HELM_PLUGIN_DIR/exitwith.sh" diff --git a/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/fullenv/completion.yaml b/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/fullenv/completion.yaml deleted file mode 100644 index 511f1fda..00000000 --- a/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/fullenv/completion.yaml +++ /dev/null @@ -1,19 +0,0 @@ -name: wrongname -commands: - - name: empty - - name: full - commands: - - name: more - validArgs: - - one - - two - flags: - - b - - ball - - name: less - flags: - - a - - all -flags: - - z - - q diff --git a/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/fullenv/fullenv.sh b/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/fullenv/fullenv.sh deleted file mode 100755 index 2efad9b3..00000000 --- a/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/fullenv/fullenv.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh -echo $HELM_PLUGIN_NAME -echo $HELM_PLUGIN_DIR -echo $HELM_PLUGINS -echo $HELM_REPOSITORY_CONFIG -echo $HELM_REPOSITORY_CACHE -echo $HELM_BIN diff --git a/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/fullenv/plugin.yaml b/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/fullenv/plugin.yaml deleted file mode 100644 index 63f2f12d..00000000 --- a/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/fullenv/plugin.yaml +++ /dev/null @@ -1,4 +0,0 @@ -name: fullenv -usage: "show env vars" -description: "show all env vars" -command: "$HELM_PLUGIN_DIR/fullenv.sh" diff --git a/pkg/helm/cmd/helm/testdata/helmhome/helm/repositories.yaml b/pkg/helm/cmd/helm/testdata/helmhome/helm/repositories.yaml deleted file mode 100644 index e9de487d..00000000 --- a/pkg/helm/cmd/helm/testdata/helmhome/helm/repositories.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: v1 -generated: 2016-10-03T16:03:10.640376913-06:00 -repositories: - - cache: testing-index.yaml - name: testing - url: http://example.com/charts diff --git a/pkg/helm/cmd/helm/testdata/output/empty_nofile_comp.txt b/pkg/helm/cmd/helm/testdata/output/empty_nofile_comp.txt deleted file mode 100644 index 8d9fad57..00000000 --- a/pkg/helm/cmd/helm/testdata/output/empty_nofile_comp.txt +++ /dev/null @@ -1,2 +0,0 @@ -:4 -Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/pkg/helm/cmd/helm/testdata/output/get-metadata.json b/pkg/helm/cmd/helm/testdata/output/get-metadata.json deleted file mode 100644 index 1d5152b2..00000000 --- a/pkg/helm/cmd/helm/testdata/output/get-metadata.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"thomas-guide","chart":"foo","version":"0.1.0-beta.1","appVersion":"1.0","namespace":"default","revision":1,"status":"deployed","deployedAt":"1977-09-02T22:04:05Z"} diff --git a/pkg/helm/cmd/helm/testdata/output/get-metadata.yaml b/pkg/helm/cmd/helm/testdata/output/get-metadata.yaml deleted file mode 100644 index b6d49b03..00000000 --- a/pkg/helm/cmd/helm/testdata/output/get-metadata.yaml +++ /dev/null @@ -1,8 +0,0 @@ -appVersion: "1.0" -chart: foo -deployedAt: "1977-09-02T22:04:05Z" -name: thomas-guide -namespace: default -revision: 1 -status: deployed -version: 0.1.0-beta.1 diff --git a/pkg/helm/cmd/helm/testdata/output/lint-chart-with-bad-subcharts.txt b/pkg/helm/cmd/helm/testdata/output/lint-chart-with-bad-subcharts.txt deleted file mode 100644 index 7c898b89..00000000 --- a/pkg/helm/cmd/helm/testdata/output/lint-chart-with-bad-subcharts.txt +++ /dev/null @@ -1,7 +0,0 @@ -==> Linting testdata/testcharts/chart-with-bad-subcharts -[INFO] Chart.yaml: icon is recommended -[ERROR] templates/: error unpacking bad-subchart in chart-with-bad-subcharts: validation: chart.metadata.name is required -[ERROR] : unable to load chart - error unpacking bad-subchart in chart-with-bad-subcharts: validation: chart.metadata.name is required - -Error: 1 chart(s) linted, 1 chart(s) failed diff --git a/pkg/helm/cmd/helm/testdata/output/rollback-wrong-args-comp.txt b/pkg/helm/cmd/helm/testdata/output/rollback-wrong-args-comp.txt deleted file mode 100644 index 8d9fad57..00000000 --- a/pkg/helm/cmd/helm/testdata/output/rollback-wrong-args-comp.txt +++ /dev/null @@ -1,2 +0,0 @@ -:4 -Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/pkg/helm/cmd/helm/testdata/output/status-with-resources.json b/pkg/helm/cmd/helm/testdata/output/status-with-resources.json deleted file mode 100644 index 275e0cfc..00000000 --- a/pkg/helm/cmd/helm/testdata/output/status-with-resources.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"flummoxed-chickadee","info":{"first_deployed":"","last_deployed":"2016-01-16T00:00:00Z","deleted":"","status":"deployed"},"namespace":"default"} diff --git a/pkg/helm/cmd/helm/testdata/output/status-wrong-args-comp.txt b/pkg/helm/cmd/helm/testdata/output/status-wrong-args-comp.txt deleted file mode 100644 index 8d9fad57..00000000 --- a/pkg/helm/cmd/helm/testdata/output/status-wrong-args-comp.txt +++ /dev/null @@ -1,2 +0,0 @@ -:4 -Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/pkg/helm/cmd/helm/testdata/output/status.json b/pkg/helm/cmd/helm/testdata/output/status.json deleted file mode 100644 index 4b499c93..00000000 --- a/pkg/helm/cmd/helm/testdata/output/status.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"flummoxed-chickadee","info":{"first_deployed":"","last_deployed":"2016-01-16T00:00:00Z","deleted":"","status":"deployed","notes":"release notes"},"namespace":"default"} diff --git a/pkg/helm/cmd/helm/testdata/output/version-client-shorthand.txt b/pkg/helm/cmd/helm/testdata/output/version-client-shorthand.txt deleted file mode 100644 index e204f7a4..00000000 --- a/pkg/helm/cmd/helm/testdata/output/version-client-shorthand.txt +++ /dev/null @@ -1 +0,0 @@ -version.BuildInfo{Version:"v3.14", GitCommit:"", GitTreeState:"", GoVersion:""} diff --git a/pkg/helm/cmd/helm/testdata/output/version-client.txt b/pkg/helm/cmd/helm/testdata/output/version-client.txt deleted file mode 100644 index e204f7a4..00000000 --- a/pkg/helm/cmd/helm/testdata/output/version-client.txt +++ /dev/null @@ -1 +0,0 @@ -version.BuildInfo{Version:"v3.14", GitCommit:"", GitTreeState:"", GoVersion:""} diff --git a/pkg/helm/cmd/helm/testdata/output/version-short.txt b/pkg/helm/cmd/helm/testdata/output/version-short.txt deleted file mode 100644 index 3ef02b86..00000000 --- a/pkg/helm/cmd/helm/testdata/output/version-short.txt +++ /dev/null @@ -1 +0,0 @@ -v3.14 diff --git a/pkg/helm/cmd/helm/testdata/output/version-template.txt b/pkg/helm/cmd/helm/testdata/output/version-template.txt deleted file mode 100644 index d33c5a92..00000000 --- a/pkg/helm/cmd/helm/testdata/output/version-template.txt +++ /dev/null @@ -1 +0,0 @@ -Version: v3.14 \ No newline at end of file diff --git a/pkg/helm/cmd/helm/testdata/output/version.txt b/pkg/helm/cmd/helm/testdata/output/version.txt deleted file mode 100644 index e204f7a4..00000000 --- a/pkg/helm/cmd/helm/testdata/output/version.txt +++ /dev/null @@ -1 +0,0 @@ -version.BuildInfo{Version:"v3.14", GitCommit:"", GitTreeState:"", GoVersion:""} diff --git a/pkg/helm/cmd/helm/testdata/plugins.yaml b/pkg/helm/cmd/helm/testdata/plugins.yaml deleted file mode 100644 index d99c1919..00000000 --- a/pkg/helm/cmd/helm/testdata/plugins.yaml +++ /dev/null @@ -1,3 +0,0 @@ -plugins: - - name: testplugin - url: testdata/testplugin diff --git a/pkg/helm/cmd/helm/testdata/testcharts/issue-7233/requirements.yaml b/pkg/helm/cmd/helm/testdata/testcharts/issue-7233/requirements.yaml deleted file mode 100644 index 306276a6..00000000 --- a/pkg/helm/cmd/helm/testdata/testcharts/issue-7233/requirements.yaml +++ /dev/null @@ -1,4 +0,0 @@ -dependencies: - - name: alpine - version: 0.1.0 - repository: file://../alpine diff --git a/pkg/helm/cmd/helm/testdata/testcharts/upgradetest/values.yaml b/pkg/helm/cmd/helm/testdata/testcharts/upgradetest/values.yaml deleted file mode 100644 index 1a660b36..00000000 --- a/pkg/helm/cmd/helm/testdata/testcharts/upgradetest/values.yaml +++ /dev/null @@ -1 +0,0 @@ -favoriteDrink: beer diff --git a/pkg/helm/cmd/helm/testdata/testplugin/plugin.yaml b/pkg/helm/cmd/helm/testdata/testplugin/plugin.yaml deleted file mode 100644 index 890292cb..00000000 --- a/pkg/helm/cmd/helm/testdata/testplugin/plugin.yaml +++ /dev/null @@ -1,4 +0,0 @@ -name: testplugin -usage: "echo test" -description: "This echos test" -command: "echo test" diff --git a/pkg/helm/intern/chart/v3/chart.go b/pkg/helm/intern/chart/v3/chart.go new file mode 100644 index 00000000..8a8ab65c --- /dev/null +++ b/pkg/helm/intern/chart/v3/chart.go @@ -0,0 +1,184 @@ +/* +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 v3 + +import ( + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/werf/nelm/pkg/helm/pkg/chart/common" +) + +// APIVersionV3 is the API version number for version 3. +const APIVersionV3 = "v3" + +// aliasNameFormat defines the characters that are legal in an alias name. +var aliasNameFormat = regexp.MustCompile("^[a-zA-Z0-9_-]+$") + +// Chart is a helm package that contains metadata, a default config, zero or more +// optionally parameterizable templates, and zero or more charts (dependencies). +type Chart struct { + // Raw contains the raw contents of the files originally contained in the chart archive. + // + // This should not be used except in special cases like `helm show values`, + // where we want to display the raw values, comments and all. + Raw []*common.File `json:"-"` + // Metadata is the contents of the Chartfile. + Metadata *Metadata `json:"metadata"` + // Lock is the contents of Chart.lock. + Lock *Lock `json:"lock"` + // Templates for this chart. + Templates []*common.File `json:"templates"` + // Values are default config for this chart. + Values map[string]interface{} `json:"values"` + // Schema is an optional JSON schema for imposing structure on Values + Schema []byte `json:"schema"` + // SchemaModTime the schema was last modified + SchemaModTime time.Time `json:"schemamodtime,omitempty"` + // Files are miscellaneous files in a chart archive, + // e.g. README, LICENSE, etc. + Files []*common.File `json:"files"` + // ModTime the chart metadata was last modified + ModTime time.Time `json:"modtime,omitzero"` + + RuntimeFiles []*common.File `json:"-"` + RuntimeDepsFiles []*common.File `json:"-"` + ExtraValues map[string]interface{} `json:"-"` + SecretsRuntimeData common.RuntimeData `json:"-"` + + parent *Chart + dependencies []*Chart +} + +type CRD struct { + // Name is the File.Name for the crd file + Name string + // Filename is the File obj Name including (sub-)chart.ChartFullPath + Filename string + // File is the File obj for the crd + File *common.File +} + +// SetDependencies replaces the chart dependencies. +func (ch *Chart) SetDependencies(charts ...*Chart) { + ch.dependencies = nil + ch.AddDependency(charts...) +} + +// Name returns the name of the chart. +func (ch *Chart) Name() string { + if ch.Metadata == nil { + return "" + } + return ch.Metadata.Name +} + +// AddDependency determines if the chart is a subchart. +func (ch *Chart) AddDependency(charts ...*Chart) { + for i, x := range charts { + charts[i].parent = ch + ch.dependencies = append(ch.dependencies, x) + } +} + +// Root finds the root chart. +func (ch *Chart) Root() *Chart { + if ch.IsRoot() { + return ch + } + return ch.Parent().Root() +} + +// Dependencies are the charts that this chart depends on. +func (ch *Chart) Dependencies() []*Chart { return ch.dependencies } + +// IsRoot determines if the chart is the root chart. +func (ch *Chart) IsRoot() bool { return ch.parent == nil } + +// Parent returns a subchart's parent chart. +func (ch *Chart) Parent() *Chart { return ch.parent } + +// ChartPath returns the full path to this chart in dot notation. +func (ch *Chart) ChartPath() string { + if !ch.IsRoot() { + return ch.Parent().ChartPath() + "." + ch.Name() + } + return ch.Name() +} + +// ChartFullPath returns the full path to this chart. +// Note that the path may not correspond to the path where the file can be found on the file system if the path +// points to an aliased subchart. +func (ch *Chart) ChartFullPath() string { + if !ch.IsRoot() { + return ch.Parent().ChartFullPath() + "/charts/" + ch.Name() + } + return ch.Name() +} + +// Validate validates the metadata. +func (ch *Chart) Validate() error { + return ch.Metadata.Validate() +} + +// AppVersion returns the appversion of the chart. +func (ch *Chart) AppVersion() string { + if ch.Metadata == nil { + return "" + } + return ch.Metadata.AppVersion +} + +// CRDs returns a list of File objects in the 'crds/' directory of a Helm chart. +// Deprecated: use CRDObjects() +func (ch *Chart) CRDs() []*common.File { + files := []*common.File{} + // Find all resources in the crds/ directory + for _, f := range ch.Files { + if strings.HasPrefix(f.Name, "crds/") && hasManifestExtension(f.Name) { + files = append(files, f) + } + } + // Get CRDs from dependencies, too. + for _, dep := range ch.Dependencies() { + files = append(files, dep.CRDs()...) + } + return files +} + +// CRDObjects returns a list of CRD objects in the 'crds/' directory of a Helm chart & subcharts +func (ch *Chart) CRDObjects() []CRD { + crds := []CRD{} + // Find all resources in the crds/ directory + for _, f := range ch.Files { + if strings.HasPrefix(f.Name, "crds/") && hasManifestExtension(f.Name) { + mycrd := CRD{Name: f.Name, Filename: filepath.Join(ch.ChartFullPath(), f.Name), File: f} + crds = append(crds, mycrd) + } + } + // Get CRDs from dependencies, too. + for _, dep := range ch.Dependencies() { + crds = append(crds, dep.CRDObjects()...) + } + return crds +} + +func hasManifestExtension(fname string) bool { + ext := filepath.Ext(fname) + return strings.EqualFold(ext, ".yaml") || strings.EqualFold(ext, ".yml") || strings.EqualFold(ext, ".json") +} diff --git a/pkg/helm/intern/chart/v3/chart_test.go b/pkg/helm/intern/chart/v3/chart_test.go new file mode 100644 index 00000000..979e4818 --- /dev/null +++ b/pkg/helm/intern/chart/v3/chart_test.go @@ -0,0 +1,229 @@ +/* +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 v3 + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/werf/nelm/pkg/helm/pkg/chart/common" +) + +func TestCRDs(t *testing.T) { + modTime := time.Now() + chrt := Chart{ + Files: []*common.File{ + { + Name: "crds/foo.yaml", + ModTime: modTime, + Data: []byte("hello"), + }, + { + Name: "bar.yaml", + ModTime: modTime, + Data: []byte("hello"), + }, + { + Name: "crds/foo/bar/baz.yaml", + ModTime: modTime, + Data: []byte("hello"), + }, + { + Name: "crdsfoo/bar/baz.yaml", + ModTime: modTime, + Data: []byte("hello"), + }, + { + Name: "crds/README.md", + ModTime: modTime, + Data: []byte("# hello"), + }, + }, + } + + is := assert.New(t) + crds := chrt.CRDs() + is.Equal(2, len(crds)) + is.Equal("crds/foo.yaml", crds[0].Name) + is.Equal("crds/foo/bar/baz.yaml", crds[1].Name) +} + +func TestSaveChartNoRawData(t *testing.T) { + chrt := Chart{ + Raw: []*common.File{ + { + Name: "fhqwhgads.yaml", + ModTime: time.Now(), + Data: []byte("Everybody to the Limit"), + }, + }, + } + + is := assert.New(t) + data, err := json.Marshal(chrt) + if err != nil { + t.Fatal(err) + } + + res := &Chart{} + if err := json.Unmarshal(data, res); err != nil { + t.Fatal(err) + } + + is.Equal([]*common.File(nil), res.Raw) +} + +func TestMetadata(t *testing.T) { + chrt := Chart{ + Metadata: &Metadata{ + Name: "foo.yaml", + AppVersion: "1.0.0", + APIVersion: "v3", + Version: "1.0.0", + Type: "application", + }, + } + + is := assert.New(t) + + is.Equal("foo.yaml", chrt.Name()) + is.Equal("1.0.0", chrt.AppVersion()) + is.Equal(nil, chrt.Validate()) +} + +func TestIsRoot(t *testing.T) { + chrt1 := Chart{ + parent: &Chart{ + Metadata: &Metadata{ + Name: "foo", + }, + }, + } + + chrt2 := Chart{ + Metadata: &Metadata{ + Name: "foo", + }, + } + + is := assert.New(t) + + is.Equal(false, chrt1.IsRoot()) + is.Equal(true, chrt2.IsRoot()) +} + +func TestChartPath(t *testing.T) { + chrt1 := Chart{ + parent: &Chart{ + Metadata: &Metadata{ + Name: "foo", + }, + }, + } + + chrt2 := Chart{ + Metadata: &Metadata{ + Name: "foo", + }, + } + + is := assert.New(t) + + is.Equal("foo.", chrt1.ChartPath()) + is.Equal("foo", chrt2.ChartPath()) +} + +func TestChartFullPath(t *testing.T) { + chrt1 := Chart{ + parent: &Chart{ + Metadata: &Metadata{ + Name: "foo", + }, + }, + } + + chrt2 := Chart{ + Metadata: &Metadata{ + Name: "foo", + }, + } + + is := assert.New(t) + + is.Equal("foo/charts/", chrt1.ChartFullPath()) + is.Equal("foo", chrt2.ChartFullPath()) +} + +func TestCRDObjects(t *testing.T) { + modTime := time.Now() + chrt := Chart{ + Files: []*common.File{ + { + Name: "crds/foo.yaml", + ModTime: modTime, + Data: []byte("hello"), + }, + { + Name: "bar.yaml", + ModTime: modTime, + Data: []byte("hello"), + }, + { + Name: "crds/foo/bar/baz.yaml", + ModTime: modTime, + Data: []byte("hello"), + }, + { + Name: "crdsfoo/bar/baz.yaml", + ModTime: modTime, + Data: []byte("hello"), + }, + { + Name: "crds/README.md", + ModTime: modTime, + Data: []byte("# hello"), + }, + }, + } + + expected := []CRD{ + { + Name: "crds/foo.yaml", + Filename: "crds/foo.yaml", + File: &common.File{ + Name: "crds/foo.yaml", + ModTime: modTime, + Data: []byte("hello"), + }, + }, + { + Name: "crds/foo/bar/baz.yaml", + Filename: "crds/foo/bar/baz.yaml", + File: &common.File{ + Name: "crds/foo/bar/baz.yaml", + ModTime: modTime, + Data: []byte("hello"), + }, + }, + } + + is := assert.New(t) + crds := chrt.CRDObjects() + is.Equal(expected, crds) +} diff --git a/pkg/helm/intern/chart/v3/dependency.go b/pkg/helm/intern/chart/v3/dependency.go new file mode 100644 index 00000000..2d956b54 --- /dev/null +++ b/pkg/helm/intern/chart/v3/dependency.go @@ -0,0 +1,82 @@ +/* +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 v3 + +import "time" + +// Dependency describes a chart upon which another chart depends. +// +// Dependencies can be used to express developer intent, or to capture the state +// of a chart. +type Dependency struct { + // Name is the name of the dependency. + // + // This must mach the name in the dependency's Chart.yaml. + Name string `json:"name" yaml:"name"` + // Version is the version (range) of this chart. + // + // A lock file will always produce a single version, while a dependency + // may contain a semantic version range. + Version string `json:"version,omitempty" yaml:"version,omitempty"` + // The URL to the repository. + // + // Appending `index.yaml` to this string should result in a URL that can be + // used to fetch the repository index. + Repository string `json:"repository" yaml:"repository"` + // A yaml path that resolves to a boolean, used for enabling/disabling charts (e.g. subchart1.enabled ) + Condition string `json:"condition,omitempty" yaml:"condition,omitempty"` + // Tags can be used to group charts for enabling/disabling together + Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"` + // Enabled bool determines if chart should be loaded + Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + // ImportValues holds the mapping of source values to parent key to be imported. Each item can be a + // string or pair of child/parent sublist items. + ImportValues []interface{} `json:"import-values,omitempty" yaml:"import-values,omitempty"` + // Alias usable alias to be used for the chart + Alias string `json:"alias,omitempty" yaml:"alias,omitempty"` +} + +// Validate checks for common problems with the dependency datastructure in +// the chart. This check must be done at load time before the dependency's charts are +// loaded. +func (d *Dependency) Validate() error { + if d == nil { + return ValidationError("dependencies must not contain empty or null nodes") + } + d.Name = sanitizeString(d.Name) + d.Version = sanitizeString(d.Version) + d.Repository = sanitizeString(d.Repository) + d.Condition = sanitizeString(d.Condition) + for i := range d.Tags { + d.Tags[i] = sanitizeString(d.Tags[i]) + } + if d.Alias != "" && !aliasNameFormat.MatchString(d.Alias) { + return ValidationErrorf("dependency %q has disallowed characters in the alias", d.Name) + } + return nil +} + +// Lock is a lock file for dependencies. +// +// It represents the state that the dependencies should be in. +type Lock struct { + // Generated is the date the lock file was last generated. + Generated time.Time `json:"generated"` + // Digest is a hash of the dependencies in Chart.yaml. + Digest string `json:"digest"` + // Dependencies is the list of dependencies that this lock file has locked. + Dependencies []*Dependency `json:"dependencies"` +} diff --git a/pkg/helm/pkg/chart/dependency_test.go b/pkg/helm/intern/chart/v3/dependency_test.go similarity index 98% rename from pkg/helm/pkg/chart/dependency_test.go rename to pkg/helm/intern/chart/v3/dependency_test.go index 90488a96..fcea19ae 100644 --- a/pkg/helm/pkg/chart/dependency_test.go +++ b/pkg/helm/intern/chart/v3/dependency_test.go @@ -13,7 +13,7 @@ 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 chart +package v3 import ( "testing" diff --git a/pkg/helm/intern/chart/v3/doc.go b/pkg/helm/intern/chart/v3/doc.go new file mode 100644 index 00000000..e003833a --- /dev/null +++ b/pkg/helm/intern/chart/v3/doc.go @@ -0,0 +1,21 @@ +/* +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 v3 provides chart handling for apiVersion v3 charts + +This package and its sub-packages provide handling for apiVersion v3 charts. +*/ +package v3 diff --git a/pkg/helm/pkg/chart/errors.go b/pkg/helm/intern/chart/v3/errors.go similarity index 98% rename from pkg/helm/pkg/chart/errors.go rename to pkg/helm/intern/chart/v3/errors.go index 2fad5f37..059e43f0 100644 --- a/pkg/helm/pkg/chart/errors.go +++ b/pkg/helm/intern/chart/v3/errors.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package chart +package v3 import "fmt" diff --git a/pkg/helm/intern/chart/v3/fuzz_test.go b/pkg/helm/intern/chart/v3/fuzz_test.go new file mode 100644 index 00000000..982c2648 --- /dev/null +++ b/pkg/helm/intern/chart/v3/fuzz_test.go @@ -0,0 +1,48 @@ +/* +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 v3 + +import ( + "testing" + + fuzz "github.com/AdaLogics/go-fuzz-headers" +) + +func FuzzMetadataValidate(f *testing.F) { + f.Fuzz(func(t *testing.T, data []byte) { + fdp := fuzz.NewConsumer(data) + // Add random values to the metadata + md := &Metadata{} + err := fdp.GenerateStruct(md) + if err != nil { + t.Skip() + } + md.Validate() + }) +} + +func FuzzDependencyValidate(f *testing.F) { + f.Fuzz(func(t *testing.T, data []byte) { + f := fuzz.NewConsumer(data) + // Add random values to the dependenci + d := &Dependency{} + err := f.GenerateStruct(d) + if err != nil { + t.Skip() + } + d.Validate() + }) +} diff --git a/pkg/helm/intern/chart/v3/lint/lint.go b/pkg/helm/intern/chart/v3/lint/lint.go new file mode 100644 index 00000000..85b46322 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/lint.go @@ -0,0 +1,66 @@ +/* +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 lint // import "github.com/werf/nelm/pkg/helm/intern/chart/v3/lint" + +import ( + "path/filepath" + + "github.com/werf/nelm/pkg/helm/intern/chart/v3/lint/rules" + "github.com/werf/nelm/pkg/helm/intern/chart/v3/lint/support" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" +) + +type linterOptions struct { + KubeVersion *common.KubeVersion + SkipSchemaValidation bool +} + +type LinterOption func(lo *linterOptions) + +func WithKubeVersion(kubeVersion *common.KubeVersion) LinterOption { + return func(lo *linterOptions) { + lo.KubeVersion = kubeVersion + } +} + +func WithSkipSchemaValidation(skipSchemaValidation bool) LinterOption { + return func(lo *linterOptions) { + lo.SkipSchemaValidation = skipSchemaValidation + } +} + +func RunAll(baseDir string, values map[string]interface{}, namespace string, options ...LinterOption) support.Linter { + + chartDir, _ := filepath.Abs(baseDir) + + lo := linterOptions{} + for _, option := range options { + option(&lo) + } + + result := support.Linter{ + ChartDir: chartDir, + } + + rules.Chartfile(&result) + rules.ValuesWithOverrides(&result, values, lo.SkipSchemaValidation) + rules.TemplatesWithSkipSchemaValidation(&result, values, namespace, lo.KubeVersion, lo.SkipSchemaValidation) + rules.Dependencies(&result) + rules.Crds(&result) + + return result +} diff --git a/pkg/helm/intern/chart/v3/lint/lint_test.go b/pkg/helm/intern/chart/v3/lint/lint_test.go new file mode 100644 index 00000000..1b776600 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/lint_test.go @@ -0,0 +1,243 @@ +/* +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 lint + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/werf/nelm/pkg/helm/intern/chart/v3/lint/support" + chartutil "github.com/werf/nelm/pkg/helm/intern/chart/v3/util" +) + +const namespace = "testNamespace" + +const badChartDir = "rules/testdata/badchartfile" +const badValuesFileDir = "rules/testdata/badvaluesfile" +const badYamlFileDir = "rules/testdata/albatross" +const badCrdFileDir = "rules/testdata/badcrdfile" +const goodChartDir = "rules/testdata/goodone" +const subChartValuesDir = "rules/testdata/withsubchart" +const malformedTemplate = "rules/testdata/malformed-template" +const invalidChartFileDir = "rules/testdata/invalidchartfile" + +func TestBadChartV3(t *testing.T) { + var values map[string]any + m := RunAll(badChartDir, values, namespace).Messages + if len(m) != 8 { + t.Errorf("Number of errors %v", len(m)) + t.Errorf("All didn't fail with expected errors, got %#v", m) + } + // There should be one INFO, one WARNING, and 2 ERROR messages, check for them + var i, w, e, e2, e3, e4, e5, e6 bool + for _, msg := range m { + if msg.Severity == support.InfoSev { + if strings.Contains(msg.Err.Error(), "icon is recommended") { + i = true + } + } + if msg.Severity == support.WarningSev { + if strings.Contains(msg.Err.Error(), "does not exist") { + w = true + } + } + if msg.Severity == support.ErrorSev { + if strings.Contains(msg.Err.Error(), "version '0.0.0.0' is not a valid SemVerV2") { + e = true + } + if strings.Contains(msg.Err.Error(), "name is required") { + e2 = true + } + + if strings.Contains(msg.Err.Error(), "apiVersion is required. The value must be \"v3\"") { + e3 = true + } + + if strings.Contains(msg.Err.Error(), "chart type is not valid in apiVersion") { + e4 = true + } + + if strings.Contains(msg.Err.Error(), "dependencies are not valid in the Chart file with apiVersion") { + e5 = true + } + // This comes from the dependency check, which loads dependency info from the Chart.yaml + if strings.Contains(msg.Err.Error(), "unable to load chart") { + e6 = true + } + } + } + if !e || !e2 || !e3 || !e4 || !e5 || !i || !e6 || !w { + t.Errorf("Didn't find all the expected errors, got %#v", m) + } +} + +func TestInvalidYaml(t *testing.T) { + var values map[string]any + m := RunAll(badYamlFileDir, values, namespace).Messages + if len(m) != 1 { + t.Fatalf("All didn't fail with expected errors, got %#v", m) + } + if !strings.Contains(m[0].Err.Error(), "deliberateSyntaxError") { + t.Errorf("All didn't have the error for deliberateSyntaxError") + } +} + +func TestInvalidChartYamlV3(t *testing.T) { + var values map[string]any + m := RunAll(invalidChartFileDir, values, namespace).Messages + t.Log(m) + if len(m) != 3 { + t.Fatalf("All didn't fail with expected errors, got %#v", m) + } + if !strings.Contains(m[0].Err.Error(), "failed to strictly parse chart metadata file") { + t.Errorf("All didn't have the error for duplicate YAML keys") + } +} + +func TestBadValuesV3(t *testing.T) { + var values map[string]any + m := RunAll(badValuesFileDir, values, namespace).Messages + if len(m) < 1 { + t.Fatalf("All didn't fail with expected errors, got %#v", m) + } + if !strings.Contains(m[0].Err.Error(), "unable to parse YAML") { + t.Errorf("All didn't have the error for invalid key format: %s", m[0].Err) + } +} + +func TestBadCrdFileV3(t *testing.T) { + var values map[string]any + m := RunAll(badCrdFileDir, values, namespace).Messages + assert.Lenf(t, m, 2, "All didn't fail with expected errors, got %#v", m) + assert.ErrorContains(t, m[0].Err, "apiVersion is not in 'apiextensions.k8s.io'") + assert.ErrorContains(t, m[1].Err, "object kind is not 'CustomResourceDefinition'") +} + +func TestGoodChart(t *testing.T) { + var values map[string]any + m := RunAll(goodChartDir, values, namespace).Messages + if len(m) != 0 { + t.Error("All returned linter messages when it shouldn't have") + for i, msg := range m { + t.Logf("Message %d: %s", i, msg) + } + } +} + +// TestHelmCreateChart tests that a `helm create` always passes a `helm lint` test. +// +// See https://github.com/helm/helm/issues/7923 +func TestHelmCreateChart(t *testing.T) { + var values map[string]any + dir := t.TempDir() + + createdChart, err := chartutil.Create("testhelmcreatepasseslint", dir) + if err != nil { + t.Error(err) + // Fatal is bad because of the defer. + return + } + + // Note: we test with strict=true here, even though others have + // strict = false. + m := RunAll(createdChart, values, namespace, WithSkipSchemaValidation(true)).Messages + if ll := len(m); ll != 1 { + t.Errorf("All should have had exactly 1 error. Got %d", ll) + for i, msg := range m { + t.Logf("Message %d: %s", i, msg.Error()) + } + } else if msg := m[0].Err.Error(); !strings.Contains(msg, "icon is recommended") { + t.Errorf("Unexpected lint error: %s", msg) + } +} + +// TestHelmCreateChart_CheckDeprecatedWarnings checks if any default template created by `helm create` throws +// deprecated warnings in the linter check against the current Kubernetes version (provided using ldflags). +// +// See https://github.com/helm/helm/issues/11495 +// +// Resources like hpa and ingress, which are disabled by default in values.yaml are enabled here using the equivalent +// of the `--set` flag. +func TestHelmCreateChart_CheckDeprecatedWarnings(t *testing.T) { + createdChart, err := chartutil.Create("checkdeprecatedwarnings", t.TempDir()) + if err != nil { + t.Error(err) + return + } + + // Add values to enable hpa, and ingress which are disabled by default. + // This is the equivalent of: + // helm lint checkdeprecatedwarnings --set 'autoscaling.enabled=true,ingress.enabled=true' + updatedValues := map[string]any{ + "autoscaling": map[string]any{ + "enabled": true, + }, + "ingress": map[string]any{ + "enabled": true, + }, + } + + linterRunDetails := RunAll(createdChart, updatedValues, namespace, WithSkipSchemaValidation(true)) + for _, msg := range linterRunDetails.Messages { + if strings.HasPrefix(msg.Error(), "[WARNING]") && + strings.Contains(msg.Error(), "deprecated") { + // When there is a deprecation warning for an object created + // by `helm create` for the current Kubernetes version, fail. + t.Errorf("Unexpected deprecation warning for %q: %s", msg.Path, msg.Error()) + } + } +} + +// lint ignores import-values +// See https://github.com/helm/helm/issues/9658 +func TestSubChartValuesChart(t *testing.T) { + var values map[string]any + m := RunAll(subChartValuesDir, values, namespace).Messages + if len(m) != 0 { + t.Error("All returned linter messages when it shouldn't have") + for i, msg := range m { + t.Logf("Message %d: %s", i, msg) + } + } +} + +// lint stuck with malformed template object +// See https://github.com/helm/helm/issues/11391 +func TestMalformedTemplate(t *testing.T) { + var values map[string]any + c := time.After(3 * time.Second) + ch := make(chan int, 1) + var m []support.Message + go func() { + m = RunAll(malformedTemplate, values, namespace).Messages + ch <- 1 + }() + select { + case <-c: + t.Fatalf("lint malformed template timeout") + case <-ch: + if len(m) != 1 { + t.Fatalf("All didn't fail with expected errors, got %#v", m) + } + if !strings.Contains(m[0].Err.Error(), "invalid character '{'") { + t.Errorf("All didn't have the error for invalid character '{'") + } + } +} diff --git a/pkg/helm/intern/chart/v3/lint/rules/chartfile.go b/pkg/helm/intern/chart/v3/lint/rules/chartfile.go new file mode 100644 index 00000000..aab053eb --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/chartfile.go @@ -0,0 +1,225 @@ +/* +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 rules // import "github.com/werf/nelm/pkg/helm/intern/chart/v3/lint/rules" + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/Masterminds/semver/v3" + "github.com/asaskevich/govalidator" + "sigs.k8s.io/yaml" + + chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" + "github.com/werf/nelm/pkg/helm/intern/chart/v3/lint/support" + chartutil "github.com/werf/nelm/pkg/helm/intern/chart/v3/util" +) + +// Chartfile runs a set of linter rules related to Chart.yaml file +func Chartfile(linter *support.Linter) { + chartFileName := "Chart.yaml" + chartPath := filepath.Join(linter.ChartDir, chartFileName) + + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartYamlNotDirectory(chartPath)) + + chartFile, err := chartutil.LoadChartfile(chartPath) + validChartFile := linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartYamlFormat(err)) + + // Guard clause. Following linter rules require a parsable ChartFile + if !validChartFile { + return + } + + _, err = chartutil.StrictLoadChartfile(chartPath) + linter.RunLinterRule(support.WarningSev, chartFileName, validateChartYamlStrictFormat(err)) + + // type check for Chart.yaml . ignoring error as any parse + // errors would already be caught in the above load function + chartFileForTypeCheck, _ := loadChartFileForTypeCheck(chartPath) + + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartName(chartFile)) + + // Chart metadata + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartAPIVersion(chartFile)) + + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartVersionType(chartFileForTypeCheck)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartVersion(chartFile)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartAppVersionType(chartFileForTypeCheck)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartMaintainer(chartFile)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartSources(chartFile)) + linter.RunLinterRule(support.InfoSev, chartFileName, validateChartIconPresence(chartFile)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartIconURL(chartFile)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartType(chartFile)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartDependencies(chartFile)) +} + +func validateChartVersionType(data map[string]interface{}) error { + return isStringValue(data, "version") +} + +func validateChartAppVersionType(data map[string]interface{}) error { + return isStringValue(data, "appVersion") +} + +func isStringValue(data map[string]interface{}, key string) error { + value, ok := data[key] + if !ok { + return nil + } + valueType := fmt.Sprintf("%T", value) + if valueType != "string" { + return fmt.Errorf("%s should be of type string but it's of type %s", key, valueType) + } + return nil +} + +func validateChartYamlNotDirectory(chartPath string) error { + fi, err := os.Stat(chartPath) + + if err == nil && fi.IsDir() { + return errors.New("should be a file, not a directory") + } + return nil +} + +func validateChartYamlFormat(chartFileError error) error { + if chartFileError != nil { + return fmt.Errorf("unable to parse YAML\n\t%w", chartFileError) + } + return nil +} + +func validateChartYamlStrictFormat(chartFileError error) error { + if chartFileError != nil { + return fmt.Errorf("failed to strictly parse chart metadata file\n\t%w", chartFileError) + } + return nil +} + +func validateChartName(cf *chart.Metadata) error { + if cf.Name == "" { + return errors.New("name is required") + } + name := filepath.Base(cf.Name) + if name != cf.Name { + return fmt.Errorf("chart name %q is invalid", cf.Name) + } + return nil +} + +func validateChartAPIVersion(cf *chart.Metadata) error { + if cf.APIVersion == "" { + return errors.New("apiVersion is required. The value must be \"v3\"") + } + + if cf.APIVersion != chart.APIVersionV3 { + return fmt.Errorf("apiVersion '%s' is not valid. The value must be \"v3\"", cf.APIVersion) + } + + return nil +} + +func validateChartVersion(cf *chart.Metadata) error { + if cf.Version == "" { + return errors.New("version is required") + } + + version, err := semver.StrictNewVersion(cf.Version) + if err != nil { + return fmt.Errorf("version '%s' is not a valid SemVerV2", cf.Version) + } + + c, err := semver.NewConstraint(">0.0.0-0") + if err != nil { + return err + } + valid, msg := c.Validate(version) + + if !valid && len(msg) > 0 { + return fmt.Errorf("version %v", msg[0]) + } + + return nil +} + +func validateChartMaintainer(cf *chart.Metadata) error { + for _, maintainer := range cf.Maintainers { + if maintainer == nil { + return errors.New("a maintainer entry is empty") + } + if maintainer.Name == "" { + return errors.New("each maintainer requires a name") + } else if maintainer.Email != "" && !govalidator.IsEmail(maintainer.Email) { + return fmt.Errorf("invalid email '%s' for maintainer '%s'", maintainer.Email, maintainer.Name) + } else if maintainer.URL != "" && !govalidator.IsURL(maintainer.URL) { + return fmt.Errorf("invalid url '%s' for maintainer '%s'", maintainer.URL, maintainer.Name) + } + } + return nil +} + +func validateChartSources(cf *chart.Metadata) error { + for _, source := range cf.Sources { + if source == "" || !govalidator.IsRequestURL(source) { + return fmt.Errorf("invalid source URL '%s'", source) + } + } + return nil +} + +func validateChartIconPresence(cf *chart.Metadata) error { + if cf.Icon == "" { + return errors.New("icon is recommended") + } + return nil +} + +func validateChartIconURL(cf *chart.Metadata) error { + if cf.Icon != "" && !govalidator.IsRequestURL(cf.Icon) { + return fmt.Errorf("invalid icon URL '%s'", cf.Icon) + } + return nil +} + +func validateChartDependencies(cf *chart.Metadata) error { + if len(cf.Dependencies) > 0 && cf.APIVersion != chart.APIVersionV3 { + return fmt.Errorf("dependencies are not valid in the Chart file with apiVersion '%s'. They are valid in apiVersion '%s'", cf.APIVersion, chart.APIVersionV3) + } + return nil +} + +func validateChartType(cf *chart.Metadata) error { + if len(cf.Type) > 0 && cf.APIVersion != chart.APIVersionV3 { + return fmt.Errorf("chart type is not valid in apiVersion '%s'. It is valid in apiVersion '%s'", cf.APIVersion, chart.APIVersionV3) + } + return nil +} + +// loadChartFileForTypeCheck loads the Chart.yaml +// in a generic form of a map[string]interface{}, so that the type +// of the values can be checked +func loadChartFileForTypeCheck(filename string) (map[string]interface{}, error) { + b, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + y := make(map[string]interface{}) + err = yaml.Unmarshal(b, &y) + return y, err +} diff --git a/pkg/helm/intern/chart/v3/lint/rules/chartfile_test.go b/pkg/helm/intern/chart/v3/lint/rules/chartfile_test.go new file mode 100644 index 00000000..b8b1b859 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/chartfile_test.go @@ -0,0 +1,278 @@ +/* +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 rules + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" + "github.com/werf/nelm/pkg/helm/intern/chart/v3/lint/support" + chartutil "github.com/werf/nelm/pkg/helm/intern/chart/v3/util" +) + +const ( + badChartNameDir = "testdata/badchartname" + badChartDir = "testdata/badchartfile" + anotherBadChartDir = "testdata/anotherbadchartfile" +) + +var ( + badChartNamePath = filepath.Join(badChartNameDir, "Chart.yaml") + badChartFilePath = filepath.Join(badChartDir, "Chart.yaml") + nonExistingChartFilePath = filepath.Join(os.TempDir(), "Chart.yaml") +) + +var badChart, _ = chartutil.LoadChartfile(badChartFilePath) +var badChartName, _ = chartutil.LoadChartfile(badChartNamePath) + +// Validation functions Test +func TestValidateChartYamlNotDirectory(t *testing.T) { + _ = os.Mkdir(nonExistingChartFilePath, os.ModePerm) + defer os.Remove(nonExistingChartFilePath) + + err := validateChartYamlNotDirectory(nonExistingChartFilePath) + if err == nil { + t.Errorf("validateChartYamlNotDirectory to return a linter error, got no error") + } +} + +func TestValidateChartYamlFormat(t *testing.T) { + err := validateChartYamlFormat(errors.New("Read error")) + if err == nil { + t.Errorf("validateChartYamlFormat to return a linter error, got no error") + } + + err = validateChartYamlFormat(nil) + if err != nil { + t.Errorf("validateChartYamlFormat to return no error, got a linter error") + } +} + +func TestValidateChartName(t *testing.T) { + err := validateChartName(badChart) + if err == nil { + t.Errorf("validateChartName to return a linter error, got no error") + } + + err = validateChartName(badChartName) + if err == nil { + t.Error("expected validateChartName to return a linter error for an invalid name, got no error") + } +} + +func TestValidateChartVersion(t *testing.T) { + var failTest = []struct { + Version string + ErrorMsg string + }{ + {"", "version is required"}, + {"1.2.3.4", "version '1.2.3.4' is not a valid SemVerV2"}, + {"waps", "'waps' is not a valid SemVerV2"}, + {"-3", "'-3' is not a valid SemVerV2"}, + {"1.1", "'1.1' is not a valid SemVerV2"}, + {"1", "'1' is not a valid SemVerV2"}, + } + + var successTest = []string{"0.0.1", "0.0.1+build", "0.0.1-beta"} + + for _, test := range failTest { + badChart.Version = test.Version + err := validateChartVersion(badChart) + if err == nil || !strings.Contains(err.Error(), test.ErrorMsg) { + t.Errorf("validateChartVersion(%s) to return \"%s\", got no error", test.Version, test.ErrorMsg) + } + } + + for _, version := range successTest { + badChart.Version = version + err := validateChartVersion(badChart) + if err != nil { + t.Errorf("validateChartVersion(%s) to return no error, got a linter error", version) + } + } +} + +func TestValidateChartMaintainer(t *testing.T) { + var failTest = []struct { + Name string + Email string + ErrorMsg string + }{ + {"", "", "each maintainer requires a name"}, + {"", "test@test.com", "each maintainer requires a name"}, + {"John Snow", "wrongFormatEmail.com", "invalid email"}, + } + + var successTest = []struct { + Name string + Email string + }{ + {"John Snow", ""}, + {"John Snow", "john@winterfell.com"}, + } + + for _, test := range failTest { + badChart.Maintainers = []*chart.Maintainer{{Name: test.Name, Email: test.Email}} + err := validateChartMaintainer(badChart) + if err == nil || !strings.Contains(err.Error(), test.ErrorMsg) { + t.Errorf("validateChartMaintainer(%s, %s) to return \"%s\", got no error", test.Name, test.Email, test.ErrorMsg) + } + } + + for _, test := range successTest { + badChart.Maintainers = []*chart.Maintainer{{Name: test.Name, Email: test.Email}} + err := validateChartMaintainer(badChart) + if err != nil { + t.Errorf("validateChartMaintainer(%s, %s) to return no error, got %s", test.Name, test.Email, err.Error()) + } + } + + // Testing for an empty maintainer + badChart.Maintainers = []*chart.Maintainer{nil} + err := validateChartMaintainer(badChart) + if err == nil { + t.Errorf("validateChartMaintainer did not return error for nil maintainer as expected") + } + if err.Error() != "a maintainer entry is empty" { + t.Errorf("validateChartMaintainer returned unexpected error for nil maintainer: %s", err.Error()) + } +} + +func TestValidateChartSources(t *testing.T) { + var failTest = []string{"", "RiverRun", "john@winterfell", "riverrun.io"} + var successTest = []string{"http://riverrun.io", "https://riverrun.io", "https://riverrun.io/blackfish"} + for _, test := range failTest { + badChart.Sources = []string{test} + err := validateChartSources(badChart) + if err == nil || !strings.Contains(err.Error(), "invalid source URL") { + t.Errorf("validateChartSources(%s) to return \"invalid source URL\", got no error", test) + } + } + + for _, test := range successTest { + badChart.Sources = []string{test} + err := validateChartSources(badChart) + if err != nil { + t.Errorf("validateChartSources(%s) to return no error, got %s", test, err.Error()) + } + } +} + +func TestValidateChartIconPresence(t *testing.T) { + t.Run("Icon absent", func(t *testing.T) { + testChart := &chart.Metadata{ + Icon: "", + } + + err := validateChartIconPresence(testChart) + + if err == nil { + t.Errorf("validateChartIconPresence to return a linter error, got no error") + } else if !strings.Contains(err.Error(), "icon is recommended") { + t.Errorf("expected %q, got %q", "icon is recommended", err.Error()) + } + }) + t.Run("Icon present", func(t *testing.T) { + testChart := &chart.Metadata{ + Icon: "http://example.org/icon.png", + } + + err := validateChartIconPresence(testChart) + + if err != nil { + t.Errorf("Unexpected error: %q", err.Error()) + } + }) +} + +func TestValidateChartIconURL(t *testing.T) { + var failTest = []string{"RiverRun", "john@winterfell", "riverrun.io"} + var successTest = []string{"http://riverrun.io", "https://riverrun.io", "https://riverrun.io/blackfish.png"} + for _, test := range failTest { + badChart.Icon = test + err := validateChartIconURL(badChart) + if err == nil || !strings.Contains(err.Error(), "invalid icon URL") { + t.Errorf("validateChartIconURL(%s) to return \"invalid icon URL\", got no error", test) + } + } + + for _, test := range successTest { + badChart.Icon = test + err := validateChartSources(badChart) + if err != nil { + t.Errorf("validateChartIconURL(%s) to return no error, got %s", test, err.Error()) + } + } +} + +func TestV3Chartfile(t *testing.T) { + t.Run("Chart.yaml basic validity issues", func(t *testing.T) { + linter := support.Linter{ChartDir: badChartDir} + Chartfile(&linter) + msgs := linter.Messages + expectedNumberOfErrorMessages := 6 + + if len(msgs) != expectedNumberOfErrorMessages { + t.Errorf("Expected %d errors, got %d", expectedNumberOfErrorMessages, len(msgs)) + return + } + + if !strings.Contains(msgs[0].Err.Error(), "name is required") { + t.Errorf("Unexpected message 0: %s", msgs[0].Err) + } + + if !strings.Contains(msgs[1].Err.Error(), "apiVersion is required. The value must be \"v3\"") { + t.Errorf("Unexpected message 1: %s", msgs[1].Err) + } + + if !strings.Contains(msgs[2].Err.Error(), "version '0.0.0.0' is not a valid SemVer") { + t.Errorf("Unexpected message 2: %s", msgs[2].Err) + } + + if !strings.Contains(msgs[3].Err.Error(), "icon is recommended") { + t.Errorf("Unexpected message 3: %s", msgs[3].Err) + } + }) + + t.Run("Chart.yaml validity issues due to type mismatch", func(t *testing.T) { + linter := support.Linter{ChartDir: anotherBadChartDir} + Chartfile(&linter) + msgs := linter.Messages + expectedNumberOfErrorMessages := 3 + + if len(msgs) != expectedNumberOfErrorMessages { + t.Errorf("Expected %d errors, got %d", expectedNumberOfErrorMessages, len(msgs)) + return + } + + if !strings.Contains(msgs[0].Err.Error(), "version should be of type string") { + t.Errorf("Unexpected message 0: %s", msgs[0].Err) + } + + if !strings.Contains(msgs[1].Err.Error(), "version '7.2445e+06' is not a valid SemVer") { + t.Errorf("Unexpected message 1: %s", msgs[1].Err) + } + + if !strings.Contains(msgs[2].Err.Error(), "appVersion should be of type string") { + t.Errorf("Unexpected message 2: %s", msgs[2].Err) + } + }) +} diff --git a/pkg/helm/intern/chart/v3/lint/rules/crds.go b/pkg/helm/intern/chart/v3/lint/rules/crds.go new file mode 100644 index 00000000..8313a771 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/crds.go @@ -0,0 +1,116 @@ +/* +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 rules + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + "k8s.io/apimachinery/pkg/util/yaml" + + "github.com/werf/nelm/pkg/helm/intern/chart/v3/lint/support" + "github.com/werf/nelm/pkg/helm/intern/chart/v3/loader" +) + +// Crds lints the CRDs in the Linter. +func Crds(linter *support.Linter) { + fpath := "crds/" + crdsPath := filepath.Join(linter.ChartDir, fpath) + + // crds directory is optional + if _, err := os.Stat(crdsPath); errors.Is(err, fs.ErrNotExist) { + return + } + + crdsDirValid := linter.RunLinterRule(support.ErrorSev, fpath, validateCrdsDir(crdsPath)) + if !crdsDirValid { + return + } + + // Load chart and parse CRDs + chart, err := loader.Load(context.Background(), linter.ChartDir) + + chartLoaded := linter.RunLinterRule(support.ErrorSev, fpath, err) + + if !chartLoaded { + return + } + + /* Iterate over all the CRDs to check: + 1. It is a YAML file and not a template + 2. The API version is apiextensions.k8s.io + 3. The kind is CustomResourceDefinition + */ + for _, crd := range chart.CRDObjects() { + fileName := crd.Name + fpath = fileName + + decoder := yaml.NewYAMLOrJSONDecoder(bytes.NewReader(crd.File.Data), 4096) + for { + var yamlStruct *k8sYamlStruct + + err := decoder.Decode(&yamlStruct) + if errors.Is(err, io.EOF) { + break + } + + // If YAML parsing fails here, it will always fail in the next block as well, so we should return here. + // This also confirms the YAML is not a template, since templates can't be decoded into a K8sYamlStruct. + if !linter.RunLinterRule(support.ErrorSev, fpath, validateYamlContent(err)) { + return + } + + if yamlStruct != nil { + linter.RunLinterRule(support.ErrorSev, fpath, validateCrdAPIVersion(yamlStruct)) + linter.RunLinterRule(support.ErrorSev, fpath, validateCrdKind(yamlStruct)) + } + } + } +} + +// Validation functions +func validateCrdsDir(crdsPath string) error { + fi, err := os.Stat(crdsPath) + if err != nil { + return err + } + if !fi.IsDir() { + return errors.New("not a directory") + } + return nil +} + +func validateCrdAPIVersion(obj *k8sYamlStruct) error { + if !strings.HasPrefix(obj.APIVersion, "apiextensions.k8s.io") { + return fmt.Errorf("apiVersion is not in 'apiextensions.k8s.io'") + } + return nil +} + +func validateCrdKind(obj *k8sYamlStruct) error { + if obj.Kind != "CustomResourceDefinition" { + return fmt.Errorf("object kind is not 'CustomResourceDefinition'") + } + return nil +} diff --git a/pkg/helm/intern/chart/v3/lint/rules/crds_test.go b/pkg/helm/intern/chart/v3/lint/rules/crds_test.go new file mode 100644 index 00000000..943fd87c --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/crds_test.go @@ -0,0 +1,66 @@ +/* +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 rules + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/werf/nelm/pkg/helm/intern/chart/v3/lint/support" +) + +const invalidCrdsDir = "./testdata/invalidcrdsdir" + +func TestInvalidCrdsDir(t *testing.T) { + linter := support.Linter{ChartDir: invalidCrdsDir} + Crds(&linter) + res := linter.Messages + + assert.Len(t, res, 1) + assert.ErrorContains(t, res[0].Err, "not a directory") +} + +// multi-document YAML with empty documents would panic +func TestCrdWithEmptyDocument(t *testing.T) { + chartDir := t.TempDir() + + os.WriteFile(filepath.Join(chartDir, "Chart.yaml"), []byte( + `apiVersion: v1 +name: test +version: 0.1.0 +`), 0644) + + // CRD with comments before --- (creates empty document) + crdsDir := filepath.Join(chartDir, "crds") + os.Mkdir(crdsDir, 0755) + os.WriteFile(filepath.Join(crdsDir, "test.yaml"), []byte( + `# Comments create empty document +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: test.example.io +`), 0644) + + linter := support.Linter{ChartDir: chartDir} + Crds(&linter) + + assert.Len(t, linter.Messages, 0) +} diff --git a/pkg/helm/intern/chart/v3/lint/rules/dependencies.go b/pkg/helm/intern/chart/v3/lint/rules/dependencies.go new file mode 100644 index 00000000..b1667497 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/dependencies.go @@ -0,0 +1,102 @@ +/* +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 rules // import "github.com/werf/nelm/pkg/helm/intern/chart/v3/lint/rules" + +import ( + "context" + "fmt" + "strings" + + chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" + "github.com/werf/nelm/pkg/helm/intern/chart/v3/lint/support" + "github.com/werf/nelm/pkg/helm/intern/chart/v3/loader" +) + +// Dependencies runs lints against a chart's dependencies +// +// See https://github.com/helm/helm/issues/7910 +func Dependencies(linter *support.Linter) { + c, err := loader.LoadDir(context.Background(), linter.ChartDir) + if !linter.RunLinterRule(support.ErrorSev, "", validateChartFormat(err)) { + return + } + + linter.RunLinterRule(support.ErrorSev, linter.ChartDir, validateDependencyInMetadata(c)) + linter.RunLinterRule(support.ErrorSev, linter.ChartDir, validateDependenciesUnique(c)) + linter.RunLinterRule(support.WarningSev, linter.ChartDir, validateDependencyInChartsDir(c)) +} + +func validateChartFormat(chartError error) error { + if chartError != nil { + return fmt.Errorf("unable to load chart\n\t%w", chartError) + } + return nil +} + +func validateDependencyInChartsDir(c *chart.Chart) (err error) { + dependencies := map[string]struct{}{} + missing := []string{} + for _, dep := range c.Dependencies() { + dependencies[dep.Metadata.Name] = struct{}{} + } + for _, dep := range c.Metadata.Dependencies { + if _, ok := dependencies[dep.Name]; !ok { + missing = append(missing, dep.Name) + } + } + if len(missing) > 0 { + err = fmt.Errorf("chart directory is missing these dependencies: %s", strings.Join(missing, ",")) + } + return err +} + +func validateDependencyInMetadata(c *chart.Chart) (err error) { + dependencies := map[string]struct{}{} + missing := []string{} + for _, dep := range c.Metadata.Dependencies { + dependencies[dep.Name] = struct{}{} + } + for _, dep := range c.Dependencies() { + if _, ok := dependencies[dep.Metadata.Name]; !ok { + missing = append(missing, dep.Metadata.Name) + } + } + if len(missing) > 0 { + err = fmt.Errorf("chart metadata is missing these dependencies: %s", strings.Join(missing, ",")) + } + return err +} + +func validateDependenciesUnique(c *chart.Chart) (err error) { + dependencies := map[string]*chart.Dependency{} + shadowing := []string{} + + for _, dep := range c.Metadata.Dependencies { + key := dep.Name + if dep.Alias != "" { + key = dep.Alias + } + if dependencies[key] != nil { + shadowing = append(shadowing, key) + } + dependencies[key] = dep + } + if len(shadowing) > 0 { + err = fmt.Errorf("multiple dependencies with name or alias: %s", strings.Join(shadowing, ",")) + } + return err +} diff --git a/pkg/helm/intern/chart/v3/lint/rules/dependencies_test.go b/pkg/helm/intern/chart/v3/lint/rules/dependencies_test.go new file mode 100644 index 00000000..465a40ca --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/dependencies_test.go @@ -0,0 +1,157 @@ +/* +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 rules + +import ( + "path/filepath" + "testing" + + chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" + "github.com/werf/nelm/pkg/helm/intern/chart/v3/lint/support" + chartutil "github.com/werf/nelm/pkg/helm/intern/chart/v3/util" +) + +func chartWithBadDependencies() chart.Chart { + badChartDeps := chart.Chart{ + Metadata: &chart.Metadata{ + Name: "badchart", + Version: "0.1.0", + APIVersion: "v2", + Dependencies: []*chart.Dependency{ + { + Name: "sub2", + }, + { + Name: "sub3", + }, + }, + }, + } + + badChartDeps.SetDependencies( + &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "sub1", + Version: "0.1.0", + APIVersion: "v2", + }, + }, + &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "sub2", + Version: "0.1.0", + APIVersion: "v2", + }, + }, + ) + return badChartDeps +} + +func TestValidateDependencyInChartsDir(t *testing.T) { + c := chartWithBadDependencies() + + if err := validateDependencyInChartsDir(&c); err == nil { + t.Error("chart should have been flagged for missing deps in chart directory") + } +} + +func TestValidateDependencyInMetadata(t *testing.T) { + c := chartWithBadDependencies() + + if err := validateDependencyInMetadata(&c); err == nil { + t.Errorf("chart should have been flagged for missing deps in chart metadata") + } +} + +func TestValidateDependenciesUnique(t *testing.T) { + tests := []struct { + chart chart.Chart + }{ + {chart.Chart{ + Metadata: &chart.Metadata{ + Name: "badchart", + Version: "0.1.0", + APIVersion: "v2", + Dependencies: []*chart.Dependency{ + { + Name: "foo", + }, + { + Name: "foo", + }, + }, + }, + }}, + {chart.Chart{ + Metadata: &chart.Metadata{ + Name: "badchart", + Version: "0.1.0", + APIVersion: "v2", + Dependencies: []*chart.Dependency{ + { + Name: "foo", + Alias: "bar", + }, + { + Name: "bar", + }, + }, + }, + }}, + {chart.Chart{ + Metadata: &chart.Metadata{ + Name: "badchart", + Version: "0.1.0", + APIVersion: "v2", + Dependencies: []*chart.Dependency{ + { + Name: "foo", + Alias: "baz", + }, + { + Name: "bar", + Alias: "baz", + }, + }, + }, + }}, + } + + for _, tt := range tests { + if err := validateDependenciesUnique(&tt.chart); err == nil { + t.Errorf("chart should have been flagged for dependency shadowing") + } + } +} + +func TestDependencies(t *testing.T) { + tmp := t.TempDir() + + c := chartWithBadDependencies() + err := chartutil.SaveDir(&c, tmp) + if err != nil { + t.Fatal(err) + } + linter := support.Linter{ChartDir: filepath.Join(tmp, c.Metadata.Name)} + + Dependencies(&linter) + if l := len(linter.Messages); l != 2 { + t.Errorf("expected 2 linter errors for bad chart dependencies. Got %d.", l) + for i, msg := range linter.Messages { + t.Logf("Message: %d, Error: %#v", i, msg) + } + } +} diff --git a/pkg/helm/intern/chart/v3/lint/rules/deprecations.go b/pkg/helm/intern/chart/v3/lint/rules/deprecations.go new file mode 100644 index 00000000..21a4e579 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/deprecations.go @@ -0,0 +1,94 @@ +/* +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 rules // import "github.com/werf/nelm/pkg/helm/intern/chart/v3/lint/rules" + +import ( + "fmt" + "strconv" + + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/endpoints/deprecation" + kscheme "k8s.io/client-go/kubernetes/scheme" +) + +// deprecatedAPIError indicates than an API is deprecated in Kubernetes +type deprecatedAPIError struct { + Deprecated string + Message string +} + +func (e deprecatedAPIError) Error() string { + msg := e.Message + return msg +} + +func validateNoDeprecations(resource *k8sYamlStruct, kubeVersion *common.KubeVersion) error { + // if `resource` does not have an APIVersion or Kind, we cannot test it for deprecation + if resource.APIVersion == "" { + return nil + } + if resource.Kind == "" { + return nil + } + + if kubeVersion == nil { + kubeVersion = &common.DefaultCapabilities.KubeVersion + } + + kubeVersionMajor, err := strconv.Atoi(kubeVersion.Major) + if err != nil { + return err + } + kubeVersionMinor, err := strconv.Atoi(kubeVersion.Minor) + if err != nil { + return err + } + + runtimeObject, err := resourceToRuntimeObject(resource) + if err != nil { + // do not error for non-kubernetes resources + if runtime.IsNotRegisteredError(err) { + return nil + } + return err + } + + if !deprecation.IsDeprecated(runtimeObject, kubeVersionMajor, kubeVersionMinor) { + return nil + } + gvk := fmt.Sprintf("%s %s", resource.APIVersion, resource.Kind) + return deprecatedAPIError{ + Deprecated: gvk, + Message: deprecation.WarningMessage(runtimeObject), + } +} + +func resourceToRuntimeObject(resource *k8sYamlStruct) (runtime.Object, error) { + scheme := runtime.NewScheme() + kscheme.AddToScheme(scheme) + + gvk := schema.FromAPIVersionAndKind(resource.APIVersion, resource.Kind) + out, err := scheme.New(gvk) + if err != nil { + return nil, err + } + out.GetObjectKind().SetGroupVersionKind(gvk) + return out, nil +} diff --git a/pkg/helm/intern/chart/v3/lint/rules/deprecations_test.go b/pkg/helm/intern/chart/v3/lint/rules/deprecations_test.go new file mode 100644 index 00000000..210ab353 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/deprecations_test.go @@ -0,0 +1,41 @@ +/* +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 rules // import "github.com/werf/nelm/pkg/helm/intern/chart/v3/lint/rules" + +import "testing" + +func TestValidateNoDeprecations(t *testing.T) { + deprecated := &k8sYamlStruct{ + APIVersion: "extensions/v1beta1", + Kind: "Deployment", + } + err := validateNoDeprecations(deprecated, nil) + if err == nil { + t.Fatal("Expected deprecated extension to be flagged") + } + depErr := err.(deprecatedAPIError) + if depErr.Message == "" { + t.Fatalf("Expected error message to be non-blank: %v", err) + } + + if err := validateNoDeprecations(&k8sYamlStruct{ + APIVersion: "v1", + Kind: "Pod", + }, nil); err != nil { + t.Errorf("Expected a v1 Pod to not be deprecated") + } +} diff --git a/pkg/helm/intern/chart/v3/lint/rules/template.go b/pkg/helm/intern/chart/v3/lint/rules/template.go new file mode 100644 index 00000000..adef230b --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/template.go @@ -0,0 +1,354 @@ +/* +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 rules + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "slices" + "strings" + + "k8s.io/apimachinery/pkg/api/validation" + apipath "k8s.io/apimachinery/pkg/api/validation/path" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apimachinery/pkg/util/yaml" + + "github.com/werf/nelm/pkg/helm/intern/chart/v3/lint/support" + "github.com/werf/nelm/pkg/helm/intern/chart/v3/loader" + chartutil "github.com/werf/nelm/pkg/helm/intern/chart/v3/util" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + "github.com/werf/nelm/pkg/helm/pkg/chart/common/util" + "github.com/werf/nelm/pkg/helm/pkg/engine" +) + +// Templates lints the templates in the Linter. +func Templates(linter *support.Linter, values map[string]interface{}, namespace string, _ bool) { + TemplatesWithKubeVersion(linter, values, namespace, nil) +} + +// TemplatesWithKubeVersion lints the templates in the Linter, allowing to specify the kubernetes version. +func TemplatesWithKubeVersion(linter *support.Linter, values map[string]interface{}, namespace string, kubeVersion *common.KubeVersion) { + TemplatesWithSkipSchemaValidation(linter, values, namespace, kubeVersion, false) +} + +// TemplatesWithSkipSchemaValidation lints the templates in the Linter, allowing to specify the kubernetes version and if schema validation is enabled or not. +func TemplatesWithSkipSchemaValidation(linter *support.Linter, values map[string]interface{}, namespace string, kubeVersion *common.KubeVersion, skipSchemaValidation bool) { + fpath := "templates/" + templatesPath := filepath.Join(linter.ChartDir, fpath) + + // Templates directory is optional for now + templatesDirExists := linter.RunLinterRule(support.WarningSev, fpath, templatesDirExists(templatesPath)) + if !templatesDirExists { + return + } + + validTemplatesDir := linter.RunLinterRule(support.ErrorSev, fpath, validateTemplatesDir(templatesPath)) + if !validTemplatesDir { + return + } + + // Load chart and parse templates + chart, err := loader.Load(context.Background(), linter.ChartDir) + + chartLoaded := linter.RunLinterRule(support.ErrorSev, fpath, err) + + if !chartLoaded { + return + } + + options := common.ReleaseOptions{ + Name: "test-release", + Namespace: namespace, + } + + caps := common.DefaultCapabilities.Copy() + if kubeVersion != nil { + caps.KubeVersion = *kubeVersion + } + + // lint ignores import-values + // See https://github.com/helm/helm/issues/9658 + if err := chartutil.ProcessDependencies(chart, values); err != nil { + return + } + + cvals, err := util.CoalesceValues(chart, values) + if err != nil { + return + } + + valuesToRender, err := util.ToRenderValuesWithSchemaValidation(chart, cvals, options, caps, skipSchemaValidation) + if err != nil { + linter.RunLinterRule(support.ErrorSev, fpath, err) + return + } + var e engine.Engine + e.LintMode = true + renderedContentMap, err := e.Render(context.Background(), chart, valuesToRender) + + renderOk := linter.RunLinterRule(support.ErrorSev, fpath, err) + + if !renderOk { + return + } + + /* Iterate over all the templates to check: + - It is a .yaml file + - All the values in the template file is defined + - {{}} include | quote + - Generated content is a valid Yaml file + - Metadata.Namespace is not set + */ + for _, template := range chart.Templates { + fileName := template.Name + fpath = fileName + + linter.RunLinterRule(support.ErrorSev, fpath, validateAllowedExtension(fileName)) + + // We only apply the following lint rules to yaml files + if !isYamlFileExtension(fileName) { + continue + } + + // NOTE: disabled for now, Refs https://github.com/helm/helm/issues/1463 + // Check that all the templates have a matching value + // linter.RunLinterRule(support.WarningSev, fpath, validateNoMissingValues(templatesPath, valuesToRender, preExecutedTemplate)) + + // NOTE: disabled for now, Refs https://github.com/helm/helm/issues/1037 + // linter.RunLinterRule(support.WarningSev, fpath, validateQuotes(string(preExecutedTemplate))) + + renderedContent := renderedContentMap[path.Join(chart.Name(), fileName)] + if strings.TrimSpace(renderedContent) != "" { + linter.RunLinterRule(support.WarningSev, fpath, validateTopIndentLevel(renderedContent)) + + decoder := yaml.NewYAMLOrJSONDecoder(strings.NewReader(renderedContent), 4096) + + // Lint all resources if the file contains multiple documents separated by --- + for { + // Even though k8sYamlStruct only defines a few fields, an error in any other + // key will be raised as well + var yamlStruct *k8sYamlStruct + + err := decoder.Decode(&yamlStruct) + if errors.Is(err, io.EOF) { + break + } + + // If YAML linting fails here, it will always fail in the next block as well, so we should return here. + // fix https://github.com/helm/helm/issues/11391 + if !linter.RunLinterRule(support.ErrorSev, fpath, validateYamlContent(err)) { + return + } + if yamlStruct != nil { + // NOTE: set to warnings to allow users to support out-of-date kubernetes + // Refs https://github.com/helm/helm/issues/8596 + linter.RunLinterRule(support.WarningSev, fpath, validateMetadataName(yamlStruct)) + linter.RunLinterRule(support.WarningSev, fpath, validateNoDeprecations(yamlStruct, kubeVersion)) + + linter.RunLinterRule(support.ErrorSev, fpath, validateMatchSelector(yamlStruct, renderedContent)) + linter.RunLinterRule(support.ErrorSev, fpath, validateListAnnotations(yamlStruct, renderedContent)) + } + } + } + } +} + +// validateTopIndentLevel checks that the content does not start with an indent level > 0. +// +// This error can occur when a template accidentally inserts space. It can cause +// unpredictable errors depending on whether the text is normalized before being passed +// into the YAML parser. So we trap it here. +// +// See https://github.com/helm/helm/issues/8467 +func validateTopIndentLevel(content string) error { + // Read lines until we get to a non-empty one + scanner := bufio.NewScanner(bytes.NewBufferString(content)) + for scanner.Scan() { + line := scanner.Text() + // If line is empty, skip + if strings.TrimSpace(line) == "" { + continue + } + // If it starts with one or more spaces, this is an error + if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { + return fmt.Errorf("document starts with an illegal indent: %q, which may cause parsing problems", line) + } + // Any other condition passes. + return nil + } + return scanner.Err() +} + +// Validation functions +func templatesDirExists(templatesPath string) error { + _, err := os.Stat(templatesPath) + if errors.Is(err, os.ErrNotExist) { + return errors.New("directory does not exist") + } + return nil +} + +func validateTemplatesDir(templatesPath string) error { + fi, err := os.Stat(templatesPath) + if err != nil { + return err + } + if !fi.IsDir() { + return errors.New("not a directory") + } + return nil +} + +func validateAllowedExtension(fileName string) error { + ext := filepath.Ext(fileName) + validExtensions := []string{".yaml", ".yml", ".tpl", ".txt"} + + if slices.Contains(validExtensions, ext) { + return nil + } + + return fmt.Errorf("file extension '%s' not valid. Valid extensions are .yaml, .yml, .tpl, or .txt", ext) +} + +func validateYamlContent(err error) error { + if err != nil { + return fmt.Errorf("unable to parse YAML: %w", err) + } + return nil +} + +// validateMetadataName uses the correct validation function for the object +// Kind, or if not set, defaults to the standard definition of a subdomain in +// DNS (RFC 1123), used by most resources. +func validateMetadataName(obj *k8sYamlStruct) error { + fn := validateMetadataNameFunc(obj) + allErrs := field.ErrorList{} + for _, msg := range fn(obj.Metadata.Name, false) { + allErrs = append(allErrs, field.Invalid(field.NewPath("metadata").Child("name"), obj.Metadata.Name, msg)) + } + if len(allErrs) > 0 { + return fmt.Errorf("object name does not conform to Kubernetes naming requirements: %q: %w", obj.Metadata.Name, allErrs.ToAggregate()) + } + return nil +} + +// validateMetadataNameFunc will return a name validation function for the +// object kind, if defined below. +// +// Rules should match those set in the various api validations: +// https://github.com/kubernetes/kubernetes/blob/v1.20.0/pkg/apis/core/validation/validation.go#L205-L274 +// https://github.com/kubernetes/kubernetes/blob/v1.20.0/pkg/apis/apps/validation/validation.go#L39 +// ... +// +// Implementing here to avoid importing k/k. +// +// If no mapping is defined, returns NameIsDNSSubdomain. This is used by object +// kinds that don't have special requirements, so is the most likely to work if +// new kinds are added. +func validateMetadataNameFunc(obj *k8sYamlStruct) validation.ValidateNameFunc { + switch strings.ToLower(obj.Kind) { + case "pod", "node", "secret", "endpoints", "resourcequota", // core + "controllerrevision", "daemonset", "deployment", "replicaset", "statefulset", // apps + "autoscaler", // autoscaler + "cronjob", "job", // batch + "lease", // coordination + "endpointslice", // discovery + "networkpolicy", "ingress", // networking + "podsecuritypolicy", // policy + "priorityclass", // scheduling + "podpreset", // settings + "storageclass", "volumeattachment", "csinode": // storage + return validation.NameIsDNSSubdomain + case "service": + return validation.NameIsDNS1035Label + case "namespace": + return validation.ValidateNamespaceName + case "serviceaccount": + return validation.ValidateServiceAccountName + case "certificatesigningrequest": + // No validation. + // https://github.com/kubernetes/kubernetes/blob/v1.20.0/pkg/apis/certificates/validation/validation.go#L137-L140 + return func(_ string, _ bool) []string { return nil } + case "role", "clusterrole", "rolebinding", "clusterrolebinding": + // https://github.com/kubernetes/kubernetes/blob/v1.20.0/pkg/apis/rbac/validation/validation.go#L32-L34 + return func(name string, _ bool) []string { + return apipath.IsValidPathSegmentName(name) + } + default: + return validation.NameIsDNSSubdomain + } +} + +// validateMatchSelector ensures that template specs have a selector declared. +// See https://github.com/helm/helm/issues/1990 +func validateMatchSelector(yamlStruct *k8sYamlStruct, manifest string) error { + switch yamlStruct.Kind { + case "Deployment", "ReplicaSet", "DaemonSet", "StatefulSet": + // verify that matchLabels or matchExpressions is present + if !strings.Contains(manifest, "matchLabels") && !strings.Contains(manifest, "matchExpressions") { + return fmt.Errorf("a %s must contain matchLabels or matchExpressions, and %q does not", yamlStruct.Kind, yamlStruct.Metadata.Name) + } + } + return nil +} + +func validateListAnnotations(yamlStruct *k8sYamlStruct, manifest string) error { + if yamlStruct.Kind == "List" { + m := struct { + Items []struct { + Metadata struct { + Annotations map[string]string + } + } + }{} + + if err := yaml.Unmarshal([]byte(manifest), &m); err != nil { + return validateYamlContent(err) + } + + for _, i := range m.Items { + if _, ok := i.Metadata.Annotations["helm.sh/resource-policy"]; ok { + return errors.New("annotation 'helm.sh/resource-policy' within List objects are ignored") + } + } + } + return nil +} + +func isYamlFileExtension(fileName string) bool { + ext := strings.ToLower(filepath.Ext(fileName)) + return ext == ".yaml" || ext == ".yml" +} + +// k8sYamlStruct stubs a Kubernetes YAML file. +type k8sYamlStruct struct { + APIVersion string `json:"apiVersion"` + Kind string + Metadata k8sYamlMetadata +} + +type k8sYamlMetadata struct { + Namespace string + Name string +} diff --git a/pkg/helm/intern/chart/v3/lint/rules/template_test.go b/pkg/helm/intern/chart/v3/lint/rules/template_test.go new file mode 100644 index 00000000..514579c9 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/template_test.go @@ -0,0 +1,467 @@ +/* +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 rules + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" + "github.com/werf/nelm/pkg/helm/intern/chart/v3/lint/support" + chartutil "github.com/werf/nelm/pkg/helm/intern/chart/v3/util" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" +) + +const templateTestBasedir = "./testdata/albatross" + +func TestValidateAllowedExtension(t *testing.T) { + var failTest = []string{"/foo", "/test.toml"} + for _, test := range failTest { + err := validateAllowedExtension(test) + if err == nil || !strings.Contains(err.Error(), "Valid extensions are .yaml, .yml, .tpl, or .txt") { + t.Errorf("validateAllowedExtension('%s') to return \"Valid extensions are .yaml, .yml, .tpl, or .txt\", got no error", test) + } + } + var successTest = []string{"/foo.yaml", "foo.yaml", "foo.tpl", "/foo/bar/baz.yaml", "NOTES.txt"} + for _, test := range successTest { + err := validateAllowedExtension(test) + if err != nil { + t.Errorf("validateAllowedExtension('%s') to return no error but got \"%s\"", test, err.Error()) + } + } +} + +var values = map[string]interface{}{"nameOverride": "", "httpPort": 80} + +const namespace = "testNamespace" +const strict = false + +func TestTemplateParsing(t *testing.T) { + linter := support.Linter{ChartDir: templateTestBasedir} + Templates(&linter, values, namespace, strict) + res := linter.Messages + + if len(res) != 1 { + t.Fatalf("Expected one error, got %d, %v", len(res), res) + } + + if !strings.Contains(res[0].Err.Error(), "deliberateSyntaxError") { + t.Errorf("Unexpected error: %s", res[0]) + } +} + +var wrongTemplatePath = filepath.Join(templateTestBasedir, "templates", "fail.yaml") +var ignoredTemplatePath = filepath.Join(templateTestBasedir, "fail.yaml.ignored") + +// Test a template with all the existing features: +// namespaces, partial templates +func TestTemplateIntegrationHappyPath(t *testing.T) { + // Rename file so it gets ignored by the linter + os.Rename(wrongTemplatePath, ignoredTemplatePath) + defer os.Rename(ignoredTemplatePath, wrongTemplatePath) + + linter := support.Linter{ChartDir: templateTestBasedir} + Templates(&linter, values, namespace, strict) + res := linter.Messages + + if len(res) != 0 { + t.Fatalf("Expected no error, got %d, %v", len(res), res) + } +} + +func TestMultiTemplateFail(t *testing.T) { + linter := support.Linter{ChartDir: "./testdata/multi-template-fail"} + Templates(&linter, values, namespace, strict) + res := linter.Messages + + if len(res) != 1 { + t.Fatalf("Expected 1 error, got %d, %v", len(res), res) + } + + if !strings.Contains(res[0].Err.Error(), "object name does not conform to Kubernetes naming requirements") { + t.Errorf("Unexpected error: %s", res[0].Err) + } +} + +func TestValidateMetadataName(t *testing.T) { + tests := []struct { + obj *k8sYamlStruct + wantErr bool + }{ + // Most kinds use IsDNS1123Subdomain. + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: ""}}, true}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "foo.bar1234baz.seventyone"}}, false}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "FOO"}}, true}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "123baz"}}, false}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "foo.BAR.baz"}}, true}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "one-two"}}, false}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "-two"}}, true}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "one_two"}}, true}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "a..b"}}, true}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "%^&#$%*@^*@&#^"}}, true}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "operator:pod"}}, true}, + {&k8sYamlStruct{Kind: "ServiceAccount", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&k8sYamlStruct{Kind: "ServiceAccount", Metadata: k8sYamlMetadata{Name: "foo.bar1234baz.seventyone"}}, false}, + {&k8sYamlStruct{Kind: "ServiceAccount", Metadata: k8sYamlMetadata{Name: "FOO"}}, true}, + {&k8sYamlStruct{Kind: "ServiceAccount", Metadata: k8sYamlMetadata{Name: "operator:sa"}}, true}, + + // Service uses IsDNS1035Label. + {&k8sYamlStruct{Kind: "Service", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&k8sYamlStruct{Kind: "Service", Metadata: k8sYamlMetadata{Name: "123baz"}}, true}, + {&k8sYamlStruct{Kind: "Service", Metadata: k8sYamlMetadata{Name: "foo.bar"}}, true}, + + // Namespace uses IsDNS1123Label. + {&k8sYamlStruct{Kind: "Namespace", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&k8sYamlStruct{Kind: "Namespace", Metadata: k8sYamlMetadata{Name: "123baz"}}, false}, + {&k8sYamlStruct{Kind: "Namespace", Metadata: k8sYamlMetadata{Name: "foo.bar"}}, true}, + {&k8sYamlStruct{Kind: "Namespace", Metadata: k8sYamlMetadata{Name: "foo-bar"}}, false}, + + // CertificateSigningRequest has no validation. + {&k8sYamlStruct{Kind: "CertificateSigningRequest", Metadata: k8sYamlMetadata{Name: ""}}, false}, + {&k8sYamlStruct{Kind: "CertificateSigningRequest", Metadata: k8sYamlMetadata{Name: "123baz"}}, false}, + {&k8sYamlStruct{Kind: "CertificateSigningRequest", Metadata: k8sYamlMetadata{Name: "%^&#$%*@^*@&#^"}}, false}, + + // RBAC uses path validation. + {&k8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&k8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "123baz"}}, false}, + {&k8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "foo.bar"}}, false}, + {&k8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "operator:role"}}, false}, + {&k8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "operator/role"}}, true}, + {&k8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "operator%role"}}, true}, + {&k8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&k8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "123baz"}}, false}, + {&k8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "foo.bar"}}, false}, + {&k8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "operator:role"}}, false}, + {&k8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "operator/role"}}, true}, + {&k8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "operator%role"}}, true}, + {&k8sYamlStruct{Kind: "RoleBinding", Metadata: k8sYamlMetadata{Name: "operator:role"}}, false}, + {&k8sYamlStruct{Kind: "ClusterRoleBinding", Metadata: k8sYamlMetadata{Name: "operator:role"}}, false}, + + // Unknown Kind + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: ""}}, true}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "foo.bar1234baz.seventyone"}}, false}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "FOO"}}, true}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "123baz"}}, false}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "foo.BAR.baz"}}, true}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "one-two"}}, false}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "-two"}}, true}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "one_two"}}, true}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "a..b"}}, true}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "%^&#$%*@^*@&#^"}}, true}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "operator:pod"}}, true}, + + // No kind + {&k8sYamlStruct{Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&k8sYamlStruct{Metadata: k8sYamlMetadata{Name: "operator:pod"}}, true}, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("%s/%s", tt.obj.Kind, tt.obj.Metadata.Name), func(t *testing.T) { + if err := validateMetadataName(tt.obj); (err != nil) != tt.wantErr { + t.Errorf("validateMetadataName() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestDeprecatedAPIFails(t *testing.T) { + modTime := time.Now() + mychart := chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: "v2", + Name: "failapi", + Version: "0.1.0", + Icon: "satisfy-the-linting-gods.gif", + }, + Templates: []*common.File{ + { + Name: "templates/baddeployment.yaml", + ModTime: modTime, + Data: []byte("apiVersion: apps/v1beta1\nkind: Deployment\nmetadata:\n name: baddep\nspec: {selector: {matchLabels: {foo: bar}}}"), + }, + { + Name: "templates/goodsecret.yaml", + ModTime: modTime, + Data: []byte("apiVersion: v1\nkind: Secret\nmetadata:\n name: goodsecret"), + }, + }, + } + tmpdir := t.TempDir() + + if err := chartutil.SaveDir(&mychart, tmpdir); err != nil { + t.Fatal(err) + } + + linter := support.Linter{ChartDir: filepath.Join(tmpdir, mychart.Name())} + Templates(&linter, values, namespace, strict) + if l := len(linter.Messages); l != 1 { + for i, msg := range linter.Messages { + t.Logf("Message %d: %s", i, msg) + } + t.Fatalf("Expected 1 lint error, got %d", l) + } + + err := linter.Messages[0].Err.(deprecatedAPIError) + if err.Deprecated != "apps/v1beta1 Deployment" { + t.Errorf("Surprised to learn that %q is deprecated", err.Deprecated) + } +} + +const manifest = `apiVersion: v1 +kind: ConfigMap +metadata: + name: foo +data: + myval1: {{default "val" .Values.mymap.key1 }} + myval2: {{default "val" .Values.mymap.key2 }} +` + +// TestStrictTemplateParsingMapError is a regression test. +// +// The template engine should not produce an error when a map in values.yaml does +// not contain all possible keys. +// +// See https://github.com/helm/helm/issues/7483 +func TestStrictTemplateParsingMapError(t *testing.T) { + + ch := chart.Chart{ + Metadata: &chart.Metadata{ + Name: "regression7483", + APIVersion: "v2", + Version: "0.1.0", + }, + Values: map[string]interface{}{ + "mymap": map[string]string{ + "key1": "val1", + }, + }, + Templates: []*common.File{ + { + Name: "templates/configmap.yaml", + ModTime: time.Now(), + Data: []byte(manifest), + }, + }, + } + dir := t.TempDir() + if err := chartutil.SaveDir(&ch, dir); err != nil { + t.Fatal(err) + } + linter := &support.Linter{ + ChartDir: filepath.Join(dir, ch.Metadata.Name), + } + Templates(linter, ch.Values, namespace, strict) + if len(linter.Messages) != 0 { + t.Errorf("expected zero messages, got %d", len(linter.Messages)) + for i, msg := range linter.Messages { + t.Logf("Message %d: %q", i, msg) + } + } +} + +func TestValidateMatchSelector(t *testing.T) { + md := &k8sYamlStruct{ + APIVersion: "apps/v1", + Kind: "Deployment", + Metadata: k8sYamlMetadata{ + Name: "mydeployment", + }, + } + manifest := ` + apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 3 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ` + if err := validateMatchSelector(md, manifest); err != nil { + t.Error(err) + } + manifest = ` + apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 3 + selector: + matchExpressions: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ` + if err := validateMatchSelector(md, manifest); err != nil { + t.Error(err) + } + manifest = ` + apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 3 + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ` + if err := validateMatchSelector(md, manifest); err == nil { + t.Error("expected Deployment with no selector to fail") + } +} + +func TestValidateTopIndentLevel(t *testing.T) { + for doc, shouldFail := range map[string]bool{ + // Should not fail + "\n\n\n\t\n \t\n": false, + "apiVersion:foo\n bar:baz": false, + "\n\n\napiVersion:foo\n\n\n": false, + // Should fail + " apiVersion:foo": true, + "\n\n apiVersion:foo\n\n": true, + } { + if err := validateTopIndentLevel(doc); (err == nil) == shouldFail { + t.Errorf("Expected %t for %q", shouldFail, doc) + } + } + +} + +// TestEmptyWithCommentsManifests checks the lint is not failing against empty manifests that contains only comments +// See https://github.com/helm/helm/issues/8621 +func TestEmptyWithCommentsManifests(t *testing.T) { + mychart := chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: "v2", + Name: "emptymanifests", + Version: "0.1.0", + Icon: "satisfy-the-linting-gods.gif", + }, + Templates: []*common.File{ + { + Name: "templates/empty-with-comments.yaml", + ModTime: time.Now(), + Data: []byte("#@formatter:off\n"), + }, + }, + } + tmpdir := t.TempDir() + + if err := chartutil.SaveDir(&mychart, tmpdir); err != nil { + t.Fatal(err) + } + + linter := support.Linter{ChartDir: filepath.Join(tmpdir, mychart.Name())} + Templates(&linter, values, namespace, strict) + if l := len(linter.Messages); l > 0 { + for i, msg := range linter.Messages { + t.Logf("Message %d: %s", i, msg) + } + t.Fatalf("Expected 0 lint errors, got %d", l) + } +} +func TestValidateListAnnotations(t *testing.T) { + md := &k8sYamlStruct{ + APIVersion: "v1", + Kind: "List", + Metadata: k8sYamlMetadata{ + Name: "list", + }, + } + manifest := ` +apiVersion: v1 +kind: List +items: + - apiVersion: v1 + kind: ConfigMap + metadata: + annotations: + helm.sh/resource-policy: keep +` + + if err := validateListAnnotations(md, manifest); err == nil { + t.Fatal("expected list with nested keep annotations to fail") + } + + manifest = ` +apiVersion: v1 +kind: List +metadata: + annotations: + helm.sh/resource-policy: keep +items: + - apiVersion: v1 + kind: ConfigMap +` + + if err := validateListAnnotations(md, manifest); err != nil { + t.Fatalf("List objects keep annotations should pass. got: %s", err) + } +} + +func TestIsYamlFileExtension(t *testing.T) { + tests := []struct { + filename string + expected bool + }{ + {"test.yaml", true}, + {"test.yml", true}, + {"test.txt", false}, + {"test", false}, + } + + for _, test := range tests { + result := isYamlFileExtension(test.filename) + if result != test.expected { + t.Errorf("isYamlFileExtension(%s) = %v; want %v", test.filename, result, test.expected) + } + } + +} diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/albatross/Chart.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/albatross/Chart.yaml new file mode 100644 index 00000000..5e1ed515 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/albatross/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: albatross +description: testing chart +version: 199.44.12345-Alpha.1+cafe009 +icon: http://riverrun.io diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/albatross/templates/_helpers.tpl b/pkg/helm/intern/chart/v3/lint/rules/testdata/albatross/templates/_helpers.tpl new file mode 100644 index 00000000..24f76db7 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/albatross/templates/_helpers.tpl @@ -0,0 +1,16 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{define "name"}}{{default "nginx" .Values.nameOverride | trunc 63 | trimSuffix "-" }}{{end}} + +{{/* +Create a default fully qualified app name. + +We truncate at 63 chars because some Kubernetes name fields are limited to this +(by the DNS naming spec). +*/}} +{{define "fullname"}} +{{- $name := default "nginx" .Values.nameOverride -}} +{{printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{end}} diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/albatross/templates/fail.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/albatross/templates/fail.yaml new file mode 100644 index 00000000..a11e0e90 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/albatross/templates/fail.yaml @@ -0,0 +1 @@ +{{ deliberateSyntaxError }} diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/albatross/templates/svc.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/albatross/templates/svc.yaml new file mode 100644 index 00000000..16bb27d5 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/albatross/templates/svc.yaml @@ -0,0 +1,19 @@ +# This is a service gateway to the replica set created by the deployment. +# Take a look at the deployment.yaml for general notes about this chart. +apiVersion: v1 +kind: Service +metadata: + name: "{{ .Values.name }}" + labels: + app.kubernetes.io/managed-by: {{ .Release.Service | quote }} + app.kubernetes.io/instance: {{ .Release.Name | quote }} + helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}" + kubeVersion: {{ .Capabilities.KubeVersion.Major }} +spec: + ports: + - port: {{default 80 .Values.httpPort | quote}} + targetPort: 80 + protocol: TCP + name: http + selector: + app.kubernetes.io/name: {{template "fullname" .}} diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/albatross/values.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/albatross/values.yaml new file mode 100644 index 00000000..74cc6a0d --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/albatross/values.yaml @@ -0,0 +1 @@ +name: "mariner" diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/anotherbadchartfile/Chart.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/anotherbadchartfile/Chart.yaml new file mode 100644 index 00000000..8a598473 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/anotherbadchartfile/Chart.yaml @@ -0,0 +1,15 @@ +name: "some-chart" +apiVersion: v3 +description: A Helm chart for Kubernetes +version: 72445e2 +home: "" +type: application +appVersion: 72225e2 +icon: "https://some-url.com/icon.jpeg" +dependencies: + - name: mariadb + version: 5.x.x + repository: https://charts.helm.sh/stable/ + condition: mariadb.enabled + tags: + - database diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/badchartfile/Chart.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/badchartfile/Chart.yaml new file mode 100644 index 00000000..3564ede3 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/badchartfile/Chart.yaml @@ -0,0 +1,11 @@ +description: A Helm chart for Kubernetes +version: 0.0.0.0 +home: "" +type: application +dependencies: +- name: mariadb + version: 5.x.x + repository: https://charts.helm.sh/stable/ + condition: mariadb.enabled + tags: + - database diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/badchartfile/values.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/badchartfile/values.yaml new file mode 100644 index 00000000..9f367033 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/badchartfile/values.yaml @@ -0,0 +1 @@ +# Default values for badchartfile. diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/badchartname/Chart.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/badchartname/Chart.yaml new file mode 100644 index 00000000..41f45235 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/badchartname/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +description: A Helm chart for Kubernetes +version: 0.1.0 +name: "../badchartname" +type: application diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/badchartname/values.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/badchartname/values.yaml new file mode 100644 index 00000000..9f367033 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/badchartname/values.yaml @@ -0,0 +1 @@ +# Default values for badchartfile. diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/badcrdfile/Chart.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/badcrdfile/Chart.yaml new file mode 100644 index 00000000..3bf00739 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/badcrdfile/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v3 +description: A Helm chart for Kubernetes +version: 0.1.0 +name: badcrdfile +type: application +icon: http://riverrun.io diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/badcrdfile/crds/bad-apiversion.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/badcrdfile/crds/bad-apiversion.yaml new file mode 100644 index 00000000..46891605 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/badcrdfile/crds/bad-apiversion.yaml @@ -0,0 +1,2 @@ +apiVersion: bad.k8s.io/v1beta1 +kind: CustomResourceDefinition diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/badcrdfile/crds/bad-crd.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/badcrdfile/crds/bad-crd.yaml new file mode 100644 index 00000000..523b97f8 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/badcrdfile/crds/bad-crd.yaml @@ -0,0 +1,2 @@ +apiVersion: apiextensions.k8s.io/v1beta1 +kind: NotACustomResourceDefinition diff --git a/pkg/helm/pkg/chartutil/testdata/joonix/charts/.gitkeep b/pkg/helm/intern/chart/v3/lint/rules/testdata/badcrdfile/templates/.gitkeep similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/joonix/charts/.gitkeep rename to pkg/helm/intern/chart/v3/lint/rules/testdata/badcrdfile/templates/.gitkeep diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/badcrdfile/values.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/badcrdfile/values.yaml new file mode 100644 index 00000000..2fffc771 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/badcrdfile/values.yaml @@ -0,0 +1 @@ +# Default values for badcrdfile. diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/badvaluesfile/Chart.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/badvaluesfile/Chart.yaml new file mode 100644 index 00000000..aace27e2 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/badvaluesfile/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v3 +name: badvaluesfile +description: A Helm chart for Kubernetes +version: 0.0.1 +home: "" +icon: http://riverrun.io diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/badvaluesfile/templates/badvaluesfile.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/badvaluesfile/templates/badvaluesfile.yaml new file mode 100644 index 00000000..6c2ceb8d --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/badvaluesfile/templates/badvaluesfile.yaml @@ -0,0 +1,2 @@ +metadata: + name: {{.name | default "foo" | title}} diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/badvaluesfile/values.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/badvaluesfile/values.yaml new file mode 100644 index 00000000..b5a10271 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/badvaluesfile/values.yaml @@ -0,0 +1,2 @@ +# Invalid value for badvaluesfile for testing lint fails with invalid yaml format +name= "value" diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/goodone/Chart.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/goodone/Chart.yaml new file mode 100644 index 00000000..bf8f5e30 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/goodone/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: goodone +description: good testing chart +version: 199.44.12345-Alpha.1+cafe009 +icon: http://riverrun.io diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-only-crds/crds/test-crd.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/goodone/crds/test-crd.yaml similarity index 87% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-only-crds/crds/test-crd.yaml rename to pkg/helm/intern/chart/v3/lint/rules/testdata/goodone/crds/test-crd.yaml index 54a55e41..1d7350f1 100644 --- a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-only-crds/crds/test-crd.yaml +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/goodone/crds/test-crd.yaml @@ -11,9 +11,9 @@ spec: singular: test scope: Namespaced versions: - - name: v1alpha2 + - name : v1alpha2 served: true storage: true - - name: v1alpha1 + - name : v1alpha1 served: true storage: false diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/goodone/templates/goodone.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/goodone/templates/goodone.yaml new file mode 100644 index 00000000..cd46f62c --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/goodone/templates/goodone.yaml @@ -0,0 +1,2 @@ +metadata: + name: {{ .Values.name | default "foo" | lower }} diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/goodone/values.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/goodone/values.yaml new file mode 100644 index 00000000..92c3d9bb --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/goodone/values.yaml @@ -0,0 +1 @@ +name: "goodone-here" diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/invalidchartfile/Chart.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/invalidchartfile/Chart.yaml new file mode 100644 index 00000000..0fd58d1d --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/invalidchartfile/Chart.yaml @@ -0,0 +1,6 @@ +name: some-chart +apiVersion: v2 +apiVersion: v1 +description: A Helm chart for Kubernetes +version: 1.3.0 +icon: http://example.com diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart/values.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/invalidchartfile/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart/values.yaml rename to pkg/helm/intern/chart/v3/lint/rules/testdata/invalidchartfile/values.yaml diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/invalidcrdsdir/Chart.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/invalidcrdsdir/Chart.yaml new file mode 100644 index 00000000..0f6d1ee9 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/invalidcrdsdir/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v3 +description: A Helm chart for Kubernetes +version: 0.1.0 +name: invalidcrdsdir +type: application +icon: http://riverrun.io diff --git a/pkg/helm/cmd/helm/testdata/helm home with space/helm/repository/test-name-charts.txt b/pkg/helm/intern/chart/v3/lint/rules/testdata/invalidcrdsdir/crds similarity index 100% rename from pkg/helm/cmd/helm/testdata/helm home with space/helm/repository/test-name-charts.txt rename to pkg/helm/intern/chart/v3/lint/rules/testdata/invalidcrdsdir/crds diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/invalidcrdsdir/values.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/invalidcrdsdir/values.yaml new file mode 100644 index 00000000..6b1611a6 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/invalidcrdsdir/values.yaml @@ -0,0 +1 @@ +# Default values for invalidcrdsdir. diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-only-crds/.helmignore b/pkg/helm/intern/chart/v3/lint/rules/testdata/malformed-template/.helmignore similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-only-crds/.helmignore rename to pkg/helm/intern/chart/v3/lint/rules/testdata/malformed-template/.helmignore diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/malformed-template/Chart.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/malformed-template/Chart.yaml new file mode 100644 index 00000000..d46b98cb --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/malformed-template/Chart.yaml @@ -0,0 +1,25 @@ +apiVersion: v3 +name: test +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.16.0" +icon: https://riverrun.io \ No newline at end of file diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/malformed-template/templates/bad.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/malformed-template/templates/bad.yaml new file mode 100644 index 00000000..213198fd --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/malformed-template/templates/bad.yaml @@ -0,0 +1 @@ +{ {- $relname := .Release.Name -}} diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/malformed-template/values.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/malformed-template/values.yaml new file mode 100644 index 00000000..1cc3182e --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/malformed-template/values.yaml @@ -0,0 +1,82 @@ +# Default values for test. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: nginx + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +podAnnotations: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/multi-template-fail/Chart.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/multi-template-fail/Chart.yaml new file mode 100644 index 00000000..bfb580be --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/multi-template-fail/Chart.yaml @@ -0,0 +1,21 @@ +apiVersion: v3 +name: multi-template-fail +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application and it is recommended to use it with quotes. +appVersion: "1.16.0" diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/multi-template-fail/templates/multi-fail.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/multi-template-fail/templates/multi-fail.yaml new file mode 100644 index 00000000..835be07b --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/multi-template-fail/templates/multi-fail.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: game-config +data: + game.properties: cheat +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: -this:name-is-not_valid$ +data: + game.properties: empty diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/v3-fail/Chart.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/v3-fail/Chart.yaml new file mode 100644 index 00000000..2a29c33f --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/v3-fail/Chart.yaml @@ -0,0 +1,21 @@ +apiVersion: v3 +name: v3-fail +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application and it is recommended to use it with quotes. +appVersion: "1.16.0" diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/v3-fail/templates/_helpers.tpl b/pkg/helm/intern/chart/v3/lint/rules/testdata/v3-fail/templates/_helpers.tpl new file mode 100644 index 00000000..0b89e723 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/v3-fail/templates/_helpers.tpl @@ -0,0 +1,63 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "v3-fail.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "v3-fail.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "v3-fail.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Common labels +*/}} +{{- define "v3-fail.labels" -}} +helm.sh/chart: {{ include "v3-fail.chart" . }} +{{ include "v3-fail.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{/* +Selector labels +*/}} +{{- define "v3-fail.selectorLabels" -}} +app.kubernetes.io/name: {{ include "v3-fail.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{/* +Create the name of the service account to use +*/}} +{{- define "v3-fail.serviceAccountName" -}} +{{- if .Values.serviceAccount.create -}} + {{ default (include "v3-fail.fullname" .) .Values.serviceAccount.name }} +{{- else -}} + {{ default "default" .Values.serviceAccount.name }} +{{- end -}} +{{- end -}} diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/v3-fail/templates/deployment.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/v3-fail/templates/deployment.yaml new file mode 100644 index 00000000..6d651ab8 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/v3-fail/templates/deployment.yaml @@ -0,0 +1,56 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "v3-fail.fullname" . }} + labels: + nope: {{ .Release.Time }} + {{- include "v3-fail.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "v3-fail.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "v3-fail.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "v3-fail.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 80 + protocol: TCP + livenessProbe: + httpGet: + path: / + port: http + readinessProbe: + httpGet: + path: / + port: http + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/v3-fail/templates/ingress.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/v3-fail/templates/ingress.yaml new file mode 100644 index 00000000..4790650d --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/v3-fail/templates/ingress.yaml @@ -0,0 +1,62 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "v3-fail.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} + {{- end }} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "v3-fail.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + "helm.sh/hook": crd-install + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} + pathType: {{ .pathType }} + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ $fullName }} + port: + number: {{ $svcPort }} + {{- else }} + serviceName: {{ $fullName }} + servicePort: {{ $svcPort }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/v3-fail/templates/service.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/v3-fail/templates/service.yaml new file mode 100644 index 00000000..79a0f40b --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/v3-fail/templates/service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "v3-fail.fullname" . }} + annotations: + helm.sh/hook: crd-install + labels: + {{- include "v3-fail.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "v3-fail.selectorLabels" . | nindent 4 }} diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/v3-fail/values.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/v3-fail/values.yaml new file mode 100644 index 00000000..01d99b4e --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/v3-fail/values.yaml @@ -0,0 +1,66 @@ +# Default values for v3-fail. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: nginx + pullPolicy: IfNotPresent + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: false + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: [] + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/withsubchart/Chart.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/withsubchart/Chart.yaml new file mode 100644 index 00000000..fa15eaba --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/withsubchart/Chart.yaml @@ -0,0 +1,16 @@ +apiVersion: v3 +name: withsubchart +description: A Helm chart for Kubernetes +type: application +version: 0.1.0 +appVersion: "1.16.0" +icon: http://riverrun.io + +dependencies: + - name: subchart + version: 0.1.16 + repository: "file://../subchart" + import-values: + - child: subchart + parent: subchart + diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/withsubchart/charts/subchart/Chart.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/withsubchart/charts/subchart/Chart.yaml new file mode 100644 index 00000000..35b13e70 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/withsubchart/charts/subchart/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v3 +name: subchart +description: A Helm chart for Kubernetes +type: application +version: 0.1.0 +appVersion: "1.16.0" diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/withsubchart/charts/subchart/templates/subchart.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/withsubchart/charts/subchart/templates/subchart.yaml new file mode 100644 index 00000000..6cb6cc2a --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/withsubchart/charts/subchart/templates/subchart.yaml @@ -0,0 +1,2 @@ +metadata: + name: {{ .Values.subchart.name | lower }} diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/withsubchart/charts/subchart/values.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/withsubchart/charts/subchart/values.yaml new file mode 100644 index 00000000..422a359d --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/withsubchart/charts/subchart/values.yaml @@ -0,0 +1,2 @@ +subchart: + name: subchart \ No newline at end of file diff --git a/pkg/helm/intern/chart/v3/lint/rules/testdata/withsubchart/templates/mainchart.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/withsubchart/templates/mainchart.yaml new file mode 100644 index 00000000..6cb6cc2a --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/testdata/withsubchart/templates/mainchart.yaml @@ -0,0 +1,2 @@ +metadata: + name: {{ .Values.subchart.name | lower }} diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/values.yaml b/pkg/helm/intern/chart/v3/lint/rules/testdata/withsubchart/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/values.yaml rename to pkg/helm/intern/chart/v3/lint/rules/testdata/withsubchart/values.yaml diff --git a/pkg/helm/intern/chart/v3/lint/rules/values.go b/pkg/helm/intern/chart/v3/lint/rules/values.go new file mode 100644 index 00000000..ae00a2d6 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/values.go @@ -0,0 +1,84 @@ +/* +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 rules + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/werf/nelm/pkg/helm/intern/chart/v3/lint/support" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + "github.com/werf/nelm/pkg/helm/pkg/chart/common/util" +) + +// ValuesWithOverrides tests the values.yaml file. +// +// If a schema is present in the chart, values are tested against that. Otherwise, +// they are only tested for well-formedness. +// +// If additional values are supplied, they are coalesced into the values in values.yaml. +func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]interface{}, skipSchemaValidation bool) { + file := "values.yaml" + vf := filepath.Join(linter.ChartDir, file) + fileExists := linter.RunLinterRule(support.InfoSev, file, validateValuesFileExistence(vf)) + + if !fileExists { + return + } + + linter.RunLinterRule(support.ErrorSev, file, validateValuesFile(vf, valueOverrides, skipSchemaValidation)) +} + +func validateValuesFileExistence(valuesPath string) error { + _, err := os.Stat(valuesPath) + if err != nil { + return fmt.Errorf("file does not exist") + } + return nil +} + +func validateValuesFile(valuesPath string, overrides map[string]interface{}, skipSchemaValidation bool) error { + values, err := common.ReadValuesFile(valuesPath) + if err != nil { + return fmt.Errorf("unable to parse YAML: %w", err) + } + + // Helm 3.0.0 carried over the values linting from Helm 2.x, which only tests the top + // level values against the top-level expectations. Subchart values are not linted. + // We could change that. For now, though, we retain that strategy, and thus can + // coalesce tables (like reuse-values does) instead of doing the full chart + // CoalesceValues + coalescedValues := util.CoalesceTables(make(map[string]interface{}, len(overrides)), overrides) + coalescedValues = util.CoalesceTables(coalescedValues, values) + + ext := filepath.Ext(valuesPath) + schemaPath := valuesPath[:len(valuesPath)-len(ext)] + ".schema.json" + schema, err := os.ReadFile(schemaPath) + if len(schema) == 0 { + return nil + } + if err != nil { + return err + } + + if !skipSchemaValidation { + return util.ValidateAgainstSingleSchema(coalescedValues, schema) + } + + return nil +} diff --git a/pkg/helm/intern/chart/v3/lint/rules/values_test.go b/pkg/helm/intern/chart/v3/lint/rules/values_test.go new file mode 100644 index 00000000..a2a5345d --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/rules/values_test.go @@ -0,0 +1,183 @@ +/* +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 rules + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/werf/nelm/pkg/helm/intern/test/ensure" +) + +var nonExistingValuesFilePath = filepath.Join("/fake/dir", "values.yaml") + +const testSchema = ` +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "helm values test schema", + "type": "object", + "additionalProperties": false, + "required": [ + "username", + "password" + ], + "properties": { + "username": { + "description": "Your username", + "type": "string" + }, + "password": { + "description": "Your password", + "type": "string" + } + } +} +` + +func TestValidateValuesYamlNotDirectory(t *testing.T) { + _ = os.Mkdir(nonExistingValuesFilePath, os.ModePerm) + defer os.Remove(nonExistingValuesFilePath) + + err := validateValuesFileExistence(nonExistingValuesFilePath) + if err == nil { + t.Errorf("validateValuesFileExistence to return a linter error, got no error") + } +} + +func TestValidateValuesFileWellFormed(t *testing.T) { + badYaml := ` + not:well[]{}formed + ` + tmpdir := ensure.TempFile(t, "values.yaml", []byte(badYaml)) + valfile := filepath.Join(tmpdir, "values.yaml") + if err := validateValuesFile(valfile, map[string]interface{}{}, false); err == nil { + t.Fatal("expected values file to fail parsing") + } +} + +func TestValidateValuesFileSchema(t *testing.T) { + yaml := "username: admin\npassword: swordfish" + tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) + createTestingSchema(t, tmpdir) + + valfile := filepath.Join(tmpdir, "values.yaml") + if err := validateValuesFile(valfile, map[string]interface{}{}, false); err != nil { + t.Fatalf("Failed validation with %s", err) + } +} + +func TestValidateValuesFileSchemaFailure(t *testing.T) { + // 1234 is an int, not a string. This should fail. + yaml := "username: 1234\npassword: swordfish" + tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) + createTestingSchema(t, tmpdir) + + valfile := filepath.Join(tmpdir, "values.yaml") + + err := validateValuesFile(valfile, map[string]interface{}{}, false) + if err == nil { + t.Fatal("expected values file to fail parsing") + } + + assert.Contains(t, err.Error(), "- at '/username': got number, want string") +} + +func TestValidateValuesFileSchemaFailureButWithSkipSchemaValidation(t *testing.T) { + // 1234 is an int, not a string. This should fail normally but pass with skipSchemaValidation. + yaml := "username: 1234\npassword: swordfish" + tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) + createTestingSchema(t, tmpdir) + + valfile := filepath.Join(tmpdir, "values.yaml") + + err := validateValuesFile(valfile, map[string]interface{}{}, true) + if err != nil { + t.Fatal("expected values file to pass parsing because of skipSchemaValidation") + } +} + +func TestValidateValuesFileSchemaOverrides(t *testing.T) { + yaml := "username: admin" + overrides := map[string]interface{}{ + "password": "swordfish", + } + tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) + createTestingSchema(t, tmpdir) + + valfile := filepath.Join(tmpdir, "values.yaml") + if err := validateValuesFile(valfile, overrides, false); err != nil { + t.Fatalf("Failed validation with %s", err) + } +} + +func TestValidateValuesFile(t *testing.T) { + tests := []struct { + name string + yaml string + overrides map[string]interface{} + errorMessage string + }{ + { + name: "value added", + yaml: "username: admin", + overrides: map[string]interface{}{"password": "swordfish"}, + }, + { + name: "value not overridden", + yaml: "username: admin\npassword:", + overrides: map[string]interface{}{"username": "anotherUser"}, + errorMessage: "- at '/password': got null, want string", + }, + { + name: "value overridden", + yaml: "username: admin\npassword:", + overrides: map[string]interface{}{"username": "anotherUser", "password": "swordfish"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpdir := ensure.TempFile(t, "values.yaml", []byte(tt.yaml)) + createTestingSchema(t, tmpdir) + + valfile := filepath.Join(tmpdir, "values.yaml") + + err := validateValuesFile(valfile, tt.overrides, false) + + switch { + case err != nil && tt.errorMessage == "": + t.Errorf("Failed validation with %s", err) + case err == nil && tt.errorMessage != "": + t.Error("expected values file to fail parsing") + case err != nil && tt.errorMessage != "": + assert.Contains(t, err.Error(), tt.errorMessage, "Failed with unexpected error") + } + }) + } +} + +func createTestingSchema(t *testing.T, dir string) string { + t.Helper() + schemafile := filepath.Join(dir, "values.schema.json") + if err := os.WriteFile(schemafile, []byte(testSchema), 0700); err != nil { + t.Fatalf("Failed to write schema to tmpdir: %s", err) + } + return schemafile +} diff --git a/pkg/helm/intern/chart/v3/lint/support/doc.go b/pkg/helm/intern/chart/v3/lint/support/doc.go new file mode 100644 index 00000000..04446bb5 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/support/doc.go @@ -0,0 +1,23 @@ +/* +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 support contains tools for linting charts. + +Linting is the process of testing charts for errors or warnings regarding +formatting, compilation, or standards compliance. +*/ +package support // import "github.com/werf/nelm/pkg/helm/intern/chart/v3/lint/support" diff --git a/pkg/helm/intern/chart/v3/lint/support/message.go b/pkg/helm/intern/chart/v3/lint/support/message.go new file mode 100644 index 00000000..5efbc7a6 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/support/message.go @@ -0,0 +1,76 @@ +/* +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 support + +import "fmt" + +// Severity indicates the severity of a Message. +const ( + // UnknownSev indicates that the severity of the error is unknown, and should not stop processing. + UnknownSev = iota + // InfoSev indicates information, for example missing values.yaml file + InfoSev + // WarningSev indicates that something does not meet code standards, but will likely function. + WarningSev + // ErrorSev indicates that something will not likely function. + ErrorSev +) + +// sev matches the *Sev states. +var sev = []string{"UNKNOWN", "INFO", "WARNING", "ERROR"} + +// Linter encapsulates a linting run of a particular chart. +type Linter struct { + Messages []Message + // The highest severity of all the failing lint rules + HighestSeverity int + ChartDir string +} + +// Message describes an error encountered while linting. +type Message struct { + // Severity is one of the *Sev constants + Severity int + Path string + Err error +} + +func (m Message) Error() string { + return fmt.Sprintf("[%s] %s: %s", sev[m.Severity], m.Path, m.Err.Error()) +} + +// NewMessage creates a new Message struct +func NewMessage(severity int, path string, err error) Message { + return Message{Severity: severity, Path: path, Err: err} +} + +// RunLinterRule returns true if the validation passed +func (l *Linter) RunLinterRule(severity int, path string, err error) bool { + // severity is out of bound + if severity < 0 || severity >= len(sev) { + return false + } + + if err != nil { + l.Messages = append(l.Messages, NewMessage(severity, path, err)) + + if severity > l.HighestSeverity { + l.HighestSeverity = severity + } + } + return err == nil +} diff --git a/pkg/helm/intern/chart/v3/lint/support/message_test.go b/pkg/helm/intern/chart/v3/lint/support/message_test.go new file mode 100644 index 00000000..ce5b5e42 --- /dev/null +++ b/pkg/helm/intern/chart/v3/lint/support/message_test.go @@ -0,0 +1,79 @@ +/* +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 support + +import ( + "errors" + "testing" +) + +var errLint = errors.New("lint failed") + +func TestRunLinterRule(t *testing.T) { + var tests = []struct { + Severity int + LintError error + ExpectedMessages int + ExpectedReturn bool + ExpectedHighestSeverity int + }{ + {InfoSev, errLint, 1, false, InfoSev}, + {WarningSev, errLint, 2, false, WarningSev}, + {ErrorSev, errLint, 3, false, ErrorSev}, + // No error so it returns true + {ErrorSev, nil, 3, true, ErrorSev}, + // Retains highest severity + {InfoSev, errLint, 4, false, ErrorSev}, + // Invalid severity values + {4, errLint, 4, false, ErrorSev}, + {22, errLint, 4, false, ErrorSev}, + {-1, errLint, 4, false, ErrorSev}, + } + + linter := Linter{} + for _, test := range tests { + isValid := linter.RunLinterRule(test.Severity, "chart", test.LintError) + if len(linter.Messages) != test.ExpectedMessages { + t.Errorf("RunLinterRule(%d, \"chart\", %v), linter.Messages should now have %d message, we got %d", test.Severity, test.LintError, test.ExpectedMessages, len(linter.Messages)) + } + + if linter.HighestSeverity != test.ExpectedHighestSeverity { + t.Errorf("RunLinterRule(%d, \"chart\", %v), linter.HighestSeverity should be %d, we got %d", test.Severity, test.LintError, test.ExpectedHighestSeverity, linter.HighestSeverity) + } + + if isValid != test.ExpectedReturn { + t.Errorf("RunLinterRule(%d, \"chart\", %v), should have returned %t but returned %t", test.Severity, test.LintError, test.ExpectedReturn, isValid) + } + } +} + +func TestMessage(t *testing.T) { + m := Message{ErrorSev, "Chart.yaml", errors.New("Foo")} + if m.Error() != "[ERROR] Chart.yaml: Foo" { + t.Errorf("Unexpected output: %s", m.Error()) + } + + m = Message{WarningSev, "templates/", errors.New("Bar")} + if m.Error() != "[WARNING] templates/: Bar" { + t.Errorf("Unexpected output: %s", m.Error()) + } + + m = Message{InfoSev, "templates/rc.yaml", errors.New("FooBar")} + if m.Error() != "[INFO] templates/rc.yaml: FooBar" { + t.Errorf("Unexpected output: %s", m.Error()) + } +} diff --git a/pkg/helm/intern/chart/v3/loader/archive.go b/pkg/helm/intern/chart/v3/loader/archive.go new file mode 100644 index 00000000..b097c84d --- /dev/null +++ b/pkg/helm/intern/chart/v3/loader/archive.go @@ -0,0 +1,75 @@ +/* +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 loader + +import ( + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "os" + + chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" + "github.com/werf/nelm/pkg/helm/pkg/chart/loader/archive" +) + +// FileLoader loads a chart from a file +type FileLoader string + +// Load loads a chart +func (l FileLoader) Load(ctx context.Context) (*chart.Chart, error) { + return LoadFile(ctx, string(l)) +} + +// LoadFile loads from an archive file. +func LoadFile(ctx context.Context, name string) (*chart.Chart, error) { + if fi, err := os.Stat(name); err != nil { + return nil, err + } else if fi.IsDir() { + return nil, errors.New("cannot load a directory") + } + + raw, err := os.Open(name) + if err != nil { + return nil, err + } + defer raw.Close() + + err = archive.EnsureArchive(name, raw) + if err != nil { + return nil, err + } + + c, err := LoadArchive(ctx, raw) + if err != nil { + if errors.Is(err, gzip.ErrHeader) { + return nil, fmt.Errorf("file '%s' does not appear to be a valid chart file (details: %s)", name, err) + } + } + return c, err +} + +// LoadArchive loads from a reader containing a compressed tar archive. +func LoadArchive(ctx context.Context, in io.Reader) (*chart.Chart, error) { + files, err := archive.LoadArchiveFiles(in) + if err != nil { + return nil, err + } + + return LoadFiles(ctx, files) +} diff --git a/pkg/helm/intern/chart/v3/loader/chart_metadata.go b/pkg/helm/intern/chart/v3/loader/chart_metadata.go new file mode 100644 index 00000000..04d8f3f1 --- /dev/null +++ b/pkg/helm/intern/chart/v3/loader/chart_metadata.go @@ -0,0 +1,37 @@ +package loader + +import chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" + +type autosetChartMetadataOptions struct { + OverrideAppVersion string + DefaultAPIVersion string + DefaultName string + DefaultVersion string +} + +func autosetChartMetadata(metadataIn *chart.Metadata, opts autosetChartMetadataOptions) *chart.Metadata { + var metadata *chart.Metadata + if metadataIn == nil { + metadata = &chart.Metadata{} + } else { + metadata = metadataIn + } + + if metadata.APIVersion == "" { + metadata.APIVersion = opts.DefaultAPIVersion + } + + if metadata.Name == "" { + metadata.Name = opts.DefaultName + } + + if opts.OverrideAppVersion != "" { + metadata.AppVersion = opts.OverrideAppVersion + } + + if metadata.Version == "" { + metadata.Version = opts.DefaultVersion + } + + return metadata +} diff --git a/pkg/helm/intern/chart/v3/loader/directory.go b/pkg/helm/intern/chart/v3/loader/directory.go new file mode 100644 index 00000000..85ce4800 --- /dev/null +++ b/pkg/helm/intern/chart/v3/loader/directory.go @@ -0,0 +1,123 @@ +/* +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 loader + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" + + chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" + "github.com/werf/nelm/pkg/helm/intern/sympath" + "github.com/werf/nelm/pkg/helm/pkg/chart/loader/archive" + "github.com/werf/nelm/pkg/helm/pkg/ignore" +) + +var utf8bom = []byte{0xEF, 0xBB, 0xBF} + +// DirLoader loads a chart from a directory +type DirLoader string + +// Load loads the chart +func (l DirLoader) Load(ctx context.Context) (*chart.Chart, error) { + return LoadDir(ctx, string(l)) +} + +// LoadDir loads from a directory. +// +// This loads charts only from directories. +func LoadDir(ctx context.Context, dir string) (*chart.Chart, error) { + topdir, err := filepath.Abs(dir) + if err != nil { + return nil, err + } + + // Just used for errors. + c := &chart.Chart{} + + rules := ignore.Empty() + ifile := filepath.Join(topdir, ignore.HelmIgnore) + if _, err := os.Stat(ifile); err == nil { + r, err := ignore.ParseFile(ifile) + if err != nil { + return c, err + } + rules = r + } + rules.AddDefaults() + + files := []*archive.BufferedFile{} + topdir += string(filepath.Separator) + + walk := func(name string, fi os.FileInfo, err error) error { + n := strings.TrimPrefix(name, topdir) + if n == "" { + // No need to process top level. Avoid bug with helmignore .* matching + // empty names. See issue 1779. + return nil + } + + // Normalize to / since it will also work on Windows + n = filepath.ToSlash(n) + + if err != nil { + return err + } + if fi.IsDir() { + // Directory-based ignore rules should involve skipping the entire + // contents of that directory. + if rules.Ignore(n, fi) { + return filepath.SkipDir + } + return nil + } + + // If a .helmignore file matches, skip this file. + if rules.Ignore(n, fi) { + return nil + } + + // Irregular files include devices, sockets, and other uses of files that + // are not regular files. In Go they have a file mode type bit set. + // See https://golang.org/pkg/os/#FileMode for examples. + if !fi.Mode().IsRegular() { + return fmt.Errorf("cannot load irregular file %s as it has file mode type bits set", name) + } + + if fi.Size() > archive.MaxDecompressedFileSize { + return fmt.Errorf("chart file %q is larger than the maximum file size %d", fi.Name(), archive.MaxDecompressedFileSize) + } + + data, err := os.ReadFile(name) + if err != nil { + return fmt.Errorf("error reading %s: %w", n, err) + } + + data = bytes.TrimPrefix(data, utf8bom) + + files = append(files, &archive.BufferedFile{Name: n, ModTime: fi.ModTime(), Data: data}) + return nil + } + if err = sympath.Walk(topdir, walk); err != nil { + return c, err + } + + return LoadFiles(ctx, files) +} diff --git a/pkg/helm/intern/chart/v3/loader/load.go b/pkg/helm/intern/chart/v3/loader/load.go new file mode 100644 index 00000000..6ab08ed2 --- /dev/null +++ b/pkg/helm/intern/chart/v3/loader/load.go @@ -0,0 +1,353 @@ +/* +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 loader + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "maps" + "os" + "path/filepath" + "slices" + "strings" + + utilyaml "k8s.io/apimachinery/pkg/util/yaml" + "sigs.k8s.io/yaml" + + "github.com/werf/common-go/pkg/secrets_manager" + nelmcommon "github.com/werf/nelm/pkg/common" + chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" + chartcommon "github.com/werf/nelm/pkg/helm/pkg/chart/common" + "github.com/werf/nelm/pkg/helm/pkg/chart/loader/archive" + legacysecret "github.com/werf/nelm/pkg/legacy/secret" +) + +// ChartLoader loads a chart. +type ChartLoader interface { + Load(ctx context.Context) (*chart.Chart, error) +} + +// Loader returns a new ChartLoader appropriate for the given chart name +func Loader(name string) (ChartLoader, error) { + fi, err := os.Stat(name) + if err != nil { + return nil, err + } + if fi.IsDir() { + return DirLoader(name), nil + } + return FileLoader(name), nil +} + +// Load takes a string name, tries to resolve it to a file or directory, and then loads it. +// +// This is the preferred way to load a chart. It will discover the chart encoding +// and hand off to the appropriate chart reader. +// +// If a .helmignore file is present, the directory loader will skip loading any files +// matching it. But .helmignore is not evaluated when reading out of an archive. +func Load(ctx context.Context, name string) (*chart.Chart, error) { + l, err := Loader(name) + if err != nil { + return nil, err + } + return l.Load(ctx) +} + +// LoadFiles loads from in-memory files. +func LoadFiles(ctx context.Context, files []*archive.BufferedFile) (*chart.Chart, error) { + helmOpts := nelmcommon.HelmOptionsFromContext(ctx) + applyWerfExtensions := nelmcommon.HasHelmOptions(ctx) + + c := new(chart.Chart) + subcharts := make(map[string][]*archive.BufferedFile) + var subChartsKeys []string + + if applyWerfExtensions { + c.SecretsRuntimeData = legacysecret.NewSecretsRuntimeData() + } + + // do not rely on assumed ordering of files in the chart and crash + // if Chart.yaml was not coming early enough to initialize metadata + for _, f := range files { + c.Raw = append(c.Raw, &chartcommon.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data}) + if f.Name == "Chart.yaml" { + if c.Metadata == nil { + c.Metadata = new(chart.Metadata) + } + if err := yaml.Unmarshal(f.Data, c.Metadata); err != nil { + return c, fmt.Errorf("cannot load Chart.yaml: %w", err) + } + // While the documentation says the APIVersion is required, in practice there + // are cases where that's not enforced. Since this package set is for v3 charts, + // when this function is used v3 is automatically added when not present. + if c.Metadata.APIVersion == "" { + c.Metadata.APIVersion = chart.APIVersionV3 + } + c.ModTime = f.ModTime + } + } + for _, f := range files { + switch { + case f.Name == "Chart.yaml": + continue + case f.Name == "Chart.lock": + c.Lock = new(chart.Lock) + if err := yaml.Unmarshal(f.Data, &c.Lock); err != nil { + return c, fmt.Errorf("cannot load Chart.lock: %w", err) + } + case f.Name == "values.yaml": + values, err := LoadValues(bytes.NewReader(f.Data)) + if err != nil { + return c, fmt.Errorf("cannot load values.yaml: %w", err) + } + c.Values = values + case f.Name == "values.schema.json": + c.Schema = f.Data + c.SchemaModTime = f.ModTime + + case strings.HasPrefix(f.Name, "templates/"): + c.Templates = append(c.Templates, &chartcommon.File{Name: f.Name, Data: f.Data, ModTime: f.ModTime}) + case strings.HasPrefix(f.Name, "charts/"): + if filepath.Ext(f.Name) == ".prov" { + c.Files = append(c.Files, &chartcommon.File{Name: f.Name, Data: f.Data, ModTime: f.ModTime}) + continue + } + + fname := strings.TrimPrefix(f.Name, "charts/") + cname := strings.SplitN(fname, "/", 2)[0] + if slices.Index(subChartsKeys, cname) == -1 { + subChartsKeys = append(subChartsKeys, cname) + } + subcharts[cname] = append(subcharts[cname], &archive.BufferedFile{Name: fname, ModTime: f.ModTime, Data: f.Data}) + case applyWerfExtensions && strings.HasPrefix(f.Name, "ts/node_modules/"): + c.RuntimeDepsFiles = append(c.RuntimeDepsFiles, &chartcommon.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data}) + case applyWerfExtensions && strings.HasPrefix(f.Name, "ts/"): + c.RuntimeFiles = append(c.RuntimeFiles, &chartcommon.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data}) + default: + c.Files = append(c.Files, &chartcommon.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data}) + } + } + + if applyWerfExtensions { + switch helmOpts.ChartLoadOpts.ChartType { + case nelmcommon.LegacyChartTypeBundle: + c.ExtraValues = helmOpts.ChartLoadOpts.ExtraValues + + if !helmOpts.ChartLoadOpts.NoSecrets { + if err := c.SecretsRuntimeData.DecodeAndLoadSecrets( + ctx, + convertBufferedFiles(files), + secrets_manager.Manager, + chartcommon.DecodeAndLoadSecretsOptions{ + CustomSecretValueFiles: helmOpts.ChartLoadOpts.SecretValuesFiles, + LoadFromLocalFilesystem: true, + NoDecryptSecrets: helmOpts.ChartLoadOpts.SecretKeyIgnore, + SecretsWorkingDir: helmOpts.ChartLoadOpts.SecretWorkDir, + WithoutDefaultSecretValues: helmOpts.ChartLoadOpts.DefaultSecretValuesDisable, + }, + ); err != nil { + return nil, fmt.Errorf("error decoding secrets: %w", err) + } + } + + if helmOpts.ChartLoadOpts.DefaultValuesDisable { + c.Values = nil + } + case nelmcommon.LegacyChartTypeChart: + c.ExtraValues = helmOpts.ChartLoadOpts.ExtraValues + + if !helmOpts.ChartLoadOpts.NoSecrets { + if err := c.SecretsRuntimeData.DecodeAndLoadSecrets( + ctx, + convertBufferedFiles(files), + secrets_manager.Manager, + chartcommon.DecodeAndLoadSecretsOptions{ + CustomSecretValueFiles: helmOpts.ChartLoadOpts.SecretValuesFiles, + LoadFromLocalFilesystem: nelmcommon.ChartFileReader == nil, + NoDecryptSecrets: helmOpts.ChartLoadOpts.SecretKeyIgnore, + SecretsWorkingDir: helmOpts.ChartLoadOpts.SecretWorkDir, + WithoutDefaultSecretValues: helmOpts.ChartLoadOpts.DefaultSecretValuesDisable, + }, + ); err != nil { + return nil, fmt.Errorf("error decoding secrets: %w", err) + } + } + + c.Metadata = autosetChartMetadata( + c.Metadata, + autosetChartMetadataOptions{ + DefaultAPIVersion: helmOpts.ChartLoadOpts.DefaultChartAPIVersion, + DefaultName: helmOpts.ChartLoadOpts.DefaultChartName, + DefaultVersion: helmOpts.ChartLoadOpts.DefaultChartVersion, + OverrideAppVersion: helmOpts.ChartLoadOpts.ChartAppVersion, + }, + ) + + c.Templates = append(c.Templates, &chartcommon.File{Name: "templates/_werf_helpers.tpl"}) + + if helmOpts.ChartLoadOpts.DefaultValuesDisable { + c.Values = nil + } + case nelmcommon.LegacyChartTypeSubchart: + if !helmOpts.ChartLoadOpts.NoSecrets { + if err := c.SecretsRuntimeData.DecodeAndLoadSecrets( + ctx, + convertBufferedFiles(files), + secrets_manager.Manager, + chartcommon.DecodeAndLoadSecretsOptions{ + LoadFromLocalFilesystem: nelmcommon.ChartFileReader == nil, + NoDecryptSecrets: helmOpts.ChartLoadOpts.SecretKeyIgnore, + SecretsWorkingDir: helmOpts.ChartLoadOpts.SecretWorkDir, + WithoutDefaultSecretValues: helmOpts.ChartLoadOpts.DefaultSecretValuesDisable, + }, + ); err != nil { + return nil, fmt.Errorf("error decoding secrets: %w", err) + } + } + case nelmcommon.LegacyChartTypeChartStub: + if !helmOpts.ChartLoadOpts.NoSecrets { + if err := c.SecretsRuntimeData.DecodeAndLoadSecrets( + ctx, + convertBufferedFiles(files), + secrets_manager.Manager, + chartcommon.DecodeAndLoadSecretsOptions{ + LoadFromLocalFilesystem: true, + NoDecryptSecrets: helmOpts.ChartLoadOpts.SecretKeyIgnore, + SecretsWorkingDir: helmOpts.ChartLoadOpts.SecretWorkDir, + WithoutDefaultSecretValues: helmOpts.ChartLoadOpts.DefaultSecretValuesDisable, + }, + ); err != nil { + return nil, fmt.Errorf("error decoding secrets: %w", err) + } + } + + c.Metadata = autosetChartMetadata( + c.Metadata, + autosetChartMetadataOptions{ + DefaultAPIVersion: chart.APIVersionV3, + DefaultName: "stubchartname", + DefaultVersion: "1.0.0", + }, + ) + + c.Templates = append(c.Templates, &chartcommon.File{Name: "templates/_werf_helpers.tpl"}) + default: + panic("unexpected type") + } + } + + if c.Metadata == nil { + return c, errors.New("Chart.yaml file is missing") //nolint:staticcheck + } + + if err := c.Validate(); err != nil { + return c, err + } + + helmOpts.ChartLoadOpts.ChartType = nelmcommon.LegacyChartTypeSubchart + ctx = nelmcommon.ContextWithHelmOptions(ctx, helmOpts) + + for n, files := range subcharts { + var sc *chart.Chart + var err error + switch { + case strings.IndexAny(n, "_.") == 0: + continue + case filepath.Ext(n) == ".tgz": + file := files[0] + if file.Name != n { + return c, fmt.Errorf("error unpacking subchart tar in %s: expected %s, got %s", c.Name(), n, file.Name) + } + sc, err = LoadArchive(ctx, bytes.NewBuffer(file.Data)) + default: + buff := make([]*archive.BufferedFile, 0, len(files)) + for _, f := range files { + parts := strings.SplitN(f.Name, "/", 2) + if len(parts) < 2 { + continue + } + f.Name = parts[1] + buff = append(buff, f) + } + sc, err = LoadFiles(ctx, buff) + } + + if err != nil { + return c, fmt.Errorf("error unpacking subchart %s in %s: %w", n, c.Name(), err) + } + c.AddDependency(sc) + } + + return c, nil +} + +func convertBufferedFiles(files []*archive.BufferedFile) []*nelmcommon.BufferedFile { + var res []*nelmcommon.BufferedFile + for _, f := range files { + res = append(res, &nelmcommon.BufferedFile{Name: f.Name, Data: f.Data}) + } + + return res +} + +// LoadValues loads values from a reader. +// +// The reader is expected to contain one or more YAML documents, the values of which are merged. +// And the values can be either a chart's default values or user-supplied values. +func LoadValues(data io.Reader) (map[string]interface{}, error) { + values := map[string]interface{}{} + reader := utilyaml.NewYAMLReader(bufio.NewReader(data)) + for { + currentMap := map[string]interface{}{} + raw, err := reader.Read() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return nil, fmt.Errorf("error reading yaml document: %w", err) + } + if err := yaml.Unmarshal(raw, ¤tMap); err != nil { + return nil, fmt.Errorf("cannot unmarshal yaml document: %w", err) + } + values = MergeMaps(values, currentMap) + } + return values, nil +} + +// MergeMaps merges two maps. If a key exists in both maps, the value from b will be used. +// If the value is a map, the maps will be merged recursively. +func MergeMaps(a, b map[string]interface{}) map[string]interface{} { + out := make(map[string]interface{}, len(a)) + maps.Copy(out, a) + for k, v := range b { + if v, ok := v.(map[string]interface{}); ok { + if bv, ok := out[k]; ok { + if bv, ok := bv.(map[string]interface{}); ok { + out[k] = MergeMaps(bv, v) + continue + } + } + } + out[k] = v + } + return out +} diff --git a/pkg/helm/intern/chart/v3/loader/load_test.go b/pkg/helm/intern/chart/v3/loader/load_test.go new file mode 100644 index 00000000..19f4a61a --- /dev/null +++ b/pkg/helm/intern/chart/v3/loader/load_test.go @@ -0,0 +1,727 @@ +/* +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 loader + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "errors" + "io" + "log" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + "time" + + chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + "github.com/werf/nelm/pkg/helm/pkg/chart/loader/archive" +) + +func TestLoadDir(t *testing.T) { + l, err := Loader("testdata/frobnitz") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + c, err := l.Load(context.Background()) + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyFrobnitz(t, c) + verifyChart(t, c) + verifyDependencies(t, c) + verifyDependenciesLock(t, c) +} + +func TestLoadDirWithDevNull(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test only works on unix systems with /dev/null present") + } + + l, err := Loader("testdata/frobnitz_with_dev_null") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + if _, err := l.Load(context.Background()); err == nil { + t.Errorf("packages with an irregular file (/dev/null) should not load") + } +} + +func TestLoadDirWithSymlink(t *testing.T) { + sym := filepath.Join("..", "LICENSE") + link := filepath.Join("testdata", "frobnitz_with_symlink", "LICENSE") + + if err := os.Symlink(sym, link); err != nil { + t.Fatal(err) + } + + defer os.Remove(link) + + l, err := Loader("testdata/frobnitz_with_symlink") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + + c, err := l.Load(context.Background()) + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyFrobnitz(t, c) + verifyChart(t, c) + verifyDependencies(t, c) + verifyDependenciesLock(t, c) +} + +func TestBomTestData(t *testing.T) { + testFiles := []string{"frobnitz_with_bom/.helmignore", "frobnitz_with_bom/templates/template.tpl", "frobnitz_with_bom/Chart.yaml"} + for _, file := range testFiles { + data, err := os.ReadFile("testdata/" + file) + if err != nil || !bytes.HasPrefix(data, utf8bom) { + t.Errorf("Test file has no BOM or is invalid: testdata/%s", file) + } + } + + archive, err := os.ReadFile("testdata/frobnitz_with_bom.tgz") + if err != nil { + t.Fatalf("Error reading archive frobnitz_with_bom.tgz: %s", err) + } + unzipped, err := gzip.NewReader(bytes.NewReader(archive)) + if err != nil { + t.Fatalf("Error reading archive frobnitz_with_bom.tgz: %s", err) + } + defer unzipped.Close() + for _, testFile := range testFiles { + data := make([]byte, 3) + err := unzipped.Reset(bytes.NewReader(archive)) + if err != nil { + t.Fatalf("Error reading archive frobnitz_with_bom.tgz: %s", err) + } + tr := tar.NewReader(unzipped) + for { + file, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatalf("Error reading archive frobnitz_with_bom.tgz: %s", err) + } + if file != nil && strings.EqualFold(file.Name, testFile) { + _, err := tr.Read(data) + if err != nil { + t.Fatalf("Error reading archive frobnitz_with_bom.tgz: %s", err) + } else { + break + } + } + } + if !bytes.Equal(data, utf8bom) { + t.Fatalf("Test file has no BOM or is invalid: frobnitz_with_bom.tgz/%s", testFile) + } + } +} + +func TestLoadDirWithUTFBOM(t *testing.T) { + l, err := Loader("testdata/frobnitz_with_bom") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + c, err := l.Load(context.Background()) + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyFrobnitz(t, c) + verifyChart(t, c) + verifyDependencies(t, c) + verifyDependenciesLock(t, c) + verifyBomStripped(t, c.Files) +} + +func TestLoadArchiveWithUTFBOM(t *testing.T) { + l, err := Loader("testdata/frobnitz_with_bom.tgz") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + c, err := l.Load(context.Background()) + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyFrobnitz(t, c) + verifyChart(t, c) + verifyDependencies(t, c) + verifyDependenciesLock(t, c) + verifyBomStripped(t, c.Files) +} + +func TestLoadFile(t *testing.T) { + l, err := Loader("testdata/frobnitz-1.2.3.tgz") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + c, err := l.Load(context.Background()) + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyFrobnitz(t, c) + verifyChart(t, c) + verifyDependencies(t, c) +} + +func TestLoadFiles(t *testing.T) { + modTime := time.Now() + goodFiles := []*archive.BufferedFile{ + { + Name: "Chart.yaml", + ModTime: modTime, + Data: []byte(`apiVersion: v3 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +`), + }, + { + Name: "values.yaml", + ModTime: modTime, + Data: []byte("var: some values"), + }, + { + Name: "values.schema.json", + ModTime: modTime, + Data: []byte("type: Values"), + }, + { + Name: "templates/deployment.yaml", + ModTime: modTime, + Data: []byte("some deployment"), + }, + { + Name: "templates/service.yaml", + ModTime: modTime, + Data: []byte("some service"), + }, + } + + c, err := LoadFiles(context.Background(), goodFiles) + if err != nil { + t.Errorf("Expected good files to be loaded, got %v", err) + } + + if c.Name() != "frobnitz" { + t.Errorf("Expected chart name to be 'frobnitz', got %s", c.Name()) + } + + if c.Values["var"] != "some values" { + t.Error("Expected chart values to be populated with default values") + } + + if len(c.Raw) != 5 { + t.Errorf("Expected %d files, got %d", 5, len(c.Raw)) + } + + if !bytes.Equal(c.Schema, []byte("type: Values")) { + t.Error("Expected chart schema to be populated with default values") + } + + if len(c.Templates) != 2 { + t.Errorf("Expected number of templates == 2, got %d", len(c.Templates)) + } + + if _, err = LoadFiles(context.Background(), []*archive.BufferedFile{}); err == nil { + t.Fatal("Expected err to be non-nil") + } + if err.Error() != "Chart.yaml file is missing" { + t.Errorf("Expected chart metadata missing error, got '%s'", err.Error()) + } +} + +// Test the order of file loading. The Chart.yaml file needs to come first for +// later comparison checks. See https://github.com/helm/helm/pull/8948 +func TestLoadFilesOrder(t *testing.T) { + modTime := time.Now() + goodFiles := []*archive.BufferedFile{ + { + Name: "requirements.yaml", + ModTime: modTime, + Data: []byte("dependencies:"), + }, + { + Name: "values.yaml", + ModTime: modTime, + Data: []byte("var: some values"), + }, + + { + Name: "templates/deployment.yaml", + ModTime: modTime, + Data: []byte("some deployment"), + }, + { + Name: "templates/service.yaml", + ModTime: modTime, + Data: []byte("some service"), + }, + { + Name: "Chart.yaml", + ModTime: modTime, + Data: []byte(`apiVersion: v3 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +`), + }, + } + + // Capture stderr to make sure message about Chart.yaml handle dependencies + // is not present + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("Unable to create pipe: %s", err) + } + stderr := log.Writer() + log.SetOutput(w) + defer func() { + log.SetOutput(stderr) + }() + + _, err = LoadFiles(context.Background(), goodFiles) + if err != nil { + t.Errorf("Expected good files to be loaded, got %v", err) + } + w.Close() + + var text bytes.Buffer + io.Copy(&text, r) + if text.String() != "" { + t.Errorf("Expected no message to Stderr, got %s", text.String()) + } + +} + +// Packaging the chart on a Windows machine will produce an +// archive that has \\ as delimiters. Test that we support these archives +func TestLoadFileBackslash(t *testing.T) { + c, err := Load(context.Background(), "testdata/frobnitz_backslash-1.2.3.tgz") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyChartFileAndTemplate(t, c, "frobnitz_backslash") + verifyChart(t, c) + verifyDependencies(t, c) +} + +func TestLoadV3WithReqs(t *testing.T) { + l, err := Loader("testdata/frobnitz.v3.reqs") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + c, err := l.Load(context.Background()) + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyDependencies(t, c) + verifyDependenciesLock(t, c) +} + +func TestLoadInvalidArchive(t *testing.T) { + tmpdir := t.TempDir() + + writeTar := func(filename, internalPath string, body []byte) { + dest, err := os.Create(filename) + if err != nil { + t.Fatal(err) + } + zipper := gzip.NewWriter(dest) + tw := tar.NewWriter(zipper) + + h := &tar.Header{ + Name: internalPath, + Mode: 0755, + Size: int64(len(body)), + ModTime: time.Now(), + } + if err := tw.WriteHeader(h); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(body); err != nil { + t.Fatal(err) + } + tw.Close() + zipper.Close() + dest.Close() + } + + for _, tt := range []struct { + chartname string + internal string + expectError string + }{ + {"illegal-dots.tgz", "../../malformed-helm-test", "chart illegally references parent directory"}, + {"illegal-dots2.tgz", "/foo/../../malformed-helm-test", "chart illegally references parent directory"}, + {"illegal-dots3.tgz", "/../../malformed-helm-test", "chart illegally references parent directory"}, + {"illegal-dots4.tgz", "./../../malformed-helm-test", "chart illegally references parent directory"}, + {"illegal-name.tgz", "./.", "chart illegally contains content outside the base directory"}, + {"illegal-name2.tgz", "/./.", "chart illegally contains content outside the base directory"}, + {"illegal-name3.tgz", "missing-leading-slash", "chart illegally contains content outside the base directory"}, + {"illegal-name4.tgz", "/missing-leading-slash", "Chart.yaml file is missing"}, + {"illegal-abspath.tgz", "//foo", "chart illegally contains absolute paths"}, + {"illegal-abspath2.tgz", "///foo", "chart illegally contains absolute paths"}, + {"illegal-abspath3.tgz", "\\\\foo", "chart illegally contains absolute paths"}, + {"illegal-abspath3.tgz", "\\..\\..\\foo", "chart illegally references parent directory"}, + + // Under special circumstances, this can get normalized to things that look like absolute Windows paths + {"illegal-abspath4.tgz", "\\.\\c:\\\\foo", "chart contains illegally named files"}, + {"illegal-abspath5.tgz", "/./c://foo", "chart contains illegally named files"}, + {"illegal-abspath6.tgz", "\\\\?\\Some\\windows\\magic", "chart illegally contains absolute paths"}, + } { + illegalChart := filepath.Join(tmpdir, tt.chartname) + writeTar(illegalChart, tt.internal, []byte("hello: world")) + _, err := Load(context.Background(), illegalChart) + if err == nil { + t.Fatal("expected error when unpacking illegal files") + } + if !strings.Contains(err.Error(), tt.expectError) { + t.Errorf("Expected error to contain %q, got %q for %s", tt.expectError, err.Error(), tt.chartname) + } + } + + // Make sure that absolute path gets interpreted as relative + illegalChart := filepath.Join(tmpdir, "abs-path.tgz") + writeTar(illegalChart, "/Chart.yaml", []byte("hello: world")) + _, err := Load(context.Background(), illegalChart) + if err.Error() != "validation: chart.metadata.name is required" { + t.Error(err) + } + + // And just to validate that the above was not spurious + illegalChart = filepath.Join(tmpdir, "abs-path2.tgz") + writeTar(illegalChart, "files/whatever.yaml", []byte("hello: world")) + _, err = Load(context.Background(), illegalChart) + if err.Error() != "Chart.yaml file is missing" { + t.Errorf("Unexpected error message: %s", err) + } + + // Finally, test that drive letter gets stripped off on Windows + illegalChart = filepath.Join(tmpdir, "abs-winpath.tgz") + writeTar(illegalChart, "c:\\Chart.yaml", []byte("hello: world")) + _, err = Load(context.Background(), illegalChart) + if err.Error() != "validation: chart.metadata.name is required" { + t.Error(err) + } +} + +func TestLoadValues(t *testing.T) { + testCases := map[string]struct { + data []byte + expctedValues map[string]interface{} + }{ + "It should load values correctly": { + data: []byte(` +foo: + image: foo:v1 +bar: + version: v2 +`), + expctedValues: map[string]interface{}{ + "foo": map[string]interface{}{ + "image": "foo:v1", + }, + "bar": map[string]interface{}{ + "version": "v2", + }, + }, + }, + "It should load values correctly with multiple documents in one file": { + data: []byte(` +foo: + image: foo:v1 +bar: + version: v2 +--- +foo: + image: foo:v2 +`), + expctedValues: map[string]interface{}{ + "foo": map[string]interface{}{ + "image": "foo:v2", + }, + "bar": map[string]interface{}{ + "version": "v2", + }, + }, + }, + } + for testName, testCase := range testCases { + t.Run(testName, func(tt *testing.T) { + values, err := LoadValues(bytes.NewReader(testCase.data)) + if err != nil { + tt.Fatal(err) + } + if !reflect.DeepEqual(values, testCase.expctedValues) { + tt.Errorf("Expected values: %v, got %v", testCase.expctedValues, values) + } + }) + } +} + +func TestMergeValuesV3(t *testing.T) { + nestedMap := map[string]interface{}{ + "foo": "bar", + "baz": map[string]string{ + "cool": "stuff", + }, + } + anotherNestedMap := map[string]interface{}{ + "foo": "bar", + "baz": map[string]string{ + "cool": "things", + "awesome": "stuff", + }, + } + flatMap := map[string]interface{}{ + "foo": "bar", + "baz": "stuff", + } + anotherFlatMap := map[string]interface{}{ + "testing": "fun", + } + + testMap := MergeMaps(flatMap, nestedMap) + equal := reflect.DeepEqual(testMap, nestedMap) + if !equal { + t.Errorf("Expected a nested map to overwrite a flat value. Expected: %v, got %v", nestedMap, testMap) + } + + testMap = MergeMaps(nestedMap, flatMap) + equal = reflect.DeepEqual(testMap, flatMap) + if !equal { + t.Errorf("Expected a flat value to overwrite a map. Expected: %v, got %v", flatMap, testMap) + } + + testMap = MergeMaps(nestedMap, anotherNestedMap) + equal = reflect.DeepEqual(testMap, anotherNestedMap) + if !equal { + t.Errorf("Expected a nested map to overwrite another nested map. Expected: %v, got %v", anotherNestedMap, testMap) + } + + testMap = MergeMaps(anotherFlatMap, anotherNestedMap) + expectedMap := map[string]interface{}{ + "testing": "fun", + "foo": "bar", + "baz": map[string]string{ + "cool": "things", + "awesome": "stuff", + }, + } + equal = reflect.DeepEqual(testMap, expectedMap) + if !equal { + t.Errorf("Expected a map with different keys to merge properly with another map. Expected: %v, got %v", expectedMap, testMap) + } +} + +func verifyChart(t *testing.T, c *chart.Chart) { + t.Helper() + if c.Name() == "" { + t.Fatalf("No chart metadata found on %v", c) + } + t.Logf("Verifying chart %s", c.Name()) + if len(c.Templates) != 1 { + t.Errorf("Expected 1 template, got %d", len(c.Templates)) + } + + numfiles := 6 + if len(c.Files) != numfiles { + t.Errorf("Expected %d extra files, got %d", numfiles, len(c.Files)) + for _, n := range c.Files { + t.Logf("\t%s", n.Name) + } + } + + if len(c.Dependencies()) != 2 { + t.Errorf("Expected 2 dependencies, got %d (%v)", len(c.Dependencies()), c.Dependencies()) + for _, d := range c.Dependencies() { + t.Logf("\tSubchart: %s\n", d.Name()) + } + } + + expect := map[string]map[string]string{ + "alpine": { + "version": "0.1.0", + }, + "mariner": { + "version": "4.3.2", + }, + } + + for _, dep := range c.Dependencies() { + if dep.Metadata == nil { + t.Fatalf("expected metadata on dependency: %v", dep) + } + exp, ok := expect[dep.Name()] + if !ok { + t.Fatalf("Unknown dependency %s", dep.Name()) + } + if exp["version"] != dep.Metadata.Version { + t.Errorf("Expected %s version %s, got %s", dep.Name(), exp["version"], dep.Metadata.Version) + } + } + +} + +func verifyDependencies(t *testing.T, c *chart.Chart) { + t.Helper() + if len(c.Metadata.Dependencies) != 2 { + t.Errorf("Expected 2 dependencies, got %d", len(c.Metadata.Dependencies)) + } + tests := []*chart.Dependency{ + {Name: "alpine", Version: "0.1.0", Repository: "https://example.com/charts"}, + {Name: "mariner", Version: "4.3.2", Repository: "https://example.com/charts"}, + } + for i, tt := range tests { + d := c.Metadata.Dependencies[i] + if d.Name != tt.Name { + t.Errorf("Expected dependency named %q, got %q", tt.Name, d.Name) + } + if d.Version != tt.Version { + t.Errorf("Expected dependency named %q to have version %q, got %q", tt.Name, tt.Version, d.Version) + } + if d.Repository != tt.Repository { + t.Errorf("Expected dependency named %q to have repository %q, got %q", tt.Name, tt.Repository, d.Repository) + } + } +} + +func verifyDependenciesLock(t *testing.T, c *chart.Chart) { + t.Helper() + if len(c.Metadata.Dependencies) != 2 { + t.Errorf("Expected 2 dependencies, got %d", len(c.Metadata.Dependencies)) + } + tests := []*chart.Dependency{ + {Name: "alpine", Version: "0.1.0", Repository: "https://example.com/charts"}, + {Name: "mariner", Version: "4.3.2", Repository: "https://example.com/charts"}, + } + for i, tt := range tests { + d := c.Metadata.Dependencies[i] + if d.Name != tt.Name { + t.Errorf("Expected dependency named %q, got %q", tt.Name, d.Name) + } + if d.Version != tt.Version { + t.Errorf("Expected dependency named %q to have version %q, got %q", tt.Name, tt.Version, d.Version) + } + if d.Repository != tt.Repository { + t.Errorf("Expected dependency named %q to have repository %q, got %q", tt.Name, tt.Repository, d.Repository) + } + } +} + +func verifyFrobnitz(t *testing.T, c *chart.Chart) { + t.Helper() + verifyChartFileAndTemplate(t, c, "frobnitz") +} + +func verifyChartFileAndTemplate(t *testing.T, c *chart.Chart, name string) { + t.Helper() + if c.Metadata == nil { + t.Fatal("Metadata is nil") + } + if c.Name() != name { + t.Errorf("Expected %s, got %s", name, c.Name()) + } + if len(c.Templates) != 1 { + t.Fatalf("Expected 1 template, got %d", len(c.Templates)) + } + if c.Templates[0].Name != "templates/template.tpl" { + t.Errorf("Unexpected template: %s", c.Templates[0].Name) + } + if len(c.Templates[0].Data) == 0 { + t.Error("No template data.") + } + if len(c.Files) != 6 { + t.Fatalf("Expected 6 Files, got %d", len(c.Files)) + } + if len(c.Dependencies()) != 2 { + t.Fatalf("Expected 2 Dependency, got %d", len(c.Dependencies())) + } + if len(c.Metadata.Dependencies) != 2 { + t.Fatalf("Expected 2 Dependencies.Dependency, got %d", len(c.Metadata.Dependencies)) + } + if len(c.Lock.Dependencies) != 2 { + t.Fatalf("Expected 2 Lock.Dependency, got %d", len(c.Lock.Dependencies)) + } + + for _, dep := range c.Dependencies() { + switch dep.Name() { + case "mariner": + case "alpine": + if len(dep.Templates) != 1 { + t.Fatalf("Expected 1 template, got %d", len(dep.Templates)) + } + if dep.Templates[0].Name != "templates/alpine-pod.yaml" { + t.Errorf("Unexpected template: %s", dep.Templates[0].Name) + } + if len(dep.Templates[0].Data) == 0 { + t.Error("No template data.") + } + if len(dep.Files) != 1 { + t.Fatalf("Expected 1 Files, got %d", len(dep.Files)) + } + if len(dep.Dependencies()) != 2 { + t.Fatalf("Expected 2 Dependency, got %d", len(dep.Dependencies())) + } + default: + t.Errorf("Unexpected dependency %s", dep.Name()) + } + } +} + +func verifyBomStripped(t *testing.T, files []*common.File) { + t.Helper() + for _, file := range files { + if bytes.HasPrefix(file.Data, utf8bom) { + t.Errorf("Byte Order Mark still present in processed file %s", file.Name) + } + } +} diff --git a/pkg/helm/pkg/chart/loader/testdata/LICENSE b/pkg/helm/intern/chart/v3/loader/testdata/LICENSE similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/LICENSE rename to pkg/helm/intern/chart/v3/loader/testdata/LICENSE diff --git a/pkg/helm/pkg/chart/loader/testdata/albatross/Chart.yaml b/pkg/helm/intern/chart/v3/loader/testdata/albatross/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/albatross/Chart.yaml rename to pkg/helm/intern/chart/v3/loader/testdata/albatross/Chart.yaml diff --git a/pkg/helm/pkg/chart/loader/testdata/albatross/values.yaml b/pkg/helm/intern/chart/v3/loader/testdata/albatross/values.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/albatross/values.yaml rename to pkg/helm/intern/chart/v3/loader/testdata/albatross/values.yaml diff --git a/pkg/helm/intern/chart/v3/loader/testdata/frobnitz-1.2.3.tgz b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz-1.2.3.tgz new file mode 100644 index 0000000000000000000000000000000000000000..de28e4120df739fc1e9a246cfce150ccb1bcea1e GIT binary patch literal 3420 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 literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/.helmignore b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/.helmignore similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/.helmignore rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/.helmignore diff --git a/pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/Chart.yaml b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/Chart.yaml new file mode 100644 index 00000000..1b63fc3e --- /dev/null +++ b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/Chart.yaml @@ -0,0 +1,27 @@ +apiVersion: v3 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +annotations: + extrakey: extravalue + anotherkey: anothervalue +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/INSTALL.txt b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/INSTALL.txt similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/INSTALL.txt rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/INSTALL.txt diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/LICENSE b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/LICENSE similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/LICENSE rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/LICENSE diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/README.md b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/README.md similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/README.md rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/README.md diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/charts/_ignore_me b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/_ignore_me similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/charts/_ignore_me rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/_ignore_me diff --git a/pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/Chart.yaml b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/Chart.yaml new file mode 100644 index 00000000..2a2c9c88 --- /dev/null +++ b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: alpine +description: Deploy a basic Alpine Linux pod +version: 0.1.0 +home: https://helm.sh/helm diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/README.md b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/README.md similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/README.md rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/README.md diff --git a/pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/charts/mast1/Chart.yaml b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/charts/mast1/Chart.yaml new file mode 100644 index 00000000..aea109c7 --- /dev/null +++ b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/charts/mast1/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: mast1 +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/charts/mast1/values.yaml b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/charts/mast1/values.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/charts/mast1/values.yaml rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/charts/mast1/values.yaml diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/charts/mast2-0.1.0.tgz similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/charts/mast2-0.1.0.tgz rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/charts/mast2-0.1.0.tgz diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/templates/alpine-pod.yaml b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/templates/alpine-pod.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/templates/alpine-pod.yaml rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/templates/alpine-pod.yaml diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/values.yaml b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/values.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/values.yaml rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/values.yaml diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/charts/mariner-4.3.2.tgz b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/mariner-4.3.2.tgz similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/charts/mariner-4.3.2.tgz rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/mariner-4.3.2.tgz diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/docs/README.md b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/docs/README.md similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/docs/README.md rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/docs/README.md diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/icon.svg b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/icon.svg similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/icon.svg rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/icon.svg diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/ignore/me.txt b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/ignore/me.txt similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/ignore/me.txt rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/ignore/me.txt diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/templates/template.tpl b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/templates/template.tpl similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/templates/template.tpl rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/templates/template.tpl diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/values.yaml b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/values.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/values.yaml rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz.v3.reqs/values.yaml diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/.helmignore b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz/.helmignore similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/.helmignore rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz/.helmignore diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/Chart.lock b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz/Chart.lock similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/Chart.lock rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz/Chart.lock diff --git a/pkg/helm/intern/chart/v3/loader/testdata/frobnitz/Chart.yaml b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz/Chart.yaml new file mode 100644 index 00000000..1b63fc3e --- /dev/null +++ b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz/Chart.yaml @@ -0,0 +1,27 @@ +apiVersion: v3 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +annotations: + extrakey: extravalue + anotherkey: anothervalue +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/INSTALL.txt b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz/INSTALL.txt similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/INSTALL.txt rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz/INSTALL.txt diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/LICENSE b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz/LICENSE similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/LICENSE rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz/LICENSE diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/README.md b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz/README.md similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/README.md rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz/README.md diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/_ignore_me b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz/charts/_ignore_me similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/_ignore_me rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz/charts/_ignore_me diff --git a/pkg/helm/intern/chart/v3/loader/testdata/frobnitz/charts/alpine/Chart.yaml b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz/charts/alpine/Chart.yaml new file mode 100644 index 00000000..2a2c9c88 --- /dev/null +++ b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz/charts/alpine/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: alpine +description: Deploy a basic Alpine Linux pod +version: 0.1.0 +home: https://helm.sh/helm diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/README.md b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz/charts/alpine/README.md similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/README.md rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz/charts/alpine/README.md diff --git a/pkg/helm/intern/chart/v3/loader/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml new file mode 100644 index 00000000..aea109c7 --- /dev/null +++ b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: mast1 +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/values.yaml b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/values.yaml rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast2-0.1.0.tgz rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/templates/alpine-pod.yaml b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/templates/alpine-pod.yaml rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/values.yaml b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz/charts/alpine/values.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/values.yaml rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz/charts/alpine/values.yaml diff --git a/pkg/helm/intern/chart/v3/loader/testdata/frobnitz/charts/mariner-4.3.2.tgz b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz/charts/mariner-4.3.2.tgz new file mode 100644 index 0000000000000000000000000000000000000000..5c6bc4dcb370466921bbde3c433614bed548313d GIT binary patch literal 910 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 MAHOr*RseVa0Fp7mZvX%Q literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz/.helmignore b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/.helmignore old mode 100644 new mode 100755 similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz/.helmignore rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/.helmignore diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz/Chart.lock b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/Chart.lock old mode 100644 new mode 100755 similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz/Chart.lock rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/Chart.lock diff --git a/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/Chart.yaml b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/Chart.yaml new file mode 100755 index 00000000..6a952e33 --- /dev/null +++ b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/Chart.yaml @@ -0,0 +1,27 @@ +apiVersion: v3 +name: frobnitz_backslash +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +annotations: + extrakey: extravalue + anotherkey: anothervalue +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz/INSTALL.txt b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/INSTALL.txt old mode 100644 new mode 100755 similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz/INSTALL.txt rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/INSTALL.txt diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz/LICENSE b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/LICENSE old mode 100644 new mode 100755 similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz/LICENSE rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/LICENSE diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz/README.md b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/README.md old mode 100644 new mode 100755 similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz/README.md rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/README.md diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz/charts/_ignore_me b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/charts/_ignore_me old mode 100644 new mode 100755 similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz/charts/_ignore_me rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/charts/_ignore_me diff --git a/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/Chart.yaml b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/Chart.yaml new file mode 100755 index 00000000..2a2c9c88 --- /dev/null +++ b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: alpine +description: Deploy a basic Alpine Linux pod +version: 0.1.0 +home: https://helm.sh/helm diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz/charts/alpine/README.md b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/README.md old mode 100644 new mode 100755 similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz/charts/alpine/README.md rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/README.md diff --git a/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/Chart.yaml b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/Chart.yaml new file mode 100755 index 00000000..aea109c7 --- /dev/null +++ b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: mast1 +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/values.yaml old mode 100644 new mode 100755 similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/values.yaml diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast2-0.1.0.tgz old mode 100644 new mode 100755 similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast2-0.1.0.tgz diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/templates/alpine-pod.yaml b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/templates/alpine-pod.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/templates/alpine-pod.yaml rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/templates/alpine-pod.yaml diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz/charts/alpine/values.yaml b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/values.yaml old mode 100644 new mode 100755 similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz/charts/alpine/values.yaml rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/values.yaml diff --git a/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/charts/mariner-4.3.2.tgz b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_backslash/charts/mariner-4.3.2.tgz new file mode 100755 index 0000000000000000000000000000000000000000..5c6bc4dcb370466921bbde3c433614bed548313d GIT binary patch literal 910 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 literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/.helmignore b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/.helmignore similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/.helmignore rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/.helmignore diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/Chart.lock b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/Chart.lock similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/Chart.lock rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/Chart.lock diff --git a/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/Chart.yaml b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/Chart.yaml new file mode 100644 index 00000000..924fae6f --- /dev/null +++ b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/Chart.yaml @@ -0,0 +1,27 @@ +apiVersion: v3 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +annotations: + extrakey: extravalue + anotherkey: anothervalue +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/INSTALL.txt b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/INSTALL.txt similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/INSTALL.txt rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/INSTALL.txt diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/LICENSE b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/LICENSE similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/LICENSE rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/LICENSE diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/README.md b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/README.md similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/README.md rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/README.md diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/charts/_ignore_me b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/charts/_ignore_me similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/charts/_ignore_me rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/charts/_ignore_me diff --git a/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/Chart.yaml b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/Chart.yaml new file mode 100644 index 00000000..6fe4f411 --- /dev/null +++ b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: alpine +description: Deploy a basic Alpine Linux pod +version: 0.1.0 +home: https://helm.sh/helm diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/README.md b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/README.md similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/README.md rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/README.md diff --git a/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/Chart.yaml b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/Chart.yaml new file mode 100644 index 00000000..0732c7d7 --- /dev/null +++ b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: mast1 +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/values.yaml b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/values.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/values.yaml rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/values.yaml diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast2-0.1.0.tgz old mode 100755 new mode 100644 similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast2-0.1.0.tgz rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast2-0.1.0.tgz diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/templates/alpine-pod.yaml b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/templates/alpine-pod.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/templates/alpine-pod.yaml rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/templates/alpine-pod.yaml diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/values.yaml b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/values.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/values.yaml rename to pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/values.yaml diff --git a/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/charts/mariner-4.3.2.tgz b/pkg/helm/intern/chart/v3/loader/testdata/frobnitz_with_bom/charts/mariner-4.3.2.tgz new file mode 100644 index 0000000000000000000000000000000000000000..5c6bc4dcb370466921bbde3c433614bed548313d GIT binary patch literal 910 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 literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/chart/loader/testdata/mariner/templates/placeholder.tpl b/pkg/helm/intern/chart/v3/loader/testdata/mariner/templates/placeholder.tpl similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/mariner/templates/placeholder.tpl rename to pkg/helm/intern/chart/v3/loader/testdata/mariner/templates/placeholder.tpl diff --git a/pkg/helm/pkg/chart/loader/testdata/mariner/values.yaml b/pkg/helm/intern/chart/v3/loader/testdata/mariner/values.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/mariner/values.yaml rename to pkg/helm/intern/chart/v3/loader/testdata/mariner/values.yaml diff --git a/pkg/helm/pkg/chart/metadata.go b/pkg/helm/intern/chart/v3/metadata.go similarity index 99% rename from pkg/helm/pkg/chart/metadata.go rename to pkg/helm/intern/chart/v3/metadata.go index a08a97cd..4629d571 100644 --- a/pkg/helm/pkg/chart/metadata.go +++ b/pkg/helm/intern/chart/v3/metadata.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package chart +package v3 import ( "path/filepath" diff --git a/pkg/helm/intern/chart/v3/metadata_test.go b/pkg/helm/intern/chart/v3/metadata_test.go new file mode 100644 index 00000000..596a0369 --- /dev/null +++ b/pkg/helm/intern/chart/v3/metadata_test.go @@ -0,0 +1,201 @@ +/* +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 v3 + +import ( + "testing" +) + +func TestValidate(t *testing.T) { + tests := []struct { + name string + md *Metadata + err error + }{ + { + "chart without metadata", + nil, + ValidationError("chart.metadata is required"), + }, + { + "chart without apiVersion", + &Metadata{Name: "test", Version: "1.0"}, + ValidationError("chart.metadata.apiVersion is required"), + }, + { + "chart without name", + &Metadata{APIVersion: "v3", Version: "1.0"}, + ValidationError("chart.metadata.name is required"), + }, + { + "chart without name", + &Metadata{Name: "../../test", APIVersion: "v3", Version: "1.0"}, + ValidationError("chart.metadata.name \"../../test\" is invalid"), + }, + { + "chart without version", + &Metadata{Name: "test", APIVersion: "v3"}, + ValidationError("chart.metadata.version is required"), + }, + { + "chart with bad type", + &Metadata{Name: "test", APIVersion: "v3", Version: "1.0", Type: "test"}, + ValidationError("chart.metadata.type must be application or library"), + }, + { + "chart without dependency", + &Metadata{Name: "test", APIVersion: "v3", Version: "1.0", Type: "application"}, + nil, + }, + { + "dependency with valid alias", + &Metadata{ + Name: "test", + APIVersion: "v3", + Version: "1.0", + Type: "application", + Dependencies: []*Dependency{ + {Name: "dependency", Alias: "legal-alias"}, + }, + }, + nil, + }, + { + "dependency with bad characters in alias", + &Metadata{ + Name: "test", + APIVersion: "v3", + Version: "1.0", + Type: "application", + Dependencies: []*Dependency{ + {Name: "bad", Alias: "illegal alias"}, + }, + }, + ValidationError("dependency \"bad\" has disallowed characters in the alias"), + }, + { + "same dependency twice", + &Metadata{ + Name: "test", + APIVersion: "v3", + Version: "1.0", + Type: "application", + Dependencies: []*Dependency{ + {Name: "foo", Alias: ""}, + {Name: "foo", Alias: ""}, + }, + }, + ValidationError("more than one dependency with name or alias \"foo\""), + }, + { + "two dependencies with alias from second dependency shadowing first one", + &Metadata{ + Name: "test", + APIVersion: "v3", + Version: "1.0", + Type: "application", + Dependencies: []*Dependency{ + {Name: "foo", Alias: ""}, + {Name: "bar", Alias: "foo"}, + }, + }, + ValidationError("more than one dependency with name or alias \"foo\""), + }, + { + // this case would make sense and could work in future versions of Helm, currently template rendering would + // result in undefined behaviour + "same dependency twice with different version", + &Metadata{ + Name: "test", + APIVersion: "v3", + Version: "1.0", + Type: "application", + Dependencies: []*Dependency{ + {Name: "foo", Alias: "", Version: "1.2.3"}, + {Name: "foo", Alias: "", Version: "1.0.0"}, + }, + }, + ValidationError("more than one dependency with name or alias \"foo\""), + }, + { + // this case would make sense and could work in future versions of Helm, currently template rendering would + // result in undefined behaviour + "two dependencies with same name but different repos", + &Metadata{ + Name: "test", + APIVersion: "v3", + Version: "1.0", + Type: "application", + Dependencies: []*Dependency{ + {Name: "foo", Repository: "repo-0"}, + {Name: "foo", Repository: "repo-1"}, + }, + }, + ValidationError("more than one dependency with name or alias \"foo\""), + }, + { + "dependencies has nil", + &Metadata{ + Name: "test", + APIVersion: "v3", + Version: "1.0", + Type: "application", + Dependencies: []*Dependency{ + nil, + }, + }, + ValidationError("dependencies must not contain empty or null nodes"), + }, + { + "maintainer not empty", + &Metadata{ + Name: "test", + APIVersion: "v3", + Version: "1.0", + Type: "application", + Maintainers: []*Maintainer{ + nil, + }, + }, + ValidationError("maintainers must not contain empty or null nodes"), + }, + { + "version invalid", + &Metadata{APIVersion: "3", Name: "test", Version: "1.2.3.4"}, + ValidationError("chart.metadata.version \"1.2.3.4\" is invalid"), + }, + } + + for _, tt := range tests { + result := tt.md.Validate() + if result != tt.err { + t.Errorf("expected %q, got %q in test %q", tt.err, result, tt.name) + } + } +} + +func TestValidate_sanitize(t *testing.T) { + md := &Metadata{APIVersion: "3", Name: "test", Version: "1.0", Description: "\adescr\u0081iption\rtest", Maintainers: []*Maintainer{{Name: "\r"}}} + if err := md.Validate(); err != nil { + t.Fatalf("unexpected error: %s", err) + } + if md.Description != "description test" { + t.Fatalf("description was not sanitized: %q", md.Description) + } + if md.Maintainers[0].Name != " " { + t.Fatal("maintainer name was not sanitized") + } +} diff --git a/pkg/helm/intern/chart/v3/util/chartfile.go b/pkg/helm/intern/chart/v3/util/chartfile.go new file mode 100644 index 00000000..a535d4b8 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/chartfile.go @@ -0,0 +1,96 @@ +/* +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 util + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "sigs.k8s.io/yaml" + + chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" +) + +// LoadChartfile loads a Chart.yaml file into a *chart.Metadata. +func LoadChartfile(filename string) (*chart.Metadata, error) { + b, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + y := new(chart.Metadata) + err = yaml.Unmarshal(b, y) + return y, err +} + +// StrictLoadChartfile loads a Chart.yaml into a *chart.Metadata using a strict unmarshaling +func StrictLoadChartfile(filename string) (*chart.Metadata, error) { + b, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + y := new(chart.Metadata) + err = yaml.UnmarshalStrict(b, y) + return y, err +} + +// SaveChartfile saves the given metadata as a Chart.yaml file at the given path. +// +// 'filename' should be the complete path and filename ('foo/Chart.yaml') +func SaveChartfile(filename string, cf *chart.Metadata) error { + out, err := yaml.Marshal(cf) + if err != nil { + return err + } + return os.WriteFile(filename, out, 0644) +} + +// IsChartDir validate a chart directory. +// +// Checks for a valid Chart.yaml. +func IsChartDir(dirName string) (bool, error) { + if fi, err := os.Stat(dirName); err != nil { + return false, err + } else if !fi.IsDir() { + return false, fmt.Errorf("%q is not a directory", dirName) + } + + chartYaml := filepath.Join(dirName, ChartfileName) + if _, err := os.Stat(chartYaml); errors.Is(err, fs.ErrNotExist) { + return false, fmt.Errorf("no %s exists in directory %q", ChartfileName, dirName) + } + + chartYamlContent, err := os.ReadFile(chartYaml) + if err != nil { + return false, fmt.Errorf("cannot read %s in directory %q", ChartfileName, dirName) + } + + chartContent := new(chart.Metadata) + if err := yaml.Unmarshal(chartYamlContent, &chartContent); err != nil { + return false, err + } + if chartContent == nil { + return false, fmt.Errorf("chart metadata (%s) missing", ChartfileName) + } + if chartContent.Name == "" { + return false, fmt.Errorf("invalid chart (%s): name must not be empty", ChartfileName) + } + + return true, nil +} diff --git a/pkg/helm/intern/chart/v3/util/chartfile_test.go b/pkg/helm/intern/chart/v3/util/chartfile_test.go new file mode 100644 index 00000000..8d721a55 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/chartfile_test.go @@ -0,0 +1,117 @@ +/* +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 util + +import ( + "testing" + + chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" +) + +const testfile = "testdata/chartfiletest.yaml" + +func TestLoadChartfile(t *testing.T) { + f, err := LoadChartfile(testfile) + if err != nil { + t.Errorf("Failed to open %s: %s", testfile, err) + return + } + verifyChartfile(t, f, "frobnitz") +} + +func verifyChartfile(t *testing.T, f *chart.Metadata, name string) { + t.Helper() + if f == nil { //nolint:staticcheck + t.Fatal("Failed verifyChartfile because f is nil") + } + + if f.Name != name { + t.Errorf("Expected %s, got %s", name, f.Name) + } + + if f.Description != "This is a frobnitz." { + t.Errorf("Unexpected description %q", f.Description) + } + + if f.Version != "1.2.3" { + t.Errorf("Unexpected version %q", f.Version) + } + + if len(f.Maintainers) != 2 { + t.Errorf("Expected 2 maintainers, got %d", len(f.Maintainers)) + } + + if f.Maintainers[0].Name != "The Helm Team" { + t.Errorf("Unexpected maintainer name.") + } + + if f.Maintainers[1].Email != "nobody@example.com" { + t.Errorf("Unexpected maintainer email.") + } + + if len(f.Sources) != 1 { + t.Fatalf("Unexpected number of sources") + } + + if f.Sources[0] != "https://example.com/foo/bar" { + t.Errorf("Expected https://example.com/foo/bar, got %s", f.Sources) + } + + if f.Home != "http://example.com" { + t.Error("Unexpected home.") + } + + if f.Icon != "https://example.com/64x64.png" { + t.Errorf("Unexpected icon: %q", f.Icon) + } + + if len(f.Keywords) != 3 { + t.Error("Unexpected keywords") + } + + if len(f.Annotations) != 2 { + t.Fatalf("Unexpected annotations") + } + + if want, got := "extravalue", f.Annotations["extrakey"]; want != got { + t.Errorf("Want %q, but got %q", want, got) + } + + if want, got := "anothervalue", f.Annotations["anotherkey"]; want != got { + t.Errorf("Want %q, but got %q", want, got) + } + + kk := []string{"frobnitz", "sprocket", "dodad"} + for i, k := range f.Keywords { + if kk[i] != k { + t.Errorf("Expected %q, got %q", kk[i], k) + } + } +} + +func TestIsChartDir(t *testing.T) { + validChartDir, err := IsChartDir("testdata/frobnitz") + if !validChartDir { + t.Errorf("unexpected error while reading chart-directory: (%v)", err) + return + } + validChartDir, err = IsChartDir("testdata") + if validChartDir || err == nil { + t.Errorf("expected error but did not get any") + return + } +} diff --git a/pkg/helm/pkg/chartutil/compatible.go b/pkg/helm/intern/chart/v3/util/compatible.go similarity index 98% rename from pkg/helm/pkg/chartutil/compatible.go rename to pkg/helm/intern/chart/v3/util/compatible.go index f4656c91..d384d2d4 100644 --- a/pkg/helm/pkg/chartutil/compatible.go +++ b/pkg/helm/intern/chart/v3/util/compatible.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package chartutil +package util import "github.com/Masterminds/semver/v3" diff --git a/pkg/helm/pkg/chartutil/compatible_test.go b/pkg/helm/intern/chart/v3/util/compatible_test.go similarity index 98% rename from pkg/helm/pkg/chartutil/compatible_test.go rename to pkg/helm/intern/chart/v3/util/compatible_test.go index df7be616..e17d33e3 100644 --- a/pkg/helm/pkg/chartutil/compatible_test.go +++ b/pkg/helm/intern/chart/v3/util/compatible_test.go @@ -15,7 +15,7 @@ limitations under the License. */ // Package version represents the current version of the project. -package chartutil +package util import "testing" diff --git a/pkg/helm/intern/chart/v3/util/create.go b/pkg/helm/intern/chart/v3/util/create.go new file mode 100644 index 00000000..6051ba33 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/create.go @@ -0,0 +1,835 @@ +/* +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 util + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + + "sigs.k8s.io/yaml" + + chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" + "github.com/werf/nelm/pkg/helm/intern/chart/v3/loader" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" +) + +// chartName is a regular expression for testing the supplied name of a chart. +// This regular expression is probably stricter than it needs to be. We can relax it +// somewhat. Newline characters, as well as $, quotes, +, parens, and % are known to be +// problematic. +var chartName = regexp.MustCompile("^[a-zA-Z0-9._-]+$") + +const ( + // ChartfileName is the default Chart file name. + ChartfileName = "Chart.yaml" + // ValuesfileName is the default values file name. + ValuesfileName = "values.yaml" + // SchemafileName is the default values schema file name. + SchemafileName = "values.schema.json" + // TemplatesDir is the relative directory name for templates. + TemplatesDir = "templates" + // ChartsDir is the relative directory name for charts dependencies. + ChartsDir = "charts" + // TemplatesTestsDir is the relative directory name for tests. + TemplatesTestsDir = TemplatesDir + sep + "tests" + // IgnorefileName is the name of the Helm ignore file. + IgnorefileName = ".helmignore" + // IngressFileName is the name of the example ingress file. + IngressFileName = TemplatesDir + sep + "ingress.yaml" + // HTTPRouteFileName is the name of the example HTTPRoute file. + HTTPRouteFileName = TemplatesDir + sep + "httproute.yaml" + // DeploymentName is the name of the example deployment file. + DeploymentName = TemplatesDir + sep + "deployment.yaml" + // ServiceName is the name of the example service file. + ServiceName = TemplatesDir + sep + "service.yaml" + // ServiceAccountName is the name of the example serviceaccount file. + ServiceAccountName = TemplatesDir + sep + "serviceaccount.yaml" + // HorizontalPodAutoscalerName is the name of the example hpa file. + HorizontalPodAutoscalerName = TemplatesDir + sep + "hpa.yaml" + // NotesName is the name of the example NOTES.txt file. + NotesName = TemplatesDir + sep + "NOTES.txt" + // HelpersName is the name of the example helpers file. + HelpersName = TemplatesDir + sep + "_helpers.tpl" + // TestConnectionName is the name of the example test file. + TestConnectionName = TemplatesTestsDir + sep + "test-connection.yaml" +) + +// maxChartNameLength is lower than the limits we know of with certain file systems, +// and with certain Kubernetes fields. +const maxChartNameLength = 250 + +const sep = string(filepath.Separator) + +const defaultChartfile = `apiVersion: v3 +name: %s +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.16.0" +` + +const defaultValues = `# Default values for %s. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +# This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ +replicaCount: 1 + +# This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ +image: + repository: nginx + # This sets the pull policy for images. + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" + +# This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ +imagePullSecrets: [] +# This is to override the chart name. +nameOverride: "" +fullnameOverride: "" + +# This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ +serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +# This is for setting Kubernetes Annotations to a Pod. +# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ +podAnnotations: {} +# This is for setting Kubernetes Labels to a Pod. +# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ +podLabels: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +# This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/ +service: + # This sets the service type more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types + type: ClusterIP + # This sets the ports more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#field-spec-ports + port: 80 + +# This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/ +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +# -- Expose the service via gateway-api HTTPRoute +# Requires Gateway API resources and suitable controller installed within the cluster +# (see: https://gateway-api.sigs.k8s.io/guides/) +httpRoute: + # HTTPRoute enabled. + enabled: false + # HTTPRoute annotations. + annotations: {} + # Which Gateways this Route is attached to. + parentRefs: + - name: gateway + sectionName: http + # namespace: default + # Hostnames matching HTTP header. + hostnames: + - chart-example.local + # List of rules and filters applied. + rules: + - matches: + - path: + type: PathPrefix + value: /headers + # filters: + # - type: RequestHeaderModifier + # requestHeaderModifier: + # set: + # - name: My-Overwrite-Header + # value: this-is-the-only-value + # remove: + # - User-Agent + # - matches: + # - path: + # type: PathPrefix + # value: /echo + # headers: + # - name: version + # value: v2 + +resources: {} + # For publicly distributed charts, we recommend leaving 'resources' commented out. + # This makes resource allocation a conscious choice for the user and increases the chances + # charts run on a wide range of environments from low-resource clusters like Minikube to those + # with strict resource policies. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +# This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ +livenessProbe: + httpGet: + path: / + port: http +readinessProbe: + httpGet: + path: / + port: http + +# This section is for setting up autoscaling more information can be found here: https://kubernetes.io/docs/concepts/workloads/autoscaling/ +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +# Additional volumes on the output Deployment definition. +volumes: [] +# - name: foo +# secret: +# secretName: mysecret +# optional: false + +# Additional volumeMounts on the output Deployment definition. +volumeMounts: [] +# - name: foo +# mountPath: "/etc/foo" +# readOnly: true + +nodeSelector: {} + +tolerations: [] + +affinity: {} +` + +const defaultIgnore = `# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ +` + +const defaultIngress = `{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include ".fullname" . }} + labels: + {{- include ".labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- with .pathType }} + pathType: {{ . }} + {{- end }} + backend: + service: + name: {{ include ".fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} +` + +const defaultHTTPRoute = `{{- if .Values.httpRoute.enabled -}} +{{- $fullName := include ".fullname" . -}} +{{- $svcPort := .Values.service.port -}} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ $fullName }} + labels: + {{- include ".labels" . | nindent 4 }} + {{- with .Values.httpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + parentRefs: + {{- with .Values.httpRoute.parentRefs }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.httpRoute.hostnames }} + hostnames: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- range .Values.httpRoute.rules }} + {{- with .matches }} + - matches: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .filters }} + filters: + {{- toYaml . | nindent 8 }} + {{- end }} + backendRefs: + - name: {{ $fullName }} + port: {{ $svcPort }} + weight: 1 + {{- end }} +{{- end }} +` + +const defaultDeployment = `apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include ".fullname" . }} + labels: + {{- include ".labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include ".selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include ".labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include ".serviceAccountName" . }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + {{- with .Values.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +` + +const defaultService = `apiVersion: v1 +kind: Service +metadata: + name: {{ include ".fullname" . }} + labels: + {{- include ".labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include ".selectorLabels" . | nindent 4 }} +` + +const defaultServiceAccount = `{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include ".serviceAccountName" . }} + labels: + {{- include ".labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} +` + +const defaultHorizontalPodAutoscaler = `{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include ".fullname" . }} + labels: + {{- include ".labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include ".fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} +` + +const defaultNotes = `1. Get the application URL by running these commands: +{{- if .Values.httpRoute.enabled }} +{{- if .Values.httpRoute.hostnames }} + export APP_HOSTNAME={{ .Values.httpRoute.hostnames | first }} +{{- else }} + export APP_HOSTNAME=$(kubectl get --namespace {{(first .Values.httpRoute.parentRefs).namespace | default .Release.Namespace }} gateway/{{ (first .Values.httpRoute.parentRefs).name }} -o jsonpath="{.spec.listeners[0].hostname}") + {{- end }} +{{- if and .Values.httpRoute.rules (first .Values.httpRoute.rules).matches (first (first .Values.httpRoute.rules).matches).path.value }} + echo "Visit http://$APP_HOSTNAME{{ (first (first .Values.httpRoute.rules).matches).path.value }} to use your application" + + NOTE: Your HTTPRoute depends on the listener configuration of your gateway and your HTTPRoute rules. + The rules can be set for path, method, header and query parameters. + You can check the gateway configuration with 'kubectl get --namespace {{(first .Values.httpRoute.parentRefs).namespace | default .Release.Namespace }} gateway/{{ (first .Values.httpRoute.parentRefs).name }} -o yaml' +{{- end }} +{{- else if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include ".fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include ".fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include ".fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include ".name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT +{{- end }} +` + +const defaultHelpers = `{{/* +Expand the name of the chart. +*/}} +{{- define ".name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define ".fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define ".chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define ".labels" -}} +helm.sh/chart: {{ include ".chart" . }} +{{ include ".selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define ".selectorLabels" -}} +app.kubernetes.io/name: {{ include ".name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define ".serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include ".fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} +` + +const defaultTestConnection = `apiVersion: v1 +kind: Pod +metadata: + name: "{{ include ".fullname" . }}-test-connection" + labels: + {{- include ".labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['{{ include ".fullname" . }}:{{ .Values.service.port }}'] + restartPolicy: Never +` + +// Stderr is an io.Writer to which error messages can be written +// +// In Helm 4, this will be replaced. It is needed in Helm 3 to preserve API backward +// compatibility. +var Stderr io.Writer = os.Stderr + +// CreateFrom creates a new chart, but scaffolds it from the src chart. +func CreateFrom(chartfile *chart.Metadata, dest, src string) error { + schart, err := loader.Load(context.Background(), src) + if err != nil { + return fmt.Errorf("could not load %s: %w", src, err) + } + + schart.Metadata = chartfile + + var updatedTemplates []*common.File + + for _, template := range schart.Templates { + newData := transform(string(template.Data), schart.Name()) + updatedTemplates = append(updatedTemplates, &common.File{Name: template.Name, ModTime: template.ModTime, Data: newData}) + } + + schart.Templates = updatedTemplates + b, err := yaml.Marshal(schart.Values) + if err != nil { + return fmt.Errorf("reading values file: %w", err) + } + + var m map[string]interface{} + if err := yaml.Unmarshal(transform(string(b), schart.Name()), &m); err != nil { + return fmt.Errorf("transforming values file: %w", err) + } + schart.Values = m + + // SaveDir looks for the file values.yaml when saving rather than the values + // key in order to preserve the comments in the YAML. The name placeholder + // needs to be replaced on that file. + for _, f := range schart.Raw { + if f.Name == ValuesfileName { + f.Data = transform(string(f.Data), schart.Name()) + } + } + + return SaveDir(schart, dest) +} + +// Create creates a new chart in a directory. +// +// Inside of dir, this will create a directory based on the name of +// chartfile.Name. It will then write the Chart.yaml into this directory and +// create the (empty) appropriate directories. +// +// The returned string will point to the newly created directory. It will be +// an absolute path, even if the provided base directory was relative. +// +// If dir does not exist, this will return an error. +// If Chart.yaml or any directories cannot be created, this will return an +// error. In such a case, this will attempt to clean up by removing the +// new chart directory. +func Create(name, dir string) (string, error) { + + // Sanity-check the name of a chart so user doesn't create one that causes problems. + if err := validateChartName(name); err != nil { + return "", err + } + + path, err := filepath.Abs(dir) + if err != nil { + return path, err + } + + if fi, err := os.Stat(path); err != nil { + return path, err + } else if !fi.IsDir() { + return path, fmt.Errorf("no such directory %s", path) + } + + cdir := filepath.Join(path, name) + if fi, err := os.Stat(cdir); err == nil && !fi.IsDir() { + return cdir, fmt.Errorf("file %s already exists and is not a directory", cdir) + } + + // Note: If adding a new template below (i.e., to `helm create`) which is disabled by default (similar to hpa and + // ingress below); or making an existing template disabled by default, add the enabling condition in + // `TestHelmCreateChart_CheckDeprecatedWarnings` in `pkg/lint/lint_test.go` to make it run through deprecation checks + // with latest Kubernetes version. + files := []struct { + path string + content []byte + }{ + { + // Chart.yaml + path: filepath.Join(cdir, ChartfileName), + content: fmt.Appendf(nil, defaultChartfile, name), + }, + { + // values.yaml + path: filepath.Join(cdir, ValuesfileName), + content: fmt.Appendf(nil, defaultValues, name), + }, + { + // .helmignore + path: filepath.Join(cdir, IgnorefileName), + content: []byte(defaultIgnore), + }, + { + // ingress.yaml + path: filepath.Join(cdir, IngressFileName), + content: transform(defaultIngress, name), + }, + { + // httproute.yaml + path: filepath.Join(cdir, HTTPRouteFileName), + content: transform(defaultHTTPRoute, name), + }, + { + // deployment.yaml + path: filepath.Join(cdir, DeploymentName), + content: transform(defaultDeployment, name), + }, + { + // service.yaml + path: filepath.Join(cdir, ServiceName), + content: transform(defaultService, name), + }, + { + // serviceaccount.yaml + path: filepath.Join(cdir, ServiceAccountName), + content: transform(defaultServiceAccount, name), + }, + { + // hpa.yaml + path: filepath.Join(cdir, HorizontalPodAutoscalerName), + content: transform(defaultHorizontalPodAutoscaler, name), + }, + { + // NOTES.txt + path: filepath.Join(cdir, NotesName), + content: transform(defaultNotes, name), + }, + { + // _helpers.tpl + path: filepath.Join(cdir, HelpersName), + content: transform(defaultHelpers, name), + }, + { + // test-connection.yaml + path: filepath.Join(cdir, TestConnectionName), + content: transform(defaultTestConnection, name), + }, + } + + for _, file := range files { + if _, err := os.Stat(file.path); err == nil { + // There is no handle to a preferred output stream here. + fmt.Fprintf(Stderr, "WARNING: File %q already exists. Overwriting.\n", file.path) + } + if err := writeFile(file.path, file.content); err != nil { + return cdir, err + } + } + // Need to add the ChartsDir explicitly as it does not contain any file OOTB + if err := os.MkdirAll(filepath.Join(cdir, ChartsDir), 0755); err != nil { + return cdir, err + } + return cdir, nil +} + +// transform performs a string replacement of the specified source for +// a given key with the replacement string +func transform(src, replacement string) []byte { + return []byte(strings.ReplaceAll(src, "", replacement)) +} + +func writeFile(name string, content []byte) error { + if err := os.MkdirAll(filepath.Dir(name), 0755); err != nil { + return err + } + return os.WriteFile(name, content, 0644) +} + +func validateChartName(name string) error { + if name == "" || len(name) > maxChartNameLength { + return fmt.Errorf("chart name must be between 1 and %d characters", maxChartNameLength) + } + if !chartName.MatchString(name) { + return fmt.Errorf("chart name must match the regular expression %q", chartName.String()) + } + return nil +} diff --git a/pkg/helm/intern/chart/v3/util/create_test.go b/pkg/helm/intern/chart/v3/util/create_test.go new file mode 100644 index 00000000..3702d2d3 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/create_test.go @@ -0,0 +1,173 @@ +/* +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 util + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" + "github.com/werf/nelm/pkg/helm/intern/chart/v3/loader" +) + +func TestCreate(t *testing.T) { + tdir := t.TempDir() + + c, err := Create("foo", tdir) + if err != nil { + t.Fatal(err) + } + + dir := filepath.Join(tdir, "foo") + + mychart, err := loader.LoadDir(context.Background(), c) + if err != nil { + t.Fatalf("Failed to load newly created chart %q: %s", c, err) + } + + if mychart.Name() != "foo" { + t.Errorf("Expected name to be 'foo', got %q", mychart.Name()) + } + + for _, f := range []string{ + ChartfileName, + DeploymentName, + HelpersName, + IgnorefileName, + NotesName, + ServiceAccountName, + ServiceName, + TemplatesDir, + TemplatesTestsDir, + TestConnectionName, + ValuesfileName, + } { + if _, err := os.Stat(filepath.Join(dir, f)); err != nil { + t.Errorf("Expected %s file: %s", f, err) + } + } +} + +func TestCreateFrom(t *testing.T) { + tdir := t.TempDir() + + cf := &chart.Metadata{ + APIVersion: chart.APIVersionV3, + Name: "foo", + Version: "0.1.0", + } + srcdir := "./testdata/frobnitz/charts/mariner" + + if err := CreateFrom(cf, tdir, srcdir); err != nil { + t.Fatal(err) + } + + dir := filepath.Join(tdir, "foo") + c := filepath.Join(tdir, cf.Name) + mychart, err := loader.LoadDir(context.Background(), c) + if err != nil { + t.Fatalf("Failed to load newly created chart %q: %s", c, err) + } + + if mychart.Name() != "foo" { + t.Errorf("Expected name to be 'foo', got %q", mychart.Name()) + } + + for _, f := range []string{ + ChartfileName, + ValuesfileName, + filepath.Join(TemplatesDir, "placeholder.tpl"), + } { + if _, err := os.Stat(filepath.Join(dir, f)); err != nil { + t.Errorf("Expected %s file: %s", f, err) + } + + // Check each file to make sure has been replaced + b, err := os.ReadFile(filepath.Join(dir, f)) + if err != nil { + t.Errorf("Unable to read file %s: %s", f, err) + } + if bytes.Contains(b, []byte("")) { + t.Errorf("File %s contains ", f) + } + } +} + +// TestCreate_Overwrite is a regression test for making sure that files are overwritten. +func TestCreate_Overwrite(t *testing.T) { + tdir := t.TempDir() + + var errlog bytes.Buffer + + if _, err := Create("foo", tdir); err != nil { + t.Fatal(err) + } + + dir := filepath.Join(tdir, "foo") + + tplname := filepath.Join(dir, "templates/hpa.yaml") + writeFile(tplname, []byte("FOO")) + + // Now re-run the create + Stderr = &errlog + if _, err := Create("foo", tdir); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(tplname) + if err != nil { + t.Fatal(err) + } + + if string(data) == "FOO" { + t.Fatal("File that should have been modified was not.") + } + + if errlog.Len() == 0 { + t.Errorf("Expected warnings about overwriting files.") + } +} + +func TestValidateChartName(t *testing.T) { + for name, shouldPass := range map[string]bool{ + "": false, + "abcdefghijklmnopqrstuvwxyz-_.": true, + "ABCDEFGHIJKLMNOPQRSTUVWXYZ-_.": true, + "$hello": false, + "Hellô": false, + "he%%o": false, + "he\nllo": false, + + "abcdefghijklmnopqrstuvwxyz-_." + + "abcdefghijklmnopqrstuvwxyz-_." + + "abcdefghijklmnopqrstuvwxyz-_." + + "abcdefghijklmnopqrstuvwxyz-_." + + "abcdefghijklmnopqrstuvwxyz-_." + + "abcdefghijklmnopqrstuvwxyz-_." + + "abcdefghijklmnopqrstuvwxyz-_." + + "abcdefghijklmnopqrstuvwxyz-_." + + "abcdefghijklmnopqrstuvwxyz-_." + + "ABCDEFGHIJKLMNOPQRSTUVWXYZ-_.": false, + } { + if err := validateChartName(name); (err != nil) == shouldPass { + t.Errorf("test for %q failed", name) + } + } +} diff --git a/pkg/helm/intern/chart/v3/util/dependencies.go b/pkg/helm/intern/chart/v3/util/dependencies.go new file mode 100644 index 00000000..a9da2756 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/dependencies.go @@ -0,0 +1,381 @@ +/* +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 util + +import ( + "fmt" + "log/slog" + "strings" + + chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" + "github.com/werf/nelm/pkg/helm/intern/copystructure" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + "github.com/werf/nelm/pkg/helm/pkg/chart/common/util" +) + +// ProcessDependencies checks through this chart's dependencies, processing accordingly. +func ProcessDependencies(c *chart.Chart, v common.Values) error { + if err := processDependencyEnabled(c, v, ""); err != nil { + return err + } + return processDependencyImportValues(c, true) +} + +// processDependencyConditions disables charts based on condition path value in values +func processDependencyConditions(reqs []*chart.Dependency, cvals common.Values, cpath string) { + if reqs == nil { + return + } + for _, r := range reqs { + for c := range strings.SplitSeq(strings.TrimSpace(r.Condition), ",") { + if len(c) > 0 { + // retrieve value + vv, err := cvals.PathValue(cpath + c) + if err == nil { + // if not bool, warn + if bv, ok := vv.(bool); ok { + r.Enabled = bv + break + } + slog.Warn("returned non-bool value", "path", c, "chart", r.Name) + } else if _, ok := err.(common.ErrNoValue); !ok { + // this is a real error + slog.Warn("the method PathValue returned error", slog.Any("error", err)) + } + } + } + } +} + +// processDependencyTags disables charts based on tags in values +func processDependencyTags(reqs []*chart.Dependency, cvals common.Values) { + if reqs == nil { + return + } + vt, err := cvals.Table("tags") + if err != nil { + return + } + for _, r := range reqs { + var hasTrue, hasFalse bool + for _, k := range r.Tags { + if b, ok := vt[k]; ok { + // if not bool, warn + if bv, ok := b.(bool); ok { + if bv { + hasTrue = true + } else { + hasFalse = true + } + } else { + slog.Warn("returned non-bool value", "tag", k, "chart", r.Name) + } + } + } + if !hasTrue && hasFalse { + r.Enabled = false + } else if hasTrue || !hasTrue && !hasFalse { + r.Enabled = true + } + } +} + +// getAliasDependency finds the chart for an alias dependency and copies parts that will be modified +func getAliasDependency(charts []*chart.Chart, dep *chart.Dependency) *chart.Chart { + for _, c := range charts { + if c == nil { + continue + } + if c.Name() != dep.Name { + continue + } + if !IsCompatibleRange(dep.Version, c.Metadata.Version) { + continue + } + + out := *c + out.Metadata = copyMetadata(c.Metadata) + + // empty dependencies and shallow copy all dependencies, otherwise parent info may be corrupted if + // there is more than one dependency aliasing this chart + out.SetDependencies() + for _, dependency := range c.Dependencies() { + cpy := *dependency + out.AddDependency(&cpy) + } + + if dep.Alias != "" { + out.Metadata.Name = dep.Alias + } + return &out + } + return nil +} + +func copyMetadata(metadata *chart.Metadata) *chart.Metadata { + md := *metadata + + if md.Dependencies != nil { + dependencies := make([]*chart.Dependency, len(md.Dependencies)) + for i := range md.Dependencies { + dependency := *md.Dependencies[i] + dependencies[i] = &dependency + } + md.Dependencies = dependencies + } + return &md +} + +// processDependencyEnabled removes disabled charts from dependencies +func processDependencyEnabled(c *chart.Chart, v map[string]interface{}, path string) error { + if c.Metadata.Dependencies == nil { + return nil + } + + var chartDependencies []*chart.Chart + // If any dependency is not a part of Chart.yaml + // then this should be added to chartDependencies. + // However, if the dependency is already specified in Chart.yaml + // we should not add it, as it would be processed from Chart.yaml anyway. + +Loop: + for _, existing := range c.Dependencies() { + for _, req := range c.Metadata.Dependencies { + if existing.Name() == req.Name && IsCompatibleRange(req.Version, existing.Metadata.Version) { + continue Loop + } + } + chartDependencies = append(chartDependencies, existing) + } + + for _, req := range c.Metadata.Dependencies { + if req == nil { + continue + } + if chartDependency := getAliasDependency(c.Dependencies(), req); chartDependency != nil { + chartDependencies = append(chartDependencies, chartDependency) + } + if req.Alias != "" { + req.Name = req.Alias + } + } + c.SetDependencies(chartDependencies...) + + // set all to true + for _, lr := range c.Metadata.Dependencies { + lr.Enabled = true + } + cvals, err := util.CoalesceValues(c, v) + if err != nil { + return err + } + // flag dependencies as enabled/disabled + processDependencyTags(c.Metadata.Dependencies, cvals) + processDependencyConditions(c.Metadata.Dependencies, cvals, path) + // make a map of charts to remove + rm := map[string]struct{}{} + for _, r := range c.Metadata.Dependencies { + if !r.Enabled { + // remove disabled chart + rm[r.Name] = struct{}{} + } + } + // don't keep disabled charts in new slice + cd := []*chart.Chart{} + copy(cd, c.Dependencies()[:0]) + for _, n := range c.Dependencies() { + if _, ok := rm[n.Metadata.Name]; !ok { + cd = append(cd, n) + } + } + // don't keep disabled charts in metadata + cdMetadata := []*chart.Dependency{} + copy(cdMetadata, c.Metadata.Dependencies[:0]) + for _, n := range c.Metadata.Dependencies { + if _, ok := rm[n.Name]; !ok { + cdMetadata = append(cdMetadata, n) + } + } + + // recursively call self to process sub dependencies + for _, t := range cd { + subpath := path + t.Metadata.Name + "." + if err := processDependencyEnabled(t, cvals, subpath); err != nil { + return err + } + } + // set the correct dependencies in metadata + c.Metadata.Dependencies = nil + c.Metadata.Dependencies = append(c.Metadata.Dependencies, cdMetadata...) + c.SetDependencies(cd...) + + return nil +} + +// pathToMap creates a nested map given a YAML path in dot notation. +func pathToMap(path string, data map[string]interface{}) map[string]interface{} { + if path == "." { + return data + } + return set(parsePath(path), data) +} + +func parsePath(key string) []string { return strings.Split(key, ".") } + +func set(path []string, data map[string]interface{}) map[string]interface{} { + if len(path) == 0 { + return nil + } + cur := data + for i := len(path) - 1; i >= 0; i-- { + cur = map[string]interface{}{path[i]: cur} + } + return cur +} + +// processImportValues merges values from child to parent based on the chart's dependencies' ImportValues field. +func processImportValues(c *chart.Chart, merge bool) error { + if c.Metadata.Dependencies == nil { + return nil + } + // combine chart values and empty config to get Values + var cvals common.Values + var err error + if merge { + cvals, err = util.MergeValues(c, nil) + } else { + cvals, err = util.CoalesceValues(c, nil) + } + if err != nil { + return err + } + b := make(map[string]interface{}) + // import values from each dependency if specified in import-values + for _, r := range c.Metadata.Dependencies { + var outiv []interface{} + for _, riv := range r.ImportValues { + switch iv := riv.(type) { + case map[string]interface{}: + child := fmt.Sprintf("%v", iv["child"]) + parent := fmt.Sprintf("%v", iv["parent"]) + + outiv = append(outiv, map[string]string{ + "child": child, + "parent": parent, + }) + + // get child table + vv, err := cvals.Table(r.Name + "." + child) + if err != nil { + slog.Warn( + "ImportValues missing table from chart", + slog.String("chart", "chart"), + slog.String("name", r.Name), + slog.Any("error", err), + ) + continue + } + // create value map from child to be merged into parent + if merge { + b = util.MergeTables(b, pathToMap(parent, vv.AsMap())) + } else { + b = util.CoalesceTables(b, pathToMap(parent, vv.AsMap())) + } + case string: + child := "exports." + iv + outiv = append(outiv, map[string]string{ + "child": child, + "parent": ".", + }) + vm, err := cvals.Table(r.Name + "." + child) + if err != nil { + slog.Warn("ImportValues missing table", slog.Any("error", err)) + continue + } + if merge { + b = util.MergeTables(b, vm.AsMap()) + } else { + b = util.CoalesceTables(b, vm.AsMap()) + } + } + } + r.ImportValues = outiv + } + + // Imported values from a child to a parent chart have a lower priority than + // the parents values. This enables parent charts to import a large section + // from a child and then override select parts. This is why b is merged into + // cvals in the code below and not the other way around. + if merge { + // deep copying the cvals as there are cases where pointers can end + // up in the cvals when they are copied onto b in ways that break things. + cvals = deepCopyMap(cvals) + c.Values = util.MergeTables(cvals, b) + } else { + // Trimming the nil values from cvals is needed for backwards compatibility. + // Previously, the b value had been populated with cvals along with some + // overrides. This caused the coalescing functionality to remove the + // nil/null values. This trimming is for backwards compat. + cvals = trimNilValues(cvals) + c.Values = util.CoalesceTables(cvals, b) + } + + return nil +} + +func deepCopyMap(vals map[string]interface{}) map[string]interface{} { + valsCopy, err := copystructure.Copy(vals) + if err != nil { + return vals + } + return valsCopy.(map[string]interface{}) +} + +func trimNilValues(vals map[string]interface{}) map[string]interface{} { + valsCopy, err := copystructure.Copy(vals) + if err != nil { + return vals + } + valsCopyMap := valsCopy.(map[string]interface{}) + for key, val := range valsCopyMap { + if val == nil { + // Iterate over the values and remove nil keys + delete(valsCopyMap, key) + } else if istable(val) { + // Recursively call into ourselves to remove keys from inner tables + valsCopyMap[key] = trimNilValues(val.(map[string]interface{})) + } + } + + return valsCopyMap +} + +// istable is a special-purpose function to see if the present thing matches the definition of a YAML table. +func istable(v interface{}) bool { + _, ok := v.(map[string]interface{}) + return ok +} + +// processDependencyImportValues imports specified chart values from child to parent. +func processDependencyImportValues(c *chart.Chart, merge bool) error { + for _, d := range c.Dependencies() { + // recurse + if err := processDependencyImportValues(d, merge); err != nil { + return err + } + } + return processImportValues(c, merge) +} diff --git a/pkg/helm/pkg/chartutil/dependencies_test.go b/pkg/helm/intern/chart/v3/util/dependencies_test.go similarity index 83% rename from pkg/helm/pkg/chartutil/dependencies_test.go rename to pkg/helm/intern/chart/v3/util/dependencies_test.go index c198c3ba..62b2725b 100644 --- a/pkg/helm/pkg/chartutil/dependencies_test.go +++ b/pkg/helm/intern/chart/v3/util/dependencies_test.go @@ -12,22 +12,24 @@ 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 chartutil +package util import ( + "context" "os" "path/filepath" "sort" "strconv" "testing" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chart/loader" + chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" + "github.com/werf/nelm/pkg/helm/intern/chart/v3/loader" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" ) func loadChart(t *testing.T, path string) *chart.Chart { t.Helper() - c, err := loader.Load(path) + c, err := loader.Load(context.Background(), path) if err != nil { t.Fatalf("failed to load testdata: %s", err) } @@ -133,7 +135,7 @@ func TestDependencyEnabled(t *testing.T) { } } -// extractCharts recursively searches chart dependencies returning all charts found +// extractChartNames recursively searches chart dependencies returning all charts found func extractChartNames(c *chart.Chart) []string { var out []string var fn func(c *chart.Chart) @@ -218,10 +220,10 @@ func TestProcessDependencyImportValues(t *testing.T) { e["SCBexported2A"] = "blaster" e["global.SC1exported2.all.SC1exported3"] = "SC1expstr" - if err := processDependencyImportExportValues(c, false); err != nil { + if err := processDependencyImportValues(c, false); err != nil { t.Fatalf("processing import values dependencies %v", err) } - cc := Values(c.Values) + cc := common.Values(c.Values) for kk, vv := range e { pv, err := cc.PathValue(kk) if err != nil { @@ -251,7 +253,7 @@ func TestProcessDependencyImportValues(t *testing.T) { t.Error("expect nil value not found but found it") } switch xerr := err.(type) { - case ErrNoValue: + case common.ErrNoValue: // We found what we expected default: t.Errorf("expected an ErrNoValue but got %q instead", xerr) @@ -261,7 +263,7 @@ func TestProcessDependencyImportValues(t *testing.T) { if err := processDependencyImportValues(c, true); err != nil { t.Fatalf("processing import values dependencies %v", err) } - cc = Values(c.Values) + cc = common.Values(c.Values) val, err := cc.PathValue("ensurenull") if err != nil { t.Error("expect value but ensurenull was not found") @@ -271,6 +273,38 @@ func TestProcessDependencyImportValues(t *testing.T) { } } +func TestProcessDependencyImportValuesFromSharedDependencyToAliases(t *testing.T) { + c := loadChart(t, "testdata/chart-with-import-from-aliased-dependencies") + + if err := processDependencyEnabled(c, c.Values, ""); err != nil { + t.Fatalf("expected no errors but got %q", err) + } + if err := processDependencyImportValues(c, true); err != nil { + t.Fatalf("processing import values dependencies %v", err) + } + e := make(map[string]string) + + e["foo-defaults.defaultValue"] = "42" + e["bar-defaults.defaultValue"] = "42" + + e["foo.defaults.defaultValue"] = "42" + e["bar.defaults.defaultValue"] = "42" + + e["foo.grandchild.defaults.defaultValue"] = "42" + e["bar.grandchild.defaults.defaultValue"] = "42" + + cValues := common.Values(c.Values) + for kk, vv := range e { + pv, err := cValues.PathValue(kk) + if err != nil { + t.Fatalf("retrieving import values table %v %v", kk, err) + } + if pv != vv { + t.Errorf("failed to match imported value %v with expected %v", pv, vv) + } + } +} + func TestProcessDependencyImportValuesMultiLevelPrecedence(t *testing.T) { c := loadChart(t, "testdata/three-level-dependent-chart/umbrella") @@ -294,10 +328,10 @@ func TestProcessDependencyImportValuesMultiLevelPrecedence(t *testing.T) { e["app2.service.port"] = "8080" e["app3.service.port"] = "9090" e["app4.service.port"] = "1234" - if err := processDependencyImportExportValues(c, true); err != nil { + if err := processDependencyImportValues(c, true); err != nil { t.Fatalf("processing import values dependencies %v", err) } - cc := Values(c.Values) + cc := common.Values(c.Values) for kk, vv := range e { pv, err := cc.PathValue(kk) if err != nil { @@ -317,79 +351,11 @@ func TestProcessDependencyImportValuesMultiLevelPrecedence(t *testing.T) { } } -func TestProcessDependencyExportValues(t *testing.T) { - c := loadChart(t, "testdata/subpop") - - e := make(map[string]string) - - // merge with no overrides - e["subchart1.exported-parent.SPExtra7"] = "exported-from-parent" - e["subchart1.exported-parent.SPExtra10"] = "should-be-unchanged" - e["subchart1.exported-parent.SPNested1.SPExtra19"] = "exported-from-parent-n6" - - // single value export - e["subchart1.exported-single-value-parent"] = "exported-from-parent-n7" - - // merge with overrides - e["subchart1.exported-overridden-parent.SC1bool"] = "true" - e["subchart1.exported-overridden-parent.SC1float"] = "22.2" - e["subchart1.exported-overridden-parent.SC1int"] = "222" - e["subchart1.exported-overridden-parent.SC1string"] = "exported-from-parent-n2" - e["subchart1.exported-overridden-parent.SPExtra8"] = "exported-from-parent-n3" - e["subchart1.exported-overridden-parent.SPExtra11"] = "should-be-unchanged" - - // `exports` style, no overrides - e["subchart1.exported-short-parent.SPExtra9"] = "exported-from-parent-n4" - e["subchart1.exported-short-parent.SPExtra12"] = "should-be-unchanged" - - // passed from child to its own child with overrides - e["subchart1.subcharta.exported-overridden-chart1.SCAbool"] = "true" - e["subchart1.subcharta.exported-overridden-chart1.SCAfloat"] = "33.3" - e["subchart1.subcharta.exported-overridden-chart1.SCAint"] = "333" - e["subchart1.subcharta.exported-overridden-chart1.SCAstring"] = "exported-from-chart1" - e["subchart1.subcharta.exported-overridden-chart1.SPExtra13"] = "exported-from-chart1-n2" - e["subchart1.subcharta.exported-overridden-chart1.SPExtra15"] = "should-be-unchanged" - - // passed from child to its own child, `exports` style, no overrides - e["subchart1.subcharta.exported-short-chart1.SPExtra14"] = "exported-from-chart1-n3" - e["subchart1.subcharta.exported-short-chart1.SPExtra16"] = "should-be-unchanged" - - // passed through from parent to the child of the child chart, no overrides - e["subchart1.subcharta.exported-passthrough.SPExtra17"] = "exported-from-parent-n5" - e["subchart1.subcharta.exported-passthrough.SPExtra18"] = "should-be-unchanged" - - if err := processDependencyImportExportValues(c, true); err != nil { - t.Fatalf("processing export values dependencies %v", err) - } - cc := Values(c.Values) - for kk, vv := range e { - pv, err := cc.PathValue(kk) - if err != nil { - t.Errorf("retrieving export values table %v %v", kk, err) - } - - switch pv := pv.(type) { - case float64: - if s := strconv.FormatFloat(pv, 'f', -1, 64); s != vv { - t.Errorf("failed to match exported float value %v with expected %v", s, vv) - } - case bool: - if b := strconv.FormatBool(pv); b != vv { - t.Errorf("failed to match exported bool value %v with expected %v", b, vv) - } - default: - if pv != vv { - t.Errorf("failed to match exported string value %q with expected %q", pv, vv) - } - } - } -} - func TestProcessDependencyImportValuesForEnabledCharts(t *testing.T) { c := loadChart(t, "testdata/import-values-from-enabled-subchart/parent-chart") nameOverride := "parent-chart-prod" - if err := processDependencyImportExportValues(c, true); err != nil { + if err := processDependencyImportValues(c, true); err != nil { t.Fatalf("processing import values dependencies %v", err) } @@ -479,6 +445,9 @@ func TestDependentChartAliases(t *testing.T) { if aliasChart == nil { t.Fatalf("failed to get dependency chart for alias %s", req[2].Name) } + if aliasChart.Parent() != c { + t.Fatalf("dependency chart has wrong parent, expected %s but got %s", c.Name(), aliasChart.Parent().Name()) + } if req[2].Alias != "" { if aliasChart.Name() != req[2].Alias { t.Fatalf("dependency chart name should be %s but got %s", req[2].Alias, aliasChart.Name()) @@ -570,3 +539,33 @@ func TestDependentChartsWithSomeSubchartsSpecifiedInDependency(t *testing.T) { t.Fatalf("expected 1 dependency specified in Chart.yaml, got %d", len(c.Metadata.Dependencies)) } } + +func validateDependencyTree(t *testing.T, c *chart.Chart) { + t.Helper() + for _, dependency := range c.Dependencies() { + if dependency.Parent() != c { + if dependency.Parent() != c { + t.Fatalf("dependency chart %s has wrong parent, expected %s but got %s", dependency.Name(), c.Name(), dependency.Parent().Name()) + } + } + // recurse entire tree + validateDependencyTree(t, dependency) + } +} + +func TestChartWithDependencyAliasedTwiceAndDoublyReferencedSubDependency(t *testing.T) { + c := loadChart(t, "testdata/chart-with-dependency-aliased-twice") + + if len(c.Dependencies()) != 1 { + t.Fatalf("expected one dependency for this chart, but got %d", len(c.Dependencies())) + } + + if err := processDependencyEnabled(c, c.Values, ""); err != nil { + t.Fatalf("expected no errors but got %q", err) + } + + if len(c.Dependencies()) != 2 { + t.Fatal("expected two dependencies after processing aliases") + } + validateDependencyTree(t, c) +} diff --git a/pkg/helm/intern/chart/v3/util/doc.go b/pkg/helm/intern/chart/v3/util/doc.go new file mode 100644 index 00000000..9a71f056 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/doc.go @@ -0,0 +1,45 @@ +/* +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 util contains tools for working with charts. + +Charts are described in the chart package (pkg/chart). +This package provides utilities for serializing and deserializing charts. + +A chart can be represented on the file system in one of two ways: + + - As a directory that contains a Chart.yaml file and other chart things. + - As a tarred gzipped file containing a directory that then contains a + Chart.yaml file. + +This package provides utilities for working with those file formats. + +The preferred way of loading a chart is using 'loader.Load`: + + chart, err := loader.Load(filename) + +This will attempt to discover whether the file at 'filename' is a directory or +a chart archive. It will then load accordingly. + +For accepting raw compressed tar file data from an io.Reader, the +'loader.LoadArchive()' will read in the data, uncompress it, and unpack it +into a Chart. + +When creating charts in memory, use the 'github.com/werf/nelm/pkg/helm/pkg/chart' +package directly. +*/ +package util // import chartutil "github.com/werf/nelm/pkg/helm/intern/chart/v3/util" diff --git a/pkg/helm/intern/chart/v3/util/expand.go b/pkg/helm/intern/chart/v3/util/expand.go new file mode 100644 index 00000000..7a9584bf --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/expand.go @@ -0,0 +1,94 @@ +/* +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 util + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + + securejoin "github.com/cyphar/filepath-securejoin" + "sigs.k8s.io/yaml" + + chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" + "github.com/werf/nelm/pkg/helm/pkg/chart/loader/archive" +) + +// Expand uncompresses and extracts a chart into the specified directory. +func Expand(dir string, r io.Reader) error { + files, err := archive.LoadArchiveFiles(r) + if err != nil { + return err + } + + // Get the name of the chart + var chartName string + for _, file := range files { + if file.Name == "Chart.yaml" { + ch := &chart.Metadata{} + if err := yaml.Unmarshal(file.Data, ch); err != nil { + return fmt.Errorf("cannot load Chart.yaml: %w", err) + } + chartName = ch.Name + } + } + if chartName == "" { + return errors.New("chart name not specified") + } + + // Find the base directory + // The directory needs to be cleaned prior to passing to SecureJoin or the location may end up + // being wrong or returning an error. This was introduced in v0.4.0. + dir = filepath.Clean(dir) + chartdir, err := securejoin.SecureJoin(dir, chartName) + if err != nil { + return err + } + + // Copy all files verbatim. We don't parse these files because parsing can remove + // comments. + for _, file := range files { + outpath, err := securejoin.SecureJoin(chartdir, file.Name) + if err != nil { + return err + } + + // Make sure the necessary subdirs get created. + basedir := filepath.Dir(outpath) + if err := os.MkdirAll(basedir, 0755); err != nil { + return err + } + + if err := os.WriteFile(outpath, file.Data, 0644); err != nil { + return err + } + } + + return nil +} + +// ExpandFile expands the src file into the dest directory. +func ExpandFile(dest, src string) error { + h, err := os.Open(src) + if err != nil { + return err + } + defer h.Close() + return Expand(dest, h) +} diff --git a/pkg/helm/pkg/chartutil/expand_test.go b/pkg/helm/intern/chart/v3/util/expand_test.go similarity index 95% rename from pkg/helm/pkg/chartutil/expand_test.go rename to pkg/helm/intern/chart/v3/util/expand_test.go index f31a3d29..280995f7 100644 --- a/pkg/helm/pkg/chartutil/expand_test.go +++ b/pkg/helm/intern/chart/v3/util/expand_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package chartutil +package util import ( "os" @@ -64,7 +64,7 @@ func TestExpand(t *testing.T) { t.Fatal(err) } // os.Stat can return different values for directories, based on the OS - // for Linux, for example, os.Stat alwaty returns the size of the directory + // for Linux, for example, os.Stat always returns the size of the directory // (value-4096) regardless of the size of the contents of the directory mode := expect.Mode() if !mode.IsDir() { @@ -112,7 +112,7 @@ func TestExpandFile(t *testing.T) { t.Fatal(err) } // os.Stat can return different values for directories, based on the OS - // for Linux, for example, os.Stat alwaty returns the size of the directory + // for Linux, for example, os.Stat always returns the size of the directory // (value-4096) regardless of the size of the contents of the directory mode := expect.Mode() if !mode.IsDir() { diff --git a/pkg/helm/intern/chart/v3/util/save.go b/pkg/helm/intern/chart/v3/util/save.go new file mode 100644 index 00000000..4be195c4 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/save.go @@ -0,0 +1,257 @@ +/* +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 util + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "time" + + "sigs.k8s.io/yaml" + + chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" +) + +var headerBytes = []byte("+aHR0cHM6Ly95b3V0dS5iZS96OVV6MWljandyTQo=") + +// SaveDir saves a chart as files in a directory. +// +// This takes the chart name, and creates a new subdirectory inside of the given dest +// directory, writing the chart's contents to that subdirectory. +func SaveDir(c *chart.Chart, dest string) error { + // Create the chart directory + err := validateName(c.Name()) + if err != nil { + return err + } + outdir := filepath.Join(dest, c.Name()) + if fi, err := os.Stat(outdir); err == nil && !fi.IsDir() { + return fmt.Errorf("file %s already exists and is not a directory", outdir) + } + if err := os.MkdirAll(outdir, 0755); err != nil { + return err + } + + // Save the chart file. + if err := SaveChartfile(filepath.Join(outdir, ChartfileName), c.Metadata); err != nil { + return err + } + + // Save values.yaml + for _, f := range c.Raw { + if f.Name == ValuesfileName { + vf := filepath.Join(outdir, ValuesfileName) + if err := writeFile(vf, f.Data); err != nil { + return err + } + } + } + + // Save values.schema.json if it exists + if c.Schema != nil { + filename := filepath.Join(outdir, SchemafileName) + if err := writeFile(filename, c.Schema); err != nil { + return err + } + } + + // Save templates and files + for _, o := range [][]*common.File{c.Templates, c.Files} { + for _, f := range o { + n := filepath.Join(outdir, f.Name) + if err := writeFile(n, f.Data); err != nil { + return err + } + } + } + + // Save dependencies + base := filepath.Join(outdir, ChartsDir) + for _, dep := range c.Dependencies() { + // Here, we write each dependency as a tar file. + if _, err := Save(dep, base); err != nil { + return fmt.Errorf("saving %s: %w", dep.ChartFullPath(), err) + } + } + return nil +} + +// Save creates an archived chart to the given directory. +// +// This takes an existing chart and a destination directory. +// +// If the directory is /foo, and the chart is named bar, with version 1.0.0, this +// will generate /foo/bar-1.0.0.tgz. +// +// This returns the absolute path to the chart archive file. +func Save(c *chart.Chart, outDir string) (string, error) { + if err := c.Validate(); err != nil { + return "", fmt.Errorf("chart validation: %w", err) + } + + filename := fmt.Sprintf("%s-%s.tgz", c.Name(), c.Metadata.Version) + filename = filepath.Join(outDir, filename) + dir := filepath.Dir(filename) + if stat, err := os.Stat(dir); err != nil { + if errors.Is(err, fs.ErrNotExist) { + if err2 := os.MkdirAll(dir, 0755); err2 != nil { + return "", err2 + } + } else { + return "", fmt.Errorf("stat %s: %w", dir, err) + } + } else if !stat.IsDir() { + return "", fmt.Errorf("is not a directory: %s", dir) + } + + f, err := os.Create(filename) + if err != nil { + return "", err + } + + // Wrap in gzip writer + zipper := gzip.NewWriter(f) + zipper.Extra = headerBytes + zipper.Comment = "Helm" + + // Wrap in tar writer + twriter := tar.NewWriter(zipper) + rollback := false + defer func() { + twriter.Close() + zipper.Close() + f.Close() + if rollback { + os.Remove(filename) + } + }() + + if err := writeTarContents(twriter, c, ""); err != nil { + rollback = true + return filename, err + } + return filename, nil +} + +func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error { + err := validateName(c.Name()) + if err != nil { + return err + } + base := filepath.Join(prefix, c.Name()) + + // Save Chart.yaml + cdata, err := yaml.Marshal(c.Metadata) + if err != nil { + return err + } + if err := writeToTar(out, filepath.Join(base, ChartfileName), cdata, c.ModTime); err != nil { + return err + } + + // Save Chart.lock + if c.Lock != nil { + ldata, err := yaml.Marshal(c.Lock) + if err != nil { + return err + } + if err := writeToTar(out, filepath.Join(base, "Chart.lock"), ldata, c.Lock.Generated); err != nil { + return err + } + } + + // Save values.yaml + for _, f := range c.Raw { + if f.Name == ValuesfileName { + if err := writeToTar(out, filepath.Join(base, ValuesfileName), f.Data, f.ModTime); err != nil { + return err + } + } + } + + // Save values.schema.json if it exists + if c.Schema != nil { + if !json.Valid(c.Schema) { + return errors.New("invalid JSON in " + SchemafileName) + } + if err := writeToTar(out, filepath.Join(base, SchemafileName), c.Schema, c.SchemaModTime); err != nil { + return err + } + } + + // Save templates + for _, f := range c.Templates { + n := filepath.Join(base, f.Name) + if err := writeToTar(out, n, f.Data, f.ModTime); err != nil { + return err + } + } + + // Save files + for _, f := range c.Files { + n := filepath.Join(base, f.Name) + if err := writeToTar(out, n, f.Data, f.ModTime); err != nil { + return err + } + } + + // Save dependencies + for _, dep := range c.Dependencies() { + if err := writeTarContents(out, dep, filepath.Join(base, ChartsDir)); err != nil { + return err + } + } + return nil +} + +// writeToTar writes a single file to a tar archive. +func writeToTar(out *tar.Writer, name string, body []byte, modTime time.Time) error { + // TODO: Do we need to create dummy parent directory names if none exist? + h := &tar.Header{ + Name: filepath.ToSlash(name), + Mode: 0644, + Size: int64(len(body)), + ModTime: modTime, + } + if h.ModTime.IsZero() { + h.ModTime = time.Now() + } + if err := out.WriteHeader(h); err != nil { + return err + } + _, err := out.Write(body) + return err +} + +// If the name has directory name has characters which would change the location +// they need to be removed. +func validateName(name string) error { + nname := filepath.Base(name) + + if nname != name { + return common.ErrInvalidChartName{Name: name} + } + + return nil +} diff --git a/pkg/helm/intern/chart/v3/util/save_test.go b/pkg/helm/intern/chart/v3/util/save_test.go new file mode 100644 index 00000000..b7d8abec --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/save_test.go @@ -0,0 +1,358 @@ +/* +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 util + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" + "github.com/werf/nelm/pkg/helm/intern/chart/v3/loader" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" +) + +func TestSave(t *testing.T) { + tmp := t.TempDir() + + for _, dest := range []string{tmp, filepath.Join(tmp, "newdir")} { + t.Run("outDir="+dest, func(t *testing.T) { + c := &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV3, + Name: "ahab", + Version: "1.2.3", + }, + Lock: &chart.Lock{ + Digest: "testdigest", + }, + Files: []*common.File{ + {Name: "scheherazade/shahryar.txt", ModTime: time.Now(), Data: []byte("1,001 Nights")}, + }, + Schema: []byte("{\n \"title\": \"Values\"\n}"), + } + chartWithInvalidJSON := withSchema(*c, []byte("{")) + + where, err := Save(c, dest) + if err != nil { + t.Fatalf("Failed to save: %s", err) + } + if !strings.HasPrefix(where, dest) { + t.Fatalf("Expected %q to start with %q", where, dest) + } + if !strings.HasSuffix(where, ".tgz") { + t.Fatalf("Expected %q to end with .tgz", where) + } + + c2, err := loader.LoadFile(context.Background(), where) + if err != nil { + t.Fatal(err) + } + if c2.Name() != c.Name() { + t.Fatalf("Expected chart archive to have %q, got %q", c.Name(), c2.Name()) + } + if len(c2.Files) != 1 || c2.Files[0].Name != "scheherazade/shahryar.txt" { + t.Fatal("Files data did not match") + } + + if !bytes.Equal(c.Schema, c2.Schema) { + indentation := 4 + formattedExpected := Indent(indentation, string(c.Schema)) + formattedActual := Indent(indentation, string(c2.Schema)) + t.Fatalf("Schema data did not match.\nExpected:\n%s\nActual:\n%s", formattedExpected, formattedActual) + } + if _, err := Save(&chartWithInvalidJSON, dest); err == nil { + t.Fatalf("Invalid JSON was not caught while saving chart") + } + + c.Metadata.APIVersion = chart.APIVersionV3 + where, err = Save(c, dest) + if err != nil { + t.Fatalf("Failed to save: %s", err) + } + c2, err = loader.LoadFile(context.Background(), where) + if err != nil { + t.Fatal(err) + } + if c2.Lock == nil { + t.Fatal("Expected v3 chart archive to contain a Chart.lock file") + } + if c2.Lock.Digest != c.Lock.Digest { + t.Fatal("Chart.lock data did not match") + } + }) + } + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV3, + Name: "../ahab", + Version: "1.2.3", + }, + Lock: &chart.Lock{ + Digest: "testdigest", + }, + Files: []*common.File{ + {Name: "scheherazade/shahryar.txt", ModTime: time.Now(), Data: []byte("1,001 Nights")}, + }, + } + _, err := Save(c, tmp) + if err == nil { + t.Fatal("Expected error saving chart with invalid name") + } +} + +// Creates a copy with a different schema; does not modify anything. +func withSchema(chart chart.Chart, schema []byte) chart.Chart { + chart.Schema = schema + return chart +} + +func Indent(n int, text string) string { + startOfLine := regexp.MustCompile(`(?m)^`) + indentation := strings.Repeat(" ", n) + return startOfLine.ReplaceAllLiteralString(text, indentation) +} + +func TestSavePreservesTimestamps(t *testing.T) { + // Test executes so quickly that if we don't subtract a second, the + // check will fail because `initialCreateTime` will be identical to the + // written timestamp for the files. + initialCreateTime := time.Now().Add(-1 * time.Second) + tmp := t.TempDir() + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV3, + Name: "ahab", + Version: "1.2.3", + }, + ModTime: initialCreateTime, + Values: map[string]interface{}{ + "imageName": "testimage", + "imageId": 42, + }, + Files: []*common.File{ + {Name: "scheherazade/shahryar.txt", ModTime: initialCreateTime, Data: []byte("1,001 Nights")}, + }, + Schema: []byte("{\n \"title\": \"Values\"\n}"), + SchemaModTime: initialCreateTime, + } + + where, err := Save(c, tmp) + if err != nil { + t.Fatalf("Failed to save: %s", err) + } + + allHeaders, err := retrieveAllHeadersFromTar(where) + if err != nil { + t.Fatalf("Failed to parse tar: %v", err) + } + + roundedTime := initialCreateTime.Round(time.Second) + for _, header := range allHeaders { + if !header.ModTime.Equal(roundedTime) { + t.Fatalf("File timestamp not preserved: %v", header.ModTime) + } + } +} + +// We could refactor `load.go` to use this `retrieveAllHeadersFromTar` function +// as well, so we are not duplicating components of the code which iterate +// through the tar. +func retrieveAllHeadersFromTar(path string) ([]*tar.Header, error) { + raw, err := os.Open(path) + if err != nil { + return nil, err + } + defer raw.Close() + + unzipped, err := gzip.NewReader(raw) + if err != nil { + return nil, err + } + defer unzipped.Close() + + tr := tar.NewReader(unzipped) + headers := []*tar.Header{} + for { + hd, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + + if err != nil { + return nil, err + } + + headers = append(headers, hd) + } + + return headers, nil +} + +func TestSaveDir(t *testing.T) { + tmp := t.TempDir() + modTime := time.Now() + + c := &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV3, + Name: "ahab", + Version: "1.2.3", + }, + Files: []*common.File{ + {Name: "scheherazade/shahryar.txt", ModTime: modTime, Data: []byte("1,001 Nights")}, + }, + Templates: []*common.File{ + {Name: path.Join(TemplatesDir, "nested", "dir", "thing.yaml"), ModTime: modTime, Data: []byte("abc: {{ .Values.abc }}")}, + }, + } + + if err := SaveDir(c, tmp); err != nil { + t.Fatalf("Failed to save: %s", err) + } + + c2, err := loader.LoadDir(context.Background(), tmp+"/ahab") + if err != nil { + t.Fatal(err) + } + + if c2.Name() != c.Name() { + t.Fatalf("Expected chart archive to have %q, got %q", c.Name(), c2.Name()) + } + + if len(c2.Templates) != 1 || c2.Templates[0].Name != c.Templates[0].Name { + t.Fatal("Templates data did not match") + } + + if len(c2.Files) != 1 || c2.Files[0].Name != c.Files[0].Name { + t.Fatal("Files data did not match") + } + + tmp2 := t.TempDir() + c.Metadata.Name = "../ahab" + pth := filepath.Join(tmp2, "tmpcharts") + if err := os.MkdirAll(filepath.Join(pth), 0755); err != nil { + t.Fatal(err) + } + + if err := SaveDir(c, pth); err.Error() != "\"../ahab\" is not a valid chart name" { + t.Fatalf("Did not get expected error for chart named %q", c.Name()) + } +} + +func TestRepeatableSave(t *testing.T) { + tmp := t.TempDir() + defer os.RemoveAll(tmp) + modTime := time.Date(2021, 9, 1, 20, 34, 58, 651387237, time.UTC) + tests := []struct { + name string + chart *chart.Chart + want string + }{ + { + name: "Package 1 file", + chart: &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV3, + Name: "ahab", + Version: "1.2.3", + }, + ModTime: modTime, + Lock: &chart.Lock{ + Digest: "testdigest", + Generated: modTime, + }, + Files: []*common.File{ + {Name: "scheherazade/shahryar.txt", ModTime: modTime, Data: []byte("1,001 Nights")}, + }, + Schema: []byte("{\n \"title\": \"Values\"\n}"), + SchemaModTime: modTime, + }, + want: "5bfea18cc3c8cbc265744bc32bffa9489a4dbe87d6b51b90f4255e4839d35e03", + }, + { + name: "Package 2 files", + chart: &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV3, + Name: "ahab", + Version: "1.2.3", + }, + ModTime: modTime, + Lock: &chart.Lock{ + Digest: "testdigest", + Generated: modTime, + }, + Files: []*common.File{ + {Name: "scheherazade/shahryar.txt", ModTime: modTime, Data: []byte("1,001 Nights")}, + {Name: "scheherazade/dunyazad.txt", ModTime: modTime, Data: []byte("1,001 Nights again")}, + }, + Schema: []byte("{\n \"title\": \"Values\"\n}"), + SchemaModTime: modTime, + }, + want: "a240365c21e0a2f4a57873132a9b686566a612d08bcb3f20c9446bfff005ccce", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // create package + dest := path.Join(tmp, "newdir") + where, err := Save(test.chart, dest) + if err != nil { + t.Fatalf("Failed to save: %s", err) + } + // get shasum for package + result, err := sha256Sum(where) + if err != nil { + t.Fatalf("Failed to check shasum: %s", err) + } + // assert that the package SHA is what we wanted. + if result != test.want { + t.Errorf("FormatName() result = %v, want %v", result, test.want) + } + }) + } +} + +func sha256Sum(filePath string) (string, error) { + f, err := os.Open(filePath) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + + return fmt.Sprintf("%x", h.Sum(nil)), nil +} diff --git a/pkg/helm/intern/chart/v3/util/testdata/chart-with-dependency-aliased-twice/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/chart-with-dependency-aliased-twice/Chart.yaml new file mode 100644 index 00000000..4a4da799 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/chart-with-dependency-aliased-twice/Chart.yaml @@ -0,0 +1,14 @@ +apiVersion: v3 +appVersion: 1.0.0 +name: chart-with-dependency-aliased-twice +type: application +version: 1.0.0 + +dependencies: + - name: child + alias: foo + version: 1.0.0 + - name: child + alias: bar + version: 1.0.0 + diff --git a/pkg/helm/intern/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/Chart.yaml new file mode 100644 index 00000000..0f3afd8c --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v3 +appVersion: 1.0.0 +name: child +type: application +version: 1.0.0 + diff --git a/pkg/helm/intern/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/Chart.yaml new file mode 100644 index 00000000..3e0bf725 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v3 +appVersion: 1.0.0 +name: grandchild +type: application +version: 1.0.0 + diff --git a/pkg/helm/intern/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/templates/dummy.yaml b/pkg/helm/intern/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/templates/dummy.yaml new file mode 100644 index 00000000..1830492e --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/templates/dummy.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Chart.Name }}-{{ .Values.from }} +data: + {{- toYaml .Values | nindent 2 }} + diff --git a/pkg/helm/intern/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/templates/dummy.yaml b/pkg/helm/intern/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/templates/dummy.yaml new file mode 100644 index 00000000..b5d55af7 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/templates/dummy.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Chart.Name }} +data: + {{- toYaml .Values | nindent 2 }} + diff --git a/pkg/helm/intern/chart/v3/util/testdata/chart-with-dependency-aliased-twice/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/chart-with-dependency-aliased-twice/values.yaml new file mode 100644 index 00000000..695521a4 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/chart-with-dependency-aliased-twice/values.yaml @@ -0,0 +1,7 @@ +foo: + grandchild: + from: foo +bar: + grandchild: + from: bar + diff --git a/pkg/helm/intern/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/Chart.yaml new file mode 100644 index 00000000..f2f0610b --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/Chart.yaml @@ -0,0 +1,20 @@ +apiVersion: v3 +appVersion: 1.0.0 +name: chart-with-dependency-aliased-twice +type: application +version: 1.0.0 + +dependencies: + - name: child + alias: foo + version: 1.0.0 + import-values: + - parent: foo-defaults + child: defaults + - name: child + alias: bar + version: 1.0.0 + import-values: + - parent: bar-defaults + child: defaults + diff --git a/pkg/helm/intern/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/Chart.yaml new file mode 100644 index 00000000..08ccac9e --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/Chart.yaml @@ -0,0 +1,12 @@ +apiVersion: v3 +appVersion: 1.0.0 +name: child +type: application +version: 1.0.0 + +dependencies: + - name: grandchild + version: 1.0.0 + import-values: + - parent: defaults + child: defaults diff --git a/pkg/helm/intern/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/Chart.yaml new file mode 100644 index 00000000..3e0bf725 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v3 +appVersion: 1.0.0 +name: grandchild +type: application +version: 1.0.0 + diff --git a/pkg/helm/intern/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/values.yaml new file mode 100644 index 00000000..f51c594f --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/values.yaml @@ -0,0 +1,2 @@ +defaults: + defaultValue: "42" \ No newline at end of file diff --git a/pkg/helm/intern/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/templates/dummy.yaml b/pkg/helm/intern/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/templates/dummy.yaml new file mode 100644 index 00000000..3140f53d --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/templates/dummy.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Chart.Name }} +data: + {{ .Values.defaults | toYaml }} + diff --git a/pkg/helm/intern/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/templates/dummy.yaml b/pkg/helm/intern/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/templates/dummy.yaml new file mode 100644 index 00000000..a2b62c95 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/templates/dummy.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Chart.Name }} +data: + {{ toYaml .Values.defaults | indent 2 }} + diff --git a/pkg/helm/intern/chart/v3/util/testdata/chartfiletest.yaml b/pkg/helm/intern/chart/v3/util/testdata/chartfiletest.yaml new file mode 100644 index 00000000..d222c8f8 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/chartfiletest.yaml @@ -0,0 +1,20 @@ +apiVersion: v3 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +annotations: + extrakey: extravalue + anotherkey: anothervalue diff --git a/pkg/helm/pkg/chartutil/testdata/coleridge.yaml b/pkg/helm/intern/chart/v3/util/testdata/coleridge.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/coleridge.yaml rename to pkg/helm/intern/chart/v3/util/testdata/coleridge.yaml diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/.helmignore b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/.helmignore similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/.helmignore rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/.helmignore diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/Chart.lock b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/Chart.lock similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/Chart.lock rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/Chart.lock diff --git a/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/Chart.yaml new file mode 100644 index 00000000..b8773d0d --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/Chart.yaml @@ -0,0 +1,29 @@ +apiVersion: v3 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts + alias: mariners2 + - name: mariner + version: "4.3.2" + repository: https://example.com/charts + alias: mariners1 diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/INSTALL.txt b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/INSTALL.txt similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/INSTALL.txt rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/INSTALL.txt diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_dev_null/LICENSE b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/LICENSE similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_dev_null/LICENSE rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/LICENSE diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/README.md b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/README.md similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/README.md rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/README.md diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/_ignore_me b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/charts/_ignore_me similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/_ignore_me rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/charts/_ignore_me diff --git a/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/Chart.yaml new file mode 100644 index 00000000..2a2c9c88 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: alpine +description: Deploy a basic Alpine Linux pod +version: 0.1.0 +home: https://helm.sh/helm diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/README.md b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/README.md similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/README.md rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/README.md diff --git a/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/charts/mast1/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/charts/mast1/Chart.yaml new file mode 100644 index 00000000..aea109c7 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/charts/mast1/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: mast1 +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/charts/mast1/values.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/charts/mast1/values.yaml diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/charts/mast2-0.1.0.tgz similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast2-0.1.0.tgz rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/charts/mast2-0.1.0.tgz diff --git a/pkg/helm/cmd/helm/testdata/testcharts/signtest/alpine/templates/alpine-pod.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/templates/alpine-pod.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/signtest/alpine/templates/alpine-pod.yaml rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/templates/alpine-pod.yaml diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/values.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/values.yaml diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/mariner-4.3.2.tgz b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/charts/mariner-4.3.2.tgz similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/mariner-4.3.2.tgz rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/charts/mariner-4.3.2.tgz diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/docs/README.md b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/docs/README.md similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/docs/README.md rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/docs/README.md diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/icon.svg b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/icon.svg similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/icon.svg rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/icon.svg diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/ignore/me.txt b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/ignore/me.txt similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/ignore/me.txt rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/ignore/me.txt diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/templates/template.tpl b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/templates/template.tpl similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/templates/template.tpl rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/templates/template.tpl diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/values.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-alias/values.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/.helmignore b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/.helmignore similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/.helmignore rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/.helmignore diff --git a/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/Chart.yaml new file mode 100644 index 00000000..8b4ad8cd --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/Chart.yaml @@ -0,0 +1,17 @@ +apiVersion: v3 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/charts/.ignore_me b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/charts/.ignore_me similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/charts/.ignore_me rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/charts/.ignore_me diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/charts/_ignore_me b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/charts/_ignore_me similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/charts/_ignore_me rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/charts/_ignore_me diff --git a/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/Chart.yaml new file mode 100644 index 00000000..2a2c9c88 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: alpine +description: Deploy a basic Alpine Linux pod +version: 0.1.0 +home: https://helm.sh/helm diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/README.md b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/README.md similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/README.md rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/README.md diff --git a/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/Chart.yaml new file mode 100644 index 00000000..aea109c7 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: mast1 +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/charts/mast1/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/charts/mast1/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/values.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast2-0.1.0.tgz similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/charts/mast2-0.1.0.tgz rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast2-0.1.0.tgz diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/templates/alpine-pod.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/templates/alpine-pod.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/templates/alpine-pod.yaml rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/templates/alpine-pod.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/values.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/templates/template.tpl b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/templates/template.tpl similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/templates/template.tpl rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/templates/template.tpl diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-helmignore/values.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/.helmignore b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/.helmignore similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/.helmignore rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/.helmignore diff --git a/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/Chart.yaml new file mode 100644 index 00000000..8b4ad8cd --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/Chart.yaml @@ -0,0 +1,17 @@ +apiVersion: v3 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/INSTALL.txt b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/INSTALL.txt similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/INSTALL.txt rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/INSTALL.txt diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/LICENSE b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/LICENSE similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/LICENSE rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/LICENSE diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/README.md b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/README.md similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/README.md rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/README.md diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/charts/_ignore_me b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/_ignore_me similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/charts/_ignore_me rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/_ignore_me diff --git a/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/Chart.yaml new file mode 100644 index 00000000..2a2c9c88 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: alpine +description: Deploy a basic Alpine Linux pod +version: 0.1.0 +home: https://helm.sh/helm diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/README.md b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/README.md similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/README.md rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/README.md diff --git a/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml new file mode 100644 index 00000000..aea109c7 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: mast1 +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/values.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/charts/mast2-0.1.0.tgz rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/templates/alpine-pod.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/templates/alpine-pod.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/templates/alpine-pod.yaml rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/templates/alpine-pod.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/values.yaml diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz/charts/mariner-4.3.2.tgz b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/mariner-4.3.2.tgz similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz/charts/mariner-4.3.2.tgz rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/mariner-4.3.2.tgz diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/docs/README.md b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/docs/README.md similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/docs/README.md rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/docs/README.md diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/icon.svg b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/icon.svg similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/icon.svg rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/icon.svg diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/ignore/me.txt b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/ignore/me.txt similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/ignore/me.txt rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/ignore/me.txt diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/templates/template.tpl b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/templates/template.tpl similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/templates/template.tpl rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/templates/template.tpl diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/values.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/.helmignore b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/.helmignore similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/.helmignore rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/.helmignore diff --git a/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/Chart.yaml new file mode 100644 index 00000000..06283093 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v3 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/INSTALL.txt b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/INSTALL.txt similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/INSTALL.txt rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/INSTALL.txt diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/LICENSE b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/LICENSE similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/LICENSE rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/LICENSE diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/README.md b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/README.md similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/README.md rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/README.md diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/_ignore_me b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/_ignore_me similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/_ignore_me rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/_ignore_me diff --git a/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/Chart.yaml new file mode 100644 index 00000000..2a2c9c88 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: alpine +description: Deploy a basic Alpine Linux pod +version: 0.1.0 +home: https://helm.sh/helm diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/README.md b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/README.md similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/README.md rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/README.md diff --git a/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml new file mode 100644 index 00000000..aea109c7 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: mast1 +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/values.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/templates/alpine-pod.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/templates/alpine-pod.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/templates/alpine-pod.yaml rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/templates/alpine-pod.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/values.yaml diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_backslash/charts/mariner-4.3.2.tgz b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/mariner-4.3.2.tgz old mode 100755 new mode 100644 similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_backslash/charts/mariner-4.3.2.tgz rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/mariner-4.3.2.tgz diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/docs/README.md b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/docs/README.md similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/docs/README.md rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/docs/README.md diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/icon.svg b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/icon.svg similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/icon.svg rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/icon.svg diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/ignore/me.txt b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/ignore/me.txt similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/ignore/me.txt rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/ignore/me.txt diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/templates/template.tpl b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/templates/template.tpl similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/templates/template.tpl rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/templates/template.tpl diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/values.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/.helmignore b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/.helmignore similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/.helmignore rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/.helmignore diff --git a/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/Chart.yaml new file mode 100644 index 00000000..6543799d --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/Chart.yaml @@ -0,0 +1,21 @@ +apiVersion: v3 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/INSTALL.txt b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/INSTALL.txt similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/INSTALL.txt rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/INSTALL.txt diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/LICENSE b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/LICENSE similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/LICENSE rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/LICENSE diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/README.md b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/README.md similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/README.md rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/README.md diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/_ignore_me b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/_ignore_me similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/_ignore_me rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/_ignore_me diff --git a/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/Chart.yaml new file mode 100644 index 00000000..2a2c9c88 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: alpine +description: Deploy a basic Alpine Linux pod +version: 0.1.0 +home: https://helm.sh/helm diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/README.md b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/README.md similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/README.md rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/README.md diff --git a/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml new file mode 100644 index 00000000..aea109c7 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: mast1 +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/values.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/templates/alpine-pod.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/templates/alpine-pod.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/templates/alpine-pod.yaml rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/templates/alpine-pod.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/values.yaml diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/charts/mariner-4.3.2.tgz b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/mariner-4.3.2.tgz similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/charts/mariner-4.3.2.tgz rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/mariner-4.3.2.tgz diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/docs/README.md b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/docs/README.md similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/docs/README.md rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/docs/README.md diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/icon.svg b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/icon.svg similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/icon.svg rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/icon.svg diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/ignore/me.txt b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/ignore/me.txt similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/ignore/me.txt rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/ignore/me.txt diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/templates/template.tpl b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/templates/template.tpl similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/templates/template.tpl rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/templates/template.tpl diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/values.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz-1.2.3.tgz b/pkg/helm/intern/chart/v3/util/testdata/frobnitz-1.2.3.tgz similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz-1.2.3.tgz rename to pkg/helm/intern/chart/v3/util/testdata/frobnitz-1.2.3.tgz diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/.helmignore b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/.helmignore similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/.helmignore rename to pkg/helm/intern/chart/v3/util/testdata/frobnitz/.helmignore diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/Chart.lock b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/Chart.lock similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/Chart.lock rename to pkg/helm/intern/chart/v3/util/testdata/frobnitz/Chart.lock diff --git a/pkg/helm/intern/chart/v3/util/testdata/frobnitz/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/Chart.yaml new file mode 100644 index 00000000..1b63fc3e --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/Chart.yaml @@ -0,0 +1,27 @@ +apiVersion: v3 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +annotations: + extrakey: extravalue + anotherkey: anothervalue +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/INSTALL.txt b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/INSTALL.txt similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/INSTALL.txt rename to pkg/helm/intern/chart/v3/util/testdata/frobnitz/INSTALL.txt diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/LICENSE b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/LICENSE similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/LICENSE rename to pkg/helm/intern/chart/v3/util/testdata/frobnitz/LICENSE diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/README.md b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/README.md similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/README.md rename to pkg/helm/intern/chart/v3/util/testdata/frobnitz/README.md diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/_ignore_me b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/_ignore_me similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/_ignore_me rename to pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/_ignore_me diff --git a/pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/alpine/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/alpine/Chart.yaml new file mode 100644 index 00000000..2a2c9c88 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/alpine/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: alpine +description: Deploy a basic Alpine Linux pod +version: 0.1.0 +home: https://helm.sh/helm diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/README.md b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/alpine/README.md similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/README.md rename to pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/alpine/README.md diff --git a/pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml new file mode 100644 index 00000000..aea109c7 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: mast1 +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz rename to pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/templates/alpine-pod.yaml b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/templates/alpine-pod.yaml rename to pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/alpine/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/alpine/values.yaml diff --git a/pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/mariner/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/mariner/Chart.yaml new file mode 100644 index 00000000..4d3eea73 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/mariner/Chart.yaml @@ -0,0 +1,9 @@ +apiVersion: v3 +name: mariner +description: A Helm chart for Kubernetes +version: 4.3.2 +home: "" +dependencies: + - name: albatross + repository: https://example.com/mariner/charts + version: "0.1.0" diff --git a/pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/mariner/charts/albatross/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/mariner/charts/albatross/Chart.yaml new file mode 100644 index 00000000..da605991 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/mariner/charts/albatross/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: albatross +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz/charts/mariner/charts/albatross/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/mariner/charts/albatross/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz/charts/mariner/charts/albatross/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/mariner/charts/albatross/values.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz/charts/mariner/templates/placeholder.tpl b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/mariner/templates/placeholder.tpl similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz/charts/mariner/templates/placeholder.tpl rename to pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/mariner/templates/placeholder.tpl diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz/charts/mariner/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/mariner/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz/charts/mariner/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/frobnitz/charts/mariner/values.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/docs/README.md b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/docs/README.md similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/docs/README.md rename to pkg/helm/intern/chart/v3/util/testdata/frobnitz/docs/README.md diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/icon.svg b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/icon.svg similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/icon.svg rename to pkg/helm/intern/chart/v3/util/testdata/frobnitz/icon.svg diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/ignore/me.txt b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/ignore/me.txt similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/ignore/me.txt rename to pkg/helm/intern/chart/v3/util/testdata/frobnitz/ignore/me.txt diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/templates/template.tpl b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/templates/template.tpl similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/templates/template.tpl rename to pkg/helm/intern/chart/v3/util/testdata/frobnitz/templates/template.tpl diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/frobnitz/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/frobnitz/values.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz_backslash-1.2.3.tgz b/pkg/helm/intern/chart/v3/util/testdata/frobnitz_backslash-1.2.3.tgz similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz_backslash-1.2.3.tgz rename to pkg/helm/intern/chart/v3/util/testdata/frobnitz_backslash-1.2.3.tgz diff --git a/pkg/helm/pkg/chart/loader/testdata/genfrob.sh b/pkg/helm/intern/chart/v3/util/testdata/genfrob.sh similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/genfrob.sh rename to pkg/helm/intern/chart/v3/util/testdata/genfrob.sh diff --git a/pkg/helm/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/Chart.lock b/pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/Chart.lock similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/Chart.lock rename to pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/Chart.lock diff --git a/pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/Chart.yaml new file mode 100644 index 00000000..0b3e9958 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/Chart.yaml @@ -0,0 +1,22 @@ +apiVersion: v3 +name: parent-chart +version: v0.1.0 +appVersion: v0.1.0 +dependencies: + - name: dev + repository: "file://envs/dev" + version: ">= 0.0.1" + condition: dev.enabled,global.dev.enabled + tags: + - dev + import-values: + - data + + - name: prod + repository: "file://envs/prod" + version: ">= 0.0.1" + condition: prod.enabled,global.prod.enabled + tags: + - prod + import-values: + - data \ No newline at end of file diff --git a/pkg/helm/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/charts/dev-v0.1.0.tgz b/pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/charts/dev-v0.1.0.tgz similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/charts/dev-v0.1.0.tgz rename to pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/charts/dev-v0.1.0.tgz diff --git a/pkg/helm/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/charts/prod-v0.1.0.tgz b/pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/charts/prod-v0.1.0.tgz similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/charts/prod-v0.1.0.tgz rename to pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/charts/prod-v0.1.0.tgz diff --git a/pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/Chart.yaml new file mode 100644 index 00000000..72427c09 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v3 +name: dev +version: v0.1.0 +appVersion: v0.1.0 \ No newline at end of file diff --git a/pkg/helm/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/values.yaml diff --git a/pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/Chart.yaml new file mode 100644 index 00000000..058ab394 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v3 +name: prod +version: v0.1.0 +appVersion: v0.1.0 \ No newline at end of file diff --git a/pkg/helm/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/values.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/templates/autoscaler.yaml b/pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/templates/autoscaler.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/templates/autoscaler.yaml rename to pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/templates/autoscaler.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/values.yaml similarity index 80% rename from pkg/helm/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/values.yaml index 258be983..b812f0a3 100644 --- a/pkg/helm/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/values.yaml +++ b/pkg/helm/intern/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/values.yaml @@ -7,4 +7,4 @@ resources: autoscaler: minReplicas: 0 maxReplicas: 0 - targetCPUUtilizationPercentage: 99 + targetCPUUtilizationPercentage: 99 \ No newline at end of file diff --git a/pkg/helm/intern/chart/v3/util/testdata/joonix/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/joonix/Chart.yaml new file mode 100644 index 00000000..1860a3df --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/joonix/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v3 +description: A Helm chart for Kubernetes +name: joonix +version: 1.2.3 diff --git a/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/echo/completion.yaml b/pkg/helm/intern/chart/v3/util/testdata/joonix/charts/.gitkeep similarity index 100% rename from pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/echo/completion.yaml rename to pkg/helm/intern/chart/v3/util/testdata/joonix/charts/.gitkeep diff --git a/pkg/helm/intern/chart/v3/util/testdata/subpop/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/Chart.yaml new file mode 100644 index 00000000..53e9ec50 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/subpop/Chart.yaml @@ -0,0 +1,41 @@ +apiVersion: v3 +description: A Helm chart for Kubernetes +name: parentchart +version: 0.1.0 +dependencies: + - name: subchart1 + repository: http://localhost:10191 + version: 0.1.0 + condition: subchart1.enabled + tags: + - front-end + - subchart1 + import-values: + - child: SC1data + parent: imported-chart1 + - child: SC1data + parent: overridden-chart1 + - child: imported-chartA + parent: imported-chartA + - child: imported-chartA-B + parent: imported-chartA-B + - child: overridden-chartA-B + parent: overridden-chartA-B + - child: SCBexported1A + parent: . + - SCBexported2 + - SC1exported1 + + - name: subchart2 + repository: http://localhost:10191 + version: 0.1.0 + condition: subchart2.enabled + tags: + - back-end + - subchart2 + + - name: subchart2 + alias: subchart2alias + repository: http://localhost:10191 + version: 0.1.0 + condition: subchart2alias.enabled diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/README.md b/pkg/helm/intern/chart/v3/util/testdata/subpop/README.md similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/subpop/README.md rename to pkg/helm/intern/chart/v3/util/testdata/subpop/README.md diff --git a/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/Chart.yaml new file mode 100644 index 00000000..1539fb97 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/Chart.yaml @@ -0,0 +1,36 @@ +apiVersion: v3 +description: A Helm chart for Kubernetes +name: subchart1 +version: 0.1.0 +dependencies: + - name: subcharta + repository: http://localhost:10191 + version: 0.1.0 + condition: subcharta.enabled + tags: + - front-end + - subcharta + import-values: + - child: SCAdata + parent: imported-chartA + - child: SCAdata + parent: overridden-chartA + - child: SCAdata + parent: imported-chartA-B + + - name: subchartb + repository: http://localhost:10191 + version: 0.1.0 + condition: subchartb.enabled + import-values: + - child: SCBdata + parent: imported-chartB + - child: SCBdata + parent: imported-chartA-B + - child: exports.SCBexported2 + parent: exports.SCBexported2 + - SCBexported1 + + tags: + - front-end + - subchartb diff --git a/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartA/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartA/Chart.yaml new file mode 100644 index 00000000..2755a821 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartA/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v3 +description: A Helm chart for Kubernetes +name: subcharta +version: 0.1.0 diff --git a/pkg/helm/cmd/helm/testdata/testcharts/subchart/charts/subchartA/templates/service.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartA/templates/service.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/subchart/charts/subchartA/templates/service.yaml rename to pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartA/templates/service.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/subchart/charts/subchartA/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartA/values.yaml similarity index 99% rename from pkg/helm/cmd/helm/testdata/testcharts/subchart/charts/subchartA/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartA/values.yaml index 6932b1c0..f0381ae6 100644 --- a/pkg/helm/cmd/helm/testdata/testcharts/subchart/charts/subchartA/values.yaml +++ b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartA/values.yaml @@ -14,3 +14,4 @@ SCAdata: SCAstring: "jabba" SCAnested1: SCAnested2: true + diff --git a/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartB/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartB/Chart.yaml new file mode 100644 index 00000000..bf12fe8f --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartB/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v3 +description: A Helm chart for Kubernetes +name: subchartb +version: 0.1.0 diff --git a/pkg/helm/cmd/helm/testdata/testcharts/subchart/charts/subchartB/templates/service.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartB/templates/service.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/subchart/charts/subchartB/templates/service.yaml rename to pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartB/templates/service.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartB/values.yaml similarity index 99% rename from pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartB/values.yaml index 7cc8bede..774fdd75 100644 --- a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB/values.yaml +++ b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartB/values.yaml @@ -30,3 +30,6 @@ global: metadata: all: port: 8775 + + + diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/crds/crdA.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/crds/crdA.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/crds/crdA.yaml rename to pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/crds/crdA.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/subchart/templates/NOTES.txt b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/templates/NOTES.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/subchart/templates/NOTES.txt rename to pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/templates/NOTES.txt diff --git a/pkg/helm/cmd/helm/testdata/testcharts/subchart/templates/service.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/templates/service.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/subchart/templates/service.yaml rename to pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/templates/service.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/templates/subdir/role.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/templates/subdir/role.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/templates/subdir/role.yaml rename to pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/templates/subdir/role.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/subchart/templates/subdir/rolebinding.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/templates/subdir/rolebinding.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/subchart/templates/subdir/rolebinding.yaml rename to pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/templates/subdir/rolebinding.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/subchart/templates/subdir/serviceaccount.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/templates/subdir/serviceaccount.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/subchart/templates/subdir/serviceaccount.yaml rename to pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/templates/subdir/serviceaccount.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/values.yaml similarity index 60% rename from pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/values.yaml index e87c171f..a974e316 100644 --- a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/values.yaml +++ b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart1/values.yaml @@ -8,6 +8,7 @@ service: externalPort: 80 internalPort: 80 + SC1data: SC1bool: true SC1float: 3.14 @@ -28,26 +29,6 @@ overridden-chartA: imported-chartA-B: SC1extra5: "tiller" -exported-parent: - SPExtra10: "should-be-unchanged" - -exported-overridden-parent: - SC1bool: false - SC1float: 11.1 - SC1int: 111 - SC1string: "should-be-overridden" - SPExtra11: "should-be-unchanged" - -exported-short-parent: - SPExtra12: "should-be-unchanged" - -exported-overridden-chart1: - SCAbool: true - SCAfloat: 33.3 - SCAint: 333 - SCAstring: "exported-from-chart1" - SPExtra13: "exported-from-chart1-n2" - overridden-chartA-B: SCAbool: true SCAfloat: 3.33 @@ -71,7 +52,4 @@ exports: global: SC1exported2: all: - SC1exported3: "SC1expstr" - exported-short-chart1: - exported-short-chart1: - SPExtra14: "exported-from-chart1-n3" + SC1exported3: "SC1expstr" \ No newline at end of file diff --git a/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/Chart.yaml new file mode 100644 index 00000000..e7765704 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/Chart.yaml @@ -0,0 +1,19 @@ +apiVersion: v3 +description: A Helm chart for Kubernetes +name: subchart2 +version: 0.1.0 +dependencies: + - name: subchartb + repository: http://localhost:10191 + version: 0.1.0 + condition: subchartb.enabled + tags: + - back-end + - subchartb + - name: subchartc + repository: http://localhost:10191 + version: 0.1.0 + condition: subchartc.enabled + tags: + - back-end + - subchartc diff --git a/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartB/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartB/Chart.yaml new file mode 100644 index 00000000..bf12fe8f --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartB/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v3 +description: A Helm chart for Kubernetes +name: subchartb +version: 0.1.0 diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/templates/service.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartB/templates/service.yaml similarity index 85% rename from pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/templates/service.yaml rename to pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartB/templates/service.yaml index 3f168bdb..fb3dfc44 100644 --- a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/templates/service.yaml +++ b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartB/templates/service.yaml @@ -3,7 +3,7 @@ kind: Service metadata: name: subchart2-{{ .Chart.Name }} labels: - helm.sh/hart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" spec: type: {{ .Values.service.type }} ports: diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartB/values.yaml similarity index 99% rename from pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartB/values.yaml index fad554f7..5e5b2106 100644 --- a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC/values.yaml +++ b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartB/values.yaml @@ -18,3 +18,4 @@ resources: requests: cpu: 100m memory: 128Mi + diff --git a/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartC/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartC/Chart.yaml new file mode 100644 index 00000000..e8c0ef5e --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartC/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v3 +description: A Helm chart for Kubernetes +name: subchartc +version: 0.1.0 diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA/templates/service.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartC/templates/service.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA/templates/service.yaml rename to pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartC/templates/service.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart2/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartC/values.yaml similarity index 99% rename from pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart2/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartC/values.yaml index fad554f7..5e5b2106 100644 --- a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart2/values.yaml +++ b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartC/values.yaml @@ -18,3 +18,4 @@ resources: requests: cpu: 100m memory: 128Mi + diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB/templates/service.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/templates/service.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB/templates/service.yaml rename to pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/templates/service.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/values.yaml similarity index 99% rename from pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/values.yaml index fad554f7..5e5b2106 100644 --- a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/values.yaml +++ b/pkg/helm/intern/chart/v3/util/testdata/subpop/charts/subchart2/values.yaml @@ -18,3 +18,4 @@ resources: requests: cpu: 100m memory: 128Mi + diff --git a/pkg/helm/intern/chart/v3/util/testdata/subpop/noreqs/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/noreqs/Chart.yaml new file mode 100644 index 00000000..09eb05a9 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/subpop/noreqs/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v3 +description: A Helm chart for Kubernetes +name: parentchart +version: 0.1.0 diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC/templates/service.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/noreqs/templates/service.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC/templates/service.yaml rename to pkg/helm/intern/chart/v3/util/testdata/subpop/noreqs/templates/service.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/noreqs/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/noreqs/values.yaml similarity index 99% rename from pkg/helm/pkg/chartutil/testdata/subpop/noreqs/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/subpop/noreqs/values.yaml index 843c5718..4ed3b7ad 100644 --- a/pkg/helm/pkg/chartutil/testdata/subpop/noreqs/values.yaml +++ b/pkg/helm/intern/chart/v3/util/testdata/subpop/noreqs/values.yaml @@ -19,6 +19,7 @@ resources: cpu: 100m memory: 128Mi + # switch-like tags: front-end: true diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/subpop/values.yaml similarity index 56% rename from pkg/helm/pkg/chartutil/testdata/subpop/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/subpop/values.yaml index baa2d017..ba70ed40 100644 --- a/pkg/helm/pkg/chartutil/testdata/subpop/values.yaml +++ b/pkg/helm/intern/chart/v3/util/testdata/subpop/values.yaml @@ -10,6 +10,7 @@ overridden-chart1: SC1string: "pollywog" SPextra2: 42 + imported-chartA: SPextra3: 1.337 @@ -34,28 +35,6 @@ overridden-chartA-B: SCBstring: "jango" SPextra6: 111 -exported-parent: - SPExtra7: "exported-from-parent" - SPNested1: - SPExtra19: "exported-from-parent-n6" - -exported-overridden-parent: - SC1bool: true - SC1float: 22.2 - SC1int: 222 - SC1string: "exported-from-parent-n2" - SPExtra8: "exported-from-parent-n3" - -exported-single-value-parent: "exported-from-parent-n7" - -exported-passthrough: - SPExtra17: "exported-from-parent-n5" - -exports: - exported-short-parent: - exported-short-parent: - SPExtra9: "exported-from-parent-n4" - tags: front-end: true back-end: false diff --git a/pkg/helm/pkg/chartutil/testdata/test-values-invalid.schema.json b/pkg/helm/intern/chart/v3/util/testdata/test-values-invalid.schema.json similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/test-values-invalid.schema.json rename to pkg/helm/intern/chart/v3/util/testdata/test-values-invalid.schema.json diff --git a/pkg/helm/pkg/chartutil/testdata/test-values-negative.yaml b/pkg/helm/intern/chart/v3/util/testdata/test-values-negative.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/test-values-negative.yaml rename to pkg/helm/intern/chart/v3/util/testdata/test-values-negative.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/test-values.schema.json b/pkg/helm/intern/chart/v3/util/testdata/test-values.schema.json similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/test-values.schema.json rename to pkg/helm/intern/chart/v3/util/testdata/test-values.schema.json diff --git a/pkg/helm/pkg/chartutil/testdata/test-values.yaml b/pkg/helm/intern/chart/v3/util/testdata/test-values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/test-values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/test-values.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/README.md b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/README.md similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/README.md rename to pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/README.md diff --git a/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/Chart.yaml new file mode 100644 index 00000000..1026f890 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/Chart.yaml @@ -0,0 +1,19 @@ +apiVersion: v3 +name: umbrella +description: A Helm chart for Kubernetes +type: application +version: 0.1.0 + +dependencies: +- name: app1 + version: 0.1.0 + condition: app1.enabled +- name: app2 + version: 0.1.0 + condition: app2.enabled +- name: app3 + version: 0.1.0 + condition: app3.enabled +- name: app4 + version: 0.1.0 + condition: app4.enabled diff --git a/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/Chart.yaml new file mode 100644 index 00000000..5bdf2157 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/Chart.yaml @@ -0,0 +1,11 @@ +apiVersion: v3 +name: app1 +description: A Helm chart for Kubernetes +type: application +version: 0.1.0 + +dependencies: +- name: library + version: 0.1.0 + import-values: + - defaults diff --git a/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/Chart.yaml new file mode 100644 index 00000000..9bc30636 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: library +description: A Helm chart for Kubernetes +type: library +version: 0.1.0 diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/templates/service.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/templates/service.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/templates/service.yaml rename to pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/templates/service.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/values.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/templates/service.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/templates/service.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/templates/service.yaml rename to pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/templates/service.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/values.yaml diff --git a/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/Chart.yaml new file mode 100644 index 00000000..1313ce4e --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/Chart.yaml @@ -0,0 +1,11 @@ +apiVersion: v3 +name: app2 +description: A Helm chart for Kubernetes +type: application +version: 0.1.0 + +dependencies: +- name: library + version: 0.1.0 + import-values: + - defaults diff --git a/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/Chart.yaml new file mode 100644 index 00000000..9bc30636 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: library +description: A Helm chart for Kubernetes +type: library +version: 0.1.0 diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/templates/service.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/templates/service.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/templates/service.yaml rename to pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/templates/service.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/values.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/templates/service.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/templates/service.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/templates/service.yaml rename to pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/templates/service.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/values.yaml diff --git a/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/Chart.yaml new file mode 100644 index 00000000..1a80533d --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/Chart.yaml @@ -0,0 +1,11 @@ +apiVersion: v3 +name: app3 +description: A Helm chart for Kubernetes +type: application +version: 0.1.0 + +dependencies: +- name: library + version: 0.1.0 + import-values: + - defaults diff --git a/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/Chart.yaml new file mode 100644 index 00000000..9bc30636 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: library +description: A Helm chart for Kubernetes +type: library +version: 0.1.0 diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/templates/service.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/templates/service.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/templates/service.yaml rename to pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/templates/service.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/values.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/templates/service.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/templates/service.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/templates/service.yaml rename to pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/templates/service.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/values.yaml diff --git a/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/Chart.yaml new file mode 100644 index 00000000..886b4b1e --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/Chart.yaml @@ -0,0 +1,9 @@ +apiVersion: v3 +name: app4 +description: A Helm chart for Kubernetes +type: application +version: 0.1.0 + +dependencies: +- name: library + version: 0.1.0 diff --git a/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/Chart.yaml new file mode 100644 index 00000000..9bc30636 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: library +description: A Helm chart for Kubernetes +type: library +version: 0.1.0 diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/templates/service.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/templates/service.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/templates/service.yaml rename to pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/templates/service.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/values.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/templates/service.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/templates/service.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/templates/service.yaml rename to pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/templates/service.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/values.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/values.yaml b/pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/values.yaml rename to pkg/helm/intern/chart/v3/util/testdata/three-level-dependent-chart/umbrella/values.yaml diff --git a/pkg/helm/pkg/chartutil/validate_name.go b/pkg/helm/intern/chart/v3/util/validate_name.go similarity index 98% rename from pkg/helm/pkg/chartutil/validate_name.go rename to pkg/helm/intern/chart/v3/util/validate_name.go index 05c090cb..6595e085 100644 --- a/pkg/helm/pkg/chartutil/validate_name.go +++ b/pkg/helm/intern/chart/v3/util/validate_name.go @@ -14,13 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -package chartutil +package util import ( + "errors" "fmt" "regexp" - - "github.com/pkg/errors" ) // validName is a regular expression for resource names. diff --git a/pkg/helm/pkg/chartutil/validate_name_test.go b/pkg/helm/intern/chart/v3/util/validate_name_test.go similarity index 97% rename from pkg/helm/pkg/chartutil/validate_name_test.go rename to pkg/helm/intern/chart/v3/util/validate_name_test.go index 5f0792f9..cfc62a0f 100644 --- a/pkg/helm/pkg/chartutil/validate_name_test.go +++ b/pkg/helm/intern/chart/v3/util/validate_name_test.go @@ -14,11 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -package chartutil +package util import "testing" -// TestValidateName is a regression test for ValidateName +// TestValidateReleaseName is a regression test for ValidateName // // Kubernetes has strict naming conventions for resource names. This test represents // those conventions. diff --git a/pkg/helm/intern/cli/output/color.go b/pkg/helm/intern/cli/output/color.go new file mode 100644 index 00000000..8d3ea084 --- /dev/null +++ b/pkg/helm/intern/cli/output/color.go @@ -0,0 +1,67 @@ +/* +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 output + +import ( + "github.com/fatih/color" + + "github.com/werf/nelm/pkg/helm/pkg/release/common" +) + +// ColorizeStatus returns a colorized version of the status string based on the status value +func ColorizeStatus(status common.Status, noColor bool) string { + // Disable color if requested + if noColor { + return status.String() + } + + switch status { + case common.StatusDeployed: + return color.GreenString(status.String()) + case common.StatusFailed: + return color.RedString(status.String()) + case common.StatusPendingInstall, common.StatusPendingUpgrade, common.StatusPendingRollback, common.StatusUninstalling: + return color.YellowString(status.String()) + case common.StatusUnknown: + return color.RedString(status.String()) + default: + // For uninstalled, superseded, and any other status + return status.String() + } +} + +// ColorizeHeader returns a colorized version of a header string +func ColorizeHeader(header string, noColor bool) string { + // Disable color if requested + if noColor { + return header + } + + // Use bold for headers + return color.New(color.Bold).Sprint(header) +} + +// ColorizeNamespace returns a colorized version of a namespace string +func ColorizeNamespace(namespace string, noColor bool) string { + // Disable color if requested + if noColor { + return namespace + } + + // Use cyan for namespaces + return color.CyanString(namespace) +} diff --git a/pkg/helm/intern/cli/output/color_test.go b/pkg/helm/intern/cli/output/color_test.go new file mode 100644 index 00000000..012550fb --- /dev/null +++ b/pkg/helm/intern/cli/output/color_test.go @@ -0,0 +1,172 @@ +/* +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 output + +import ( + "strings" + "testing" + + "github.com/werf/nelm/pkg/helm/pkg/release/common" +) + +func TestColorizeStatus(t *testing.T) { + + tests := []struct { + name string + status common.Status + noColor bool + envNoColor string + wantColor bool // whether we expect color codes in output + }{ + { + name: "deployed status with color", + status: common.StatusDeployed, + noColor: false, + envNoColor: "", + wantColor: true, + }, + { + name: "deployed status without color flag", + status: common.StatusDeployed, + noColor: true, + envNoColor: "", + wantColor: false, + }, + { + name: "failed status with color", + status: common.StatusFailed, + noColor: false, + envNoColor: "", + wantColor: true, + }, + { + name: "pending install status with color", + status: common.StatusPendingInstall, + noColor: false, + envNoColor: "", + wantColor: true, + }, + { + name: "unknown status with color", + status: common.StatusUnknown, + noColor: false, + envNoColor: "", + wantColor: true, + }, + { + name: "superseded status with color", + status: common.StatusSuperseded, + noColor: false, + envNoColor: "", + wantColor: false, // superseded doesn't get colored + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("NO_COLOR", tt.envNoColor) + + result := ColorizeStatus(tt.status, tt.noColor) + + // Check if result contains ANSI escape codes + hasColor := strings.Contains(result, "\033[") + + // In test environment, term.IsTerminal will be false, so we won't get color + // unless we're testing the logic without terminal detection + if hasColor && !tt.wantColor { + t.Errorf("ColorizeStatus() returned color when none expected: %q", result) + } + + // Always check the status text is present + if !strings.Contains(result, tt.status.String()) { + t.Errorf("ColorizeStatus() = %q, want to contain %q", result, tt.status.String()) + } + }) + } +} + +func TestColorizeHeader(t *testing.T) { + + tests := []struct { + name string + header string + noColor bool + envNoColor string + }{ + { + name: "header with color", + header: "NAME", + noColor: false, + envNoColor: "", + }, + { + name: "header without color flag", + header: "NAME", + noColor: true, + envNoColor: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("NO_COLOR", tt.envNoColor) + + result := ColorizeHeader(tt.header, tt.noColor) + + // Always check the header text is present + if !strings.Contains(result, tt.header) { + t.Errorf("ColorizeHeader() = %q, want to contain %q", result, tt.header) + } + }) + } +} + +func TestColorizeNamespace(t *testing.T) { + + tests := []struct { + name string + namespace string + noColor bool + envNoColor string + }{ + { + name: "namespace with color", + namespace: "default", + noColor: false, + envNoColor: "", + }, + { + name: "namespace without color flag", + namespace: "default", + noColor: true, + envNoColor: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("NO_COLOR", tt.envNoColor) + + result := ColorizeNamespace(tt.namespace, tt.noColor) + + // Always check the namespace text is present + if !strings.Contains(result, tt.namespace) { + t.Errorf("ColorizeNamespace() = %q, want to contain %q", result, tt.namespace) + } + }) + } +} diff --git a/pkg/helm/intern/copystructure/copystructure.go b/pkg/helm/intern/copystructure/copystructure.go new file mode 100644 index 00000000..c55897aa --- /dev/null +++ b/pkg/helm/intern/copystructure/copystructure.go @@ -0,0 +1,128 @@ +/* +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 copystructure + +import ( + "fmt" + "reflect" +) + +// Copy performs a deep copy of the given src. +// This implementation handles the specific use cases needed by Helm. +func Copy(src any) (any, error) { + if src == nil { + return make(map[string]any), nil + } + return copyValue(reflect.ValueOf(src)) +} + +// copyValue handles copying using reflection for non-map types +func copyValue(original reflect.Value) (any, error) { + switch original.Kind() { + case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, + reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, + reflect.Uint64, reflect.Uintptr, reflect.Float32, reflect.Float64, + reflect.Complex64, reflect.Complex128, reflect.String, reflect.Array: + return original.Interface(), nil + + case reflect.Interface: + if original.IsNil() { + return original.Interface(), nil + } + return copyValue(original.Elem()) + + case reflect.Map: + if original.IsNil() { + return original.Interface(), nil + } + copied := reflect.MakeMap(original.Type()) + + var err error + var child any + iter := original.MapRange() + for iter.Next() { + key := iter.Key() + value := iter.Value() + + if value.Kind() == reflect.Interface && value.IsNil() { + copied.SetMapIndex(key, value) + continue + } + + child, err = copyValue(value) + if err != nil { + return nil, err + } + copied.SetMapIndex(key, reflect.ValueOf(child)) + } + return copied.Interface(), nil + + case reflect.Pointer: + if original.IsNil() { + return original.Interface(), nil + } + copied, err := copyValue(original.Elem()) + if err != nil { + return nil, err + } + ptr := reflect.New(original.Type().Elem()) + ptr.Elem().Set(reflect.ValueOf(copied)) + return ptr.Interface(), nil + + case reflect.Slice: + if original.IsNil() { + return original.Interface(), nil + } + copied := reflect.MakeSlice(original.Type(), original.Len(), original.Cap()) + for i := 0; i < original.Len(); i++ { + elem := original.Index(i) + + // Handle nil values in slices (e.g., interface{} elements that are nil) + if elem.Kind() == reflect.Interface && elem.IsNil() { + copied.Index(i).Set(elem) + continue + } + + val, err := copyValue(elem) + if err != nil { + return nil, err + } + copied.Index(i).Set(reflect.ValueOf(val)) + } + return copied.Interface(), nil + + case reflect.Struct: + copied := reflect.New(original.Type()).Elem() + for i := 0; i < original.NumField(); i++ { + elem, err := copyValue(original.Field(i)) + if err != nil { + return nil, err + } + copied.Field(i).Set(reflect.ValueOf(elem)) + } + return copied.Interface(), nil + + case reflect.Func, reflect.Chan, reflect.UnsafePointer: + if original.IsNil() { + return original.Interface(), nil + } + return original.Interface(), nil + + default: + return original.Interface(), fmt.Errorf("unsupported type %v", original) + } +} diff --git a/pkg/helm/intern/copystructure/copystructure_test.go b/pkg/helm/intern/copystructure/copystructure_test.go new file mode 100644 index 00000000..b21af646 --- /dev/null +++ b/pkg/helm/intern/copystructure/copystructure_test.go @@ -0,0 +1,389 @@ +/* +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 copystructure + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCopy_Nil(t *testing.T) { + result, err := Copy(nil) + require.NoError(t, err) + assert.Equal(t, map[string]any{}, result) +} + +func TestCopy_PrimitiveTypes(t *testing.T) { + tests := []struct { + name string + input any + }{ + {"bool", true}, + {"int", 42}, + {"int8", int8(8)}, + {"int16", int16(16)}, + {"int32", int32(32)}, + {"int64", int64(64)}, + {"uint", uint(42)}, + {"uint8", uint8(8)}, + {"uint16", uint16(16)}, + {"uint32", uint32(32)}, + {"uint64", uint64(64)}, + {"float32", float32(3.14)}, + {"float64", 3.14159}, + {"complex64", complex64(1 + 2i)}, + {"complex128", 1 + 2i}, + {"string", "hello world"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := Copy(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.input, result) + }) + } +} + +func TestCopy_Array(t *testing.T) { + input := [3]int{1, 2, 3} + result, err := Copy(input) + require.NoError(t, err) + assert.Equal(t, input, result) +} + +func TestCopy_Slice(t *testing.T) { + t.Run("slice of ints", func(t *testing.T) { + input := []int{1, 2, 3, 4, 5} + result, err := Copy(input) + require.NoError(t, err) + + resultSlice, ok := result.([]int) + require.True(t, ok) + assert.Equal(t, input, resultSlice) + + // Verify it's a deep copy by modifying original + input[0] = 999 + assert.Equal(t, 1, resultSlice[0]) + }) + + t.Run("slice of strings", func(t *testing.T) { + input := []string{"a", "b", "c"} + result, err := Copy(input) + require.NoError(t, err) + assert.Equal(t, input, result) + }) + + t.Run("nil slice", func(t *testing.T) { + var input []int + result, err := Copy(input) + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("slice of maps", func(t *testing.T) { + input := []map[string]any{ + {"key1": "value1"}, + {"key2": "value2"}, + } + result, err := Copy(input) + require.NoError(t, err) + + resultSlice, ok := result.([]map[string]any) + require.True(t, ok) + assert.Equal(t, input, resultSlice) + + // Verify deep copy + input[0]["key1"] = "modified" + assert.Equal(t, "value1", resultSlice[0]["key1"]) + }) + + t.Run("slice with nil elements", func(t *testing.T) { + input := []any{ + "value1", + nil, + "value2", + } + result, err := Copy(input) + require.NoError(t, err) + + resultSlice, ok := result.([]any) + require.True(t, ok) + assert.Equal(t, input, resultSlice) + assert.Nil(t, resultSlice[1]) + }) +} + +func TestCopy_Map(t *testing.T) { + t.Run("map[string]any", func(t *testing.T) { + input := map[string]any{ + "string": "value", + "int": 42, + "bool": true, + "nested": map[string]any{ + "inner": "value", + }, + } + + result, err := Copy(input) + require.NoError(t, err) + + resultMap, ok := result.(map[string]any) + require.True(t, ok) + assert.Equal(t, input, resultMap) + + // Verify deep copy + input["string"] = "modified" + assert.Equal(t, "value", resultMap["string"]) + + nestedInput := input["nested"].(map[string]any) + nestedResult := resultMap["nested"].(map[string]any) + nestedInput["inner"] = "modified" + assert.Equal(t, "value", nestedResult["inner"]) + }) + + t.Run("map[string]string", func(t *testing.T) { + input := map[string]string{ + "key1": "value1", + "key2": "value2", + } + + result, err := Copy(input) + require.NoError(t, err) + assert.Equal(t, input, result) + }) + + t.Run("nil map", func(t *testing.T) { + var input map[string]any + result, err := Copy(input) + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("map with nil values", func(t *testing.T) { + input := map[string]any{ + "key1": "value1", + "key2": nil, + } + + result, err := Copy(input) + require.NoError(t, err) + + resultMap, ok := result.(map[string]any) + require.True(t, ok) + assert.Equal(t, input, resultMap) + assert.Nil(t, resultMap["key2"]) + }) +} + +func TestCopy_Struct(t *testing.T) { + type TestStruct struct { + Name string + Age int + Active bool + Scores []int + Metadata map[string]any + } + + input := TestStruct{ + Name: "John", + Age: 30, + Active: true, + Scores: []int{95, 87, 92}, + Metadata: map[string]any{ + "level": "advanced", + "tags": []string{"go", "programming"}, + }, + } + + result, err := Copy(input) + require.NoError(t, err) + + resultStruct, ok := result.(TestStruct) + require.True(t, ok) + assert.Equal(t, input, resultStruct) + + // Verify deep copy + input.Name = "Modified" + input.Scores[0] = 999 + assert.Equal(t, "John", resultStruct.Name) + assert.Equal(t, 95, resultStruct.Scores[0]) +} + +func TestCopy_Pointer(t *testing.T) { + t.Run("pointer to int", func(t *testing.T) { + value := 42 + input := &value + + result, err := Copy(input) + require.NoError(t, err) + + resultPtr, ok := result.(*int) + require.True(t, ok) + assert.Equal(t, *input, *resultPtr) + + // Verify they point to different memory locations + assert.NotSame(t, input, resultPtr) + + // Verify deep copy + *input = 999 + assert.Equal(t, 42, *resultPtr) + }) + + t.Run("pointer to struct", func(t *testing.T) { + type Person struct { + Name string + Age int + } + + input := &Person{Name: "Alice", Age: 25} + + result, err := Copy(input) + require.NoError(t, err) + + resultPtr, ok := result.(*Person) + require.True(t, ok) + assert.Equal(t, *input, *resultPtr) + assert.NotSame(t, input, resultPtr) + }) + + t.Run("nil pointer", func(t *testing.T) { + var input *int + result, err := Copy(input) + require.NoError(t, err) + assert.Nil(t, result) + }) +} + +func TestCopy_Interface(t *testing.T) { + t.Run("any with value", func(t *testing.T) { + var input any = "hello" + result, err := Copy(input) + require.NoError(t, err) + assert.Equal(t, input, result) + }) + + t.Run("nil any", func(t *testing.T) { + var input any + result, err := Copy(input) + require.NoError(t, err) + // Copy(nil) returns an empty map according to the implementation + assert.Equal(t, map[string]any{}, result) + }) + + t.Run("any with complex value", func(t *testing.T) { + var input any = map[string]any{ + "key": "value", + "nested": map[string]any{ + "inner": 42, + }, + } + + result, err := Copy(input) + require.NoError(t, err) + assert.Equal(t, input, result) + }) +} + +func TestCopy_ComplexNested(t *testing.T) { + input := map[string]any{ + "users": []map[string]any{ + { + "name": "Alice", + "age": 30, + "addresses": []map[string]any{ + {"type": "home", "city": "NYC"}, + {"type": "work", "city": "SF"}, + }, + }, + { + "name": "Bob", + "age": 25, + "addresses": []map[string]any{ + {"type": "home", "city": "LA"}, + }, + }, + }, + "metadata": map[string]any{ + "version": "1.0", + "flags": []bool{true, false, true}, + }, + } + + result, err := Copy(input) + require.NoError(t, err) + + resultMap, ok := result.(map[string]any) + require.True(t, ok) + assert.Equal(t, input, resultMap) + + // Verify deep copy by modifying nested values + users := input["users"].([]map[string]any) + addresses := users[0]["addresses"].([]map[string]any) + addresses[0]["city"] = "Modified" + + resultUsers := resultMap["users"].([]map[string]any) + resultAddresses := resultUsers[0]["addresses"].([]map[string]any) + assert.Equal(t, "NYC", resultAddresses[0]["city"]) +} + +func TestCopy_Functions(t *testing.T) { + t.Run("function", func(t *testing.T) { + input := func() string { return "hello" } + result, err := Copy(input) + require.NoError(t, err) + + // Functions should be copied as-is (same reference) + resultFunc, ok := result.(func() string) + require.True(t, ok) + assert.Equal(t, input(), resultFunc()) + }) + + t.Run("nil function", func(t *testing.T) { + var input func() + result, err := Copy(input) + require.NoError(t, err) + assert.Nil(t, result) + }) +} + +func TestCopy_Channels(t *testing.T) { + t.Run("channel", func(t *testing.T) { + input := make(chan int, 1) + input <- 42 + + result, err := Copy(input) + require.NoError(t, err) + + // Channels should be copied as-is (same reference) + resultChan, ok := result.(chan int) + require.True(t, ok) + + // Since channels are copied as references, verify we can read from the result channel + value := <-resultChan + assert.Equal(t, 42, value) + }) + + t.Run("nil channel", func(t *testing.T) { + var input chan int + result, err := Copy(input) + require.NoError(t, err) + assert.Nil(t, result) + }) +} diff --git a/pkg/helm/internal/fileutil/fileutil.go b/pkg/helm/intern/fileutil/fileutil.go similarity index 95% rename from pkg/helm/internal/fileutil/fileutil.go rename to pkg/helm/intern/fileutil/fileutil.go index 45a79594..a964fc04 100644 --- a/pkg/helm/internal/fileutil/fileutil.go +++ b/pkg/helm/intern/fileutil/fileutil.go @@ -21,7 +21,7 @@ import ( "os" "path/filepath" - "github.com/werf/nelm/pkg/helm/internal/third_party/dep/fs" + "github.com/werf/nelm/pkg/helm/intern/third_party/dep/fs" ) // AtomicWriteFile atomically (as atomic as os.Rename allows) writes a file to a diff --git a/pkg/helm/intern/fileutil/fileutil_test.go b/pkg/helm/intern/fileutil/fileutil_test.go new file mode 100644 index 00000000..71fcae17 --- /dev/null +++ b/pkg/helm/intern/fileutil/fileutil_test.go @@ -0,0 +1,147 @@ +/* +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 fileutil + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestAtomicWriteFile tests the happy path of AtomicWriteFile function. +// It verifies that the function correctly writes content to a file with the specified mode. +func TestAtomicWriteFile(t *testing.T) { + dir := t.TempDir() + + testpath := filepath.Join(dir, "test") + stringContent := "Test content" + reader := bytes.NewReader([]byte(stringContent)) + mode := os.FileMode(0644) + + err := AtomicWriteFile(testpath, reader, mode) + if err != nil { + t.Errorf("AtomicWriteFile error: %s", err) + } + + got, err := os.ReadFile(testpath) + if err != nil { + t.Fatal(err) + } + + if stringContent != string(got) { + t.Fatalf("expected: %s, got: %s", stringContent, string(got)) + } + + gotinfo, err := os.Stat(testpath) + if err != nil { + t.Fatal(err) + } + + if mode != gotinfo.Mode() { + t.Fatalf("expected %s: to be the same mode as %s", + mode, gotinfo.Mode()) + } +} + +// TestAtomicWriteFile_CreateTempError tests the error path when os.CreateTemp fails +func TestAtomicWriteFile_CreateTempError(t *testing.T) { + invalidPath := "/invalid/path/that/does/not/exist/testfile" + + reader := bytes.NewReader([]byte("test content")) + mode := os.FileMode(0644) + + err := AtomicWriteFile(invalidPath, reader, mode) + if err == nil { + t.Error("Expected error when CreateTemp fails, but got nil") + } +} + +// TestAtomicWriteFile_EmptyContent tests with empty content +func TestAtomicWriteFile_EmptyContent(t *testing.T) { + dir := t.TempDir() + testpath := filepath.Join(dir, "empty_helm") + + reader := bytes.NewReader([]byte("")) + mode := os.FileMode(0644) + + err := AtomicWriteFile(testpath, reader, mode) + if err != nil { + t.Errorf("AtomicWriteFile error with empty content: %s", err) + } + + got, err := os.ReadFile(testpath) + if err != nil { + t.Fatal(err) + } + + if len(got) != 0 { + t.Fatalf("expected empty content, got: %s", string(got)) + } +} + +// TestAtomicWriteFile_LargeContent tests with large content +func TestAtomicWriteFile_LargeContent(t *testing.T) { + dir := t.TempDir() + testpath := filepath.Join(dir, "large_test") + + // Create a large content string + largeContent := strings.Repeat("HELM", 1024*1024) + reader := bytes.NewReader([]byte(largeContent)) + mode := os.FileMode(0644) + + err := AtomicWriteFile(testpath, reader, mode) + if err != nil { + t.Errorf("AtomicWriteFile error with large content: %s", err) + } + + got, err := os.ReadFile(testpath) + if err != nil { + t.Fatal(err) + } + + if largeContent != string(got) { + t.Fatalf("expected large content to match, got different length: %d vs %d", len(largeContent), len(got)) + } +} + +// TestPlatformAtomicWriteFile_OverwritesExisting verifies that the platform +// helper replaces existing files instead of silently skipping them. +func TestPlatformAtomicWriteFile_OverwritesExisting(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "overwrite_test") + + first := bytes.NewReader([]byte("first")) + if err := PlatformAtomicWriteFile(path, first, 0644); err != nil { + t.Fatalf("first write failed: %v", err) + } + + second := bytes.NewReader([]byte("second")) + if err := PlatformAtomicWriteFile(path, second, 0644); err != nil { + t.Fatalf("second write failed: %v", err) + } + + contents, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed reading result: %v", err) + } + + if string(contents) != "second" { + t.Fatalf("expected file to be overwritten, got %q", string(contents)) + } +} diff --git a/pkg/helm/intern/fileutil/fileutil_unix.go b/pkg/helm/intern/fileutil/fileutil_unix.go new file mode 100644 index 00000000..bbacb10b --- /dev/null +++ b/pkg/helm/intern/fileutil/fileutil_unix.go @@ -0,0 +1,32 @@ +//go:build !windows + +/* +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 fileutil + +import ( + "io" + "os" +) + +// PlatformAtomicWriteFile atomically writes a file to disk. +// +// On non-Windows platforms we don't need extra coordination, so this simply +// delegates to AtomicWriteFile to preserve the existing overwrite behaviour. +func PlatformAtomicWriteFile(filename string, reader io.Reader, mode os.FileMode) error { + return AtomicWriteFile(filename, reader, mode) +} diff --git a/pkg/helm/intern/fileutil/fileutil_windows.go b/pkg/helm/intern/fileutil/fileutil_windows.go new file mode 100644 index 00000000..17923786 --- /dev/null +++ b/pkg/helm/intern/fileutil/fileutil_windows.go @@ -0,0 +1,54 @@ +//go:build windows + +/* +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 fileutil + +import ( + "io" + "os" + + "github.com/gofrs/flock" +) + +// PlatformAtomicWriteFile atomically writes a file to disk with file locking to +// prevent concurrent writes. This is particularly useful on Windows where +// concurrent writes to the same file can cause "Access Denied" errors. +// +// The function acquires a lock on the target file and performs an atomic write, +// preserving the existing behaviour of overwriting any previous content once +// the lock is obtained. +func PlatformAtomicWriteFile(filename string, reader io.Reader, mode os.FileMode) error { + // Use a separate lock file to coordinate access between processes + // We cannot lock the target file directly as it would prevent the atomic rename + lockFileName := filename + ".lock" + fileLock := flock.New(lockFileName) + + // Lock() ensures serialized access - if another process is writing, this will wait + if err := fileLock.Lock(); err != nil { + return err + } + defer func() { + fileLock.Unlock() + // Clean up the lock file + // Ignore errors as the file might not exist or be in use by another process + os.Remove(lockFileName) + }() + + // Perform the atomic write while holding the lock + return AtomicWriteFile(filename, reader, mode) +} diff --git a/pkg/helm/intern/logging/logging.go b/pkg/helm/intern/logging/logging.go new file mode 100644 index 00000000..674e2db3 --- /dev/null +++ b/pkg/helm/intern/logging/logging.go @@ -0,0 +1,125 @@ +/* +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 logging + +import ( + "context" + "log/slog" + "os" + "sync/atomic" +) + +// DebugEnabledFunc is a function type that determines if debug logging is enabled +// We use a function because we want to check the setting at log time, not when the logger is created +type DebugEnabledFunc func() bool + +// DebugCheckHandler checks settings.Debug at log time +type DebugCheckHandler struct { + handler slog.Handler + debugEnabled DebugEnabledFunc +} + +// Enabled implements slog.Handler.Enabled +func (h *DebugCheckHandler) Enabled(_ context.Context, level slog.Level) bool { + if level == slog.LevelDebug { + if h.debugEnabled == nil { + return false + } + return h.debugEnabled() + } + return true // Always log other levels +} + +// Handle implements slog.Handler.Handle +func (h *DebugCheckHandler) Handle(ctx context.Context, r slog.Record) error { + return h.handler.Handle(ctx, r) +} + +// WithAttrs implements slog.Handler.WithAttrs +func (h *DebugCheckHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &DebugCheckHandler{ + handler: h.handler.WithAttrs(attrs), + debugEnabled: h.debugEnabled, + } +} + +// WithGroup implements slog.Handler.WithGroup +func (h *DebugCheckHandler) WithGroup(name string) slog.Handler { + return &DebugCheckHandler{ + handler: h.handler.WithGroup(name), + debugEnabled: h.debugEnabled, + } +} + +// NewLogger creates a new logger with dynamic debug checking +func NewLogger(debugEnabled DebugEnabledFunc) *slog.Logger { + // Create base handler that removes timestamps + baseHandler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + // Always use LevelDebug here to allow all messages through + // Our custom handler will do the filtering + Level: slog.LevelDebug, + ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { + // Remove the time attribute + if a.Key == slog.TimeKey { + return slog.Attr{} + } + return a + }, + }) + + // Wrap with our dynamic debug-checking handler + dynamicHandler := &DebugCheckHandler{ + handler: baseHandler, + debugEnabled: debugEnabled, + } + + return slog.New(dynamicHandler) +} + +// LoggerSetterGetter is an interface that can set and get a logger +type LoggerSetterGetter interface { + // SetLogger sets a new slog.Handler + SetLogger(newHandler slog.Handler) + // Logger returns the slog.Logger created from the slog.Handler + Logger() *slog.Logger +} + +type LogHolder struct { + // logger is an atomic.Pointer[slog.Logger] to store the slog.Logger + // We use atomic.Pointer for thread safety + logger atomic.Pointer[slog.Logger] +} + +// Logger returns the logger for the LogHolder. If nil, returns slog.Default(). +func (l *LogHolder) Logger() *slog.Logger { + if lg := l.logger.Load(); lg != nil { + return lg + } + return slog.New(slog.DiscardHandler) // Should never be reached +} + +// SetLogger sets the logger for the LogHolder. If nil, sets the default logger. +func (l *LogHolder) SetLogger(newHandler slog.Handler) { + if newHandler == nil { + l.logger.Store(slog.New(slog.DiscardHandler)) // Assume nil as discarding logs + return + } + l.logger.Store(slog.New(newHandler)) +} + +// Ensure LogHolder implements LoggerSetterGetter +var _ LoggerSetterGetter = &LogHolder{} diff --git a/pkg/helm/intern/logging/logging_test.go b/pkg/helm/intern/logging/logging_test.go new file mode 100644 index 00000000..d22a47a3 --- /dev/null +++ b/pkg/helm/intern/logging/logging_test.go @@ -0,0 +1,373 @@ +/* +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 logging + +import ( + "bytes" + "context" + "log/slog" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestLogHolder_Logger(t *testing.T) { + t.Run("should return new logger with a then set handler", func(t *testing.T) { + holder := &LogHolder{} + buf := &bytes.Buffer{} + handler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + + holder.SetLogger(handler) + logger := holder.Logger() + + assert.NotNil(t, logger) + + // Test that the logger works + logger.Info("test message") + assert.Contains(t, buf.String(), "test message") + }) + + t.Run("should return discard - defaultlogger when no handler is set", func(t *testing.T) { + holder := &LogHolder{} + logger := holder.Logger() + + assert.Equal(t, slog.Handler(slog.DiscardHandler), logger.Handler()) + }) +} + +func TestLogHolder_SetLogger(t *testing.T) { + t.Run("sets logger with valid handler", func(t *testing.T) { + holder := &LogHolder{} + buf := &bytes.Buffer{} + handler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + + holder.SetLogger(handler) + logger := holder.Logger() + + assert.NotNil(t, logger) + + // Compare the handler directly + assert.Equal(t, handler, logger.Handler()) + }) + + t.Run("sets discard logger with nil handler", func(t *testing.T) { + holder := &LogHolder{} + + holder.SetLogger(nil) + logger := holder.Logger() + + assert.NotNil(t, logger) + + assert.Equal(t, slog.Handler(slog.DiscardHandler), logger.Handler()) + }) + + t.Run("can replace existing logger", func(t *testing.T) { + holder := &LogHolder{} + + // Set first logger + buf1 := &bytes.Buffer{} + handler1 := slog.NewTextHandler(buf1, &slog.HandlerOptions{Level: slog.LevelDebug}) + holder.SetLogger(handler1) + + logger1 := holder.Logger() + assert.Equal(t, handler1, logger1.Handler()) + + // Replace with second logger + buf2 := &bytes.Buffer{} + handler2 := slog.NewTextHandler(buf2, &slog.HandlerOptions{Level: slog.LevelDebug}) + holder.SetLogger(handler2) + + logger2 := holder.Logger() + assert.Equal(t, handler2, logger2.Handler()) + }) +} + +func TestLogHolder_InterfaceCompliance(t *testing.T) { + t.Run("implements LoggerSetterGetter interface", func(_ *testing.T) { + var _ LoggerSetterGetter = &LogHolder{} + }) + + t.Run("interface methods work correctly", func(t *testing.T) { + var holder LoggerSetterGetter = &LogHolder{} + + buf := &bytes.Buffer{} + handler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + + holder.SetLogger(handler) + logger := holder.Logger() + + assert.NotNil(t, logger) + assert.Equal(t, handler, logger.Handler()) + }) +} + +func TestDebugCheckHandler_Enabled(t *testing.T) { + t.Run("returns debugEnabled function result for debug level", func(t *testing.T) { + // Test with debug enabled + debugEnabled := func() bool { return true } + buf := &bytes.Buffer{} + baseHandler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + handler := &DebugCheckHandler{ + handler: baseHandler, + debugEnabled: debugEnabled, + } + + assert.True(t, handler.Enabled(t.Context(), slog.LevelDebug)) + }) + + t.Run("returns false for debug level when debug disabled", func(t *testing.T) { + // Test with debug disabled + debugEnabled := func() bool { return false } + buf := &bytes.Buffer{} + baseHandler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + handler := &DebugCheckHandler{ + handler: baseHandler, + debugEnabled: debugEnabled, + } + + assert.False(t, handler.Enabled(t.Context(), slog.LevelDebug)) + }) + + t.Run("always returns true for non-debug levels", func(t *testing.T) { + debugEnabled := func() bool { return false } // Debug disabled + buf := &bytes.Buffer{} + baseHandler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + handler := &DebugCheckHandler{ + handler: baseHandler, + debugEnabled: debugEnabled, + } + + // Even with debug disabled, other levels should always be enabled + assert.True(t, handler.Enabled(t.Context(), slog.LevelInfo)) + assert.True(t, handler.Enabled(t.Context(), slog.LevelWarn)) + assert.True(t, handler.Enabled(t.Context(), slog.LevelError)) + }) + + t.Run("calls debugEnabled function dynamically", func(t *testing.T) { + callCount := 0 + debugEnabled := func() bool { + callCount++ + return callCount%2 == 1 // Alternates between true and false + } + + buf := &bytes.Buffer{} + baseHandler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + handler := &DebugCheckHandler{ + handler: baseHandler, + debugEnabled: debugEnabled, + } + + // First call should return true + assert.True(t, handler.Enabled(t.Context(), slog.LevelDebug)) + assert.Equal(t, 1, callCount) + + // Second call should return false + assert.False(t, handler.Enabled(t.Context(), slog.LevelDebug)) + assert.Equal(t, 2, callCount) + + // Third call should return true again + assert.True(t, handler.Enabled(t.Context(), slog.LevelDebug)) + assert.Equal(t, 3, callCount) + }) +} + +func TestDebugCheckHandler_Handle(t *testing.T) { + t.Run("delegates to underlying handler", func(t *testing.T) { + buf := &bytes.Buffer{} + baseHandler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + handler := &DebugCheckHandler{ + handler: baseHandler, + debugEnabled: func() bool { return true }, + } + + record := slog.NewRecord(time.Now(), slog.LevelInfo, "test message", 0) + err := handler.Handle(t.Context(), record) + + assert.NoError(t, err) + assert.Contains(t, buf.String(), "test message") + }) + + t.Run("handles context correctly", func(t *testing.T) { + buf := &bytes.Buffer{} + baseHandler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + handler := &DebugCheckHandler{ + handler: baseHandler, + debugEnabled: func() bool { return true }, + } + + type testKey string + ctx := context.WithValue(t.Context(), testKey("test"), "value") + record := slog.NewRecord(time.Now(), slog.LevelInfo, "context test", 0) + err := handler.Handle(ctx, record) + + assert.NoError(t, err) + assert.Contains(t, buf.String(), "context test") + }) +} + +func TestDebugCheckHandler_WithAttrs(t *testing.T) { + t.Run("returns new DebugCheckHandler with attributes", func(t *testing.T) { + logger := NewLogger(func() bool { return true }) + handler := logger.Handler() + newHandler := handler.WithAttrs([]slog.Attr{ + slog.String("key1", "value1"), + slog.Int("key2", 42), + }) + + // Should return a DebugCheckHandler + debugHandler, ok := newHandler.(*DebugCheckHandler) + assert.True(t, ok) + assert.NotNil(t, debugHandler) + + // Should preserve the debugEnabled function + assert.True(t, debugHandler.Enabled(t.Context(), slog.LevelDebug)) + + // Should have the attributes applied to the underlying handler + assert.NotEqual(t, handler, debugHandler.handler) + }) + + t.Run("preserves debugEnabled function", func(t *testing.T) { + callCount := 0 + debugEnabled := func() bool { + callCount++ + return callCount%2 == 1 + } + + buf := &bytes.Buffer{} + baseHandler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + handler := &DebugCheckHandler{ + handler: baseHandler, + debugEnabled: debugEnabled, + } + + attrs := []slog.Attr{slog.String("test", "value")} + newHandler := handler.WithAttrs(attrs) + + // The new handler should use the same debugEnabled function + assert.True(t, newHandler.Enabled(t.Context(), slog.LevelDebug)) + assert.Equal(t, 1, callCount) + + assert.False(t, newHandler.Enabled(t.Context(), slog.LevelDebug)) + assert.Equal(t, 2, callCount) + }) +} + +func TestDebugCheckHandler_WithGroup(t *testing.T) { + t.Run("returns new DebugCheckHandler with group", func(t *testing.T) { + buf := &bytes.Buffer{} + baseHandler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + handler := &DebugCheckHandler{ + handler: baseHandler, + debugEnabled: func() bool { return true }, + } + + newHandler := handler.WithGroup("testgroup") + + // Should return a DebugCheckHandler + debugHandler, ok := newHandler.(*DebugCheckHandler) + assert.True(t, ok) + assert.NotNil(t, debugHandler) + + // Should preserve the debugEnabled function + assert.True(t, debugHandler.Enabled(t.Context(), slog.LevelDebug)) + + // Should have the group applied to the underlying handler + assert.NotEqual(t, handler.handler, debugHandler.handler) + }) + + t.Run("preserves debugEnabled function", func(t *testing.T) { + callCount := 0 + debugEnabled := func() bool { + callCount++ + return callCount%2 == 1 + } + + buf := &bytes.Buffer{} + baseHandler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + handler := &DebugCheckHandler{ + handler: baseHandler, + debugEnabled: debugEnabled, + } + + newHandler := handler.WithGroup("testgroup") + + // The new handler should use the same debugEnabled function + assert.True(t, newHandler.Enabled(t.Context(), slog.LevelDebug)) + assert.Equal(t, 1, callCount) + + assert.False(t, newHandler.Enabled(t.Context(), slog.LevelDebug)) + assert.Equal(t, 2, callCount) + }) +} + +func TestDebugCheckHandler_Integration(t *testing.T) { + t.Run("works with NewLogger function", func(t *testing.T) { + debugEnabled := func() bool { return true } + logger := NewLogger(debugEnabled) + + assert.NotNil(t, logger) + + // The logger should have a DebugCheckHandler + handler := logger.Handler() + debugHandler, ok := handler.(*DebugCheckHandler) + assert.True(t, ok) + + // Should enable debug when debugEnabled returns true + assert.True(t, debugHandler.Enabled(t.Context(), slog.LevelDebug)) + + // Should enable other levels regardless + assert.True(t, debugHandler.Enabled(t.Context(), slog.LevelInfo)) + }) + + t.Run("dynamic debug checking works in practice", func(t *testing.T) { + debugState := false + debugEnabled := func() bool { return debugState } + + logger := NewLogger(debugEnabled) + + // Initially debug should be disabled + assert.False(t, logger.Handler().(*DebugCheckHandler).Enabled(t.Context(), slog.LevelDebug)) + + // Enable debug + debugState = true + assert.True(t, logger.Handler().(*DebugCheckHandler).Enabled(t.Context(), slog.LevelDebug)) + + // Disable debug again + debugState = false + assert.False(t, logger.Handler().(*DebugCheckHandler).Enabled(t.Context(), slog.LevelDebug)) + }) + + t.Run("handles nil debugEnabled function", func(t *testing.T) { + logger := NewLogger(nil) + + assert.NotNil(t, logger) + + // The logger should have a DebugCheckHandler + handler := logger.Handler() + debugHandler, ok := handler.(*DebugCheckHandler) + assert.True(t, ok) + + // When debugEnabled is nil, debug level should be disabled (default behavior) + assert.False(t, debugHandler.Enabled(t.Context(), slog.LevelDebug)) + + // Other levels should always be enabled + assert.True(t, debugHandler.Enabled(t.Context(), slog.LevelInfo)) + assert.True(t, debugHandler.Enabled(t.Context(), slog.LevelWarn)) + assert.True(t, debugHandler.Enabled(t.Context(), slog.LevelError)) + }) +} diff --git a/pkg/helm/internal/monocular/client.go b/pkg/helm/intern/monocular/client.go similarity index 90% rename from pkg/helm/internal/monocular/client.go rename to pkg/helm/intern/monocular/client.go index 88a2564b..f4ef5d64 100644 --- a/pkg/helm/internal/monocular/client.go +++ b/pkg/helm/intern/monocular/client.go @@ -29,9 +29,6 @@ type Client struct { // The base URL for requests BaseURL string - - // The internal logger to use - Log func(string, ...interface{}) } // New creates a new client @@ -44,12 +41,9 @@ func New(u string) (*Client, error) { return &Client{ BaseURL: u, - Log: nopLogger, }, nil } -var nopLogger = func(_ string, _ ...interface{}) {} - // Validate if the base URL for monocular is valid. func validate(u string) error { diff --git a/pkg/helm/internal/monocular/client_test.go b/pkg/helm/intern/monocular/client_test.go similarity index 100% rename from pkg/helm/internal/monocular/client_test.go rename to pkg/helm/intern/monocular/client_test.go diff --git a/pkg/helm/internal/monocular/doc.go b/pkg/helm/intern/monocular/doc.go similarity index 100% rename from pkg/helm/internal/monocular/doc.go rename to pkg/helm/intern/monocular/doc.go diff --git a/pkg/helm/internal/monocular/search.go b/pkg/helm/intern/monocular/search.go similarity index 96% rename from pkg/helm/internal/monocular/search.go rename to pkg/helm/intern/monocular/search.go index 76441e57..e5accbaf 100644 --- a/pkg/helm/internal/monocular/search.go +++ b/pkg/helm/intern/monocular/search.go @@ -24,8 +24,8 @@ import ( "path" "time" - "github.com/werf/nelm/pkg/helm/internal/version" - "github.com/werf/nelm/pkg/helm/pkg/chart" + "github.com/werf/nelm/pkg/helm/intern/version" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" ) // SearchPath is the url path to the search API in monocular. @@ -129,7 +129,7 @@ func (c *Client) Search(term string) ([]SearchResult, error) { } defer res.Body.Close() - if res.StatusCode != 200 { + if res.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to fetch %s : %s", p.String(), res.Status) } diff --git a/pkg/helm/internal/monocular/search_test.go b/pkg/helm/intern/monocular/search_test.go similarity index 99% rename from pkg/helm/internal/monocular/search_test.go rename to pkg/helm/intern/monocular/search_test.go index 9f6954af..fc82ef4b 100644 --- a/pkg/helm/internal/monocular/search_test.go +++ b/pkg/helm/intern/monocular/search_test.go @@ -28,7 +28,7 @@ var searchResult = `{"data":[{"id":"stable/phpmyadmin","type":"chart","attribute func TestSearch(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { fmt.Fprintln(w, searchResult) })) defer ts.Close() diff --git a/pkg/helm/internal/resolver/resolver.go b/pkg/helm/intern/resolver/resolver.go similarity index 80% rename from pkg/helm/internal/resolver/resolver.go rename to pkg/helm/intern/resolver/resolver.go index 3b6611fa..5d29806f 100644 --- a/pkg/helm/internal/resolver/resolver.go +++ b/pkg/helm/intern/resolver/resolver.go @@ -17,23 +17,24 @@ package resolver import ( "bytes" + "context" "encoding/json" + "errors" "fmt" + "io/fs" "os" "path/filepath" "strings" "time" "github.com/Masterminds/semver/v3" - "github.com/pkg/errors" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chart/loader" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/loader" "github.com/werf/nelm/pkg/helm/pkg/helmpath" "github.com/werf/nelm/pkg/helm/pkg/provenance" "github.com/werf/nelm/pkg/helm/pkg/registry" - "github.com/werf/nelm/pkg/helm/pkg/repo" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1" ) // Resolver resolves dependencies from semantic version ranges to a particular version. @@ -53,7 +54,7 @@ func New(chartpath, cachepath string, registryClient *registry.Client) *Resolver } // Resolve resolves dependencies and returns a lock file with the resolution. -func (r *Resolver) Resolve(reqs []*chart.Dependency, repoNames map[string]string, opts helmopts.HelmOptions) (*chart.Lock, error) { +func (r *Resolver) Resolve(ctx context.Context, reqs []*chart.Dependency, repoNames map[string]string) (*chart.Lock, error) { // Now we clone the dependencies, locking as we go. locked := make([]*chart.Dependency, len(reqs)) @@ -61,7 +62,7 @@ func (r *Resolver) Resolve(reqs []*chart.Dependency, repoNames map[string]string for i, d := range reqs { constraint, err := semver.NewConstraint(d.Version) if err != nil { - return nil, errors.Wrapf(err, "dependency %q has an invalid version/constraint format", d.Name) + return nil, fmt.Errorf("dependency %q has an invalid version/constraint format: %w", d.Name, err) } if d.Repository == "" { @@ -78,13 +79,12 @@ func (r *Resolver) Resolve(reqs []*chart.Dependency, repoNames map[string]string continue } if strings.HasPrefix(d.Repository, "file://") { - chartpath, err := GetLocalPath(d.Repository, r.chartpath) if err != nil { return nil, err } - ch, err := loader.LoadDir(chartpath, opts) + ch, err := loader.LoadDir(ctx, chartpath) if err != nil { return nil, err } @@ -96,7 +96,7 @@ func (r *Resolver) Resolve(reqs []*chart.Dependency, repoNames map[string]string } if !constraint.Check(v) { - missing = append(missing, d.Name) + missing = append(missing, fmt.Sprintf("%q (repository %q, version %q)", d.Name, d.Repository, d.Version)) continue } @@ -126,12 +126,12 @@ func (r *Resolver) Resolve(reqs []*chart.Dependency, repoNames map[string]string if !registry.IsOCI(d.Repository) { repoIndex, err := repo.LoadIndexFile(filepath.Join(r.cachepath, helmpath.CacheIndexFile(repoName))) if err != nil { - return nil, errors.Wrapf(err, "no cached repository for %s found. (try 'helm repo update')", repoName) + return nil, fmt.Errorf("no cached repository for %s found. (try 'helm repo update'): %w", repoName, err) } vs, ok = repoIndex.Entries[d.Name] if !ok { - return nil, errors.Errorf("%s chart not found in repo %s", d.Name, d.Repository) + return nil, fmt.Errorf("%s chart not found in repo %s", d.Name, d.Repository) } found = false } else { @@ -153,7 +153,7 @@ func (r *Resolver) Resolve(reqs []*chart.Dependency, repoNames map[string]string ref := fmt.Sprintf("%s/%s", strings.TrimPrefix(d.Repository, fmt.Sprintf("%s://", registry.OCIScheme)), d.Name) tags, err := r.registryClient.Tags(ref) if err != nil { - return nil, errors.Wrapf(err, "could not retrieve list of tags for repository %s", d.Repository) + return nil, fmt.Errorf("could not retrieve list of tags for repository %s: %w", d.Repository, err) } vs = make(repo.ChartVersions, len(tags)) @@ -174,7 +174,7 @@ func (r *Resolver) Resolve(reqs []*chart.Dependency, repoNames map[string]string Repository: d.Repository, Version: version, } - // The version are already sorted and hence the first one to satisfy the constraint is used + // The versions are already sorted and hence the first one to satisfy the constraint is used for _, ver := range vs { v, err := semver.NewVersion(ver.Version) // OCI does not need URLs @@ -190,11 +190,11 @@ func (r *Resolver) Resolve(reqs []*chart.Dependency, repoNames map[string]string } if !found { - missing = append(missing, d.Name) + missing = append(missing, fmt.Sprintf("%q (repository %q, version %q)", d.Name, d.Repository, d.Version)) } } if len(missing) > 0 { - return nil, errors.Errorf("can't get a valid version for repositories %s. Try changing the version constraint in Chart.yaml", strings.Join(missing, ", ")) + return nil, fmt.Errorf("can't get a valid version for %d subchart(s): %s. Make sure a matching chart version exists in the repo, or change the version constraint in Chart.yaml", len(missing), strings.Join(missing, ", ")) } digest, err := HashReq(reqs, locked) @@ -254,8 +254,8 @@ func GetLocalPath(repo, chartpath string) (string, error) { depPath = filepath.Join(chartpath, p) } - if _, err = os.Stat(depPath); os.IsNotExist(err) { - return "", errors.Errorf("directory %s not found", depPath) + if _, err = os.Stat(depPath); errors.Is(err, fs.ErrNotExist) { + return "", fmt.Errorf("directory %s not found", depPath) } else if err != nil { return "", err } diff --git a/pkg/helm/internal/resolver/resolver_test.go b/pkg/helm/intern/resolver/resolver_test.go similarity index 98% rename from pkg/helm/internal/resolver/resolver_test.go rename to pkg/helm/intern/resolver/resolver_test.go index cfbf16d2..64889853 100644 --- a/pkg/helm/internal/resolver/resolver_test.go +++ b/pkg/helm/intern/resolver/resolver_test.go @@ -16,10 +16,11 @@ limitations under the License. package resolver import ( + "context" "runtime" "testing" - "github.com/werf/nelm/pkg/helm/pkg/chart" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" "github.com/werf/nelm/pkg/helm/pkg/registry" ) @@ -144,7 +145,7 @@ func TestResolve(t *testing.T) { r := New("testdata/chartpath", "testdata/repository", registryClient) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - l, err := r.Resolve(tt.req, repoNames) + l, err := r.Resolve(context.Background(), tt.req, repoNames) if err != nil { if tt.err { return diff --git a/pkg/helm/internal/resolver/testdata/chartpath/base/Chart.yaml b/pkg/helm/intern/resolver/testdata/chartpath/base/Chart.yaml similarity index 100% rename from pkg/helm/internal/resolver/testdata/chartpath/base/Chart.yaml rename to pkg/helm/intern/resolver/testdata/chartpath/base/Chart.yaml diff --git a/pkg/helm/internal/resolver/testdata/chartpath/charts/localdependency/Chart.yaml b/pkg/helm/intern/resolver/testdata/chartpath/charts/localdependency/Chart.yaml similarity index 100% rename from pkg/helm/internal/resolver/testdata/chartpath/charts/localdependency/Chart.yaml rename to pkg/helm/intern/resolver/testdata/chartpath/charts/localdependency/Chart.yaml diff --git a/pkg/helm/internal/resolver/testdata/repository/kubernetes-charts-index.yaml b/pkg/helm/intern/resolver/testdata/repository/kubernetes-charts-index.yaml similarity index 100% rename from pkg/helm/internal/resolver/testdata/repository/kubernetes-charts-index.yaml rename to pkg/helm/intern/resolver/testdata/repository/kubernetes-charts-index.yaml diff --git a/pkg/helm/intern/statusreaders/job_status_reader.go b/pkg/helm/intern/statusreaders/job_status_reader.go new file mode 100644 index 00000000..3cd9ac7a --- /dev/null +++ b/pkg/helm/intern/statusreaders/job_status_reader.go @@ -0,0 +1,121 @@ +/* +Copyright The Helm Authors. +This file was initially copied and modified from + https://github.com/fluxcd/kustomize-controller/blob/main/internal/statusreaders/job.go +Copyright 2022 The Flux 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 statusreaders + +import ( + "context" + "fmt" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine" + "github.com/fluxcd/cli-utils/pkg/kstatus/polling/event" + "github.com/fluxcd/cli-utils/pkg/kstatus/polling/statusreaders" + "github.com/fluxcd/cli-utils/pkg/kstatus/status" + "github.com/fluxcd/cli-utils/pkg/object" +) + +type customJobStatusReader struct { + genericStatusReader engine.StatusReader +} + +func NewCustomJobStatusReader(mapper meta.RESTMapper) engine.StatusReader { + genericStatusReader := statusreaders.NewGenericStatusReader(mapper, jobConditions) + return &customJobStatusReader{ + genericStatusReader: genericStatusReader, + } +} + +func (j *customJobStatusReader) Supports(gk schema.GroupKind) bool { + return gk == batchv1.SchemeGroupVersion.WithKind("Job").GroupKind() +} + +func (j *customJobStatusReader) ReadStatus(ctx context.Context, reader engine.ClusterReader, resource object.ObjMetadata) (*event.ResourceStatus, error) { + return j.genericStatusReader.ReadStatus(ctx, reader, resource) +} + +func (j *customJobStatusReader) ReadStatusForObject(ctx context.Context, reader engine.ClusterReader, resource *unstructured.Unstructured) (*event.ResourceStatus, error) { + return j.genericStatusReader.ReadStatusForObject(ctx, reader, resource) +} + +// Ref: https://github.com/kubernetes-sigs/cli-utils/blob/v0.29.4/pkg/kstatus/status/core.go +// Modified to return Current status only when the Job has completed as opposed to when it's in progress. +func jobConditions(u *unstructured.Unstructured) (*status.Result, error) { + obj := u.UnstructuredContent() + + parallelism := status.GetIntField(obj, ".spec.parallelism", 1) + completions := status.GetIntField(obj, ".spec.completions", parallelism) + succeeded := status.GetIntField(obj, ".status.succeeded", 0) + failed := status.GetIntField(obj, ".status.failed", 0) + + // Conditions + // https://github.com/kubernetes/kubernetes/blob/master/pkg/controller/job/utils.go#L24 + objc, err := status.GetObjectWithConditions(obj) + if err != nil { + return nil, err + } + for _, c := range objc.Status.Conditions { + switch c.Type { + case "Complete": + if c.Status == corev1.ConditionTrue { + message := fmt.Sprintf("Job Completed. succeeded: %d/%d", succeeded, completions) + return &status.Result{ + Status: status.CurrentStatus, + Message: message, + Conditions: []status.Condition{}, + }, nil + } + case "Failed": + message := fmt.Sprintf("Job Failed. failed: %d/%d", failed, completions) + if c.Status == corev1.ConditionTrue { + return &status.Result{ + Status: status.FailedStatus, + Message: message, + Conditions: []status.Condition{ + { + Type: status.ConditionStalled, + Status: corev1.ConditionTrue, + Reason: "JobFailed", + Message: message, + }, + }, + }, nil + } + } + } + + message := "Job in progress" + return &status.Result{ + Status: status.InProgressStatus, + Message: message, + Conditions: []status.Condition{ + { + Type: status.ConditionReconciling, + Status: corev1.ConditionTrue, + Reason: "JobInProgress", + Message: message, + }, + }, + }, nil +} diff --git a/pkg/helm/intern/statusreaders/job_status_reader_test.go b/pkg/helm/intern/statusreaders/job_status_reader_test.go new file mode 100644 index 00000000..6e9ed5a7 --- /dev/null +++ b/pkg/helm/intern/statusreaders/job_status_reader_test.go @@ -0,0 +1,116 @@ +/* +Copyright The Helm Authors. +This file was initially copied and modified from + https://github.com/fluxcd/kustomize-controller/blob/main/internal/statusreaders/job_test.go +Copyright 2022 The Flux 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 statusreaders + +import ( + "testing" + + "github.com/stretchr/testify/assert" + batchv1 "k8s.io/api/batch/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" + + "github.com/fluxcd/cli-utils/pkg/kstatus/status" +) + +func toUnstructured(t *testing.T, obj runtime.Object) (*unstructured.Unstructured, error) { + t.Helper() + // If the incoming object is already unstructured, perform a deep copy first + // otherwise DefaultUnstructuredConverter ends up returning the inner map without + // making a copy. + if _, ok := obj.(runtime.Unstructured); ok { + obj = obj.DeepCopyObject() + } + rawMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return nil, err + } + return &unstructured.Unstructured{Object: rawMap}, nil +} + +func TestJobConditions(t *testing.T) { + t.Parallel() + tests := []struct { + name string + job *batchv1.Job + expectedStatus status.Status + }{ + { + name: "job without Complete condition returns InProgress status", + job: &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "job-no-condition", + }, + Spec: batchv1.JobSpec{}, + Status: batchv1.JobStatus{}, + }, + expectedStatus: status.InProgressStatus, + }, + { + name: "job with Complete condition as True returns Current status", + job: &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "job-complete", + }, + Spec: batchv1.JobSpec{}, + Status: batchv1.JobStatus{ + Conditions: []batchv1.JobCondition{ + { + Type: batchv1.JobComplete, + Status: corev1.ConditionTrue, + }, + }, + }, + }, + expectedStatus: status.CurrentStatus, + }, + { + name: "job with Failed condition as True returns Failed status", + job: &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "job-failed", + }, + Spec: batchv1.JobSpec{}, + Status: batchv1.JobStatus{ + Conditions: []batchv1.JobCondition{ + { + Type: batchv1.JobFailed, + Status: corev1.ConditionTrue, + }, + }, + }, + }, + expectedStatus: status.FailedStatus, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + us, err := toUnstructured(t, tc.job) + assert.NoError(t, err) + result, err := jobConditions(us) + assert.NoError(t, err) + assert.Equal(t, tc.expectedStatus, result.Status) + }) + } +} diff --git a/pkg/helm/intern/statusreaders/pod_status_reader.go b/pkg/helm/intern/statusreaders/pod_status_reader.go new file mode 100644 index 00000000..bf633c0d --- /dev/null +++ b/pkg/helm/intern/statusreaders/pod_status_reader.go @@ -0,0 +1,104 @@ +/* +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 statusreaders + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine" + "github.com/fluxcd/cli-utils/pkg/kstatus/polling/event" + "github.com/fluxcd/cli-utils/pkg/kstatus/polling/statusreaders" + "github.com/fluxcd/cli-utils/pkg/kstatus/status" + "github.com/fluxcd/cli-utils/pkg/object" +) + +type customPodStatusReader struct { + genericStatusReader engine.StatusReader +} + +func NewCustomPodStatusReader(mapper meta.RESTMapper) engine.StatusReader { + genericStatusReader := statusreaders.NewGenericStatusReader(mapper, podConditions) + return &customPodStatusReader{ + genericStatusReader: genericStatusReader, + } +} + +func (j *customPodStatusReader) Supports(gk schema.GroupKind) bool { + return gk == corev1.SchemeGroupVersion.WithKind("Pod").GroupKind() +} + +func (j *customPodStatusReader) ReadStatus(ctx context.Context, reader engine.ClusterReader, resource object.ObjMetadata) (*event.ResourceStatus, error) { + return j.genericStatusReader.ReadStatus(ctx, reader, resource) +} + +func (j *customPodStatusReader) ReadStatusForObject(ctx context.Context, reader engine.ClusterReader, resource *unstructured.Unstructured) (*event.ResourceStatus, error) { + return j.genericStatusReader.ReadStatusForObject(ctx, reader, resource) +} + +func podConditions(u *unstructured.Unstructured) (*status.Result, error) { + obj := u.UnstructuredContent() + phase := status.GetStringField(obj, ".status.phase", "") + switch corev1.PodPhase(phase) { + case corev1.PodSucceeded: + message := fmt.Sprintf("pod %s succeeded", u.GetName()) + return &status.Result{ + Status: status.CurrentStatus, + Message: message, + Conditions: []status.Condition{ + { + Type: status.ConditionStalled, + Status: corev1.ConditionTrue, + Message: message, + }, + }, + }, nil + case corev1.PodFailed: + message := fmt.Sprintf("pod %s failed", u.GetName()) + return &status.Result{ + Status: status.FailedStatus, + Message: message, + Conditions: []status.Condition{ + { + Type: status.ConditionStalled, + Status: corev1.ConditionTrue, + Reason: "PodFailed", + Message: message, + }, + }, + }, nil + default: + message := "Pod in progress" + return &status.Result{ + Status: status.InProgressStatus, + Message: message, + Conditions: []status.Condition{ + { + Type: status.ConditionReconciling, + Status: corev1.ConditionTrue, + Reason: "PodInProgress", + Message: message, + }, + }, + }, nil + } +} diff --git a/pkg/helm/intern/statusreaders/pod_status_reader_test.go b/pkg/helm/intern/statusreaders/pod_status_reader_test.go new file mode 100644 index 00000000..ba0d1f1b --- /dev/null +++ b/pkg/helm/intern/statusreaders/pod_status_reader_test.go @@ -0,0 +1,111 @@ +/* +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 statusreaders + +import ( + "testing" + + "github.com/stretchr/testify/assert" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/fluxcd/cli-utils/pkg/kstatus/status" +) + +func TestPodConditions(t *testing.T) { + tests := []struct { + name string + pod *v1.Pod + expectedStatus status.Status + }{ + { + name: "pod without status returns in progress", + pod: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "pod-no-status"}, + Spec: v1.PodSpec{}, + Status: v1.PodStatus{}, + }, + expectedStatus: status.InProgressStatus, + }, + { + name: "pod succeeded returns current status", + pod: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "pod-succeeded"}, + Spec: v1.PodSpec{}, + Status: v1.PodStatus{ + Phase: v1.PodSucceeded, + }, + }, + expectedStatus: status.CurrentStatus, + }, + { + name: "pod failed returns failed status", + pod: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "pod-failed"}, + Spec: v1.PodSpec{}, + Status: v1.PodStatus{ + Phase: v1.PodFailed, + }, + }, + expectedStatus: status.FailedStatus, + }, + { + name: "pod pending returns in progress status", + pod: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "pod-pending"}, + Spec: v1.PodSpec{}, + Status: v1.PodStatus{ + Phase: v1.PodPending, + }, + }, + expectedStatus: status.InProgressStatus, + }, + { + name: "pod running returns in progress status", + pod: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "pod-running"}, + Spec: v1.PodSpec{}, + Status: v1.PodStatus{ + Phase: v1.PodRunning, + }, + }, + expectedStatus: status.InProgressStatus, + }, + { + name: "pod with unknown phase returns in progress status", + pod: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "pod-unknown"}, + Spec: v1.PodSpec{}, + Status: v1.PodStatus{ + Phase: v1.PodUnknown, + }, + }, + expectedStatus: status.InProgressStatus, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + us, err := toUnstructured(t, tc.pod) + assert.NoError(t, err) + result, err := podConditions(us) + assert.NoError(t, err) + assert.Equal(t, tc.expectedStatus, result.Status) + }) + } +} diff --git a/pkg/helm/internal/sympath/walk.go b/pkg/helm/intern/sympath/walk.go similarity index 89% rename from pkg/helm/internal/sympath/walk.go rename to pkg/helm/intern/sympath/walk.go index a276cfef..812bb68c 100644 --- a/pkg/helm/internal/sympath/walk.go +++ b/pkg/helm/intern/sympath/walk.go @@ -21,12 +21,11 @@ limitations under the License. package sympath import ( - "log" + "fmt" + "log/slog" "os" "path/filepath" "sort" - - "github.com/pkg/errors" ) // Walk walks the file tree rooted at root, calling walkFn for each file or directory @@ -69,9 +68,10 @@ func symwalk(path string, info os.FileInfo, walkFn filepath.WalkFunc) error { if IsSymlink(info) { resolved, err := filepath.EvalSymlinks(path) if err != nil { - return errors.Wrapf(err, "error evaluating symlink %s", path) + return fmt.Errorf("error evaluating symlink %s: %w", path, err) } - log.Printf("found symbolic link in path: %s resolves to %s. Contents of linked file included and used", path, resolved) + // This log message is to highlight a symlink that is being used within a chart, symlinks can be used for nefarious reasons. + slog.Info("found symbolic link in path. Contents of linked file included and used", "path", path, "resolved", resolved) if info, err = os.Lstat(resolved); err != nil { return err } diff --git a/pkg/helm/internal/sympath/walk_test.go b/pkg/helm/intern/sympath/walk_test.go similarity index 87% rename from pkg/helm/internal/sympath/walk_test.go rename to pkg/helm/intern/sympath/walk_test.go index 25f73713..1eba8b99 100644 --- a/pkg/helm/internal/sympath/walk_test.go +++ b/pkg/helm/intern/sympath/walk_test.go @@ -76,6 +76,7 @@ func walkTree(n *Node, path string, f func(path string, n *Node)) { } func makeTree(t *testing.T) { + t.Helper() walkTree(tree, tree.name, func(path string, n *Node) { if n.entries == nil { if n.symLinkedTo != "" { @@ -99,6 +100,7 @@ func makeTree(t *testing.T) { } func checkMarks(t *testing.T, report bool) { + t.Helper() walkTree(tree, tree.name, func(path string, n *Node) { if n.marks != n.expectedMarks && report { t.Errorf("node %s mark = %d; expected %d", path, n.marks, n.expectedMarks) @@ -108,18 +110,18 @@ func checkMarks(t *testing.T, report bool) { } // Assumes that each node name is unique. Good enough for a test. -// If clear is true, any incoming error is cleared before return. The errors -// are always accumulated, though. -func mark(info os.FileInfo, err error, errors *[]error, clear bool) error { +// If clearIncomingError is true, any incoming error is cleared before +// return. The errors are always accumulated, though. +func mark(info os.FileInfo, err error, errors *[]error, clearIncomingError bool) error { if err != nil { *errors = append(*errors, err) - if clear { + if clearIncomingError { return nil } return err } name := info.Name() - walkTree(tree, tree.name, func(path string, n *Node) { + walkTree(tree, tree.name, func(_ string, n *Node) { if n.name == name { n.marks++ } @@ -130,9 +132,8 @@ func mark(info os.FileInfo, err error, errors *[]error, clear bool) error { func TestWalk(t *testing.T) { makeTree(t) errors := make([]error, 0, 10) - clear := true - markFn := func(path string, info os.FileInfo, err error) error { - return mark(info, err, &errors, clear) + markFn := func(_ string, info os.FileInfo, err error) error { + return mark(info, err, &errors, true) } // Expect no errors. err := Walk(tree.name, markFn) diff --git a/pkg/helm/internal/test/ensure/ensure.go b/pkg/helm/intern/test/ensure/ensure.go similarity index 81% rename from pkg/helm/internal/test/ensure/ensure.go rename to pkg/helm/intern/test/ensure/ensure.go index 52fdb27a..70a2250c 100644 --- a/pkg/helm/internal/test/ensure/ensure.go +++ b/pkg/helm/intern/test/ensure/ensure.go @@ -29,12 +29,12 @@ import ( func HelmHome(t *testing.T) { t.Helper() base := t.TempDir() - os.Setenv(xdg.CacheHomeEnvVar, base) - os.Setenv(xdg.ConfigHomeEnvVar, base) - os.Setenv(xdg.DataHomeEnvVar, base) - os.Setenv(helmpath.CacheHomeEnvVar, "") - os.Setenv(helmpath.ConfigHomeEnvVar, "") - os.Setenv(helmpath.DataHomeEnvVar, "") + t.Setenv(xdg.CacheHomeEnvVar, base) + t.Setenv(xdg.ConfigHomeEnvVar, base) + t.Setenv(xdg.DataHomeEnvVar, base) + t.Setenv(helmpath.CacheHomeEnvVar, "") + t.Setenv(helmpath.ConfigHomeEnvVar, "") + t.Setenv(helmpath.DataHomeEnvVar, "") } // TempFile ensures a temp file for unit testing purposes. @@ -46,9 +46,10 @@ func HelmHome(t *testing.T) { // tempdir := TempFile(t, "foo", []byte("bar")) // filename := filepath.Join(tempdir, "foo") func TempFile(t *testing.T, name string, data []byte) string { + t.Helper() path := t.TempDir() filename := filepath.Join(path, name) - if err := os.WriteFile(filename, data, 0755); err != nil { + if err := os.WriteFile(filename, data, 0o755); err != nil { t.Fatal(err) } return path diff --git a/pkg/helm/internal/test/test.go b/pkg/helm/intern/test/test.go similarity index 89% rename from pkg/helm/internal/test/test.go rename to pkg/helm/intern/test/test.go index e6821282..632bc72f 100644 --- a/pkg/helm/internal/test/test.go +++ b/pkg/helm/intern/test/test.go @@ -19,10 +19,9 @@ package test import ( "bytes" "flag" + "fmt" "os" "path/filepath" - - "github.com/pkg/errors" ) // UpdateGolden writes out the golden files with the latest values, rather than failing the test. @@ -75,11 +74,11 @@ func compare(actual []byte, filename string) error { expected, err := os.ReadFile(filename) if err != nil { - return errors.Wrapf(err, "unable to read testdata %s", filename) + return fmt.Errorf("unable to read testdata %s: %w", filename, err) } expected = normalize(expected) if !bytes.Equal(expected, actual) { - return errors.Errorf("does not match golden file %s\n\nWANT:\n'%s'\n\nGOT:\n'%s'", filename, expected, actual) + return fmt.Errorf("does not match golden file %s\n\nWANT:\n'%s'\n\nGOT:\n'%s'", filename, expected, actual) } return nil } @@ -92,5 +91,5 @@ func update(filename string, in []byte) error { } func normalize(in []byte) []byte { - return bytes.Replace(in, []byte("\r\n"), []byte("\n"), -1) + return bytes.ReplaceAll(in, []byte("\r\n"), []byte("\n")) } diff --git a/pkg/helm/internal/third_party/dep/fs/fs.go b/pkg/helm/intern/third_party/dep/fs/fs.go similarity index 89% rename from pkg/helm/internal/third_party/dep/fs/fs.go rename to pkg/helm/intern/third_party/dep/fs/fs.go index 4e4eacc6..6e2720f3 100644 --- a/pkg/helm/internal/third_party/dep/fs/fs.go +++ b/pkg/helm/intern/third_party/dep/fs/fs.go @@ -32,13 +32,14 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. package fs import ( + "errors" + "fmt" "io" + "io/fs" "os" "path/filepath" "runtime" "syscall" - - "github.com/pkg/errors" ) // fs contains a copy of a few functions from dep tool code to avoid a dependency on golang/dep. @@ -51,7 +52,7 @@ import ( func RenameWithFallback(src, dst string) error { _, err := os.Stat(src) if err != nil { - return errors.Wrapf(err, "cannot stat %s", src) + return fmt.Errorf("cannot stat %s: %w", src, err) } err = os.Rename(src, dst) @@ -69,20 +70,24 @@ func renameByCopy(src, dst string) error { if dir, _ := IsDir(src); dir { cerr = CopyDir(src, dst) if cerr != nil { - cerr = errors.Wrap(cerr, "copying directory failed") + cerr = fmt.Errorf("copying directory failed: %w", cerr) } } else { - cerr = copyFile(src, dst) + cerr = CopyFile(src, dst) if cerr != nil { - cerr = errors.Wrap(cerr, "copying file failed") + cerr = fmt.Errorf("copying file failed: %w", cerr) } } if cerr != nil { - return errors.Wrapf(cerr, "rename fallback failed: cannot rename %s to %s", src, dst) + return fmt.Errorf("rename fallback failed: cannot rename %s to %s: %w", src, dst, cerr) + } + + if err := os.RemoveAll(src); err != nil { + return fmt.Errorf("cannot delete %s: %w", src, err) } - return errors.Wrapf(os.RemoveAll(src), "cannot delete %s", src) + return nil } var ( @@ -107,7 +112,7 @@ func CopyDir(src, dst string) error { } _, err = os.Stat(dst) - if err != nil && !os.IsNotExist(err) { + if err != nil && !errors.Is(err, fs.ErrNotExist) { return err } if err == nil { @@ -115,12 +120,12 @@ func CopyDir(src, dst string) error { } if err = os.MkdirAll(dst, fi.Mode()); err != nil { - return errors.Wrapf(err, "cannot mkdir %s", dst) + return fmt.Errorf("cannot mkdir %s: %w", dst, err) } entries, err := os.ReadDir(src) if err != nil { - return errors.Wrapf(err, "cannot read directory %s", dst) + return fmt.Errorf("cannot read directory %s: %w", dst, err) } for _, entry := range entries { @@ -129,13 +134,13 @@ func CopyDir(src, dst string) error { if entry.IsDir() { if err = CopyDir(srcPath, dstPath); err != nil { - return errors.Wrap(err, "copying directory failed") + return fmt.Errorf("copying directory failed: %w", err) } } else { // This will include symlinks, which is what we want when // copying things. - if err = copyFile(srcPath, dstPath); err != nil { - return errors.Wrap(err, "copying file failed") + if err = CopyFile(srcPath, dstPath); err != nil { + return fmt.Errorf("copying file failed: %w", err) } } } @@ -143,13 +148,13 @@ func CopyDir(src, dst string) error { return nil } -// copyFile copies the contents of the file named src to the file named +// CopyFile copies the contents of the file named src to the file named // by dst. The file will be created if it does not already exist. If the // destination file exists, all its contents will be replaced by the contents // of the source file. The file mode will be copied from the source. -func copyFile(src, dst string) (err error) { +func CopyFile(src, dst string) (err error) { if sym, err := IsSymlink(src); err != nil { - return errors.Wrap(err, "symlink check failed") + return fmt.Errorf("symlink check failed: %w", err) } else if sym { if err := cloneSymlink(src, dst); err != nil { if runtime.GOOS == "windows" { @@ -172,28 +177,28 @@ func copyFile(src, dst string) (err error) { in, err := os.Open(src) if err != nil { - return + return err } defer in.Close() out, err := os.Create(dst) if err != nil { - return + return err } if _, err = io.Copy(out, in); err != nil { out.Close() - return + return err } // Check for write errors on Close if err = out.Close(); err != nil { - return + return err } si, err := os.Stat(src) if err != nil { - return + return err } // Temporary fix for Go < 1.9 @@ -205,7 +210,7 @@ func copyFile(src, dst string) (err error) { } err = os.Chmod(dst, si.Mode()) - return + return err } // cloneSymlink will create a new symlink that points to the resolved path of sl. @@ -226,7 +231,7 @@ func IsDir(name string) (bool, error) { return false, err } if !fi.IsDir() { - return false, errors.Errorf("%q is not a directory", name) + return false, fmt.Errorf("%q is not a directory", name) } return true, nil } @@ -260,7 +265,7 @@ func fixLongPath(path string) string { // minus 12)." Since MAX_PATH is 260, 260 - 12 = 248. // // The MSDN docs appear to say that a normal path that is 248 bytes long - // will work; empirically the path must be less then 248 bytes long. + // will work; empirically the path must be less than 248 bytes long. if len(path) < 248 { // Don't fix. (This is how Go 1.7 and earlier worked, // not automatically generating the \\?\ form) diff --git a/pkg/helm/internal/third_party/dep/fs/fs_test.go b/pkg/helm/intern/third_party/dep/fs/fs_test.go similarity index 96% rename from pkg/helm/internal/third_party/dep/fs/fs_test.go rename to pkg/helm/intern/third_party/dep/fs/fs_test.go index d42c3f11..610771bc 100644 --- a/pkg/helm/internal/third_party/dep/fs/fs_test.go +++ b/pkg/helm/intern/third_party/dep/fs/fs_test.go @@ -33,17 +33,11 @@ package fs import ( "os" - "os/exec" "path/filepath" "runtime" - "sync" "testing" ) -var ( - mu sync.Mutex -) - func TestRenameWithFallback(t *testing.T) { dir := t.TempDir() @@ -332,7 +326,7 @@ func TestCopyFile(t *testing.T) { srcf.Close() destf := filepath.Join(dir, "destf") - if err := copyFile(srcf.Name(), destf); err != nil { + if err := CopyFile(srcf.Name(), destf); err != nil { t.Fatal(err) } @@ -360,19 +354,6 @@ func TestCopyFile(t *testing.T) { } } -func cleanUpDir(dir string) { - // NOTE(mattn): It seems that sometimes git.exe is not dead - // when cleanUpDir() is called. But we do not know any way to wait for it. - if runtime.GOOS == "windows" { - mu.Lock() - exec.Command(`taskkill`, `/F`, `/IM`, `git.exe`).Run() - mu.Unlock() - } - if dir != "" { - os.RemoveAll(dir) - } -} - func TestCopyFileSymlink(t *testing.T) { tempdir := t.TempDir() @@ -385,7 +366,7 @@ func TestCopyFileSymlink(t *testing.T) { for symlink, dst := range testcases { t.Run(symlink, func(t *testing.T) { var err error - if err = copyFile(symlink, dst); err != nil { + if err = CopyFile(symlink, dst); err != nil { t.Fatalf("failed to copy symlink: %s", err) } @@ -457,7 +438,7 @@ func TestCopyFileFail(t *testing.T) { defer cleanup() fn := filepath.Join(dstdir, "file") - if err := copyFile(srcf.Name(), fn); err == nil { + if err := CopyFile(srcf.Name(), fn); err == nil { t.Fatalf("expected error for %s, got none", fn) } } @@ -476,6 +457,7 @@ func TestCopyFileFail(t *testing.T) { // files this function creates. It is the caller's responsibility to call // this function before the test is done running, whether there's an error or not. func setupInaccessibleDir(t *testing.T, op func(dir string) error) func() { + t.Helper() dir := t.TempDir() subdir := filepath.Join(dir, "dir") diff --git a/pkg/helm/internal/third_party/dep/fs/rename.go b/pkg/helm/intern/third_party/dep/fs/rename.go similarity index 95% rename from pkg/helm/internal/third_party/dep/fs/rename.go rename to pkg/helm/intern/third_party/dep/fs/rename.go index a3e5e56a..5f13b1ca 100644 --- a/pkg/helm/internal/third_party/dep/fs/rename.go +++ b/pkg/helm/intern/third_party/dep/fs/rename.go @@ -34,10 +34,9 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. package fs import ( + "fmt" "os" "syscall" - - "github.com/pkg/errors" ) // renameFallback attempts to determine the appropriate fallback to failed rename @@ -51,7 +50,7 @@ func renameFallback(err error, src, dst string) error { if !ok { return err } else if terr.Err != syscall.EXDEV { - return errors.Wrapf(terr, "link error: cannot rename %s to %s", src, dst) + return fmt.Errorf("link error: cannot rename %s to %s: %w", src, dst, terr) } return renameByCopy(src, dst) diff --git a/pkg/helm/internal/third_party/dep/fs/rename_windows.go b/pkg/helm/intern/third_party/dep/fs/rename_windows.go similarity index 96% rename from pkg/helm/internal/third_party/dep/fs/rename_windows.go rename to pkg/helm/intern/third_party/dep/fs/rename_windows.go index a377720a..566f695d 100644 --- a/pkg/helm/internal/third_party/dep/fs/rename_windows.go +++ b/pkg/helm/intern/third_party/dep/fs/rename_windows.go @@ -34,10 +34,9 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. package fs import ( + "fmt" "os" "syscall" - - "github.com/pkg/errors" ) // renameFallback attempts to determine the appropriate fallback to failed rename @@ -61,7 +60,7 @@ func renameFallback(err error, src, dst string) error { // 0x11 (ERROR_NOT_SAME_DEVICE) is the windows error. // See https://msdn.microsoft.com/en-us/library/cc231199.aspx if ok && noerr != 0x11 { - return errors.Wrapf(terr, "link error: cannot rename %s to %s", src, dst) + return fmt.Errorf("link error: cannot rename %s to %s: %w", src, dst, terr) } } diff --git a/pkg/helm/internal/third_party/dep/fs/testdata/symlinks/file-symlink b/pkg/helm/intern/third_party/dep/fs/testdata/symlinks/file-symlink similarity index 100% rename from pkg/helm/internal/third_party/dep/fs/testdata/symlinks/file-symlink rename to pkg/helm/intern/third_party/dep/fs/testdata/symlinks/file-symlink diff --git a/pkg/helm/internal/third_party/dep/fs/testdata/symlinks/invalid-symlink b/pkg/helm/intern/third_party/dep/fs/testdata/symlinks/invalid-symlink similarity index 100% rename from pkg/helm/internal/third_party/dep/fs/testdata/symlinks/invalid-symlink rename to pkg/helm/intern/third_party/dep/fs/testdata/symlinks/invalid-symlink diff --git a/pkg/helm/internal/third_party/dep/fs/testdata/symlinks/windows-file-symlink b/pkg/helm/intern/third_party/dep/fs/testdata/symlinks/windows-file-symlink similarity index 100% rename from pkg/helm/internal/third_party/dep/fs/testdata/symlinks/windows-file-symlink rename to pkg/helm/intern/third_party/dep/fs/testdata/symlinks/windows-file-symlink diff --git a/pkg/helm/internal/third_party/dep/fs/testdata/test.file b/pkg/helm/intern/third_party/dep/fs/testdata/test.file similarity index 100% rename from pkg/helm/internal/third_party/dep/fs/testdata/test.file rename to pkg/helm/intern/third_party/dep/fs/testdata/test.file diff --git a/pkg/helm/internal/third_party/k8s.io/kubernetes/deployment/util/deploymentutil.go b/pkg/helm/intern/third_party/k8s.io/kubernetes/deployment/util/deploymentutil.go similarity index 100% rename from pkg/helm/internal/third_party/k8s.io/kubernetes/deployment/util/deploymentutil.go rename to pkg/helm/intern/third_party/k8s.io/kubernetes/deployment/util/deploymentutil.go diff --git a/pkg/helm/intern/tlsutil/tls.go b/pkg/helm/intern/tlsutil/tls.go new file mode 100644 index 00000000..88f26d47 --- /dev/null +++ b/pkg/helm/intern/tlsutil/tls.go @@ -0,0 +1,122 @@ +/* +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 tlsutil + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "os" + + "errors" +) + +type TLSConfigOptions struct { + insecureSkipTLSVerify bool + certPEMBlock, keyPEMBlock []byte + caPEMBlock []byte +} + +type TLSConfigOption func(options *TLSConfigOptions) error + +func WithInsecureSkipVerify(insecureSkipTLSVerify bool) TLSConfigOption { + return func(options *TLSConfigOptions) error { + options.insecureSkipTLSVerify = insecureSkipTLSVerify + + return nil + } +} + +func WithCertKeyPairFiles(certFile, keyFile string) TLSConfigOption { + return func(options *TLSConfigOptions) error { + if certFile == "" && keyFile == "" { + return nil + } + + certPEMBlock, err := os.ReadFile(certFile) + if err != nil { + return fmt.Errorf("unable to read cert file: %q: %w", certFile, err) + } + + keyPEMBlock, err := os.ReadFile(keyFile) + if err != nil { + return fmt.Errorf("unable to read key file: %q: %w", keyFile, err) + } + + options.certPEMBlock = certPEMBlock + options.keyPEMBlock = keyPEMBlock + + return nil + } +} + +func WithCAFile(caFile string) TLSConfigOption { + return func(options *TLSConfigOptions) error { + if caFile == "" { + return nil + } + + caPEMBlock, err := os.ReadFile(caFile) + if err != nil { + return fmt.Errorf("can't read CA file: %q: %w", caFile, err) + } + + options.caPEMBlock = caPEMBlock + + return nil + } +} + +func NewTLSConfig(options ...TLSConfigOption) (*tls.Config, error) { + to := TLSConfigOptions{} + + errs := []error{} + for _, option := range options { + err := option(&to) + if err != nil { + errs = append(errs, err) + } + } + + if len(errs) > 0 { + return nil, errors.Join(errs...) + } + + config := tls.Config{ + InsecureSkipVerify: to.insecureSkipTLSVerify, + } + + if len(to.certPEMBlock) > 0 && len(to.keyPEMBlock) > 0 { + cert, err := tls.X509KeyPair(to.certPEMBlock, to.keyPEMBlock) + if err != nil { + return nil, fmt.Errorf("unable to load cert from key pair: %w", err) + } + + config.Certificates = []tls.Certificate{cert} + } + + if len(to.caPEMBlock) > 0 { + cp := x509.NewCertPool() + if !cp.AppendCertsFromPEM(to.caPEMBlock) { + return nil, fmt.Errorf("failed to append certificates from pem block") + } + + config.RootCAs = cp + } + + return &config, nil +} diff --git a/pkg/helm/intern/tlsutil/tls_test.go b/pkg/helm/intern/tlsutil/tls_test.go new file mode 100644 index 00000000..f16eb218 --- /dev/null +++ b/pkg/helm/intern/tlsutil/tls_test.go @@ -0,0 +1,106 @@ +/* +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 tlsutil + +import ( + "path/filepath" + "testing" +) + +const tlsTestDir = "../../testdata" + +const ( + testCaCertFile = "rootca.crt" + testCertFile = "crt.pem" + testKeyFile = "key.pem" +) + +func testfile(t *testing.T, file string) (path string) { + t.Helper() + path, err := filepath.Abs(filepath.Join(tlsTestDir, file)) + if err != nil { + t.Fatalf("error getting absolute path to test file %q: %v", file, err) + } + return path +} + +func TestNewTLSConfig(t *testing.T) { + certFile := testfile(t, testCertFile) + keyFile := testfile(t, testKeyFile) + caCertFile := testfile(t, testCaCertFile) + insecureSkipTLSVerify := false + + { + cfg, err := NewTLSConfig( + WithInsecureSkipVerify(insecureSkipTLSVerify), + WithCertKeyPairFiles(certFile, keyFile), + WithCAFile(caCertFile), + ) + if err != nil { + t.Error(err) + } + + if got := len(cfg.Certificates); got != 1 { + t.Fatalf("expecting 1 client certificates, got %d", got) + } + if cfg.InsecureSkipVerify { + t.Fatalf("insecure skip verify mismatch, expecting false") + } + if cfg.RootCAs == nil { + t.Fatalf("mismatch tls RootCAs, expecting non-nil") + } + } + { + cfg, err := NewTLSConfig( + WithInsecureSkipVerify(insecureSkipTLSVerify), + WithCAFile(caCertFile), + ) + if err != nil { + t.Error(err) + } + + if got := len(cfg.Certificates); got != 0 { + t.Fatalf("expecting 0 client certificates, got %d", got) + } + if cfg.InsecureSkipVerify { + t.Fatalf("insecure skip verify mismatch, expecting false") + } + if cfg.RootCAs == nil { + t.Fatalf("mismatch tls RootCAs, expecting non-nil") + } + } + + { + cfg, err := NewTLSConfig( + WithInsecureSkipVerify(insecureSkipTLSVerify), + WithCertKeyPairFiles(certFile, keyFile), + ) + if err != nil { + t.Error(err) + } + + if got := len(cfg.Certificates); got != 1 { + t.Fatalf("expecting 1 client certificates, got %d", got) + } + if cfg.InsecureSkipVerify { + t.Fatalf("insecure skip verify mismatch, expecting false") + } + if cfg.RootCAs != nil { + t.Fatalf("mismatch tls RootCAs, expecting nil") + } + } +} diff --git a/pkg/helm/internal/urlutil/urlutil.go b/pkg/helm/intern/urlutil/urlutil.go similarity index 100% rename from pkg/helm/internal/urlutil/urlutil.go rename to pkg/helm/intern/urlutil/urlutil.go diff --git a/pkg/helm/internal/urlutil/urlutil_test.go b/pkg/helm/intern/urlutil/urlutil_test.go similarity index 100% rename from pkg/helm/internal/urlutil/urlutil_test.go rename to pkg/helm/intern/urlutil/urlutil_test.go diff --git a/pkg/helm/intern/version/clientgo.go b/pkg/helm/intern/version/clientgo.go new file mode 100644 index 00000000..ab2a38fd --- /dev/null +++ b/pkg/helm/intern/version/clientgo.go @@ -0,0 +1,44 @@ +/* +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 version + +import ( + "fmt" + "runtime/debug" + "slices" + + _ "k8s.io/client-go/kubernetes" // Force k8s.io/client-go to be included in the build +) + +func K8sIOClientGoModVersion() (string, error) { + info, ok := debug.ReadBuildInfo() + if !ok { + return "", fmt.Errorf("failed to read build info") + } + + idx := slices.IndexFunc(info.Deps, func(m *debug.Module) bool { + return m.Path == "k8s.io/client-go" + }) + + if idx == -1 { + return "", fmt.Errorf("k8s.io/client-go not found in build info") + } + + m := info.Deps[idx] + + return m.Version, nil +} diff --git a/pkg/helm/intern/version/clientgo_test.go b/pkg/helm/intern/version/clientgo_test.go new file mode 100644 index 00000000..624c669a --- /dev/null +++ b/pkg/helm/intern/version/clientgo_test.go @@ -0,0 +1,30 @@ +/* +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 version + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestK8sClientGoModVersion(t *testing.T) { + // Unfortunately, test builds don't include debug info / module info + // So we expect "K8sIOClientGoModVersion" to return error + _, err := K8sIOClientGoModVersion() + require.ErrorContains(t, err, "k8s.io/client-go not found in build info") +} diff --git a/pkg/helm/internal/version/version.go b/pkg/helm/intern/version/version.go similarity index 61% rename from pkg/helm/internal/version/version.go rename to pkg/helm/intern/version/version.go index 414957bc..3daf8089 100644 --- a/pkg/helm/internal/version/version.go +++ b/pkg/helm/intern/version/version.go @@ -14,12 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -package version // import "helm.sh/helm/v3/internal/version" +package version import ( "flag" + "fmt" + "log/slog" "runtime" "strings" + "testing" + + "github.com/Masterminds/semver/v3" ) var ( @@ -29,7 +34,7 @@ var ( // // Increment major number for new feature additions and behavioral changes. // Increment minor number for bug fixes and performance enhancements. - version = "v3.14" + version = "v4.1" // metadata is extra build time data metadata = "" @@ -39,6 +44,10 @@ var ( gitTreeState = "" ) +const ( + kubeClientGoVersionTesting = "v1.20" +) + // BuildInfo describes the compile time information. type BuildInfo struct { // Version is the current semver. @@ -49,6 +58,8 @@ type BuildInfo struct { GitTreeState string `json:"git_tree_state,omitempty"` // GoVersion is the version of the Go compiler used. GoVersion string `json:"go_version,omitempty"` + // KubeClientVersion is the version of client-go Helm was build with + KubeClientVersion string `json:"kube_client_version"` } // GetVersion returns the semver string of the version @@ -66,11 +77,39 @@ func GetUserAgent() string { // Get returns build info func Get() BuildInfo { + + makeKubeClientVersionString := func() string { + // Test builds don't include debug info / module info + // (And even if they did, we probably want a stable version during tests anyway) + // Return a default value for test builds + if testing.Testing() { + return kubeClientGoVersionTesting + } + + vstr, err := K8sIOClientGoModVersion() + if err != nil { + slog.Error("failed to retrieve k8s.io/client-go version", slog.Any("error", err)) + return "" + } + + v, err := semver.NewVersion(vstr) + if err != nil { + slog.Error("unable to parse k8s.io/client-go version", slog.String("version", vstr), slog.Any("error", err)) + return "" + } + + kubeClientVersionMajor := v.Major() + 1 + kubeClientVersionMinor := v.Minor() + + return fmt.Sprintf("v%d.%d", kubeClientVersionMajor, kubeClientVersionMinor) + } + v := BuildInfo{ - Version: GetVersion(), - GitCommit: gitCommit, - GitTreeState: gitTreeState, - GoVersion: runtime.Version(), + Version: GetVersion(), + GitCommit: gitCommit, + GitTreeState: gitTreeState, + GoVersion: runtime.Version(), + KubeClientVersion: makeKubeClientVersionString(), } // HACK(bacongobbler): strip out GoVersion during a test run for consistent test output diff --git a/pkg/helm/internal/fileutil/fileutil_test.go b/pkg/helm/internal/fileutil/fileutil_test.go deleted file mode 100644 index 92920d3c..00000000 --- a/pkg/helm/internal/fileutil/fileutil_test.go +++ /dev/null @@ -1,57 +0,0 @@ -/* -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 fileutil - -import ( - "bytes" - "os" - "path/filepath" - "testing" -) - -func TestAtomicWriteFile(t *testing.T) { - dir := t.TempDir() - - testpath := filepath.Join(dir, "test") - stringContent := "Test content" - reader := bytes.NewReader([]byte(stringContent)) - mode := os.FileMode(0644) - - err := AtomicWriteFile(testpath, reader, mode) - if err != nil { - t.Errorf("AtomicWriteFile error: %s", err) - } - - got, err := os.ReadFile(testpath) - if err != nil { - t.Fatal(err) - } - - if stringContent != string(got) { - t.Fatalf("expected: %s, got: %s", stringContent, string(got)) - } - - gotinfo, err := os.Stat(testpath) - if err != nil { - t.Fatal(err) - } - - if mode != gotinfo.Mode() { - t.Fatalf("expected %s: to be the same mode as %s", - mode, gotinfo.Mode()) - } -} diff --git a/pkg/helm/internal/tlsutil/cfg.go b/pkg/helm/internal/tlsutil/cfg.go deleted file mode 100644 index 8b9d4329..00000000 --- a/pkg/helm/internal/tlsutil/cfg.go +++ /dev/null @@ -1,58 +0,0 @@ -/* -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 tlsutil - -import ( - "crypto/tls" - "crypto/x509" - "os" - - "github.com/pkg/errors" -) - -// Options represents configurable options used to create client and server TLS configurations. -type Options struct { - CaCertFile string - // If either the KeyFile or CertFile is empty, ClientConfig() will not load them. - KeyFile string - CertFile string - // Client-only options - InsecureSkipVerify bool -} - -// ClientConfig returns a TLS configuration for use by a Helm client. -func ClientConfig(opts Options) (cfg *tls.Config, err error) { - var cert *tls.Certificate - var pool *x509.CertPool - - if opts.CertFile != "" || opts.KeyFile != "" { - if cert, err = CertFromFilePair(opts.CertFile, opts.KeyFile); err != nil { - if os.IsNotExist(err) { - return nil, errors.Wrapf(err, "could not load x509 key pair (cert: %q, key: %q)", opts.CertFile, opts.KeyFile) - } - return nil, errors.Wrapf(err, "could not read x509 key pair (cert: %q, key: %q)", opts.CertFile, opts.KeyFile) - } - } - if !opts.InsecureSkipVerify && opts.CaCertFile != "" { - if pool, err = CertPoolFromFile(opts.CaCertFile); err != nil { - return nil, err - } - } - - cfg = &tls.Config{InsecureSkipVerify: opts.InsecureSkipVerify, Certificates: []tls.Certificate{*cert}, RootCAs: pool} - return cfg, nil -} diff --git a/pkg/helm/internal/tlsutil/tls.go b/pkg/helm/internal/tlsutil/tls.go deleted file mode 100644 index dc832ed8..00000000 --- a/pkg/helm/internal/tlsutil/tls.go +++ /dev/null @@ -1,78 +0,0 @@ -/* -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 tlsutil - -import ( - "crypto/tls" - "crypto/x509" - "os" - - "github.com/pkg/errors" -) - -// NewClientTLS returns tls.Config appropriate for client auth. -func NewClientTLS(certFile, keyFile, caFile string, insecureSkipTLSverify bool) (*tls.Config, error) { - config := tls.Config{ - InsecureSkipVerify: insecureSkipTLSverify, - } - - if certFile != "" && keyFile != "" { - cert, err := CertFromFilePair(certFile, keyFile) - if err != nil { - return nil, err - } - config.Certificates = []tls.Certificate{*cert} - } - - if caFile != "" { - cp, err := CertPoolFromFile(caFile) - if err != nil { - return nil, err - } - config.RootCAs = cp - } - - return &config, nil -} - -// CertPoolFromFile returns an x509.CertPool containing the certificates -// in the given PEM-encoded file. -// Returns an error if the file could not be read, a certificate could not -// be parsed, or if the file does not contain any certificates -func CertPoolFromFile(filename string) (*x509.CertPool, error) { - b, err := os.ReadFile(filename) - if err != nil { - return nil, errors.Errorf("can't read CA file: %v", filename) - } - cp := x509.NewCertPool() - if !cp.AppendCertsFromPEM(b) { - return nil, errors.Errorf("failed to append certificates from file: %s", filename) - } - return cp, nil -} - -// CertFromFilePair returns an tls.Certificate containing the -// certificates public/private key pair from a pair of given PEM-encoded files. -// Returns an error if the file could not be read, a certificate could not -// be parsed, or if the file does not contain any certificates -func CertFromFilePair(certFile, keyFile string) (*tls.Certificate, error) { - cert, err := tls.LoadX509KeyPair(certFile, keyFile) - if err != nil { - return nil, errors.Wrapf(err, "can't load key pair from cert %s and key %s", certFile, keyFile) - } - return &cert, err -} diff --git a/pkg/helm/internal/tlsutil/tlsutil_test.go b/pkg/helm/internal/tlsutil/tlsutil_test.go deleted file mode 100644 index e31a873d..00000000 --- a/pkg/helm/internal/tlsutil/tlsutil_test.go +++ /dev/null @@ -1,114 +0,0 @@ -/* -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 tlsutil - -import ( - "path/filepath" - "testing" -) - -const tlsTestDir = "../../testdata" - -const ( - testCaCertFile = "rootca.crt" - testCertFile = "crt.pem" - testKeyFile = "key.pem" -) - -func TestClientConfig(t *testing.T) { - opts := Options{ - CaCertFile: testfile(t, testCaCertFile), - CertFile: testfile(t, testCertFile), - KeyFile: testfile(t, testKeyFile), - InsecureSkipVerify: false, - } - - cfg, err := ClientConfig(opts) - if err != nil { - t.Fatalf("error building tls client config: %v", err) - } - - if got := len(cfg.Certificates); got != 1 { - t.Fatalf("expecting 1 client certificates, got %d", got) - } - if cfg.InsecureSkipVerify { - t.Fatalf("insecure skip verify mismatch, expecting false") - } - if cfg.RootCAs == nil { - t.Fatalf("mismatch tls RootCAs, expecting non-nil") - } -} - -func testfile(t *testing.T, file string) (path string) { - var err error - if path, err = filepath.Abs(filepath.Join(tlsTestDir, file)); err != nil { - t.Fatalf("error getting absolute path to test file %q: %v", file, err) - } - return path -} - -func TestNewClientTLS(t *testing.T) { - certFile := testfile(t, testCertFile) - keyFile := testfile(t, testKeyFile) - caCertFile := testfile(t, testCaCertFile) - insecureSkipTLSverify := false - - cfg, err := NewClientTLS(certFile, keyFile, caCertFile, insecureSkipTLSverify) - if err != nil { - t.Error(err) - } - - if got := len(cfg.Certificates); got != 1 { - t.Fatalf("expecting 1 client certificates, got %d", got) - } - if cfg.InsecureSkipVerify { - t.Fatalf("insecure skip verify mismatch, expecting false") - } - if cfg.RootCAs == nil { - t.Fatalf("mismatch tls RootCAs, expecting non-nil") - } - - cfg, err = NewClientTLS("", "", caCertFile, insecureSkipTLSverify) - if err != nil { - t.Error(err) - } - - if got := len(cfg.Certificates); got != 0 { - t.Fatalf("expecting 0 client certificates, got %d", got) - } - if cfg.InsecureSkipVerify { - t.Fatalf("insecure skip verify mismatch, expecting false") - } - if cfg.RootCAs == nil { - t.Fatalf("mismatch tls RootCAs, expecting non-nil") - } - - cfg, err = NewClientTLS(certFile, keyFile, "", insecureSkipTLSverify) - if err != nil { - t.Error(err) - } - - if got := len(cfg.Certificates); got != 1 { - t.Fatalf("expecting 1 client certificates, got %d", got) - } - if cfg.InsecureSkipVerify { - t.Fatalf("insecure skip verify mismatch, expecting false") - } - if cfg.RootCAs != nil { - t.Fatalf("mismatch tls RootCAs, expecting nil") - } -} diff --git a/pkg/helm/pkg/action/action.go b/pkg/helm/pkg/action/action.go index 91d0efae..ad835b8b 100644 --- a/pkg/helm/pkg/action/action.go +++ b/pkg/helm/pkg/action/action.go @@ -18,32 +18,42 @@ package action import ( "bytes" + "context" + "errors" "fmt" + "io" + "log/slog" + "maps" "os" "path" "path/filepath" - "regexp" + "slices" "strings" + "sync" + "text/template" + "time" - "github.com/pkg/errors" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/client-go/discovery" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" + "sigs.k8s.io/kustomize/kyaml/kio" + kyaml "sigs.k8s.io/kustomize/kyaml/yaml" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chartutil" + "github.com/werf/nelm/pkg/helm/intern/logging" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + chartutil "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" "github.com/werf/nelm/pkg/helm/pkg/engine" "github.com/werf/nelm/pkg/helm/pkg/kube" - "github.com/werf/nelm/pkg/helm/pkg/postrender" + "github.com/werf/nelm/pkg/helm/pkg/postrenderer" "github.com/werf/nelm/pkg/helm/pkg/registry" - "github.com/werf/nelm/pkg/helm/pkg/release" - "github.com/werf/nelm/pkg/helm/pkg/releaseutil" + ri "github.com/werf/nelm/pkg/helm/pkg/release" + release "github.com/werf/nelm/pkg/helm/pkg/release/v1" + releaseutil "github.com/werf/nelm/pkg/helm/pkg/release/v1/util" "github.com/werf/nelm/pkg/helm/pkg/storage" "github.com/werf/nelm/pkg/helm/pkg/storage/driver" - "github.com/werf/nelm/pkg/helm/pkg/time" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" ) // Timestamper is a function capable of producing a timestamp.Timestamper. @@ -63,20 +73,20 @@ var ( errPending = errors.New("another operation (install/upgrade/rollback) is in progress") ) -// ValidName is a regular expression for resource names. -// -// DEPRECATED: This will be removed in Helm 4, and is no longer used here. See -// pkg/lint/rules.validateMetadataNameFunc for the replacement. -// -// According to the Kubernetes help text, the regular expression it uses is: -// -// [a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)* -// -// This follows the above regular expression (but requires a full string match, not partial). -// -// The Kubernetes documentation is here, though it is not entirely correct: -// https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names -var ValidName = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`) +type DryRunStrategy string + +const ( + // DryRunNone indicates the client will make all mutating calls + DryRunNone DryRunStrategy = "none" + + // DryRunClient, or client-side dry-run, indicates the client will avoid + // making calls to the server + DryRunClient DryRunStrategy = "client" + + // DryRunServer, or server-side dry-run, indicates the client will send + // calls to the APIServer with the dry-run parameter to prevent persisting changes + DryRunServer DryRunStrategy = "server" +) // Configuration injects the dependencies that all actions share. type Configuration struct { @@ -93,9 +103,155 @@ type Configuration struct { RegistryClient *registry.Client // Capabilities describes the capabilities of the Kubernetes cluster. - Capabilities *chartutil.Capabilities + Capabilities *common.Capabilities + + // CustomTemplateFuncs is defined by users to provide custom template funcs + CustomTemplateFuncs template.FuncMap + + // HookOutputFunc called with container name and returns and expects writer that will receive the log output. + HookOutputFunc func(namespace, pod, container string) io.Writer + + // Mutex is an exclusive lock for concurrent access to the action + mutex sync.Mutex + + // Embed a LogHolder to provide logger functionality + logging.LogHolder +} + +type ConfigurationOption func(c *Configuration) + +// Override the default logging handler +// If unspecified, the default logger will be used +func ConfigurationSetLogger(h slog.Handler) ConfigurationOption { + return func(c *Configuration) { + c.SetLogger(h) + } +} + +func NewConfiguration(options ...ConfigurationOption) *Configuration { + c := &Configuration{} + c.SetLogger(slog.Default().Handler()) + + for _, o := range options { + o(c) + } - Log func(string, ...interface{}) + return c +} + +const ( + // filenameAnnotation is the annotation key used to store the original filename + // information in manifest annotations for post-rendering reconstruction. + filenameAnnotation = "postrenderer.helm.sh/postrender-filename" +) + +// fixDocSeparators ensures YAML document separators ("---") are always +// followed by a newline in rendered template content. Go template whitespace +// trimming ({{-) can remove the newline after "---", producing e.g. +// "---apiVersion: v1" which is not a valid YAML document separator. +// This function inserts a newline after any "---" at the start of a line +// that is immediately followed by non-whitespace content. +func fixDocSeparators(content string) string { + var b strings.Builder + remaining := content + for { + // Find "---" at the start of a line (or start of content). + idx := strings.Index(remaining, "---") + if idx == -1 { + b.WriteString(remaining) + break + } + // "---" must be at the start of a line: either idx==0 or preceded by '\n'. + if idx > 0 && remaining[idx-1] != '\n' { + b.WriteString(remaining[:idx+3]) + remaining = remaining[idx+3:] + continue + } + b.WriteString(remaining[:idx+3]) + remaining = remaining[idx+3:] + // If "---" is followed by non-whitespace (e.g. "---apiVersion"), + // insert a newline to make it a proper document separator. + if len(remaining) > 0 && remaining[0] != '\n' && remaining[0] != '\r' && remaining[0] != ' ' && remaining[0] != '\t' { + b.WriteByte('\n') + } + } + return b.String() +} + +// annotateAndMerge combines multiple YAML files into a single stream of documents, +// adding filename annotations to each document for later reconstruction. +func annotateAndMerge(files map[string]string) (string, error) { + var combinedManifests []*kyaml.RNode + + // Get sorted filenames to ensure result is deterministic + fnames := slices.Sorted(maps.Keys(files)) + + for _, fname := range fnames { + content := files[fname] + // Skip partials and empty files. + if strings.HasPrefix(path.Base(fname), "_") || strings.TrimSpace(content) == "" { + continue + } + + // Fix document separators where Go template whitespace trimming + // ({{-) has removed the newline after "---", producing e.g. + // "---apiVersion: v1" which is not a valid YAML document + // separator. Insert the missing newline so kio.ParseAll can + // parse the content correctly. + content = fixDocSeparators(content) + + manifests, err := kio.ParseAll(content) + if err != nil { + return "", fmt.Errorf("parsing %s: %w", fname, err) + } + for _, manifest := range manifests { + if err := manifest.PipeE(kyaml.SetAnnotation(filenameAnnotation, fname)); err != nil { + return "", fmt.Errorf("annotating %s: %w", fname, err) + } + combinedManifests = append(combinedManifests, manifest) + } + } + + merged, err := kio.StringAll(combinedManifests) + if err != nil { + return "", fmt.Errorf("writing merged docs: %w", err) + } + return merged, nil +} + +// splitAndDeannotate reconstructs individual files from a merged YAML stream, +// removing filename annotations and grouping documents by their original filenames. +func splitAndDeannotate(postrendered string) (map[string]string, error) { + manifests, err := kio.ParseAll(postrendered) + if err != nil { + return nil, fmt.Errorf("error parsing YAML: %w", err) + } + + manifestsByFilename := make(map[string][]*kyaml.RNode) + for i, manifest := range manifests { + meta, err := manifest.GetMeta() + if err != nil { + return nil, fmt.Errorf("getting metadata: %w", err) + } + fname := meta.Annotations[filenameAnnotation] + if fname == "" { + fname = fmt.Sprintf("generated-by-postrender-%d.yaml", i) + } + if err := manifest.PipeE(kyaml.ClearAnnotation(filenameAnnotation)); err != nil { + return nil, fmt.Errorf("clearing filename annotation: %w", err) + } + manifestsByFilename[fname] = append(manifestsByFilename[fname], manifest) + } + + reconstructed := make(map[string]string, len(manifestsByFilename)) + for fname, docs := range manifestsByFilename { + fileContents, err := kio.StringAll(docs) + if err != nil { + return nil, fmt.Errorf("re-writing %s: %w", fname, err) + } + reconstructed[fname] = fileContents + } + return reconstructed, nil } // renderResources renders the templates in a chart @@ -104,8 +260,8 @@ type Configuration struct { // TODO: As part of the refactor the duplicate code in cmd/helm/template.go should be removed // // This code has to do with writing files to disk. -func (cfg *Configuration) renderResources(ch *chart.Chart, values chartutil.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrender.PostRenderer, interactWithRemote, enableDNS bool, opts helmopts.HelmOptions) ([]*release.Hook, *bytes.Buffer, string, error) { - hs := []*release.Hook{} +func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrenderer.PostRenderer, interactWithRemote, enableDNS, hideSecret bool) ([]*release.Hook, *bytes.Buffer, string, error) { + var hs []*release.Hook b := bytes.NewBuffer(nil) caps, err := cfg.getCapabilities() @@ -115,7 +271,7 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values chartutil.Valu if ch.Metadata.KubeVersion != "" { if !chartutil.IsCompatibleRange(ch.Metadata.KubeVersion, caps.KubeVersion.String()) { - return hs, b, "", errors.Errorf("chart requires kubeVersion: %s which is incompatible with Kubernetes %s", ch.Metadata.KubeVersion, caps.KubeVersion.String()) + return hs, b, "", fmt.Errorf("chart requires kubeVersion: %s which is incompatible with Kubernetes %s", ch.Metadata.KubeVersion, caps.KubeVersion.Version) } } @@ -132,11 +288,15 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values chartutil.Valu } e := engine.New(restConfig) e.EnableDNS = enableDNS - files, err2 = e.Render(ch, values, opts) + e.CustomTemplateFuncs = cfg.CustomTemplateFuncs + + files, err2 = e.Render(context.Background(), ch, values) } else { var e engine.Engine e.EnableDNS = enableDNS - files, err2 = e.Render(ch, values, opts) + e.CustomTemplateFuncs = cfg.CustomTemplateFuncs + + files, err2 = e.Render(context.Background(), ch, values) } if err2 != nil { @@ -163,10 +323,37 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values chartutil.Valu } notes := notesBuffer.String() + if pr != nil { + // We need to send files to the post-renderer before sorting and splitting + // hooks from manifests. The post-renderer interface expects a stream of + // manifests (similar to what tools like Kustomize and kubectl expect), whereas + // the sorter uses filenames. + // Here, we merge the documents into a stream, post-render them, and then split + // them back into a map of filename -> content. + + // Merge files as stream of documents for sending to post renderer + merged, err := annotateAndMerge(files) + if err != nil { + return hs, b, notes, fmt.Errorf("error merging manifests: %w", err) + } + + // Run the post renderer + postRendered, err := pr.Run(bytes.NewBufferString(merged)) + if err != nil { + return hs, b, notes, fmt.Errorf("error while running post render on files: %w", err) + } + + // Use the file list and contents received from the post renderer + files, err = splitAndDeannotate(postRendered.String()) + if err != nil { + return hs, b, notes, fmt.Errorf("error while parsing post rendered output: %w", err) + } + } + // Sort hooks, manifests, and partials. Only hooks and manifests are returned, // as partials are not used after renderer.Render. Empty manifests are also // removed here. - hs, manifests, err := releaseutil.SortManifests(files, caps.APIVersions, releaseutil.InstallOrder) + hs, manifests, err := releaseutil.SortManifests(files, nil, releaseutil.InstallOrder) if err != nil { // By catching parse errors here, we can prevent bogus releases from going // to Kubernetes. @@ -201,7 +388,11 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values chartutil.Valu for _, m := range manifests { if outputDir == "" { - fmt.Fprintf(b, "---\n# Source: %s\n%s\n", m.Name, m.Content) + if hideSecret && m.Head.Kind == "Secret" && m.Head.Version == "v1" { + fmt.Fprintf(b, "---\n# Source: %s\n# HIDDEN: The Secret output has been suppressed\n", m.Name) + } else { + fmt.Fprintf(b, "---\n# Source: %s\n%s\n", m.Name, m.Content) + } } else { newDir := outputDir if useReleaseName { @@ -219,13 +410,6 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values chartutil.Valu } } - if pr != nil { - b, err = pr.Run(b) - if err != nil { - return hs, b, notes, errors.Wrap(err, "error while running post render on files") - } - } - return hs, b, notes, nil } @@ -236,23 +420,20 @@ type RESTClientGetter interface { ToRESTMapper() (meta.RESTMapper, error) } -// DebugLog sets the logger that writes debug strings -type DebugLog func(format string, v ...interface{}) - // capabilities builds a Capabilities from discovery information. -func (cfg *Configuration) getCapabilities() (*chartutil.Capabilities, error) { +func (cfg *Configuration) getCapabilities() (*common.Capabilities, error) { if cfg.Capabilities != nil { return cfg.Capabilities, nil } dc, err := cfg.RESTClientGetter.ToDiscoveryClient() if err != nil { - return nil, errors.Wrap(err, "could not get Kubernetes discovery client") + return nil, fmt.Errorf("could not get Kubernetes discovery client: %w", err) } // force a discovery cache invalidation to always fetch the latest server version/capabilities. dc.Invalidate() kubeVersion, err := dc.ServerVersion() if err != nil { - return nil, errors.Wrap(err, "could not get server version from Kubernetes") + return nil, fmt.Errorf("could not get server version from Kubernetes: %w", err) } // Issue #6361: // Client-Go emits an error when an API service is registered but unimplemented. @@ -262,21 +443,21 @@ func (cfg *Configuration) getCapabilities() (*chartutil.Capabilities, error) { apiVersions, err := GetVersionSet(dc) if err != nil { if discovery.IsGroupDiscoveryFailedError(err) { - cfg.Log("WARNING: The Kubernetes server has an orphaned API service. Server reports: %s", err) - cfg.Log("WARNING: To fix this, kubectl delete apiservice ") + cfg.Logger().Warn("the kubernetes server has an orphaned API service", slog.Any("error", err)) + cfg.Logger().Warn("to fix this, kubectl delete apiservice ") } else { - return nil, errors.Wrap(err, "could not get apiVersions from Kubernetes") + return nil, fmt.Errorf("could not get apiVersions from Kubernetes: %w", err) } } - cfg.Capabilities = &chartutil.Capabilities{ + cfg.Capabilities = &common.Capabilities{ APIVersions: apiVersions, - KubeVersion: chartutil.KubeVersion{ + KubeVersion: common.KubeVersion{ Version: kubeVersion.GitVersion, Major: kubeVersion.Major, Minor: kubeVersion.Minor, }, - HelmVersion: chartutil.DefaultCapabilities.HelmVersion, + HelmVersion: common.DefaultCapabilities.HelmVersion, } return cfg.Capabilities, nil } @@ -285,7 +466,7 @@ func (cfg *Configuration) getCapabilities() (*chartutil.Capabilities, error) { func (cfg *Configuration) KubernetesClientSet() (kubernetes.Interface, error) { conf, err := cfg.RESTClientGetter.ToRESTConfig() if err != nil { - return nil, errors.Wrap(err, "unable to generate config for kubernetes client") + return nil, fmt.Errorf("unable to generate config for kubernetes client: %w", err) } return kubernetes.NewForConfig(conf) @@ -299,9 +480,9 @@ func (cfg *Configuration) Now() time.Time { return Timestamper() } -func (cfg *Configuration) releaseContent(name string, version int) (*release.Release, error) { +func (cfg *Configuration) releaseContent(name string, version int) (ri.Releaser, error) { if err := chartutil.ValidateReleaseName(name); err != nil { - return nil, errors.Errorf("releaseContent: Release name is invalid: %s", name) + return nil, fmt.Errorf("releaseContent: Release name is invalid: %s", name) } if version <= 0 { @@ -312,10 +493,10 @@ func (cfg *Configuration) releaseContent(name string, version int) (*release.Rel } // GetVersionSet retrieves a set of available k8s API versions -func GetVersionSet(client discovery.ServerResourcesInterface) (chartutil.VersionSet, error) { +func GetVersionSet(client discovery.ServerResourcesInterface) (common.VersionSet, error) { groups, resources, err := client.ServerGroupsAndResources() if err != nil && !discovery.IsGroupDiscoveryFailedError(err) { - return chartutil.DefaultVersionSet, errors.Wrap(err, "could not get apiVersions from Kubernetes") + return common.DefaultVersionSet, fmt.Errorf("could not get apiVersions from Kubernetes: %w", err) } // FIXME: The Kubernetes test fixture for cli appears to always return nil @@ -323,11 +504,11 @@ func GetVersionSet(client discovery.ServerResourcesInterface) (chartutil.Version // return the default API list. This is also a safe value to return in any // other odd-ball case. if len(groups) == 0 && len(resources) == 0 { - return chartutil.DefaultVersionSet, nil + return common.DefaultVersionSet, nil } versionMap := make(map[string]interface{}) - versions := []string{} + var versions []string // Extract the groups for _, g := range groups { @@ -356,20 +537,25 @@ func GetVersionSet(client discovery.ServerResourcesInterface) (chartutil.Version versions = append(versions, k) } - return chartutil.VersionSet(versions), nil + return common.VersionSet(versions), nil } // recordRelease with an update operation in case reuse has been set. func (cfg *Configuration) recordRelease(r *release.Release) { if err := cfg.Releases.Update(r); err != nil { - cfg.Log("warning: Failed to update release %s: %s", r.Name, err) + cfg.Logger().Warn( + "failed to update release", + slog.String("name", r.Name), + slog.Int("revision", r.Version), + slog.Any("error", err), + ) } } // Init initializes the action configuration -func (cfg *Configuration) Init(getter genericclioptions.RESTClientGetter, namespace, helmDriver string, log DebugLog) error { +func (cfg *Configuration) Init(getter genericclioptions.RESTClientGetter, namespace, helmDriver string) error { kc := kube.New(getter) - kc.Log = log + kc.SetLogger(cfg.Logger().Handler()) lazyClient := &lazyClient{ namespace: namespace, @@ -380,58 +566,68 @@ func (cfg *Configuration) Init(getter genericclioptions.RESTClientGetter, namesp switch helmDriver { case "secret", "secrets", "": d := driver.NewSecrets(newSecretClient(lazyClient)) - d.Log = log + d.SetLogger(cfg.Logger().Handler()) store = storage.Init(d) case "configmap", "configmaps": d := driver.NewConfigMaps(newConfigMapClient(lazyClient)) - d.Log = log + d.SetLogger(cfg.Logger().Handler()) store = storage.Init(d) case "memory": var d *driver.Memory if cfg.Releases != nil { if mem, ok := cfg.Releases.Driver.(*driver.Memory); ok { // This function can be called more than once (e.g., helm list --all-namespaces). - // If a memory driver was already initialized, re-use it but set the possibly new namespace. - // We re-use it in case some releases where already created in the existing memory driver. + // If a memory driver was already initialized, reuse it but set the possibly new namespace. + // We reuse it in case some releases where already created in the existing memory driver. d = mem } } if d == nil { d = driver.NewMemory() } + d.SetLogger(cfg.Logger().Handler()) d.SetNamespace(namespace) store = storage.Init(d) case "sql": d, err := driver.NewSQL( os.Getenv("HELM_DRIVER_SQL_CONNECTION_STRING"), - log, namespace, ) if err != nil { - panic(fmt.Sprintf("Unable to instantiate SQL driver: %v", err)) + return fmt.Errorf("unable to instantiate SQL driver: %w", err) } + d.SetLogger(cfg.Logger().Handler()) store = storage.Init(d) default: - // Not sure what to do here. - panic("Unknown driver in HELM_DRIVER: " + helmDriver) + return fmt.Errorf("unknown driver %q", helmDriver) } cfg.RESTClientGetter = getter cfg.KubeClient = kc cfg.Releases = store - cfg.Log = log + cfg.HookOutputFunc = func(_, _, _ string) io.Writer { return io.Discard } return nil } -func (cfg *Configuration) RenderResources(ch *chart.Chart, values chartutil.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrender.PostRenderer, interactWithRemote, enableDNS bool, opts helmopts.HelmOptions) ([]*release.Hook, *bytes.Buffer, string, error) { - return cfg.renderResources(ch, values, releaseName, outputDir, subNotes, useReleaseName, includeCrds, pr, interactWithRemote, enableDNS, opts) +// SetHookOutputFunc sets the HookOutputFunc on the Configuration. +func (cfg *Configuration) SetHookOutputFunc(hookOutputFunc func(_, _, _ string) io.Writer) { + cfg.HookOutputFunc = hookOutputFunc +} + +func determineReleaseSSApplyMethod(serverSideApply bool) release.ApplyMethod { + if serverSideApply { + return release.ApplyMethodServerSideApply + } + return release.ApplyMethodClientSideApply } -func (cfg *Configuration) GetCapabilities() (*chartutil.Capabilities, error) { - return cfg.getCapabilities() +// isDryRun returns true if the strategy is set to run as a DryRun +func isDryRun(strategy DryRunStrategy) bool { + return strategy == DryRunClient || strategy == DryRunServer } -func ErrMissingChart() error { - return errMissingChart +// interactWithServer determine whether or not to interact with a remote Kubernetes server +func interactWithServer(strategy DryRunStrategy) bool { + return strategy == DryRunNone || strategy == DryRunServer } diff --git a/pkg/helm/pkg/action/action_test.go b/pkg/helm/pkg/action/action_test.go index f6b288f1..499fac10 100644 --- a/pkg/helm/pkg/action/action_test.go +++ b/pkg/helm/pkg/action/action_test.go @@ -16,26 +16,46 @@ limitations under the License. package action import ( + "bytes" + "errors" "flag" + "fmt" "io" + "log/slog" + "strings" "testing" + "time" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" fakeclientset "k8s.io/client-go/kubernetes/fake" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chartutil" + "github.com/werf/nelm/pkg/helm/intern/logging" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/kube" kubefake "github.com/werf/nelm/pkg/helm/pkg/kube/fake" "github.com/werf/nelm/pkg/helm/pkg/registry" - "github.com/werf/nelm/pkg/helm/pkg/release" + rcommon "github.com/werf/nelm/pkg/helm/pkg/release/common" + release "github.com/werf/nelm/pkg/helm/pkg/release/v1" "github.com/werf/nelm/pkg/helm/pkg/storage" "github.com/werf/nelm/pkg/helm/pkg/storage/driver" - "github.com/werf/nelm/pkg/helm/pkg/time" ) -var verbose = flag.Bool("test.log", false, "enable test logging") +var verbose = flag.Bool("test.log", false, "enable test logging (debug by default)") func actionConfigFixture(t *testing.T) *Configuration { t.Helper() + return actionConfigFixtureWithDummyResources(t, nil) +} + +func actionConfigFixtureWithDummyResources(t *testing.T, dummyResources kube.ResourceList) *Configuration { + t.Helper() + + logger := logging.NewLogger(func() bool { + return *verbose + }) + slog.SetDefault(logger) registryClient, err := registry.NewClient() if err != nil { @@ -44,15 +64,9 @@ func actionConfigFixture(t *testing.T) *Configuration { return &Configuration{ Releases: storage.Init(driver.NewMemory()), - KubeClient: &kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}}, - Capabilities: chartutil.DefaultCapabilities, + KubeClient: &kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: dummyResources}, + Capabilities: common.DefaultCapabilities, RegistryClient: registryClient, - Log: func(format string, v ...interface{}) { - t.Helper() - if *verbose { - t.Logf(format, v...) - } - }, } } @@ -109,6 +123,15 @@ type chartOptions struct { type chartOption func(*chartOptions) func buildChart(opts ...chartOption) *chart.Chart { + modTime := time.Now() + defaultTemplates := []*common.File{ + {Name: "templates/hello", ModTime: modTime, Data: []byte("hello: world")}, + {Name: "templates/hooks", ModTime: modTime, Data: []byte(manifestWithHook)}, + } + return buildChartWithTemplates(defaultTemplates, opts...) +} + +func buildChartWithTemplates(templates []*common.File, opts ...chartOption) *chart.Chart { c := &chartOptions{ Chart: &chart.Chart{ // TODO: This should be more complete. @@ -117,18 +140,13 @@ func buildChart(opts ...chartOption) *chart.Chart { Name: "hello", Version: "0.1.0", }, - // This adds a basic template and hooks. - Templates: []*chart.File{ - {Name: "templates/hello", Data: []byte("hello: world")}, - {Name: "templates/hooks", Data: []byte(manifestWithHook)}, - }, + Templates: templates, }, } for _, opt := range opts { opt(c) } - return c.Chart } @@ -163,9 +181,10 @@ func withValues(values map[string]interface{}) chartOption { func withNotes(notes string) chartOption { return func(opts *chartOptions) { - opts.Templates = append(opts.Templates, &chart.File{ - Name: "templates/NOTES.txt", - Data: []byte(notes), + opts.Templates = append(opts.Templates, &common.File{ + Name: "templates/NOTES.txt", + ModTime: time.Now(), + Data: []byte(notes), }) } } @@ -182,28 +201,43 @@ func withMetadataDependency(dependency chart.Dependency) chartOption { } } +func withFile(file common.File) chartOption { + return func(opts *chartOptions) { + opts.Files = append(opts.Files, &file) + } +} + func withSampleTemplates() chartOption { return func(opts *chartOptions) { - sampleTemplates := []*chart.File{ + modTime := time.Now() + sampleTemplates := []*common.File{ // This adds basic templates and partials. - {Name: "templates/goodbye", Data: []byte("goodbye: world")}, - {Name: "templates/empty", Data: []byte("")}, - {Name: "templates/with-partials", Data: []byte(`hello: {{ template "_planet" . }}`)}, - {Name: "templates/partials/_planet", Data: []byte(`{{define "_planet"}}Earth{{end}}`)}, + {Name: "templates/goodbye", ModTime: modTime, Data: []byte("goodbye: world")}, + {Name: "templates/empty", ModTime: modTime, Data: []byte("")}, + {Name: "templates/with-partials", ModTime: modTime, Data: []byte(`hello: {{ template "_planet" . }}`)}, + {Name: "templates/partials/_planet", ModTime: modTime, Data: []byte(`{{define "_planet"}}Earth{{end}}`)}, } opts.Templates = append(opts.Templates, sampleTemplates...) } } +func withSampleSecret() chartOption { + return func(opts *chartOptions) { + sampleSecret := &common.File{Name: "templates/secret.yaml", ModTime: time.Now(), Data: []byte("apiVersion: v1\nkind: Secret\n")} + opts.Templates = append(opts.Templates, sampleSecret) + } +} + func withSampleIncludingIncorrectTemplates() chartOption { return func(opts *chartOptions) { - sampleTemplates := []*chart.File{ + modTime := time.Now() + sampleTemplates := []*common.File{ // This adds basic templates and partials. - {Name: "templates/goodbye", Data: []byte("goodbye: world")}, - {Name: "templates/empty", Data: []byte("")}, - {Name: "templates/incorrect", Data: []byte("{{ .Values.bad.doh }}")}, - {Name: "templates/with-partials", Data: []byte(`hello: {{ template "_planet" . }}`)}, - {Name: "templates/partials/_planet", Data: []byte(`{{define "_planet"}}Earth{{end}}`)}, + {Name: "templates/goodbye", ModTime: modTime, Data: []byte("goodbye: world")}, + {Name: "templates/empty", ModTime: modTime, Data: []byte("")}, + {Name: "templates/incorrect", ModTime: modTime, Data: []byte("{{ .Values.bad.doh }}")}, + {Name: "templates/with-partials", ModTime: modTime, Data: []byte(`hello: {{ template "_planet" . }}`)}, + {Name: "templates/partials/_planet", ModTime: modTime, Data: []byte(`{{define "_planet"}}Earth{{end}}`)}, } opts.Templates = append(opts.Templates, sampleTemplates...) } @@ -211,8 +245,8 @@ func withSampleIncludingIncorrectTemplates() chartOption { func withMultipleManifestTemplate() chartOption { return func(opts *chartOptions) { - sampleTemplates := []*chart.File{ - {Name: "templates/rbac", Data: []byte(rbacManifests)}, + sampleTemplates := []*common.File{ + {Name: "templates/rbac", ModTime: time.Now(), Data: []byte(rbacManifests)}, } opts.Templates = append(opts.Templates, sampleTemplates...) } @@ -226,10 +260,10 @@ func withKube(version string) chartOption { // releaseStub creates a release stub, complete with the chartStub as its chart. func releaseStub() *release.Release { - return namedReleaseStub("angry-panda", release.StatusDeployed) + return namedReleaseStub("angry-panda", rcommon.StatusDeployed) } -func namedReleaseStub(name string, status release.Status) *release.Release { +func namedReleaseStub(name string, status rcommon.Status) *release.Release { now := time.Now() return &release.Release{ Name: name, @@ -266,8 +300,76 @@ func namedReleaseStub(name string, status release.Status) *release.Release { } } +func TestConfiguration_Init(t *testing.T) { + tests := []struct { + name string + helmDriver string + expectedDriverType interface{} + expectErr bool + errMsg string + }{ + { + name: "Test secret driver", + helmDriver: "secret", + expectedDriverType: &driver.Secrets{}, + }, + { + name: "Test secrets driver", + helmDriver: "secrets", + expectedDriverType: &driver.Secrets{}, + }, + { + name: "Test empty driver", + helmDriver: "", + expectedDriverType: &driver.Secrets{}, + }, + { + name: "Test configmap driver", + helmDriver: "configmap", + expectedDriverType: &driver.ConfigMaps{}, + }, + { + name: "Test configmaps driver", + helmDriver: "configmaps", + expectedDriverType: &driver.ConfigMaps{}, + }, + { + name: "Test memory driver", + helmDriver: "memory", + expectedDriverType: &driver.Memory{}, + }, + { + name: "Test sql driver", + helmDriver: "sql", + expectErr: true, + errMsg: "unable to instantiate SQL driver", + }, + { + name: "Test unknown driver", + helmDriver: "someDriver", + expectErr: true, + errMsg: fmt.Sprintf("unknown driver %q", "someDriver"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := NewConfiguration() + + actualErr := cfg.Init(nil, "default", tt.helmDriver) + if tt.expectErr { + assert.Error(t, actualErr) + assert.Contains(t, actualErr.Error(), tt.errMsg) + } else { + assert.NoError(t, actualErr) + assert.IsType(t, tt.expectedDriverType, cfg.Releases.Driver) + } + }) + } +} + func TestGetVersionSet(t *testing.T) { - client := fakeclientset.NewSimpleClientset() + client := fakeclientset.NewClientset() vs, err := GetVersionSet(client.Discovery()) if err != nil { @@ -281,3 +383,743 @@ func TestGetVersionSet(t *testing.T) { t.Error("Non-existent version is reported found.") } } + +// Mock PostRenderer for testing +type mockPostRenderer struct { + shouldError bool + transform func(string) string +} + +func (m *mockPostRenderer) Run(renderedManifests *bytes.Buffer) (*bytes.Buffer, error) { + if m.shouldError { + return nil, errors.New("mock post-renderer error") + } + + content := renderedManifests.String() + if m.transform != nil { + content = m.transform(content) + } + + return bytes.NewBufferString(content), nil +} + +func TestFixDocSeparators(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "no separator", + input: "apiVersion: v1\nkind: Service\n", + expected: "apiVersion: v1\nkind: Service\n", + }, + { + name: "separator on its own line", + input: "---\napiVersion: v1\nkind: Service\n", + expected: "---\napiVersion: v1\nkind: Service\n", + }, + { + name: "leading separator glued to content", + input: "---apiVersion: v1\nkind: Service\n", + expected: "---\napiVersion: v1\nkind: Service\n", + }, + { + name: "mid-content separator glued to content", + input: "apiVersion: v1\nkind: ConfigMap\n---apiVersion: v1\nkind: Service\n", + expected: "apiVersion: v1\nkind: ConfigMap\n---\napiVersion: v1\nkind: Service\n", + }, + { + name: "multiple separators all proper", + input: "---\napiVersion: v1\n---\napiVersion: v1\n", + expected: "---\napiVersion: v1\n---\napiVersion: v1\n", + }, + { + name: "multiple separators some glued", + input: "---apiVersion: v1\nkind: ConfigMap\n---apiVersion: v1\nkind: Service\n", + expected: "---\napiVersion: v1\nkind: ConfigMap\n---\napiVersion: v1\nkind: Service\n", + }, + { + name: "empty string", + input: "", + expected: "", + }, + { + name: "only separator", + input: "---\n", + expected: "---\n", + }, + { + name: "triple dash in a value is not a separator", + input: "data:\n key: ---value\n", + expected: "data:\n key: ---value\n", + }, + { + name: "realistic multi-doc template output", + input: "apiVersion: v1\nkind: Deployment\n---\napiVersion: v1\nkind: Ingress\n---apiVersion: v1\nkind: Service\n", + expected: "apiVersion: v1\nkind: Deployment\n---\napiVersion: v1\nkind: Ingress\n---\napiVersion: v1\nkind: Service\n", + }, + { + name: "separator followed by carriage return", + input: "---\r\napiVersion: v1\n", + expected: "---\r\napiVersion: v1\n", + }, + { + name: "separator followed by space", + input: "--- \napiVersion: v1\n", + expected: "--- \napiVersion: v1\n", + }, + { + name: "separator followed by tab", + input: "---\t\napiVersion: v1\n", + expected: "---\t\napiVersion: v1\n", + }, + { + name: "four dashes on its own line", + input: "----\napiVersion: v1\n", + expected: "---\n-\napiVersion: v1\n", + }, + { + name: "four dashes followed by text", + input: "----more\napiVersion: v1\n", + expected: "---\n-more\napiVersion: v1\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, fixDocSeparators(tt.input)) + }) + } +} + +func TestAnnotateAndMerge(t *testing.T) { + tests := []struct { + name string + files map[string]string + expectedError string + expected string + }{ + { + name: "no files", + files: map[string]string{}, + expected: "", + }, + { + name: "single file with single manifest", + files: map[string]string{ + "templates/configmap.yaml": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm +data: + key: value`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/configmap.yaml' +data: + key: value +`, + }, + { + name: "multiple files with multiple manifests", + files: map[string]string{ + "templates/configmap.yaml": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm +data: + key: value`, + "templates/secret.yaml": `apiVersion: v1 +kind: Secret +metadata: + name: test-secret +data: + password: dGVzdA==`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/configmap.yaml' +data: + key: value +--- +apiVersion: v1 +kind: Secret +metadata: + name: test-secret + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/secret.yaml' +data: + password: dGVzdA== +`, + }, + { + name: "file with multiple manifests", + files: map[string]string{ + "templates/multi.yaml": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm1 +data: + key: value1 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm2 +data: + key: value2`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm1 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/multi.yaml' +data: + key: value1 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm2 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/multi.yaml' +data: + key: value2 +`, + }, + { + name: "partials and empty files are removed", + files: map[string]string{ + "templates/cm.yaml": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm1 +`, + "templates/_partial.tpl": ` +{{-define name}} + {{- "abracadabra"}} +{{- end -}}`, + "templates/empty.yaml": ``, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm1 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +`, + }, + { + name: "empty file", + files: map[string]string{ + "templates/empty.yaml": "", + }, + expected: ``, + }, + { + name: "invalid yaml", + files: map[string]string{ + "templates/invalid.yaml": `invalid: yaml: content: + - malformed`, + }, + expectedError: "parsing templates/invalid.yaml", + }, + { + name: "leading doc separator glued to content by template whitespace trimming", + files: map[string]string{ + "templates/service.yaml": "---apiVersion: v1\nkind: Service\nmetadata:\n name: test-svc\n", + }, + expected: `apiVersion: v1 +kind: Service +metadata: + name: test-svc + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/service.yaml' +`, + }, + { + name: "leading doc separator on its own line", + files: map[string]string{ + "templates/service.yaml": "---\napiVersion: v1\nkind: Service\nmetadata:\n name: test-svc\n", + }, + expected: `apiVersion: v1 +kind: Service +metadata: + name: test-svc + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/service.yaml' +`, + }, + { + name: "multiple leading doc separators", + files: map[string]string{ + "templates/service.yaml": "---\n---\napiVersion: v1\nkind: Service\nmetadata:\n name: test-svc\n", + }, + expected: `apiVersion: v1 +kind: Service +metadata: + name: test-svc + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/service.yaml' +`, + }, + { + name: "mid-content doc separator glued to content by template whitespace trimming", + files: map[string]string{ + "templates/all.yaml": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: test-cm\n---apiVersion: v1\nkind: Service\nmetadata:\n name: test-svc\n", + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/all.yaml' +--- +apiVersion: v1 +kind: Service +metadata: + name: test-svc + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/all.yaml' +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + merged, err := annotateAndMerge(tt.files) + + if tt.expectedError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + } else { + assert.NoError(t, err) + assert.NotNil(t, merged) + assert.Equal(t, tt.expected, merged) + } + }) + } +} + +func TestSplitAndDeannotate(t *testing.T) { + tests := []struct { + name string + input string + expectedFiles map[string]string + expectedError string + }{ + { + name: "single annotated manifest", + input: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm + annotations: + postrenderer.helm.sh/postrender-filename: templates/configmap.yaml +data: + key: value`, + expectedFiles: map[string]string{ + "templates/configmap.yaml": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm +data: + key: value +`, + }, + }, + { + name: "multiple manifests with different filenames", + input: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm + annotations: + postrenderer.helm.sh/postrender-filename: templates/configmap.yaml +data: + key: value +--- +apiVersion: v1 +kind: Secret +metadata: + name: test-secret + annotations: + postrenderer.helm.sh/postrender-filename: templates/secret.yaml +data: + password: dGVzdA==`, + expectedFiles: map[string]string{ + "templates/configmap.yaml": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm +data: + key: value +`, + "templates/secret.yaml": `apiVersion: v1 +kind: Secret +metadata: + name: test-secret +data: + password: dGVzdA== +`, + }, + }, + { + name: "multiple manifests with same filename", + input: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm1 + annotations: + postrenderer.helm.sh/postrender-filename: templates/multi.yaml +data: + key: value1 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm2 + annotations: + postrenderer.helm.sh/postrender-filename: templates/multi.yaml +data: + key: value2`, + expectedFiles: map[string]string{ + "templates/multi.yaml": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm1 +data: + key: value1 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm2 +data: + key: value2 +`, + }, + }, + { + name: "manifest with other annotations", + input: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm + annotations: + postrenderer.helm.sh/postrender-filename: templates/configmap.yaml + other-annotation: should-remain +data: + key: value`, + expectedFiles: map[string]string{ + "templates/configmap.yaml": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm + annotations: + other-annotation: should-remain +data: + key: value +`, + }, + }, + { + name: "invalid yaml input", + input: "invalid: yaml: content:", + expectedError: "error parsing YAML: MalformedYAMLError", + }, + { + name: "manifest without filename annotation", + input: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm +data: + key: value`, + expectedFiles: map[string]string{ + "generated-by-postrender-0.yaml": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm +data: + key: value +`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + files, err := splitAndDeannotate(tt.input) + + if tt.expectedError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + } else { + assert.NoError(t, err) + assert.Equal(t, len(tt.expectedFiles), len(files)) + + for expectedFile, expectedContent := range tt.expectedFiles { + actualContent, exists := files[expectedFile] + assert.True(t, exists, "Expected file %s not found", expectedFile) + assert.Equal(t, expectedContent, actualContent) + } + } + }) + } +} + +func TestAnnotateAndMerge_SplitAndDeannotate_Roundtrip(t *testing.T) { + // Test that merge/split operations are symmetric + originalFiles := map[string]string{ + "templates/configmap.yaml": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm +data: + key: value`, + "templates/secret.yaml": `apiVersion: v1 +kind: Secret +metadata: + name: test-secret +data: + password: dGVzdA==`, + "templates/multi.yaml": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm1 +data: + key: value1 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm2 +data: + key: value2`, + } + + // Merge and annotate + merged, err := annotateAndMerge(originalFiles) + require.NoError(t, err) + + // Split and deannotate + reconstructed, err := splitAndDeannotate(merged) + require.NoError(t, err) + + // Compare the results + assert.Equal(t, len(originalFiles), len(reconstructed)) + for filename, originalContent := range originalFiles { + reconstructedContent, exists := reconstructed[filename] + assert.True(t, exists, "File %s should exist in reconstructed files", filename) + + // Normalize whitespace for comparison since YAML processing might affect formatting + normalizeContent := func(content string) string { + return strings.TrimSpace(strings.ReplaceAll(content, "\r\n", "\n")) + } + + assert.Equal(t, normalizeContent(originalContent), normalizeContent(reconstructedContent)) + } +} + +func TestRenderResources_PostRenderer_Success(t *testing.T) { + cfg := actionConfigFixture(t) + + // Create a simple mock post-renderer + mockPR := &mockPostRenderer{ + transform: func(content string) string { + content = strings.ReplaceAll(content, "hello", "yellow") + content = strings.ReplaceAll(content, "goodbye", "foodpie") + return strings.ReplaceAll(content, "test-cm", "test-cm-postrendered") + }, + } + + ch := buildChart(withSampleTemplates()) + values := map[string]interface{}{} + + hooks, buf, notes, err := cfg.renderResources( + ch, values, "test-release", "", false, false, false, + mockPR, false, false, false, + ) + + assert.NoError(t, err) + assert.NotNil(t, hooks) + assert.NotNil(t, buf) + assert.Equal(t, "", notes) + expectedBuf := `--- +# Source: yellow/templates/foodpie +foodpie: world +--- +# Source: yellow/templates/with-partials +yellow: Earth +--- +# Source: yellow/templates/yellow +yellow: world +` + expectedHook := `kind: ConfigMap +metadata: + name: test-cm-postrendered + annotations: + "helm.sh/hook": post-install,pre-delete,post-upgrade +data: + name: value` + + assert.Equal(t, expectedBuf, buf.String()) + assert.Len(t, hooks, 1) + assert.Equal(t, expectedHook, hooks[0].Manifest) +} + +func TestRenderResources_PostRenderer_Error(t *testing.T) { + cfg := actionConfigFixture(t) + + // Create a post-renderer that returns an error + mockPR := &mockPostRenderer{ + shouldError: true, + } + + ch := buildChart(withSampleTemplates()) + values := map[string]interface{}{} + + _, _, _, err := cfg.renderResources( + ch, values, "test-release", "", false, false, false, + mockPR, false, false, false, + ) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "error while running post render on files") +} + +func TestRenderResources_PostRenderer_MergeError(t *testing.T) { + cfg := actionConfigFixture(t) + + // Create a mock post-renderer + mockPR := &mockPostRenderer{} + + // Create a chart with invalid YAML that would cause AnnotateAndMerge to fail + ch := &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: "v1", + Name: "test-chart", + Version: "0.1.0", + }, + Templates: []*common.File{ + {Name: "templates/invalid", ModTime: time.Now(), Data: []byte("invalid: yaml: content:")}, + }, + } + values := map[string]interface{}{} + + _, _, _, err := cfg.renderResources( + ch, values, "test-release", "", false, false, false, + mockPR, false, false, false, + ) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "error merging manifests") +} + +func TestRenderResources_PostRenderer_SplitError(t *testing.T) { + cfg := actionConfigFixture(t) + + // Create a post-renderer that returns invalid YAML + mockPR := &mockPostRenderer{ + transform: func(_ string) string { + return "invalid: yaml: content:" + }, + } + + ch := buildChart(withSampleTemplates()) + values := map[string]interface{}{} + + _, _, _, err := cfg.renderResources( + ch, values, "test-release", "", false, false, false, + mockPR, false, false, false, + ) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "error while parsing post rendered output: error parsing YAML: MalformedYAMLError:") +} + +func TestRenderResources_PostRenderer_Integration(t *testing.T) { + cfg := actionConfigFixture(t) + + mockPR := &mockPostRenderer{ + transform: func(content string) string { + return strings.ReplaceAll(content, "metadata:", "color: blue\nmetadata:") + }, + } + + ch := buildChart(withSampleTemplates()) + values := map[string]interface{}{} + + hooks, buf, notes, err := cfg.renderResources( + ch, values, "test-release", "", false, false, false, + mockPR, false, false, false, + ) + + assert.NoError(t, err) + assert.NotNil(t, hooks) + assert.NotNil(t, buf) + assert.Equal(t, "", notes) // Notes should be empty for this test + + // Verify that the post-renderer modifications are present in the output + output := buf.String() + expected := `--- +# Source: hello/templates/goodbye +goodbye: world +color: blue +--- +# Source: hello/templates/hello +hello: world +color: blue +--- +# Source: hello/templates/with-partials +hello: Earth +color: blue +` + assert.Contains(t, output, "color: blue") + assert.Equal(t, 3, strings.Count(output, "color: blue")) + assert.Equal(t, expected, output) +} + +func TestRenderResources_NoPostRenderer(t *testing.T) { + cfg := actionConfigFixture(t) + + ch := buildChart(withSampleTemplates()) + values := map[string]interface{}{} + + hooks, buf, notes, err := cfg.renderResources( + ch, values, "test-release", "", false, false, false, + nil, false, false, false, + ) + + assert.NoError(t, err) + assert.NotNil(t, hooks) + assert.NotNil(t, buf) + assert.Equal(t, "", notes) +} + +func TestDetermineReleaseSSAApplyMethod(t *testing.T) { + assert.Equal(t, release.ApplyMethodClientSideApply, determineReleaseSSApplyMethod(false)) + assert.Equal(t, release.ApplyMethodServerSideApply, determineReleaseSSApplyMethod(true)) +} + +func TestIsDryRun(t *testing.T) { + assert.False(t, isDryRun(DryRunNone)) + assert.True(t, isDryRun(DryRunClient)) + assert.True(t, isDryRun(DryRunServer)) +} + +func TestInteractWithServer(t *testing.T) { + assert.True(t, interactWithServer(DryRunNone)) + assert.False(t, interactWithServer(DryRunClient)) + assert.True(t, interactWithServer(DryRunServer)) +} diff --git a/pkg/helm/pkg/action/dependency.go b/pkg/helm/pkg/action/dependency.go index 66d244b5..a2c786f8 100644 --- a/pkg/helm/pkg/action/dependency.go +++ b/pkg/helm/pkg/action/dependency.go @@ -17,6 +17,7 @@ limitations under the License. package action import ( + "context" "fmt" "io" "os" @@ -26,19 +27,25 @@ import ( "github.com/Masterminds/semver/v3" "github.com/gosuri/uitable" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chart/loader" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/loader" ) // Dependency is the action for building a given chart's dependency tree. // // It provides the implementation of 'helm dependency' and its respective subcommands. type Dependency struct { - Verify bool - Keyring string - SkipRefresh bool - ColumnWidth uint + Verify bool + Keyring string + SkipRefresh bool + ColumnWidth uint + Username string + Password string + CertFile string + KeyFile string + CaFile string + InsecureSkipTLSVerify bool + PlainHTTP bool } // NewDependency creates a new Dependency object with the given configuration. @@ -49,8 +56,8 @@ func NewDependency() *Dependency { } // List executes 'helm dependency list'. -func (d *Dependency) List(chartpath string, out io.Writer, opts helmopts.HelmOptions) error { - c, err := loader.Load(chartpath, opts) +func (d *Dependency) List(chartpath string, out io.Writer) error { + c, err := loader.Load(context.Background(), chartpath) if err != nil { return err } @@ -60,14 +67,14 @@ func (d *Dependency) List(chartpath string, out io.Writer, opts helmopts.HelmOpt return nil } - d.printDependencies(chartpath, out, c, opts) + d.printDependencies(chartpath, out, c) fmt.Fprintln(out) - d.printMissing(chartpath, out, c.Metadata.Dependencies, opts) + d.printMissing(chartpath, out, c.Metadata.Dependencies) return nil } // dependencyStatus returns a string describing the status of a dependency viz a viz the parent chart. -func (d *Dependency) dependencyStatus(chartpath string, dep *chart.Dependency, parent *chart.Chart, opts helmopts.HelmOptions) string { +func (d *Dependency) dependencyStatus(chartpath string, dep *chart.Dependency, parent *chart.Chart) string { filename := fmt.Sprintf("%s-%s.tgz", dep.Name, "*") // If a chart is unpacked, this will check the unpacked chart's `charts/` directory for tarballs. @@ -97,7 +104,7 @@ func (d *Dependency) dependencyStatus(chartpath string, dep *chart.Dependency, p if l := len(found); l == 1 { // If we get here, we do the same thing as in len(archives) == 1. - if r := statArchiveForStatus(found[0], dep, opts); r != "" { + if r := statArchiveForStatus(found[0], dep); r != "" { return r } @@ -111,7 +118,7 @@ func (d *Dependency) dependencyStatus(chartpath string, dep *chart.Dependency, p case len(archives) == 1: archive := archives[0] - if r := statArchiveForStatus(archive, dep, opts); r != "" { + if r := statArchiveForStatus(archive, dep); r != "" { return r } @@ -152,9 +159,9 @@ func (d *Dependency) dependencyStatus(chartpath string, dep *chart.Dependency, p // // This is a refactor of the code originally in dependencyStatus. It is here to // support legacy behavior, and should be removed in Helm 4. -func statArchiveForStatus(archive string, dep *chart.Dependency, opts helmopts.HelmOptions) string { +func statArchiveForStatus(archive string, dep *chart.Dependency) string { if _, err := os.Stat(archive); err == nil { - c, err := loader.Load(archive, opts) + c, err := loader.Load(context.Background(), archive) if err != nil { return "corrupt" } @@ -183,19 +190,19 @@ func statArchiveForStatus(archive string, dep *chart.Dependency, opts helmopts.H } // printDependencies prints all of the dependencies in the yaml file. -func (d *Dependency) printDependencies(chartpath string, out io.Writer, c *chart.Chart, opts helmopts.HelmOptions) { +func (d *Dependency) printDependencies(chartpath string, out io.Writer, c *chart.Chart) { table := uitable.New() table.MaxColWidth = d.ColumnWidth table.AddRow("NAME", "VERSION", "REPOSITORY", "STATUS") for _, row := range c.Metadata.Dependencies { - table.AddRow(row.Name, row.Version, row.Repository, d.dependencyStatus(chartpath, row, c, opts)) + table.AddRow(row.Name, row.Version, row.Repository, d.dependencyStatus(chartpath, row, c)) } fmt.Fprintln(out, table) } // printMissing prints warnings about charts that are present on disk, but are // not in Chart.yaml. -func (d *Dependency) printMissing(chartpath string, out io.Writer, reqs []*chart.Dependency, opts helmopts.HelmOptions) { +func (d *Dependency) printMissing(chartpath string, out io.Writer, reqs []*chart.Dependency) { folder := filepath.Join(chartpath, "charts/*") files, err := filepath.Glob(folder) if err != nil { @@ -212,7 +219,7 @@ func (d *Dependency) printMissing(chartpath string, out io.Writer, reqs []*chart if !fi.IsDir() && filepath.Ext(f) != ".tgz" { continue } - c, err := loader.Load(f, opts) + c, err := loader.Load(context.Background(), f) if err != nil { fmt.Fprintf(out, "WARNING: %q is not a chart.\n", f) continue diff --git a/pkg/helm/pkg/action/dependency_test.go b/pkg/helm/pkg/action/dependency_test.go index 0419de52..d5691610 100644 --- a/pkg/helm/pkg/action/dependency_test.go +++ b/pkg/helm/pkg/action/dependency_test.go @@ -24,9 +24,9 @@ import ( "github.com/stretchr/testify/assert" - "github.com/werf/nelm/pkg/helm/internal/test" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chartutil" + "github.com/werf/nelm/pkg/helm/intern/test" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + chartutil "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" ) func TestList(t *testing.T) { diff --git a/pkg/helm/pkg/action/exports.go b/pkg/helm/pkg/action/exports.go deleted file mode 100644 index 075968cc..00000000 --- a/pkg/helm/pkg/action/exports.go +++ /dev/null @@ -1,17 +0,0 @@ -package action - -import "k8s.io/client-go/kubernetes" - -const NotesFileSuffix = notesFileSuffix - -var ( - NewSecretClient = newSecretClient - NewConfigMapClient = newConfigMapClient -) - -func NewLazyClient(namespace string, clientFn func() (*kubernetes.Clientset, error)) *lazyClient { - return &lazyClient{ - namespace: namespace, - clientFn: clientFn, - } -} diff --git a/pkg/helm/pkg/action/get_values.go b/pkg/helm/pkg/action/get_values.go new file mode 100644 index 00000000..dae118d1 --- /dev/null +++ b/pkg/helm/pkg/action/get_values.go @@ -0,0 +1,37 @@ +/* +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 ( + "fmt" + + release "github.com/werf/nelm/pkg/helm/pkg/release" + rspb "github.com/werf/nelm/pkg/helm/pkg/release/v1" +) + +func releaserToV1Release(rel release.Releaser) (*rspb.Release, error) { + switch r := rel.(type) { + case rspb.Release: + return &r, nil + case *rspb.Release: + return r, nil + case nil: + return nil, nil + default: + return nil, fmt.Errorf("unsupported release type: %T", rel) + } +} diff --git a/pkg/helm/pkg/action/history.go b/pkg/helm/pkg/action/history.go index c7f38b56..fb4b911d 100644 --- a/pkg/helm/pkg/action/history.go +++ b/pkg/helm/pkg/action/history.go @@ -17,9 +17,9 @@ limitations under the License. package action import ( - "github.com/pkg/errors" + "fmt" - "github.com/werf/nelm/pkg/helm/pkg/chartutil" + chartutil "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" "github.com/werf/nelm/pkg/helm/pkg/release" ) @@ -44,15 +44,15 @@ func NewHistory(cfg *Configuration) *History { } // Run executes 'helm history' against the given release. -func (h *History) Run(name string) ([]*release.Release, error) { +func (h *History) Run(name string) ([]release.Releaser, error) { if err := h.cfg.KubeClient.IsReachable(); err != nil { return nil, err } if err := chartutil.ValidateReleaseName(name); err != nil { - return nil, errors.Errorf("release name is invalid: %s", name) + return nil, fmt.Errorf("release name is invalid: %s", name) } - h.cfg.Log("getting history for release %s", name) + h.cfg.Logger().Debug("getting history for release", "release", name) return h.cfg.Releases.History(name) } diff --git a/pkg/helm/pkg/action/history_test.go b/pkg/helm/pkg/action/history_test.go new file mode 100644 index 00000000..b45ed8e8 --- /dev/null +++ b/pkg/helm/pkg/action/history_test.go @@ -0,0 +1,108 @@ +/* +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 ( + "errors" + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + kubefake "github.com/werf/nelm/pkg/helm/pkg/kube/fake" + release "github.com/werf/nelm/pkg/helm/pkg/release/v1" + + "github.com/werf/nelm/pkg/helm/pkg/release/common" +) + +func TestNewHistory(t *testing.T) { + config := actionConfigFixture(t) + client := NewHistory(config) + + assert.NotNil(t, client) + assert.Equal(t, config, client.cfg) +} + +func TestHistoryRun(t *testing.T) { + releaseName := "test-release" + simpleRelease := namedReleaseStub(releaseName, common.StatusPendingUpgrade) + updatedRelease := namedReleaseStub(releaseName, common.StatusDeployed) + updatedRelease.Chart.Metadata.Version = "0.1.1" + updatedRelease.Version = 2 + + config := actionConfigFixture(t) + client := NewHistory(config) + client.Max = 3 + client.cfg.Releases.MaxHistory = 3 + for _, rel := range []*release.Release{simpleRelease, updatedRelease} { + if err := client.cfg.Releases.Create(rel); err != nil { + t.Fatal(err, "Could not add releases to Config") + } + } + + releases, err := config.Releases.ListReleases() + require.NoError(t, err) + assert.Len(t, releases, 2, "expected 2 Releases in Config") + + releasers, err := client.Run(releaseName) + require.NoError(t, err) + assert.Len(t, releasers, 2, "expected 2 Releases in History result") + + release1, err := releaserToV1Release(releasers[0]) + require.NoError(t, err) + assert.Equal(t, simpleRelease.Name, release1.Name) + assert.Equal(t, simpleRelease.Version, release1.Version) + + release2, err := releaserToV1Release(releasers[1]) + require.NoError(t, err) + assert.Equal(t, updatedRelease.Name, release2.Name) + assert.Equal(t, updatedRelease.Version, release2.Version) +} + +func TestHistoryRun_UnreachableKubeClient(t *testing.T) { + config := actionConfigFixture(t) + failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil} + failingKubeClient.ConnectionError = errors.New("connection refused") + config.KubeClient = &failingKubeClient + + client := NewHistory(config) + result, err := client.Run("release-name") + assert.Nil(t, result) + assert.Error(t, err) +} + +func TestHistoryRun_InvalidReleaseNames(t *testing.T) { + config := actionConfigFixture(t) + client := NewHistory(config) + invalidReleaseNames := []string{ + "", + "too-long-release-name-max-53-characters-abcdefghijklmnopqrstuvwxyz", + "MyRelease", + "release_name", + "release@123", + "-badstart", + "badend-", + ".dotstart", + } + + for _, name := range invalidReleaseNames { + result, err := client.Run(name) + assert.Nil(t, result) + assert.ErrorContains(t, err, "release name is invalid") + } +} diff --git a/pkg/helm/pkg/action/install.go b/pkg/helm/pkg/action/install.go index 2529c6a9..fff038c2 100644 --- a/pkg/helm/pkg/action/install.go +++ b/pkg/helm/pkg/action/install.go @@ -17,40 +17,38 @@ limitations under the License. package action import ( - "bytes" + "errors" "fmt" + "io/fs" "net/url" "os" - "path" "path/filepath" "strings" - "text/template" - "github.com/Masterminds/sprig/v3" - "github.com/pkg/errors" - - "github.com/werf/nelm/pkg/helm/pkg/chart" + ci "github.com/werf/nelm/pkg/helm/pkg/chart" "github.com/werf/nelm/pkg/helm/pkg/cli" "github.com/werf/nelm/pkg/helm/pkg/downloader" "github.com/werf/nelm/pkg/helm/pkg/getter" "github.com/werf/nelm/pkg/helm/pkg/registry" - "github.com/werf/nelm/pkg/helm/pkg/repo" + ri "github.com/werf/nelm/pkg/helm/pkg/release" + release "github.com/werf/nelm/pkg/helm/pkg/release/v1" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1" ) -// NOTESFILE_SUFFIX that we want to treat special. It goes through the templating engine -// but it's not a yaml file (resource) hence can't have hooks, etc. And the user actually +// notesFileSuffix that we want to treat specially. It goes through the templating engine +// but it's not a YAML file (resource) hence can't have hooks, etc. And the user actually // wants to see this file after rendering in the status command. However, it must be a suffix // since there can be filepath in front of it. const notesFileSuffix = "NOTES.txt" -const defaultDirectoryPermission = 0o755 +const defaultDirectoryPermission = 0755 // ChartPathOptions captures common options used for controlling chart paths type ChartPathOptions struct { CaFile string // --ca-file CertFile string // --cert-file KeyFile string // --key-file - InsecureSkipTLSverify bool // --insecure-skip-verify + InsecureSkipTLSVerify bool // --insecure-skip-verify PlainHTTP bool // --plain-http Keyring string // --keyring Password string // --password @@ -65,8 +63,30 @@ type ChartPathOptions struct { registryClient *registry.Client } -// write the to /. controls if the file is created or content will be appended -func writeToFile(outputDir string, name string, data string, append bool) error { +func releaseListToV1List(ls []ri.Releaser) ([]*release.Release, error) { + rls := make([]*release.Release, 0, len(ls)) + for _, val := range ls { + rel, err := releaserToV1Release(val) + if err != nil { + return nil, err + } + rls = append(rls, rel) + } + + return rls, nil +} + +func releaseV1ListToReleaserList(ls []*release.Release) ([]ri.Releaser, error) { + rls := make([]ri.Releaser, 0, len(ls)) + for _, val := range ls { + rls = append(rls, val) + } + + return rls, nil +} + +// write the to /. controls if the file is created or content will be appended +func writeToFile(outputDir string, name string, data string, appendData bool) error { outfileName := strings.Join([]string{outputDir, name}, string(filepath.Separator)) err := ensureDirectoryForFile(outfileName) @@ -74,14 +94,15 @@ func writeToFile(outputDir string, name string, data string, append bool) error return err } - f, err := createOrOpenFile(outfileName, append) + f, err := createOrOpenFile(outfileName, appendData) if err != nil { return err } defer f.Close() - _, err = f.WriteString(fmt.Sprintf("---\n# Source: %s\n%s\n", name, data)) + _, err = fmt.Fprintf(f, "---\n# Source: %s\n%s\n", name, data) + if err != nil { return err } @@ -90,68 +111,82 @@ func writeToFile(outputDir string, name string, data string, append bool) error return nil } -func createOrOpenFile(filename string, append bool) (*os.File, error) { - if append { - return os.OpenFile(filename, os.O_APPEND|os.O_WRONLY, 0o600) +func createOrOpenFile(filename string, appendData bool) (*os.File, error) { + if appendData { + return os.OpenFile(filename, os.O_APPEND|os.O_WRONLY, 0600) } return os.Create(filename) } -// check if the directory exists to create file. creates if don't exists +// check if the directory exists to create file. creates if doesn't exist func ensureDirectoryForFile(file string) error { - baseDir := path.Dir(file) + baseDir := filepath.Dir(file) _, err := os.Stat(baseDir) - if err != nil && !os.IsNotExist(err) { + if err != nil && !errors.Is(err, fs.ErrNotExist) { return err } return os.MkdirAll(baseDir, defaultDirectoryPermission) } -// TemplateName renders a name template, returning the name or an error. -func TemplateName(nameTemplate string) (string, error) { - if nameTemplate == "" { - return "", nil - } - - t, err := template.New("name-template").Funcs(sprig.TxtFuncMap()).Parse(nameTemplate) +// CheckDependencies checks the dependencies for a chart. +func CheckDependencies(ch ci.Charter, reqs []ci.Dependency) error { + ac, err := ci.NewAccessor(ch) if err != nil { - return "", err - } - var b bytes.Buffer - if err := t.Execute(&b, nil); err != nil { - return "", err + return err } - return b.String(), nil -} - -// CheckDependencies checks the dependencies for a chart. -func CheckDependencies(ch *chart.Chart, reqs []*chart.Dependency) error { var missing []string OUTER: for _, r := range reqs { - for _, d := range ch.Dependencies() { - if d.Name() == r.Name { + rac, err := ci.NewDependencyAccessor(r) + if err != nil { + return err + } + for _, d := range ac.Dependencies() { + dac, err := ci.NewAccessor(d) + if err != nil { + return err + } + if dac.Name() == rac.Name() { continue OUTER } } - missing = append(missing, r.Name) + missing = append(missing, rac.Name()) } if len(missing) > 0 { - return errors.Errorf("found in Chart.yaml, but missing in charts/ directory: %s", strings.Join(missing, ", ")) + return fmt.Errorf("found in Chart.yaml, but missing in charts/ directory: %s", strings.Join(missing, ", ")) } return nil } +func portOrDefault(u *url.URL) string { + if p := u.Port(); p != "" { + return p + } + + switch u.Scheme { + case "http": + return "80" + case "https": + return "443" + default: + return "" + } +} + +func urlEqual(u1, u2 *url.URL) bool { + return u1.Scheme == u2.Scheme && u1.Hostname() == u2.Hostname() && portOrDefault(u1) == portOrDefault(u2) +} + // LocateChart looks for a chart directory in known places, and returns either the full path or an error. // // This does not ensure that the chart is well-formed; only that the requested filename exists. // // Order of resolution: -// - relative to current working directory +// - relative to current working directory when --repo flag is not presented // - if path is absolute or begins with '.', error out here // - URL // @@ -164,20 +199,22 @@ func (c *ChartPathOptions) LocateChart(name string, settings *cli.EnvSettings) ( name = strings.TrimSpace(name) version := strings.TrimSpace(c.Version) - if _, err := os.Stat(name); err == nil { - abs, err := filepath.Abs(name) - if err != nil { - return abs, err - } - if c.Verify { - if _, err := downloader.VerifyChart(abs, c.Keyring); err != nil { - return "", err + if c.RepoURL == "" { + if _, err := os.Stat(name); err == nil { + abs, err := filepath.Abs(name) + if err != nil { + return abs, err + } + if c.Verify { + if _, err := downloader.VerifyChart(abs, abs+".prov", c.Keyring); err != nil { + return "", err + } } + return abs, nil + } + if filepath.IsAbs(name) || strings.HasPrefix(name, ".") { + return name, fmt.Errorf("path %q not found", name) } - return abs, nil - } - if filepath.IsAbs(name) || strings.HasPrefix(name, ".") { - return name, errors.Errorf("path %q not found", name) } dl := downloader.ChartDownloader{ @@ -187,11 +224,13 @@ func (c *ChartPathOptions) LocateChart(name string, settings *cli.EnvSettings) ( Options: []getter.Option{ getter.WithPassCredentialsAll(c.PassCredentialsAll), getter.WithTLSClientConfig(c.CertFile, c.KeyFile, c.CaFile), - getter.WithInsecureSkipVerifyTLS(c.InsecureSkipTLSverify), + getter.WithInsecureSkipVerifyTLS(c.InsecureSkipTLSVerify), getter.WithPlainHTTP(c.PlainHTTP), + getter.WithBasicAuth(c.Username, c.Password), }, RepositoryConfig: settings.RepositoryConfig, RepositoryCache: settings.RepositoryCache, + ContentCache: settings.ContentCache, RegistryClient: c.registryClient, } @@ -203,8 +242,16 @@ func (c *ChartPathOptions) LocateChart(name string, settings *cli.EnvSettings) ( dl.Verify = downloader.VerifyAlways } if c.RepoURL != "" { - chartURL, err := repo.FindChartInAuthAndTLSAndPassRepoURL(c.RepoURL, c.Username, c.Password, name, version, - c.CertFile, c.KeyFile, c.CaFile, c.InsecureSkipTLSverify, c.PassCredentialsAll, getter.All(settings)) + chartURL, err := repo.FindChartInRepoURL( + c.RepoURL, + name, + getter.All(settings), + repo.WithChartVersion(version), + repo.WithClientTLS(c.CertFile, c.KeyFile, c.CaFile), + repo.WithUsernamePassword(c.Username, c.Password), + repo.WithInsecureSkipTLSVerify(c.InsecureSkipTLSVerify), + repo.WithPassCredentialsAll(c.PassCredentialsAll), + ) if err != nil { return "", err } @@ -224,7 +271,7 @@ func (c *ChartPathOptions) LocateChart(name string, settings *cli.EnvSettings) ( // Host on URL (returned from url.Parse) contains the port if present. // This check ensures credentials are not passed between different // services on different ports. - if c.PassCredentialsAll || (u1.Scheme == u2.Scheme && u1.Host == u2.Host) { + if c.PassCredentialsAll || urlEqual(u1, u2) { dl.Options = append(dl.Options, getter.WithBasicAuth(c.Username, c.Password)) } else { dl.Options = append(dl.Options, getter.WithBasicAuth("", "")) @@ -233,11 +280,11 @@ func (c *ChartPathOptions) LocateChart(name string, settings *cli.EnvSettings) ( dl.Options = append(dl.Options, getter.WithBasicAuth(c.Username, c.Password)) } - if err := os.MkdirAll(settings.RepositoryCache, 0o755); err != nil { + if err := os.MkdirAll(settings.RepositoryCache, 0755); err != nil { return "", err } - filename, _, err := dl.DownloadTo(name, version, settings.RepositoryCache) + filename, _, err := dl.DownloadToCache(name, version) if err != nil { return "", err } @@ -248,7 +295,3 @@ func (c *ChartPathOptions) LocateChart(name string, settings *cli.EnvSettings) ( } return lname, nil } - -func (c *ChartPathOptions) SetRegistryClient(cli *registry.Client) { - c.registryClient = cli -} diff --git a/pkg/helm/pkg/action/list.go b/pkg/helm/pkg/action/list.go index 994e775d..c63a966b 100644 --- a/pkg/helm/pkg/action/list.go +++ b/pkg/helm/pkg/action/list.go @@ -22,8 +22,9 @@ import ( "k8s.io/apimachinery/pkg/labels" - "github.com/werf/nelm/pkg/helm/pkg/release" - "github.com/werf/nelm/pkg/helm/pkg/releaseutil" + ri "github.com/werf/nelm/pkg/helm/pkg/release" + release "github.com/werf/nelm/pkg/helm/pkg/release/v1" + releaseutil "github.com/werf/nelm/pkg/helm/pkg/release/v1/util" ) // ListStates represents zero or more status codes that a list item may have set @@ -139,13 +140,13 @@ type List struct { // NewList constructs a new *List func NewList(cfg *Configuration) *List { return &List{ - StateMask: ListDeployed | ListFailed, + StateMask: ListAll, cfg: cfg, } } // Run executes the list command, returning a set of matches. -func (l *List) Run() ([]*release.Release, error) { +func (l *List) Run() ([]ri.Releaser, error) { if err := l.cfg.KubeClient.IsReachable(); err != nil { return nil, err } @@ -159,9 +160,13 @@ func (l *List) Run() ([]*release.Release, error) { } } - results, err := l.cfg.Releases.List(func(rel *release.Release) bool { + results, err := l.cfg.Releases.List(func(rel ri.Releaser) bool { + r, err := releaserToV1Release(rel) + if err != nil { + return false + } // Skip anything that doesn't match the filter. - if filter != nil && !filter.MatchString(rel.Name) { + if filter != nil && !filter.MatchString(r.Name) { return false } @@ -176,30 +181,35 @@ func (l *List) Run() ([]*release.Release, error) { return results, nil } + rresults, err := releaseListToV1List(results) + if err != nil { + return nil, err + } + // by definition, superseded releases are never shown if // only the latest releases are returned. so if requested statemask // is _only_ ListSuperseded, skip the latest release filter if l.StateMask != ListSuperseded { - results = filterLatestReleases(results) + rresults = filterLatestReleases(rresults) } // State mask application must occur after filtering to // latest releases, otherwise outdated entries can be returned - results = l.filterStateMask(results) + rresults = l.filterStateMask(rresults) // Skip anything that doesn't match the selector selectorObj, err := labels.Parse(l.Selector) if err != nil { return nil, err } - results = l.filterSelector(results, selectorObj) + rresults = l.filterSelector(rresults, selectorObj) // Unfortunately, we have to sort before truncating, which can incur substantial overhead - l.sort(results) + l.sort(rresults) // Guard on offset - if l.Offset >= len(results) { - return []*release.Release{}, nil + if l.Offset >= len(rresults) { + return releaseV1ListToReleaserList([]*release.Release{}) } // Calculate the limit and offset, and then truncate results if necessary. @@ -208,12 +218,12 @@ func (l *List) Run() ([]*release.Release, error) { limit = l.Limit } last := l.Offset + limit - if l := len(results); l < last { + if l := len(rresults); l < last { last = l } - results = results[l.Offset:last] + rresults = rresults[l.Offset:last] - return results, err + return releaseV1ListToReleaserList(rresults) } // sort is an in-place sort where order is based on the value of a.Sort @@ -317,7 +327,7 @@ func (l *List) SetStateMask() { // Apply a default if state == 0 { - state = ListDeployed | ListFailed + state = ListAll } l.StateMask = state diff --git a/pkg/helm/pkg/action/list_test.go b/pkg/helm/pkg/action/list_test.go index 193470e5..c5d8baca 100644 --- a/pkg/helm/pkg/action/list_test.go +++ b/pkg/helm/pkg/action/list_test.go @@ -17,11 +17,16 @@ limitations under the License. package action import ( + "errors" + "io" "testing" "github.com/stretchr/testify/assert" - "github.com/werf/nelm/pkg/helm/pkg/release" + kubefake "github.com/werf/nelm/pkg/helm/pkg/kube/fake" + ri "github.com/werf/nelm/pkg/helm/pkg/release" + "github.com/werf/nelm/pkg/helm/pkg/release/common" + release "github.com/werf/nelm/pkg/helm/pkg/release/v1" "github.com/werf/nelm/pkg/helm/pkg/storage" ) @@ -64,13 +69,14 @@ func TestList_Empty(t *testing.T) { } func newListFixture(t *testing.T) *List { + t.Helper() return NewList(actionConfigFixture(t)) } func TestList_OneNamespace(t *testing.T) { is := assert.New(t) lister := newListFixture(t) - makeMeSomeReleases(lister.cfg.Releases, t) + makeMeSomeReleases(t, lister.cfg.Releases) list, err := lister.Run() is.NoError(err) is.Len(list, 3) @@ -79,7 +85,7 @@ func TestList_OneNamespace(t *testing.T) { func TestList_AllNamespaces(t *testing.T) { is := assert.New(t) lister := newListFixture(t) - makeMeSomeReleases(lister.cfg.Releases, t) + makeMeSomeReleases(t, lister.cfg.Releases) lister.AllNamespaces = true lister.SetStateMask() list, err := lister.Run() @@ -91,9 +97,12 @@ func TestList_Sort(t *testing.T) { is := assert.New(t) lister := newListFixture(t) lister.Sort = ByNameDesc // Other sorts are tested elsewhere - makeMeSomeReleases(lister.cfg.Releases, t) - list, err := lister.Run() + makeMeSomeReleases(t, lister.cfg.Releases) + l, err := lister.Run() is.NoError(err) + list, err := releaseListToV1List(l) + is.NoError(err) + is.Len(list, 3) is.Equal("two", list[0].Name) is.Equal("three", list[1].Name) @@ -104,8 +113,10 @@ func TestList_Limit(t *testing.T) { is := assert.New(t) lister := newListFixture(t) lister.Limit = 2 - makeMeSomeReleases(lister.cfg.Releases, t) - list, err := lister.Run() + makeMeSomeReleases(t, lister.cfg.Releases) + l, err := lister.Run() + is.NoError(err) + list, err := releaseListToV1List(l) is.NoError(err) is.Len(list, 2) // Lex order means one, three, two @@ -117,8 +128,10 @@ func TestList_BigLimit(t *testing.T) { is := assert.New(t) lister := newListFixture(t) lister.Limit = 20 - makeMeSomeReleases(lister.cfg.Releases, t) - list, err := lister.Run() + makeMeSomeReleases(t, lister.cfg.Releases) + l, err := lister.Run() + is.NoError(err) + list, err := releaseListToV1List(l) is.NoError(err) is.Len(list, 3) @@ -133,8 +146,10 @@ func TestList_LimitOffset(t *testing.T) { lister := newListFixture(t) lister.Limit = 2 lister.Offset = 1 - makeMeSomeReleases(lister.cfg.Releases, t) - list, err := lister.Run() + makeMeSomeReleases(t, lister.cfg.Releases) + l, err := lister.Run() + is.NoError(err) + list, err := releaseListToV1List(l) is.NoError(err) is.Len(list, 2) @@ -148,7 +163,7 @@ func TestList_LimitOffsetOutOfBounds(t *testing.T) { lister := newListFixture(t) lister.Limit = 2 lister.Offset = 3 // Last item is index 2 - makeMeSomeReleases(lister.cfg.Releases, t) + makeMeSomeReleases(t, lister.cfg.Releases) list, err := lister.Run() is.NoError(err) is.Len(list, 0) @@ -163,24 +178,46 @@ func TestList_LimitOffsetOutOfBounds(t *testing.T) { func TestList_StateMask(t *testing.T) { is := assert.New(t) lister := newListFixture(t) - makeMeSomeReleases(lister.cfg.Releases, t) - one, err := lister.cfg.Releases.Get("one", 1) + makeMeSomeReleases(t, lister.cfg.Releases) + oner, err := lister.cfg.Releases.Get("one", 1) is.NoError(err) - one.SetStatus(release.StatusUninstalled, "uninstalled") + + var one release.Release + switch v := oner.(type) { + case release.Release: + one = v + case *release.Release: + one = *v + default: + t.Fatal("unsupported release type") + } + + one.SetStatus(common.StatusUninstalled, "uninstalled") err = lister.cfg.Releases.Update(one) is.NoError(err) res, err := lister.Run() is.NoError(err) - is.Len(res, 2) - is.Equal("three", res[0].Name) - is.Equal("two", res[1].Name) + is.Len(res, 3) + + ac0, err := ri.NewAccessor(res[0]) + is.NoError(err) + ac1, err := ri.NewAccessor(res[1]) + is.NoError(err) + ac2, err := ri.NewAccessor(res[2]) + is.NoError(err) + + is.Equal("one", ac0.Name()) + is.Equal("three", ac1.Name()) + is.Equal("two", ac2.Name()) lister.StateMask = ListUninstalled res, err = lister.Run() is.NoError(err) is.Len(res, 1) - is.Equal("one", res[0].Name) + ac0, err = ri.NewAccessor(res[0]) + is.NoError(err) + is.Equal("one", ac0.Name()) lister.StateMask |= ListDeployed res, err = lister.Run() @@ -193,7 +230,7 @@ func TestList_StateMaskWithStaleRevisions(t *testing.T) { lister := newListFixture(t) lister.StateMask = ListFailed - makeMeSomeReleasesWithStaleFailure(lister.cfg.Releases, t) + makeMeSomeReleasesWithStaleFailure(t, lister.cfg.Releases) res, err := lister.Run() @@ -202,28 +239,30 @@ func TestList_StateMaskWithStaleRevisions(t *testing.T) { // "dirty" release should _not_ be present as most recent // release is deployed despite failed release in past - is.Equal("failed", res[0].Name) + ac0, err := ri.NewAccessor(res[0]) + is.NoError(err) + is.Equal("failed", ac0.Name()) } -func makeMeSomeReleasesWithStaleFailure(store *storage.Storage, t *testing.T) { +func makeMeSomeReleasesWithStaleFailure(t *testing.T, store *storage.Storage) { t.Helper() - one := namedReleaseStub("clean", release.StatusDeployed) + one := namedReleaseStub("clean", common.StatusDeployed) one.Namespace = "default" one.Version = 1 - two := namedReleaseStub("dirty", release.StatusDeployed) + two := namedReleaseStub("dirty", common.StatusDeployed) two.Namespace = "default" two.Version = 1 - three := namedReleaseStub("dirty", release.StatusFailed) + three := namedReleaseStub("dirty", common.StatusFailed) three.Namespace = "default" three.Version = 2 - four := namedReleaseStub("dirty", release.StatusDeployed) + four := namedReleaseStub("dirty", common.StatusDeployed) four.Namespace = "default" four.Version = 3 - five := namedReleaseStub("failed", release.StatusFailed) + five := namedReleaseStub("failed", common.StatusFailed) five.Namespace = "default" five.Version = 1 @@ -242,25 +281,27 @@ func TestList_Filter(t *testing.T) { is := assert.New(t) lister := newListFixture(t) lister.Filter = "th." - makeMeSomeReleases(lister.cfg.Releases, t) + makeMeSomeReleases(t, lister.cfg.Releases) res, err := lister.Run() is.NoError(err) is.Len(res, 1) - is.Equal("three", res[0].Name) + ac0, err := ri.NewAccessor(res[0]) + is.NoError(err) + is.Equal("three", ac0.Name()) } func TestList_FilterFailsCompile(t *testing.T) { is := assert.New(t) lister := newListFixture(t) lister.Filter = "t[h.{{{" - makeMeSomeReleases(lister.cfg.Releases, t) + makeMeSomeReleases(t, lister.cfg.Releases) _, err := lister.Run() is.Error(err) } -func makeMeSomeReleases(store *storage.Storage, t *testing.T) { +func makeMeSomeReleases(t *testing.T, store *storage.Storage) { t.Helper() one := releaseStub() one.Name = "one" @@ -366,3 +407,16 @@ func TestSelectorList(t *testing.T) { assert.ElementsMatch(t, expectedFilteredList, res) }) } + +func TestListRun_UnreachableKubeClient(t *testing.T) { + config := actionConfigFixture(t) + failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil} + failingKubeClient.ConnectionError = errors.New("connection refused") + config.KubeClient = &failingKubeClient + + lister := NewList(config) + result, err := lister.Run() + + assert.Nil(t, result) + assert.ErrorContains(t, err, "connection refused") +} diff --git a/pkg/helm/pkg/action/package.go b/pkg/helm/pkg/action/package.go index 97f4ae41..89f1b9d2 100644 --- a/pkg/helm/pkg/action/package.go +++ b/pkg/helm/pkg/action/package.go @@ -18,18 +18,22 @@ package action import ( "bufio" + "context" + "errors" "fmt" "os" + "path/filepath" "syscall" "github.com/Masterminds/semver/v3" - "github.com/pkg/errors" "golang.org/x/term" + "sigs.k8s.io/yaml" + ci "github.com/werf/nelm/pkg/helm/pkg/chart" "github.com/werf/nelm/pkg/helm/pkg/chart/loader" - "github.com/werf/nelm/pkg/helm/pkg/chartutil" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + chartutil "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" "github.com/werf/nelm/pkg/helm/pkg/provenance" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" ) // Package is the action for packaging a chart. @@ -40,23 +44,49 @@ type Package struct { Key string Keyring string PassphraseFile string + cachedPassphrase []byte Version string AppVersion string Destination string DependencyUpdate bool - RepositoryConfig string - RepositoryCache string + RepositoryConfig string + RepositoryCache string + PlainHTTP bool + Username string + Password string + CertFile string + KeyFile string + CaFile string + InsecureSkipTLSVerify bool } +const ( + passPhraseFileStdin = "-" +) + // NewPackage creates a new Package object with the given configuration. func NewPackage() *Package { return &Package{} } // Run executes 'helm package' against the given chart and returns the path to the packaged chart. -func (p *Package) Run(path string, _ map[string]interface{}, opts helmopts.HelmOptions) (string, error) { - ch, err := loader.LoadDir(path, opts) +func (p *Package) Run(path string, _ map[string]interface{}) (string, error) { + chrt, err := loader.LoadDir(context.Background(), path) + if err != nil { + return "", err + } + var ch *chart.Chart + switch c := chrt.(type) { + case *chart.Chart: + ch = c + case chart.Chart: + ch = &c + default: + return "", errors.New("invalid chart apiVersion") + } + + ac, err := ci.NewAccessor(ch) if err != nil { return "", err } @@ -74,7 +104,7 @@ func (p *Package) Run(path string, _ map[string]interface{}, opts helmopts.HelmO ch.Metadata.AppVersion = p.AppVersion } - if reqs := ch.Metadata.Dependencies; reqs != nil { + if reqs := ac.MetaDependencies(); len(reqs) > 0 { if err := CheckDependencies(ch, reqs); err != nil { return "", err } @@ -94,11 +124,11 @@ func (p *Package) Run(path string, _ map[string]interface{}, opts helmopts.HelmO name, err := chartutil.Save(ch, dest) if err != nil { - return "", errors.Wrap(err, "failed to save") + return "", fmt.Errorf("failed to save: %w", err) } if p.Sign { - err = p.Clearsign(name, opts) + err = p.Clearsign(name) } return name, err @@ -113,7 +143,7 @@ func validateVersion(ver string) error { } // Clearsign signs a chart -func (p *Package) Clearsign(filename string, opts helmopts.HelmOptions) error { +func (p *Package) Clearsign(filename string) error { // Load keyring signer, err := provenance.NewFromKeyring(p.Keyring, p.Key) if err != nil { @@ -122,7 +152,7 @@ func (p *Package) Clearsign(filename string, opts helmopts.HelmOptions) error { passphraseFetcher := promptUser if p.PassphraseFile != "" { - passphraseFetcher, err = passphraseFileFetcher(p.PassphraseFile, os.Stdin) + passphraseFetcher, err = p.passphraseFileFetcher(p.PassphraseFile, os.Stdin) if err != nil { return err } @@ -132,7 +162,35 @@ func (p *Package) Clearsign(filename string, opts helmopts.HelmOptions) error { return err } - sig, err := signer.ClearSign(filename, opts) + // Load the chart archive to extract metadata + chrt, err := loader.LoadFile(context.Background(), filename) + if err != nil { + return fmt.Errorf("failed to load chart for signing: %w", err) + } + var ch *chart.Chart + switch c := chrt.(type) { + case *chart.Chart: + ch = c + case chart.Chart: + ch = &c + default: + return errors.New("invalid chart apiVersion") + } + + // Marshal chart metadata to YAML bytes + metadataBytes, err := yaml.Marshal(ch.Metadata) + if err != nil { + return fmt.Errorf("failed to marshal chart metadata: %w", err) + } + + // Read the chart archive file + archiveData, err := os.ReadFile(filename) + if err != nil { + return fmt.Errorf("failed to read chart archive: %w", err) + } + + // Use the generic provenance signing function + sig, err := signer.ClearSign(archiveData, filepath.Base(filename), metadataBytes) if err != nil { return err } @@ -150,25 +208,42 @@ func promptUser(name string) ([]byte, error) { return pw, err } -func passphraseFileFetcher(passphraseFile string, stdin *os.File) (provenance.PassphraseFetcher, error) { - file, err := openPassphraseFile(passphraseFile, stdin) - if err != nil { - return nil, err - } - defer file.Close() +func (p *Package) passphraseFileFetcher(passphraseFile string, stdin *os.File) (provenance.PassphraseFetcher, error) { + // When reading from stdin we cache the passphrase here. If we are + // packaging multiple charts, we reuse the cached passphrase. This + // allows giving the passphrase once on stdin without failing with + // complaints about stdin already being closed. + // + // An alternative to this would be to omit file.Close() for stdin + // below and require the user to provide the same passphrase once + // per chart on stdin, but that does not seem very user-friendly. - reader := bufio.NewReader(file) - passphrase, _, err := reader.ReadLine() - if err != nil { - return nil, err + if p.cachedPassphrase == nil { + file, err := openPassphraseFile(passphraseFile, stdin) + if err != nil { + return nil, err + } + defer file.Close() + + reader := bufio.NewReader(file) + passphrase, _, err := reader.ReadLine() + if err != nil { + return nil, err + } + p.cachedPassphrase = passphrase + + return func(_ string) ([]byte, error) { + return passphrase, nil + }, nil } - return func(name string) ([]byte, error) { - return passphrase, nil + + return func(_ string) ([]byte, error) { + return p.cachedPassphrase, nil }, nil } func openPassphraseFile(passphraseFile string, stdin *os.File) (*os.File, error) { - if passphraseFile == "-" { + if passphraseFile == passPhraseFileStdin { stat, err := stdin.Stat() if err != nil { return nil, err diff --git a/pkg/helm/pkg/action/package_test.go b/pkg/helm/pkg/action/package_test.go index 49e772f7..9b6d7705 100644 --- a/pkg/helm/pkg/action/package_test.go +++ b/pkg/helm/pkg/action/package_test.go @@ -22,15 +22,17 @@ import ( "testing" "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/require" - "github.com/werf/nelm/pkg/helm/internal/test/ensure" + "github.com/werf/nelm/pkg/helm/intern/test/ensure" ) func TestPassphraseFileFetcher(t *testing.T) { secret := "secret" directory := ensure.TempFile(t, "passphrase-file", []byte(secret)) + testPkg := NewPackage() - fetcher, err := passphraseFileFetcher(path.Join(directory, "passphrase-file"), nil) + fetcher, err := testPkg.passphraseFileFetcher(path.Join(directory, "passphrase-file"), nil) if err != nil { t.Fatal("Unable to create passphraseFileFetcher", err) } @@ -48,8 +50,9 @@ func TestPassphraseFileFetcher(t *testing.T) { func TestPassphraseFileFetcher_WithLineBreak(t *testing.T) { secret := "secret" directory := ensure.TempFile(t, "passphrase-file", []byte(secret+"\n\n.")) + testPkg := NewPackage() - fetcher, err := passphraseFileFetcher(path.Join(directory, "passphrase-file"), nil) + fetcher, err := testPkg.passphraseFileFetcher(path.Join(directory, "passphrase-file"), nil) if err != nil { t.Fatal("Unable to create passphraseFileFetcher", err) } @@ -66,17 +69,49 @@ func TestPassphraseFileFetcher_WithLineBreak(t *testing.T) { func TestPassphraseFileFetcher_WithInvalidStdin(t *testing.T) { directory := t.TempDir() + testPkg := NewPackage() stdin, err := os.CreateTemp(directory, "non-existing") if err != nil { t.Fatal("Unable to create test file", err) } - if _, err := passphraseFileFetcher("-", stdin); err == nil { + if _, err := testPkg.passphraseFileFetcher("-", stdin); err == nil { t.Error("Expected passphraseFileFetcher returning an error") } } +func TestPassphraseFileFetcher_WithStdinAndMultipleFetches(t *testing.T) { + testPkg := NewPackage() + stdin, w, err := os.Pipe() + if err != nil { + t.Fatal("Unable to create pipe", err) + } + + passphrase := "secret-from-stdin" + + go func() { + _, err = w.Write([]byte(passphrase + "\n")) + require.NoError(t, err) + }() + + for range 4 { + fetcher, err := testPkg.passphraseFileFetcher("-", stdin) + if err != nil { + t.Errorf("Expected passphraseFileFetcher to not return an error, but got %v", err) + } + + pass, err := fetcher("key") + if err != nil { + t.Errorf("Expected passphraseFileFetcher invocation to succeed, failed with %v", err) + } + + if string(pass) != string(passphrase) { + t.Errorf("Expected multiple passphrase fetch to return %q, got %q", passphrase, pass) + } + } +} + func TestValidateVersion(t *testing.T) { type args struct { ver string @@ -119,3 +154,18 @@ func TestValidateVersion(t *testing.T) { }) } } + +func TestRun_ErrorPath(t *testing.T) { + client := NewPackage() + _, err := client.Run("err-path", nil) + require.Error(t, err) +} + +func TestRun(t *testing.T) { + chartPath := "testdata/charts/chart-with-schema" + client := NewPackage() + filename, err := client.Run(chartPath, nil) + require.NoError(t, err) + require.Equal(t, "empty-0.1.0.tgz", filename) + require.NoError(t, os.Remove(filename)) +} diff --git a/pkg/helm/pkg/action/pull.go b/pkg/helm/pkg/action/pull.go index 35a9b5cc..5226853b 100644 --- a/pkg/helm/pkg/action/pull.go +++ b/pkg/helm/pkg/action/pull.go @@ -22,14 +22,12 @@ import ( "path/filepath" "strings" - "github.com/pkg/errors" - - "github.com/werf/nelm/pkg/helm/pkg/chartutil" + chartutil "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" "github.com/werf/nelm/pkg/helm/pkg/cli" "github.com/werf/nelm/pkg/helm/pkg/downloader" "github.com/werf/nelm/pkg/helm/pkg/getter" "github.com/werf/nelm/pkg/helm/pkg/registry" - "github.com/werf/nelm/pkg/helm/pkg/repo" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1" ) // Pull is the action for checking a given release's information. @@ -56,13 +54,8 @@ func WithConfig(cfg *Configuration) PullOpt { } } -// NewPull creates a new Pull object. -func NewPull() *Pull { - return NewPullWithOpts() -} - -// NewPullWithOpts creates a new pull, with configuration options. -func NewPullWithOpts(opts ...PullOpt) *Pull { +// NewPull creates a new Pull with configuration options. +func NewPull(opts ...PullOpt) *Pull { p := &Pull{} for _, fn := range opts { fn(p) @@ -89,12 +82,13 @@ func (p *Pull) Run(chartRef string) (string, error) { getter.WithBasicAuth(p.Username, p.Password), getter.WithPassCredentialsAll(p.PassCredentialsAll), getter.WithTLSClientConfig(p.CertFile, p.KeyFile, p.CaFile), - getter.WithInsecureSkipVerifyTLS(p.InsecureSkipTLSverify), + getter.WithInsecureSkipVerifyTLS(p.InsecureSkipTLSVerify), getter.WithPlainHTTP(p.PlainHTTP), }, RegistryClient: p.cfg.RegistryClient, RepositoryConfig: p.Settings.RepositoryConfig, RepositoryCache: p.Settings.RepositoryCache, + ContentCache: p.Settings.ContentCache, } if registry.IsOCI(chartRef) { @@ -116,20 +110,30 @@ func (p *Pull) Run(chartRef string) (string, error) { var err error dest, err = os.MkdirTemp("", "helm-") if err != nil { - return out.String(), errors.Wrap(err, "failed to untar") + return out.String(), fmt.Errorf("failed to untar: %w", err) } defer os.RemoveAll(dest) } + downloadSourceRef := chartRef if p.RepoURL != "" { - chartURL, err := repo.FindChartInAuthAndTLSAndPassRepoURL(p.RepoURL, p.Username, p.Password, chartRef, p.Version, p.CertFile, p.KeyFile, p.CaFile, p.InsecureSkipTLSverify, p.PassCredentialsAll, getter.All(p.Settings)) + chartURL, err := repo.FindChartInRepoURL( + p.RepoURL, + chartRef, + getter.All(p.Settings), + repo.WithChartVersion(p.Version), + repo.WithClientTLS(p.CertFile, p.KeyFile, p.CaFile), + repo.WithUsernamePassword(p.Username, p.Password), + repo.WithInsecureSkipTLSVerify(p.InsecureSkipTLSVerify), + repo.WithPassCredentialsAll(p.PassCredentialsAll), + ) if err != nil { return out.String(), err } - chartRef = chartURL + downloadSourceRef = chartURL } - saved, v, err := c.DownloadTo(chartRef, p.Version, dest) + saved, v, err := c.DownloadTo(downloadSourceRef, p.Version, dest) if err != nil { return out.String(), err } @@ -159,11 +163,10 @@ func (p *Pull) Run(chartRef string) (string, error) { if _, err := os.Stat(udCheck); err != nil { if err := os.MkdirAll(udCheck, 0755); err != nil { - return out.String(), errors.Wrap(err, "failed to untar (mkdir)") + return out.String(), fmt.Errorf("failed to untar (mkdir): %w", err) } - } else { - return out.String(), errors.Errorf("failed to untar: a file or directory with the name %s already exists", udCheck) + return out.String(), fmt.Errorf("failed to untar: a file or directory with the name %s already exists", udCheck) } return out.String(), chartutil.ExpandFile(ud, saved) diff --git a/pkg/helm/pkg/action/pull_test.go b/pkg/helm/pkg/action/pull_test.go new file mode 100644 index 00000000..e6480694 --- /dev/null +++ b/pkg/helm/pkg/action/pull_test.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 action + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/werf/nelm/pkg/helm/pkg/cli" + "github.com/werf/nelm/pkg/helm/pkg/registry" +) + +func TestNewPull(t *testing.T) { + config := actionConfigFixture(t) + client := NewPull(WithConfig(config)) + + assert.NotNil(t, client) + assert.Equal(t, config, client.cfg) +} + +func TestPullSetRegistryClient(t *testing.T) { + config := actionConfigFixture(t) + client := NewPull(WithConfig(config)) + + registryClient := ®istry.Client{} + client.SetRegistryClient(registryClient) + assert.Equal(t, registryClient, client.cfg.RegistryClient) +} + +func TestPullRun_ChartNotFound(t *testing.T) { + srv, err := startLocalServerForTests(t, nil) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + + config := actionConfigFixture(t) + client := NewPull(WithConfig(config)) + client.Settings = cli.New() + client.RepoURL = srv.URL + + chartRef := "nginx" + _, err = client.Run(chartRef) + require.ErrorContains(t, err, "404 Not Found") +} + +func startLocalServerForTests(t *testing.T, handler http.Handler) (*httptest.Server, error) { + t.Helper() + if handler == nil { + fileBytes, err := os.ReadFile("../repo/v1/testdata/local-index.yaml") + if err != nil { + return nil, err + } + handler = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, err = w.Write(fileBytes) + require.NoError(t, err) + }) + } + + return httptest.NewServer(handler), nil +} diff --git a/pkg/helm/pkg/action/push.go b/pkg/helm/pkg/action/push.go index 1ca56b1b..1efe5edc 100644 --- a/pkg/helm/pkg/action/push.go +++ b/pkg/helm/pkg/action/push.go @@ -24,7 +24,6 @@ import ( "github.com/werf/nelm/pkg/helm/pkg/pusher" "github.com/werf/nelm/pkg/helm/pkg/registry" "github.com/werf/nelm/pkg/helm/pkg/uploader" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" ) // Push is the action for uploading a chart. @@ -36,7 +35,7 @@ type Push struct { certFile string keyFile string caFile string - insecureSkipTLSverify bool + insecureSkipTLSVerify bool plainHTTP bool out io.Writer } @@ -63,7 +62,7 @@ func WithTLSClientConfig(certFile, keyFile, caFile string) PushOpt { // WithInsecureSkipTLSVerify determines if a TLS Certificate will be checked func WithInsecureSkipTLSVerify(insecureSkipTLSVerify bool) PushOpt { return func(p *Push) { - p.insecureSkipTLSverify = insecureSkipTLSVerify + p.insecureSkipTLSVerify = insecureSkipTLSVerify } } @@ -74,7 +73,7 @@ func WithPlainHTTP(plainHTTP bool) PushOpt { } } -// WithOptWriter sets the registryOut field on the push configuration object. +// WithPushOptWriter sets the registryOut field on the push configuration object. func WithPushOptWriter(out io.Writer) PushOpt { return func(p *Push) { p.out = out @@ -91,7 +90,7 @@ func NewPushWithOpts(opts ...PushOpt) *Push { } // Run executes 'helm push' against the given chart archive. -func (p *Push) Run(chartRef string, remote string, opts helmopts.HelmOptions) (string, error) { +func (p *Push) Run(chartRef string, remote string) (string, error) { var out strings.Builder c := uploader.ChartUploader{ @@ -99,7 +98,7 @@ func (p *Push) Run(chartRef string, remote string, opts helmopts.HelmOptions) (s Pushers: pusher.All(p.Settings), Options: []pusher.Option{ pusher.WithTLSClientConfig(p.certFile, p.keyFile, p.caFile), - pusher.WithInsecureSkipTLSVerify(p.insecureSkipTLSverify), + pusher.WithInsecureSkipTLSVerify(p.insecureSkipTLSVerify), pusher.WithPlainHTTP(p.plainHTTP), }, } @@ -109,5 +108,5 @@ func (p *Push) Run(chartRef string, remote string, opts helmopts.HelmOptions) (s c.Options = append(c.Options, pusher.WithRegistryClient(p.cfg.RegistryClient)) } - return out.String(), c.UploadTo(chartRef, remote, opts) + return out.String(), c.UploadTo(chartRef, remote) } diff --git a/pkg/helm/pkg/action/push_test.go b/pkg/helm/pkg/action/push_test.go new file mode 100644 index 00000000..35c6f3ef --- /dev/null +++ b/pkg/helm/pkg/action/push_test.go @@ -0,0 +1,66 @@ +/* +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" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewPushWithPushConfig(t *testing.T) { + config := actionConfigFixture(t) + client := NewPushWithOpts(WithPushConfig(config)) + + assert.NotNil(t, client) + assert.Equal(t, config, client.cfg) +} + +func TestNewPushWithTLSClientConfig(t *testing.T) { + certFile := "certFile" + keyFile := "keyFile" + caFile := "caFile" + client := NewPushWithOpts(WithTLSClientConfig(certFile, keyFile, caFile)) + + assert.NotNil(t, client) + assert.Equal(t, certFile, client.certFile) + assert.Equal(t, keyFile, client.keyFile) + assert.Equal(t, caFile, client.caFile) +} + +func TestNewPushWithInsecureSkipTLSVerify(t *testing.T) { + client := NewPushWithOpts(WithInsecureSkipTLSVerify(true)) + + assert.NotNil(t, client) + assert.Equal(t, true, client.insecureSkipTLSVerify) +} + +func TestNewPushWithPlainHTTP(t *testing.T) { + client := NewPushWithOpts(WithPlainHTTP(true)) + + assert.NotNil(t, client) + assert.Equal(t, true, client.plainHTTP) +} + +func TestNewPushWithPushOptWriter(t *testing.T) { + buf := new(bytes.Buffer) + client := NewPushWithOpts(WithPushOptWriter(buf)) + + assert.NotNil(t, client) + assert.Equal(t, buf, client.out) +} diff --git a/pkg/helm/pkg/action/registry_login.go b/pkg/helm/pkg/action/registry_login.go index cc60eb7a..ef2535f2 100644 --- a/pkg/helm/pkg/action/registry_login.go +++ b/pkg/helm/pkg/action/registry_login.go @@ -24,11 +24,12 @@ import ( // RegistryLogin performs a registry login operation. type RegistryLogin struct { - cfg *Configuration - certFile string - keyFile string - caFile string - insecure bool + cfg *Configuration + certFile string + keyFile string + caFile string + insecure bool + plainHTTP bool } type RegistryLoginOpt func(*RegistryLogin) error @@ -41,7 +42,7 @@ func WithCertFile(certFile string) RegistryLoginOpt { } } -// WithKeyFile specifies whether to very certificates when communicating. +// WithInsecure specifies whether to verify certificates. func WithInsecure(insecure bool) RegistryLoginOpt { return func(r *RegistryLogin) error { r.insecure = insecure @@ -65,6 +66,14 @@ func WithCAFile(caFile string) RegistryLoginOpt { } } +// WithPlainHTTPLogin use http rather than https for login. +func WithPlainHTTPLogin(isPlain bool) RegistryLoginOpt { + return func(r *RegistryLogin) error { + r.plainHTTP = isPlain + return nil + } +} + // NewRegistryLogin creates a new RegistryLogin object with the given configuration. func NewRegistryLogin(cfg *Configuration) *RegistryLogin { return &RegistryLogin{ @@ -84,5 +93,7 @@ func (a *RegistryLogin) Run(_ io.Writer, hostname string, username string, passw hostname, registry.LoginOptBasicAuth(username, password), registry.LoginOptInsecure(a.insecure), - registry.LoginOptTLSClientConfig(a.certFile, a.keyFile, a.caFile)) + registry.LoginOptTLSClientConfig(a.certFile, a.keyFile, a.caFile), + registry.LoginOptPlainText(a.plainHTTP), + ) } diff --git a/pkg/helm/pkg/action/registry_login_test.go b/pkg/helm/pkg/action/registry_login_test.go new file mode 100644 index 00000000..de2450d9 --- /dev/null +++ b/pkg/helm/pkg/action/registry_login_test.go @@ -0,0 +1,84 @@ +/* +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 ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewRegistryLogin(t *testing.T) { + config := actionConfigFixture(t) + client := NewRegistryLogin(config) + + assert.NotNil(t, client) + assert.Equal(t, config, client.cfg) +} + +func TestWithCertFile(t *testing.T) { + config := actionConfigFixture(t) + client := NewRegistryLogin(config) + + certFile := "testdata/cert.pem" + opt := WithCertFile(certFile) + + assert.Nil(t, opt(client)) + assert.Equal(t, certFile, client.certFile) +} + +func TestWithInsecure(t *testing.T) { + config := actionConfigFixture(t) + client := NewRegistryLogin(config) + + opt := WithInsecure(true) + + assert.Nil(t, opt(client)) + assert.Equal(t, true, client.insecure) +} + +func TestWithKeyFile(t *testing.T) { + config := actionConfigFixture(t) + client := NewRegistryLogin(config) + + keyFile := "testdata/key.pem" + opt := WithKeyFile(keyFile) + + assert.Nil(t, opt(client)) + assert.Equal(t, keyFile, client.keyFile) +} + +func TestWithCAFile(t *testing.T) { + config := actionConfigFixture(t) + client := NewRegistryLogin(config) + + caFile := "testdata/ca.pem" + opt := WithCAFile(caFile) + + assert.Nil(t, opt(client)) + assert.Equal(t, caFile, client.caFile) +} + +func TestWithPlainHTTPLogin(t *testing.T) { + config := actionConfigFixture(t) + client := NewRegistryLogin(config) + + opt := WithPlainHTTPLogin(true) + + assert.Nil(t, opt(client)) + assert.Equal(t, true, client.plainHTTP) +} diff --git a/pkg/helm/pkg/action/registry_logout_test.go b/pkg/helm/pkg/action/registry_logout_test.go new file mode 100644 index 00000000..669d9c9b --- /dev/null +++ b/pkg/helm/pkg/action/registry_logout_test.go @@ -0,0 +1,31 @@ +/* +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 ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewRegistryLogout(t *testing.T) { + config := actionConfigFixture(t) + client := NewRegistryLogout(config) + + assert.NotNil(t, client) + assert.Equal(t, config, client.cfg) +} diff --git a/pkg/helm/pkg/action/show.go b/pkg/helm/pkg/action/show.go new file mode 100644 index 00000000..3a3b6da0 --- /dev/null +++ b/pkg/helm/pkg/action/show.go @@ -0,0 +1,157 @@ +/* +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" + "context" + "fmt" + "strings" + + "k8s.io/cli-runtime/pkg/printers" + "sigs.k8s.io/yaml" + + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/loader" + chartutil "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" + "github.com/werf/nelm/pkg/helm/pkg/registry" +) + +// ShowOutputFormat is the format of the output of `helm show` +type ShowOutputFormat string + +const ( + // ShowAll is the format which shows all the information of a chart + ShowAll ShowOutputFormat = "all" + // ShowChart is the format which only shows the chart's definition + ShowChart ShowOutputFormat = "chart" + // ShowValues is the format which only shows the chart's values + ShowValues ShowOutputFormat = "values" + // ShowReadme is the format which only shows the chart's README + ShowReadme ShowOutputFormat = "readme" + // ShowCRDs is the format which only shows the chart's CRDs + ShowCRDs ShowOutputFormat = "crds" +) + +var readmeFileNames = []string{"readme.md", "readme.txt", "readme"} + +func (o ShowOutputFormat) String() string { + return string(o) +} + +// Show is the action for checking a given release's information. +// +// It provides the implementation of 'helm show' and its respective subcommands. +type Show struct { + ChartPathOptions + Devel bool + OutputFormat ShowOutputFormat + JSONPathTemplate string + chart *chart.Chart // for testing +} + +// NewShow creates a new Show object with the given configuration. +func NewShow(output ShowOutputFormat, cfg *Configuration) *Show { + sh := &Show{ + OutputFormat: output, + } + sh.registryClient = cfg.RegistryClient + + return sh +} + +// SetRegistryClient sets the registry client to use when pulling a chart from a registry. +func (s *Show) SetRegistryClient(client *registry.Client) { + s.registryClient = client +} + +// Run executes 'helm show' against the given release. +func (s *Show) Run(chartpath string) (string, error) { + if s.chart == nil { + chrt, err := loader.Load(context.Background(), chartpath) + if err != nil { + return "", err + } + s.chart = chrt + } + cf, err := yaml.Marshal(s.chart.Metadata) + if err != nil { + return "", err + } + + var out strings.Builder + if s.OutputFormat == ShowChart || s.OutputFormat == ShowAll { + fmt.Fprintf(&out, "%s\n", cf) + } + + if (s.OutputFormat == ShowValues || s.OutputFormat == ShowAll) && s.chart.Values != nil { + if s.OutputFormat == ShowAll { + fmt.Fprintln(&out, "---") + } + if s.JSONPathTemplate != "" { + printer, err := printers.NewJSONPathPrinter(s.JSONPathTemplate) + if err != nil { + return "", fmt.Errorf("error parsing jsonpath %s: %w", s.JSONPathTemplate, err) + } + printer.Execute(&out, s.chart.Values) + } else { + for _, f := range s.chart.Raw { + if f.Name == chartutil.ValuesfileName { + fmt.Fprintln(&out, string(f.Data)) + } + } + } + } + + if s.OutputFormat == ShowReadme || s.OutputFormat == ShowAll { + readme := findReadme(s.chart.Files) + if readme != nil { + if s.OutputFormat == ShowAll { + fmt.Fprintln(&out, "---") + } + fmt.Fprintf(&out, "%s\n", readme.Data) + } + } + + if s.OutputFormat == ShowCRDs || s.OutputFormat == ShowAll { + crds := s.chart.CRDObjects() + if len(crds) > 0 { + for _, crd := range crds { + if !bytes.HasPrefix(crd.File.Data, []byte("---")) { + fmt.Fprintln(&out, "---") + } + fmt.Fprintf(&out, "%s\n", string(crd.File.Data)) + } + } + } + return out.String(), nil +} + +func findReadme(files []*common.File) (file *common.File) { + for _, file := range files { + for _, n := range readmeFileNames { + if file == nil { + continue + } + if strings.EqualFold(file.Name, n) { + return file + } + } + } + return nil +} diff --git a/pkg/helm/pkg/action/show_test.go b/pkg/helm/pkg/action/show_test.go new file mode 100644 index 00000000..eadf3ce5 --- /dev/null +++ b/pkg/helm/pkg/action/show_test.go @@ -0,0 +1,182 @@ +/* +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 ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/registry" +) + +func TestShow(t *testing.T) { + config := actionConfigFixture(t) + client := NewShow(ShowAll, config) + modTime := time.Now() + client.chart = &chart.Chart{ + Metadata: &chart.Metadata{Name: "alpine"}, + Files: []*common.File{ + {Name: "README.md", ModTime: modTime, Data: []byte("README\n")}, + {Name: "crds/ignoreme.txt", ModTime: modTime, Data: []byte("error")}, + {Name: "crds/foo.yaml", ModTime: modTime, Data: []byte("---\nfoo\n")}, + {Name: "crds/bar.json", ModTime: modTime, Data: []byte("---\nbar\n")}, + {Name: "crds/baz.yaml", ModTime: modTime, Data: []byte("baz\n")}, + }, + Raw: []*common.File{ + {Name: "values.yaml", ModTime: modTime, Data: []byte("VALUES\n")}, + }, + Values: map[string]interface{}{}, + } + + output, err := client.Run("") + if err != nil { + t.Fatal(err) + } + + expect := `name: alpine + +--- +VALUES + +--- +README + +--- +foo + +--- +bar + +--- +baz + +` + if output != expect { + t.Errorf("Expected\n%q\nGot\n%q\n", expect, output) + } +} + +func TestShowNoValues(t *testing.T) { + config := actionConfigFixture(t) + client := NewShow(ShowAll, config) + client.chart = new(chart.Chart) + + // Regression tests for missing values. See issue #1024. + client.OutputFormat = ShowValues + output, err := client.Run("") + if err != nil { + t.Fatal(err) + } + + if len(output) != 0 { + t.Errorf("expected empty values buffer, got %s", output) + } +} + +func TestShowValuesByJsonPathFormat(t *testing.T) { + config := actionConfigFixture(t) + client := NewShow(ShowValues, config) + client.JSONPathTemplate = "{$.nestedKey.simpleKey}" + client.chart = buildChart(withSampleValues()) + output, err := client.Run("") + if err != nil { + t.Fatal(err) + } + expect := "simpleValue" + if output != expect { + t.Errorf("Expected\n%q\nGot\n%q\n", expect, output) + } +} + +func TestShowCRDs(t *testing.T) { + config := actionConfigFixture(t) + client := NewShow(ShowCRDs, config) + modTime := time.Now() + client.chart = &chart.Chart{ + Metadata: &chart.Metadata{Name: "alpine"}, + Files: []*common.File{ + {Name: "crds/ignoreme.txt", ModTime: modTime, Data: []byte("error")}, + {Name: "crds/foo.yaml", ModTime: modTime, Data: []byte("---\nfoo\n")}, + {Name: "crds/bar.json", ModTime: modTime, Data: []byte("---\nbar\n")}, + {Name: "crds/baz.yaml", ModTime: modTime, Data: []byte("baz\n")}, + }, + } + + output, err := client.Run("") + if err != nil { + t.Fatal(err) + } + + expect := `--- +foo + +--- +bar + +--- +baz + +` + if output != expect { + t.Errorf("Expected\n%q\nGot\n%q\n", expect, output) + } +} + +func TestShowNoReadme(t *testing.T) { + config := actionConfigFixture(t) + client := NewShow(ShowAll, config) + modTime := time.Now() + client.chart = &chart.Chart{ + Metadata: &chart.Metadata{Name: "alpine"}, + Files: []*common.File{ + {Name: "crds/ignoreme.txt", ModTime: modTime, Data: []byte("error")}, + {Name: "crds/foo.yaml", ModTime: modTime, Data: []byte("---\nfoo\n")}, + {Name: "crds/bar.json", ModTime: modTime, Data: []byte("---\nbar\n")}, + }, + } + + output, err := client.Run("") + if err != nil { + t.Fatal(err) + } + + expect := `name: alpine + +--- +foo + +--- +bar + +` + if output != expect { + t.Errorf("Expected\n%q\nGot\n%q\n", expect, output) + } +} + +func TestShowSetRegistryClient(t *testing.T) { + config := actionConfigFixture(t) + client := NewShow(ShowAll, config) + + registryClient := ®istry.Client{} + client.SetRegistryClient(registryClient) + assert.Equal(t, registryClient, client.registryClient) +} diff --git a/pkg/helm/pkg/action/status.go b/pkg/helm/pkg/action/status.go new file mode 100644 index 00000000..5bb1312a --- /dev/null +++ b/pkg/helm/pkg/action/status.go @@ -0,0 +1,83 @@ +/* +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" + + "github.com/werf/nelm/pkg/helm/pkg/kube" + ri "github.com/werf/nelm/pkg/helm/pkg/release" +) + +// Status is the action for checking the deployment status of releases. +// +// It provides the implementation of 'helm status'. +type Status struct { + cfg *Configuration + + Version int + + // ShowResourcesTable is used with ShowResources. When true this will cause + // the resulting objects to be retrieved as a kind=table. + ShowResourcesTable bool +} + +// NewStatus creates a new Status object with the given configuration. +func NewStatus(cfg *Configuration) *Status { + return &Status{ + cfg: cfg, + } +} + +// Run executes 'helm status' against the given release. +func (s *Status) Run(name string) (ri.Releaser, error) { + if err := s.cfg.KubeClient.IsReachable(); err != nil { + return nil, err + } + + reli, err := s.cfg.releaseContent(name, s.Version) + if err != nil { + return nil, err + } + + rel, err := releaserToV1Release(reli) + if err != nil { + return nil, err + } + + var resources kube.ResourceList + if s.ShowResourcesTable { + resources, err = s.cfg.KubeClient.BuildTable(bytes.NewBufferString(rel.Manifest), false) + if err != nil { + return nil, err + } + } else { + resources, err = s.cfg.KubeClient.Build(bytes.NewBufferString(rel.Manifest), false) + if err != nil { + return nil, err + } + } + + resp, err := s.cfg.KubeClient.Get(resources, true) + if err != nil { + return nil, err + } + + rel.Info.Resources = resp + + return rel, nil +} diff --git a/pkg/helm/pkg/action/status_test.go b/pkg/helm/pkg/action/status_test.go new file mode 100644 index 00000000..b7ef5ac8 --- /dev/null +++ b/pkg/helm/pkg/action/status_test.go @@ -0,0 +1,143 @@ +/* +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 ( + "errors" + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + kubefake "github.com/werf/nelm/pkg/helm/pkg/kube/fake" + rcommon "github.com/werf/nelm/pkg/helm/pkg/release/common" + release "github.com/werf/nelm/pkg/helm/pkg/release/v1" +) + +func TestNewStatus(t *testing.T) { + config := actionConfigFixture(t) + client := NewStatus(config) + + assert.NotNil(t, client) + assert.Equal(t, config, client.cfg) + assert.Equal(t, 0, client.Version) +} + +func TestStatusRun(t *testing.T) { + config := actionConfigFixture(t) + failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil} + failingKubeClient.BuildDummy = true + config.KubeClient = &failingKubeClient + client := NewStatus(config) + client.ShowResourcesTable = true + + releaseName := "test-release" + require.NoError(t, configureReleaseContent(config, releaseName)) + releaser, err := client.Run(releaseName) + require.NoError(t, err) + + result, err := releaserToV1Release(releaser) + require.NoError(t, err) + assert.Equal(t, releaseName, result.Name) + assert.Equal(t, 1, result.Version) +} + +func TestStatusRun_KubeClientNotReachable(t *testing.T) { + config := actionConfigFixture(t) + failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil} + failingKubeClient.ConnectionError = errors.New("connection refused") + config.KubeClient = &failingKubeClient + + client := NewStatus(config) + + result, err := client.Run("") + assert.Nil(t, result) + assert.Error(t, err) +} + +func TestStatusRun_KubeClientBuildTableError(t *testing.T) { + config := actionConfigFixture(t) + failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil} + failingKubeClient.BuildTableError = errors.New("build table error") + config.KubeClient = &failingKubeClient + + releaseName := "test-release" + require.NoError(t, configureReleaseContent(config, releaseName)) + + client := NewStatus(config) + client.ShowResourcesTable = true + + result, err := client.Run(releaseName) + + assert.Nil(t, result) + assert.ErrorContains(t, err, "build table error") +} + +func TestStatusRun_KubeClientBuildError(t *testing.T) { + config := actionConfigFixture(t) + failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil} + failingKubeClient.BuildError = errors.New("build error") + config.KubeClient = &failingKubeClient + + releaseName := "test-release" + require.NoError(t, configureReleaseContent(config, releaseName)) + + client := NewStatus(config) + client.ShowResourcesTable = false + + result, err := client.Run(releaseName) + assert.Nil(t, result) + assert.ErrorContains(t, err, "build error") +} + +func TestStatusRun_KubeClientGetError(t *testing.T) { + config := actionConfigFixture(t) + failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil} + failingKubeClient.BuildError = errors.New("get error") + config.KubeClient = &failingKubeClient + + releaseName := "test-release" + require.NoError(t, configureReleaseContent(config, releaseName)) + client := NewStatus(config) + + result, err := client.Run(releaseName) + assert.Nil(t, result) + assert.ErrorContains(t, err, "get error") +} + +func configureReleaseContent(cfg *Configuration, releaseName string) error { + rel := &release.Release{ + Name: releaseName, + Info: &release.Info{ + Status: rcommon.StatusDeployed, + }, + Manifest: testManifest, + Version: 1, + Namespace: "default", + } + + return cfg.Releases.Create(rel) +} + +const testManifest = ` +apiVersion: v1 +kind: Pod +metadata: + namespace: default + name: test-application +` diff --git a/pkg/helm/pkg/action/testdata/charts/chart-missing-deps/requirements.yaml b/pkg/helm/pkg/action/testdata/charts/chart-missing-deps/requirements.yaml index be739891..fef7d0b7 100755 --- a/pkg/helm/pkg/action/testdata/charts/chart-missing-deps/requirements.yaml +++ b/pkg/helm/pkg/action/testdata/charts/chart-missing-deps/requirements.yaml @@ -1,7 +1,7 @@ dependencies: - - name: mariadb - version: 4.x.x - repository: https://charts.helm.sh/stable/ - condition: mariadb.enabled - tags: - - wordpress-database +- name: mariadb + version: 4.x.x + repository: https://charts.helm.sh/stable/ + condition: mariadb.enabled + tags: + - wordpress-database diff --git a/pkg/helm/pkg/action/testdata/charts/chart-with-compressed-dependencies/requirements.yaml b/pkg/helm/pkg/action/testdata/charts/chart-with-compressed-dependencies/requirements.yaml index be739891..fef7d0b7 100755 --- a/pkg/helm/pkg/action/testdata/charts/chart-with-compressed-dependencies/requirements.yaml +++ b/pkg/helm/pkg/action/testdata/charts/chart-with-compressed-dependencies/requirements.yaml @@ -1,7 +1,7 @@ dependencies: - - name: mariadb - version: 4.x.x - repository: https://charts.helm.sh/stable/ - condition: mariadb.enabled - tags: - - wordpress-database +- name: mariadb + version: 4.x.x + repository: https://charts.helm.sh/stable/ + condition: mariadb.enabled + tags: + - wordpress-database diff --git a/pkg/helm/pkg/action/testdata/charts/chart-with-schema-negative/Chart.yaml b/pkg/helm/pkg/action/testdata/charts/chart-with-schema-negative/Chart.yaml index b65566e5..395d24f6 100644 --- a/pkg/helm/pkg/action/testdata/charts/chart-with-schema-negative/Chart.yaml +++ b/pkg/helm/pkg/action/testdata/charts/chart-with-schema-negative/Chart.yaml @@ -3,5 +3,5 @@ description: Empty testing chart home: https://k8s.io/helm name: empty sources: - - https://github.com/kubernetes/helm +- https://github.com/kubernetes/helm version: 0.1.0 diff --git a/pkg/helm/pkg/action/testdata/charts/chart-with-schema/Chart.yaml b/pkg/helm/pkg/action/testdata/charts/chart-with-schema/Chart.yaml index b65566e5..395d24f6 100644 --- a/pkg/helm/pkg/action/testdata/charts/chart-with-schema/Chart.yaml +++ b/pkg/helm/pkg/action/testdata/charts/chart-with-schema/Chart.yaml @@ -3,5 +3,5 @@ description: Empty testing chart home: https://k8s.io/helm name: empty sources: - - https://github.com/kubernetes/helm +- https://github.com/kubernetes/helm version: 0.1.0 diff --git a/pkg/helm/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/Chart.yaml b/pkg/helm/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/Chart.yaml index a4762cf1..4d8569c8 100755 --- a/pkg/helm/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/Chart.yaml +++ b/pkg/helm/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/Chart.yaml @@ -4,17 +4,17 @@ engine: gotpl home: http://www.wordpress.com/ icon: https://bitnami.com/assets/stacks/wordpress/img/wordpress-stack-220x234.png keywords: - - wordpress - - cms - - blog - - http - - web - - application - - php +- wordpress +- cms +- blog +- http +- web +- application +- php maintainers: - - email: containers@bitnami.com - name: bitnami-bot +- email: containers@bitnami.com + name: bitnami-bot name: chart-with-uncompressed-dependencies sources: - - https://github.com/bitnami/bitnami-docker-wordpress +- https://github.com/bitnami/bitnami-docker-wordpress version: 2.1.8 diff --git a/pkg/helm/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/Chart.yaml b/pkg/helm/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/Chart.yaml index 6209ec47..cefc1583 100755 --- a/pkg/helm/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/Chart.yaml +++ b/pkg/helm/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/Chart.yaml @@ -6,16 +6,16 @@ engine: gotpl home: https://mariadb.org icon: https://bitnami.com/assets/stacks/mariadb/img/mariadb-stack-220x234.png keywords: - - mariadb - - mysql - - database - - sql - - prometheus +- mariadb +- mysql +- database +- sql +- prometheus maintainers: - - email: containers@bitnami.com - name: bitnami-bot +- email: containers@bitnami.com + name: bitnami-bot name: mariadb sources: - - https://github.com/bitnami/bitnami-docker-mariadb - - https://github.com/prometheus/mysqld_exporter +- https://github.com/bitnami/bitnami-docker-mariadb +- https://github.com/prometheus/mysqld_exporter version: 4.3.1 diff --git a/pkg/helm/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/values.yaml b/pkg/helm/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/values.yaml index b73bf998..ce2414e9 100755 --- a/pkg/helm/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/values.yaml +++ b/pkg/helm/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/charts/mariadb/values.yaml @@ -91,7 +91,7 @@ master: ## Persistent Volume Access Mode ## accessModes: - - ReadWriteOnce + - ReadWriteOnce ## Persistent Volume size ## size: 8Gi @@ -160,7 +160,7 @@ slave: # storageClass: "-" annotations: accessModes: - - ReadWriteOnce + - ReadWriteOnce ## Persistent Volume size ## size: 8Gi diff --git a/pkg/helm/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/requirements.yaml b/pkg/helm/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/requirements.yaml index be739891..fef7d0b7 100755 --- a/pkg/helm/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/requirements.yaml +++ b/pkg/helm/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/requirements.yaml @@ -1,7 +1,7 @@ dependencies: - - name: mariadb - version: 4.x.x - repository: https://charts.helm.sh/stable/ - condition: mariadb.enabled - tags: - - wordpress-database +- name: mariadb + version: 4.x.x + repository: https://charts.helm.sh/stable/ + condition: mariadb.enabled + tags: + - wordpress-database diff --git a/pkg/helm/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/values.yaml b/pkg/helm/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/values.yaml index bd9cd690..98c70aad 100755 --- a/pkg/helm/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/values.yaml +++ b/pkg/helm/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/values.yaml @@ -70,11 +70,11 @@ allowEmptyPassword: yes replicaCount: 1 externalDatabase: - ## All of these values are only used when mariadb.enabled is set to false +## All of these values are only used when mariadb.enabled is set to false ## Database host host: localhost - ## non-root Username for Wordpress Database + ## non-root Username for WordPress Database user: bn_wordpress ## Database password @@ -102,7 +102,7 @@ mariadb: db: name: bitnami_wordpress user: bn_wordpress - ## If the password is not specified, mariadb will generates a random password + ## If the password is not specified, mariadb will generate a random password ## # password: @@ -165,7 +165,7 @@ readinessProbe: successThreshold: 1 ## Configure the ingress resource that allows you to access the -## Wordpress installation. Set up the URL +## WordPress installation. Set up the URL ## ref: http://kubernetes.io/docs/user-guide/ingress/ ## ingress: @@ -175,26 +175,26 @@ ingress: ## The list of hostnames to be covered with this ingress record. ## Most likely this will be just one host, but in the event more hosts are needed, this is an array hosts: - - name: wordpress.local + - name: wordpress.local - ## Set this to true in order to enable TLS on the ingress record - ## A side effect of this will be that the backend wordpress service will be connected at port 443 - tls: false + ## Set this to true in order to enable TLS on the ingress record + ## A side effect of this will be that the backend wordpress service will be connected at port 443 + tls: false - ## If TLS is set to true, you must declare what secret will store the key/certificate for TLS - tlsSecret: wordpress.local-tls + ## If TLS is set to true, you must declare what secret will store the key/certificate for TLS + tlsSecret: wordpress.local-tls - ## Ingress annotations done as key:value pairs - ## If you're using kube-lego, you will want to add: - ## kubernetes.io/tls-acme: true - ## - ## For a full list of possible ingress annotations, please see - ## ref: https://github.com/kubernetes/ingress-nginx/blob/master/docs/annotations.md - ## - ## If tls is set to true, annotation ingress.kubernetes.io/secure-backends: "true" will automatically be set - annotations: - # kubernetes.io/ingress.class: nginx - # kubernetes.io/tls-acme: true + ## Ingress annotations done as key:value pairs + ## If you're using kube-lego, you will want to add: + ## kubernetes.io/tls-acme: true + ## + ## For a full list of possible ingress annotations, please see + ## ref: https://github.com/kubernetes/ingress-nginx/blob/master/docs/annotations.md + ## + ## If tls is set to true, annotation ingress.kubernetes.io/secure-backends: "true" will automatically be set + annotations: + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: true secrets: ## If you're providing your own certificates, please use this to add the certificates as secrets diff --git a/pkg/helm/pkg/action/testdata/charts/decompressedchart/values.yaml b/pkg/helm/pkg/action/testdata/charts/decompressedchart/values.yaml index 3be6084f..a940d1fd 100644 --- a/pkg/helm/pkg/action/testdata/charts/decompressedchart/values.yaml +++ b/pkg/helm/pkg/action/testdata/charts/decompressedchart/values.yaml @@ -1,4 +1,4 @@ # Default values for decompressedchart. # This is a YAML-formatted file. # Declare name/value pairs to be passed into your templates. -name: my-decompressed-chart + name: my-decompressed-chart diff --git a/pkg/helm/pkg/action/testdata/charts/multiplecharts-lint-chart-1/Chart.yaml b/pkg/helm/pkg/action/testdata/charts/multiplecharts-lint-chart-1/Chart.yaml index 61a2a195..e33c97e8 100644 --- a/pkg/helm/pkg/action/testdata/charts/multiplecharts-lint-chart-1/Chart.yaml +++ b/pkg/helm/pkg/action/testdata/charts/multiplecharts-lint-chart-1/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 name: multiplecharts-lint-chart-1 version: "1" -icon: "" +icon: "" \ No newline at end of file diff --git a/pkg/helm/pkg/action/testdata/charts/multiplecharts-lint-chart-1/values.yaml b/pkg/helm/pkg/action/testdata/charts/multiplecharts-lint-chart-1/values.yaml index 52893eb3..aafb09e4 100644 --- a/pkg/helm/pkg/action/testdata/charts/multiplecharts-lint-chart-1/values.yaml +++ b/pkg/helm/pkg/action/testdata/charts/multiplecharts-lint-chart-1/values.yaml @@ -1 +1 @@ -config: "Test" +config: "Test" \ No newline at end of file diff --git a/pkg/helm/pkg/action/testdata/charts/multiplecharts-lint-chart-2/Chart.yaml b/pkg/helm/pkg/action/testdata/charts/multiplecharts-lint-chart-2/Chart.yaml index 7f348df6..b27de275 100644 --- a/pkg/helm/pkg/action/testdata/charts/multiplecharts-lint-chart-2/Chart.yaml +++ b/pkg/helm/pkg/action/testdata/charts/multiplecharts-lint-chart-2/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 name: multiplecharts-lint-chart-2 version: "1" -icon: "" +icon: "" \ No newline at end of file diff --git a/pkg/helm/pkg/action/testdata/charts/multiplecharts-lint-chart-2/values.yaml b/pkg/helm/pkg/action/testdata/charts/multiplecharts-lint-chart-2/values.yaml index 20b32088..9139f486 100644 --- a/pkg/helm/pkg/action/testdata/charts/multiplecharts-lint-chart-2/values.yaml +++ b/pkg/helm/pkg/action/testdata/charts/multiplecharts-lint-chart-2/values.yaml @@ -1,2 +1,2 @@ config: - test: "Test" + test: "Test" \ No newline at end of file diff --git a/pkg/helm/pkg/action/uninstall.go b/pkg/helm/pkg/action/uninstall.go new file mode 100644 index 00000000..1cf68396 --- /dev/null +++ b/pkg/helm/pkg/action/uninstall.go @@ -0,0 +1,45 @@ +/* +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 ( + "strings" +) + +type joinedErrors struct { + errs []error + sep string +} + +func joinErrors(errs []error, sep string) error { + return &joinedErrors{ + errs: errs, + sep: sep, + } +} + +func (e *joinedErrors) Error() string { + errs := make([]string, 0, len(e.errs)) + for _, err := range e.errs { + errs = append(errs, err.Error()) + } + return strings.Join(errs, e.sep) +} + +func (e *joinedErrors) Unwrap() []error { + return e.errs +} diff --git a/pkg/helm/pkg/action/validate.go b/pkg/helm/pkg/action/validate.go deleted file mode 100644 index 945691fa..00000000 --- a/pkg/helm/pkg/action/validate.go +++ /dev/null @@ -1,60 +0,0 @@ -/* -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 ( - "fmt" - - "github.com/pkg/errors" - "github.com/werf/nelm/pkg/helm/pkg/kube" - "github.com/werf/nelm/pkg/helm/pkg/releaseutil" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/cli-runtime/pkg/resource" -) - -func existingResourceConflict(resources kube.ResourceList, releaseName, releaseNamespace string) (kube.ResourceList, error) { - var requireUpdate kube.ResourceList - - err := resources.Visit(func(info *resource.Info, err error) error { - if err != nil { - return err - } - - helper := resource.NewHelper(info.Client, info.Mapping) - existing, err := helper.Get(info.Namespace, info.Name) - if err != nil { - if apierrors.IsNotFound(err) { - return nil - } - return errors.Wrapf(err, "could not get information about the resource %s", releaseutil.ResourceString(info)) - } - - // Allow adoption of the resource if it is managed by Helm and is annotated with correct release name and namespace. - if err := releaseutil.CheckOwnership(existing, releaseName, releaseNamespace); err != nil { - return fmt.Errorf("%s exists and cannot be imported into the current release: %s", releaseutil.ResourceString(info), err) - } - - requireUpdate.Append(info) - return nil - }) - - return requireUpdate, err -} - -func ExistingResourceConflict(resources kube.ResourceList, releaseName, releaseNamespace string) (kube.ResourceList, error) { - return existingResourceConflict(resources, releaseName, releaseNamespace) -} diff --git a/pkg/helm/pkg/action/verify.go b/pkg/helm/pkg/action/verify.go index 18301939..04454a03 100644 --- a/pkg/helm/pkg/action/verify.go +++ b/pkg/helm/pkg/action/verify.go @@ -28,7 +28,6 @@ import ( // It provides the implementation of 'helm verify'. type Verify struct { Keyring string - Out string } // NewVerify creates a new Verify object with the given configuration. @@ -37,23 +36,18 @@ func NewVerify() *Verify { } // Run executes 'helm verify'. -func (v *Verify) Run(chartfile string) error { +func (v *Verify) Run(chartfile string) (string, error) { var out strings.Builder - p, err := downloader.VerifyChart(chartfile, v.Keyring) + p, err := downloader.VerifyChart(chartfile, chartfile+".prov", v.Keyring) if err != nil { - return err + return "", err } for name := range p.SignedBy.Identities { - fmt.Fprintf(&out, "Signed by: %v\n", name) + _, _ = fmt.Fprintf(&out, "Signed by: %v\n", name) } - fmt.Fprintf(&out, "Using Key With Fingerprint: %X\n", p.SignedBy.PrimaryKey.Fingerprint) - fmt.Fprintf(&out, "Chart Hash Verified: %s\n", p.FileHash) + _, _ = fmt.Fprintf(&out, "Using Key With Fingerprint: %X\n", p.SignedBy.PrimaryKey.Fingerprint) + _, _ = fmt.Fprintf(&out, "Chart Hash Verified: %s\n", p.FileHash) - // TODO(mattfarina): The output is set as a property rather than returned - // to maintain the Go API. In Helm v4 this function should return the out - // and the property on the struct can be removed. - v.Out = out.String() - - return nil + return out.String(), err } diff --git a/pkg/helm/pkg/action/verify_test.go b/pkg/helm/pkg/action/verify_test.go new file mode 100644 index 00000000..343dacae --- /dev/null +++ b/pkg/helm/pkg/action/verify_test.go @@ -0,0 +1,48 @@ +/* +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 ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewVerify(t *testing.T) { + client := NewVerify() + + assert.NotNil(t, client) +} + +func TestVerifyRun(t *testing.T) { + client := NewVerify() + + client.Keyring = "../downloader/testdata/helm-test-key.pub" + output, err := client.Run("../downloader/testdata/signtest-0.1.0.tgz") + assert.Contains(t, output, "Signed by:") + assert.Contains(t, output, "Using Key With Fingerprint:") + assert.Contains(t, output, "Chart Hash Verified:") + require.NoError(t, err) +} + +func TestVerifyRun_DownloadError(t *testing.T) { + client := NewVerify() + output, err := client.Run("invalid-chart-path") + require.Error(t, err) + assert.Empty(t, output) +} diff --git a/pkg/helm/pkg/chart/common.go b/pkg/helm/pkg/chart/common.go new file mode 100644 index 00000000..455a9066 --- /dev/null +++ b/pkg/helm/pkg/chart/common.go @@ -0,0 +1,243 @@ +/* +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 chart + +import ( + "errors" + "fmt" + "log/slog" + "reflect" + "strings" + + v3chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" + common "github.com/werf/nelm/pkg/helm/pkg/chart/common" + v2chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" +) + +var NewAccessor func(chrt Charter) (Accessor, error) = NewDefaultAccessor //nolint:revive + +func NewDefaultAccessor(chrt Charter) (Accessor, error) { + switch v := chrt.(type) { + case v2chart.Chart: + return &v2Accessor{&v}, nil + case *v2chart.Chart: + return &v2Accessor{v}, nil + case v3chart.Chart: + return &v3Accessor{&v}, nil + case *v3chart.Chart: + return &v3Accessor{v}, nil + default: + return nil, errors.New("unsupported chart type") + } +} + +type v2Accessor struct { + chrt *v2chart.Chart +} + +func (r *v2Accessor) Name() string { + return r.chrt.Metadata.Name +} + +func (r *v2Accessor) IsRoot() bool { + return r.chrt.IsRoot() +} + +func (r *v2Accessor) MetadataAsMap() map[string]interface{} { + var ret map[string]interface{} + if r.chrt.Metadata == nil { + return ret + } + + ret, err := structToMap(r.chrt.Metadata) + if err != nil { + slog.Error("error converting metadata to map", "error", err) + } + return ret +} + +func (r *v2Accessor) Files() []*common.File { + return r.chrt.Files +} + +func (r *v2Accessor) Templates() []*common.File { + return r.chrt.Templates +} + +func (r *v2Accessor) ChartFullPath() string { + return r.chrt.ChartFullPath() +} + +func (r *v2Accessor) IsLibraryChart() bool { + return strings.EqualFold(r.chrt.Metadata.Type, "library") +} + +func (r *v2Accessor) Dependencies() []Charter { + var deps = make([]Charter, len(r.chrt.Dependencies())) + for i, c := range r.chrt.Dependencies() { + deps[i] = c + } + return deps +} + +func (r *v2Accessor) MetaDependencies() []Dependency { + var deps = make([]Dependency, len(r.chrt.Metadata.Dependencies)) + for i, c := range r.chrt.Metadata.Dependencies { + deps[i] = c + } + return deps +} + +func (r *v2Accessor) Values() map[string]interface{} { + return r.chrt.Values +} + +func (r *v2Accessor) Schema() []byte { + return r.chrt.Schema +} + +func (r *v2Accessor) Deprecated() bool { + return r.chrt.Metadata.Deprecated +} + +type v3Accessor struct { + chrt *v3chart.Chart +} + +func (r *v3Accessor) Name() string { + return r.chrt.Metadata.Name +} + +func (r *v3Accessor) IsRoot() bool { + return r.chrt.IsRoot() +} + +func (r *v3Accessor) MetadataAsMap() map[string]interface{} { + var ret map[string]interface{} + if r.chrt.Metadata == nil { + return ret + } + + ret, err := structToMap(r.chrt.Metadata) + if err != nil { + slog.Error("error converting metadata to map", "error", err) + } + return ret +} + +func (r *v3Accessor) Files() []*common.File { + return r.chrt.Files +} + +func (r *v3Accessor) Templates() []*common.File { + return r.chrt.Templates +} + +func (r *v3Accessor) ChartFullPath() string { + return r.chrt.ChartFullPath() +} + +func (r *v3Accessor) IsLibraryChart() bool { + return strings.EqualFold(r.chrt.Metadata.Type, "library") +} + +func (r *v3Accessor) Dependencies() []Charter { + var deps = make([]Charter, len(r.chrt.Dependencies())) + for i, c := range r.chrt.Dependencies() { + deps[i] = c + } + return deps +} + +func (r *v3Accessor) MetaDependencies() []Dependency { + var deps = make([]Dependency, len(r.chrt.Dependencies())) + for i, c := range r.chrt.Metadata.Dependencies { + deps[i] = c + } + return deps +} + +func (r *v3Accessor) Values() map[string]interface{} { + return r.chrt.Values +} + +func (r *v3Accessor) Schema() []byte { + return r.chrt.Schema +} + +func (r *v3Accessor) Deprecated() bool { + return r.chrt.Metadata.Deprecated +} + +func structToMap(obj interface{}) (map[string]interface{}, error) { + objValue := reflect.ValueOf(obj) + + // If the value is a pointer, dereference it + if objValue.Kind() == reflect.Pointer { + objValue = objValue.Elem() + } + + // Check if the input is a struct + if objValue.Kind() != reflect.Struct { + return nil, fmt.Errorf("input must be a struct or a pointer to a struct") + } + + result := make(map[string]interface{}) + objType := objValue.Type() + + for i := 0; i < objValue.NumField(); i++ { + field := objType.Field(i) + value := objValue.Field(i) + + switch value.Kind() { + case reflect.Struct: + nestedMap, err := structToMap(value.Interface()) + if err != nil { + return nil, err + } + result[field.Name] = nestedMap + case reflect.Pointer: + // Recurse for pointers by dereferencing + if value.IsNil() { + result[field.Name] = nil + } else { + nestedMap, err := structToMap(value.Interface()) + if err != nil { + return nil, err + } + result[field.Name] = nestedMap + } + case reflect.Slice: + sliceOfMaps := make([]interface{}, value.Len()) + for j := 0; j < value.Len(); j++ { + sliceElement := value.Index(j) + if sliceElement.Kind() == reflect.Struct || sliceElement.Kind() == reflect.Pointer { + nestedMap, err := structToMap(sliceElement.Interface()) + if err != nil { + return nil, err + } + sliceOfMaps[j] = nestedMap + } else { + sliceOfMaps[j] = sliceElement.Interface() + } + } + result[field.Name] = sliceOfMaps + default: + result[field.Name] = value.Interface() + } + } + return result, nil +} diff --git a/pkg/helm/pkg/chart/common/capabilities.go b/pkg/helm/pkg/chart/common/capabilities.go new file mode 100644 index 00000000..375da741 --- /dev/null +++ b/pkg/helm/pkg/chart/common/capabilities.go @@ -0,0 +1,182 @@ +/* +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 common + +import ( + "fmt" + "slices" + "strconv" + "strings" + "testing" + + "github.com/Masterminds/semver/v3" + "k8s.io/client-go/kubernetes/scheme" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + k8sversion "k8s.io/apimachinery/pkg/util/version" + + helmversion "github.com/werf/nelm/pkg/helm/intern/version" +) + +const ( + kubeVersionMajorTesting = 1 + kubeVersionMinorTesting = 20 +) + +var ( + // DefaultVersionSet is the default version set, which includes only Core V1 ("v1"). + DefaultVersionSet = allKnownVersions() + + DefaultCapabilities = func() *Capabilities { + caps, err := makeDefaultCapabilities() + if err != nil { + panic(fmt.Sprintf("failed to create default capabilities: %v", err)) + } + return caps + + }() +) + +// Capabilities describes the capabilities of the Kubernetes cluster. +type Capabilities struct { + // KubeVersion is the Kubernetes version. + KubeVersion KubeVersion + // APIVersions are supported Kubernetes API versions. + APIVersions VersionSet + // HelmVersion is the build information for this helm version + HelmVersion helmversion.BuildInfo +} + +func (capabilities *Capabilities) Copy() *Capabilities { + return &Capabilities{ + KubeVersion: capabilities.KubeVersion, + APIVersions: capabilities.APIVersions, + HelmVersion: capabilities.HelmVersion, + } +} + +// KubeVersion is the Kubernetes version. +type KubeVersion struct { + Version string // Full version (e.g., v1.33.4-gke.1245000) + normalizedVersion string // Normalized for constraint checking (e.g., v1.33.4) + Major string // Kubernetes major version + Minor string // Kubernetes minor version +} + +// String implements fmt.Stringer. +// Returns the normalized version used for constraint checking. +func (kv *KubeVersion) String() string { + if kv.normalizedVersion != "" { + return kv.normalizedVersion + } + return kv.Version +} + +// GitVersion returns the full Kubernetes version string. +// +// Deprecated: use KubeVersion.Version. +func (kv *KubeVersion) GitVersion() string { return kv.Version } + +// ParseKubeVersion parses kubernetes version from string +func ParseKubeVersion(version string) (*KubeVersion, error) { + // Based on the original k8s version parser. + // https://github.com/kubernetes/kubernetes/blob/b266ac2c3e42c2c4843f81e20213d2b2f43e450a/staging/src/k8s.io/apimachinery/pkg/util/version/version.go#L137 + sv, err := k8sversion.ParseGeneric(version) + if err != nil { + return nil, err + } + + // Preserve original input (e.g., v1.33.4-gke.1245000) + gitVersion := version + if !strings.HasPrefix(version, "v") { + gitVersion = "v" + version + } + + // Normalize for constraint checking (strips all suffixes) + normalizedVer := "v" + sv.String() + + return &KubeVersion{ + Version: gitVersion, + normalizedVersion: normalizedVer, + Major: strconv.FormatUint(uint64(sv.Major()), 10), + Minor: strconv.FormatUint(uint64(sv.Minor()), 10), + }, nil +} + +// VersionSet is a set of Kubernetes API versions. +type VersionSet []string + +// Has returns true if the version string is in the set. +// +// vs.Has("apps/v1") +func (v VersionSet) Has(apiVersion string) bool { + return slices.Contains(v, apiVersion) +} + +func allKnownVersions() VersionSet { + // We should register the built in extension APIs as well so CRDs are + // supported in the default version set. This has caused problems with `helm + // template` in the past, so let's be safe + apiextensionsv1beta1.AddToScheme(scheme.Scheme) + apiextensionsv1.AddToScheme(scheme.Scheme) + + groups := scheme.Scheme.PrioritizedVersionsAllGroups() + vs := make(VersionSet, 0, len(groups)) + for _, gv := range groups { + vs = append(vs, gv.String()) + } + return vs +} + +func makeDefaultCapabilities() (*Capabilities, error) { + // Test builds don't include debug info / module info + // (And even if they did, we probably want stable capabilities for tests anyway) + // Return a default value for test builds + if testing.Testing() { + return newCapabilities(kubeVersionMajorTesting, kubeVersionMinorTesting) + } + + vstr, err := helmversion.K8sIOClientGoModVersion() + if err != nil { + return nil, fmt.Errorf("failed to retrieve k8s.io/client-go version: %w", err) + } + + v, err := semver.NewVersion(vstr) + if err != nil { + return nil, fmt.Errorf("unable to parse k8s.io/client-go version %q: %v", vstr, err) + } + + kubeVersionMajor := v.Major() + 1 + kubeVersionMinor := v.Minor() + + return newCapabilities(kubeVersionMajor, kubeVersionMinor) +} + +func newCapabilities(kubeVersionMajor, kubeVersionMinor uint64) (*Capabilities, error) { + + version := fmt.Sprintf("v%d.%d.0", kubeVersionMajor, kubeVersionMinor) + return &Capabilities{ + KubeVersion: KubeVersion{ + Version: version, + normalizedVersion: version, + Major: fmt.Sprintf("%d", kubeVersionMajor), + Minor: fmt.Sprintf("%d", kubeVersionMinor), + }, + APIVersions: DefaultVersionSet, + HelmVersion: helmversion.Get(), + }, nil +} diff --git a/pkg/helm/pkg/chartutil/capabilities_test.go b/pkg/helm/pkg/chart/common/capabilities_test.go similarity index 59% rename from pkg/helm/pkg/chartutil/capabilities_test.go rename to pkg/helm/pkg/chart/common/capabilities_test.go index b58d7e0f..b96d7d29 100644 --- a/pkg/helm/pkg/chartutil/capabilities_test.go +++ b/pkg/helm/pkg/chart/common/capabilities_test.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package chartutil +package common import ( "testing" @@ -41,7 +41,8 @@ func TestDefaultVersionSet(t *testing.T) { } func TestDefaultCapabilities(t *testing.T) { - kv := DefaultCapabilities.KubeVersion + caps := DefaultCapabilities + kv := caps.KubeVersion if kv.String() != "v1.20.0" { t.Errorf("Expected default KubeVersion.String() to be v1.20.0, got %q", kv.String()) } @@ -57,13 +58,10 @@ func TestDefaultCapabilities(t *testing.T) { if kv.Minor != "20" { t.Errorf("Expected default KubeVersion.Minor to be 20, got %q", kv.Minor) } -} - -func TestDefaultCapabilitiesHelmVersion(t *testing.T) { - hv := DefaultCapabilities.HelmVersion - if hv.Version != "v3.14" { - t.Errorf("Expected default HelmVersion to be v3.14, got %q", hv.Version) + hv := caps.HelmVersion + if hv.Version != "v4.1" { + t.Errorf("Expected default HelmVersion to be v4.1, got %q", hv.Version) } } @@ -82,3 +80,42 @@ func TestParseKubeVersion(t *testing.T) { t.Errorf("Expected parsed KubeVersion.Minor to be 16, got %q", kv.Minor) } } + +func TestParseKubeVersionWithVendorSuffixes(t *testing.T) { + tests := []struct { + name string + input string + wantVer string + wantString string + wantMajor string + wantMinor string + }{ + {"GKE vendor suffix", "v1.33.4-gke.1245000", "v1.33.4-gke.1245000", "v1.33.4", "1", "33"}, + {"GKE without v", "1.30.2-gke.1587003", "v1.30.2-gke.1587003", "v1.30.2", "1", "30"}, + {"EKS trailing +", "v1.28+", "v1.28+", "v1.28", "1", "28"}, + {"EKS + without v", "1.28+", "v1.28+", "v1.28", "1", "28"}, + {"Standard version", "v1.31.0", "v1.31.0", "v1.31.0", "1", "31"}, + {"Standard without v", "1.29.0", "v1.29.0", "v1.29.0", "1", "29"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + kv, err := ParseKubeVersion(tt.input) + if err != nil { + t.Fatalf("ParseKubeVersion() error = %v", err) + } + if kv.Version != tt.wantVer { + t.Errorf("Version = %q, want %q", kv.Version, tt.wantVer) + } + if kv.String() != tt.wantString { + t.Errorf("String() = %q, want %q", kv.String(), tt.wantString) + } + if kv.Major != tt.wantMajor { + t.Errorf("Major = %q, want %q", kv.Major, tt.wantMajor) + } + if kv.Minor != tt.wantMinor { + t.Errorf("Minor = %q, want %q", kv.Minor, tt.wantMinor) + } + }) + } +} diff --git a/pkg/helm/pkg/chartutil/errors.go b/pkg/helm/pkg/chart/common/errors.go similarity index 98% rename from pkg/helm/pkg/chartutil/errors.go rename to pkg/helm/pkg/chart/common/errors.go index 0a4046d2..b0a2d650 100644 --- a/pkg/helm/pkg/chartutil/errors.go +++ b/pkg/helm/pkg/chart/common/errors.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package chartutil +package common import ( "fmt" diff --git a/pkg/helm/pkg/chartutil/errors_test.go b/pkg/helm/pkg/chart/common/errors_test.go similarity index 97% rename from pkg/helm/pkg/chartutil/errors_test.go rename to pkg/helm/pkg/chart/common/errors_test.go index 3f63e373..06b3b054 100644 --- a/pkg/helm/pkg/chartutil/errors_test.go +++ b/pkg/helm/pkg/chart/common/errors_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package chartutil +package common import ( "testing" diff --git a/pkg/helm/pkg/chart/file.go b/pkg/helm/pkg/chart/common/file.go similarity index 88% rename from pkg/helm/pkg/chart/file.go rename to pkg/helm/pkg/chart/common/file.go index 9dd7c08d..1068bf45 100644 --- a/pkg/helm/pkg/chart/file.go +++ b/pkg/helm/pkg/chart/common/file.go @@ -13,7 +13,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -package chart +package common + +import "time" // File represents a file as a name/value pair. // @@ -24,4 +26,6 @@ type File struct { Name string `json:"name"` // Data is the template as byte data. Data []byte `json:"data"` + // ModTime is the file's mod-time + ModTime time.Time `json:"modtime,omitzero"` } diff --git a/pkg/helm/pkg/chart/common/runtime_data.go b/pkg/helm/pkg/chart/common/runtime_data.go new file mode 100644 index 00000000..9f6f5929 --- /dev/null +++ b/pkg/helm/pkg/chart/common/runtime_data.go @@ -0,0 +1,22 @@ +package common + +import ( + "context" + + "github.com/werf/common-go/pkg/secrets_manager" + nelmcommon "github.com/werf/nelm/pkg/common" +) + +type RuntimeData interface { + DecodeAndLoadSecrets(ctx context.Context, loadedChartFiles []*nelmcommon.BufferedFile, secretsManager *secrets_manager.SecretsManager, opts DecodeAndLoadSecretsOptions) error + GetDecryptedSecretValues() map[string]interface{} + GetDecryptedSecretFilesData() map[string]string +} + +type DecodeAndLoadSecretsOptions struct { + CustomSecretValueFiles []string + LoadFromLocalFilesystem bool + NoDecryptSecrets bool + SecretsWorkingDir string + WithoutDefaultSecretValues bool +} diff --git a/pkg/helm/pkg/chart/common/testdata/coleridge.yaml b/pkg/helm/pkg/chart/common/testdata/coleridge.yaml new file mode 100644 index 00000000..b6579628 --- /dev/null +++ b/pkg/helm/pkg/chart/common/testdata/coleridge.yaml @@ -0,0 +1,12 @@ +poet: "Coleridge" +title: "Rime of the Ancient Mariner" +stanza: ["at", "length", "did", "cross", "an", "Albatross"] + +mariner: + with: "crossbow" + shot: "ALBATROSS" + +water: + water: + where: "everywhere" + nor: "any drop to drink" diff --git a/pkg/helm/pkg/chartutil/coalesce.go b/pkg/helm/pkg/chart/common/util/coalesce.go similarity index 71% rename from pkg/helm/pkg/chartutil/coalesce.go rename to pkg/helm/pkg/chart/common/util/coalesce.go index fcfdfecb..f754adfe 100644 --- a/pkg/helm/pkg/chartutil/coalesce.go +++ b/pkg/helm/pkg/chart/common/util/coalesce.go @@ -14,18 +14,20 @@ See the License for the specific language governing permissions and limitations under the License. */ -package chartutil +package util import ( "context" "fmt" "log" - - "github.com/mitchellh/copystructure" - "github.com/pkg/errors" - - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/werf/secrets" + "maps" + + nelmcommon "github.com/werf/nelm/pkg/common" + v3 "github.com/werf/nelm/pkg/helm/intern/chart/v3" + "github.com/werf/nelm/pkg/helm/intern/copystructure" + chart "github.com/werf/nelm/pkg/helm/pkg/chart" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + v2 "github.com/werf/nelm/pkg/helm/pkg/chart/v2" ) func concatPrefix(a, b string) string { @@ -44,7 +46,7 @@ func concatPrefix(a, b string) string { // - Scalar values and arrays are replaced, maps are merged // - A chart has access to all of the variables for it, as well as all of // the values destined for its dependencies. -func CoalesceValues(chrt *chart.Chart, vals map[string]interface{}) (Values, error) { +func CoalesceValues(chrt chart.Charter, vals map[string]interface{}) (common.Values, error) { vals, err := makeValues(chrt, vals) if err != nil { return vals, err @@ -71,7 +73,7 @@ func CoalesceValues(chrt *chart.Chart, vals map[string]interface{}) (Values, err // Retaining Nils is useful when processes early in a Helm action or business // logic need to retain them for when Coalescing will happen again later in the // business logic. -func MergeValues(chrt *chart.Chart, vals map[string]interface{}) (Values, error) { +func MergeValues(chrt chart.Charter, vals map[string]interface{}) (common.Values, error) { vals, err := makeValues(chrt, vals) if err != nil { return vals, err @@ -84,7 +86,7 @@ func MergeValues(chrt *chart.Chart, vals map[string]interface{}) (Values, error) return coalesce(log.Printf, chrt, valsCopy, "", true) } -func copyValues(vals map[string]interface{}) (Values, error) { +func copyValues(vals map[string]interface{}) (common.Values, error) { v, err := copystructure.Copy(vals) if err != nil { return vals, err @@ -108,28 +110,36 @@ type printFn func(format string, v ...interface{}) // Note, the merge argument specifies whether this is being used by MergeValues // or CoalesceValues. Coalescing removes null values and their keys in some // situations while merging keeps the null values. -func coalesce(printf printFn, ch *chart.Chart, dest map[string]interface{}, prefix string, merge bool) (map[string]interface{}, error) { +func coalesce(printf printFn, ch chart.Charter, dest map[string]interface{}, prefix string, merge bool) (map[string]interface{}, error) { coalesceValues(printf, ch, dest, prefix, merge) return coalesceDeps(printf, ch, dest, prefix, merge) } // coalesceDeps coalesces the dependencies of the given chart. -func coalesceDeps(printf printFn, chrt *chart.Chart, dest map[string]interface{}, prefix string, merge bool) (map[string]interface{}, error) { - for _, subchart := range chrt.Dependencies() { - if c, ok := dest[subchart.Name()]; !ok { +func coalesceDeps(printf printFn, chrt chart.Charter, dest map[string]interface{}, prefix string, merge bool) (map[string]interface{}, error) { + ch, err := chart.NewAccessor(chrt) + if err != nil { + return dest, err + } + for _, subchart := range ch.Dependencies() { + sub, err := chart.NewAccessor(subchart) + if err != nil { + return dest, err + } + if c, ok := dest[sub.Name()]; !ok { // If dest doesn't already have the key, create it. - dest[subchart.Name()] = make(map[string]interface{}) + dest[sub.Name()] = make(map[string]interface{}) } else if !istable(c) { - return dest, errors.Errorf("type mismatch on %s: %t", subchart.Name(), c) + return dest, fmt.Errorf("type mismatch on %s: %t", sub.Name(), c) } - if dv, ok := dest[subchart.Name()]; ok { + if dv, ok := dest[sub.Name()]; ok { dvmap := dv.(map[string]interface{}) - subPrefix := concatPrefix(prefix, chrt.Metadata.Name) + subPrefix := concatPrefix(prefix, ch.Name()) // Get globals out of dest and merge them into dvmap. coalesceGlobals(printf, dvmap, dest, subPrefix, merge) // Now coalesce the rest of the values. var err error - dest[subchart.Name()], err = coalesce(printf, subchart, dvmap, subPrefix, merge) + dest[sub.Name()], err = coalesce(printf, subchart, dvmap, subPrefix, merge) if err != nil { return dest, err } @@ -144,17 +154,17 @@ func coalesceDeps(printf printFn, chrt *chart.Chart, dest map[string]interface{} func coalesceGlobals(printf printFn, dest, src map[string]interface{}, prefix string, _ bool) { var dg, sg map[string]interface{} - if destglob, ok := dest[GlobalKey]; !ok { + if destglob, ok := dest[common.GlobalKey]; !ok { dg = make(map[string]interface{}) } else if dg, ok = destglob.(map[string]interface{}); !ok { - printf("warning: skipping globals because destination %s is not a table.", GlobalKey) + printf("warning: skipping globals because destination %s is not a table.", common.GlobalKey) return } - if srcglob, ok := src[GlobalKey]; !ok { + if srcglob, ok := src[common.GlobalKey]; !ok { sg = make(map[string]interface{}) } else if sg, ok = srcglob.(map[string]interface{}); !ok { - printf("warning: skipping globals because source %s is not a table.", GlobalKey) + printf("warning: skipping globals because source %s is not a table.", common.GlobalKey) return } @@ -190,27 +200,30 @@ func coalesceGlobals(printf printFn, dest, src map[string]interface{}, prefix st dg[key] = val } } - dest[GlobalKey] = dg + dest[common.GlobalKey] = dg } func copyMap(src map[string]interface{}) map[string]interface{} { m := make(map[string]interface{}, len(src)) - for k, v := range src { - m[k] = v - } + maps.Copy(m, src) return m } // coalesceValues builds up a values map for a particular chart. // // Values in v will override the values in the chart. -func coalesceValues(printf printFn, c *chart.Chart, v map[string]interface{}, prefix string, merge bool) { - subPrefix := concatPrefix(prefix, c.Metadata.Name) +func coalesceValues(printf printFn, c chart.Charter, v map[string]interface{}, prefix string, merge bool) { + ch, err := chart.NewAccessor(c) + if err != nil { + return + } + + subPrefix := concatPrefix(prefix, ch.Name()) // Using c.Values directly when coalescing a table can cause problems where // the original c.Values is altered. Creating a deep copy stops the problem. // This section is fault-tolerant as there is no ability to return an error. - valuesCopy, err := copystructure.Copy(c.Values) + valuesCopy, err := copystructure.Copy(ch.Values()) var vc map[string]interface{} var ok bool if err != nil { @@ -219,7 +232,7 @@ func coalesceValues(printf printFn, c *chart.Chart, v map[string]interface{}, pr // wrong with c.Values. In this case we will use c.Values and report // an error. printf("warning: unable to copy values, err: %s", err) - vc = c.Values + vc = ch.Values() } else { vc, ok = valuesCopy.(map[string]interface{}) if !ok { @@ -227,7 +240,7 @@ func coalesceValues(printf printFn, c *chart.Chart, v map[string]interface{}, pr // it cannot be treated as map[string]interface{} there is something // strangely wrong. Log it and use c.Values printf("warning: unable to convert values copy to values type") - vc = c.Values + vc = ch.Values() } } @@ -249,6 +262,9 @@ func coalesceValues(printf printFn, c *chart.Chart, v map[string]interface{}, pr printf("warning: skipped value for %s.%s: Not a table.", subPrefix, key) } } else { + // If the key is a child chart, coalesce tables with Merge set to true + merge := childChartMergeTrue(c, key, merge) + // Because v has higher precedence than nv, dest values override src // values. coalesceTablesFullKey(printf, dest, src, concatPrefix(subPrefix, key), merge) @@ -261,6 +277,23 @@ func coalesceValues(printf printFn, c *chart.Chart, v map[string]interface{}, pr } } +func childChartMergeTrue(chrt chart.Charter, key string, merge bool) bool { + ch, err := chart.NewAccessor(chrt) + if err != nil { + return merge + } + for _, subchart := range ch.Dependencies() { + sub, err := chart.NewAccessor(subchart) + if err != nil { + return merge + } + if sub.Name() == key { + return true + } + } + return merge +} + // CoalesceTables merges a source map into a destination map. // // dest is considered authoritative. @@ -283,13 +316,31 @@ func coalesceTablesFullKey(printf printFn, dst, src map[string]interface{}, pref if dst == nil { return src } + // Track original non-nil src keys before modifying src + // This lets us distinguish between user nullifying a chart default vs + // user setting nil for a key not in chart defaults. + srcOriginalNonNil := make(map[string]bool) + for key, val := range src { + if val != nil { + srcOriginalNonNil[key] = true + } + } + for key, val := range dst { + if val == nil { + src[key] = nil + } + } // Because dest has higher precedence than src, dest values override src // values. for key, val := range src { fullkey := concatPrefix(prefix, key) - if dv, ok := dst[key]; ok && !merge && dv == nil { + if dv, ok := dst[key]; ok && !merge && dv == nil && srcOriginalNonNil[key] { + // When coalescing (not merging), if dst has nil and src has a non-nil + // value, the user is nullifying a chart default - remove the key. + // But if src also has nil (or key not in src), preserve the nil delete(dst, key) } else if !ok { + // key not in user values, preserve src value (including nil) dst[key] = val } else if istable(val) { if istable(dv) { @@ -304,15 +355,30 @@ func coalesceTablesFullKey(printf printFn, dst, src map[string]interface{}, pref return dst } -func makeValues(chrt *chart.Chart, vals map[string]interface{}) (map[string]interface{}, error) { - var decryptedSecretValues map[string]interface{} - if chrt.SecretsRuntimeData != nil { - decryptedSecretValues = chrt.SecretsRuntimeData.GetDecryptedSecretValues() - } +// istable is a special-purpose function to see if the present thing matches the definition of a YAML table. +func istable(v interface{}) bool { + _, ok := v.(map[string]interface{}) + return ok +} +func makeValues(chrt chart.Charter, vals map[string]interface{}) (map[string]interface{}, error) { + var secretsRuntimeData common.RuntimeData var extraValues map[string]interface{} - if chrt.ExtraValues != nil { - extraValues = chrt.ExtraValues + + switch c := chrt.(type) { + case *v2.Chart: + secretsRuntimeData = c.SecretsRuntimeData + extraValues = c.ExtraValues + case *v3.Chart: + secretsRuntimeData = c.SecretsRuntimeData + extraValues = c.ExtraValues + default: + return vals, nil + } + + var decryptedSecretValues map[string]interface{} + if secretsRuntimeData != nil { + decryptedSecretValues = secretsRuntimeData.GetDecryptedSecretValues() } result, err := MergeInternal( @@ -331,7 +397,7 @@ func makeValues(chrt *chart.Chart, vals map[string]interface{}) (map[string]inte func MergeInternal(ctx context.Context, inputVals, serviceVals map[string]interface{}, decryptedSecretValues map[string]interface{}) (map[string]interface{}, error) { vals := make(map[string]interface{}) - CoalesceTables(vals, serviceVals) // NOTE: service values will not be saved into the marshalled release + CoalesceTables(vals, serviceVals) if decryptedSecretValues != nil { CoalesceTables(vals, decryptedSecretValues) @@ -343,5 +409,5 @@ func MergeInternal(ctx context.Context, inputVals, serviceVals map[string]interf } func init() { - secrets.CoalesceTablesFunc = CoalesceTables + nelmcommon.LegacyCoalesceTablesFunc = CoalesceTables } diff --git a/pkg/helm/pkg/chartutil/coalesce_test.go b/pkg/helm/pkg/chart/common/util/coalesce_test.go similarity index 87% rename from pkg/helm/pkg/chartutil/coalesce_test.go rename to pkg/helm/pkg/chart/common/util/coalesce_test.go index 772ea036..24e0b376 100644 --- a/pkg/helm/pkg/chartutil/coalesce_test.go +++ b/pkg/helm/pkg/chart/common/util/coalesce_test.go @@ -14,16 +14,20 @@ See the License for the specific language governing permissions and limitations under the License. */ -package chartutil +package util import ( + "bytes" "encoding/json" "fmt" + "maps" "testing" + "text/template" "github.com/stretchr/testify/assert" - "github.com/werf/nelm/pkg/helm/pkg/chart" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" ) // ref: http://www.yaml.org/spec/1.2/spec.html#id2803362 @@ -44,18 +48,21 @@ global: boat: true pequod: + boat: null global: name: Stinky harpooner: Tashtego nested: boat: false sail: true + foo2: null ahab: scope: whale boat: null nested: foo: true - bar: null + boat: null + object: null `) func withDeps(c *chart.Chart, deps ...*chart.Chart) *chart.Chart { @@ -82,6 +89,13 @@ func TestCoalesceValues(t *testing.T) { "global": map[string]interface{}{ "nested2": map[string]interface{}{"l0": "moby"}, }, + "pequod": map[string]interface{}{ + "boat": "maybe", + "ahab": map[string]interface{}{ + "boat": "maybe", + "nested": map[string]interface{}{"boat": "maybe"}, + }, + }, }, }, withDeps(&chart.Chart{ @@ -92,19 +106,25 @@ func TestCoalesceValues(t *testing.T) { "global": map[string]interface{}{ "nested2": map[string]interface{}{"l1": "pequod"}, }, + "boat": false, + "ahab": map[string]interface{}{ + "boat": false, + "nested": map[string]interface{}{"boat": false}, + }, }, }, &chart.Chart{ Metadata: &chart.Metadata{Name: "ahab"}, Values: map[string]interface{}{ "global": map[string]interface{}{ - "nested": map[string]interface{}{"foo": "bar"}, + "nested": map[string]interface{}{"foo": "bar", "foo2": "bar2"}, "nested2": map[string]interface{}{"l2": "ahab"}, }, "scope": "ahab", "name": "ahab", "boat": true, - "nested": map[string]interface{}{"foo": false, "bar": true}, + "nested": map[string]interface{}{"foo": false, "boat": true}, + "object": map[string]interface{}{"foo": "bar"}, }, }, ), @@ -119,7 +139,7 @@ func TestCoalesceValues(t *testing.T) { }, ) - vals, err := ReadValues(testCoalesceValuesYaml) + vals, err := common.ReadValues(testCoalesceValuesYaml) if err != nil { t.Fatal(err) } @@ -127,10 +147,8 @@ func TestCoalesceValues(t *testing.T) { // taking a copy of the values before passing it // to CoalesceValues as argument, so that we can // use it for asserting later - valsCopy := make(Values, len(vals)) - for key, value := range vals { - valsCopy[key] = value - } + valsCopy := make(common.Values, len(vals)) + maps.Copy(valsCopy, vals) v, err := CoalesceValues(c, vals) if err != nil { @@ -155,6 +173,7 @@ func TestCoalesceValues(t *testing.T) { {"{{.pequod.ahab.nested.foo}}", "true"}, {"{{.pequod.ahab.global.name}}", "Ishmael"}, {"{{.pequod.ahab.global.nested.foo}}", "bar"}, + {"{{.pequod.ahab.global.nested.foo2}}", ""}, {"{{.pequod.ahab.global.subject}}", "Queequeg"}, {"{{.pequod.ahab.global.harpooner}}", "Tashtego"}, {"{{.pequod.global.name}}", "Ishmael"}, @@ -200,19 +219,35 @@ func TestCoalesceValues(t *testing.T) { t.Error("Expected nested boat key to be removed, still present") } - subchart := v["pequod"].(map[string]interface{})["ahab"].(map[string]interface{}) + subchart := v["pequod"].(map[string]interface{}) if _, ok := subchart["boat"]; ok { t.Error("Expected subchart boat key to be removed, still present") } - if _, ok := subchart["nested"].(map[string]interface{})["bar"]; ok { - t.Error("Expected subchart nested bar key to be removed, still present") + subsubchart := subchart["ahab"].(map[string]interface{}) + if _, ok := subsubchart["boat"]; ok { + t.Error("Expected sub-subchart ahab boat key to be removed, still present") + } + + if _, ok := subsubchart["nested"].(map[string]interface{})["boat"]; ok { + t.Error("Expected sub-subchart nested boat key to be removed, still present") + } + + if _, ok := subsubchart["object"]; ok { + t.Error("Expected sub-subchart object map to be removed, still present") } // CoalesceValues should not mutate the passed arguments is.Equal(valsCopy, vals) } +func ttpl(tpl string, v map[string]interface{}) (string, error) { + var b bytes.Buffer + tt := template.Must(template.New("t").Parse(tpl)) + err := tt.Execute(&b, v) + return b.String(), err +} + func TestMergeValues(t *testing.T) { is := assert.New(t) @@ -269,7 +304,7 @@ func TestMergeValues(t *testing.T) { }, ) - vals, err := ReadValues(testCoalesceValuesYaml) + vals, err := common.ReadValues(testCoalesceValuesYaml) if err != nil { t.Fatal(err) } @@ -277,10 +312,8 @@ func TestMergeValues(t *testing.T) { // taking a copy of the values before passing it // to MergeValues as argument, so that we can // use it for asserting later - valsCopy := make(Values, len(vals)) - for key, value := range vals { - valsCopy[key] = value - } + valsCopy := make(common.Values, len(vals)) + maps.Copy(valsCopy, vals) v, err := MergeValues(c, vals) if err != nil { @@ -698,3 +731,37 @@ func TestConcatPrefix(t *testing.T) { assert.Equal(t, "b", concatPrefix("", "b")) assert.Equal(t, "a.b", concatPrefix("a", "b")) } + +// TestCoalesceValuesEmptyMapWithNils tests the full CoalesceValues scenario +// from issue #31643 where chart has data: {} and user provides data: {foo: bar, baz: ~} +func TestCoalesceValuesEmptyMapWithNils(t *testing.T) { + is := assert.New(t) + + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: "test"}, + Values: map[string]any{ + "data": map[string]any{}, // empty map in chart defaults + }, + } + + vals := map[string]any{ + "data": map[string]any{ + "foo": "bar", + "baz": nil, // explicit nil from user + }, + } + + v, err := CoalesceValues(c, vals) + is.NoError(err) + + data, ok := v["data"].(map[string]any) + is.True(ok, "data is not a map") + + // "foo" should be preserved + is.Equal("bar", data["foo"]) + + // "baz" should be preserved with nil value since it wasn't in chart defaults + _, ok = data["baz"] + is.True(ok, "Expected data.baz key to be present but it was removed") + is.Nil(data["baz"], "Expected data.baz key to be nil but it is not") +} diff --git a/pkg/helm/pkg/chart/common/util/jsonschema.go b/pkg/helm/pkg/chart/common/util/jsonschema.go new file mode 100644 index 00000000..25f50009 --- /dev/null +++ b/pkg/helm/pkg/chart/common/util/jsonschema.go @@ -0,0 +1,216 @@ +/* +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 util + +import ( + "bytes" + "crypto/tls" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + "sync" + "time" + + "github.com/santhosh-tekuri/jsonschema/v6" + + "github.com/werf/nelm/pkg/helm/intern/version" + + chart "github.com/werf/nelm/pkg/helm/pkg/chart" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" +) + +// HTTPURLLoader implements a loader for HTTP/HTTPS URLs +type HTTPURLLoader http.Client + +func (l *HTTPURLLoader) Load(urlStr string) (any, error) { + client := (*http.Client)(l) + + req, err := http.NewRequest(http.MethodGet, urlStr, nil) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP request for %s: %w", urlStr, err) + } + req.Header.Set("User-Agent", version.GetUserAgent()) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed for %s: %w", urlStr, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP request to %s returned status %d (%s)", urlStr, resp.StatusCode, http.StatusText(resp.StatusCode)) + } + + return jsonschema.UnmarshalJSON(resp.Body) +} + +// newHTTPURLLoader creates a HTTP URL loader with proxy support. +func newHTTPURLLoader() *HTTPURLLoader { + httpLoader := HTTPURLLoader(http.Client{ + Timeout: 15 * time.Second, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: &tls.Config{}, + }, + }) + return &httpLoader +} + +// ValidateAgainstSchema checks that values does not violate the structure laid out in schema +func ValidateAgainstSchema(ch chart.Charter, values map[string]interface{}) error { + chrt, err := chart.NewAccessor(ch) + if err != nil { + return err + } + var sb strings.Builder + if chrt.Schema() != nil { + slog.Debug("chart name", "chart-name", chrt.Name()) + err := ValidateAgainstSingleSchema(values, chrt.Schema()) + if err != nil { + fmt.Fprintf(&sb, "%s:\n", chrt.Name()) + sb.WriteString(err.Error()) + } + } + slog.Debug("number of dependencies in the chart", "chart", chrt.Name(), "dependencies", len(chrt.Dependencies())) + // For each dependency, recursively call this function with the coalesced values + for _, subchart := range chrt.Dependencies() { + sub, err := chart.NewAccessor(subchart) + if err != nil { + return err + } + + raw, exists := values[sub.Name()] + if !exists || raw == nil { + // No values provided for this subchart; nothing to validate + continue + } + + subchartValues, ok := raw.(map[string]any) + if !ok { + fmt.Fprintf(&sb, "%s:\ninvalid type for values: expected object (map), got %T\n", + sub.Name(), raw) + continue + } + + if err := ValidateAgainstSchema(subchart, subchartValues); err != nil { + sb.WriteString(err.Error()) + } + } + + if sb.Len() > 0 { + return errors.New(sb.String()) + } + + return nil +} + +// ValidateAgainstSingleSchema checks that values does not violate the structure laid out in this schema +func ValidateAgainstSingleSchema(values common.Values, schemaJSON []byte) (reterr error) { + defer func() { + if r := recover(); r != nil { + reterr = fmt.Errorf("unable to validate schema: %s", r) + } + }() + + // This unmarshal function leverages UseNumber() for number precision. The parser + // used for values does this as well. + schema, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaJSON)) + if err != nil { + return err + } + slog.Debug("unmarshalled JSON schema", "schema", schemaJSON) + + // Configure compiler with loaders for different URL schemes + loader := jsonschema.SchemeURLLoader{ + "file": jsonschema.FileLoader{}, + "http": newHTTPURLLoader(), + "https": newHTTPURLLoader(), + "urn": urnLoader{}, + } + + compiler := jsonschema.NewCompiler() + compiler.UseLoader(loader) + err = compiler.AddResource("file:///values.schema.json", schema) + if err != nil { + return err + } + + validator, err := compiler.Compile("file:///values.schema.json") + if err != nil { + return err + } + + err = validator.Validate(values.AsMap()) + if err != nil { + return JSONSchemaValidationError{err} + } + + return nil +} + +// URNResolverFunc allows SDK to plug a URN resolver. It must return a +// schema document compatible with the validator (e.g., result of +// jsonschema.UnmarshalJSON). +type URNResolverFunc func(urn string) (any, error) + +// URNResolver is the default resolver used by the URN loader. By default it +// returns a clear error. +var URNResolver URNResolverFunc = func(urn string) (any, error) { + return nil, fmt.Errorf("URN not resolved: %s", urn) +} + +// urnLoader implements resolution for the urn: scheme by delegating to +// URNResolver. If unresolved, it logs a warning and returns a permissive +// boolean-true schema to avoid hard failures (back-compat behavior). +type urnLoader struct{} + +// warnedURNs ensures we log the unresolved-URN warning only once per URN. +var warnedURNs sync.Map + +func (l urnLoader) Load(urlStr string) (any, error) { + if doc, err := URNResolver(urlStr); err == nil && doc != nil { + return doc, nil + } + if _, loaded := warnedURNs.LoadOrStore(urlStr, struct{}{}); !loaded { + slog.Warn("unresolved URN reference ignored; using permissive schema", "urn", urlStr) + } + return jsonschema.UnmarshalJSON(strings.NewReader("true")) +} + +// Note, JSONSchemaValidationError is used to wrap the error from the underlying +// validation package so that Helm has a clean interface and the validation package +// could be replaced without changing the Helm SDK API. + +// JSONSchemaValidationError is the error returned when there is a schema validation +// error. +type JSONSchemaValidationError struct { + embeddedErr error +} + +// Error prints the error message +func (e JSONSchemaValidationError) Error() string { + errStr := e.embeddedErr.Error() + + // This string prefixes all of our error details. Further up the stack of helm error message + // building more detail is provided to users. This is removed. + errStr = strings.TrimPrefix(errStr, "jsonschema validation failed with 'file:///values.schema.json#'\n") + + // The extra new line is needed for when there are sub-charts. + return errStr + "\n" +} diff --git a/pkg/helm/pkg/chart/common/util/jsonschema_test.go b/pkg/helm/pkg/chart/common/util/jsonschema_test.go new file mode 100644 index 00000000..507c0a46 --- /dev/null +++ b/pkg/helm/pkg/chart/common/util/jsonschema_test.go @@ -0,0 +1,391 @@ +/* +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 util + +import ( + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" +) + +func TestValidateAgainstSingleSchema(t *testing.T) { + values, err := common.ReadValuesFile("./testdata/test-values.yaml") + if err != nil { + t.Fatalf("Error reading YAML file: %s", err) + } + schema, err := os.ReadFile("./testdata/test-values.schema.json") + if err != nil { + t.Fatalf("Error reading YAML file: %s", err) + } + + if err := ValidateAgainstSingleSchema(values, schema); err != nil { + t.Errorf("Error validating Values against Schema: %s", err) + } +} + +func TestValidateAgainstInvalidSingleSchema(t *testing.T) { + values, err := common.ReadValuesFile("./testdata/test-values.yaml") + if err != nil { + t.Fatalf("Error reading YAML file: %s", err) + } + schema, err := os.ReadFile("./testdata/test-values-invalid.schema.json") + if err != nil { + t.Fatalf("Error reading YAML file: %s", err) + } + + var errString string + if err := ValidateAgainstSingleSchema(values, schema); err == nil { + t.Fatalf("Expected an error, but got nil") + } else { + errString = err.Error() + } + + expectedErrString := `"file:///values.schema.json#" is not valid against metaschema: jsonschema validation failed with 'https://json-schema.org/draft/2020-12/schema#' +- at '': got number, want boolean or object` + if errString != expectedErrString { + t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString) + } +} + +func TestValidateAgainstSingleSchemaNegative(t *testing.T) { + values, err := common.ReadValuesFile("./testdata/test-values-negative.yaml") + if err != nil { + t.Fatalf("Error reading YAML file: %s", err) + } + schema, err := os.ReadFile("./testdata/test-values.schema.json") + if err != nil { + t.Fatalf("Error reading JSON file: %s", err) + } + + var errString string + if err := ValidateAgainstSingleSchema(values, schema); err == nil { + t.Fatalf("Expected an error, but got nil") + } else { + errString = err.Error() + } + + expectedErrString := `- at '': missing property 'employmentInfo' +- at '/age': minimum: got -5, want 0 +` + if errString != expectedErrString { + t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString) + } +} + +const subchartSchema = `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Values", + "type": "object", + "properties": { + "age": { + "description": "Age", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "age" + ] +} +` + +const subchartSchema2020 = `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Values", + "type": "object", + "properties": { + "data": { + "type": "array", + "contains": { "type": "string" }, + "unevaluatedItems": { "type": "number" } + } + }, + "required": ["data"] +} +` + +func TestValidateAgainstSchema(t *testing.T) { + subchartJSON := []byte(subchartSchema) + subchart := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "subchart", + }, + Schema: subchartJSON, + } + chrt := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "chrt", + }, + } + chrt.AddDependency(subchart) + + vals := map[string]interface{}{ + "name": "John", + "subchart": map[string]interface{}{ + "age": 25, + }, + } + + if err := ValidateAgainstSchema(chrt, vals); err != nil { + t.Errorf("Error validating Values against Schema: %s", err) + } +} + +func TestValidateAgainstSchemaNegative(t *testing.T) { + subchartJSON := []byte(subchartSchema) + subchart := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "subchart", + }, + Schema: subchartJSON, + } + chrt := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "chrt", + }, + } + chrt.AddDependency(subchart) + + vals := map[string]interface{}{ + "name": "John", + "subchart": map[string]interface{}{}, + } + + var errString string + if err := ValidateAgainstSchema(chrt, vals); err == nil { + t.Fatalf("Expected an error, but got nil") + } else { + errString = err.Error() + } + + expectedErrString := `subchart: +- at '': missing property 'age' +` + if errString != expectedErrString { + t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString) + } +} + +func TestValidateAgainstSchema2020(t *testing.T) { + subchartJSON := []byte(subchartSchema2020) + subchart := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "subchart", + }, + Schema: subchartJSON, + } + chrt := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "chrt", + }, + } + chrt.AddDependency(subchart) + + vals := map[string]interface{}{ + "name": "John", + "subchart": map[string]interface{}{ + "data": []any{"hello", 12}, + }, + } + + if err := ValidateAgainstSchema(chrt, vals); err != nil { + t.Errorf("Error validating Values against Schema: %s", err) + } +} + +func TestValidateAgainstSchema2020Negative(t *testing.T) { + subchartJSON := []byte(subchartSchema2020) + subchart := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "subchart", + }, + Schema: subchartJSON, + } + chrt := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "chrt", + }, + } + chrt.AddDependency(subchart) + + vals := map[string]interface{}{ + "name": "John", + "subchart": map[string]interface{}{ + "data": []any{12}, + }, + } + + var errString string + if err := ValidateAgainstSchema(chrt, vals); err == nil { + t.Fatalf("Expected an error, but got nil") + } else { + errString = err.Error() + } + + expectedErrString := `subchart: +- at '/data': no items match contains schema + - at '/data/0': got number, want string +` + if errString != expectedErrString { + t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString) + } +} + +func TestHTTPURLLoader_Load(t *testing.T) { + // Test successful JSON schema loading + t.Run("successful load", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"type": "object", "properties": {"name": {"type": "string"}}}`)) + })) + defer server.Close() + + loader := newHTTPURLLoader() + result, err := loader.Load(server.URL) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + if result == nil { + t.Fatal("Expected result to be non-nil") + } + }) + + t.Run("HTTP error status", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + loader := newHTTPURLLoader() + _, err := loader.Load(server.URL) + if err == nil { + t.Fatal("Expected error for HTTP 404") + } + if !strings.Contains(err.Error(), "404") { + t.Errorf("Expected error message to contain '404', got: %v", err) + } + }) +} + +// Test that an unresolved URN $ref is soft-ignored and validation succeeds. +// it mimics the behavior of Helm 3.18.4 +func TestValidateAgainstSingleSchema_UnresolvedURN_Ignored(t *testing.T) { + schema := []byte(`{ + "$schema": "https://json-schema.org/draft-07/schema#", + "$ref": "urn:example:helm:schemas:v1:helm-schema-validation-conditions:v1/helmSchemaValidation-true" + }`) + vals := map[string]interface{}{"any": "value"} + if err := ValidateAgainstSingleSchema(vals, schema); err != nil { + t.Fatalf("expected no error when URN unresolved is ignored, got: %v", err) + } +} + +// Non-regression tests for https://github.com/helm/helm/issues/31202 +// Ensure ValidateAgainstSchema does not panic when: +// - subchart key is missing +// - subchart value is nil +// - subchart value has an invalid type + +func TestValidateAgainstSchema_MissingSubchartValues_NoPanic(t *testing.T) { + subchartJSON := []byte(subchartSchema) + subchart := &chart.Chart{ + Metadata: &chart.Metadata{Name: "subchart"}, + Schema: subchartJSON, + } + chrt := &chart.Chart{ + Metadata: &chart.Metadata{Name: "chrt"}, + } + chrt.AddDependency(subchart) + + // No "subchart" key present in values + vals := map[string]any{ + "name": "John", + } + + defer func() { + if r := recover(); r != nil { + t.Fatalf("ValidateAgainstSchema panicked (missing subchart values): %v", r) + } + }() + + if err := ValidateAgainstSchema(chrt, vals); err != nil { + t.Fatalf("expected no error when subchart values are missing, got: %v", err) + } +} + +func TestValidateAgainstSchema_SubchartNil_NoPanic(t *testing.T) { + subchartJSON := []byte(subchartSchema) + subchart := &chart.Chart{ + Metadata: &chart.Metadata{Name: "subchart"}, + Schema: subchartJSON, + } + chrt := &chart.Chart{ + Metadata: &chart.Metadata{Name: "chrt"}, + } + chrt.AddDependency(subchart) + + // "subchart" key present but nil + vals := map[string]any{ + "name": "John", + "subchart": nil, + } + + defer func() { + if r := recover(); r != nil { + t.Fatalf("ValidateAgainstSchema panicked (nil subchart values): %v", r) + } + }() + + if err := ValidateAgainstSchema(chrt, vals); err != nil { + t.Fatalf("expected no error when subchart values are nil, got: %v", err) + } +} + +func TestValidateAgainstSchema_InvalidSubchartValuesType_NoPanic(t *testing.T) { + subchartJSON := []byte(subchartSchema) + subchart := &chart.Chart{ + Metadata: &chart.Metadata{Name: "subchart"}, + Schema: subchartJSON, + } + chrt := &chart.Chart{ + Metadata: &chart.Metadata{Name: "chrt"}, + } + chrt.AddDependency(subchart) + + // "subchart" is the wrong type (string instead of map) + vals := map[string]any{ + "name": "John", + "subchart": "oops", + } + + defer func() { + if r := recover(); r != nil { + t.Fatalf("ValidateAgainstSchema panicked (invalid subchart values type): %v", r) + } + }() + + // We expect a non-nil error (invalid type), but crucially no panic. + if err := ValidateAgainstSchema(chrt, vals); err == nil { + t.Fatalf("expected an error when subchart values have invalid type, got nil") + } +} diff --git a/pkg/helm/pkg/chart/common/util/testdata/test-values-invalid.schema.json b/pkg/helm/pkg/chart/common/util/testdata/test-values-invalid.schema.json new file mode 100644 index 00000000..35a16a2c --- /dev/null +++ b/pkg/helm/pkg/chart/common/util/testdata/test-values-invalid.schema.json @@ -0,0 +1 @@ + 1E1111111 diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema-negative/values.yaml b/pkg/helm/pkg/chart/common/util/testdata/test-values-negative.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema-negative/values.yaml rename to pkg/helm/pkg/chart/common/util/testdata/test-values-negative.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema-negative/values.schema.json b/pkg/helm/pkg/chart/common/util/testdata/test-values.schema.json similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema-negative/values.schema.json rename to pkg/helm/pkg/chart/common/util/testdata/test-values.schema.json diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema/values.yaml b/pkg/helm/pkg/chart/common/util/testdata/test-values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema/values.yaml rename to pkg/helm/pkg/chart/common/util/testdata/test-values.yaml diff --git a/pkg/helm/pkg/chart/common/util/values.go b/pkg/helm/pkg/chart/common/util/values.go new file mode 100644 index 00000000..509de42d --- /dev/null +++ b/pkg/helm/pkg/chart/common/util/values.go @@ -0,0 +1,70 @@ +/* +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 util + +import ( + "fmt" + + "github.com/werf/nelm/pkg/helm/pkg/chart" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" +) + +// ToRenderValues composes the struct from the data coming from the Releases, Charts and Values files +// +// This takes both ReleaseOptions and Capabilities to merge into the render values. +func ToRenderValues(chrt chart.Charter, chrtVals map[string]interface{}, options common.ReleaseOptions, caps *common.Capabilities) (common.Values, error) { + return ToRenderValuesWithSchemaValidation(chrt, chrtVals, options, caps, false) +} + +// ToRenderValuesWithSchemaValidation composes the struct from the data coming from the Releases, Charts and Values files +// +// This takes both ReleaseOptions and Capabilities to merge into the render values. +func ToRenderValuesWithSchemaValidation(chrt chart.Charter, chrtVals map[string]interface{}, options common.ReleaseOptions, caps *common.Capabilities, skipSchemaValidation bool) (common.Values, error) { + if caps == nil { + caps = common.DefaultCapabilities + } + accessor, err := chart.NewAccessor(chrt) + if err != nil { + return nil, err + } + top := map[string]interface{}{ + "Chart": accessor.MetadataAsMap(), + "Capabilities": caps, + "Release": map[string]interface{}{ + "Name": options.Name, + "Namespace": options.Namespace, + "IsUpgrade": options.IsUpgrade, + "IsInstall": options.IsInstall, + "Revision": options.Revision, + "Service": "Helm", + }, + } + + vals, err := CoalesceValues(chrt, chrtVals) + if err != nil { + return common.Values(top), err + } + + if !skipSchemaValidation { + if err := ValidateAgainstSchema(chrt, vals); err != nil { + return top, fmt.Errorf("values don't meet the specifications of the schema(s) in the following chart(s):\n%w", err) + } + } + + top["Values"] = vals + return top, nil +} diff --git a/pkg/helm/pkg/chart/common/util/values_test.go b/pkg/helm/pkg/chart/common/util/values_test.go new file mode 100644 index 00000000..b983319a --- /dev/null +++ b/pkg/helm/pkg/chart/common/util/values_test.go @@ -0,0 +1,112 @@ +/* +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 util + +import ( + "testing" + "time" + + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" +) + +func TestToRenderValues(t *testing.T) { + + chartValues := map[string]interface{}{ + "name": "al Rashid", + "where": map[string]interface{}{ + "city": "Basrah", + "title": "caliph", + }, + } + + overrideValues := map[string]interface{}{ + "name": "Haroun", + "where": map[string]interface{}{ + "city": "Baghdad", + "date": "809 CE", + }, + } + + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: "test"}, + Templates: []*common.File{}, + Values: chartValues, + Files: []*common.File{ + {Name: "scheherazade/shahryar.txt", ModTime: time.Now(), Data: []byte("1,001 Nights")}, + }, + } + c.AddDependency(&chart.Chart{ + Metadata: &chart.Metadata{Name: "where"}, + }) + + o := common.ReleaseOptions{ + Name: "Seven Voyages", + Namespace: "default", + Revision: 1, + IsInstall: true, + } + + res, err := ToRenderValuesWithSchemaValidation(c, overrideValues, o, nil, false) + if err != nil { + t.Fatal(err) + } + + // Ensure that the top-level values are all set. + metamap := res["Chart"].(map[string]interface{}) + if name := metamap["Name"]; name.(string) != "test" { + t.Errorf("Expected chart name 'test', got %q", name) + } + relmap := res["Release"].(map[string]interface{}) + if name := relmap["Name"]; name.(string) != "Seven Voyages" { + t.Errorf("Expected release name 'Seven Voyages', got %q", name) + } + if namespace := relmap["Namespace"]; namespace.(string) != "default" { + t.Errorf("Expected namespace 'default', got %q", namespace) + } + if revision := relmap["Revision"]; revision.(int) != 1 { + t.Errorf("Expected revision '1', got %d", revision) + } + if relmap["IsUpgrade"].(bool) { + t.Error("Expected upgrade to be false.") + } + if !relmap["IsInstall"].(bool) { + t.Errorf("Expected install to be true.") + } + if !res["Capabilities"].(*common.Capabilities).APIVersions.Has("v1") { + t.Error("Expected Capabilities to have v1 as an API") + } + if res["Capabilities"].(*common.Capabilities).KubeVersion.Major != "1" { + t.Error("Expected Capabilities to have a Kube version") + } + + vals := res["Values"].(common.Values) + if vals["name"] != "Haroun" { + t.Errorf("Expected 'Haroun', got %q (%v)", vals["name"], vals) + } + where := vals["where"].(map[string]interface{}) + expects := map[string]string{ + "city": "Baghdad", + "date": "809 CE", + "title": "caliph", + } + for field, expect := range expects { + if got := where[field]; got != expect { + t.Errorf("Expected %q, got %q (%v)", expect, got, where) + } + } +} diff --git a/pkg/helm/pkg/chartutil/values.go b/pkg/helm/pkg/chart/common/values.go similarity index 75% rename from pkg/helm/pkg/chartutil/values.go rename to pkg/helm/pkg/chart/common/values.go index 22ba586e..94958a77 100644 --- a/pkg/helm/pkg/chartutil/values.go +++ b/pkg/helm/pkg/chart/common/values.go @@ -14,20 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -package chartutil +package common import ( - "fmt" + "errors" "io" - "log" "os" "strings" - "github.com/pkg/errors" - "github.com/samber/lo" "sigs.k8s.io/yaml" - - "github.com/werf/nelm/pkg/helm/pkg/chart" ) // GlobalKey is the name of the Values key that is used for storing global vars. @@ -133,49 +128,6 @@ type ReleaseOptions struct { IsInstall bool } -// ToRenderValues composes the struct from the data coming from the Releases, Charts and Values files -// -// This takes both ReleaseOptions and Capabilities to merge into the render values. -func ToRenderValues(chrt *chart.Chart, chrtVals map[string]interface{}, options ReleaseOptions, caps *Capabilities, runtime, defaultRootContext map[string]interface{}) (Values, error) { - if caps == nil { - caps = DefaultCapabilities - } - - top := map[string]interface{}{ - "Chart": chrt.Metadata, - "Capabilities": caps, - "Release": map[string]interface{}{ - "Name": options.Name, - "Namespace": options.Namespace, - "IsUpgrade": options.IsUpgrade, - "IsInstall": options.IsInstall, - "Revision": options.Revision, - "Service": "Helm", - }, - "Runtime": runtime, - } - - top = lo.Assign(defaultRootContext, top) - - vals, err := CoalesceValues(chrt, chrtVals) - if err != nil { - return top, err - } - - if err := ValidateAgainstSchema(chrt, vals); err != nil { - errFmt := "values don't meet the specifications of the schema(s) in the following chart(s):\n%s" - - if strings.Contains(err.Error(), "(root): Additional property werf is not allowed") { - log.Printf("Warning: %s", fmt.Sprintf(errFmt, err.Error())) - } else { - return top, fmt.Errorf(errFmt, err.Error()) - } - } - - top["Values"] = vals - return top, nil -} - // istable is a special-purpose function to see if the present thing matches the definition of a YAML table. func istable(v interface{}) bool { _, ok := v.(map[string]interface{}) diff --git a/pkg/helm/pkg/chartutil/values_test.go b/pkg/helm/pkg/chart/common/values_test.go similarity index 66% rename from pkg/helm/pkg/chartutil/values_test.go rename to pkg/helm/pkg/chart/common/values_test.go index 66c50cb5..3cceeb2b 100644 --- a/pkg/helm/pkg/chartutil/values_test.go +++ b/pkg/helm/pkg/chart/common/values_test.go @@ -14,15 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -package chartutil +package common import ( "bytes" "fmt" "testing" "text/template" - - "github.com/werf/nelm/pkg/helm/pkg/chart" ) func TestReadValues(t *testing.T) { @@ -66,92 +64,6 @@ water: } } -func TestToRenderValues(t *testing.T) { - - chartValues := map[string]interface{}{ - "name": "al Rashid", - "where": map[string]interface{}{ - "city": "Basrah", - "title": "caliph", - }, - } - - overrideValues := map[string]interface{}{ - "name": "Haroun", - "where": map[string]interface{}{ - "city": "Baghdad", - "date": "809 CE", - }, - } - - c := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test"}, - Templates: []*chart.File{}, - Values: chartValues, - Files: []*chart.File{ - {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, - }, - } - c.AddDependency(&chart.Chart{ - Metadata: &chart.Metadata{Name: "where"}, - }) - - o := ReleaseOptions{ - Name: "Seven Voyages", - Namespace: "default", - Revision: 1, - IsInstall: true, - } - - res, err := ToRenderValues(c, overrideValues, o, nil, nil) - if err != nil { - t.Fatal(err) - } - - // Ensure that the top-level values are all set. - if name := res["Chart"].(*chart.Metadata).Name; name != "test" { - t.Errorf("Expected chart name 'test', got %q", name) - } - relmap := res["Release"].(map[string]interface{}) - if name := relmap["Name"]; name.(string) != "Seven Voyages" { - t.Errorf("Expected release name 'Seven Voyages', got %q", name) - } - if namespace := relmap["Namespace"]; namespace.(string) != "default" { - t.Errorf("Expected namespace 'default', got %q", namespace) - } - if revision := relmap["Revision"]; revision.(int) != 1 { - t.Errorf("Expected revision '1', got %d", revision) - } - if relmap["IsUpgrade"].(bool) { - t.Error("Expected upgrade to be false.") - } - if !relmap["IsInstall"].(bool) { - t.Errorf("Expected install to be true.") - } - if !res["Capabilities"].(*Capabilities).APIVersions.Has("v1") { - t.Error("Expected Capabilities to have v1 as an API") - } - if res["Capabilities"].(*Capabilities).KubeVersion.Major != "1" { - t.Error("Expected Capabilities to have a Kube version") - } - - vals := res["Values"].(Values) - if vals["name"] != "Haroun" { - t.Errorf("Expected 'Haroun', got %q (%v)", vals["name"], vals) - } - where := vals["where"].(map[string]interface{}) - expects := map[string]string{ - "city": "Baghdad", - "date": "809 CE", - "title": "caliph", - } - for field, expect := range expects { - if got := where[field]; got != expect { - t.Errorf("Expected %q, got %q (%v)", expect, got, where) - } - } -} - func TestReadValuesFile(t *testing.T) { data, err := ReadValuesFile("./testdata/coleridge.yaml") if err != nil { @@ -224,6 +136,7 @@ chapter: } func matchValues(t *testing.T, data map[string]interface{}) { + t.Helper() if data["poet"] != "Coleridge" { t.Errorf("Unexpected poet: %s", data["poet"]) } diff --git a/pkg/helm/pkg/chart/dependency.go b/pkg/helm/pkg/chart/dependency.go index 001c69ac..1226cc59 100644 --- a/pkg/helm/pkg/chart/dependency.go +++ b/pkg/helm/pkg/chart/dependency.go @@ -15,72 +15,50 @@ limitations under the License. package chart -import "time" +import ( + "errors" -// Dependency describes a chart upon which another chart depends. -// -// Dependencies can be used to express developer intent, or to capture the state -// of a chart. -type Dependency struct { - // Name is the name of the dependency. - // - // This must mach the name in the dependency's Chart.yaml. - Name string `json:"name"` - // Version is the version (range) of this chart. - // - // A lock file will always produce a single version, while a dependency - // may contain a semantic version range. - Version string `json:"version,omitempty"` - // The URL to the repository. - // - // Appending `index.yaml` to this string should result in a URL that can be - // used to fetch the repository index. - Repository string `json:"repository"` - // A yaml path that resolves to a boolean, used for enabling/disabling charts (e.g. subchart1.enabled ) - Condition string `json:"condition,omitempty"` - // Tags can be used to group charts for enabling/disabling together - Tags []string `json:"tags,omitempty"` - // Enabled bool determines if chart should be loaded - Enabled bool `json:"enabled,omitempty"` - // ImportValues holds the mapping of source values to parent key to be imported. Each item can be a - // string or pair of child/parent sublist items. - ImportValues []interface{} `json:"import-values,omitempty"` - // Alias usable alias to be used for the chart - Alias string `json:"alias,omitempty"` + v3chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" + v2chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" +) - // ExportValues holds the mapping of parent values to child key to be exported. Each item can be a - // string or pair of parent/child sublist items. - ExportValues []interface{} `json:"export-values,omitempty"` -} +var NewDependencyAccessor func(dep Dependency) (DependencyAccessor, error) = NewDefaultDependencyAccessor //nolint:revive -// Validate checks for common problems with the dependency datastructure in -// the chart. This check must be done at load time before the dependency's charts are -// loaded. -func (d *Dependency) Validate() error { - if d == nil { - return ValidationError("dependencies must not contain empty or null nodes") - } - d.Name = sanitizeString(d.Name) - d.Version = sanitizeString(d.Version) - d.Repository = sanitizeString(d.Repository) - d.Condition = sanitizeString(d.Condition) - for i := range d.Tags { - d.Tags[i] = sanitizeString(d.Tags[i]) +func NewDefaultDependencyAccessor(dep Dependency) (DependencyAccessor, error) { + switch v := dep.(type) { + case v2chart.Dependency: + return &v2DependencyAccessor{&v}, nil + case *v2chart.Dependency: + return &v2DependencyAccessor{v}, nil + case v3chart.Dependency: + return &v3DependencyAccessor{&v}, nil + case *v3chart.Dependency: + return &v3DependencyAccessor{v}, nil + default: + return nil, errors.New("unsupported chart dependency type") } - if d.Alias != "" && !aliasNameFormat.MatchString(d.Alias) { - return ValidationErrorf("dependency %q has disallowed characters in the alias", d.Name) - } - return nil } -// Lock is a lock file for dependencies. -// -// It represents the state that the dependencies should be in. -type Lock struct { - // Generated is the date the lock file was last generated. - Generated time.Time `json:"generated"` - // Digest is a hash of the dependencies in Chart.yaml. - Digest string `json:"digest"` - // Dependencies is the list of dependencies that this lock file has locked. - Dependencies []*Dependency `json:"dependencies"` +type v2DependencyAccessor struct { + dep *v2chart.Dependency +} + +func (r *v2DependencyAccessor) Name() string { + return r.dep.Name +} + +func (r *v2DependencyAccessor) Alias() string { + return r.dep.Alias +} + +type v3DependencyAccessor struct { + dep *v3chart.Dependency +} + +func (r *v3DependencyAccessor) Name() string { + return r.dep.Name +} + +func (r *v3DependencyAccessor) Alias() string { + return r.dep.Alias } diff --git a/pkg/helm/pkg/chart/interfaces.go b/pkg/helm/pkg/chart/interfaces.go new file mode 100644 index 00000000..508dfb54 --- /dev/null +++ b/pkg/helm/pkg/chart/interfaces.go @@ -0,0 +1,44 @@ +/* +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 chart + +import ( + common "github.com/werf/nelm/pkg/helm/pkg/chart/common" +) + +type Charter interface{} + +type Dependency interface{} + +type Accessor interface { + Name() string + IsRoot() bool + MetadataAsMap() map[string]interface{} + Files() []*common.File + Templates() []*common.File + ChartFullPath() string + IsLibraryChart() bool + Dependencies() []Charter + MetaDependencies() []Dependency + Values() map[string]interface{} + Schema() []byte + Deprecated() bool +} + +type DependencyAccessor interface { + Name() string + Alias() string +} diff --git a/pkg/helm/pkg/chart/loader/archive.go b/pkg/helm/pkg/chart/loader/archive/archive.go similarity index 67% rename from pkg/helm/pkg/chart/loader/archive.go rename to pkg/helm/pkg/chart/loader/archive/archive.go index 6139f6c3..e98f5c33 100644 --- a/pkg/helm/pkg/chart/loader/archive.go +++ b/pkg/helm/pkg/chart/loader/archive/archive.go @@ -14,12 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -package loader +// archive provides utility functions for working with Helm chart archive files +package archive import ( "archive/tar" "bytes" "compress/gzip" + "errors" "fmt" "io" "net/http" @@ -27,85 +29,27 @@ import ( "path" "regexp" "strings" - - "github.com/pkg/errors" - - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" + "time" ) -var drivePathPattern = regexp.MustCompile(`^[a-zA-Z]:/`) - -// FileLoader loads a chart from a file -type FileLoader string - -// Load loads a chart -func (l FileLoader) Load(opts helmopts.HelmOptions) (*chart.Chart, error) { - return LoadFile(string(l), opts) -} - -// LoadFile loads from an archive file. -func LoadFile(name string, opts helmopts.HelmOptions) (*chart.Chart, error) { - if fi, err := os.Stat(name); err != nil { - return nil, err - } else if fi.IsDir() { - return nil, errors.New("cannot load a directory") - } - - raw, err := os.Open(name) - if err != nil { - return nil, err - } - defer raw.Close() - - err = ensureArchive(name, raw) - if err != nil { - return nil, err - } - - c, err := LoadArchive(raw, opts) - if err != nil { - if err == gzip.ErrHeader { - return nil, fmt.Errorf("file '%s' does not appear to be a valid chart file (details: %s)", name, err) - } - } - return c, err -} - -// ensureArchive's job is to return an informative error if the file does not appear to be a gzipped archive. -// -// Sometimes users will provide a values.yaml for an argument where a chart is expected. One common occurrence -// of this is invoking `helm template values.yaml mychart` which would otherwise produce a confusing error -// if we didn't check for this. -func ensureArchive(name string, raw *os.File) error { - defer raw.Seek(0, 0) // reset read offset to allow archive loading to proceed. +// MaxDecompressedChartSize is the maximum size of a chart archive that will be +// decompressed. This is the decompressed size of all the files. +// The default value is 100 MiB. +var MaxDecompressedChartSize int64 = 100 * 1024 * 1024 // Default 100 MiB - // Check the file format to give us a chance to provide the user with more actionable feedback. - buffer := make([]byte, 512) - _, err := raw.Read(buffer) - if err != nil && err != io.EOF { - return fmt.Errorf("file '%s' cannot be read: %s", name, err) - } +// MaxDecompressedFileSize is the size of the largest file that Helm will attempt to load. +// The size of the file is the decompressed version of it when it is stored in an archive. +var MaxDecompressedFileSize int64 = 5 * 1024 * 1024 // Default 5 MiB - // Helm may identify achieve of the application/x-gzip as application/vnd.ms-fontobject. - // Fix for: https://github.com/helm/helm/issues/12261 - if contentType := http.DetectContentType(buffer); contentType != "application/x-gzip" && !isGZipApplication(buffer) { - // TODO: Is there a way to reliably test if a file content is YAML? ghodss/yaml accepts a wide - // variety of content (Makefile, .zshrc) as valid YAML without errors. +var drivePathPattern = regexp.MustCompile(`^[a-zA-Z]:/`) - // Wrong content type. Let's check if it's yaml and give an extra hint? - if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") { - return fmt.Errorf("file '%s' seems to be a YAML file, but expected a gzipped archive", name) - } - return fmt.Errorf("file '%s' does not appear to be a gzipped archive; got '%s'", name, contentType) - } - return nil -} +var utf8bom = []byte{0xEF, 0xBB, 0xBF} -// isGZipApplication checks whether the achieve is of the application/x-gzip type. -func isGZipApplication(data []byte) bool { - sig := []byte("\x1F\x8B\x08") - return bytes.HasPrefix(data, sig) +// BufferedFile represents an archive file buffered for later processing. +type BufferedFile struct { + Name string + ModTime time.Time + Data []byte } // LoadArchiveFiles reads in files out of an archive into memory. This function @@ -120,10 +64,11 @@ func LoadArchiveFiles(in io.Reader) ([]*BufferedFile, error) { files := []*BufferedFile{} tr := tar.NewReader(unzipped) + remainingSize := MaxDecompressedChartSize for { b := bytes.NewBuffer(nil) hd, err := tr.Next() - if err == io.EOF { + if errors.Is(err, io.EOF) { break } if err != nil { @@ -161,7 +106,7 @@ func LoadArchiveFiles(in io.Reader) ([]*BufferedFile, error) { n = path.Clean(n) if n == "." { // In this case, the original path was relative when it should have been absolute. - return nil, errors.Errorf("chart illegally contains content outside the base directory: %q", hd.Name) + return nil, fmt.Errorf("chart illegally contains content outside the base directory: %q", hd.Name) } if strings.HasPrefix(n, "..") { return nil, errors.New("chart illegally references parent directory") @@ -179,13 +124,33 @@ func LoadArchiveFiles(in io.Reader) ([]*BufferedFile, error) { return nil, errors.New("chart yaml not in base directory") } - if _, err := io.Copy(b, tr); err != nil { + if hd.Size > remainingSize { + return nil, fmt.Errorf("decompressed chart is larger than the maximum size %d", MaxDecompressedChartSize) + } + + if hd.Size > MaxDecompressedFileSize { + return nil, fmt.Errorf("decompressed chart file %q is larger than the maximum file size %d", hd.Name, MaxDecompressedFileSize) + } + + limitedReader := io.LimitReader(tr, remainingSize) + + bytesWritten, err := io.Copy(b, limitedReader) + if err != nil { return nil, err } + remainingSize -= bytesWritten + // When the bytesWritten are less than the file size it means the limit reader ended + // copying early. Here we report that error. This is important if the last file extracted + // is the one that goes over the limit. It assumes the Size stored in the tar header + // is correct, something many applications do. + if bytesWritten < hd.Size || remainingSize <= 0 { + return nil, fmt.Errorf("decompressed chart is larger than the maximum size %d", MaxDecompressedChartSize) + } + data := bytes.TrimPrefix(b.Bytes(), utf8bom) - files = append(files, &BufferedFile{Name: n, Data: data}) + files = append(files, &BufferedFile{Name: n, ModTime: hd.ModTime, Data: data}) b.Reset() } @@ -195,12 +160,38 @@ func LoadArchiveFiles(in io.Reader) ([]*BufferedFile, error) { return files, nil } -// LoadArchive loads from a reader containing a compressed tar archive. -func LoadArchive(in io.Reader, opts helmopts.HelmOptions) (*chart.Chart, error) { - files, err := LoadArchiveFiles(in) - if err != nil { - return nil, err +// ensureArchive's job is to return an informative error if the file does not appear to be a gzipped archive. +// +// Sometimes users will provide a values.yaml for an argument where a chart is expected. One common occurrence +// of this is invoking `helm template values.yaml mychart` which would otherwise produce a confusing error +// if we didn't check for this. +func EnsureArchive(name string, raw *os.File) error { + defer raw.Seek(0, 0) // reset read offset to allow archive loading to proceed. + + // Check the file format to give us a chance to provide the user with more actionable feedback. + buffer := make([]byte, 512) + _, err := raw.Read(buffer) + if err != nil && err != io.EOF { + return fmt.Errorf("file '%s' cannot be read: %s", name, err) } - return LoadFiles(files, opts) + // Helm may identify achieve of the application/x-gzip as application/vnd.ms-fontobject. + // Fix for: https://github.com/helm/helm/issues/12261 + if contentType := http.DetectContentType(buffer); contentType != "application/x-gzip" && !isGZipApplication(buffer) { + // TODO: Is there a way to reliably test if a file content is YAML? ghodss/yaml accepts a wide + // variety of content (Makefile, .zshrc) as valid YAML without errors. + + // Wrong content type. Let's check if it's yaml and give an extra hint? + if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") { + return fmt.Errorf("file '%s' seems to be a YAML file, but expected a gzipped archive", name) + } + return fmt.Errorf("file '%s' does not appear to be a gzipped archive; got '%s'", name, contentType) + } + return nil +} + +// isGZipApplication checks whether the archive is of the application/x-gzip type. +func isGZipApplication(data []byte) bool { + sig := []byte("\x1F\x8B\x08") + return bytes.HasPrefix(data, sig) } diff --git a/pkg/helm/pkg/chart/loader/archive_test.go b/pkg/helm/pkg/chart/loader/archive/archive_test.go similarity index 93% rename from pkg/helm/pkg/chart/loader/archive_test.go rename to pkg/helm/pkg/chart/loader/archive/archive_test.go index 41b0af1a..2fe09e9b 100644 --- a/pkg/helm/pkg/chart/loader/archive_test.go +++ b/pkg/helm/pkg/chart/loader/archive/archive_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package loader +package archive import ( "archive/tar" @@ -31,8 +31,9 @@ func TestLoadArchiveFiles(t *testing.T) { }{ { name: "empty input should return no files", - generate: func(w *tar.Writer) {}, - check: func(t *testing.T, files []*BufferedFile, err error) { + generate: func(_ *tar.Writer) {}, + check: func(t *testing.T, _ []*BufferedFile, err error) { + t.Helper() if err.Error() != "no files in chart archive" { t.Fatalf(`expected "no files in chart archive", got [%#v]`, err) } @@ -61,6 +62,7 @@ func TestLoadArchiveFiles(t *testing.T) { } }, check: func(t *testing.T, files []*BufferedFile, err error) { + t.Helper() if err != nil { t.Fatalf(`got unwanted error [%#v] for tar file with pax_global_header content`, err) } diff --git a/pkg/helm/pkg/chart/loader/directory.go b/pkg/helm/pkg/chart/loader/directory.go deleted file mode 100644 index 405b2c9e..00000000 --- a/pkg/helm/pkg/chart/loader/directory.go +++ /dev/null @@ -1,215 +0,0 @@ -/* -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 loader - -import ( - "bytes" - "context" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/pkg/errors" - - "github.com/werf/nelm/pkg/helm/internal/sympath" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/ignore" - "github.com/werf/nelm/pkg/helm/pkg/werf/file" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" -) - -var utf8bom = []byte{0xEF, 0xBB, 0xBF} - -// DirLoader loads a chart from a directory -type DirLoader string - -// Load loads the chart -func (l DirLoader) Load(opts helmopts.HelmOptions) (*chart.Chart, error) { - return LoadDir(string(l), opts) -} - -// LoadDir loads from a directory. -// -// This loads charts only from directories. -func LoadDir(dir string, opts helmopts.HelmOptions) (*chart.Chart, error) { - ctx := context.Background() - - var files []*BufferedFile - switch opts.ChartLoadOpts.ChartType { - case helmopts.ChartTypeChart: - var chartTreeFiles []*file.ChartExtenderBufferedFile - if file.ChartFileReader != nil { - chartFiles, err := file.ChartFileReader.LoadChartDir(ctx, dir) - if err != nil { - return nil, fmt.Errorf("load chart dir: %w", err) - } - - chartTreeFiles, err = LoadChartDependencies( - ctx, - file.ChartFileReader.LoadChartDir, - dir, - chartFiles, - opts, - ) - if err != nil { - return nil, fmt.Errorf("load chart dependencies: %w", err) - } - } else { - var chartFiles []*file.ChartExtenderBufferedFile - if files, err := GetFilesFromLocalFilesystem(dir); err != nil { - return nil, fmt.Errorf("load files from filesystem: %w", err) - } else { - chartFiles = convertBufferedFilesForChartExtender(files) - } - - var err error - chartTreeFiles, err = LoadChartDependencies( - ctx, - func(ctx context.Context, dir string) ([]*file.ChartExtenderBufferedFile, error) { - files, err := GetFilesFromLocalFilesystem(dir) - if err != nil { - return nil, fmt.Errorf("load files from filesystem: %w", err) - } - - return convertBufferedFilesForChartExtender(files), nil - }, - dir, - chartFiles, - opts, - ) - if err != nil { - return nil, fmt.Errorf("load chart dependencies: %w", err) - } - } - - files = convertChartExtenderFilesToBufferedFiles(chartTreeFiles) - case helmopts.ChartTypeSubchart: - var err error - files, err = GetFilesFromLocalFilesystem(dir) - if err != nil { - return &chart.Chart{}, err - } - case helmopts.ChartTypeChartStub: - var err error - files, err = GetFilesFromLocalFilesystem(dir) - if err != nil { - return &chart.Chart{}, err - } - case helmopts.ChartTypeBundle: - chartFiles, err := GetFilesFromLocalFilesystem(dir) - if err != nil { - return nil, fmt.Errorf("load files from filesystem: %w", err) - } - - chartTreeFiles, err := LoadChartDependencies( - ctx, - func(ctx context.Context, dir string) ([]*file.ChartExtenderBufferedFile, error) { - files, err := GetFilesFromLocalFilesystem(dir) - if err != nil { - return nil, fmt.Errorf("load files from filesystem: %w", err) - } - - return convertBufferedFilesForChartExtender(files), nil - }, - dir, - convertBufferedFilesForChartExtender(chartFiles), - opts, - ) - if err != nil { - return nil, fmt.Errorf("load chart dependencies: %w", err) - } - - files = convertChartExtenderFilesToBufferedFiles(chartTreeFiles) - default: - panic("unexpected type") - } - - return LoadFiles(files, opts) -} - -func GetFilesFromLocalFilesystem(dir string) ([]*BufferedFile, error) { - topdir, err := filepath.Abs(dir) - if err != nil { - return nil, err - } - - rules := ignore.Empty() - ifile := filepath.Join(topdir, ignore.HelmIgnore) - if _, err := os.Stat(ifile); err == nil { - r, err := ignore.ParseFile(ifile) - if err != nil { - return nil, err - } - rules = r - } - rules.AddDefaults() - - files := []*BufferedFile{} - topdir += string(filepath.Separator) - - walk := func(name string, fi os.FileInfo, err error) error { - n := strings.TrimPrefix(name, topdir) - if n == "" { - // No need to process top level. Avoid bug with helmignore .* matching - // empty names. See issue 1779. - return nil - } - - // Normalize to / since it will also work on Windows - n = filepath.ToSlash(n) - - if err != nil { - return err - } - if fi.IsDir() { - // Directory-based ignore rules should involve skipping the entire - // contents of that directory. - if rules.Ignore(n, fi) { - return filepath.SkipDir - } - return nil - } - - // If a .helmignore file matches, skip this file. - if rules.Ignore(n, fi) { - return nil - } - - // Irregular files include devices, sockets, and other uses of files that - // are not regular files. In Go they have a file mode type bit set. - // See https://golang.org/pkg/os/#FileMode for examples. - if !fi.Mode().IsRegular() { - return fmt.Errorf("cannot load irregular file %s as it has file mode type bits set", name) - } - - data, err := os.ReadFile(name) - if err != nil { - return errors.Wrapf(err, "error reading %s", n) - } - - data = bytes.TrimPrefix(data, utf8bom) - - files = append(files, &BufferedFile{Name: n, Data: data}) - return nil - } - if err = sympath.Walk(topdir, walk); err != nil { - return nil, err - } - - return files, nil -} diff --git a/pkg/helm/pkg/chart/loader/load.go b/pkg/helm/pkg/chart/loader/load.go index 4e797393..d85b3b72 100644 --- a/pkg/helm/pkg/chart/loader/load.go +++ b/pkg/helm/pkg/chart/loader/load.go @@ -17,38 +17,36 @@ limitations under the License. package loader import ( - "bytes" + "compress/gzip" "context" + "errors" "fmt" - "log" + "io" "os" "path/filepath" - "strings" - "github.com/pkg/errors" "sigs.k8s.io/yaml" - "github.com/werf/common-go/pkg/secrets_manager" + nelmcommon "github.com/werf/nelm/pkg/common" + c3 "github.com/werf/nelm/pkg/helm/intern/chart/v3" + c3load "github.com/werf/nelm/pkg/helm/intern/chart/v3/loader" "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/werf/chartextender" - "github.com/werf/nelm/pkg/helm/pkg/werf/file" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" - "github.com/werf/nelm/pkg/helm/pkg/werf/secrets" - "github.com/werf/nelm/pkg/helm/pkg/werf/secrets/runtimedata" + "github.com/werf/nelm/pkg/helm/pkg/chart/loader/archive" + c2 "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + c2load "github.com/werf/nelm/pkg/helm/pkg/chart/v2/loader" ) // ChartLoader loads a chart. type ChartLoader interface { - Load(options helmopts.HelmOptions) (*chart.Chart, error) + Load(ctx context.Context) (chart.Charter, error) } // Loader returns a new ChartLoader appropriate for the given chart name func Loader(name string) (ChartLoader, error) { isDir, err := loader(name) if err != nil { - return nil, errors.Wrapf(err, "error checking if %s is a directory", name) + return nil, err } - if isDir { return DirLoader(name), nil } @@ -56,17 +54,16 @@ func Loader(name string) (ChartLoader, error) { } func loader(name string) (bool, error) { - if file.ChartFileReader == nil { + if nelmcommon.ChartFileReader == nil { fi, err := os.Stat(name) if err != nil { return false, err } - if fi.IsDir() { - return true, nil - } - return false, nil + + return fi.IsDir(), nil } - return file.ChartFileReader.ChartIsDir(name) + + return nelmcommon.ChartFileReader.ChartIsDir(name) } // Load takes a string name, tries to resolve it to a file or directory, and then loads it. @@ -75,290 +72,227 @@ func loader(name string) (bool, error) { // and hand off to the appropriate chart reader. // // If a .helmignore file is present, the directory loader will skip loading any files -// matching it. -func Load(name string, opts helmopts.HelmOptions) (*chart.Chart, error) { +// matching it. But .helmignore is not evaluated when reading out of an archive. +func Load(ctx context.Context, name string) (chart.Charter, error) { l, err := Loader(name) if err != nil { return nil, err } - return l.Load(opts) + + return l.Load(ctx) } -// BufferedFile represents an archive file buffered for later processing. -type BufferedFile struct { - Name string - Data []byte +// DirLoader loads a chart from a directory +type DirLoader string + +// Load loads the chart +func (l DirLoader) Load(ctx context.Context) (chart.Charter, error) { + return LoadDir(ctx, string(l)) } -// LoadFiles loads from in-memory files. -func LoadFiles(files []*BufferedFile, opts helmopts.HelmOptions) (*chart.Chart, error) { - c := new(chart.Chart) - subcharts := make(map[string][]*BufferedFile) +func LoadDir(ctx context.Context, dir string) (chart.Charter, error) { + if nelmcommon.HasHelmOptions(ctx) { + return loadDirWerf(ctx, dir) + } - c.SecretsRuntimeData = secrets.NewSecretsRuntimeData() + return loadDirVanilla(ctx, dir) +} - // do not rely on assumed ordering of files in the chart and crash - // if Chart.yaml was not coming early enough to initialize metadata - for _, f := range files { - c.Raw = append(c.Raw, &chart.File{Name: f.Name, Data: f.Data}) - if f.Name == "Chart.yaml" { - if c.Metadata == nil { - c.Metadata = new(chart.Metadata) - } - if err := yaml.Unmarshal(f.Data, c.Metadata); err != nil { - return c, errors.Wrap(err, "cannot load Chart.yaml") - } - // NOTE(bacongobbler): while the chart specification says that APIVersion must be set, - // Helm 2 accepted charts that did not provide an APIVersion in their chart metadata. - // Because of that, if APIVersion is unset, we should assume we're loading a v1 chart. - if c.Metadata.APIVersion == "" { - c.Metadata.APIVersion = chart.APIVersionV1 - } - } +func loadDirVanilla(ctx context.Context, dir string) (chart.Charter, error) { + topdir, err := filepath.Abs(dir) + if err != nil { + return nil, err } - for _, f := range files { - switch { - case f.Name == "Chart.yaml": - // already processed - continue - case f.Name == "Chart.lock": - c.Lock = new(chart.Lock) - if err := yaml.Unmarshal(f.Data, &c.Lock); err != nil { - return c, errors.Wrap(err, "cannot load Chart.lock") - } - case f.Name == "values.yaml": - c.Values = make(map[string]interface{}) - if err := yaml.Unmarshal(f.Data, &c.Values); err != nil { - return c, errors.Wrap(err, "cannot load values.yaml") - } - case f.Name == "values.schema.json": - c.Schema = f.Data - - // Deprecated: requirements.yaml is deprecated use Chart.yaml. - // We will handle it for you because we are nice people - case f.Name == "requirements.yaml": - if c.Metadata == nil { - c.Metadata = new(chart.Metadata) - } - if c.Metadata.APIVersion != chart.APIVersionV1 { - log.Printf("Warning: Dependencies are handled in Chart.yaml since apiVersion \"v2\". We recommend migrating dependencies to Chart.yaml.") - } - if err := yaml.Unmarshal(f.Data, c.Metadata); err != nil { - return c, errors.Wrap(err, "cannot load requirements.yaml") - } - if c.Metadata.APIVersion == chart.APIVersionV1 { - c.Files = append(c.Files, &chart.File{Name: f.Name, Data: f.Data}) - } - // Deprecated: requirements.lock is deprecated use Chart.lock. - case f.Name == "requirements.lock": - c.Lock = new(chart.Lock) - if err := yaml.Unmarshal(f.Data, &c.Lock); err != nil { - return c, errors.Wrap(err, "cannot load requirements.lock") - } - if c.Metadata == nil { - c.Metadata = new(chart.Metadata) - } - if c.Metadata.APIVersion == chart.APIVersionV1 { - c.Files = append(c.Files, &chart.File{Name: f.Name, Data: f.Data}) - } - case strings.HasPrefix(f.Name, "templates/"): - c.Templates = append(c.Templates, &chart.File{Name: f.Name, Data: f.Data}) - case strings.HasPrefix(f.Name, "charts/"): - if filepath.Ext(f.Name) == ".prov" { - c.Files = append(c.Files, &chart.File{Name: f.Name, Data: f.Data}) - continue - } + name := filepath.Join(topdir, "Chart.yaml") - fname := strings.TrimPrefix(f.Name, "charts/") - cname := strings.SplitN(fname, "/", 2)[0] - subcharts[cname] = append(subcharts[cname], &BufferedFile{Name: fname, Data: f.Data}) - case strings.HasPrefix(f.Name, "ts/node_modules/"): - c.RuntimeDepsFiles = append(c.RuntimeDepsFiles, &chart.File{Name: f.Name, Data: f.Data}) - case strings.HasPrefix(f.Name, "ts/"): - c.RuntimeFiles = append(c.RuntimeFiles, &chart.File{Name: f.Name, Data: f.Data}) - default: - c.Files = append(c.Files, &chart.File{Name: f.Name, Data: f.Data}) - } + data, err := os.ReadFile(name) + if err != nil { + return nil, fmt.Errorf("unable to detect chart at %s: %w", name, err) } - switch opts.ChartLoadOpts.ChartType { - case helmopts.ChartTypeBundle: - c.ExtraValues = opts.ChartLoadOpts.ExtraValues - - if !opts.ChartLoadOpts.NoSecrets { - if err := c.SecretsRuntimeData.DecodeAndLoadSecrets( - context.Background(), - convertBufferedFilesForChartExtender(files), - secrets_manager.Manager, - runtimedata.DecodeAndLoadSecretsOptions{ - CustomSecretValueFiles: opts.ChartLoadOpts.SecretValuesFiles, - LoadFromLocalFilesystem: true, - NoDecryptSecrets: opts.ChartLoadOpts.SecretKeyIgnore, - SecretsWorkingDir: opts.ChartLoadOpts.SecretWorkDir, - WithoutDefaultSecretValues: opts.ChartLoadOpts.DefaultSecretValuesDisable, - }, - ); err != nil { - return nil, fmt.Errorf("error decoding secrets: %w", err) - } - } + c := new(chartBase) + if err = yaml.Unmarshal(data, c); err != nil { + return nil, fmt.Errorf("cannot load Chart.yaml: %w", err) + } - if opts.ChartLoadOpts.DefaultValuesDisable { - c.Values = nil - } - case helmopts.ChartTypeChart: - c.ExtraValues = opts.ChartLoadOpts.ExtraValues - - if !opts.ChartLoadOpts.NoSecrets { - if err := c.SecretsRuntimeData.DecodeAndLoadSecrets( - context.Background(), - convertBufferedFilesForChartExtender(files), - secrets_manager.Manager, - runtimedata.DecodeAndLoadSecretsOptions{ - CustomSecretValueFiles: opts.ChartLoadOpts.SecretValuesFiles, - LoadFromLocalFilesystem: file.ChartFileReader == nil, - NoDecryptSecrets: opts.ChartLoadOpts.SecretKeyIgnore, - SecretsWorkingDir: opts.ChartLoadOpts.SecretWorkDir, - WithoutDefaultSecretValues: opts.ChartLoadOpts.DefaultSecretValuesDisable, - }, - ); err != nil { - return nil, fmt.Errorf("error decoding secrets: %w", err) + switch c.APIVersion { + case c2.APIVersionV1, c2.APIVersionV2, "": + return c2load.Load(ctx, dir) + case c3.APIVersionV3: + return c3load.Load(ctx, dir) + default: + return nil, errors.New("unsupported chart version") + } +} + +func loadDirWerf(ctx context.Context, dir string) (chart.Charter, error) { + helmOpts := nelmcommon.HelmOptionsFromContext(ctx) + + var chartFiles []*nelmcommon.BufferedFile + switch helmOpts.ChartLoadOpts.ChartType { + case nelmcommon.LegacyChartTypeChart: + if nelmcommon.ChartFileReader != nil { + var err error + chartFiles, err = nelmcommon.ChartFileReader.LoadChartDir(ctx, dir) + if err != nil { + return nil, fmt.Errorf("load chart dir: %w", err) + } + } else { + localFiles, err := getFilesFromLocalFilesystem(dir) + if err != nil { + return nil, fmt.Errorf("load chart dir from filesystem: %w", err) } - } - c.Metadata = chartextender.AutosetChartMetadata( - c.Metadata, - chartextender.GetHelmChartMetadataOptions{ - DefaultAPIVersion: opts.ChartLoadOpts.DefaultChartAPIVersion, - DefaultName: opts.ChartLoadOpts.DefaultChartName, - DefaultVersion: opts.ChartLoadOpts.DefaultChartVersion, - OverrideAppVersion: opts.ChartLoadOpts.ChartAppVersion, - }, - ) - - c.Templates = append(c.Templates, &chart.File{ - Name: "templates/_werf_helpers.tpl", - }) - - if opts.ChartLoadOpts.DefaultValuesDisable { - c.Values = nil + chartFiles = localFiles } - case helmopts.ChartTypeSubchart: - if !opts.ChartLoadOpts.NoSecrets { - if err := c.SecretsRuntimeData.DecodeAndLoadSecrets( - context.Background(), - convertBufferedFilesForChartExtender(files), - secrets_manager.Manager, - runtimedata.DecodeAndLoadSecretsOptions{ - LoadFromLocalFilesystem: file.ChartFileReader == nil, - NoDecryptSecrets: opts.ChartLoadOpts.SecretKeyIgnore, - SecretsWorkingDir: opts.ChartLoadOpts.SecretWorkDir, - WithoutDefaultSecretValues: opts.ChartLoadOpts.DefaultSecretValuesDisable, - }, - ); err != nil { - return nil, fmt.Errorf("error decoding secrets: %w", err) - } + case nelmcommon.LegacyChartTypeBundle, nelmcommon.LegacyChartTypeSubchart, nelmcommon.LegacyChartTypeChartStub: + localFiles, err := getFilesFromLocalFilesystem(dir) + if err != nil { + return nil, fmt.Errorf("load chart dir from filesystem: %w", err) } - case helmopts.ChartTypeChartStub: - if !opts.ChartLoadOpts.NoSecrets { - if err := c.SecretsRuntimeData.DecodeAndLoadSecrets( - context.Background(), - convertBufferedFilesForChartExtender(files), - secrets_manager.Manager, - runtimedata.DecodeAndLoadSecretsOptions{ - LoadFromLocalFilesystem: true, - NoDecryptSecrets: opts.ChartLoadOpts.SecretKeyIgnore, - SecretsWorkingDir: opts.ChartLoadOpts.SecretWorkDir, - WithoutDefaultSecretValues: opts.ChartLoadOpts.DefaultSecretValuesDisable, - }, - ); err != nil { - return nil, fmt.Errorf("error decoding secrets: %w", err) + + chartFiles = localFiles + default: + return nil, fmt.Errorf("unexpected chart type: %q", helmOpts.ChartLoadOpts.ChartType) + } + + switch helmOpts.ChartLoadOpts.ChartType { + case nelmcommon.LegacyChartTypeChart, nelmcommon.LegacyChartTypeBundle: + var loadChartDirFunc func(ctx context.Context, dir string) ([]*nelmcommon.BufferedFile, error) + if nelmcommon.ChartFileReader != nil { + loadChartDirFunc = nelmcommon.ChartFileReader.LoadChartDir + } else { + loadChartDirFunc = func(ctx context.Context, dir string) ([]*nelmcommon.BufferedFile, error) { + return getFilesFromLocalFilesystem(dir) } } - c.Metadata = chartextender.AutosetChartMetadata( - c.Metadata, - chartextender.GetHelmChartMetadataOptions{ - DefaultAPIVersion: chart.APIVersionV2, - DefaultName: "stubchartname", - DefaultVersion: "1.0.0", - }, - ) - - c.Templates = append(c.Templates, &chart.File{ - Name: "templates/_werf_helpers.tpl", - }) - default: - panic("unexpected type") + var err error + chartFiles, err = LoadChartDependencies(ctx, loadChartDirFunc, dir, chartFiles, helmOpts) + if err != nil { + return nil, fmt.Errorf("load chart dependencies: %w", err) + } } - if c.Metadata == nil { - return c, errors.New("Chart.yaml file is missing") + files := make([]*archive.BufferedFile, 0, len(chartFiles)) + for _, f := range chartFiles { + files = append(files, &archive.BufferedFile{Name: f.Name, Data: f.Data}) } - if err := c.Validate(); err != nil { - return c, err + apiVersion := detectAPIVersion(chartFiles) + + switch apiVersion { + case c2.APIVersionV1, c2.APIVersionV2, "": + return c2load.LoadFiles(ctx, files) + case c3.APIVersionV3: + return c3load.LoadFiles(ctx, files) + default: + return nil, fmt.Errorf("unsupported chart version: %s", apiVersion) } +} - for n, files := range subcharts { - var sc *chart.Chart - var err error - switch { - case strings.IndexAny(n, "_.") == 0: - continue - case filepath.Ext(n) == ".tgz": - file := files[0] - if file.Name != n { - return c, errors.Errorf("error unpacking tar in %s: expected %s, got %s", c.Name(), n, file.Name) +func detectAPIVersion(files []*nelmcommon.BufferedFile) string { + for _, f := range files { + if f.Name == "Chart.yaml" { + c := new(chartBase) + if err := yaml.Unmarshal(f.Data, c); err == nil { + return c.APIVersion } + } + } - opts.ChartLoadOpts.ChartType = helmopts.ChartTypeSubchart - - // Untar the chart and add to c.Dependencies - sc, err = LoadArchive(bytes.NewBuffer(file.Data), opts) - default: - // We have to trim the prefix off of every file, and ignore any file - // that is in charts/, but isn't actually a chart. - buff := make([]*BufferedFile, 0, len(files)) - for _, f := range files { - parts := strings.SplitN(f.Name, "/", 2) - if len(parts) < 2 { - continue - } - f.Name = parts[1] - buff = append(buff, f) - } + return "" +} + +// FileLoader loads a chart from a file +type FileLoader string + +// Load loads a chart +func (l FileLoader) Load(ctx context.Context) (chart.Charter, error) { + return LoadFile(ctx, string(l)) +} + +func LoadFile(ctx context.Context, name string) (chart.Charter, error) { + if fi, err := os.Stat(name); err != nil { + return nil, err + } else if fi.IsDir() { + return nil, errors.New("cannot load a directory") + } + + raw, err := os.Open(name) + if err != nil { + return nil, err + } + defer raw.Close() - opts.ChartLoadOpts.ChartType = helmopts.ChartTypeSubchart + err = archive.EnsureArchive(name, raw) + if err != nil { + return nil, err + } - sc, err = LoadFiles(buff, opts) + files, err := archive.LoadArchiveFiles(raw) + if err != nil { + if errors.Is(err, gzip.ErrHeader) { + return nil, fmt.Errorf("file '%s' does not appear to be a valid chart file (details: %w)", name, err) } + return nil, errors.New("unable to load chart archive") + } - if err != nil { - return c, errors.Wrapf(err, "error unpacking %s in %s", n, c.Name()) + for _, f := range files { + if f.Name == "Chart.yaml" { + c := new(chartBase) + if err := yaml.Unmarshal(f.Data, c); err != nil { + return c, fmt.Errorf("cannot load Chart.yaml: %w", err) + } + switch c.APIVersion { + case c2.APIVersionV1, c2.APIVersionV2, "": + return c2load.Load(ctx, name) + case c3.APIVersionV3: + return c3load.Load(ctx, name) + default: + return nil, errors.New("unsupported chart version") + } } - c.AddDependency(sc) } - return c, nil + return nil, errors.New("unable to detect chart version, no Chart.yaml found") } -func convertBufferedFilesForChartExtender(files []*BufferedFile) []*file.ChartExtenderBufferedFile { - var res []*file.ChartExtenderBufferedFile - for _, f := range files { - f1 := new(file.ChartExtenderBufferedFile) - *f1 = file.ChartExtenderBufferedFile(*f) - res = append(res, f1) +// LoadArchive loads from a reader containing a compressed tar archive. +func LoadArchive(ctx context.Context, in io.Reader) (chart.Charter, error) { + // Note: This function is for use by SDK users such as Flux. + + files, err := archive.LoadArchiveFiles(in) + if err != nil { + if errors.Is(err, gzip.ErrHeader) { + return nil, fmt.Errorf("stream does not appear to be a valid chart file (details: %w)", err) + } + return nil, fmt.Errorf("unable to load chart archive: %w", err) } - return res -} -func convertChartExtenderFilesToBufferedFiles(files []*file.ChartExtenderBufferedFile) []*BufferedFile { - var res []*BufferedFile for _, f := range files { - f1 := new(BufferedFile) - *f1 = BufferedFile(*f) - res = append(res, f1) + if f.Name == "Chart.yaml" { + c := new(chartBase) + if err := yaml.Unmarshal(f.Data, c); err != nil { + return c, fmt.Errorf("cannot load Chart.yaml: %w", err) + } + switch c.APIVersion { + case c2.APIVersionV1, c2.APIVersionV2, "": + return c2load.LoadFiles(ctx, files) + case c3.APIVersionV3: + return c3load.LoadFiles(ctx, files) + default: + return nil, errors.New("unsupported chart version") + } + } } - return res + + return nil, errors.New("unable to detect chart version, no Chart.yaml found") +} + +// chartBase is used to detect the API Version for the chart to run it through the +// loader for that type. +type chartBase struct { + APIVersion string `json:"apiVersion,omitempty"` } diff --git a/pkg/helm/pkg/chart/loader/load_dependencies.go b/pkg/helm/pkg/chart/loader/load_dependencies.go index 0b13e5d5..eb572201 100644 --- a/pkg/helm/pkg/chart/loader/load_dependencies.go +++ b/pkg/helm/pkg/chart/loader/load_dependencies.go @@ -8,23 +8,26 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "os" "path/filepath" "strings" "github.com/google/uuid" - "github.com/pkg/errors" "sigs.k8s.io/yaml" "github.com/werf/common-go/pkg/locker" "github.com/werf/common-go/pkg/util" "github.com/werf/lockgate" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/werf/file" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" + nelmcommon "github.com/werf/nelm/pkg/common" + "github.com/werf/nelm/pkg/helm/intern/sympath" + "github.com/werf/nelm/pkg/helm/pkg/chart/loader/archive" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/ignore" + "github.com/werf/nelm/pkg/log" ) +var utf8bom = []byte{0xEF, 0xBB, 0xBF} + var localCacheDir string var serviceDir string @@ -66,11 +69,11 @@ func SetServiceDir(dir string) { func LoadChartDependencies( ctx context.Context, - loadChartDirFunc func(ctx context.Context, dir string) ([]*file.ChartExtenderBufferedFile, error), + loadChartDirFunc func(ctx context.Context, dir string) ([]*nelmcommon.BufferedFile, error), chartDir string, - loadedChartFiles []*file.ChartExtenderBufferedFile, - opts helmopts.HelmOptions, -) ([]*file.ChartExtenderBufferedFile, error) { + loadedChartFiles []*nelmcommon.BufferedFile, + opts nelmcommon.HelmOptions, +) ([]*nelmcommon.BufferedFile, error) { res := loadedChartFiles var chartMetadata *chart.Metadata @@ -81,7 +84,7 @@ func LoadChartDependencies( case "Chart.yaml": chartMetadata = new(chart.Metadata) if err := yaml.Unmarshal(f.Data, chartMetadata); err != nil { - return nil, errors.Wrap(err, "cannot load Chart.yaml") + return nil, fmt.Errorf("cannot load Chart.yaml: %w", err) } if chartMetadata.APIVersion == "" { chartMetadata.APIVersion = chart.APIVersionV1 @@ -90,7 +93,7 @@ func LoadChartDependencies( case "Chart.lock": chartMetadataLock = new(chart.Lock) if err := yaml.Unmarshal(f.Data, chartMetadataLock); err != nil { - return nil, errors.Wrap(err, "cannot load Chart.lock") + return nil, fmt.Errorf("cannot load Chart.lock: %w", err) } } } @@ -102,7 +105,7 @@ func LoadChartDependencies( chartMetadata = new(chart.Metadata) } if err := yaml.Unmarshal(f.Data, chartMetadata); err != nil { - return nil, errors.Wrap(err, "cannot load requirements.yaml") + return nil, fmt.Errorf("cannot load requirements.yaml: %w", err) } case "requirements.lock": @@ -110,7 +113,7 @@ func LoadChartDependencies( chartMetadataLock = new(chart.Lock) } if err := yaml.Unmarshal(f.Data, chartMetadataLock); err != nil { - return nil, errors.Wrap(err, "cannot load requirements.lock") + return nil, fmt.Errorf("cannot load requirements.lock: %w", err) } } } @@ -121,8 +124,7 @@ func LoadChartDependencies( if chartMetadataLock == nil { if len(chartMetadata.Dependencies) > 0 && NoChartLockWarning != "" { - // TODO(werf): move logger to common-go and use it - fmt.Println(NoChartLockWarning) + log.Default.Warn(ctx, "%s", NoChartLockWarning) } return res, nil @@ -130,8 +132,6 @@ func LoadChartDependencies( conf := newChartDependenciesConfiguration(chartMetadata, chartMetadataLock) - // Append virtually loaded files from custom dependency repositories in the local filesystem, - // pretending these files are located in the charts/ dir as designed in the Helm. for _, chartDep := range chartMetadataLock.Dependencies { if !strings.HasPrefix(chartDep.Repository, "file://") { continue @@ -164,15 +164,15 @@ func LoadChartDependencies( if err != nil { return nil, fmt.Errorf("error preparing chart dependencies: %w", err) } - localFiles, err := GetFilesFromLocalFilesystem(depsDir) + localFiles, err := getFilesFromLocalFilesystem(depsDir) if err != nil { return nil, err } for _, f := range localFiles { if strings.HasPrefix(f.Name, "charts/") { - f1 := new(file.ChartExtenderBufferedFile) - *f1 = file.ChartExtenderBufferedFile(*f) + f1 := new(nelmcommon.BufferedFile) + *f1 = nelmcommon.BufferedFile(*f) res = append(res, f1) } } @@ -231,7 +231,6 @@ func prepareDependenciesDir(ctx context.Context, metadataBytes, metadataLockByte case err != nil: return fmt.Errorf("error accessing %s: %w", depsDir, err) default: - // at the time we have acquired a lock the target directory was created return nil } @@ -264,18 +263,18 @@ func createChartDependenciesDir(destDir string, metadataBytes, metadataLockBytes return fmt.Errorf("error creating dir %q: %w", destDir, err) } - files := []*file.ChartExtenderBufferedFile{ + files := []*nelmcommon.BufferedFile{ {Name: "Chart.yaml", Data: metadataBytes}, {Name: "Chart.lock", Data: metadataLockBytes}, } - for _, file := range files { - if file == nil { + for _, f := range files { + if f == nil { continue } - path := filepath.Join(destDir, file.Name) - if err := ioutil.WriteFile(path, file.Data, 0o644); err != nil { + path := filepath.Join(destDir, f.Name) + if err := os.WriteFile(path, f.Data, 0o644); err != nil { return fmt.Errorf("error writing %q: %w", path, err) } } @@ -283,7 +282,7 @@ func createChartDependenciesDir(destDir string, metadataBytes, metadataLockBytes return nil } -func getPreparedChartDependenciesDir(ctx context.Context, metadataFile, metadataLockFile *file.ChartExtenderBufferedFile, opts helmopts.HelmOptions) (string, error) { +func getPreparedChartDependenciesDir(ctx context.Context, metadataFile, metadataLockFile *nelmcommon.BufferedFile, opts nelmcommon.HelmOptions) (string, error) { return prepareDependenciesDir(ctx, metadataFile.Data, metadataLockFile.Data, func(tmpDepsDir string) error { if err := buildChartDependenciesInDir(ctx, tmpDepsDir, opts); err != nil { return fmt.Errorf("error building chart dependencies: %w", err) @@ -301,7 +300,7 @@ func newChartDependenciesConfiguration(chartMetadata *chart.Metadata, chartMetad return &chartDependenciesConfiguration{ChartMetadata: chartMetadata, ChartMetadataLock: chartMetadataLock} } -func (conf *chartDependenciesConfiguration) GetExternalDependenciesFiles(loadedChartFiles []*file.ChartExtenderBufferedFile) (bool, *file.ChartExtenderBufferedFile, *file.ChartExtenderBufferedFile, error) { +func (conf *chartDependenciesConfiguration) GetExternalDependenciesFiles(loadedChartFiles []*nelmcommon.BufferedFile) (bool, *nelmcommon.BufferedFile, *nelmcommon.BufferedFile, error) { metadataBytes, err := yaml.Marshal(conf.ChartMetadata) if err != nil { return false, nil, nil, fmt.Errorf("unable to marshal original chart metadata into yaml: %w", err) @@ -320,7 +319,7 @@ func (conf *chartDependenciesConfiguration) GetExternalDependenciesFiles(loadedC return false, nil, nil, fmt.Errorf("unable to unmarshal original chart metadata lock yaml: %w", err) } - metadata.APIVersion = "v2" + metadata.APIVersion = chart.APIVersionV2 var externalDependenciesNames []string isExternalDependency := func(depName string) bool { @@ -329,6 +328,7 @@ func (conf *chartDependenciesConfiguration) GetExternalDependenciesFiles(loadedC return true } } + return false } @@ -369,7 +369,6 @@ FindExternalDependencies: return false, nil, nil, nil } - // Set resolved repository from the lock file for _, dep := range metadata.Dependencies { for _, depLock := range metadataLock.Dependencies { if dep.Name == depLock.Name { @@ -385,14 +384,14 @@ FindExternalDependencies: metadataLock.Digest = newDigest } - metadataFile := &file.ChartExtenderBufferedFile{Name: "Chart.yaml"} + metadataFile := &nelmcommon.BufferedFile{Name: "Chart.yaml"} if data, err := yaml.Marshal(metadata); err != nil { return false, nil, nil, fmt.Errorf("unable to marshal chart metadata file with external dependencies: %w", err) } else { metadataFile.Data = data } - metadataLockFile := &file.ChartExtenderBufferedFile{Name: "Chart.lock"} + metadataLockFile := &nelmcommon.BufferedFile{Name: "Chart.lock"} if data, err := yaml.Marshal(metadataLock); err != nil { return false, nil, nil, fmt.Errorf("unable to marshal chart metadata lock file with external dependencies: %w", err) } else { @@ -407,15 +406,21 @@ func hashReq(req, lock []*chart.Dependency) (string, error) { if err != nil { return "", err } + s, err := digest(bytes.NewBuffer(data)) - return "sha256:" + s, err + if err != nil { + return "", err + } + + return "sha256:" + s, nil } func digest(in io.Reader) (string, error) { hash := crypto.SHA256.New() if _, err := io.Copy(hash, in); err != nil { - return "", nil + return "", err } + return hex.EncodeToString(hash.Sum(nil)), nil } @@ -423,9 +428,85 @@ func makeDependencyArchiveName(depName, depVersion string) string { return fmt.Sprintf("%s-%s.tgz", depName, depVersion) } -func buildChartDependenciesInDir(ctx context.Context, targetDir string, opts helmopts.HelmOptions) error { - opts.ChartLoadOpts.ChartType = helmopts.ChartTypeChartStub - opts.ChartLoadOpts.DepDownloader.SetChartPath(targetDir) +func buildChartDependenciesInDir(ctx context.Context, targetDir string, opts nelmcommon.HelmOptions) error { + if opts.ChartLoadOpts.ChartDepsDownloader == nil { + return fmt.Errorf("dependency downloader is required") + } + + opts.ChartLoadOpts.ChartType = nelmcommon.LegacyChartTypeChartStub + opts.ChartLoadOpts.ChartDepsDownloader.SetChartPath(targetDir) + ctx = nelmcommon.ContextWithHelmOptions(ctx, opts) + + if err := opts.ChartLoadOpts.ChartDepsDownloader.Build(ctx); err != nil { + return fmt.Errorf("build dependencies: %w", err) + } + + return nil +} + +func getFilesFromLocalFilesystem(dir string) ([]*nelmcommon.BufferedFile, error) { + topdir, err := filepath.Abs(dir) + if err != nil { + return nil, err + } + + rules := ignore.Empty() + ifile := filepath.Join(topdir, ignore.HelmIgnore) + if _, err := os.Stat(ifile); err == nil { + r, err := ignore.ParseFile(ifile) + if err != nil { + return nil, err + } + rules = r + } + rules.AddDefaults() + + var files []*nelmcommon.BufferedFile + topdir += string(filepath.Separator) + + walk := func(name string, fi os.FileInfo, err error) error { + n := strings.TrimPrefix(name, topdir) + if n == "" { + return nil + } + + n = filepath.ToSlash(n) + + if err != nil { + return err + } + if fi.IsDir() { + if rules.Ignore(n, fi) { + return filepath.SkipDir + } + return nil + } + + if rules.Ignore(n, fi) { + return nil + } + + if !fi.Mode().IsRegular() { + return fmt.Errorf("cannot load irregular file %s as it has file mode type bits set", name) + } + + if fi.Size() > archive.MaxDecompressedFileSize { + return fmt.Errorf("chart file %q is larger than the maximum file size %d", fi.Name(), archive.MaxDecompressedFileSize) + } + + data, err := os.ReadFile(name) + if err != nil { + return fmt.Errorf("error reading %s: %w", n, err) + } + + data = bytes.TrimPrefix(data, utf8bom) + + files = append(files, &nelmcommon.BufferedFile{Name: n, Data: data}) + return nil + } + if err = sympath.Walk(topdir, walk); err != nil { + return nil, err + } - return opts.ChartLoadOpts.DepDownloader.Build(opts) + return files, nil } diff --git a/pkg/helm/pkg/chart/loader/load_test.go b/pkg/helm/pkg/chart/loader/load_test.go index 93b8d7b3..16cdcf2b 100644 --- a/pkg/helm/pkg/chart/loader/load_test.go +++ b/pkg/helm/pkg/chart/loader/load_test.go @@ -20,719 +20,168 @@ import ( "archive/tar" "bytes" "compress/gzip" + "context" + "fmt" "io" - "log" - "os" + "maps" "path/filepath" - "runtime" "strings" "testing" "time" + c3 "github.com/werf/nelm/pkg/helm/intern/chart/v3" "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" + c2 "github.com/werf/nelm/pkg/helm/pkg/chart/v2" ) -// GlobalLoadOptions is a default set of options for testing -var GlobalLoadOptions = &helmopts.HelmOptions{} - -func TestLoadDir(t *testing.T) { - l, err := Loader("testdata/frobnitz") - if err != nil { - t.Fatalf("Failed to load testdata: %s", err) - } - c, err := l.Load(*GlobalLoadOptions) - if err != nil { - t.Fatalf("Failed to load testdata: %s", err) - } - verifyFrobnitz(t, c) - verifyChart(t, c) - verifyDependencies(t, c) - verifyDependenciesLock(t, c) -} - -func TestLoadDirWithDevNull(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("test only works on unix systems with /dev/null present") - } - - l, err := Loader("testdata/frobnitz_with_dev_null") - if err != nil { - t.Fatalf("Failed to load testdata: %s", err) - } - if _, err := l.Load(*GlobalLoadOptions); err == nil { - t.Errorf("packages with an irregular file (/dev/null) should not load") - } -} - -func TestLoadDirWithSymlink(t *testing.T) { - sym := filepath.Join("..", "LICENSE") - link := filepath.Join("testdata", "frobnitz_with_symlink", "LICENSE") - - if err := os.Symlink(sym, link); err != nil { - t.Fatal(err) - } - - defer os.Remove(link) - - l, err := Loader("testdata/frobnitz_with_symlink") - if err != nil { - t.Fatalf("Failed to load testdata: %s", err) - } - - c, err := l.Load(*GlobalLoadOptions) - if err != nil { - t.Fatalf("Failed to load testdata: %s", err) - } - verifyFrobnitz(t, c) - verifyChart(t, c) - verifyDependencies(t, c) - verifyDependenciesLock(t, c) -} - -func TestBomTestData(t *testing.T) { - testFiles := []string{"frobnitz_with_bom/.helmignore", "frobnitz_with_bom/templates/template.tpl", "frobnitz_with_bom/Chart.yaml"} - for _, file := range testFiles { - data, err := os.ReadFile("testdata/" + file) - if err != nil || !bytes.HasPrefix(data, utf8bom) { - t.Errorf("Test file has no BOM or is invalid: testdata/%s", file) - } - } - - archive, err := os.ReadFile("testdata/frobnitz_with_bom.tgz") - if err != nil { - t.Fatalf("Error reading archive frobnitz_with_bom.tgz: %s", err) - } - unzipped, err := gzip.NewReader(bytes.NewReader(archive)) - if err != nil { - t.Fatalf("Error reading archive frobnitz_with_bom.tgz: %s", err) - } - defer unzipped.Close() - for _, testFile := range testFiles { - data := make([]byte, 3) - err := unzipped.Reset(bytes.NewReader(archive)) - if err != nil { - t.Fatalf("Error reading archive frobnitz_with_bom.tgz: %s", err) +// createChartArchive is a helper function to create a gzipped tar archive in memory +func createChartArchive(t *testing.T, chartName, apiVersion string, extraFiles map[string][]byte, createChartYaml bool) io.Reader { + t.Helper() + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + files := make(map[string][]byte) + maps.Copy(files, extraFiles) + + if createChartYaml { + chartYAMLContent := fmt.Sprintf(`apiVersion: %s +name: %s +version: 0.1.0 +description: A test chart +`, apiVersion, chartName) + files["Chart.yaml"] = []byte(chartYAMLContent) + } + + for name, data := range files { + header := &tar.Header{ + Name: filepath.Join(chartName, name), + Mode: 0644, + Size: int64(len(data)), + ModTime: time.Now(), } - tr := tar.NewReader(unzipped) - for { - file, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - t.Fatalf("Error reading archive frobnitz_with_bom.tgz: %s", err) - } - if file != nil && strings.EqualFold(file.Name, testFile) { - _, err := tr.Read(data) - if err != nil { - t.Fatalf("Error reading archive frobnitz_with_bom.tgz: %s", err) - } else { - break - } - } + if err := tw.WriteHeader(header); err != nil { + t.Fatalf("Failed to write tar header for %s: %v", name, err) } - if !bytes.Equal(data, utf8bom) { - t.Fatalf("Test file has no BOM or is invalid: frobnitz_with_bom.tgz/%s", testFile) + if _, err := tw.Write(data); err != nil { + t.Fatalf("Failed to write tar data for %s: %v", name, err) } } -} - -func TestLoadDirWithUTFBOM(t *testing.T) { - l, err := Loader("testdata/frobnitz_with_bom") - if err != nil { - t.Fatalf("Failed to load testdata: %s", err) - } - c, err := l.Load(*GlobalLoadOptions) - if err != nil { - t.Fatalf("Failed to load testdata: %s", err) - } - verifyFrobnitz(t, c) - verifyChart(t, c) - verifyDependencies(t, c) - verifyDependenciesLock(t, c) - verifyBomStripped(t, c.Files) -} - -func TestLoadArchiveWithUTFBOM(t *testing.T) { - l, err := Loader("testdata/frobnitz_with_bom.tgz") - if err != nil { - t.Fatalf("Failed to load testdata: %s", err) - } - c, err := l.Load(*GlobalLoadOptions) - if err != nil { - t.Fatalf("Failed to load testdata: %s", err) - } - verifyFrobnitz(t, c) - verifyChart(t, c) - verifyDependencies(t, c) - verifyDependenciesLock(t, c) - verifyBomStripped(t, c.Files) -} -func TestLoadV1(t *testing.T) { - l, err := Loader("testdata/frobnitz.v1") - if err != nil { - t.Fatalf("Failed to load testdata: %s", err) + if err := tw.Close(); err != nil { + t.Fatalf("Failed to close tar writer: %v", err) } - c, err := l.Load(*GlobalLoadOptions) - if err != nil { - t.Fatalf("Failed to load testdata: %s", err) + if err := gw.Close(); err != nil { + t.Fatalf("Failed to close gzip writer: %v", err) } - verifyDependencies(t, c) - verifyDependenciesLock(t, c) + return &buf } -func TestLoadFileV1(t *testing.T) { - l, err := Loader("testdata/frobnitz.v1.tgz") - if err != nil { - t.Fatalf("Failed to load testdata: %s", err) - } - c, err := l.Load(*GlobalLoadOptions) - if err != nil { - t.Fatalf("Failed to load testdata: %s", err) - } - verifyDependencies(t, c) - verifyDependenciesLock(t, c) -} - -func TestLoadFile(t *testing.T) { - l, err := Loader("testdata/frobnitz-1.2.3.tgz") - if err != nil { - t.Fatalf("Failed to load testdata: %s", err) - } - c, err := l.Load(*GlobalLoadOptions) - if err != nil { - t.Fatalf("Failed to load testdata: %s", err) - } - verifyFrobnitz(t, c) - verifyChart(t, c) - verifyDependencies(t, c) -} - -func TestLoadFiles_BadCases(t *testing.T) { - for _, tt := range []struct { - name string - bufferedFiles []*BufferedFile - expectError string +func TestLoadArchive(t *testing.T) { + testCases := []struct { + name string + chartName string + apiVersion string + extraFiles map[string][]byte + inputReader io.Reader + expectedChart chart.Charter + expectedError string + createChartYaml bool }{ { - name: "These files contain only requirements.lock", - bufferedFiles: []*BufferedFile{ - { - Name: "requirements.lock", - Data: []byte(""), - }, + name: "valid v2 chart archive", + chartName: "mychart-v2", + apiVersion: c2.APIVersionV2, + extraFiles: map[string][]byte{"templates/config.yaml": []byte("key: value")}, + expectedChart: &c2.Chart{ + Metadata: &c2.Metadata{APIVersion: c2.APIVersionV2, Name: "mychart-v2", Version: "0.1.0", Description: "A test chart"}, }, - expectError: "validation: chart.metadata.apiVersion is required"}, - } { - _, err := LoadFiles(tt.bufferedFiles, *GlobalLoadOptions) - if err == nil { - t.Fatal("expected error when load illegal files") - } - if !strings.Contains(err.Error(), tt.expectError) { - t.Errorf("Expected error to contain %q, got %q for %s", tt.expectError, err.Error(), tt.name) - } - } -} - -func TestLoadFiles(t *testing.T) { - goodFiles := []*BufferedFile{ - { - Name: "Chart.yaml", - Data: []byte(`apiVersion: v1 -name: frobnitz -description: This is a frobnitz. -version: "1.2.3" -keywords: - - frobnitz - - sprocket - - dodad -maintainers: - - name: The Helm Team - email: helm@example.com - - name: Someone Else - email: nobody@example.com -sources: - - https://example.com/foo/bar -home: http://example.com -icon: https://example.com/64x64.png -`), - }, - { - Name: "values.yaml", - Data: []byte("var: some values"), - }, - { - Name: "values.schema.json", - Data: []byte("type: Values"), - }, - { - Name: "templates/deployment.yaml", - Data: []byte("some deployment"), + createChartYaml: true, }, { - Name: "templates/service.yaml", - Data: []byte("some service"), - }, - } - - c, err := LoadFiles(goodFiles, *GlobalLoadOptions) - if err != nil { - t.Errorf("Expected good files to be loaded, got %v", err) - } - - if c.Name() != "frobnitz" { - t.Errorf("Expected chart name to be 'frobnitz', got %s", c.Name()) - } - - if c.Values["var"] != "some values" { - t.Error("Expected chart values to be populated with default values") - } - - if len(c.Raw) != 5 { - t.Errorf("Expected %d files, got %d", 5, len(c.Raw)) - } - - if !bytes.Equal(c.Schema, []byte("type: Values")) { - t.Error("Expected chart schema to be populated with default values") - } - - if len(c.Templates) != 2 { - t.Errorf("Expected number of templates == 2, got %d", len(c.Templates)) - } - - if _, err = LoadFiles([]*BufferedFile{}, *GlobalLoadOptions); err == nil { - t.Fatal("Expected err to be non-nil") - } - if err.Error() != "Chart.yaml file is missing" { - t.Errorf("Expected chart metadata missing error, got '%s'", err.Error()) - } -} - -// Test the order of file loading. The Chart.yaml file needs to come first for -// later comparison checks. See https://github.com/helm/helm/pull/8948 -func TestLoadFilesOrder(t *testing.T) { - goodFiles := []*BufferedFile{ - { - Name: "requirements.yaml", - Data: []byte("dependencies:"), + name: "valid v3 chart archive", + chartName: "mychart-v3", + apiVersion: c3.APIVersionV3, + extraFiles: map[string][]byte{"templates/config.yaml": []byte("key: value")}, + expectedChart: &c3.Chart{ + Metadata: &c3.Metadata{APIVersion: c3.APIVersionV3, Name: "mychart-v3", Version: "0.1.0", Description: "A test chart"}, + }, + createChartYaml: true, }, { - Name: "values.yaml", - Data: []byte("var: some values"), + name: "invalid gzip header", + inputReader: bytes.NewBufferString("not a gzip file"), + expectedError: "stream does not appear to be a valid chart file (details: gzip: invalid header)", }, - { - Name: "templates/deployment.yaml", - Data: []byte("some deployment"), + name: "archive without Chart.yaml", + chartName: "no-chart-yaml", + apiVersion: c2.APIVersionV2, // This will be ignored as Chart.yaml is missing + extraFiles: map[string][]byte{"values.yaml": []byte("foo: bar")}, + expectedError: "unable to detect chart version, no Chart.yaml found", + createChartYaml: false, }, { - Name: "templates/service.yaml", - Data: []byte("some service"), + name: "archive with malformed Chart.yaml", + chartName: "malformed-chart-yaml", + apiVersion: c2.APIVersionV2, + extraFiles: map[string][]byte{"Chart.yaml": []byte("apiVersion: v2\nname: mychart\nversion: 0.1.0\ndescription: A test chart\ninvalid: :")}, + expectedError: "cannot load Chart.yaml: error converting YAML to JSON: yaml: line 5: mapping values are not allowed in this context", + createChartYaml: false, }, { - Name: "Chart.yaml", - Data: []byte(`apiVersion: v1 -name: frobnitz -description: This is a frobnitz. -version: "1.2.3" -keywords: - - frobnitz - - sprocket - - dodad -maintainers: - - name: The Helm Team - email: helm@example.com - - name: Someone Else - email: nobody@example.com -sources: - - https://example.com/foo/bar -home: http://example.com -icon: https://example.com/64x64.png -`), + name: "unsupported API version", + chartName: "unsupported-api", + apiVersion: "v99", + expectedError: "unsupported chart version", + createChartYaml: true, }, } - // Capture stderr to make sure message about Chart.yaml handle dependencies - // is not present - r, w, err := os.Pipe() - if err != nil { - t.Fatalf("Unable to create pipe: %s", err) - } - stderr := log.Writer() - log.SetOutput(w) - defer func() { - log.SetOutput(stderr) - }() - - _, err = LoadFiles(goodFiles, *GlobalLoadOptions) - if err != nil { - t.Errorf("Expected good files to be loaded, got %v", err) - } - w.Close() - - var text bytes.Buffer - io.Copy(&text, r) - if text.String() != "" { - t.Errorf("Expected no message to Stderr, got %s", text.String()) - } - -} - -// Packaging the chart on a Windows machine will produce an -// archive that has \\ as delimiters. Test that we support these archives -func TestLoadFileBackslash(t *testing.T) { - c, err := Load("testdata/frobnitz_backslash-1.2.3.tgz", *GlobalLoadOptions) - if err != nil { - t.Fatalf("Failed to load testdata: %s", err) - } - verifyChartFileAndTemplate(t, c, "frobnitz_backslash") - verifyChart(t, c) - verifyDependencies(t, c) -} - -func TestLoadV2WithReqs(t *testing.T) { - l, err := Loader("testdata/frobnitz.v2.reqs") - if err != nil { - t.Fatalf("Failed to load testdata: %s", err) - } - c, err := l.Load(*GlobalLoadOptions) - if err != nil { - t.Fatalf("Failed to load testdata: %s", err) - } - verifyDependencies(t, c) - verifyDependenciesLock(t, c) -} - -func TestLoadInvalidArchive(t *testing.T) { - tmpdir := t.TempDir() - - writeTar := func(filename, internalPath string, body []byte) { - dest, err := os.Create(filename) - if err != nil { - t.Fatal(err) - } - zipper := gzip.NewWriter(dest) - tw := tar.NewWriter(zipper) - - h := &tar.Header{ - Name: internalPath, - Mode: 0755, - Size: int64(len(body)), - ModTime: time.Now(), - } - if err := tw.WriteHeader(h); err != nil { - t.Fatal(err) - } - if _, err := tw.Write(body); err != nil { - t.Fatal(err) - } - tw.Close() - zipper.Close() - dest.Close() - } - - for _, tt := range []struct { - chartname string - internal string - expectError string - }{ - {"illegal-dots.tgz", "../../malformed-helm-test", "chart illegally references parent directory"}, - {"illegal-dots2.tgz", "/foo/../../malformed-helm-test", "chart illegally references parent directory"}, - {"illegal-dots3.tgz", "/../../malformed-helm-test", "chart illegally references parent directory"}, - {"illegal-dots4.tgz", "./../../malformed-helm-test", "chart illegally references parent directory"}, - {"illegal-name.tgz", "./.", "chart illegally contains content outside the base directory"}, - {"illegal-name2.tgz", "/./.", "chart illegally contains content outside the base directory"}, - {"illegal-name3.tgz", "missing-leading-slash", "chart illegally contains content outside the base directory"}, - {"illegal-name4.tgz", "/missing-leading-slash", "Chart.yaml file is missing"}, - {"illegal-abspath.tgz", "//foo", "chart illegally contains absolute paths"}, - {"illegal-abspath2.tgz", "///foo", "chart illegally contains absolute paths"}, - {"illegal-abspath3.tgz", "\\\\foo", "chart illegally contains absolute paths"}, - {"illegal-abspath3.tgz", "\\..\\..\\foo", "chart illegally references parent directory"}, - - // Under special circumstances, this can get normalized to things that look like absolute Windows paths - {"illegal-abspath4.tgz", "\\.\\c:\\\\foo", "chart contains illegally named files"}, - {"illegal-abspath5.tgz", "/./c://foo", "chart contains illegally named files"}, - {"illegal-abspath6.tgz", "\\\\?\\Some\\windows\\magic", "chart illegally contains absolute paths"}, - } { - illegalChart := filepath.Join(tmpdir, tt.chartname) - writeTar(illegalChart, tt.internal, []byte("hello: world")) - _, err := Load(illegalChart, *GlobalLoadOptions) - if err == nil { - t.Fatal("expected error when unpacking illegal files") - } - if !strings.Contains(err.Error(), tt.expectError) { - t.Errorf("Expected error to contain %q, got %q for %s", tt.expectError, err.Error(), tt.chartname) - } - } - - // Make sure that absolute path gets interpreted as relative - illegalChart := filepath.Join(tmpdir, "abs-path.tgz") - writeTar(illegalChart, "/Chart.yaml", []byte("hello: world")) - _, err := Load(illegalChart, *GlobalLoadOptions) - if err.Error() != "validation: chart.metadata.name is required" { - t.Error(err) - } - - // And just to validate that the above was not spurious - illegalChart = filepath.Join(tmpdir, "abs-path2.tgz") - writeTar(illegalChart, "files/whatever.yaml", []byte("hello: world")) - _, err = Load(illegalChart, *GlobalLoadOptions) - if err.Error() != "Chart.yaml file is missing" { - t.Errorf("Unexpected error message: %s", err) - } - - // Finally, test that drive letter gets stripped off on Windows - illegalChart = filepath.Join(tmpdir, "abs-winpath.tgz") - writeTar(illegalChart, "c:\\Chart.yaml", []byte("hello: world")) - _, err = Load(illegalChart, *GlobalLoadOptions) - if err.Error() != "validation: chart.metadata.name is required" { - t.Error(err) - } -} - -func verifyChart(t *testing.T, c *chart.Chart) { - t.Helper() - if c.Name() == "" { - t.Fatalf("No chart metadata found on %v", c) - } - t.Logf("Verifying chart %s", c.Name()) - if len(c.Templates) != 1 { - t.Errorf("Expected 1 template, got %d", len(c.Templates)) - } - - numfiles := 6 - if len(c.Files) != numfiles { - t.Errorf("Expected %d extra files, got %d", numfiles, len(c.Files)) - for _, n := range c.Files { - t.Logf("\t%s", n.Name) - } - } - - if len(c.Dependencies()) != 2 { - t.Errorf("Expected 2 dependencies, got %d (%v)", len(c.Dependencies()), c.Dependencies()) - for _, d := range c.Dependencies() { - t.Logf("\tSubchart: %s\n", d.Name()) - } - } - - expect := map[string]map[string]string{ - "alpine": { - "version": "0.1.0", - }, - "mariner": { - "version": "4.3.2", - }, - } - - for _, dep := range c.Dependencies() { - if dep.Metadata == nil { - t.Fatalf("expected metadata on dependency: %v", dep) - } - exp, ok := expect[dep.Name()] - if !ok { - t.Fatalf("Unknown dependency %s", dep.Name()) - } - if exp["version"] != dep.Metadata.Version { - t.Errorf("Expected %s version %s, got %s", dep.Name(), exp["version"], dep.Metadata.Version) - } - } - -} - -func verifyDependencies(t *testing.T, c *chart.Chart) { - if len(c.Metadata.Dependencies) != 2 { - t.Errorf("Expected 2 dependencies, got %d", len(c.Metadata.Dependencies)) - } - tests := []*chart.Dependency{ - {Name: "alpine", Version: "0.1.0", Repository: "https://example.com/charts"}, - {Name: "mariner", Version: "4.3.2", Repository: "https://example.com/charts"}, - } - for i, tt := range tests { - d := c.Metadata.Dependencies[i] - if d.Name != tt.Name { - t.Errorf("Expected dependency named %q, got %q", tt.Name, d.Name) - } - if d.Version != tt.Version { - t.Errorf("Expected dependency named %q to have version %q, got %q", tt.Name, tt.Version, d.Version) - } - if d.Repository != tt.Repository { - t.Errorf("Expected dependency named %q to have repository %q, got %q", tt.Name, tt.Repository, d.Repository) - } - } -} - -func verifyDependenciesLock(t *testing.T, c *chart.Chart) { - if len(c.Metadata.Dependencies) != 2 { - t.Errorf("Expected 2 dependencies, got %d", len(c.Metadata.Dependencies)) - } - tests := []*chart.Dependency{ - {Name: "alpine", Version: "0.1.0", Repository: "https://example.com/charts"}, - {Name: "mariner", Version: "4.3.2", Repository: "https://example.com/charts"}, - } - for i, tt := range tests { - d := c.Metadata.Dependencies[i] - if d.Name != tt.Name { - t.Errorf("Expected dependency named %q, got %q", tt.Name, d.Name) - } - if d.Version != tt.Version { - t.Errorf("Expected dependency named %q to have version %q, got %q", tt.Name, tt.Version, d.Version) - } - if d.Repository != tt.Repository { - t.Errorf("Expected dependency named %q to have repository %q, got %q", tt.Name, tt.Repository, d.Repository) - } - } -} + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var reader io.Reader + if tc.inputReader != nil { + reader = tc.inputReader + } else { + reader = createChartArchive(t, tc.chartName, tc.apiVersion, tc.extraFiles, tc.createChartYaml) + } -func verifyFrobnitz(t *testing.T, c *chart.Chart) { - verifyChartFileAndTemplate(t, c, "frobnitz") -} + loadedChart, err := LoadArchive(context.Background(), reader) -func verifyChartFileAndTemplate(t *testing.T, c *chart.Chart, name string) { - if c.Metadata == nil { - t.Fatal("Metadata is nil") - } - if c.Name() != name { - t.Errorf("Expected %s, got %s", name, c.Name()) - } - if len(c.Templates) != 1 { - t.Fatalf("Expected 1 template, got %d", len(c.Templates)) - } - if c.Templates[0].Name != "templates/template.tpl" { - t.Errorf("Unexpected template: %s", c.Templates[0].Name) - } - if len(c.Templates[0].Data) == 0 { - t.Error("No template data.") - } - if len(c.Files) != 6 { - t.Fatalf("Expected 6 Files, got %d", len(c.Files)) - } - if len(c.Dependencies()) != 2 { - t.Fatalf("Expected 2 Dependency, got %d", len(c.Dependencies())) - } - if len(c.Metadata.Dependencies) != 2 { - t.Fatalf("Expected 2 Dependencies.Dependency, got %d", len(c.Metadata.Dependencies)) - } - if len(c.Lock.Dependencies) != 2 { - t.Fatalf("Expected 2 Lock.Dependency, got %d", len(c.Lock.Dependencies)) - } - - for _, dep := range c.Dependencies() { - switch dep.Name() { - case "mariner": - case "alpine": - if len(dep.Templates) != 1 { - t.Fatalf("Expected 1 template, got %d", len(dep.Templates)) - } - if dep.Templates[0].Name != "templates/alpine-pod.yaml" { - t.Errorf("Unexpected template: %s", dep.Templates[0].Name) + if tc.expectedError != "" { + if err == nil || !strings.Contains(err.Error(), tc.expectedError) { + t.Errorf("Expected error containing %q, but got %v", tc.expectedError, err) + } + return } - if len(dep.Templates[0].Data) == 0 { - t.Error("No template data.") + + if err != nil { + t.Fatalf("Unexpected error: %v", err) } - if len(dep.Files) != 1 { - t.Fatalf("Expected 1 Files, got %d", len(dep.Files)) + lac, err := chart.NewAccessor(loadedChart) + if err != nil { + t.Fatalf("Unexpected error: %v", err) } - if len(dep.Dependencies()) != 2 { - t.Fatalf("Expected 2 Dependency, got %d", len(dep.Dependencies())) + eac, err := chart.NewAccessor(tc.expectedChart) + if err != nil { + t.Fatalf("Unexpected error: %v", err) } - default: - t.Errorf("Unexpected dependency %s", dep.Name()) - } - } -} -func verifyBomStripped(t *testing.T, files []*chart.File) { - for _, file := range files { - if bytes.HasPrefix(file.Data, utf8bom) { - t.Errorf("Byte Order Mark still present in processed file %s", file.Name) - } - } -} - -func TestLoadFilesRuntimeFiles(t *testing.T) { - files := []*BufferedFile{ - { - Name: "Chart.yaml", - Data: []byte(`apiVersion: v2 -name: test-chart -version: "1.0.0" -`), - }, - { - Name: "ts/runtime.ts", - Data: []byte("console.log('runtime');"), - }, - { - Name: "ts/utils/helper.ts", - Data: []byte("export function helper() {}"), - }, - { - Name: "templates/deployment.yaml", - Data: []byte("some deployment"), - }, - { - Name: "values.yaml", - Data: []byte("key: value"), - }, - } - - c, err := LoadFiles(files, *GlobalLoadOptions) - if err != nil { - t.Fatalf("Expected files to be loaded, got %v", err) - } - - // Verify RuntimeFiles are loaded correctly - if len(c.RuntimeFiles) != 2 { - t.Errorf("Expected 2 runtime files, got %d", len(c.RuntimeFiles)) - } - - expectedRuntimeFiles := map[string][]byte{ - "ts/runtime.ts": []byte("console.log('runtime');"), - "ts/utils/helper.ts": []byte("export function helper() {}"), - } - - for _, rf := range c.RuntimeFiles { - expected, ok := expectedRuntimeFiles[rf.Name] - if !ok { - t.Errorf("Unexpected runtime file: %s", rf.Name) - continue - } - if !bytes.Equal(rf.Data, expected) { - t.Errorf("Runtime file %s has unexpected content", rf.Name) - } - } - - // Verify runtime files are NOT in Files collection - for _, f := range c.Files { - if strings.HasPrefix(f.Name, "ts/") { - t.Errorf("Runtime file %s should not be in Files collection", f.Name) - } - } - - // Verify runtime files are NOT in Templates collection - for _, f := range c.Templates { - if strings.HasPrefix(f.Name, "ts/") { - t.Errorf("Runtime file %s should not be in Templates collection", f.Name) - } - } - - // Verify other files are loaded correctly - // Note: default ChartTypeChart adds _werf_helpers.tpl template, so we expect 2 templates - if len(c.Templates) != 2 { - t.Errorf("Expected 2 templates, got %d", len(c.Templates)) - } + if lac.Name() != eac.Name() { + t.Errorf("Expected chart name %q, got %q", eac.Name(), lac.Name()) + } - // Verify the user template is present - foundDeployment := false - for _, tmpl := range c.Templates { - if tmpl.Name == "templates/deployment.yaml" { - foundDeployment = true - break - } - } - if !foundDeployment { - t.Error("Expected to find templates/deployment.yaml template") + var loadedAPIVersion string + switch lc := loadedChart.(type) { + case *c2.Chart: + loadedAPIVersion = lc.Metadata.APIVersion + case *c3.Chart: + loadedAPIVersion = lc.Metadata.APIVersion + } + if loadedAPIVersion != tc.apiVersion { + t.Errorf("Expected API version %q, got %q", tc.apiVersion, loadedAPIVersion) + } + }) } } diff --git a/pkg/helm/pkg/chart/chart.go b/pkg/helm/pkg/chart/v2/chart.go similarity index 85% rename from pkg/helm/pkg/chart/chart.go rename to pkg/helm/pkg/chart/v2/chart.go index f11c0495..4496b768 100644 --- a/pkg/helm/pkg/chart/chart.go +++ b/pkg/helm/pkg/chart/v2/chart.go @@ -13,14 +13,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -package chart +package v2 import ( "path/filepath" "regexp" "strings" + "time" - "github.com/werf/nelm/pkg/helm/pkg/werf/secrets/runtimedata" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" ) // APIVersionV1 is the API version number for version 1. @@ -39,30 +40,32 @@ type Chart struct { // // This should not be used except in special cases like `helm show values`, // where we want to display the raw values, comments and all. - Raw []*File `json:"-" copy:"shallow"` + Raw []*common.File `json:"-"` // Metadata is the contents of the Chartfile. Metadata *Metadata `json:"metadata"` // Lock is the contents of Chart.lock. Lock *Lock `json:"lock"` // Templates for this chart. - Templates []*File `json:"templates" copy:"shallow"` + Templates []*common.File `json:"templates"` // Values are default config for this chart. Values map[string]interface{} `json:"values"` // Schema is an optional JSON schema for imposing structure on Values Schema []byte `json:"schema"` + // SchemaModTime the schema was last modified + SchemaModTime time.Time `json:"schemamodtime,omitempty"` // Files are miscellaneous files in a chart archive, // e.g. README, LICENSE, etc. - Files []*File `json:"files" copy:"shallow"` - // Files that are used at runtime, but should not be saved to secret/configmap. - RuntimeFiles []*File `json:"-" copy:"shallow"` - // Dependencies for RuntimeFiles that are used at runtime, but should not be saved to secret/configmap and not added to packaged chart. - RuntimeDepsFiles []*File `json:"-" copy:"shallow"` + Files []*common.File `json:"files"` + // ModTime the chart metadata was last modified + ModTime time.Time `json:"modtime,omitzero"` + + RuntimeFiles []*common.File `json:"-"` + RuntimeDepsFiles []*common.File `json:"-"` + ExtraValues map[string]interface{} `json:"-"` + SecretsRuntimeData common.RuntimeData `json:"-"` parent *Chart dependencies []*Chart - - SecretsRuntimeData runtimedata.RuntimeData `json:"-"` - ExtraValues map[string]interface{} `json:"-"` } type CRD struct { @@ -71,7 +74,7 @@ type CRD struct { // Filename is the File obj Name including (sub-)chart.ChartFullPath Filename string // File is the File obj for the crd - File *File + File *common.File } // SetDependencies replaces the chart dependencies. @@ -122,6 +125,8 @@ func (ch *Chart) ChartPath() string { } // ChartFullPath returns the full path to this chart. +// Note that the path may not correspond to the path where the file can be found on the file system if the path +// points to an aliased subchart. func (ch *Chart) ChartFullPath() string { if !ch.IsRoot() { return ch.Parent().ChartFullPath() + "/charts/" + ch.Name() @@ -144,8 +149,8 @@ func (ch *Chart) AppVersion() string { // CRDs returns a list of File objects in the 'crds/' directory of a Helm chart. // Deprecated: use CRDObjects() -func (ch *Chart) CRDs() []*File { - files := []*File{} +func (ch *Chart) CRDs() []*common.File { + files := []*common.File{} // Find all resources in the crds/ directory for _, f := range ch.Files { if strings.HasPrefix(f.Name, "crds/") && hasManifestExtension(f.Name) { diff --git a/pkg/helm/pkg/chart/chart_test.go b/pkg/helm/pkg/chart/v2/chart_test.go similarity index 66% rename from pkg/helm/pkg/chart/chart_test.go rename to pkg/helm/pkg/chart/v2/chart_test.go index 62d60765..bdd11b17 100644 --- a/pkg/helm/pkg/chart/chart_test.go +++ b/pkg/helm/pkg/chart/v2/chart_test.go @@ -13,37 +13,46 @@ 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 chart +package v2 import ( "encoding/json" "testing" + "time" "github.com/stretchr/testify/assert" + + "github.com/werf/nelm/pkg/helm/pkg/chart/common" ) func TestCRDs(t *testing.T) { + modTime := time.Now() chrt := Chart{ - Files: []*File{ + Files: []*common.File{ { - Name: "crds/foo.yaml", - Data: []byte("hello"), + Name: "crds/foo.yaml", + ModTime: modTime, + Data: []byte("hello"), }, { - Name: "bar.yaml", - Data: []byte("hello"), + Name: "bar.yaml", + ModTime: modTime, + Data: []byte("hello"), }, { - Name: "crds/foo/bar/baz.yaml", - Data: []byte("hello"), + Name: "crds/foo/bar/baz.yaml", + ModTime: modTime, + Data: []byte("hello"), }, { - Name: "crdsfoo/bar/baz.yaml", - Data: []byte("hello"), + Name: "crdsfoo/bar/baz.yaml", + ModTime: modTime, + Data: []byte("hello"), }, { - Name: "crds/README.md", - Data: []byte("# hello"), + Name: "crds/README.md", + ModTime: modTime, + Data: []byte("# hello"), }, }, } @@ -57,10 +66,11 @@ func TestCRDs(t *testing.T) { func TestSaveChartNoRawData(t *testing.T) { chrt := Chart{ - Raw: []*File{ + Raw: []*common.File{ { - Name: "fhqwhgads.yaml", - Data: []byte("Everybody to the Limit"), + Name: "fhqwhgads.yaml", + ModTime: time.Now(), + Data: []byte("Everybody to the Limit"), }, }, } @@ -76,7 +86,7 @@ func TestSaveChartNoRawData(t *testing.T) { t.Fatal(err) } - is.Equal([]*File(nil), res.Raw) + is.Equal([]*common.File(nil), res.Raw) } func TestMetadata(t *testing.T) { @@ -161,27 +171,33 @@ func TestChartFullPath(t *testing.T) { } func TestCRDObjects(t *testing.T) { + modTime := time.Now() chrt := Chart{ - Files: []*File{ + Files: []*common.File{ { - Name: "crds/foo.yaml", - Data: []byte("hello"), + Name: "crds/foo.yaml", + ModTime: modTime, + Data: []byte("hello"), }, { - Name: "bar.yaml", - Data: []byte("hello"), + Name: "bar.yaml", + ModTime: modTime, + Data: []byte("hello"), }, { - Name: "crds/foo/bar/baz.yaml", - Data: []byte("hello"), + Name: "crds/foo/bar/baz.yaml", + ModTime: modTime, + Data: []byte("hello"), }, { - Name: "crdsfoo/bar/baz.yaml", - Data: []byte("hello"), + Name: "crdsfoo/bar/baz.yaml", + ModTime: modTime, + Data: []byte("hello"), }, { - Name: "crds/README.md", - Data: []byte("# hello"), + Name: "crds/README.md", + ModTime: modTime, + Data: []byte("# hello"), }, }, } @@ -190,17 +206,19 @@ func TestCRDObjects(t *testing.T) { { Name: "crds/foo.yaml", Filename: "crds/foo.yaml", - File: &File{ - Name: "crds/foo.yaml", - Data: []byte("hello"), + File: &common.File{ + Name: "crds/foo.yaml", + ModTime: modTime, + Data: []byte("hello"), }, }, { Name: "crds/foo/bar/baz.yaml", Filename: "crds/foo/bar/baz.yaml", - File: &File{ - Name: "crds/foo/bar/baz.yaml", - Data: []byte("hello"), + File: &common.File{ + Name: "crds/foo/bar/baz.yaml", + ModTime: modTime, + Data: []byte("hello"), }, }, } diff --git a/pkg/helm/pkg/chart/v2/dependency.go b/pkg/helm/pkg/chart/v2/dependency.go new file mode 100644 index 00000000..1fb4534d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/dependency.go @@ -0,0 +1,83 @@ +/* +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 v2 + +import "time" + +// Dependency describes a chart upon which another chart depends. +// +// Dependencies can be used to express developer intent, or to capture the state +// of a chart. +type Dependency struct { + // Name is the name of the dependency. + // + // This must mach the name in the dependency's Chart.yaml. + Name string `json:"name" yaml:"name"` + // Version is the version (range) of this chart. + // + // A lock file will always produce a single version, while a dependency + // may contain a semantic version range. + Version string `json:"version,omitempty" yaml:"version,omitempty"` + // The URL to the repository. + // + // Appending `index.yaml` to this string should result in a URL that can be + // used to fetch the repository index. + Repository string `json:"repository" yaml:"repository"` + // A yaml path that resolves to a boolean, used for enabling/disabling charts (e.g. subchart1.enabled ) + Condition string `json:"condition,omitempty" yaml:"condition,omitempty"` + // Tags can be used to group charts for enabling/disabling together + Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"` + // Enabled bool determines if chart should be loaded + Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + // ImportValues holds the mapping of source values to parent key to be imported. Each item can be a + // string or pair of child/parent sublist items. + ImportValues []interface{} `json:"import-values,omitempty" yaml:"import-values,omitempty"` + // Alias usable alias to be used for the chart + Alias string `json:"alias,omitempty" yaml:"alias,omitempty"` + ExportValues []interface{} `json:"export-values,omitempty" yaml:"export-values,omitempty"` +} + +// Validate checks for common problems with the dependency datastructure in +// the chart. This check must be done at load time before the dependency's charts are +// loaded. +func (d *Dependency) Validate() error { + if d == nil { + return ValidationError("dependencies must not contain empty or null nodes") + } + d.Name = sanitizeString(d.Name) + d.Version = sanitizeString(d.Version) + d.Repository = sanitizeString(d.Repository) + d.Condition = sanitizeString(d.Condition) + for i := range d.Tags { + d.Tags[i] = sanitizeString(d.Tags[i]) + } + if d.Alias != "" && !aliasNameFormat.MatchString(d.Alias) { + return ValidationErrorf("dependency %q has disallowed characters in the alias", d.Name) + } + return nil +} + +// Lock is a lock file for dependencies. +// +// It represents the state that the dependencies should be in. +type Lock struct { + // Generated is the date the lock file was last generated. + Generated time.Time `json:"generated"` + // Digest is a hash of the dependencies in Chart.yaml. + Digest string `json:"digest"` + // Dependencies is the list of dependencies that this lock file has locked. + Dependencies []*Dependency `json:"dependencies"` +} diff --git a/pkg/helm/pkg/chart/v2/dependency_test.go b/pkg/helm/pkg/chart/v2/dependency_test.go new file mode 100644 index 00000000..35919bd7 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/dependency_test.go @@ -0,0 +1,44 @@ +/* +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 v2 + +import ( + "testing" +) + +func TestValidateDependency(t *testing.T) { + dep := &Dependency{ + Name: "example", + } + for value, shouldFail := range map[string]bool{ + "abcdefghijklmenopQRSTUVWXYZ-0123456780_": false, + "-okay": false, + "_okay": false, + "- bad": true, + " bad": true, + "bad\nvalue": true, + "bad ": true, + "bad$": true, + } { + dep.Alias = value + res := dep.Validate() + if res != nil && !shouldFail { + t.Errorf("Failed on case %q", dep.Alias) + } else if res == nil && shouldFail { + t.Errorf("Expected failure for %q", dep.Alias) + } + } +} diff --git a/pkg/helm/pkg/chart/v2/doc.go b/pkg/helm/pkg/chart/v2/doc.go new file mode 100644 index 00000000..d36ca3ec --- /dev/null +++ b/pkg/helm/pkg/chart/v2/doc.go @@ -0,0 +1,23 @@ +/* +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 v2 provides chart handling for apiVersion v1 and v2 charts + +This package and its sub-packages provide handling for apiVersion v1 and v2 charts. +The changes from v1 to v2 charts are minor and were able to be handled with minor +switches based on characteristics. +*/ +package v2 diff --git a/pkg/helm/pkg/chart/v2/errors.go b/pkg/helm/pkg/chart/v2/errors.go new file mode 100644 index 00000000..eeef7531 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/errors.go @@ -0,0 +1,30 @@ +/* +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 v2 + +import "fmt" + +// ValidationError represents a data validation error. +type ValidationError string + +func (v ValidationError) Error() string { + return "validation: " + string(v) +} + +// ValidationErrorf takes a message and formatting options and creates a ValidationError +func ValidationErrorf(msg string, args ...interface{}) ValidationError { + return ValidationError(fmt.Sprintf(msg, args...)) +} diff --git a/pkg/helm/pkg/chart/v2/fuzz_test.go b/pkg/helm/pkg/chart/v2/fuzz_test.go new file mode 100644 index 00000000..a897ef7b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/fuzz_test.go @@ -0,0 +1,48 @@ +/* +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 v2 + +import ( + "testing" + + fuzz "github.com/AdaLogics/go-fuzz-headers" +) + +func FuzzMetadataValidate(f *testing.F) { + f.Fuzz(func(t *testing.T, data []byte) { + fdp := fuzz.NewConsumer(data) + // Add random values to the metadata + md := &Metadata{} + err := fdp.GenerateStruct(md) + if err != nil { + t.Skip() + } + md.Validate() + }) +} + +func FuzzDependencyValidate(f *testing.F) { + f.Fuzz(func(t *testing.T, data []byte) { + f := fuzz.NewConsumer(data) + // Add random values to the dependenci + d := &Dependency{} + err := f.GenerateStruct(d) + if err != nil { + t.Skip() + } + d.Validate() + }) +} diff --git a/pkg/helm/pkg/chart/v2/lint/lint.go b/pkg/helm/pkg/chart/v2/lint/lint.go new file mode 100644 index 00000000..2e028a13 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/lint.go @@ -0,0 +1,71 @@ +/* +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 lint // import "github.com/werf/nelm/pkg/helm/pkg/chart/v2/lint" + +import ( + "path/filepath" + + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/lint/rules" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/lint/support" +) + +type linterOptions struct { + KubeVersion *common.KubeVersion + SkipSchemaValidation bool +} + +type LinterOption func(lo *linterOptions) + +func WithKubeVersion(kubeVersion *common.KubeVersion) LinterOption { + return func(lo *linterOptions) { + lo.KubeVersion = kubeVersion + } +} + +func WithSkipSchemaValidation(skipSchemaValidation bool) LinterOption { + return func(lo *linterOptions) { + lo.SkipSchemaValidation = skipSchemaValidation + } +} + +func RunAll(baseDir string, values map[string]interface{}, namespace string, options ...LinterOption) support.Linter { + + chartDir, _ := filepath.Abs(baseDir) + + lo := linterOptions{} + for _, option := range options { + option(&lo) + } + + result := support.Linter{ + ChartDir: chartDir, + } + + rules.Chartfile(&result) + rules.ValuesWithOverrides(&result, values, lo.SkipSchemaValidation) + rules.Templates( + &result, + namespace, + values, + rules.TemplateLinterKubeVersion(lo.KubeVersion), + rules.TemplateLinterSkipSchemaValidation(lo.SkipSchemaValidation)) + rules.Dependencies(&result) + rules.Crds(&result) + + return result +} diff --git a/pkg/helm/pkg/chart/v2/lint/lint_test.go b/pkg/helm/pkg/chart/v2/lint/lint_test.go new file mode 100644 index 00000000..0e136a99 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/lint_test.go @@ -0,0 +1,247 @@ +/* +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 lint + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/lint/support" + chartutil "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" +) + +const namespace = "testNamespace" + +const badChartDir = "rules/testdata/badchartfile" +const badValuesFileDir = "rules/testdata/badvaluesfile" +const badYamlFileDir = "rules/testdata/albatross" +const badCrdFileDir = "rules/testdata/badcrdfile" +const goodChartDir = "rules/testdata/goodone" +const subChartValuesDir = "rules/testdata/withsubchart" +const malformedTemplate = "rules/testdata/malformed-template" +const invalidChartFileDir = "rules/testdata/invalidchartfile" + +func TestBadChart(t *testing.T) { + var values map[string]any + m := RunAll(badChartDir, values, namespace).Messages + if len(m) != 9 { + t.Errorf("Number of errors %v", len(m)) + t.Errorf("All didn't fail with expected errors, got %#v", m) + } + // There should be one INFO, 2 WARNING and 2 ERROR messages, check for them + var i, w, w2, e, e2, e3, e4, e5, e6 bool + for _, msg := range m { + if msg.Severity == support.InfoSev { + if strings.Contains(msg.Err.Error(), "icon is recommended") { + i = true + } + } + if msg.Severity == support.WarningSev { + if strings.Contains(msg.Err.Error(), "does not exist") { + w = true + } + } + if msg.Severity == support.ErrorSev { + if strings.Contains(msg.Err.Error(), "version '0.0.0.0' is not a valid SemVer") { + e = true + } + if strings.Contains(msg.Err.Error(), "name is required") { + e2 = true + } + + if strings.Contains(msg.Err.Error(), "apiVersion is required. The value must be either \"v1\" or \"v2\"") { + e3 = true + } + + if strings.Contains(msg.Err.Error(), "chart type is not valid in apiVersion") { + e4 = true + } + + if strings.Contains(msg.Err.Error(), "dependencies are not valid in the Chart file with apiVersion") { + e5 = true + } + // This comes from the dependency check, which loads dependency info from the Chart.yaml + if strings.Contains(msg.Err.Error(), "unable to load chart") { + e6 = true + } + } + if msg.Severity == support.WarningSev { + if strings.Contains(msg.Err.Error(), "version '0.0.0.0' is not a valid SemVerV2") { + w2 = true + } + } + } + if !e || !e2 || !e3 || !e4 || !e5 || !i || !e6 || !w || !w2 { + t.Errorf("Didn't find all the expected errors, got %#v", m) + } +} + +func TestInvalidYaml(t *testing.T) { + var values map[string]any + m := RunAll(badYamlFileDir, values, namespace).Messages + if len(m) != 1 { + t.Fatalf("All didn't fail with expected errors, got %#v", m) + } + if !strings.Contains(m[0].Err.Error(), "deliberateSyntaxError") { + t.Errorf("All didn't have the error for deliberateSyntaxError") + } +} + +func TestInvalidChartYaml(t *testing.T) { + var values map[string]any + m := RunAll(invalidChartFileDir, values, namespace).Messages + if len(m) != 2 { + t.Fatalf("All didn't fail with expected errors, got %#v", m) + } + if !strings.Contains(m[0].Err.Error(), "failed to strictly parse chart metadata file") { + t.Errorf("All didn't have the error for duplicate YAML keys") + } +} + +func TestBadValues(t *testing.T) { + var values map[string]any + m := RunAll(badValuesFileDir, values, namespace).Messages + if len(m) < 1 { + t.Fatalf("All didn't fail with expected errors, got %#v", m) + } + if !strings.Contains(m[0].Err.Error(), "unable to parse YAML") { + t.Errorf("All didn't have the error for invalid key format: %s", m[0].Err) + } +} + +func TestBadCrdFile(t *testing.T) { + var values map[string]any + m := RunAll(badCrdFileDir, values, namespace).Messages + assert.Lenf(t, m, 2, "All didn't fail with expected errors, got %#v", m) + assert.ErrorContains(t, m[0].Err, "apiVersion is not in 'apiextensions.k8s.io'") + assert.ErrorContains(t, m[1].Err, "object kind is not 'CustomResourceDefinition'") +} + +func TestGoodChart(t *testing.T) { + var values map[string]any + m := RunAll(goodChartDir, values, namespace).Messages + if len(m) != 0 { + t.Error("All returned linter messages when it shouldn't have") + for i, msg := range m { + t.Logf("Message %d: %s", i, msg) + } + } +} + +// TestHelmCreateChart tests that a `helm create` always passes a `helm lint` test. +// +// See https://github.com/helm/helm/issues/7923 +func TestHelmCreateChart(t *testing.T) { + var values map[string]any + dir := t.TempDir() + + createdChart, err := chartutil.Create("testhelmcreatepasseslint", dir) + if err != nil { + t.Error(err) + // Fatal is bad because of the defer. + return + } + + // Note: we test with strict=true here, even though others have + // strict = false. + m := RunAll(createdChart, values, namespace, WithSkipSchemaValidation(true)).Messages + if ll := len(m); ll != 1 { + t.Errorf("All should have had exactly 1 error. Got %d", ll) + for i, msg := range m { + t.Logf("Message %d: %s", i, msg.Error()) + } + } else if msg := m[0].Err.Error(); !strings.Contains(msg, "icon is recommended") { + t.Errorf("Unexpected lint error: %s", msg) + } +} + +// TestHelmCreateChart_CheckDeprecatedWarnings checks if any default template created by `helm create` throws +// deprecated warnings in the linter check against the current Kubernetes version (provided using ldflags). +// +// See https://github.com/helm/helm/issues/11495 +// +// Resources like hpa and ingress, which are disabled by default in values.yaml are enabled here using the equivalent +// of the `--set` flag. +func TestHelmCreateChart_CheckDeprecatedWarnings(t *testing.T) { + createdChart, err := chartutil.Create("checkdeprecatedwarnings", t.TempDir()) + if err != nil { + t.Error(err) + return + } + + // Add values to enable hpa, and ingress which are disabled by default. + // This is the equivalent of: + // helm lint checkdeprecatedwarnings --set 'autoscaling.enabled=true,ingress.enabled=true' + updatedValues := map[string]any{ + "autoscaling": map[string]any{ + "enabled": true, + }, + "ingress": map[string]any{ + "enabled": true, + }, + } + + linterRunDetails := RunAll(createdChart, updatedValues, namespace, WithSkipSchemaValidation(true)) + for _, msg := range linterRunDetails.Messages { + if strings.HasPrefix(msg.Error(), "[WARNING]") && + strings.Contains(msg.Error(), "deprecated") { + // When there is a deprecation warning for an object created + // by `helm create` for the current Kubernetes version, fail. + t.Errorf("Unexpected deprecation warning for %q: %s", msg.Path, msg.Error()) + } + } +} + +// lint ignores import-values +// See https://github.com/helm/helm/issues/9658 +func TestSubChartValuesChart(t *testing.T) { + var values map[string]any + m := RunAll(subChartValuesDir, values, namespace).Messages + if len(m) != 0 { + t.Error("All returned linter messages when it shouldn't have") + for i, msg := range m { + t.Logf("Message %d: %s", i, msg) + } + } +} + +// lint stuck with malformed template object +// See https://github.com/helm/helm/issues/11391 +func TestMalformedTemplate(t *testing.T) { + var values map[string]any + c := time.After(3 * time.Second) + ch := make(chan int, 1) + var m []support.Message + go func() { + m = RunAll(malformedTemplate, values, namespace).Messages + ch <- 1 + }() + select { + case <-c: + t.Fatalf("lint malformed template timeout") + case <-ch: + if len(m) != 1 { + t.Fatalf("All didn't fail with expected errors, got %#v", m) + } + if !strings.Contains(m[0].Err.Error(), "invalid character '{'") { + t.Errorf("All didn't have the error for invalid character '{'") + } + } +} diff --git a/pkg/helm/pkg/chart/v2/lint/rules/chartfile.go b/pkg/helm/pkg/chart/v2/lint/rules/chartfile.go new file mode 100644 index 00000000..a4ee83e0 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/chartfile.go @@ -0,0 +1,236 @@ +/* +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 rules // import "github.com/werf/nelm/pkg/helm/pkg/chart/v2/lint/rules" + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/Masterminds/semver/v3" + "github.com/asaskevich/govalidator" + "sigs.k8s.io/yaml" + + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/lint/support" + chartutil "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" +) + +// Chartfile runs a set of linter rules related to Chart.yaml file +func Chartfile(linter *support.Linter) { + chartFileName := "Chart.yaml" + chartPath := filepath.Join(linter.ChartDir, chartFileName) + + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartYamlNotDirectory(chartPath)) + + chartFile, err := chartutil.LoadChartfile(chartPath) + validChartFile := linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartYamlFormat(err)) + + // Guard clause. Following linter rules require a parsable ChartFile + if !validChartFile { + return + } + + _, err = chartutil.StrictLoadChartfile(chartPath) + linter.RunLinterRule(support.WarningSev, chartFileName, validateChartYamlStrictFormat(err)) + + // type check for Chart.yaml . ignoring error as any parse + // errors would already be caught in the above load function + chartFileForTypeCheck, _ := loadChartFileForTypeCheck(chartPath) + + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartName(chartFile)) + + // Chart metadata + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartAPIVersion(chartFile)) + + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartVersionType(chartFileForTypeCheck)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartVersion(chartFile)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartAppVersionType(chartFileForTypeCheck)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartMaintainer(chartFile)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartSources(chartFile)) + linter.RunLinterRule(support.InfoSev, chartFileName, validateChartIconPresence(chartFile)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartIconURL(chartFile)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartType(chartFile)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartDependencies(chartFile)) + linter.RunLinterRule(support.WarningSev, chartFileName, validateChartVersionStrictSemVerV2(chartFile)) +} + +func validateChartVersionType(data map[string]interface{}) error { + return isStringValue(data, "version") +} + +func validateChartAppVersionType(data map[string]interface{}) error { + return isStringValue(data, "appVersion") +} + +func isStringValue(data map[string]interface{}, key string) error { + value, ok := data[key] + if !ok { + return nil + } + valueType := fmt.Sprintf("%T", value) + if valueType != "string" { + return fmt.Errorf("%s should be of type string but it's of type %s", key, valueType) + } + return nil +} + +func validateChartYamlNotDirectory(chartPath string) error { + fi, err := os.Stat(chartPath) + + if err == nil && fi.IsDir() { + return errors.New("should be a file, not a directory") + } + return nil +} + +func validateChartYamlFormat(chartFileError error) error { + if chartFileError != nil { + return fmt.Errorf("unable to parse YAML\n\t%w", chartFileError) + } + return nil +} + +func validateChartYamlStrictFormat(chartFileError error) error { + if chartFileError != nil { + return fmt.Errorf("failed to strictly parse chart metadata file\n\t%w", chartFileError) + } + return nil +} + +func validateChartName(cf *chart.Metadata) error { + if cf.Name == "" { + return errors.New("name is required") + } + name := filepath.Base(cf.Name) + if name != cf.Name { + return fmt.Errorf("chart name %q is invalid", cf.Name) + } + return nil +} + +func validateChartAPIVersion(cf *chart.Metadata) error { + if cf.APIVersion == "" { + return errors.New("apiVersion is required. The value must be either \"v1\" or \"v2\"") + } + + if cf.APIVersion != chart.APIVersionV1 && cf.APIVersion != chart.APIVersionV2 { + return fmt.Errorf("apiVersion '%s' is not valid. The value must be either \"v1\" or \"v2\"", cf.APIVersion) + } + + return nil +} + +func validateChartVersion(cf *chart.Metadata) error { + if cf.Version == "" { + return errors.New("version is required") + } + + version, err := semver.NewVersion(cf.Version) + if err != nil { + return fmt.Errorf("version '%s' is not a valid SemVer", cf.Version) + } + + c, err := semver.NewConstraint(">0.0.0-0") + if err != nil { + return err + } + valid, msg := c.Validate(version) + + if !valid && len(msg) > 0 { + return fmt.Errorf("version %v", msg[0]) + } + + return nil +} + +func validateChartVersionStrictSemVerV2(cf *chart.Metadata) error { + _, err := semver.StrictNewVersion(cf.Version) + + if err != nil { + return fmt.Errorf("version '%s' is not a valid SemVerV2", cf.Version) + } + + return nil +} + +func validateChartMaintainer(cf *chart.Metadata) error { + for _, maintainer := range cf.Maintainers { + if maintainer == nil { + return errors.New("a maintainer entry is empty") + } + if maintainer.Name == "" { + return errors.New("each maintainer requires a name") + } else if maintainer.Email != "" && !govalidator.IsEmail(maintainer.Email) { + return fmt.Errorf("invalid email '%s' for maintainer '%s'", maintainer.Email, maintainer.Name) + } else if maintainer.URL != "" && !govalidator.IsURL(maintainer.URL) { + return fmt.Errorf("invalid url '%s' for maintainer '%s'", maintainer.URL, maintainer.Name) + } + } + return nil +} + +func validateChartSources(cf *chart.Metadata) error { + for _, source := range cf.Sources { + if source == "" || !govalidator.IsRequestURL(source) { + return fmt.Errorf("invalid source URL '%s'", source) + } + } + return nil +} + +func validateChartIconPresence(cf *chart.Metadata) error { + if cf.Icon == "" { + return errors.New("icon is recommended") + } + return nil +} + +func validateChartIconURL(cf *chart.Metadata) error { + if cf.Icon != "" && !govalidator.IsRequestURL(cf.Icon) { + return fmt.Errorf("invalid icon URL '%s'", cf.Icon) + } + return nil +} + +func validateChartDependencies(cf *chart.Metadata) error { + if len(cf.Dependencies) > 0 && cf.APIVersion != chart.APIVersionV2 { + return fmt.Errorf("dependencies are not valid in the Chart file with apiVersion '%s'. They are valid in apiVersion '%s'", cf.APIVersion, chart.APIVersionV2) + } + return nil +} + +func validateChartType(cf *chart.Metadata) error { + if len(cf.Type) > 0 && cf.APIVersion != chart.APIVersionV2 { + return fmt.Errorf("chart type is not valid in apiVersion '%s'. It is valid in apiVersion '%s'", cf.APIVersion, chart.APIVersionV2) + } + return nil +} + +// loadChartFileForTypeCheck loads the Chart.yaml +// in a generic form of a map[string]interface{}, so that the type +// of the values can be checked +func loadChartFileForTypeCheck(filename string) (map[string]interface{}, error) { + b, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + y := make(map[string]interface{}) + err = yaml.Unmarshal(b, &y) + return y, err +} diff --git a/pkg/helm/pkg/chart/v2/lint/rules/chartfile_test.go b/pkg/helm/pkg/chart/v2/lint/rules/chartfile_test.go new file mode 100644 index 00000000..c1752a9c --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/chartfile_test.go @@ -0,0 +1,319 @@ +/* +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 rules + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/lint/support" + chartutil "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" +) + +const ( + badChartNameDir = "testdata/badchartname" + badChartDir = "testdata/badchartfile" + anotherBadChartDir = "testdata/anotherbadchartfile" +) + +var ( + badChartNamePath = filepath.Join(badChartNameDir, "Chart.yaml") + badChartFilePath = filepath.Join(badChartDir, "Chart.yaml") + nonExistingChartFilePath = filepath.Join(os.TempDir(), "Chart.yaml") +) + +var badChart, _ = chartutil.LoadChartfile(badChartFilePath) +var badChartName, _ = chartutil.LoadChartfile(badChartNamePath) + +// Validation functions Test +func TestValidateChartYamlNotDirectory(t *testing.T) { + _ = os.Mkdir(nonExistingChartFilePath, os.ModePerm) + defer os.Remove(nonExistingChartFilePath) + + err := validateChartYamlNotDirectory(nonExistingChartFilePath) + if err == nil { + t.Errorf("validateChartYamlNotDirectory to return a linter error, got no error") + } +} + +func TestValidateChartYamlFormat(t *testing.T) { + err := validateChartYamlFormat(errors.New("Read error")) + if err == nil { + t.Errorf("validateChartYamlFormat to return a linter error, got no error") + } + + err = validateChartYamlFormat(nil) + if err != nil { + t.Errorf("validateChartYamlFormat to return no error, got a linter error") + } +} + +func TestValidateChartName(t *testing.T) { + err := validateChartName(badChart) + if err == nil { + t.Errorf("validateChartName to return a linter error, got no error") + } + + err = validateChartName(badChartName) + if err == nil { + t.Error("expected validateChartName to return a linter error for an invalid name, got no error") + } +} + +func TestValidateChartVersion(t *testing.T) { + var failTest = []struct { + Version string + ErrorMsg string + }{ + {"", "version is required"}, + {"1.2.3.4", "version '1.2.3.4' is not a valid SemVer"}, + {"waps", "'waps' is not a valid SemVer"}, + {"-3", "'-3' is not a valid SemVer"}, + } + + var successTest = []string{"0.0.1", "0.0.1+build", "0.0.1-beta"} + + for _, test := range failTest { + badChart.Version = test.Version + err := validateChartVersion(badChart) + if err == nil || !strings.Contains(err.Error(), test.ErrorMsg) { + t.Errorf("validateChartVersion(%s) to return \"%s\", got no error", test.Version, test.ErrorMsg) + } + } + + for _, version := range successTest { + badChart.Version = version + err := validateChartVersion(badChart) + if err != nil { + t.Errorf("validateChartVersion(%s) to return no error, got a linter error", version) + } + } +} + +func TestValidateChartVersionStrictSemVerV2(t *testing.T) { + var failTest = []struct { + Version string + ErrorMsg string + }{ + {"", "version '' is not a valid SemVerV2"}, + {"1", "version '1' is not a valid SemVerV2"}, + {"1.1", "version '1.1' is not a valid SemVerV2"}, + } + + var successTest = []string{"1.1.1", "0.0.1+build", "0.0.1-beta"} + + for _, test := range failTest { + badChart.Version = test.Version + err := validateChartVersionStrictSemVerV2(badChart) + if err == nil || !strings.Contains(err.Error(), test.ErrorMsg) { + t.Errorf("validateChartVersionStrictSemVerV2(%s) to return \"%s\", got no error", test.Version, test.ErrorMsg) + } + } + + for _, version := range successTest { + badChart.Version = version + err := validateChartVersionStrictSemVerV2(badChart) + if err != nil { + t.Errorf("validateChartVersionStrictSemVerV2(%s) to return no error, got a linter error", version) + } + } +} + +func TestValidateChartMaintainer(t *testing.T) { + var failTest = []struct { + Name string + Email string + ErrorMsg string + }{ + {"", "", "each maintainer requires a name"}, + {"", "test@test.com", "each maintainer requires a name"}, + {"John Snow", "wrongFormatEmail.com", "invalid email"}, + } + + var successTest = []struct { + Name string + Email string + }{ + {"John Snow", ""}, + {"John Snow", "john@winterfell.com"}, + } + + for _, test := range failTest { + badChart.Maintainers = []*chart.Maintainer{{Name: test.Name, Email: test.Email}} + err := validateChartMaintainer(badChart) + if err == nil || !strings.Contains(err.Error(), test.ErrorMsg) { + t.Errorf("validateChartMaintainer(%s, %s) to return \"%s\", got no error", test.Name, test.Email, test.ErrorMsg) + } + } + + for _, test := range successTest { + badChart.Maintainers = []*chart.Maintainer{{Name: test.Name, Email: test.Email}} + err := validateChartMaintainer(badChart) + if err != nil { + t.Errorf("validateChartMaintainer(%s, %s) to return no error, got %s", test.Name, test.Email, err.Error()) + } + } + + // Testing for an empty maintainer + badChart.Maintainers = []*chart.Maintainer{nil} + err := validateChartMaintainer(badChart) + if err == nil { + t.Errorf("validateChartMaintainer did not return error for nil maintainer as expected") + } + if err.Error() != "a maintainer entry is empty" { + t.Errorf("validateChartMaintainer returned unexpected error for nil maintainer: %s", err.Error()) + } +} + +func TestValidateChartSources(t *testing.T) { + var failTest = []string{"", "RiverRun", "john@winterfell", "riverrun.io"} + var successTest = []string{"http://riverrun.io", "https://riverrun.io", "https://riverrun.io/blackfish"} + for _, test := range failTest { + badChart.Sources = []string{test} + err := validateChartSources(badChart) + if err == nil || !strings.Contains(err.Error(), "invalid source URL") { + t.Errorf("validateChartSources(%s) to return \"invalid source URL\", got no error", test) + } + } + + for _, test := range successTest { + badChart.Sources = []string{test} + err := validateChartSources(badChart) + if err != nil { + t.Errorf("validateChartSources(%s) to return no error, got %s", test, err.Error()) + } + } +} + +func TestValidateChartIconPresence(t *testing.T) { + t.Run("Icon absent", func(t *testing.T) { + testChart := &chart.Metadata{ + Icon: "", + } + + err := validateChartIconPresence(testChart) + + if err == nil { + t.Errorf("validateChartIconPresence to return a linter error, got no error") + } else if !strings.Contains(err.Error(), "icon is recommended") { + t.Errorf("expected %q, got %q", "icon is recommended", err.Error()) + } + }) + t.Run("Icon present", func(t *testing.T) { + testChart := &chart.Metadata{ + Icon: "http://example.org/icon.png", + } + + err := validateChartIconPresence(testChart) + + if err != nil { + t.Errorf("Unexpected error: %q", err.Error()) + } + }) +} + +func TestValidateChartIconURL(t *testing.T) { + var failTest = []string{"RiverRun", "john@winterfell", "riverrun.io"} + var successTest = []string{"http://riverrun.io", "https://riverrun.io", "https://riverrun.io/blackfish.png"} + for _, test := range failTest { + badChart.Icon = test + err := validateChartIconURL(badChart) + if err == nil || !strings.Contains(err.Error(), "invalid icon URL") { + t.Errorf("validateChartIconURL(%s) to return \"invalid icon URL\", got no error", test) + } + } + + for _, test := range successTest { + badChart.Icon = test + err := validateChartSources(badChart) + if err != nil { + t.Errorf("validateChartIconURL(%s) to return no error, got %s", test, err.Error()) + } + } +} + +func TestChartfile(t *testing.T) { + t.Run("Chart.yaml basic validity issues", func(t *testing.T) { + linter := support.Linter{ChartDir: badChartDir} + Chartfile(&linter) + msgs := linter.Messages + expectedNumberOfErrorMessages := 7 + + if len(msgs) != expectedNumberOfErrorMessages { + t.Errorf("Expected %d errors, got %d", expectedNumberOfErrorMessages, len(msgs)) + return + } + + if !strings.Contains(msgs[0].Err.Error(), "name is required") { + t.Errorf("Unexpected message 0: %s", msgs[0].Err) + } + + if !strings.Contains(msgs[1].Err.Error(), "apiVersion is required. The value must be either \"v1\" or \"v2\"") { + t.Errorf("Unexpected message 1: %s", msgs[1].Err) + } + + if !strings.Contains(msgs[2].Err.Error(), "version '0.0.0.0' is not a valid SemVer") { + t.Errorf("Unexpected message 2: %s", msgs[2].Err) + } + + if !strings.Contains(msgs[3].Err.Error(), "icon is recommended") { + t.Errorf("Unexpected message 3: %s", msgs[3].Err) + } + + if !strings.Contains(msgs[4].Err.Error(), "chart type is not valid in apiVersion") { + t.Errorf("Unexpected message 4: %s", msgs[4].Err) + } + + if !strings.Contains(msgs[5].Err.Error(), "dependencies are not valid in the Chart file with apiVersion") { + t.Errorf("Unexpected message 5: %s", msgs[5].Err) + } + if !strings.Contains(msgs[6].Err.Error(), "version '0.0.0.0' is not a valid SemVerV2") { + t.Errorf("Unexpected message 6: %s", msgs[6].Err) + } + }) + + t.Run("Chart.yaml validity issues due to type mismatch", func(t *testing.T) { + linter := support.Linter{ChartDir: anotherBadChartDir} + Chartfile(&linter) + msgs := linter.Messages + expectedNumberOfErrorMessages := 4 + + if len(msgs) != expectedNumberOfErrorMessages { + t.Errorf("Expected %d errors, got %d", expectedNumberOfErrorMessages, len(msgs)) + return + } + + if !strings.Contains(msgs[0].Err.Error(), "version should be of type string") { + t.Errorf("Unexpected message 0: %s", msgs[0].Err) + } + + if !strings.Contains(msgs[1].Err.Error(), "version '7.2445e+06' is not a valid SemVer") { + t.Errorf("Unexpected message 1: %s", msgs[1].Err) + } + + if !strings.Contains(msgs[2].Err.Error(), "appVersion should be of type string") { + t.Errorf("Unexpected message 2: %s", msgs[2].Err) + } + if !strings.Contains(msgs[3].Err.Error(), "version '7.2445e+06' is not a valid SemVerV2") { + t.Errorf("Unexpected message 3: %s", msgs[3].Err) + } + }) +} diff --git a/pkg/helm/pkg/chart/v2/lint/rules/crds.go b/pkg/helm/pkg/chart/v2/lint/rules/crds.go new file mode 100644 index 00000000..2c1c327f --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/crds.go @@ -0,0 +1,116 @@ +/* +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 rules + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + "k8s.io/apimachinery/pkg/util/yaml" + + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/lint/support" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/loader" +) + +// Crds lints the CRDs in the Linter. +func Crds(linter *support.Linter) { + fpath := "crds/" + crdsPath := filepath.Join(linter.ChartDir, fpath) + + // crds directory is optional + if _, err := os.Stat(crdsPath); errors.Is(err, fs.ErrNotExist) { + return + } + + crdsDirValid := linter.RunLinterRule(support.ErrorSev, fpath, validateCrdsDir(crdsPath)) + if !crdsDirValid { + return + } + + // Load chart and parse CRDs + chart, err := loader.Load(context.Background(), linter.ChartDir) + + chartLoaded := linter.RunLinterRule(support.ErrorSev, fpath, err) + + if !chartLoaded { + return + } + + /* Iterate over all the CRDs to check: + 1. It is a YAML file and not a template + 2. The API version is apiextensions.k8s.io + 3. The kind is CustomResourceDefinition + */ + for _, crd := range chart.CRDObjects() { + fileName := crd.Name + fpath = fileName + + decoder := yaml.NewYAMLOrJSONDecoder(bytes.NewReader(crd.File.Data), 4096) + for { + var yamlStruct *k8sYamlStruct + + err := decoder.Decode(&yamlStruct) + if errors.Is(err, io.EOF) { + break + } + + // If YAML parsing fails here, it will always fail in the next block as well, so we should return here. + // This also confirms the YAML is not a template, since templates can't be decoded into a K8sYamlStruct. + if !linter.RunLinterRule(support.ErrorSev, fpath, validateYamlContent(err)) { + return + } + + if yamlStruct != nil { + linter.RunLinterRule(support.ErrorSev, fpath, validateCrdAPIVersion(yamlStruct)) + linter.RunLinterRule(support.ErrorSev, fpath, validateCrdKind(yamlStruct)) + } + } + } +} + +// Validation functions +func validateCrdsDir(crdsPath string) error { + fi, err := os.Stat(crdsPath) + if err != nil { + return err + } + if !fi.IsDir() { + return errors.New("not a directory") + } + return nil +} + +func validateCrdAPIVersion(obj *k8sYamlStruct) error { + if !strings.HasPrefix(obj.APIVersion, "apiextensions.k8s.io") { + return fmt.Errorf("apiVersion is not in 'apiextensions.k8s.io'") + } + return nil +} + +func validateCrdKind(obj *k8sYamlStruct) error { + if obj.Kind != "CustomResourceDefinition" { + return fmt.Errorf("object kind is not 'CustomResourceDefinition'") + } + return nil +} diff --git a/pkg/helm/pkg/chart/v2/lint/rules/crds_test.go b/pkg/helm/pkg/chart/v2/lint/rules/crds_test.go new file mode 100644 index 00000000..3c24922e --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/crds_test.go @@ -0,0 +1,66 @@ +/* +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 rules + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/lint/support" +) + +const invalidCrdsDir = "./testdata/invalidcrdsdir" + +func TestInvalidCrdsDir(t *testing.T) { + linter := support.Linter{ChartDir: invalidCrdsDir} + Crds(&linter) + res := linter.Messages + + assert.Len(t, res, 1) + assert.ErrorContains(t, res[0].Err, "not a directory") +} + +// multi-document YAML with empty documents would panic +func TestCrdWithEmptyDocument(t *testing.T) { + chartDir := t.TempDir() + + os.WriteFile(filepath.Join(chartDir, "Chart.yaml"), []byte( + `apiVersion: v1 +name: test +version: 0.1.0 +`), 0644) + + // CRD with comments before --- (creates empty document) + crdsDir := filepath.Join(chartDir, "crds") + os.Mkdir(crdsDir, 0755) + os.WriteFile(filepath.Join(crdsDir, "test.yaml"), []byte( + `# Comments create empty document +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: test.example.io +`), 0644) + + linter := support.Linter{ChartDir: chartDir} + Crds(&linter) + + assert.Len(t, linter.Messages, 0) +} diff --git a/pkg/helm/pkg/chart/v2/lint/rules/dependencies.go b/pkg/helm/pkg/chart/v2/lint/rules/dependencies.go new file mode 100644 index 00000000..293bccdc --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/dependencies.go @@ -0,0 +1,102 @@ +/* +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 rules // import "github.com/werf/nelm/pkg/helm/pkg/chart/v2/lint/rules" + +import ( + "context" + "fmt" + "strings" + + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/lint/support" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/loader" +) + +// Dependencies runs lints against a chart's dependencies +// +// See https://github.com/helm/helm/issues/7910 +func Dependencies(linter *support.Linter) { + c, err := loader.LoadDir(context.Background(), linter.ChartDir) + if !linter.RunLinterRule(support.ErrorSev, "", validateChartFormat(err)) { + return + } + + linter.RunLinterRule(support.ErrorSev, linter.ChartDir, validateDependencyInMetadata(c)) + linter.RunLinterRule(support.ErrorSev, linter.ChartDir, validateDependenciesUnique(c)) + linter.RunLinterRule(support.WarningSev, linter.ChartDir, validateDependencyInChartsDir(c)) +} + +func validateChartFormat(chartError error) error { + if chartError != nil { + return fmt.Errorf("unable to load chart\n\t%w", chartError) + } + return nil +} + +func validateDependencyInChartsDir(c *chart.Chart) (err error) { + dependencies := map[string]struct{}{} + missing := []string{} + for _, dep := range c.Dependencies() { + dependencies[dep.Metadata.Name] = struct{}{} + } + for _, dep := range c.Metadata.Dependencies { + if _, ok := dependencies[dep.Name]; !ok { + missing = append(missing, dep.Name) + } + } + if len(missing) > 0 { + err = fmt.Errorf("chart directory is missing these dependencies: %s", strings.Join(missing, ",")) + } + return err +} + +func validateDependencyInMetadata(c *chart.Chart) (err error) { + dependencies := map[string]struct{}{} + missing := []string{} + for _, dep := range c.Metadata.Dependencies { + dependencies[dep.Name] = struct{}{} + } + for _, dep := range c.Dependencies() { + if _, ok := dependencies[dep.Metadata.Name]; !ok { + missing = append(missing, dep.Metadata.Name) + } + } + if len(missing) > 0 { + err = fmt.Errorf("chart metadata is missing these dependencies: %s", strings.Join(missing, ",")) + } + return err +} + +func validateDependenciesUnique(c *chart.Chart) (err error) { + dependencies := map[string]*chart.Dependency{} + shadowing := []string{} + + for _, dep := range c.Metadata.Dependencies { + key := dep.Name + if dep.Alias != "" { + key = dep.Alias + } + if dependencies[key] != nil { + shadowing = append(shadowing, key) + } + dependencies[key] = dep + } + if len(shadowing) > 0 { + err = fmt.Errorf("multiple dependencies with name or alias: %s", strings.Join(shadowing, ",")) + } + return err +} diff --git a/pkg/helm/pkg/chart/v2/lint/rules/dependencies_test.go b/pkg/helm/pkg/chart/v2/lint/rules/dependencies_test.go new file mode 100644 index 00000000..4122e7ce --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/dependencies_test.go @@ -0,0 +1,157 @@ +/* +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 rules + +import ( + "path/filepath" + "testing" + + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/lint/support" + chartutil "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" +) + +func chartWithBadDependencies() chart.Chart { + badChartDeps := chart.Chart{ + Metadata: &chart.Metadata{ + Name: "badchart", + Version: "0.1.0", + APIVersion: "v2", + Dependencies: []*chart.Dependency{ + { + Name: "sub2", + }, + { + Name: "sub3", + }, + }, + }, + } + + badChartDeps.SetDependencies( + &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "sub1", + Version: "0.1.0", + APIVersion: "v2", + }, + }, + &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "sub2", + Version: "0.1.0", + APIVersion: "v2", + }, + }, + ) + return badChartDeps +} + +func TestValidateDependencyInChartsDir(t *testing.T) { + c := chartWithBadDependencies() + + if err := validateDependencyInChartsDir(&c); err == nil { + t.Error("chart should have been flagged for missing deps in chart directory") + } +} + +func TestValidateDependencyInMetadata(t *testing.T) { + c := chartWithBadDependencies() + + if err := validateDependencyInMetadata(&c); err == nil { + t.Errorf("chart should have been flagged for missing deps in chart metadata") + } +} + +func TestValidateDependenciesUnique(t *testing.T) { + tests := []struct { + chart chart.Chart + }{ + {chart.Chart{ + Metadata: &chart.Metadata{ + Name: "badchart", + Version: "0.1.0", + APIVersion: "v2", + Dependencies: []*chart.Dependency{ + { + Name: "foo", + }, + { + Name: "foo", + }, + }, + }, + }}, + {chart.Chart{ + Metadata: &chart.Metadata{ + Name: "badchart", + Version: "0.1.0", + APIVersion: "v2", + Dependencies: []*chart.Dependency{ + { + Name: "foo", + Alias: "bar", + }, + { + Name: "bar", + }, + }, + }, + }}, + {chart.Chart{ + Metadata: &chart.Metadata{ + Name: "badchart", + Version: "0.1.0", + APIVersion: "v2", + Dependencies: []*chart.Dependency{ + { + Name: "foo", + Alias: "baz", + }, + { + Name: "bar", + Alias: "baz", + }, + }, + }, + }}, + } + + for _, tt := range tests { + if err := validateDependenciesUnique(&tt.chart); err == nil { + t.Errorf("chart should have been flagged for dependency shadowing") + } + } +} + +func TestDependencies(t *testing.T) { + tmp := t.TempDir() + + c := chartWithBadDependencies() + err := chartutil.SaveDir(&c, tmp) + if err != nil { + t.Fatal(err) + } + linter := support.Linter{ChartDir: filepath.Join(tmp, c.Metadata.Name)} + + Dependencies(&linter) + if l := len(linter.Messages); l != 2 { + t.Errorf("expected 2 linter errors for bad chart dependencies. Got %d.", l) + for i, msg := range linter.Messages { + t.Logf("Message: %d, Error: %#v", i, msg) + } + } +} diff --git a/pkg/helm/pkg/chart/v2/lint/rules/deprecations.go b/pkg/helm/pkg/chart/v2/lint/rules/deprecations.go new file mode 100644 index 00000000..6a4c3618 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/deprecations.go @@ -0,0 +1,94 @@ +/* +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 rules // import "github.com/werf/nelm/pkg/helm/pkg/chart/v2/lint/rules" + +import ( + "fmt" + "strconv" + + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/endpoints/deprecation" + kscheme "k8s.io/client-go/kubernetes/scheme" +) + +// deprecatedAPIError indicates than an API is deprecated in Kubernetes +type deprecatedAPIError struct { + Deprecated string + Message string +} + +func (e deprecatedAPIError) Error() string { + msg := e.Message + return msg +} + +func validateNoDeprecations(resource *k8sYamlStruct, kubeVersion *common.KubeVersion) error { + // if `resource` does not have an APIVersion or Kind, we cannot test it for deprecation + if resource.APIVersion == "" { + return nil + } + if resource.Kind == "" { + return nil + } + + if kubeVersion == nil { + kubeVersion = &common.DefaultCapabilities.KubeVersion + } + + runtimeObject, err := resourceToRuntimeObject(resource) + if err != nil { + // do not error for non-kubernetes resources + if runtime.IsNotRegisteredError(err) { + return nil + } + return err + } + + kubeVersionMajor, err := strconv.Atoi(kubeVersion.Major) + if err != nil { + return err + } + kubeVersionMinor, err := strconv.Atoi(kubeVersion.Minor) + if err != nil { + return err + } + + if !deprecation.IsDeprecated(runtimeObject, kubeVersionMajor, kubeVersionMinor) { + return nil + } + gvk := fmt.Sprintf("%s %s", resource.APIVersion, resource.Kind) + return deprecatedAPIError{ + Deprecated: gvk, + Message: deprecation.WarningMessage(runtimeObject), + } +} + +func resourceToRuntimeObject(resource *k8sYamlStruct) (runtime.Object, error) { + scheme := runtime.NewScheme() + kscheme.AddToScheme(scheme) + + gvk := schema.FromAPIVersionAndKind(resource.APIVersion, resource.Kind) + out, err := scheme.New(gvk) + if err != nil { + return nil, err + } + out.GetObjectKind().SetGroupVersionKind(gvk) + return out, nil +} diff --git a/pkg/helm/pkg/chart/v2/lint/rules/deprecations_test.go b/pkg/helm/pkg/chart/v2/lint/rules/deprecations_test.go new file mode 100644 index 00000000..dbba1bd1 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/deprecations_test.go @@ -0,0 +1,41 @@ +/* +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 rules // import "github.com/werf/nelm/pkg/helm/pkg/chart/v2/lint/rules" + +import "testing" + +func TestValidateNoDeprecations(t *testing.T) { + deprecated := &k8sYamlStruct{ + APIVersion: "extensions/v1beta1", + Kind: "Deployment", + } + err := validateNoDeprecations(deprecated, nil) + if err == nil { + t.Fatal("Expected deprecated extension to be flagged") + } + depErr := err.(deprecatedAPIError) + if depErr.Message == "" { + t.Fatalf("Expected error message to be non-blank: %v", err) + } + + if err := validateNoDeprecations(&k8sYamlStruct{ + APIVersion: "v1", + Kind: "Pod", + }, nil); err != nil { + t.Errorf("Expected a v1 Pod to not be deprecated") + } +} diff --git a/pkg/helm/pkg/chart/v2/lint/rules/template.go b/pkg/helm/pkg/chart/v2/lint/rules/template.go new file mode 100644 index 00000000..bfeeef50 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/template.go @@ -0,0 +1,385 @@ +/* +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 rules + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "slices" + "strings" + + "k8s.io/apimachinery/pkg/api/validation" + apipath "k8s.io/apimachinery/pkg/api/validation/path" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apimachinery/pkg/util/yaml" + + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + "github.com/werf/nelm/pkg/helm/pkg/chart/common/util" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/lint/support" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/loader" + chartutil "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" + "github.com/werf/nelm/pkg/helm/pkg/engine" +) + +// Templates lints the templates in the Linter. +func Templates(linter *support.Linter, namespace string, values map[string]any, options ...TemplateLinterOption) { + templateLinter := newTemplateLinter(linter, namespace, values, options...) + templateLinter.Lint() +} + +type TemplateLinterOption func(*templateLinter) + +func TemplateLinterKubeVersion(kubeVersion *common.KubeVersion) TemplateLinterOption { + return func(tl *templateLinter) { + tl.kubeVersion = kubeVersion + } +} + +func TemplateLinterSkipSchemaValidation(skipSchemaValidation bool) TemplateLinterOption { + return func(tl *templateLinter) { + tl.skipSchemaValidation = skipSchemaValidation + } +} + +func newTemplateLinter(linter *support.Linter, namespace string, values map[string]any, options ...TemplateLinterOption) templateLinter { + + result := templateLinter{ + linter: linter, + values: values, + namespace: namespace, + } + + for _, o := range options { + o(&result) + } + + return result +} + +type templateLinter struct { + linter *support.Linter + values map[string]any + namespace string + kubeVersion *common.KubeVersion + skipSchemaValidation bool +} + +func (t *templateLinter) Lint() { + templatesDir := "templates/" + templatesPath := filepath.Join(t.linter.ChartDir, templatesDir) + + templatesDirExists := t.linter.RunLinterRule(support.WarningSev, templatesDir, templatesDirExists(templatesPath)) + if !templatesDirExists { + return + } + + validTemplatesDir := t.linter.RunLinterRule(support.ErrorSev, templatesDir, validateTemplatesDir(templatesPath)) + if !validTemplatesDir { + return + } + + // Load chart and parse templates + chart, err := loader.Load(context.Background(), t.linter.ChartDir) + + chartLoaded := t.linter.RunLinterRule(support.ErrorSev, templatesDir, err) + + if !chartLoaded { + return + } + + options := common.ReleaseOptions{ + Name: "test-release", + Namespace: t.namespace, + } + + caps := common.DefaultCapabilities.Copy() + if t.kubeVersion != nil { + caps.KubeVersion = *t.kubeVersion + } + + // lint ignores import-values + // See https://github.com/helm/helm/issues/9658 + if err := chartutil.ProcessDependencies(chart, &t.values); err != nil { + return + } + + cvals, err := util.CoalesceValues(chart, t.values) + if err != nil { + return + } + + valuesToRender, err := util.ToRenderValuesWithSchemaValidation(chart, cvals, options, caps, t.skipSchemaValidation) + if err != nil { + t.linter.RunLinterRule(support.ErrorSev, templatesDir, err) + return + } + var e engine.Engine + e.LintMode = true + renderedContentMap, err := e.Render(context.Background(), chart, valuesToRender) + + renderOk := t.linter.RunLinterRule(support.ErrorSev, templatesDir, err) + + if !renderOk { + return + } + + /* Iterate over all the templates to check: + - It is a .yaml file + - All the values in the template file is defined + - {{}} include | quote + - Generated content is a valid Yaml file + - Metadata.Namespace is not set + */ + for _, template := range chart.Templates { + fileName := template.Name + + t.linter.RunLinterRule(support.ErrorSev, fileName, validateAllowedExtension(fileName)) + + // We only apply the following lint rules to yaml files + if !isYamlFileExtension(fileName) { + continue + } + + // NOTE: disabled for now, Refs https://github.com/helm/helm/issues/1463 + // Check that all the templates have a matching value + // linter.RunLinterRule(support.WarningSev, fpath, validateNoMissingValues(templatesPath, valuesToRender, preExecutedTemplate)) + + // NOTE: disabled for now, Refs https://github.com/helm/helm/issues/1037 + // linter.RunLinterRule(support.WarningSev, fpath, validateQuotes(string(preExecutedTemplate))) + + renderedContent := renderedContentMap[path.Join(chart.Name(), fileName)] + if strings.TrimSpace(renderedContent) != "" { + t.linter.RunLinterRule(support.WarningSev, fileName, validateTopIndentLevel(renderedContent)) + + decoder := yaml.NewYAMLOrJSONDecoder(strings.NewReader(renderedContent), 4096) + + // Lint all resources if the file contains multiple documents separated by --- + for { + // Even though k8sYamlStruct only defines a few fields, an error in any other + // key will be raised as well + var yamlStruct *k8sYamlStruct + + err := decoder.Decode(&yamlStruct) + if errors.Is(err, io.EOF) { + break + } + + // If YAML linting fails here, it will always fail in the next block as well, so we should return here. + // fix https://github.com/helm/helm/issues/11391 + if !t.linter.RunLinterRule(support.ErrorSev, fileName, validateYamlContent(err)) { + return + } + if yamlStruct != nil { + // NOTE: set to warnings to allow users to support out-of-date kubernetes + // Refs https://github.com/helm/helm/issues/8596 + t.linter.RunLinterRule(support.WarningSev, fileName, validateMetadataName(yamlStruct)) + t.linter.RunLinterRule(support.WarningSev, fileName, validateNoDeprecations(yamlStruct, t.kubeVersion)) + + t.linter.RunLinterRule(support.ErrorSev, fileName, validateMatchSelector(yamlStruct, renderedContent)) + t.linter.RunLinterRule(support.ErrorSev, fileName, validateListAnnotations(yamlStruct, renderedContent)) + } + } + } + } +} + +// validateTopIndentLevel checks that the content does not start with an indent level > 0. +// +// This error can occur when a template accidentally inserts space. It can cause +// unpredictable errors depending on whether the text is normalized before being passed +// into the YAML parser. So we trap it here. +// +// See https://github.com/helm/helm/issues/8467 +func validateTopIndentLevel(content string) error { + // Read lines until we get to a non-empty one + scanner := bufio.NewScanner(bytes.NewBufferString(content)) + for scanner.Scan() { + line := scanner.Text() + // If line is empty, skip + if strings.TrimSpace(line) == "" { + continue + } + // If it starts with one or more spaces, this is an error + if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { + return fmt.Errorf("document starts with an illegal indent: %q, which may cause parsing problems", line) + } + // Any other condition passes. + return nil + } + return scanner.Err() +} + +// Validation functions +func templatesDirExists(templatesPath string) error { + _, err := os.Stat(templatesPath) + if errors.Is(err, os.ErrNotExist) { + return errors.New("directory does not exist") + } + return nil +} + +func validateTemplatesDir(templatesPath string) error { + fi, err := os.Stat(templatesPath) + if err != nil { + return err + } + if !fi.IsDir() { + return errors.New("not a directory") + } + return nil +} + +func validateAllowedExtension(fileName string) error { + ext := filepath.Ext(fileName) + validExtensions := []string{".yaml", ".yml", ".tpl", ".txt"} + + if slices.Contains(validExtensions, ext) { + return nil + } + + return fmt.Errorf("file extension '%s' not valid. Valid extensions are .yaml, .yml, .tpl, or .txt", ext) +} + +func validateYamlContent(err error) error { + if err != nil { + return fmt.Errorf("unable to parse YAML: %w", err) + } + + return nil +} + +// validateMetadataName uses the correct validation function for the object +// Kind, or if not set, defaults to the standard definition of a subdomain in +// DNS (RFC 1123), used by most resources. +func validateMetadataName(obj *k8sYamlStruct) error { + fn := validateMetadataNameFunc(obj) + allErrs := field.ErrorList{} + for _, msg := range fn(obj.Metadata.Name, false) { + allErrs = append(allErrs, field.Invalid(field.NewPath("metadata").Child("name"), obj.Metadata.Name, msg)) + } + if len(allErrs) > 0 { + return fmt.Errorf("object name does not conform to Kubernetes naming requirements: %q: %w", obj.Metadata.Name, allErrs.ToAggregate()) + } + return nil +} + +// validateMetadataNameFunc will return a name validation function for the +// object kind, if defined below. +// +// Rules should match those set in the various api validations: +// https://github.com/kubernetes/kubernetes/blob/v1.20.0/pkg/apis/core/validation/validation.go#L205-L274 +// https://github.com/kubernetes/kubernetes/blob/v1.20.0/pkg/apis/apps/validation/validation.go#L39 +// ... +// +// Implementing here to avoid importing k/k. +// +// If no mapping is defined, returns NameIsDNSSubdomain. This is used by object +// kinds that don't have special requirements, so is the most likely to work if +// new kinds are added. +func validateMetadataNameFunc(obj *k8sYamlStruct) validation.ValidateNameFunc { + switch strings.ToLower(obj.Kind) { + case "pod", "node", "secret", "endpoints", "resourcequota", // core + "controllerrevision", "daemonset", "deployment", "replicaset", "statefulset", // apps + "autoscaler", // autoscaler + "cronjob", "job", // batch + "lease", // coordination + "endpointslice", // discovery + "networkpolicy", "ingress", // networking + "podsecuritypolicy", // policy + "priorityclass", // scheduling + "podpreset", // settings + "storageclass", "volumeattachment", "csinode": // storage + return validation.NameIsDNSSubdomain + case "service": + return validation.NameIsDNS1035Label + case "namespace": + return validation.ValidateNamespaceName + case "serviceaccount": + return validation.ValidateServiceAccountName + case "certificatesigningrequest": + // No validation. + // https://github.com/kubernetes/kubernetes/blob/v1.20.0/pkg/apis/certificates/validation/validation.go#L137-L140 + return func(_ string, _ bool) []string { return nil } + case "role", "clusterrole", "rolebinding", "clusterrolebinding": + // https://github.com/kubernetes/kubernetes/blob/v1.20.0/pkg/apis/rbac/validation/validation.go#L32-L34 + return func(name string, _ bool) []string { + return apipath.IsValidPathSegmentName(name) + } + default: + return validation.NameIsDNSSubdomain + } +} + +// validateMatchSelector ensures that template specs have a selector declared. +// See https://github.com/helm/helm/issues/1990 +func validateMatchSelector(yamlStruct *k8sYamlStruct, manifest string) error { + switch yamlStruct.Kind { + case "Deployment", "ReplicaSet", "DaemonSet", "StatefulSet": + // verify that matchLabels or matchExpressions is present + if !strings.Contains(manifest, "matchLabels") && !strings.Contains(manifest, "matchExpressions") { + return fmt.Errorf("a %s must contain matchLabels or matchExpressions, and %q does not", yamlStruct.Kind, yamlStruct.Metadata.Name) + } + } + return nil +} + +func validateListAnnotations(yamlStruct *k8sYamlStruct, manifest string) error { + if yamlStruct.Kind == "List" { + m := struct { + Items []struct { + Metadata struct { + Annotations map[string]string + } + } + }{} + + if err := yaml.Unmarshal([]byte(manifest), &m); err != nil { + return validateYamlContent(err) + } + + for _, i := range m.Items { + if _, ok := i.Metadata.Annotations["helm.sh/resource-policy"]; ok { + return errors.New("annotation 'helm.sh/resource-policy' within List objects are ignored") + } + } + } + return nil +} + +func isYamlFileExtension(fileName string) bool { + ext := strings.ToLower(filepath.Ext(fileName)) + return ext == ".yaml" || ext == ".yml" +} + +// k8sYamlStruct stubs a Kubernetes YAML file. +type k8sYamlStruct struct { + APIVersion string `json:"apiVersion"` + Kind string + Metadata k8sYamlMetadata +} + +type k8sYamlMetadata struct { + Namespace string + Name string +} diff --git a/pkg/helm/pkg/chart/v2/lint/rules/template_test.go b/pkg/helm/pkg/chart/v2/lint/rules/template_test.go new file mode 100644 index 00000000..f757d6ca --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/template_test.go @@ -0,0 +1,490 @@ +/* +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 rules + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/lint/support" + chartutil "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" +) + +const templateTestBasedir = "./testdata/albatross" + +func TestValidateAllowedExtension(t *testing.T) { + var failTest = []string{"/foo", "/test.toml"} + for _, test := range failTest { + err := validateAllowedExtension(test) + if err == nil || !strings.Contains(err.Error(), "Valid extensions are .yaml, .yml, .tpl, or .txt") { + t.Errorf("validateAllowedExtension('%s') to return \"Valid extensions are .yaml, .yml, .tpl, or .txt\", got no error", test) + } + } + var successTest = []string{"/foo.yaml", "foo.yaml", "foo.tpl", "/foo/bar/baz.yaml", "NOTES.txt"} + for _, test := range successTest { + err := validateAllowedExtension(test) + if err != nil { + t.Errorf("validateAllowedExtension('%s') to return no error but got \"%s\"", test, err.Error()) + } + } +} + +var values = map[string]interface{}{"nameOverride": "", "httpPort": 80} + +const namespace = "testNamespace" + +func TestTemplateParsing(t *testing.T) { + linter := support.Linter{ChartDir: templateTestBasedir} + Templates( + &linter, + namespace, + values, + TemplateLinterSkipSchemaValidation(false)) + res := linter.Messages + + if len(res) != 1 { + t.Fatalf("Expected one error, got %d, %v", len(res), res) + } + + if !strings.Contains(res[0].Err.Error(), "deliberateSyntaxError") { + t.Errorf("Unexpected error: %s", res[0]) + } +} + +var wrongTemplatePath = filepath.Join(templateTestBasedir, "templates", "fail.yaml") +var ignoredTemplatePath = filepath.Join(templateTestBasedir, "fail.yaml.ignored") + +// Test a template with all the existing features: +// namespaces, partial templates +func TestTemplateIntegrationHappyPath(t *testing.T) { + // Rename file so it gets ignored by the linter + os.Rename(wrongTemplatePath, ignoredTemplatePath) + defer os.Rename(ignoredTemplatePath, wrongTemplatePath) + + linter := support.Linter{ChartDir: templateTestBasedir} + Templates( + &linter, + namespace, + values, + TemplateLinterSkipSchemaValidation(false)) + res := linter.Messages + + if len(res) != 0 { + t.Fatalf("Expected no error, got %d, %v", len(res), res) + } +} + +func TestMultiTemplateFail(t *testing.T) { + linter := support.Linter{ChartDir: "./testdata/multi-template-fail"} + Templates( + &linter, + namespace, + values, + TemplateLinterSkipSchemaValidation(false)) + res := linter.Messages + + if len(res) != 1 { + t.Fatalf("Expected 1 error, got %d, %v", len(res), res) + } + + if !strings.Contains(res[0].Err.Error(), "object name does not conform to Kubernetes naming requirements") { + t.Errorf("Unexpected error: %s", res[0].Err) + } +} + +func TestValidateMetadataName(t *testing.T) { + tests := []struct { + obj *k8sYamlStruct + wantErr bool + }{ + // Most kinds use IsDNS1123Subdomain. + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: ""}}, true}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "foo.bar1234baz.seventyone"}}, false}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "FOO"}}, true}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "123baz"}}, false}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "foo.BAR.baz"}}, true}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "one-two"}}, false}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "-two"}}, true}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "one_two"}}, true}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "a..b"}}, true}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "%^&#$%*@^*@&#^"}}, true}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "operator:pod"}}, true}, + {&k8sYamlStruct{Kind: "ServiceAccount", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&k8sYamlStruct{Kind: "ServiceAccount", Metadata: k8sYamlMetadata{Name: "foo.bar1234baz.seventyone"}}, false}, + {&k8sYamlStruct{Kind: "ServiceAccount", Metadata: k8sYamlMetadata{Name: "FOO"}}, true}, + {&k8sYamlStruct{Kind: "ServiceAccount", Metadata: k8sYamlMetadata{Name: "operator:sa"}}, true}, + + // Service uses IsDNS1035Label. + {&k8sYamlStruct{Kind: "Service", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&k8sYamlStruct{Kind: "Service", Metadata: k8sYamlMetadata{Name: "123baz"}}, true}, + {&k8sYamlStruct{Kind: "Service", Metadata: k8sYamlMetadata{Name: "foo.bar"}}, true}, + + // Namespace uses IsDNS1123Label. + {&k8sYamlStruct{Kind: "Namespace", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&k8sYamlStruct{Kind: "Namespace", Metadata: k8sYamlMetadata{Name: "123baz"}}, false}, + {&k8sYamlStruct{Kind: "Namespace", Metadata: k8sYamlMetadata{Name: "foo.bar"}}, true}, + {&k8sYamlStruct{Kind: "Namespace", Metadata: k8sYamlMetadata{Name: "foo-bar"}}, false}, + + // CertificateSigningRequest has no validation. + {&k8sYamlStruct{Kind: "CertificateSigningRequest", Metadata: k8sYamlMetadata{Name: ""}}, false}, + {&k8sYamlStruct{Kind: "CertificateSigningRequest", Metadata: k8sYamlMetadata{Name: "123baz"}}, false}, + {&k8sYamlStruct{Kind: "CertificateSigningRequest", Metadata: k8sYamlMetadata{Name: "%^&#$%*@^*@&#^"}}, false}, + + // RBAC uses path validation. + {&k8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&k8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "123baz"}}, false}, + {&k8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "foo.bar"}}, false}, + {&k8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "operator:role"}}, false}, + {&k8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "operator/role"}}, true}, + {&k8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "operator%role"}}, true}, + {&k8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&k8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "123baz"}}, false}, + {&k8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "foo.bar"}}, false}, + {&k8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "operator:role"}}, false}, + {&k8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "operator/role"}}, true}, + {&k8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "operator%role"}}, true}, + {&k8sYamlStruct{Kind: "RoleBinding", Metadata: k8sYamlMetadata{Name: "operator:role"}}, false}, + {&k8sYamlStruct{Kind: "ClusterRoleBinding", Metadata: k8sYamlMetadata{Name: "operator:role"}}, false}, + + // Unknown Kind + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: ""}}, true}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "foo.bar1234baz.seventyone"}}, false}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "FOO"}}, true}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "123baz"}}, false}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "foo.BAR.baz"}}, true}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "one-two"}}, false}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "-two"}}, true}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "one_two"}}, true}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "a..b"}}, true}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "%^&#$%*@^*@&#^"}}, true}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "operator:pod"}}, true}, + + // No kind + {&k8sYamlStruct{Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&k8sYamlStruct{Metadata: k8sYamlMetadata{Name: "operator:pod"}}, true}, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("%s/%s", tt.obj.Kind, tt.obj.Metadata.Name), func(t *testing.T) { + if err := validateMetadataName(tt.obj); (err != nil) != tt.wantErr { + t.Errorf("validateMetadataName() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestDeprecatedAPIFails(t *testing.T) { + modTime := time.Now() + mychart := chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: "v2", + Name: "failapi", + Version: "0.1.0", + Icon: "satisfy-the-linting-gods.gif", + }, + Templates: []*common.File{ + { + Name: "templates/baddeployment.yaml", + ModTime: modTime, + Data: []byte("apiVersion: apps/v1beta1\nkind: Deployment\nmetadata:\n name: baddep\nspec: {selector: {matchLabels: {foo: bar}}}"), + }, + { + Name: "templates/goodsecret.yaml", + ModTime: modTime, + Data: []byte("apiVersion: v1\nkind: Secret\nmetadata:\n name: goodsecret"), + }, + }, + } + tmpdir := t.TempDir() + + if err := chartutil.SaveDir(&mychart, tmpdir); err != nil { + t.Fatal(err) + } + + linter := support.Linter{ChartDir: filepath.Join(tmpdir, mychart.Name())} + Templates( + &linter, + namespace, + values, + TemplateLinterSkipSchemaValidation(false)) + if l := len(linter.Messages); l != 1 { + for i, msg := range linter.Messages { + t.Logf("Message %d: %s", i, msg) + } + t.Fatalf("Expected 1 lint error, got %d", l) + } + + err := linter.Messages[0].Err.(deprecatedAPIError) + if err.Deprecated != "apps/v1beta1 Deployment" { + t.Errorf("Surprised to learn that %q is deprecated", err.Deprecated) + } +} + +const manifest = `apiVersion: v1 +kind: ConfigMap +metadata: + name: foo +data: + myval1: {{default "val" .Values.mymap.key1 }} + myval2: {{default "val" .Values.mymap.key2 }} +` + +// TestStrictTemplateParsingMapError is a regression test. +// +// The template engine should not produce an error when a map in values.yaml does +// not contain all possible keys. +// +// See https://github.com/helm/helm/issues/7483 +func TestStrictTemplateParsingMapError(t *testing.T) { + + ch := chart.Chart{ + Metadata: &chart.Metadata{ + Name: "regression7483", + APIVersion: "v2", + Version: "0.1.0", + }, + Values: map[string]interface{}{ + "mymap": map[string]string{ + "key1": "val1", + }, + }, + Templates: []*common.File{ + { + Name: "templates/configmap.yaml", + ModTime: time.Now(), + Data: []byte(manifest), + }, + }, + } + dir := t.TempDir() + if err := chartutil.SaveDir(&ch, dir); err != nil { + t.Fatal(err) + } + linter := &support.Linter{ + ChartDir: filepath.Join(dir, ch.Metadata.Name), + } + Templates( + linter, + namespace, + ch.Values, + TemplateLinterSkipSchemaValidation(false)) + if len(linter.Messages) != 0 { + t.Errorf("expected zero messages, got %d", len(linter.Messages)) + for i, msg := range linter.Messages { + t.Logf("Message %d: %q", i, msg) + } + } +} + +func TestValidateMatchSelector(t *testing.T) { + md := &k8sYamlStruct{ + APIVersion: "apps/v1", + Kind: "Deployment", + Metadata: k8sYamlMetadata{ + Name: "mydeployment", + }, + } + manifest := ` + apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 3 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ` + if err := validateMatchSelector(md, manifest); err != nil { + t.Error(err) + } + manifest = ` + apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 3 + selector: + matchExpressions: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ` + if err := validateMatchSelector(md, manifest); err != nil { + t.Error(err) + } + manifest = ` + apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 3 + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ` + if err := validateMatchSelector(md, manifest); err == nil { + t.Error("expected Deployment with no selector to fail") + } +} + +func TestValidateTopIndentLevel(t *testing.T) { + for doc, shouldFail := range map[string]bool{ + // Should not fail + "\n\n\n\t\n \t\n": false, + "apiVersion:foo\n bar:baz": false, + "\n\n\napiVersion:foo\n\n\n": false, + // Should fail + " apiVersion:foo": true, + "\n\n apiVersion:foo\n\n": true, + } { + if err := validateTopIndentLevel(doc); (err == nil) == shouldFail { + t.Errorf("Expected %t for %q", shouldFail, doc) + } + } + +} + +// TestEmptyWithCommentsManifests checks the lint is not failing against empty manifests that contains only comments +// See https://github.com/helm/helm/issues/8621 +func TestEmptyWithCommentsManifests(t *testing.T) { + mychart := chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: "v2", + Name: "emptymanifests", + Version: "0.1.0", + Icon: "satisfy-the-linting-gods.gif", + }, + Templates: []*common.File{ + { + Name: "templates/empty-with-comments.yaml", + ModTime: time.Now(), + Data: []byte("#@formatter:off\n"), + }, + }, + } + tmpdir := t.TempDir() + + if err := chartutil.SaveDir(&mychart, tmpdir); err != nil { + t.Fatal(err) + } + + linter := support.Linter{ChartDir: filepath.Join(tmpdir, mychart.Name())} + Templates( + &linter, + namespace, + values, + TemplateLinterSkipSchemaValidation(false)) + if l := len(linter.Messages); l > 0 { + for i, msg := range linter.Messages { + t.Logf("Message %d: %s", i, msg) + } + t.Fatalf("Expected 0 lint errors, got %d", l) + } +} +func TestValidateListAnnotations(t *testing.T) { + md := &k8sYamlStruct{ + APIVersion: "v1", + Kind: "List", + Metadata: k8sYamlMetadata{ + Name: "list", + }, + } + manifest := ` +apiVersion: v1 +kind: List +items: + - apiVersion: v1 + kind: ConfigMap + metadata: + annotations: + helm.sh/resource-policy: keep +` + + if err := validateListAnnotations(md, manifest); err == nil { + t.Fatal("expected list with nested keep annotations to fail") + } + + manifest = ` +apiVersion: v1 +kind: List +metadata: + annotations: + helm.sh/resource-policy: keep +items: + - apiVersion: v1 + kind: ConfigMap +` + + if err := validateListAnnotations(md, manifest); err != nil { + t.Fatalf("List objects keep annotations should pass. got: %s", err) + } +} + +func TestIsYamlFileExtension(t *testing.T) { + tests := []struct { + filename string + expected bool + }{ + {"test.yaml", true}, + {"test.yml", true}, + {"test.txt", false}, + {"test", false}, + } + + for _, test := range tests { + result := isYamlFileExtension(test.filename) + if result != test.expected { + t.Errorf("isYamlFileExtension(%s) = %v; want %v", test.filename, result, test.expected) + } + } + +} diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/albatross/Chart.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/albatross/Chart.yaml new file mode 100644 index 00000000..21124acf --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/albatross/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: albatross +description: testing chart +version: 199.44.12345-Alpha.1+cafe009 +icon: http://riverrun.io diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/albatross/templates/_helpers.tpl b/pkg/helm/pkg/chart/v2/lint/rules/testdata/albatross/templates/_helpers.tpl new file mode 100644 index 00000000..24f76db7 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/albatross/templates/_helpers.tpl @@ -0,0 +1,16 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{define "name"}}{{default "nginx" .Values.nameOverride | trunc 63 | trimSuffix "-" }}{{end}} + +{{/* +Create a default fully qualified app name. + +We truncate at 63 chars because some Kubernetes name fields are limited to this +(by the DNS naming spec). +*/}} +{{define "fullname"}} +{{- $name := default "nginx" .Values.nameOverride -}} +{{printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{end}} diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/albatross/templates/fail.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/albatross/templates/fail.yaml new file mode 100644 index 00000000..a11e0e90 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/albatross/templates/fail.yaml @@ -0,0 +1 @@ +{{ deliberateSyntaxError }} diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/albatross/templates/svc.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/albatross/templates/svc.yaml new file mode 100644 index 00000000..16bb27d5 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/albatross/templates/svc.yaml @@ -0,0 +1,19 @@ +# This is a service gateway to the replica set created by the deployment. +# Take a look at the deployment.yaml for general notes about this chart. +apiVersion: v1 +kind: Service +metadata: + name: "{{ .Values.name }}" + labels: + app.kubernetes.io/managed-by: {{ .Release.Service | quote }} + app.kubernetes.io/instance: {{ .Release.Name | quote }} + helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}" + kubeVersion: {{ .Capabilities.KubeVersion.Major }} +spec: + ports: + - port: {{default 80 .Values.httpPort | quote}} + targetPort: 80 + protocol: TCP + name: http + selector: + app.kubernetes.io/name: {{template "fullname" .}} diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/albatross/values.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/albatross/values.yaml new file mode 100644 index 00000000..74cc6a0d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/albatross/values.yaml @@ -0,0 +1 @@ +name: "mariner" diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/anotherbadchartfile/Chart.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/anotherbadchartfile/Chart.yaml new file mode 100644 index 00000000..e6bac769 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/anotherbadchartfile/Chart.yaml @@ -0,0 +1,15 @@ +name: "some-chart" +apiVersion: v2 +description: A Helm chart for Kubernetes +version: 72445e2 +home: "" +type: application +appVersion: 72225e2 +icon: "https://some-url.com/icon.jpeg" +dependencies: + - name: mariadb + version: 5.x.x + repository: https://charts.helm.sh/stable/ + condition: mariadb.enabled + tags: + - database diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/badchartfile/Chart.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/badchartfile/Chart.yaml new file mode 100644 index 00000000..3564ede3 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/badchartfile/Chart.yaml @@ -0,0 +1,11 @@ +description: A Helm chart for Kubernetes +version: 0.0.0.0 +home: "" +type: application +dependencies: +- name: mariadb + version: 5.x.x + repository: https://charts.helm.sh/stable/ + condition: mariadb.enabled + tags: + - database diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/badchartfile/values.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/badchartfile/values.yaml new file mode 100644 index 00000000..9f367033 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/badchartfile/values.yaml @@ -0,0 +1 @@ +# Default values for badchartfile. diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/badchartname/Chart.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/badchartname/Chart.yaml new file mode 100644 index 00000000..64f8fb8b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/badchartname/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +description: A Helm chart for Kubernetes +version: 0.1.0 +name: "../badchartname" +type: application diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/badchartname/values.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/badchartname/values.yaml new file mode 100644 index 00000000..9f367033 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/badchartname/values.yaml @@ -0,0 +1 @@ +# Default values for badchartfile. diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/badcrdfile/Chart.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/badcrdfile/Chart.yaml new file mode 100644 index 00000000..08c4b61a --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/badcrdfile/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +description: A Helm chart for Kubernetes +version: 0.1.0 +name: badcrdfile +type: application +icon: http://riverrun.io diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/badcrdfile/crds/bad-apiversion.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/badcrdfile/crds/bad-apiversion.yaml new file mode 100644 index 00000000..46891605 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/badcrdfile/crds/bad-apiversion.yaml @@ -0,0 +1,2 @@ +apiVersion: bad.k8s.io/v1beta1 +kind: CustomResourceDefinition diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/badcrdfile/crds/bad-crd.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/badcrdfile/crds/bad-crd.yaml new file mode 100644 index 00000000..523b97f8 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/badcrdfile/crds/bad-crd.yaml @@ -0,0 +1,2 @@ +apiVersion: apiextensions.k8s.io/v1beta1 +kind: NotACustomResourceDefinition diff --git a/pkg/helm/cmd/helm/testdata/helmhome/helm/repository/test-name-charts.txt b/pkg/helm/pkg/chart/v2/lint/rules/testdata/badcrdfile/templates/.gitkeep similarity index 100% rename from pkg/helm/cmd/helm/testdata/helmhome/helm/repository/test-name-charts.txt rename to pkg/helm/pkg/chart/v2/lint/rules/testdata/badcrdfile/templates/.gitkeep diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/badcrdfile/values.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/badcrdfile/values.yaml new file mode 100644 index 00000000..2fffc771 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/badcrdfile/values.yaml @@ -0,0 +1 @@ +# Default values for badcrdfile. diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/badvaluesfile/Chart.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/badvaluesfile/Chart.yaml new file mode 100644 index 00000000..632919d0 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/badvaluesfile/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +name: badvaluesfile +description: A Helm chart for Kubernetes +version: 0.0.1 +home: "" +icon: http://riverrun.io diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/badvaluesfile/templates/badvaluesfile.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/badvaluesfile/templates/badvaluesfile.yaml new file mode 100644 index 00000000..6c2ceb8d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/badvaluesfile/templates/badvaluesfile.yaml @@ -0,0 +1,2 @@ +metadata: + name: {{.name | default "foo" | title}} diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/badvaluesfile/values.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/badvaluesfile/values.yaml new file mode 100644 index 00000000..b5a10271 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/badvaluesfile/values.yaml @@ -0,0 +1,2 @@ +# Invalid value for badvaluesfile for testing lint fails with invalid yaml format +name= "value" diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/goodone/Chart.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/goodone/Chart.yaml new file mode 100644 index 00000000..cb7a4bf2 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/goodone/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +name: goodone +description: good testing chart +version: 199.44.12345-Alpha.1+cafe009 +icon: http://riverrun.io diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/goodone/crds/test-crd.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/goodone/crds/test-crd.yaml new file mode 100644 index 00000000..1d7350f1 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/goodone/crds/test-crd.yaml @@ -0,0 +1,19 @@ +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: tests.test.io +spec: + group: test.io + names: + kind: Test + listKind: TestList + plural: tests + singular: test + scope: Namespaced + versions: + - name : v1alpha2 + served: true + storage: true + - name : v1alpha1 + served: true + storage: false diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/goodone/templates/goodone.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/goodone/templates/goodone.yaml new file mode 100644 index 00000000..cd46f62c --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/goodone/templates/goodone.yaml @@ -0,0 +1,2 @@ +metadata: + name: {{ .Values.name | default "foo" | lower }} diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/goodone/values.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/goodone/values.yaml new file mode 100644 index 00000000..92c3d9bb --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/goodone/values.yaml @@ -0,0 +1 @@ +name: "goodone-here" diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/invalidchartfile/Chart.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/invalidchartfile/Chart.yaml new file mode 100644 index 00000000..0fd58d1d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/invalidchartfile/Chart.yaml @@ -0,0 +1,6 @@ +name: some-chart +apiVersion: v2 +apiVersion: v1 +description: A Helm chart for Kubernetes +version: 1.3.0 +icon: http://example.com diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/values.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/invalidchartfile/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/values.yaml rename to pkg/helm/pkg/chart/v2/lint/rules/testdata/invalidchartfile/values.yaml diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/invalidcrdsdir/Chart.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/invalidcrdsdir/Chart.yaml new file mode 100644 index 00000000..18e30f70 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/invalidcrdsdir/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +description: A Helm chart for Kubernetes +version: 0.1.0 +name: invalidcrdsdir +type: application +icon: http://riverrun.io diff --git a/pkg/helm/cmd/helm/testdata/output/lint-quiet-with-warning.txt b/pkg/helm/pkg/chart/v2/lint/rules/testdata/invalidcrdsdir/crds similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/lint-quiet-with-warning.txt rename to pkg/helm/pkg/chart/v2/lint/rules/testdata/invalidcrdsdir/crds diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/invalidcrdsdir/values.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/invalidcrdsdir/values.yaml new file mode 100644 index 00000000..6b1611a6 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/invalidcrdsdir/values.yaml @@ -0,0 +1 @@ +# Default values for invalidcrdsdir. diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/malformed-template/.helmignore b/pkg/helm/pkg/chart/v2/lint/rules/testdata/malformed-template/.helmignore new file mode 100644 index 00000000..0e8a0eb3 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/malformed-template/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/malformed-template/Chart.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/malformed-template/Chart.yaml new file mode 100644 index 00000000..11b2c71c --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/malformed-template/Chart.yaml @@ -0,0 +1,25 @@ +apiVersion: v2 +name: test +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.16.0" +icon: https://riverrun.io \ No newline at end of file diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/malformed-template/templates/bad.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/malformed-template/templates/bad.yaml new file mode 100644 index 00000000..213198fd --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/malformed-template/templates/bad.yaml @@ -0,0 +1 @@ +{ {- $relname := .Release.Name -}} diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/malformed-template/values.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/malformed-template/values.yaml new file mode 100644 index 00000000..1cc3182e --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/malformed-template/values.yaml @@ -0,0 +1,82 @@ +# Default values for test. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: nginx + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +podAnnotations: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/multi-template-fail/Chart.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/multi-template-fail/Chart.yaml new file mode 100644 index 00000000..b57427de --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/multi-template-fail/Chart.yaml @@ -0,0 +1,21 @@ +apiVersion: v2 +name: multi-template-fail +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application and it is recommended to use it with quotes. +appVersion: "1.16.0" diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/multi-template-fail/templates/multi-fail.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/multi-template-fail/templates/multi-fail.yaml new file mode 100644 index 00000000..835be07b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/multi-template-fail/templates/multi-fail.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: game-config +data: + game.properties: cheat +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: -this:name-is-not_valid$ +data: + game.properties: empty diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/v3-fail/Chart.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/v3-fail/Chart.yaml new file mode 100644 index 00000000..7097e17d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/v3-fail/Chart.yaml @@ -0,0 +1,21 @@ +apiVersion: v2 +name: v3-fail +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application and it is recommended to use it with quotes. +appVersion: "1.16.0" diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/_helpers.tpl b/pkg/helm/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/_helpers.tpl new file mode 100644 index 00000000..0b89e723 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/_helpers.tpl @@ -0,0 +1,63 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "v3-fail.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "v3-fail.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "v3-fail.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Common labels +*/}} +{{- define "v3-fail.labels" -}} +helm.sh/chart: {{ include "v3-fail.chart" . }} +{{ include "v3-fail.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{/* +Selector labels +*/}} +{{- define "v3-fail.selectorLabels" -}} +app.kubernetes.io/name: {{ include "v3-fail.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{/* +Create the name of the service account to use +*/}} +{{- define "v3-fail.serviceAccountName" -}} +{{- if .Values.serviceAccount.create -}} + {{ default (include "v3-fail.fullname" .) .Values.serviceAccount.name }} +{{- else -}} + {{ default "default" .Values.serviceAccount.name }} +{{- end -}} +{{- end -}} diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/deployment.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/deployment.yaml new file mode 100644 index 00000000..6d651ab8 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/deployment.yaml @@ -0,0 +1,56 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "v3-fail.fullname" . }} + labels: + nope: {{ .Release.Time }} + {{- include "v3-fail.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "v3-fail.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "v3-fail.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "v3-fail.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 80 + protocol: TCP + livenessProbe: + httpGet: + path: / + port: http + readinessProbe: + httpGet: + path: / + port: http + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/ingress.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/ingress.yaml new file mode 100644 index 00000000..4790650d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/ingress.yaml @@ -0,0 +1,62 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "v3-fail.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} + {{- end }} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "v3-fail.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + "helm.sh/hook": crd-install + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} + pathType: {{ .pathType }} + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ $fullName }} + port: + number: {{ $svcPort }} + {{- else }} + serviceName: {{ $fullName }} + servicePort: {{ $svcPort }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/service.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/service.yaml new file mode 100644 index 00000000..79a0f40b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "v3-fail.fullname" . }} + annotations: + helm.sh/hook: crd-install + labels: + {{- include "v3-fail.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "v3-fail.selectorLabels" . | nindent 4 }} diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/v3-fail/values.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/v3-fail/values.yaml new file mode 100644 index 00000000..01d99b4e --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/v3-fail/values.yaml @@ -0,0 +1,66 @@ +# Default values for v3-fail. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: nginx + pullPolicy: IfNotPresent + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: false + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: [] + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/withsubchart/Chart.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/withsubchart/Chart.yaml new file mode 100644 index 00000000..6648daf5 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/withsubchart/Chart.yaml @@ -0,0 +1,16 @@ +apiVersion: v2 +name: withsubchart +description: A Helm chart for Kubernetes +type: application +version: 0.1.0 +appVersion: "1.16.0" +icon: http://riverrun.io + +dependencies: + - name: subchart + version: 0.1.16 + repository: "file://../subchart" + import-values: + - child: subchart + parent: subchart + diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/withsubchart/charts/subchart/Chart.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/withsubchart/charts/subchart/Chart.yaml new file mode 100644 index 00000000..8610a4f2 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/withsubchart/charts/subchart/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: subchart +description: A Helm chart for Kubernetes +type: application +version: 0.1.0 +appVersion: "1.16.0" diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/withsubchart/charts/subchart/templates/subchart.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/withsubchart/charts/subchart/templates/subchart.yaml new file mode 100644 index 00000000..6cb6cc2a --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/withsubchart/charts/subchart/templates/subchart.yaml @@ -0,0 +1,2 @@ +metadata: + name: {{ .Values.subchart.name | lower }} diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/withsubchart/charts/subchart/values.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/withsubchart/charts/subchart/values.yaml new file mode 100644 index 00000000..422a359d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/withsubchart/charts/subchart/values.yaml @@ -0,0 +1,2 @@ +subchart: + name: subchart \ No newline at end of file diff --git a/pkg/helm/pkg/chart/v2/lint/rules/testdata/withsubchart/templates/mainchart.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/withsubchart/templates/mainchart.yaml new file mode 100644 index 00000000..6cb6cc2a --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/testdata/withsubchart/templates/mainchart.yaml @@ -0,0 +1,2 @@ +metadata: + name: {{ .Values.subchart.name | lower }} diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-deprecated-api/values.yaml b/pkg/helm/pkg/chart/v2/lint/rules/testdata/withsubchart/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-deprecated-api/values.yaml rename to pkg/helm/pkg/chart/v2/lint/rules/testdata/withsubchart/values.yaml diff --git a/pkg/helm/pkg/chart/v2/lint/rules/values.go b/pkg/helm/pkg/chart/v2/lint/rules/values.go new file mode 100644 index 00000000..320ee6c2 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/values.go @@ -0,0 +1,84 @@ +/* +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 rules + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + "github.com/werf/nelm/pkg/helm/pkg/chart/common/util" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/lint/support" +) + +// ValuesWithOverrides tests the values.yaml file. +// +// If a schema is present in the chart, values are tested against that. Otherwise, +// they are only tested for well-formedness. +// +// If additional values are supplied, they are coalesced into the values in values.yaml. +func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]interface{}, skipSchemaValidation bool) { + file := "values.yaml" + vf := filepath.Join(linter.ChartDir, file) + fileExists := linter.RunLinterRule(support.InfoSev, file, validateValuesFileExistence(vf)) + + if !fileExists { + return + } + + linter.RunLinterRule(support.ErrorSev, file, validateValuesFile(vf, valueOverrides, skipSchemaValidation)) +} + +func validateValuesFileExistence(valuesPath string) error { + _, err := os.Stat(valuesPath) + if err != nil { + return fmt.Errorf("file does not exist") + } + return nil +} + +func validateValuesFile(valuesPath string, overrides map[string]interface{}, skipSchemaValidation bool) error { + values, err := common.ReadValuesFile(valuesPath) + if err != nil { + return fmt.Errorf("unable to parse YAML: %w", err) + } + + // Helm 3.0.0 carried over the values linting from Helm 2.x, which only tests the top + // level values against the top-level expectations. Subchart values are not linted. + // We could change that. For now, though, we retain that strategy, and thus can + // coalesce tables (like reuse-values does) instead of doing the full chart + // CoalesceValues + coalescedValues := util.CoalesceTables(make(map[string]interface{}, len(overrides)), overrides) + coalescedValues = util.CoalesceTables(coalescedValues, values) + + ext := filepath.Ext(valuesPath) + schemaPath := valuesPath[:len(valuesPath)-len(ext)] + ".schema.json" + schema, err := os.ReadFile(schemaPath) + if len(schema) == 0 { + return nil + } + if err != nil { + return err + } + + if !skipSchemaValidation { + return util.ValidateAgainstSingleSchema(coalescedValues, schema) + } + + return nil +} diff --git a/pkg/helm/pkg/chart/v2/lint/rules/values_test.go b/pkg/helm/pkg/chart/v2/lint/rules/values_test.go new file mode 100644 index 00000000..a2a5345d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/rules/values_test.go @@ -0,0 +1,183 @@ +/* +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 rules + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/werf/nelm/pkg/helm/intern/test/ensure" +) + +var nonExistingValuesFilePath = filepath.Join("/fake/dir", "values.yaml") + +const testSchema = ` +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "helm values test schema", + "type": "object", + "additionalProperties": false, + "required": [ + "username", + "password" + ], + "properties": { + "username": { + "description": "Your username", + "type": "string" + }, + "password": { + "description": "Your password", + "type": "string" + } + } +} +` + +func TestValidateValuesYamlNotDirectory(t *testing.T) { + _ = os.Mkdir(nonExistingValuesFilePath, os.ModePerm) + defer os.Remove(nonExistingValuesFilePath) + + err := validateValuesFileExistence(nonExistingValuesFilePath) + if err == nil { + t.Errorf("validateValuesFileExistence to return a linter error, got no error") + } +} + +func TestValidateValuesFileWellFormed(t *testing.T) { + badYaml := ` + not:well[]{}formed + ` + tmpdir := ensure.TempFile(t, "values.yaml", []byte(badYaml)) + valfile := filepath.Join(tmpdir, "values.yaml") + if err := validateValuesFile(valfile, map[string]interface{}{}, false); err == nil { + t.Fatal("expected values file to fail parsing") + } +} + +func TestValidateValuesFileSchema(t *testing.T) { + yaml := "username: admin\npassword: swordfish" + tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) + createTestingSchema(t, tmpdir) + + valfile := filepath.Join(tmpdir, "values.yaml") + if err := validateValuesFile(valfile, map[string]interface{}{}, false); err != nil { + t.Fatalf("Failed validation with %s", err) + } +} + +func TestValidateValuesFileSchemaFailure(t *testing.T) { + // 1234 is an int, not a string. This should fail. + yaml := "username: 1234\npassword: swordfish" + tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) + createTestingSchema(t, tmpdir) + + valfile := filepath.Join(tmpdir, "values.yaml") + + err := validateValuesFile(valfile, map[string]interface{}{}, false) + if err == nil { + t.Fatal("expected values file to fail parsing") + } + + assert.Contains(t, err.Error(), "- at '/username': got number, want string") +} + +func TestValidateValuesFileSchemaFailureButWithSkipSchemaValidation(t *testing.T) { + // 1234 is an int, not a string. This should fail normally but pass with skipSchemaValidation. + yaml := "username: 1234\npassword: swordfish" + tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) + createTestingSchema(t, tmpdir) + + valfile := filepath.Join(tmpdir, "values.yaml") + + err := validateValuesFile(valfile, map[string]interface{}{}, true) + if err != nil { + t.Fatal("expected values file to pass parsing because of skipSchemaValidation") + } +} + +func TestValidateValuesFileSchemaOverrides(t *testing.T) { + yaml := "username: admin" + overrides := map[string]interface{}{ + "password": "swordfish", + } + tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) + createTestingSchema(t, tmpdir) + + valfile := filepath.Join(tmpdir, "values.yaml") + if err := validateValuesFile(valfile, overrides, false); err != nil { + t.Fatalf("Failed validation with %s", err) + } +} + +func TestValidateValuesFile(t *testing.T) { + tests := []struct { + name string + yaml string + overrides map[string]interface{} + errorMessage string + }{ + { + name: "value added", + yaml: "username: admin", + overrides: map[string]interface{}{"password": "swordfish"}, + }, + { + name: "value not overridden", + yaml: "username: admin\npassword:", + overrides: map[string]interface{}{"username": "anotherUser"}, + errorMessage: "- at '/password': got null, want string", + }, + { + name: "value overridden", + yaml: "username: admin\npassword:", + overrides: map[string]interface{}{"username": "anotherUser", "password": "swordfish"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpdir := ensure.TempFile(t, "values.yaml", []byte(tt.yaml)) + createTestingSchema(t, tmpdir) + + valfile := filepath.Join(tmpdir, "values.yaml") + + err := validateValuesFile(valfile, tt.overrides, false) + + switch { + case err != nil && tt.errorMessage == "": + t.Errorf("Failed validation with %s", err) + case err == nil && tt.errorMessage != "": + t.Error("expected values file to fail parsing") + case err != nil && tt.errorMessage != "": + assert.Contains(t, err.Error(), tt.errorMessage, "Failed with unexpected error") + } + }) + } +} + +func createTestingSchema(t *testing.T, dir string) string { + t.Helper() + schemafile := filepath.Join(dir, "values.schema.json") + if err := os.WriteFile(schemafile, []byte(testSchema), 0700); err != nil { + t.Fatalf("Failed to write schema to tmpdir: %s", err) + } + return schemafile +} diff --git a/pkg/helm/pkg/chart/v2/lint/support/doc.go b/pkg/helm/pkg/chart/v2/lint/support/doc.go new file mode 100644 index 00000000..8c8dced7 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/support/doc.go @@ -0,0 +1,23 @@ +/* +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 support contains tools for linting charts. + +Linting is the process of testing charts for errors or warnings regarding +formatting, compilation, or standards compliance. +*/ +package support // import "github.com/werf/nelm/pkg/helm/pkg/chart/v2/lint/support" diff --git a/pkg/helm/pkg/chart/v2/lint/support/message.go b/pkg/helm/pkg/chart/v2/lint/support/message.go new file mode 100644 index 00000000..5efbc7a6 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/support/message.go @@ -0,0 +1,76 @@ +/* +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 support + +import "fmt" + +// Severity indicates the severity of a Message. +const ( + // UnknownSev indicates that the severity of the error is unknown, and should not stop processing. + UnknownSev = iota + // InfoSev indicates information, for example missing values.yaml file + InfoSev + // WarningSev indicates that something does not meet code standards, but will likely function. + WarningSev + // ErrorSev indicates that something will not likely function. + ErrorSev +) + +// sev matches the *Sev states. +var sev = []string{"UNKNOWN", "INFO", "WARNING", "ERROR"} + +// Linter encapsulates a linting run of a particular chart. +type Linter struct { + Messages []Message + // The highest severity of all the failing lint rules + HighestSeverity int + ChartDir string +} + +// Message describes an error encountered while linting. +type Message struct { + // Severity is one of the *Sev constants + Severity int + Path string + Err error +} + +func (m Message) Error() string { + return fmt.Sprintf("[%s] %s: %s", sev[m.Severity], m.Path, m.Err.Error()) +} + +// NewMessage creates a new Message struct +func NewMessage(severity int, path string, err error) Message { + return Message{Severity: severity, Path: path, Err: err} +} + +// RunLinterRule returns true if the validation passed +func (l *Linter) RunLinterRule(severity int, path string, err error) bool { + // severity is out of bound + if severity < 0 || severity >= len(sev) { + return false + } + + if err != nil { + l.Messages = append(l.Messages, NewMessage(severity, path, err)) + + if severity > l.HighestSeverity { + l.HighestSeverity = severity + } + } + return err == nil +} diff --git a/pkg/helm/pkg/chart/v2/lint/support/message_test.go b/pkg/helm/pkg/chart/v2/lint/support/message_test.go new file mode 100644 index 00000000..ce5b5e42 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/lint/support/message_test.go @@ -0,0 +1,79 @@ +/* +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 support + +import ( + "errors" + "testing" +) + +var errLint = errors.New("lint failed") + +func TestRunLinterRule(t *testing.T) { + var tests = []struct { + Severity int + LintError error + ExpectedMessages int + ExpectedReturn bool + ExpectedHighestSeverity int + }{ + {InfoSev, errLint, 1, false, InfoSev}, + {WarningSev, errLint, 2, false, WarningSev}, + {ErrorSev, errLint, 3, false, ErrorSev}, + // No error so it returns true + {ErrorSev, nil, 3, true, ErrorSev}, + // Retains highest severity + {InfoSev, errLint, 4, false, ErrorSev}, + // Invalid severity values + {4, errLint, 4, false, ErrorSev}, + {22, errLint, 4, false, ErrorSev}, + {-1, errLint, 4, false, ErrorSev}, + } + + linter := Linter{} + for _, test := range tests { + isValid := linter.RunLinterRule(test.Severity, "chart", test.LintError) + if len(linter.Messages) != test.ExpectedMessages { + t.Errorf("RunLinterRule(%d, \"chart\", %v), linter.Messages should now have %d message, we got %d", test.Severity, test.LintError, test.ExpectedMessages, len(linter.Messages)) + } + + if linter.HighestSeverity != test.ExpectedHighestSeverity { + t.Errorf("RunLinterRule(%d, \"chart\", %v), linter.HighestSeverity should be %d, we got %d", test.Severity, test.LintError, test.ExpectedHighestSeverity, linter.HighestSeverity) + } + + if isValid != test.ExpectedReturn { + t.Errorf("RunLinterRule(%d, \"chart\", %v), should have returned %t but returned %t", test.Severity, test.LintError, test.ExpectedReturn, isValid) + } + } +} + +func TestMessage(t *testing.T) { + m := Message{ErrorSev, "Chart.yaml", errors.New("Foo")} + if m.Error() != "[ERROR] Chart.yaml: Foo" { + t.Errorf("Unexpected output: %s", m.Error()) + } + + m = Message{WarningSev, "templates/", errors.New("Bar")} + if m.Error() != "[WARNING] templates/: Bar" { + t.Errorf("Unexpected output: %s", m.Error()) + } + + m = Message{InfoSev, "templates/rc.yaml", errors.New("FooBar")} + if m.Error() != "[INFO] templates/rc.yaml: FooBar" { + t.Errorf("Unexpected output: %s", m.Error()) + } +} diff --git a/pkg/helm/pkg/chart/v2/loader/archive.go b/pkg/helm/pkg/chart/v2/loader/archive.go new file mode 100644 index 00000000..04af3711 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/archive.go @@ -0,0 +1,76 @@ +/* +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 loader + +import ( + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "os" + + "github.com/werf/nelm/pkg/helm/pkg/chart/loader/archive" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" +) + +// FileLoader loads a chart from a file +type FileLoader string + +// Load loads a chart +func (l FileLoader) Load(ctx context.Context) (*chart.Chart, error) { + return LoadFile(ctx, string(l)) +} + +// LoadFile loads from an archive file. +func LoadFile(ctx context.Context, name string) (*chart.Chart, error) { + + if fi, err := os.Stat(name); err != nil { + return nil, err + } else if fi.IsDir() { + return nil, errors.New("cannot load a directory") + } + + raw, err := os.Open(name) + if err != nil { + return nil, err + } + defer raw.Close() + + err = archive.EnsureArchive(name, raw) + if err != nil { + return nil, err + } + + c, err := LoadArchive(ctx, raw) + if err != nil { + if errors.Is(err, gzip.ErrHeader) { + return nil, fmt.Errorf("file '%s' does not appear to be a valid chart file (details: %w)", name, err) + } + } + return c, err +} + +// LoadArchive loads from a reader containing a compressed tar archive. +func LoadArchive(ctx context.Context, in io.Reader) (*chart.Chart, error) { + files, err := archive.LoadArchiveFiles(in) + if err != nil { + return nil, err + } + + return LoadFiles(ctx, files) +} diff --git a/pkg/helm/pkg/werf/chartextender/chart_metadata.go b/pkg/helm/pkg/chart/v2/loader/chart_metadata.go similarity index 71% rename from pkg/helm/pkg/werf/chartextender/chart_metadata.go rename to pkg/helm/pkg/chart/v2/loader/chart_metadata.go index ddf2666e..48776d19 100644 --- a/pkg/helm/pkg/werf/chartextender/chart_metadata.go +++ b/pkg/helm/pkg/chart/v2/loader/chart_metadata.go @@ -1,15 +1,15 @@ -package chartextender +package loader -import "github.com/werf/nelm/pkg/helm/pkg/chart" +import chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" -type GetHelmChartMetadataOptions struct { +type autosetChartMetadataOptions struct { OverrideAppVersion string DefaultAPIVersion string DefaultName string DefaultVersion string } -func AutosetChartMetadata(metadataIn *chart.Metadata, opts GetHelmChartMetadataOptions) *chart.Metadata { +func autosetChartMetadata(metadataIn *chart.Metadata, opts autosetChartMetadataOptions) *chart.Metadata { var metadata *chart.Metadata if metadataIn == nil { metadata = &chart.Metadata{} diff --git a/pkg/helm/pkg/chart/v2/loader/directory.go b/pkg/helm/pkg/chart/v2/loader/directory.go new file mode 100644 index 00000000..9cf58e3a --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/directory.go @@ -0,0 +1,159 @@ +/* +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 loader + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" + + nelmcommon "github.com/werf/nelm/pkg/common" + "github.com/werf/nelm/pkg/helm/intern/sympath" + "github.com/werf/nelm/pkg/helm/pkg/chart/loader/archive" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/ignore" +) + +var utf8bom = []byte{0xEF, 0xBB, 0xBF} + +// DirLoader loads a chart from a directory +type DirLoader string + +// Load loads the chart +func (l DirLoader) Load(ctx context.Context) (*chart.Chart, error) { + return LoadDir(ctx, string(l)) +} + +// LoadDir loads from a directory. +// +// This loads charts only from directories. +func LoadDir(ctx context.Context, dir string) (*chart.Chart, error) { + helmOpts := nelmcommon.HelmOptionsFromContext(ctx) + + var files []*archive.BufferedFile + switch helmOpts.ChartLoadOpts.ChartType { + case nelmcommon.LegacyChartTypeChart: + if nelmcommon.ChartFileReader != nil { + chartFiles, err := nelmcommon.ChartFileReader.LoadChartDir(ctx, dir) + if err != nil { + return nil, fmt.Errorf("load chart dir: %w", err) + } + + files = make([]*archive.BufferedFile, 0, len(chartFiles)) + for _, f := range chartFiles { + files = append(files, &archive.BufferedFile{Name: f.Name, Data: f.Data}) + } + } else { + localFiles, err := getFilesFromLocalFilesystem(dir) + if err != nil { + return nil, err + } + + files = localFiles + } + case nelmcommon.LegacyChartTypeBundle, nelmcommon.LegacyChartTypeSubchart, nelmcommon.LegacyChartTypeChartStub: + localFiles, err := getFilesFromLocalFilesystem(dir) + if err != nil { + return nil, err + } + + files = localFiles + default: + panic("unexpected type") + } + + return LoadFiles(ctx, files) +} + +func getFilesFromLocalFilesystem(dir string) ([]*archive.BufferedFile, error) { + topdir, err := filepath.Abs(dir) + if err != nil { + return nil, err + } + + rules := ignore.Empty() + ifile := filepath.Join(topdir, ignore.HelmIgnore) + if _, err := os.Stat(ifile); err == nil { + r, err := ignore.ParseFile(ifile) + if err != nil { + return nil, err + } + rules = r + } + rules.AddDefaults() + + files := []*archive.BufferedFile{} + topdir += string(filepath.Separator) + + walk := func(name string, fi os.FileInfo, err error) error { + n := strings.TrimPrefix(name, topdir) + if n == "" { + // No need to process top level. Avoid bug with helmignore .* matching + // empty names. See issue 1779. + return nil + } + + // Normalize to / since it will also work on Windows + n = filepath.ToSlash(n) + + if err != nil { + return err + } + if fi.IsDir() { + // Directory-based ignore rules should involve skipping the entire + // contents of that directory. + if rules.Ignore(n, fi) { + return filepath.SkipDir + } + return nil + } + + // If a .helmignore file matches, skip this file. + if rules.Ignore(n, fi) { + return nil + } + + // Irregular files include devices, sockets, and other uses of files that + // are not regular files. In Go they have a file mode type bit set. + // See https://golang.org/pkg/os/#FileMode for examples. + if !fi.Mode().IsRegular() { + return fmt.Errorf("cannot load irregular file %s as it has file mode type bits set", name) + } + + if fi.Size() > archive.MaxDecompressedFileSize { + return fmt.Errorf("chart file %q is larger than the maximum file size %d", fi.Name(), archive.MaxDecompressedFileSize) + } + + data, err := os.ReadFile(name) + if err != nil { + return fmt.Errorf("error reading %s: %w", n, err) + } + + data = bytes.TrimPrefix(data, utf8bom) + + files = append(files, &archive.BufferedFile{Name: n, ModTime: fi.ModTime(), Data: data}) + return nil + } + if err = sympath.Walk(topdir, walk); err != nil { + return nil, err + } + + return files, nil +} diff --git a/pkg/helm/pkg/chart/v2/loader/load.go b/pkg/helm/pkg/chart/v2/loader/load.go new file mode 100644 index 00000000..15e0714f --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/load.go @@ -0,0 +1,382 @@ +/* +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 loader + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "log" + "maps" + "os" + "path/filepath" + "strings" + + utilyaml "k8s.io/apimachinery/pkg/util/yaml" + "sigs.k8s.io/yaml" + + "github.com/werf/common-go/pkg/secrets_manager" + nelmcommon "github.com/werf/nelm/pkg/common" + chartcommon "github.com/werf/nelm/pkg/helm/pkg/chart/common" + "github.com/werf/nelm/pkg/helm/pkg/chart/loader/archive" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + legacysecret "github.com/werf/nelm/pkg/legacy/secret" +) + +// ChartLoader loads a chart. +type ChartLoader interface { + Load(ctx context.Context) (*chart.Chart, error) +} + +// Loader returns a new ChartLoader appropriate for the given chart name +func Loader(name string) (ChartLoader, error) { + fi, err := os.Stat(name) + if err != nil { + return nil, err + } + if fi.IsDir() { + return DirLoader(name), nil + } + return FileLoader(name), nil +} + +// Load takes a string name, tries to resolve it to a file or directory, and then loads it. +// +// This is the preferred way to load a chart. It will discover the chart encoding +// and hand off to the appropriate chart reader. +// +// If a .helmignore file is present, the directory loader will skip loading any files +// matching it. But .helmignore is not evaluated when reading out of an archive. +func Load(ctx context.Context, name string) (*chart.Chart, error) { + l, err := Loader(name) + if err != nil { + return nil, err + } + + return l.Load(ctx) +} + +// LoadFiles loads from in-memory files. +func LoadFiles(ctx context.Context, files []*archive.BufferedFile) (*chart.Chart, error) { + helmOpts := nelmcommon.HelmOptionsFromContext(ctx) + applyWerfExtensions := nelmcommon.HasHelmOptions(ctx) + + c := new(chart.Chart) + subcharts := make(map[string][]*archive.BufferedFile) + + if applyWerfExtensions { + c.SecretsRuntimeData = legacysecret.NewSecretsRuntimeData() + } + + // do not rely on assumed ordering of files in the chart and crash + // if Chart.yaml was not coming early enough to initialize metadata + for _, f := range files { + c.Raw = append(c.Raw, &chartcommon.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data}) + if f.Name == "Chart.yaml" { + if c.Metadata == nil { + c.Metadata = new(chart.Metadata) + } + if err := yaml.Unmarshal(f.Data, c.Metadata); err != nil { + return c, fmt.Errorf("cannot load Chart.yaml: %w", err) + } + // NOTE(bacongobbler): while the chart specification says that APIVersion must be set, + // Helm 2 accepted charts that did not provide an APIVersion in their chart metadata. + // Because of that, if APIVersion is unset, we should assume we're loading a v1 chart. + if c.Metadata.APIVersion == "" { + c.Metadata.APIVersion = chart.APIVersionV1 + } + c.ModTime = f.ModTime + } + } + for _, f := range files { + switch { + case f.Name == "Chart.yaml": + // already processed + continue + case f.Name == "Chart.lock": + c.Lock = new(chart.Lock) + if err := yaml.Unmarshal(f.Data, &c.Lock); err != nil { + return c, fmt.Errorf("cannot load Chart.lock: %w", err) + } + case f.Name == "values.yaml": + values, err := LoadValues(bytes.NewReader(f.Data)) + if err != nil { + return c, fmt.Errorf("cannot load values.yaml: %w", err) + } + c.Values = values + case f.Name == "values.schema.json": + c.Schema = f.Data + c.SchemaModTime = f.ModTime + + // Deprecated: requirements.yaml is deprecated use Chart.yaml. + // We will handle it for you because we are nice people + case f.Name == "requirements.yaml": + if c.Metadata == nil { + c.Metadata = new(chart.Metadata) + } + if c.Metadata.APIVersion != chart.APIVersionV1 { + log.Printf("Warning: Dependencies are handled in Chart.yaml since apiVersion \"v2\". We recommend migrating dependencies to Chart.yaml.") + } + if err := yaml.Unmarshal(f.Data, c.Metadata); err != nil { + return c, fmt.Errorf("cannot load requirements.yaml: %w", err) + } + if c.Metadata.APIVersion == chart.APIVersionV1 { + c.Files = append(c.Files, &chartcommon.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data}) + } + // Deprecated: requirements.lock is deprecated use Chart.lock. + case f.Name == "requirements.lock": + c.Lock = new(chart.Lock) + if err := yaml.Unmarshal(f.Data, &c.Lock); err != nil { + return c, fmt.Errorf("cannot load requirements.lock: %w", err) + } + if c.Metadata == nil { + c.Metadata = new(chart.Metadata) + } + if c.Metadata.APIVersion != chart.APIVersionV1 { + log.Printf("Warning: Dependency locking is handled in Chart.lock since apiVersion \"v2\". We recommend migrating to Chart.lock.") + } + if c.Metadata.APIVersion == chart.APIVersionV1 { + c.Files = append(c.Files, &chartcommon.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data}) + } + + case strings.HasPrefix(f.Name, "templates/"): + c.Templates = append(c.Templates, &chartcommon.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data}) + case strings.HasPrefix(f.Name, "charts/"): + if filepath.Ext(f.Name) == ".prov" { + c.Files = append(c.Files, &chartcommon.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data}) + continue + } + + fname := strings.TrimPrefix(f.Name, "charts/") + cname := strings.SplitN(fname, "/", 2)[0] + subcharts[cname] = append(subcharts[cname], &archive.BufferedFile{Name: fname, ModTime: f.ModTime, Data: f.Data}) + case applyWerfExtensions && strings.HasPrefix(f.Name, "ts/node_modules/"): + c.RuntimeDepsFiles = append(c.RuntimeDepsFiles, &chartcommon.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data}) + case applyWerfExtensions && strings.HasPrefix(f.Name, "ts/"): + c.RuntimeFiles = append(c.RuntimeFiles, &chartcommon.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data}) + default: + c.Files = append(c.Files, &chartcommon.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data}) + } + } + + if applyWerfExtensions { + switch helmOpts.ChartLoadOpts.ChartType { + case nelmcommon.LegacyChartTypeBundle: + c.ExtraValues = helmOpts.ChartLoadOpts.ExtraValues + + if !helmOpts.ChartLoadOpts.NoSecrets { + if err := c.SecretsRuntimeData.DecodeAndLoadSecrets( + ctx, + convertBufferedFiles(files), + secrets_manager.Manager, + chartcommon.DecodeAndLoadSecretsOptions{ + CustomSecretValueFiles: helmOpts.ChartLoadOpts.SecretValuesFiles, + LoadFromLocalFilesystem: true, + NoDecryptSecrets: helmOpts.ChartLoadOpts.SecretKeyIgnore, + SecretsWorkingDir: helmOpts.ChartLoadOpts.SecretWorkDir, + WithoutDefaultSecretValues: helmOpts.ChartLoadOpts.DefaultSecretValuesDisable, + }, + ); err != nil { + return nil, fmt.Errorf("error decoding secrets: %w", err) + } + } + + if helmOpts.ChartLoadOpts.DefaultValuesDisable { + c.Values = nil + } + case nelmcommon.LegacyChartTypeChart: + c.ExtraValues = helmOpts.ChartLoadOpts.ExtraValues + + if !helmOpts.ChartLoadOpts.NoSecrets { + if err := c.SecretsRuntimeData.DecodeAndLoadSecrets( + ctx, + convertBufferedFiles(files), + secrets_manager.Manager, + chartcommon.DecodeAndLoadSecretsOptions{ + CustomSecretValueFiles: helmOpts.ChartLoadOpts.SecretValuesFiles, + LoadFromLocalFilesystem: nelmcommon.ChartFileReader == nil, + NoDecryptSecrets: helmOpts.ChartLoadOpts.SecretKeyIgnore, + SecretsWorkingDir: helmOpts.ChartLoadOpts.SecretWorkDir, + WithoutDefaultSecretValues: helmOpts.ChartLoadOpts.DefaultSecretValuesDisable, + }, + ); err != nil { + return nil, fmt.Errorf("error decoding secrets: %w", err) + } + } + + c.Metadata = autosetChartMetadata( + c.Metadata, + autosetChartMetadataOptions{ + DefaultAPIVersion: helmOpts.ChartLoadOpts.DefaultChartAPIVersion, + DefaultName: helmOpts.ChartLoadOpts.DefaultChartName, + DefaultVersion: helmOpts.ChartLoadOpts.DefaultChartVersion, + OverrideAppVersion: helmOpts.ChartLoadOpts.ChartAppVersion, + }, + ) + + c.Templates = append(c.Templates, &chartcommon.File{Name: "templates/_werf_helpers.tpl"}) + + if helmOpts.ChartLoadOpts.DefaultValuesDisable { + c.Values = nil + } + case nelmcommon.LegacyChartTypeSubchart: + if !helmOpts.ChartLoadOpts.NoSecrets { + if err := c.SecretsRuntimeData.DecodeAndLoadSecrets( + ctx, + convertBufferedFiles(files), + secrets_manager.Manager, + chartcommon.DecodeAndLoadSecretsOptions{ + LoadFromLocalFilesystem: nelmcommon.ChartFileReader == nil, + NoDecryptSecrets: helmOpts.ChartLoadOpts.SecretKeyIgnore, + SecretsWorkingDir: helmOpts.ChartLoadOpts.SecretWorkDir, + WithoutDefaultSecretValues: helmOpts.ChartLoadOpts.DefaultSecretValuesDisable, + }, + ); err != nil { + return nil, fmt.Errorf("error decoding secrets: %w", err) + } + } + case nelmcommon.LegacyChartTypeChartStub: + if !helmOpts.ChartLoadOpts.NoSecrets { + if err := c.SecretsRuntimeData.DecodeAndLoadSecrets( + ctx, + convertBufferedFiles(files), + secrets_manager.Manager, + chartcommon.DecodeAndLoadSecretsOptions{ + LoadFromLocalFilesystem: true, + NoDecryptSecrets: helmOpts.ChartLoadOpts.SecretKeyIgnore, + SecretsWorkingDir: helmOpts.ChartLoadOpts.SecretWorkDir, + WithoutDefaultSecretValues: helmOpts.ChartLoadOpts.DefaultSecretValuesDisable, + }, + ); err != nil { + return nil, fmt.Errorf("error decoding secrets: %w", err) + } + } + + c.Metadata = autosetChartMetadata( + c.Metadata, + autosetChartMetadataOptions{ + DefaultAPIVersion: chart.APIVersionV2, + DefaultName: "stubchartname", + DefaultVersion: "1.0.0", + }, + ) + + c.Templates = append(c.Templates, &chartcommon.File{Name: "templates/_werf_helpers.tpl"}) + default: + panic("unexpected type") + } + } + + if c.Metadata == nil { + return c, errors.New("Chart.yaml file is missing") //nolint:staticcheck + } + + if err := c.Validate(); err != nil { + return c, err + } + + helmOpts.ChartLoadOpts.ChartType = nelmcommon.LegacyChartTypeSubchart + ctx = nelmcommon.ContextWithHelmOptions(ctx, helmOpts) + + for n, files := range subcharts { + var sc *chart.Chart + var err error + switch { + case strings.IndexAny(n, "_.") == 0: + continue + case filepath.Ext(n) == ".tgz": + file := files[0] + if file.Name != n { + return c, fmt.Errorf("error unpacking subchart tar in %s: expected %s, got %s", c.Name(), n, file.Name) + } + sc, err = LoadArchive(ctx, bytes.NewBuffer(file.Data)) + default: + buff := make([]*archive.BufferedFile, 0, len(files)) + for _, f := range files { + parts := strings.SplitN(f.Name, "/", 2) + if len(parts) < 2 { + continue + } + f.Name = parts[1] + buff = append(buff, f) + } + sc, err = LoadFiles(ctx, buff) + } + + if err != nil { + return c, fmt.Errorf("error unpacking subchart %s in %s: %w", n, c.Name(), err) + } + c.AddDependency(sc) + } + + return c, nil +} + +func convertBufferedFiles(files []*archive.BufferedFile) []*nelmcommon.BufferedFile { + var res []*nelmcommon.BufferedFile + for _, f := range files { + res = append(res, &nelmcommon.BufferedFile{Name: f.Name, Data: f.Data}) + } + + return res +} + +// LoadValues loads values from a reader. +// +// The reader is expected to contain one or more YAML documents, the values of which are merged. +// And the values can be either a chart's default values or user-supplied values. +func LoadValues(data io.Reader) (map[string]interface{}, error) { + values := map[string]interface{}{} + reader := utilyaml.NewYAMLReader(bufio.NewReader(data)) + for { + currentMap := map[string]interface{}{} + raw, err := reader.Read() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return nil, fmt.Errorf("error reading yaml document: %w", err) + } + if err := yaml.Unmarshal(raw, ¤tMap); err != nil { + return nil, fmt.Errorf("cannot unmarshal yaml document: %w", err) + } + values = MergeMaps(values, currentMap) + } + return values, nil +} + +// MergeMaps merges two maps. If a key exists in both maps, the value from b will be used. +// If the value is a map, the maps will be merged recursively. +func MergeMaps(a, b map[string]interface{}) map[string]interface{} { + out := make(map[string]interface{}, len(a)) + maps.Copy(out, a) + for k, v := range b { + if v, ok := v.(map[string]interface{}); ok { + if bv, ok := out[k]; ok { + if bv, ok := bv.(map[string]interface{}); ok { + out[k] = MergeMaps(bv, v) + continue + } + } + } + out[k] = v + } + return out +} diff --git a/pkg/helm/pkg/chart/v2/loader/load_test.go b/pkg/helm/pkg/chart/v2/loader/load_test.go new file mode 100644 index 00000000..b7c38851 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/load_test.go @@ -0,0 +1,780 @@ +/* +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 loader + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "errors" + "io" + "log" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + "time" + + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + "github.com/werf/nelm/pkg/helm/pkg/chart/loader/archive" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" +) + +func TestLoadDir(t *testing.T) { + l, err := Loader("testdata/frobnitz") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + c, err := l.Load(context.Background()) + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyFrobnitz(t, c) + verifyChart(t, c) + verifyDependencies(t, c) + verifyDependenciesLock(t, c) +} + +func TestLoadDirWithDevNull(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test only works on unix systems with /dev/null present") + } + + l, err := Loader("testdata/frobnitz_with_dev_null") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + if _, err := l.Load(context.Background()); err == nil { + t.Errorf("packages with an irregular file (/dev/null) should not load") + } +} + +func TestLoadDirWithSymlink(t *testing.T) { + sym := filepath.Join("..", "LICENSE") + link := filepath.Join("testdata", "frobnitz_with_symlink", "LICENSE") + + if err := os.Symlink(sym, link); err != nil { + t.Fatal(err) + } + + defer os.Remove(link) + + l, err := Loader("testdata/frobnitz_with_symlink") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + + c, err := l.Load(context.Background()) + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyFrobnitz(t, c) + verifyChart(t, c) + verifyDependencies(t, c) + verifyDependenciesLock(t, c) +} + +func TestBomTestData(t *testing.T) { + testFiles := []string{"frobnitz_with_bom/.helmignore", "frobnitz_with_bom/templates/template.tpl", "frobnitz_with_bom/Chart.yaml"} + for _, file := range testFiles { + data, err := os.ReadFile("testdata/" + file) + if err != nil || !bytes.HasPrefix(data, utf8bom) { + t.Errorf("Test file has no BOM or is invalid: testdata/%s", file) + } + } + + archive, err := os.ReadFile("testdata/frobnitz_with_bom.tgz") + if err != nil { + t.Fatalf("Error reading archive frobnitz_with_bom.tgz: %s", err) + } + unzipped, err := gzip.NewReader(bytes.NewReader(archive)) + if err != nil { + t.Fatalf("Error reading archive frobnitz_with_bom.tgz: %s", err) + } + defer unzipped.Close() + for _, testFile := range testFiles { + data := make([]byte, 3) + err := unzipped.Reset(bytes.NewReader(archive)) + if err != nil { + t.Fatalf("Error reading archive frobnitz_with_bom.tgz: %s", err) + } + tr := tar.NewReader(unzipped) + for { + file, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatalf("Error reading archive frobnitz_with_bom.tgz: %s", err) + } + if file != nil && strings.EqualFold(file.Name, testFile) { + _, err := tr.Read(data) + if err != nil { + t.Fatalf("Error reading archive frobnitz_with_bom.tgz: %s", err) + } else { + break + } + } + } + if !bytes.Equal(data, utf8bom) { + t.Fatalf("Test file has no BOM or is invalid: frobnitz_with_bom.tgz/%s", testFile) + } + } +} + +func TestLoadDirWithUTFBOM(t *testing.T) { + l, err := Loader("testdata/frobnitz_with_bom") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + c, err := l.Load(context.Background()) + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyFrobnitz(t, c) + verifyChart(t, c) + verifyDependencies(t, c) + verifyDependenciesLock(t, c) + verifyBomStripped(t, c.Files) +} + +func TestLoadArchiveWithUTFBOM(t *testing.T) { + l, err := Loader("testdata/frobnitz_with_bom.tgz") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + c, err := l.Load(context.Background()) + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyFrobnitz(t, c) + verifyChart(t, c) + verifyDependencies(t, c) + verifyDependenciesLock(t, c) + verifyBomStripped(t, c.Files) +} + +func TestLoadV1(t *testing.T) { + l, err := Loader("testdata/frobnitz.v1") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + c, err := l.Load(context.Background()) + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyDependencies(t, c) + verifyDependenciesLock(t, c) +} + +func TestLoadFileV1(t *testing.T) { + l, err := Loader("testdata/frobnitz.v1.tgz") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + c, err := l.Load(context.Background()) + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyDependencies(t, c) + verifyDependenciesLock(t, c) +} + +func TestLoadFile(t *testing.T) { + l, err := Loader("testdata/frobnitz-1.2.3.tgz") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + c, err := l.Load(context.Background()) + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyFrobnitz(t, c) + verifyChart(t, c) + verifyDependencies(t, c) +} + +func TestLoadFiles_BadCases(t *testing.T) { + for _, tt := range []struct { + name string + bufferedFiles []*archive.BufferedFile + expectError string + }{ + { + name: "These files contain only requirements.lock", + bufferedFiles: []*archive.BufferedFile{ + { + Name: "requirements.lock", + ModTime: time.Now(), + Data: []byte(""), + }, + }, + expectError: "validation: chart.metadata.apiVersion is required"}, + } { + _, err := LoadFiles(context.Background(), tt.bufferedFiles) + if err == nil { + t.Fatal("expected error when load illegal files") + } + if !strings.Contains(err.Error(), tt.expectError) { + t.Errorf("Expected error to contain %q, got %q for %s", tt.expectError, err.Error(), tt.name) + } + } +} + +func TestLoadFiles(t *testing.T) { + modTime := time.Now() + goodFiles := []*archive.BufferedFile{ + { + Name: "Chart.yaml", + ModTime: modTime, + Data: []byte(`apiVersion: v1 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +`), + }, + { + Name: "values.yaml", + ModTime: modTime, + Data: []byte("var: some values"), + }, + { + Name: "values.schema.json", + ModTime: modTime, + Data: []byte("type: Values"), + }, + { + Name: "templates/deployment.yaml", + ModTime: modTime, + Data: []byte("some deployment"), + }, + { + Name: "templates/service.yaml", + ModTime: modTime, + Data: []byte("some service"), + }, + } + + c, err := LoadFiles(context.Background(), goodFiles) + if err != nil { + t.Errorf("Expected good files to be loaded, got %v", err) + } + + if c.Name() != "frobnitz" { + t.Errorf("Expected chart name to be 'frobnitz', got %s", c.Name()) + } + + if c.Values["var"] != "some values" { + t.Error("Expected chart values to be populated with default values") + } + + if len(c.Raw) != 5 { + t.Errorf("Expected %d files, got %d", 5, len(c.Raw)) + } + + if !bytes.Equal(c.Schema, []byte("type: Values")) { + t.Error("Expected chart schema to be populated with default values") + } + + if len(c.Templates) != 2 { + t.Errorf("Expected number of templates == 2, got %d", len(c.Templates)) + } + + if _, err = LoadFiles(context.Background(), []*archive.BufferedFile{}); err == nil { + t.Fatal("Expected err to be non-nil") + } + if err.Error() != "Chart.yaml file is missing" { + t.Errorf("Expected chart metadata missing error, got '%s'", err.Error()) + } +} + +// Test the order of file loading. The Chart.yaml file needs to come first for +// later comparison checks. See https://github.com/helm/helm/pull/8948 +func TestLoadFilesOrder(t *testing.T) { + modTime := time.Now() + goodFiles := []*archive.BufferedFile{ + { + Name: "requirements.yaml", + ModTime: modTime, + Data: []byte("dependencies:"), + }, + { + Name: "values.yaml", + ModTime: modTime, + Data: []byte("var: some values"), + }, + + { + Name: "templates/deployment.yaml", + ModTime: modTime, + Data: []byte("some deployment"), + }, + { + Name: "templates/service.yaml", + ModTime: modTime, + Data: []byte("some service"), + }, + { + Name: "Chart.yaml", + ModTime: modTime, + Data: []byte(`apiVersion: v1 +name: frobnitz +description: This is a frobnitz. +version: "1.2.3" +keywords: + - frobnitz + - sprocket + - dodad +maintainers: + - name: The Helm Team + email: helm@example.com + - name: Someone Else + email: nobody@example.com +sources: + - https://example.com/foo/bar +home: http://example.com +icon: https://example.com/64x64.png +`), + }, + } + + // Capture stderr to make sure message about Chart.yaml handle dependencies + // is not present + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("Unable to create pipe: %s", err) + } + stderr := log.Writer() + log.SetOutput(w) + defer func() { + log.SetOutput(stderr) + }() + + _, err = LoadFiles(context.Background(), goodFiles) + if err != nil { + t.Errorf("Expected good files to be loaded, got %v", err) + } + w.Close() + + var text bytes.Buffer + io.Copy(&text, r) + if text.String() != "" { + t.Errorf("Expected no message to Stderr, got %s", text.String()) + } + +} + +// Packaging the chart on a Windows machine will produce an +// archive that has \\ as delimiters. Test that we support these archives +func TestLoadFileBackslash(t *testing.T) { + c, err := Load(context.Background(), "testdata/frobnitz_backslash-1.2.3.tgz") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyChartFileAndTemplate(t, c, "frobnitz_backslash") + verifyChart(t, c) + verifyDependencies(t, c) +} + +func TestLoadV2WithReqs(t *testing.T) { + l, err := Loader("testdata/frobnitz.v2.reqs") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + c, err := l.Load(context.Background()) + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyDependencies(t, c) + verifyDependenciesLock(t, c) +} + +func TestLoadInvalidArchive(t *testing.T) { + tmpdir := t.TempDir() + + writeTar := func(filename, internalPath string, body []byte) { + dest, err := os.Create(filename) + if err != nil { + t.Fatal(err) + } + zipper := gzip.NewWriter(dest) + tw := tar.NewWriter(zipper) + + h := &tar.Header{ + Name: internalPath, + Mode: 0755, + Size: int64(len(body)), + ModTime: time.Now(), + } + if err := tw.WriteHeader(h); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(body); err != nil { + t.Fatal(err) + } + tw.Close() + zipper.Close() + dest.Close() + } + + for _, tt := range []struct { + chartname string + internal string + expectError string + }{ + {"illegal-dots.tgz", "../../malformed-helm-test", "chart illegally references parent directory"}, + {"illegal-dots2.tgz", "/foo/../../malformed-helm-test", "chart illegally references parent directory"}, + {"illegal-dots3.tgz", "/../../malformed-helm-test", "chart illegally references parent directory"}, + {"illegal-dots4.tgz", "./../../malformed-helm-test", "chart illegally references parent directory"}, + {"illegal-name.tgz", "./.", "chart illegally contains content outside the base directory"}, + {"illegal-name2.tgz", "/./.", "chart illegally contains content outside the base directory"}, + {"illegal-name3.tgz", "missing-leading-slash", "chart illegally contains content outside the base directory"}, + {"illegal-name4.tgz", "/missing-leading-slash", "Chart.yaml file is missing"}, + {"illegal-abspath.tgz", "//foo", "chart illegally contains absolute paths"}, + {"illegal-abspath2.tgz", "///foo", "chart illegally contains absolute paths"}, + {"illegal-abspath3.tgz", "\\\\foo", "chart illegally contains absolute paths"}, + {"illegal-abspath3.tgz", "\\..\\..\\foo", "chart illegally references parent directory"}, + + // Under special circumstances, this can get normalized to things that look like absolute Windows paths + {"illegal-abspath4.tgz", "\\.\\c:\\\\foo", "chart contains illegally named files"}, + {"illegal-abspath5.tgz", "/./c://foo", "chart contains illegally named files"}, + {"illegal-abspath6.tgz", "\\\\?\\Some\\windows\\magic", "chart illegally contains absolute paths"}, + } { + illegalChart := filepath.Join(tmpdir, tt.chartname) + writeTar(illegalChart, tt.internal, []byte("hello: world")) + _, err := Load(context.Background(), illegalChart) + if err == nil { + t.Fatal("expected error when unpacking illegal files") + } + if !strings.Contains(err.Error(), tt.expectError) { + t.Errorf("Expected error to contain %q, got %q for %s", tt.expectError, err.Error(), tt.chartname) + } + } + + // Make sure that absolute path gets interpreted as relative + illegalChart := filepath.Join(tmpdir, "abs-path.tgz") + writeTar(illegalChart, "/Chart.yaml", []byte("hello: world")) + _, err := Load(context.Background(), illegalChart) + if err.Error() != "validation: chart.metadata.name is required" { + t.Error(err) + } + + // And just to validate that the above was not spurious + illegalChart = filepath.Join(tmpdir, "abs-path2.tgz") + writeTar(illegalChart, "files/whatever.yaml", []byte("hello: world")) + _, err = Load(context.Background(), illegalChart) + if err.Error() != "Chart.yaml file is missing" { + t.Errorf("Unexpected error message: %s", err) + } + + // Finally, test that drive letter gets stripped off on Windows + illegalChart = filepath.Join(tmpdir, "abs-winpath.tgz") + writeTar(illegalChart, "c:\\Chart.yaml", []byte("hello: world")) + _, err = Load(context.Background(), illegalChart) + if err.Error() != "validation: chart.metadata.name is required" { + t.Error(err) + } +} + +func TestLoadValues(t *testing.T) { + testCases := map[string]struct { + data []byte + expctedValues map[string]interface{} + }{ + "It should load values correctly": { + data: []byte(` +foo: + image: foo:v1 +bar: + version: v2 +`), + expctedValues: map[string]interface{}{ + "foo": map[string]interface{}{ + "image": "foo:v1", + }, + "bar": map[string]interface{}{ + "version": "v2", + }, + }, + }, + "It should load values correctly with multiple documents in one file": { + data: []byte(` +foo: + image: foo:v1 +bar: + version: v2 +--- +foo: + image: foo:v2 +`), + expctedValues: map[string]interface{}{ + "foo": map[string]interface{}{ + "image": "foo:v2", + }, + "bar": map[string]interface{}{ + "version": "v2", + }, + }, + }, + } + for testName, testCase := range testCases { + t.Run(testName, func(tt *testing.T) { + values, err := LoadValues(bytes.NewReader(testCase.data)) + if err != nil { + tt.Fatal(err) + } + if !reflect.DeepEqual(values, testCase.expctedValues) { + tt.Errorf("Expected values: %v, got %v", testCase.expctedValues, values) + } + }) + } +} + +func TestMergeValuesV2(t *testing.T) { + nestedMap := map[string]interface{}{ + "foo": "bar", + "baz": map[string]string{ + "cool": "stuff", + }, + } + anotherNestedMap := map[string]interface{}{ + "foo": "bar", + "baz": map[string]string{ + "cool": "things", + "awesome": "stuff", + }, + } + flatMap := map[string]interface{}{ + "foo": "bar", + "baz": "stuff", + } + anotherFlatMap := map[string]interface{}{ + "testing": "fun", + } + + testMap := MergeMaps(flatMap, nestedMap) + equal := reflect.DeepEqual(testMap, nestedMap) + if !equal { + t.Errorf("Expected a nested map to overwrite a flat value. Expected: %v, got %v", nestedMap, testMap) + } + + testMap = MergeMaps(nestedMap, flatMap) + equal = reflect.DeepEqual(testMap, flatMap) + if !equal { + t.Errorf("Expected a flat value to overwrite a map. Expected: %v, got %v", flatMap, testMap) + } + + testMap = MergeMaps(nestedMap, anotherNestedMap) + equal = reflect.DeepEqual(testMap, anotherNestedMap) + if !equal { + t.Errorf("Expected a nested map to overwrite another nested map. Expected: %v, got %v", anotherNestedMap, testMap) + } + + testMap = MergeMaps(anotherFlatMap, anotherNestedMap) + expectedMap := map[string]interface{}{ + "testing": "fun", + "foo": "bar", + "baz": map[string]string{ + "cool": "things", + "awesome": "stuff", + }, + } + equal = reflect.DeepEqual(testMap, expectedMap) + if !equal { + t.Errorf("Expected a map with different keys to merge properly with another map. Expected: %v, got %v", expectedMap, testMap) + } +} + +func verifyChart(t *testing.T, c *chart.Chart) { + t.Helper() + if c.Name() == "" { + t.Fatalf("No chart metadata found on %v", c) + } + t.Logf("Verifying chart %s", c.Name()) + if len(c.Templates) != 1 { + t.Errorf("Expected 1 template, got %d", len(c.Templates)) + } + + numfiles := 6 + if len(c.Files) != numfiles { + t.Errorf("Expected %d extra files, got %d", numfiles, len(c.Files)) + for _, n := range c.Files { + t.Logf("\t%s", n.Name) + } + } + + if len(c.Dependencies()) != 2 { + t.Errorf("Expected 2 dependencies, got %d (%v)", len(c.Dependencies()), c.Dependencies()) + for _, d := range c.Dependencies() { + t.Logf("\tSubchart: %s\n", d.Name()) + } + } + + expect := map[string]map[string]string{ + "alpine": { + "version": "0.1.0", + }, + "mariner": { + "version": "4.3.2", + }, + } + + for _, dep := range c.Dependencies() { + if dep.Metadata == nil { + t.Fatalf("expected metadata on dependency: %v", dep) + } + exp, ok := expect[dep.Name()] + if !ok { + t.Fatalf("Unknown dependency %s", dep.Name()) + } + if exp["version"] != dep.Metadata.Version { + t.Errorf("Expected %s version %s, got %s", dep.Name(), exp["version"], dep.Metadata.Version) + } + } + +} + +func verifyDependencies(t *testing.T, c *chart.Chart) { + t.Helper() + if len(c.Metadata.Dependencies) != 2 { + t.Errorf("Expected 2 dependencies, got %d", len(c.Metadata.Dependencies)) + } + tests := []*chart.Dependency{ + {Name: "alpine", Version: "0.1.0", Repository: "https://example.com/charts"}, + {Name: "mariner", Version: "4.3.2", Repository: "https://example.com/charts"}, + } + for i, tt := range tests { + d := c.Metadata.Dependencies[i] + if d.Name != tt.Name { + t.Errorf("Expected dependency named %q, got %q", tt.Name, d.Name) + } + if d.Version != tt.Version { + t.Errorf("Expected dependency named %q to have version %q, got %q", tt.Name, tt.Version, d.Version) + } + if d.Repository != tt.Repository { + t.Errorf("Expected dependency named %q to have repository %q, got %q", tt.Name, tt.Repository, d.Repository) + } + } +} + +func verifyDependenciesLock(t *testing.T, c *chart.Chart) { + t.Helper() + if len(c.Metadata.Dependencies) != 2 { + t.Errorf("Expected 2 dependencies, got %d", len(c.Metadata.Dependencies)) + } + tests := []*chart.Dependency{ + {Name: "alpine", Version: "0.1.0", Repository: "https://example.com/charts"}, + {Name: "mariner", Version: "4.3.2", Repository: "https://example.com/charts"}, + } + for i, tt := range tests { + d := c.Metadata.Dependencies[i] + if d.Name != tt.Name { + t.Errorf("Expected dependency named %q, got %q", tt.Name, d.Name) + } + if d.Version != tt.Version { + t.Errorf("Expected dependency named %q to have version %q, got %q", tt.Name, tt.Version, d.Version) + } + if d.Repository != tt.Repository { + t.Errorf("Expected dependency named %q to have repository %q, got %q", tt.Name, tt.Repository, d.Repository) + } + } +} + +func verifyFrobnitz(t *testing.T, c *chart.Chart) { + t.Helper() + verifyChartFileAndTemplate(t, c, "frobnitz") +} + +func verifyChartFileAndTemplate(t *testing.T, c *chart.Chart, name string) { + t.Helper() + if c.Metadata == nil { + t.Fatal("Metadata is nil") + } + if c.Name() != name { + t.Errorf("Expected %s, got %s", name, c.Name()) + } + if len(c.Templates) != 1 { + t.Fatalf("Expected 1 template, got %d", len(c.Templates)) + } + if c.Templates[0].Name != "templates/template.tpl" { + t.Errorf("Unexpected template: %s", c.Templates[0].Name) + } + if len(c.Templates[0].Data) == 0 { + t.Error("No template data.") + } + if len(c.Files) != 6 { + t.Fatalf("Expected 6 Files, got %d", len(c.Files)) + } + if len(c.Dependencies()) != 2 { + t.Fatalf("Expected 2 Dependency, got %d", len(c.Dependencies())) + } + if len(c.Metadata.Dependencies) != 2 { + t.Fatalf("Expected 2 Dependencies.Dependency, got %d", len(c.Metadata.Dependencies)) + } + if len(c.Lock.Dependencies) != 2 { + t.Fatalf("Expected 2 Lock.Dependency, got %d", len(c.Lock.Dependencies)) + } + + for _, dep := range c.Dependencies() { + switch dep.Name() { + case "mariner": + case "alpine": + if len(dep.Templates) != 1 { + t.Fatalf("Expected 1 template, got %d", len(dep.Templates)) + } + if dep.Templates[0].Name != "templates/alpine-pod.yaml" { + t.Errorf("Unexpected template: %s", dep.Templates[0].Name) + } + if len(dep.Templates[0].Data) == 0 { + t.Error("No template data.") + } + if len(dep.Files) != 1 { + t.Fatalf("Expected 1 Files, got %d", len(dep.Files)) + } + if len(dep.Dependencies()) != 2 { + t.Fatalf("Expected 2 Dependency, got %d", len(dep.Dependencies())) + } + default: + t.Errorf("Unexpected dependency %s", dep.Name()) + } + } +} + +func verifyBomStripped(t *testing.T, files []*common.File) { + t.Helper() + for _, file := range files { + if bytes.HasPrefix(file.Data, utf8bom) { + t.Errorf("Byte Order Mark still present in processed file %s", file.Name) + } + } +} diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz/LICENSE b/pkg/helm/pkg/chart/v2/loader/testdata/LICENSE similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz/LICENSE rename to pkg/helm/pkg/chart/v2/loader/testdata/LICENSE diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/albatross/Chart.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/albatross/Chart.yaml new file mode 100644 index 00000000..eeef737f --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/albatross/Chart.yaml @@ -0,0 +1,4 @@ +name: albatross +description: A Helm chart for Kubernetes +version: 0.1.0 +home: "" diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/albatross/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/albatross/values.yaml new file mode 100644 index 00000000..3121cd7c --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/albatross/values.yaml @@ -0,0 +1,4 @@ +albatross: "true" + +global: + author: Coleridge diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz-1.2.3.tgz b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz-1.2.3.tgz similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz-1.2.3.tgz rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz-1.2.3.tgz diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v1.tgz b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1.tgz similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v1.tgz rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1.tgz diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz/.helmignore b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/.helmignore similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz/.helmignore rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/.helmignore diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz/Chart.lock b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/Chart.lock similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz/Chart.lock rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/Chart.lock diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/Chart.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/Chart.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/Chart.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz/INSTALL.txt b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/INSTALL.txt similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz/INSTALL.txt rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/INSTALL.txt diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/LICENSE b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/LICENSE new file mode 100644 index 00000000..6121943b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/LICENSE @@ -0,0 +1 @@ +LICENSE placeholder. diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz/README.md b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/README.md similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz/README.md rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/README.md diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz/charts/_ignore_me b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/_ignore_me similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz/charts/_ignore_me rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/_ignore_me diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/Chart.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/Chart.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/Chart.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz/charts/alpine/README.md b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/README.md similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz/charts/alpine/README.md rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/README.md diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/charts/mast1/Chart.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/charts/mast1/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/charts/mast1/Chart.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/charts/mast1/Chart.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/charts/mast1/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/charts/mast1/values.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/charts/mast2-0.1.0.tgz similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/charts/mast2-0.1.0.tgz diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/templates/alpine-pod.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/templates/alpine-pod.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/templates/alpine-pod.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/templates/alpine-pod.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz/charts/alpine/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz/charts/alpine/values.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/values.yaml diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/mariner-4.3.2.tgz b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/mariner-4.3.2.tgz similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/mariner-4.3.2.tgz rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/mariner-4.3.2.tgz diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz/docs/README.md b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/docs/README.md similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz/docs/README.md rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/docs/README.md diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz/icon.svg b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/icon.svg similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz/icon.svg rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/icon.svg diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz/ignore/me.txt b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/ignore/me.txt similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz/ignore/me.txt rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/ignore/me.txt diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/requirements.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/requirements.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v1/requirements.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/requirements.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz/templates/template.tpl b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/templates/template.tpl similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz/templates/template.tpl rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/templates/template.tpl diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/values.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz/values.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v1/values.yaml diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/.helmignore b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/.helmignore new file mode 100644 index 00000000..9973a57b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/.helmignore @@ -0,0 +1 @@ +ignore/ diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/Chart.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/Chart.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/INSTALL.txt b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/INSTALL.txt new file mode 100644 index 00000000..2010438c --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/INSTALL.txt @@ -0,0 +1 @@ +This is an install document. The client may display this. diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/LICENSE b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/LICENSE new file mode 100644 index 00000000..6121943b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/LICENSE @@ -0,0 +1 @@ +LICENSE placeholder. diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/README.md b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/README.md new file mode 100644 index 00000000..8cf4cc3d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/README.md @@ -0,0 +1,11 @@ +# Frobnitz + +This is an example chart. + +## Usage + +This is an example. It has no usage. + +## Development + +For developer info, see the top-level repository. diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/_ignore_me b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/_ignore_me new file mode 100644 index 00000000..2cecca68 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/_ignore_me @@ -0,0 +1 @@ +This should be ignored by the loader, but may be included in a chart. diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/Chart.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/Chart.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/README.md b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/README.md new file mode 100644 index 00000000..b30b949d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.toml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/Chart.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/Chart.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/values.yaml new file mode 100644 index 00000000..42c39c26 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/values.yaml @@ -0,0 +1,4 @@ +# Default values for mast1. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name = "value" diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast2-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..61cb62051110b55f3d08213dc81dcf0b1c2d8e53 GIT binary patch literal 252 zcmVDc zVQyr3R8em|NM&qo0PNJUs=_c72H?(liabH@pWIst-7YSIyL+rhEHrIN(t?QZE=F{y zgNRfS&$pa5Ly`mMk2OB%pV`*9knW7FlL-Joo@KED7*{C$d;N~z z37$S{+}wvSU9}|VtF|fRpv9Ve>8dWo|9?5B+RE}Y9CFh-x#(Bq8Vck^V=NUiPLCKa z8z5CF#JgK!4>;$4Fm+FUst4d+{(+nP|0&J+e}(;l^U4@w-{=?s0RR8vgVbLD3;+OM Cs&R<` literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/templates/alpine-pod.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..21ae20aa --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service}} + app.kubernetes.io/name: {{.Chart.Name}} + helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}" +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.9" + command: ["/bin/sleep","9000"] diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/values.yaml new file mode 100644 index 00000000..6c2aab7b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: "my-alpine" diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/mariner-4.3.2.tgz b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/mariner-4.3.2.tgz similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/mariner-4.3.2.tgz rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/mariner-4.3.2.tgz diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/docs/README.md b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/docs/README.md new file mode 100644 index 00000000..d40747ca --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/docs/README.md @@ -0,0 +1 @@ +This is a placeholder for documentation. diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/icon.svg b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/icon.svg new file mode 100644 index 00000000..89213060 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/icon.svg @@ -0,0 +1,8 @@ + + + Example icon + + + diff --git a/pkg/helm/cmd/helm/testdata/output/lint-quiet.txt b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/ignore/me.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/lint-quiet.txt rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/ignore/me.txt diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/requirements.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/requirements.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz.v2.reqs/requirements.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/requirements.yaml diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/templates/template.tpl b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/templates/template.tpl new file mode 100644 index 00000000..c651ee6a --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/templates/template.tpl @@ -0,0 +1 @@ +Hello {{.Name | default "world"}} diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/values.yaml new file mode 100644 index 00000000..61f50125 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/values.yaml @@ -0,0 +1,6 @@ +# A values file contains configuration. + +name: "Some Name" + +section: + name: "Name in a section" diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/.helmignore b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/.helmignore new file mode 100644 index 00000000..9973a57b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/.helmignore @@ -0,0 +1 @@ +ignore/ diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/Chart.lock b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/Chart.lock new file mode 100644 index 00000000..6fcc2ed9 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/Chart.lock @@ -0,0 +1,8 @@ +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts +digest: invalid diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz/Chart.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz/Chart.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/INSTALL.txt b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/INSTALL.txt new file mode 100644 index 00000000..2010438c --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/INSTALL.txt @@ -0,0 +1 @@ +This is an install document. The client may display this. diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/LICENSE b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/LICENSE new file mode 100644 index 00000000..6121943b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/LICENSE @@ -0,0 +1 @@ +LICENSE placeholder. diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/README.md b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/README.md new file mode 100644 index 00000000..8cf4cc3d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/README.md @@ -0,0 +1,11 @@ +# Frobnitz + +This is an example chart. + +## Usage + +This is an example. It has no usage. + +## Development + +For developer info, see the top-level repository. diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/_ignore_me b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/_ignore_me new file mode 100644 index 00000000..2cecca68 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/_ignore_me @@ -0,0 +1 @@ +This should be ignored by the loader, but may be included in a chart. diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz/charts/alpine/Chart.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz/charts/alpine/Chart.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/README.md b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/README.md new file mode 100644 index 00000000..b30b949d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.toml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml new file mode 100644 index 00000000..42c39c26 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml @@ -0,0 +1,4 @@ +# Default values for mast1. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name = "value" diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..61cb62051110b55f3d08213dc81dcf0b1c2d8e53 GIT binary patch literal 252 zcmVDc zVQyr3R8em|NM&qo0PNJUs=_c72H?(liabH@pWIst-7YSIyL+rhEHrIN(t?QZE=F{y zgNRfS&$pa5Ly`mMk2OB%pV`*9knW7FlL-Joo@KED7*{C$d;N~z z37$S{+}wvSU9}|VtF|fRpv9Ve>8dWo|9?5B+RE}Y9CFh-x#(Bq8Vck^V=NUiPLCKa z8z5CF#JgK!4>;$4Fm+FUst4d+{(+nP|0&J+e}(;l^U4@w-{=?s0RR8vgVbLD3;+OM Cs&R<` literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..21ae20aa --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service}} + app.kubernetes.io/name: {{.Chart.Name}} + helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}" +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.9" + command: ["/bin/sleep","9000"] diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/values.yaml new file mode 100644 index 00000000..6c2aab7b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: "my-alpine" diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/charts/mariner-4.3.2.tgz b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/mariner-4.3.2.tgz similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/charts/mariner-4.3.2.tgz rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/charts/mariner-4.3.2.tgz diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/docs/README.md b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/docs/README.md new file mode 100644 index 00000000..d40747ca --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/docs/README.md @@ -0,0 +1 @@ +This is a placeholder for documentation. diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/icon.svg b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/icon.svg new file mode 100644 index 00000000..89213060 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/icon.svg @@ -0,0 +1,8 @@ + + + Example icon + + + diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/ignore/me.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/ignore/me.txt diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/templates/template.tpl b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/templates/template.tpl new file mode 100644 index 00000000..c651ee6a --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/templates/template.tpl @@ -0,0 +1 @@ +Hello {{.Name | default "world"}} diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/values.yaml new file mode 100644 index 00000000..61f50125 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz/values.yaml @@ -0,0 +1,6 @@ +# A values file contains configuration. + +name: "Some Name" + +section: + name: "Name in a section" diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_backslash-1.2.3.tgz b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash-1.2.3.tgz similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_backslash-1.2.3.tgz rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash-1.2.3.tgz diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/.helmignore b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/.helmignore new file mode 100755 index 00000000..9973a57b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/.helmignore @@ -0,0 +1 @@ +ignore/ diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/Chart.lock b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/Chart.lock new file mode 100755 index 00000000..6fcc2ed9 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/Chart.lock @@ -0,0 +1,8 @@ +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts +digest: invalid diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_backslash/Chart.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_backslash/Chart.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/INSTALL.txt b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/INSTALL.txt new file mode 100755 index 00000000..2010438c --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/INSTALL.txt @@ -0,0 +1 @@ +This is an install document. The client may display this. diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/LICENSE b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/LICENSE new file mode 100755 index 00000000..6121943b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/LICENSE @@ -0,0 +1 @@ +LICENSE placeholder. diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/README.md b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/README.md new file mode 100755 index 00000000..8cf4cc3d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/README.md @@ -0,0 +1,11 @@ +# Frobnitz + +This is an example chart. + +## Usage + +This is an example. It has no usage. + +## Development + +For developer info, see the top-level repository. diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/_ignore_me b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/_ignore_me new file mode 100755 index 00000000..2cecca68 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/_ignore_me @@ -0,0 +1 @@ +This should be ignored by the loader, but may be included in a chart. diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/Chart.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/Chart.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/README.md b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/README.md new file mode 100755 index 00000000..b30b949d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.toml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/Chart.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/Chart.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/values.yaml new file mode 100755 index 00000000..42c39c26 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/values.yaml @@ -0,0 +1,4 @@ +# Default values for mast1. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name = "value" diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast2-0.1.0.tgz new file mode 100755 index 0000000000000000000000000000000000000000..61cb62051110b55f3d08213dc81dcf0b1c2d8e53 GIT binary patch literal 252 zcmVDc zVQyr3R8em|NM&qo0PNJUs=_c72H?(liabH@pWIst-7YSIyL+rhEHrIN(t?QZE=F{y zgNRfS&$pa5Ly`mMk2OB%pV`*9knW7FlL-Joo@KED7*{C$d;N~z z37$S{+}wvSU9}|VtF|fRpv9Ve>8dWo|9?5B+RE}Y9CFh-x#(Bq8Vck^V=NUiPLCKa z8z5CF#JgK!4>;$4Fm+FUst4d+{(+nP|0&J+e}(;l^U4@w-{=?s0RR8vgVbLD3;+OM Cs&R<` literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/templates/alpine-pod.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/templates/alpine-pod.yaml new file mode 100755 index 00000000..0ac5ca6a --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service | quote }} + app.kubernetes.io/name: {{.Chart.Name}} + helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}" +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.9" + command: ["/bin/sleep","9000"] diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/values.yaml new file mode 100755 index 00000000..6c2aab7b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: "my-alpine" diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/mariner-4.3.2.tgz b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/mariner-4.3.2.tgz old mode 100644 new mode 100755 similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/mariner-4.3.2.tgz rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/mariner-4.3.2.tgz diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/docs/README.md b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/docs/README.md new file mode 100755 index 00000000..d40747ca --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/docs/README.md @@ -0,0 +1 @@ +This is a placeholder for documentation. diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/icon.svg b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/icon.svg new file mode 100755 index 00000000..89213060 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/icon.svg @@ -0,0 +1,8 @@ + + + Example icon + + + diff --git a/pkg/helm/cmd/helm/testdata/testcharts/object-order/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/ignore/me.txt old mode 100644 new mode 100755 similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/object-order/values.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/ignore/me.txt diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/templates/template.tpl b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/templates/template.tpl new file mode 100755 index 00000000..c651ee6a --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/templates/template.tpl @@ -0,0 +1 @@ +Hello {{.Name | default "world"}} diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/values.yaml new file mode 100755 index 00000000..61f50125 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_backslash/values.yaml @@ -0,0 +1,6 @@ +# A values file contains configuration. + +name: "Some Name" + +section: + name: "Name in a section" diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom.tgz b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom.tgz similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom.tgz rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom.tgz diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/.helmignore b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/.helmignore new file mode 100644 index 00000000..7a4b92da --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/.helmignore @@ -0,0 +1 @@ +ignore/ diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/Chart.lock b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/Chart.lock new file mode 100644 index 00000000..ed43b227 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/Chart.lock @@ -0,0 +1,8 @@ +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts +digest: invalid diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/Chart.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/Chart.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/INSTALL.txt b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/INSTALL.txt new file mode 100644 index 00000000..77c4e724 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/INSTALL.txt @@ -0,0 +1 @@ +This is an install document. The client may display this. diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/LICENSE b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/LICENSE new file mode 100644 index 00000000..c27b00bf --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/LICENSE @@ -0,0 +1 @@ +LICENSE placeholder. diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/README.md b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/README.md new file mode 100644 index 00000000..e9c40031 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/README.md @@ -0,0 +1,11 @@ +# Frobnitz + +This is an example chart. + +## Usage + +This is an example. It has no usage. + +## Development + +For developer info, see the top-level repository. diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/_ignore_me b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/_ignore_me new file mode 100644 index 00000000..a7e3a38b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/_ignore_me @@ -0,0 +1 @@ +This should be ignored by the loader, but may be included in a chart. diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/Chart.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/Chart.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/README.md b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/README.md new file mode 100644 index 00000000..ea7526be --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.toml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/Chart.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/Chart.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/values.yaml new file mode 100644 index 00000000..f690d53c --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/values.yaml @@ -0,0 +1,4 @@ +# Default values for mast1. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name = "value" diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast2-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..61cb62051110b55f3d08213dc81dcf0b1c2d8e53 GIT binary patch literal 252 zcmVDc zVQyr3R8em|NM&qo0PNJUs=_c72H?(liabH@pWIst-7YSIyL+rhEHrIN(t?QZE=F{y zgNRfS&$pa5Ly`mMk2OB%pV`*9knW7FlL-Joo@KED7*{C$d;N~z z37$S{+}wvSU9}|VtF|fRpv9Ve>8dWo|9?5B+RE}Y9CFh-x#(Bq8Vck^V=NUiPLCKa z8z5CF#JgK!4>;$4Fm+FUst4d+{(+nP|0&J+e}(;l^U4@w-{=?s0RR8vgVbLD3;+OM Cs&R<` literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/templates/alpine-pod.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..f3e662a2 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service}} + app.kubernetes.io/name: {{.Chart.Name}} + helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}" +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.9" + command: ["/bin/sleep","9000"] diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/values.yaml new file mode 100644 index 00000000..6b7cb259 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: "my-alpine" diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/mariner-4.3.2.tgz b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/mariner-4.3.2.tgz similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/mariner-4.3.2.tgz rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/mariner-4.3.2.tgz diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/docs/README.md b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/docs/README.md new file mode 100644 index 00000000..816c3e43 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/docs/README.md @@ -0,0 +1 @@ +This is a placeholder for documentation. diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/icon.svg b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/icon.svg new file mode 100644 index 00000000..89213060 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/icon.svg @@ -0,0 +1,8 @@ + + + Example icon + + + diff --git a/pkg/helm/cmd/helm/testdata/testcharts/signtest/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/ignore/me.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/signtest/values.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/ignore/me.txt diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/templates/template.tpl b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/templates/template.tpl new file mode 100644 index 00000000..bb29c549 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/templates/template.tpl @@ -0,0 +1 @@ +Hello {{.Name | default "world"}} diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/values.yaml new file mode 100644 index 00000000..c24ceadf --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_bom/values.yaml @@ -0,0 +1,6 @@ +# A values file contains configuration. + +name: "Some Name" + +section: + name: "Name in a section" diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/.helmignore b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/.helmignore new file mode 100644 index 00000000..9973a57b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/.helmignore @@ -0,0 +1 @@ +ignore/ diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/Chart.lock b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/Chart.lock new file mode 100644 index 00000000..6fcc2ed9 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/Chart.lock @@ -0,0 +1,8 @@ +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts +digest: invalid diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_dev_null/Chart.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_dev_null/Chart.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/INSTALL.txt b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/INSTALL.txt new file mode 100644 index 00000000..2010438c --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/INSTALL.txt @@ -0,0 +1 @@ +This is an install document. The client may display this. diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/LICENSE b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/LICENSE new file mode 100644 index 00000000..6121943b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/LICENSE @@ -0,0 +1 @@ +LICENSE placeholder. diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/README.md b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/README.md new file mode 100644 index 00000000..8cf4cc3d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/README.md @@ -0,0 +1,11 @@ +# Frobnitz + +This is an example chart. + +## Usage + +This is an example. It has no usage. + +## Development + +For developer info, see the top-level repository. diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/_ignore_me b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/_ignore_me new file mode 100644 index 00000000..2cecca68 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/_ignore_me @@ -0,0 +1 @@ +This should be ignored by the loader, but may be included in a chart. diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/Chart.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/Chart.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/README.md b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/README.md new file mode 100644 index 00000000..b30b949d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.toml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/Chart.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/Chart.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/values.yaml new file mode 100644 index 00000000..42c39c26 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/values.yaml @@ -0,0 +1,4 @@ +# Default values for mast1. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name = "value" diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast2-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..61cb62051110b55f3d08213dc81dcf0b1c2d8e53 GIT binary patch literal 252 zcmVDc zVQyr3R8em|NM&qo0PNJUs=_c72H?(liabH@pWIst-7YSIyL+rhEHrIN(t?QZE=F{y zgNRfS&$pa5Ly`mMk2OB%pV`*9knW7FlL-Joo@KED7*{C$d;N~z z37$S{+}wvSU9}|VtF|fRpv9Ve>8dWo|9?5B+RE}Y9CFh-x#(Bq8Vck^V=NUiPLCKa z8z5CF#JgK!4>;$4Fm+FUst4d+{(+nP|0&J+e}(;l^U4@w-{=?s0RR8vgVbLD3;+OM Cs&R<` literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/templates/alpine-pod.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..21ae20aa --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service}} + app.kubernetes.io/name: {{.Chart.Name}} + helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}" +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.9" + command: ["/bin/sleep","9000"] diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/values.yaml new file mode 100644 index 00000000..6c2aab7b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: "my-alpine" diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/mariner-4.3.2.tgz b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/mariner-4.3.2.tgz similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/mariner-4.3.2.tgz rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/mariner-4.3.2.tgz diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/docs/README.md b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/docs/README.md new file mode 100644 index 00000000..d40747ca --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/docs/README.md @@ -0,0 +1 @@ +This is a placeholder for documentation. diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/icon.svg b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/icon.svg new file mode 100644 index 00000000..89213060 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/icon.svg @@ -0,0 +1,8 @@ + + + Example icon + + + diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/ignore/me.txt b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/ignore/me.txt new file mode 100644 index 00000000..e69de29b diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/null b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/null new file mode 120000 index 00000000..dc1dc0cd --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/null @@ -0,0 +1 @@ +/dev/null \ No newline at end of file diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/templates/template.tpl b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/templates/template.tpl new file mode 100644 index 00000000..c651ee6a --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/templates/template.tpl @@ -0,0 +1 @@ +Hello {{.Name | default "world"}} diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/values.yaml new file mode 100644 index 00000000..61f50125 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/values.yaml @@ -0,0 +1,6 @@ +# A values file contains configuration. + +name: "Some Name" + +section: + name: "Name in a section" diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/.helmignore b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/.helmignore new file mode 100644 index 00000000..9973a57b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/.helmignore @@ -0,0 +1 @@ +ignore/ diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/Chart.lock b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/Chart.lock new file mode 100644 index 00000000..6fcc2ed9 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/Chart.lock @@ -0,0 +1,8 @@ +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts +digest: invalid diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/Chart.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/Chart.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/INSTALL.txt b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/INSTALL.txt new file mode 100644 index 00000000..2010438c --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/INSTALL.txt @@ -0,0 +1 @@ +This is an install document. The client may display this. diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/README.md b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/README.md new file mode 100644 index 00000000..8cf4cc3d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/README.md @@ -0,0 +1,11 @@ +# Frobnitz + +This is an example chart. + +## Usage + +This is an example. It has no usage. + +## Development + +For developer info, see the top-level repository. diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/_ignore_me b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/_ignore_me new file mode 100644 index 00000000..2cecca68 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/_ignore_me @@ -0,0 +1 @@ +This should be ignored by the loader, but may be included in a chart. diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/Chart.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/Chart.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/README.md b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/README.md new file mode 100644 index 00000000..b30b949d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.toml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/Chart.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/Chart.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/values.yaml new file mode 100644 index 00000000..42c39c26 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/values.yaml @@ -0,0 +1,4 @@ +# Default values for mast1. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name = "value" diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast2-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..61cb62051110b55f3d08213dc81dcf0b1c2d8e53 GIT binary patch literal 252 zcmVDc zVQyr3R8em|NM&qo0PNJUs=_c72H?(liabH@pWIst-7YSIyL+rhEHrIN(t?QZE=F{y zgNRfS&$pa5Ly`mMk2OB%pV`*9knW7FlL-Joo@KED7*{C$d;N~z z37$S{+}wvSU9}|VtF|fRpv9Ve>8dWo|9?5B+RE}Y9CFh-x#(Bq8Vck^V=NUiPLCKa z8z5CF#JgK!4>;$4Fm+FUst4d+{(+nP|0&J+e}(;l^U4@w-{=?s0RR8vgVbLD3;+OM Cs&R<` literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/templates/alpine-pod.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..21ae20aa --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service}} + app.kubernetes.io/name: {{.Chart.Name}} + helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}" +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.9" + command: ["/bin/sleep","9000"] diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/values.yaml new file mode 100644 index 00000000..6c2aab7b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: "my-alpine" diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/mariner-4.3.2.tgz b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/mariner-4.3.2.tgz new file mode 100644 index 0000000000000000000000000000000000000000..3190136b050e62c628b3c817fd963ac9dc4a9e25 GIT binary patch literal 967 zcmV;&133I2iwFR+9h6)E1MQb_y%a`Bd>#= zhbqf2w!b6}wZ9@rvIhtwzm?~C&+QLm+G2!l%`$_aUgS(@pdjdX3a%E}VXVc7`)dg( zL%IRN2}c1D3xoMi2w@WuWOMZ?5i&3FelBVyq_Ce_8fREdP%NDf<&d!wu3{&VUQNs{N%vK$Ha~k^gB2!0bO7r0ic0 zbqCp*X#j?=|H@GNONsuE)&Idbh>2MX(Z}|+=@*sLod*wS?A8Ugp#mMPuMO0NnZmos9_rr z3xpDL+otj~lRh?B4hHFTl+d5-8NBXmUXB~EyF^bx7m*+!*g>obczvGF|8xkWsHN8; z%#+wiWP{=2pC*98@$VNzzsll&G#D7&11-;D>HT0x|DV2?6}a~*p46@R|2l??e_8dX z@Bb>D3t~VC_*wjq2Dy!6J-_5ME%%J+xmsbK5a#qO>ZV?Wt`d|TDdrB^B&LqFr@lYdUA zs&4-JAg3&uc4dA0gv*bXU9QePa9^8wR{HovA)j@)JOAIkak0Jl)aKqA_3XLM#?H=V z-{tlG=xy^os=1Z><53;&+C4=zp|%rwv!)UyY=%k_t%Y|wNRQl`C6N_Z&S;S+~wb1=)2Uh_4I81`y>DO zoLPSt=btB&x{CUK`0DA&){efW_O36RxnsYZNT0r;JbTLR{H4M3C$8(Cee==aQ~Gbq p$`5v3?NB{4-j0XQHf literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/docs/README.md b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/docs/README.md new file mode 100644 index 00000000..d40747ca --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/docs/README.md @@ -0,0 +1 @@ +This is a placeholder for documentation. diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/icon.svg b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/icon.svg new file mode 100644 index 00000000..89213060 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/icon.svg @@ -0,0 +1,8 @@ + + + Example icon + + + diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/ignore/me.txt b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/ignore/me.txt new file mode 100644 index 00000000..e69de29b diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/templates/template.tpl b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/templates/template.tpl new file mode 100644 index 00000000..c651ee6a --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/templates/template.tpl @@ -0,0 +1 @@ +Hello {{.Name | default "world"}} diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/values.yaml new file mode 100644 index 00000000..61f50125 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/values.yaml @@ -0,0 +1,6 @@ +# A values file contains configuration. + +name: "Some Name" + +section: + name: "Name in a section" diff --git a/pkg/helm/pkg/chartutil/testdata/genfrob.sh b/pkg/helm/pkg/chart/v2/loader/testdata/genfrob.sh similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/genfrob.sh rename to pkg/helm/pkg/chart/v2/loader/testdata/genfrob.sh diff --git a/pkg/helm/pkg/chart/loader/testdata/mariner/Chart.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/mariner/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/mariner/Chart.yaml rename to pkg/helm/pkg/chart/v2/loader/testdata/mariner/Chart.yaml diff --git a/pkg/helm/pkg/chart/loader/testdata/mariner/charts/albatross-0.1.0.tgz b/pkg/helm/pkg/chart/v2/loader/testdata/mariner/charts/albatross-0.1.0.tgz similarity index 100% rename from pkg/helm/pkg/chart/loader/testdata/mariner/charts/albatross-0.1.0.tgz rename to pkg/helm/pkg/chart/v2/loader/testdata/mariner/charts/albatross-0.1.0.tgz diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/mariner/templates/placeholder.tpl b/pkg/helm/pkg/chart/v2/loader/testdata/mariner/templates/placeholder.tpl new file mode 100644 index 00000000..29c11843 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/mariner/templates/placeholder.tpl @@ -0,0 +1 @@ +# This is a placeholder. diff --git a/pkg/helm/pkg/chart/v2/loader/testdata/mariner/values.yaml b/pkg/helm/pkg/chart/v2/loader/testdata/mariner/values.yaml new file mode 100644 index 00000000..b0ccb008 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/loader/testdata/mariner/values.yaml @@ -0,0 +1,7 @@ +# Default values for . +# This is a YAML-formatted file. https://github.com/toml-lang/toml +# Declare name/value pairs to be passed into your templates. +# name: "value" + +: + test: true diff --git a/pkg/helm/pkg/chart/v2/metadata.go b/pkg/helm/pkg/chart/v2/metadata.go new file mode 100644 index 00000000..c4600786 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/metadata.go @@ -0,0 +1,178 @@ +/* +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 v2 + +import ( + "path/filepath" + "strings" + "unicode" + + "github.com/Masterminds/semver/v3" +) + +// Maintainer describes a Chart maintainer. +type Maintainer struct { + // Name is a user name or organization name + Name string `json:"name,omitempty"` + // Email is an optional email address to contact the named maintainer + Email string `json:"email,omitempty"` + // URL is an optional URL to an address for the named maintainer + URL string `json:"url,omitempty"` +} + +// Validate checks valid data and sanitizes string characters. +func (m *Maintainer) Validate() error { + if m == nil { + return ValidationError("maintainers must not contain empty or null nodes") + } + m.Name = sanitizeString(m.Name) + m.Email = sanitizeString(m.Email) + m.URL = sanitizeString(m.URL) + return nil +} + +// Metadata for a Chart file. This models the structure of a Chart.yaml file. +type Metadata struct { + // The name of the chart. Required. + Name string `json:"name,omitempty"` + // The URL to a relevant project page, git repo, or contact person + Home string `json:"home,omitempty"` + // Source is the URL to the source code of this chart + Sources []string `json:"sources,omitempty"` + // A version string of the chart. Required. + Version string `json:"version,omitempty"` + // A one-sentence description of the chart + Description string `json:"description,omitempty"` + // A list of string keywords + Keywords []string `json:"keywords,omitempty"` + // A list of name and URL/email address combinations for the maintainer(s) + Maintainers []*Maintainer `json:"maintainers,omitempty"` + // The URL to an icon file. + Icon string `json:"icon,omitempty"` + // The API Version of this chart. Required. + APIVersion string `json:"apiVersion,omitempty"` + // The condition to check to enable chart + Condition string `json:"condition,omitempty"` + // The tags to check to enable chart + Tags string `json:"tags,omitempty"` + // The version of the application enclosed inside of this chart. + AppVersion string `json:"appVersion,omitempty"` + // Whether or not this chart is deprecated + Deprecated bool `json:"deprecated,omitempty"` + // Annotations are additional mappings uninterpreted by Helm, + // made available for inspection by other applications. + Annotations map[string]string `json:"annotations,omitempty"` + // KubeVersion is a SemVer constraint specifying the version of Kubernetes required. + KubeVersion string `json:"kubeVersion,omitempty"` + // Dependencies are a list of dependencies for a chart. + Dependencies []*Dependency `json:"dependencies,omitempty"` + // Specifies the chart type: application or library + Type string `json:"type,omitempty"` +} + +// Validate checks the metadata for known issues and sanitizes string +// characters. +func (md *Metadata) Validate() error { + if md == nil { + return ValidationError("chart.metadata is required") + } + + md.Name = sanitizeString(md.Name) + md.Description = sanitizeString(md.Description) + md.Home = sanitizeString(md.Home) + md.Icon = sanitizeString(md.Icon) + md.Condition = sanitizeString(md.Condition) + md.Tags = sanitizeString(md.Tags) + md.AppVersion = sanitizeString(md.AppVersion) + md.KubeVersion = sanitizeString(md.KubeVersion) + for i := range md.Sources { + md.Sources[i] = sanitizeString(md.Sources[i]) + } + for i := range md.Keywords { + md.Keywords[i] = sanitizeString(md.Keywords[i]) + } + + if md.APIVersion == "" { + return ValidationError("chart.metadata.apiVersion is required") + } + if md.Name == "" { + return ValidationError("chart.metadata.name is required") + } + + if md.Name != filepath.Base(md.Name) { + return ValidationErrorf("chart.metadata.name %q is invalid", md.Name) + } + + if md.Version == "" { + return ValidationError("chart.metadata.version is required") + } + if !isValidSemver(md.Version) { + return ValidationErrorf("chart.metadata.version %q is invalid", md.Version) + } + if !isValidChartType(md.Type) { + return ValidationError("chart.metadata.type must be application or library") + } + + for _, m := range md.Maintainers { + if err := m.Validate(); err != nil { + return err + } + } + + // Aliases need to be validated here to make sure that the alias name does + // not contain any illegal characters. + dependencies := map[string]*Dependency{} + for _, dependency := range md.Dependencies { + if err := dependency.Validate(); err != nil { + return err + } + key := dependency.Name + if dependency.Alias != "" { + key = dependency.Alias + } + if dependencies[key] != nil { + return ValidationErrorf("more than one dependency with name or alias %q", key) + } + dependencies[key] = dependency + } + return nil +} + +func isValidChartType(in string) bool { + switch in { + case "", "application", "library": + return true + } + return false +} + +func isValidSemver(v string) bool { + _, err := semver.NewVersion(v) + return err == nil +} + +// sanitizeString normalize spaces and removes non-printable characters. +func sanitizeString(str string) string { + return strings.Map(func(r rune) rune { + if unicode.IsSpace(r) { + return ' ' + } + if unicode.IsPrint(r) { + return r + } + return -1 + }, str) +} diff --git a/pkg/helm/pkg/chart/metadata_test.go b/pkg/helm/pkg/chart/v2/metadata_test.go similarity index 99% rename from pkg/helm/pkg/chart/metadata_test.go rename to pkg/helm/pkg/chart/v2/metadata_test.go index 62aea726..7892f020 100644 --- a/pkg/helm/pkg/chart/metadata_test.go +++ b/pkg/helm/pkg/chart/v2/metadata_test.go @@ -13,7 +13,7 @@ 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 chart +package v2 import ( "testing" diff --git a/pkg/helm/pkg/chartutil/chartfile.go b/pkg/helm/pkg/chart/v2/util/chartfile.go similarity index 71% rename from pkg/helm/pkg/chartutil/chartfile.go rename to pkg/helm/pkg/chart/v2/util/chartfile.go index a7f0d54d..954e9762 100644 --- a/pkg/helm/pkg/chartutil/chartfile.go +++ b/pkg/helm/pkg/chart/v2/util/chartfile.go @@ -14,16 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -package chartutil +package util import ( + "errors" + "fmt" + "io/fs" "os" "path/filepath" - "github.com/pkg/errors" "sigs.k8s.io/yaml" - "github.com/werf/nelm/pkg/helm/pkg/chart" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" ) // LoadChartfile loads a Chart.yaml file into a *chart.Metadata. @@ -37,6 +39,17 @@ func LoadChartfile(filename string) (*chart.Metadata, error) { return y, err } +// StrictLoadChartfile loads a Chart.yaml into a *chart.Metadata using a strict unmarshaling +func StrictLoadChartfile(filename string) (*chart.Metadata, error) { + b, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + y := new(chart.Metadata) + err = yaml.UnmarshalStrict(b, y) + return y, err +} + // SaveChartfile saves the given metadata as a Chart.yaml file at the given path. // // 'filename' should be the complete path and filename ('foo/Chart.yaml') @@ -64,17 +77,17 @@ func IsChartDir(dirName string) (bool, error) { if fi, err := os.Stat(dirName); err != nil { return false, err } else if !fi.IsDir() { - return false, errors.Errorf("%q is not a directory", dirName) + return false, fmt.Errorf("%q is not a directory", dirName) } chartYaml := filepath.Join(dirName, ChartfileName) - if _, err := os.Stat(chartYaml); os.IsNotExist(err) { - return false, errors.Errorf("no %s exists in directory %q", ChartfileName, dirName) + if _, err := os.Stat(chartYaml); errors.Is(err, fs.ErrNotExist) { + return false, fmt.Errorf("no %s exists in directory %q", ChartfileName, dirName) } chartYamlContent, err := os.ReadFile(chartYaml) if err != nil { - return false, errors.Errorf("cannot read %s in directory %q", ChartfileName, dirName) + return false, fmt.Errorf("cannot read %s in directory %q", ChartfileName, dirName) } chartContent := new(chart.Metadata) @@ -82,10 +95,10 @@ func IsChartDir(dirName string) (bool, error) { return false, err } if chartContent == nil { - return false, errors.Errorf("chart metadata (%s) missing", ChartfileName) + return false, fmt.Errorf("chart metadata (%s) missing", ChartfileName) } if chartContent.Name == "" { - return false, errors.Errorf("invalid chart (%s): name must not be empty", ChartfileName) + return false, fmt.Errorf("invalid chart (%s): name must not be empty", ChartfileName) } return true, nil diff --git a/pkg/helm/pkg/chartutil/chartfile_test.go b/pkg/helm/pkg/chart/v2/util/chartfile_test.go similarity index 97% rename from pkg/helm/pkg/chartutil/chartfile_test.go rename to pkg/helm/pkg/chart/v2/util/chartfile_test.go index 0c66069b..e7c4790d 100644 --- a/pkg/helm/pkg/chartutil/chartfile_test.go +++ b/pkg/helm/pkg/chart/v2/util/chartfile_test.go @@ -14,12 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -package chartutil +package util import ( "testing" - "github.com/werf/nelm/pkg/helm/pkg/chart" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" ) const testfile = "testdata/chartfiletest.yaml" @@ -34,7 +34,7 @@ func TestLoadChartfile(t *testing.T) { } func verifyChartfile(t *testing.T, f *chart.Metadata, name string) { - + t.Helper() if f == nil { //nolint:staticcheck t.Fatal("Failed verifyChartfile because f is nil") } diff --git a/pkg/helm/pkg/chart/v2/util/compatible.go b/pkg/helm/pkg/chart/v2/util/compatible.go new file mode 100644 index 00000000..d384d2d4 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/compatible.go @@ -0,0 +1,34 @@ +/* +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 util + +import "github.com/Masterminds/semver/v3" + +// IsCompatibleRange compares a version to a constraint. +// It returns true if the version matches the constraint, and false in all other cases. +func IsCompatibleRange(constraint, ver string) bool { + sv, err := semver.NewVersion(ver) + if err != nil { + return false + } + + c, err := semver.NewConstraint(constraint) + if err != nil { + return false + } + return c.Check(sv) +} diff --git a/pkg/helm/pkg/chart/v2/util/compatible_test.go b/pkg/helm/pkg/chart/v2/util/compatible_test.go new file mode 100644 index 00000000..e17d33e3 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/compatible_test.go @@ -0,0 +1,43 @@ +/* +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 version represents the current version of the project. +package util + +import "testing" + +func TestIsCompatibleRange(t *testing.T) { + tests := []struct { + constraint string + ver string + expected bool + }{ + {"v2.0.0-alpha.4", "v2.0.0-alpha.4", true}, + {"v2.0.0-alpha.3", "v2.0.0-alpha.4", false}, + {"v2.0.0", "v2.0.0-alpha.4", false}, + {"v2.0.0-alpha.4", "v2.0.0", false}, + {"~v2.0.0", "v2.0.1", true}, + {"v2", "v2.0.0", true}, + {">2.0.0", "v2.1.1", true}, + {"v2.1.*", "v2.1.1", true}, + } + + for _, tt := range tests { + if IsCompatibleRange(tt.constraint, tt.ver) != tt.expected { + t.Errorf("expected constraint %s to be %v for %s", tt.constraint, tt.expected, tt.ver) + } + } +} diff --git a/pkg/helm/pkg/chartutil/create.go b/pkg/helm/pkg/chart/v2/util/create.go similarity index 72% rename from pkg/helm/pkg/chartutil/create.go rename to pkg/helm/pkg/chart/v2/util/create.go index 95c39a58..a850b3a9 100644 --- a/pkg/helm/pkg/chartutil/create.go +++ b/pkg/helm/pkg/chart/v2/util/create.go @@ -14,9 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -package chartutil +package util import ( + "context" "fmt" "io" "os" @@ -24,12 +25,11 @@ import ( "regexp" "strings" - "github.com/pkg/errors" "sigs.k8s.io/yaml" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chart/loader" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/loader" ) // chartName is a regular expression for testing the supplied name of a chart. @@ -55,6 +55,8 @@ const ( IgnorefileName = ".helmignore" // IngressFileName is the name of the example ingress file. IngressFileName = TemplatesDir + sep + "ingress.yaml" + // HTTPRouteFileName is the name of the example HTTPRoute file. + HTTPRouteFileName = TemplatesDir + sep + "httproute.yaml" // DeploymentName is the name of the example deployment file. DeploymentName = TemplatesDir + sep + "deployment.yaml" // ServiceName is the name of the example service file. @@ -107,30 +109,40 @@ const defaultValues = `# Default values for %s. # This is a YAML-formatted file. # Declare variables to be passed into your templates. +# This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ replicaCount: 1 +# This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ image: repository: nginx + # This sets the pull policy for images. pullPolicy: IfNotPresent # Overrides the image tag whose default is the chart appVersion. tag: "" +# This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ imagePullSecrets: [] +# This is to override the chart name. nameOverride: "" fullnameOverride: "" +# This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ serviceAccount: - # Specifies whether a service account should be created + # Specifies whether a service account should be created. create: true # Automatically mount a ServiceAccount's API credentials? automount: true - # Annotations to add to the service account + # Annotations to add to the service account. annotations: {} # The name of the service account to use. - # If not set and create is true, a name is generated using the fullname template + # If not set and create is true, a name is generated using the fullname template. name: "" +# This is for setting Kubernetes Annotations to a Pod. +# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ podAnnotations: {} +# This is for setting Kubernetes Labels to a Pod. +# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ podLabels: {} podSecurityContext: {} @@ -144,10 +156,14 @@ securityContext: {} # runAsNonRoot: true # runAsUser: 1000 +# This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/ service: + # This sets the service type more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types type: ClusterIP + # This sets the ports more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#field-spec-ports port: 80 +# This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/ ingress: enabled: false className: "" @@ -160,9 +176,47 @@ ingress: - path: / pathType: ImplementationSpecific tls: [] - # - secretName: chart-example-tls - # hosts: - # - chart-example.local + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +# -- Expose the service via gateway-api HTTPRoute +# Requires Gateway API resources and suitable controller installed within the cluster +# (see: https://gateway-api.sigs.k8s.io/guides/) +httpRoute: + # HTTPRoute enabled. + enabled: false + # HTTPRoute annotations. + annotations: {} + # Which Gateways this Route is attached to. + parentRefs: + - name: gateway + sectionName: http + # namespace: default + # Hostnames matching HTTP header. + hostnames: + - chart-example.local + # List of rules and filters applied. + rules: + - matches: + - path: + type: PathPrefix + value: /headers + # filters: + # - type: RequestHeaderModifier + # requestHeaderModifier: + # set: + # - name: My-Overwrite-Header + # value: this-is-the-only-value + # remove: + # - User-Agent + # - matches: + # - path: + # type: PathPrefix + # value: /echo + # headers: + # - name: version + # value: v2 resources: {} # We usually recommend not to specify default resources and to leave this as a conscious @@ -176,6 +230,7 @@ resources: {} # cpu: 100m # memory: 128Mi +# This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ livenessProbe: httpGet: path: / @@ -185,6 +240,7 @@ readinessProbe: path: / port: http +# This section is for setting up autoscaling more information can be found here: https://kubernetes.io/docs/concepts/workloads/autoscaling/ autoscaling: enabled: false minReplicas: 1 @@ -194,16 +250,16 @@ autoscaling: # Additional volumes on the output Deployment definition. volumes: [] -# - name: foo -# secret: -# secretName: mysecret -# optional: false + # - name: foo + # secret: + # secretName: mysecret + # optional: false # Additional volumeMounts on the output Deployment definition. volumeMounts: [] -# - name: foo -# mountPath: "/etc/foo" -# readOnly: true + # - name: foo + # mountPath: "/etc/foo" + # readOnly: true nodeSelector: {} @@ -238,23 +294,10 @@ const defaultIgnore = `# Patterns to ignore when building packages. ` const defaultIngress = `{{- if .Values.ingress.enabled -}} -{{- $fullName := include ".fullname" . -}} -{{- $svcPort := .Values.service.port -}} -{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} - {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} - {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} - {{- end }} -{{- end }} -{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} apiVersion: networking.k8s.io/v1 -{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} -apiVersion: networking.k8s.io/v1beta1 -{{- else -}} -apiVersion: extensions/v1beta1 -{{- end }} kind: Ingress metadata: - name: {{ $fullName }} + name: {{ include ".fullname" . }} labels: {{- include ".labels" . | nindent 4 }} {{- with .Values.ingress.annotations }} @@ -262,8 +305,8 @@ metadata: {{- toYaml . | nindent 4 }} {{- end }} spec: - {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} - ingressClassName: {{ .Values.ingress.className }} + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} {{- end }} {{- if .Values.ingress.tls }} tls: @@ -282,24 +325,59 @@ spec: paths: {{- range .paths }} - path: {{ .path }} - {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} - pathType: {{ .pathType }} + {{- with .pathType }} + pathType: {{ . }} {{- end }} backend: - {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} service: - name: {{ $fullName }} + name: {{ include ".fullname" $ }} port: - number: {{ $svcPort }} - {{- else }} - serviceName: {{ $fullName }} - servicePort: {{ $svcPort }} - {{- end }} + number: {{ $.Values.service.port }} {{- end }} {{- end }} {{- end }} ` +const defaultHTTPRoute = `{{- if .Values.httpRoute.enabled -}} +{{- $fullName := include ".fullname" . -}} +{{- $svcPort := .Values.service.port -}} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ $fullName }} + labels: + {{- include ".labels" . | nindent 4 }} + {{- with .Values.httpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + parentRefs: + {{- with .Values.httpRoute.parentRefs }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.httpRoute.hostnames }} + hostnames: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- range .Values.httpRoute.rules }} + {{- with .matches }} + - matches: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .filters }} + filters: + {{- toYaml . | nindent 8 }} + {{- end }} + backendRefs: + - name: {{ $fullName }} + port: {{ $svcPort }} + weight: 1 + {{- end }} +{{- end }} +` + const defaultDeployment = `apiVersion: apps/v1 kind: Deployment metadata: @@ -330,24 +408,34 @@ spec: {{- toYaml . | nindent 8 }} {{- end }} serviceAccountName: {{ include ".serviceAccountName" . }} + {{- with .Values.podSecurityContext }} securityContext: - {{- toYaml .Values.podSecurityContext | nindent 8 }} + {{- toYaml . | nindent 8 }} + {{- end }} containers: - name: {{ .Chart.Name }} + {{- with .Values.securityContext }} securityContext: - {{- toYaml .Values.securityContext | nindent 12 }} + {{- toYaml . | nindent 12 }} + {{- end }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" imagePullPolicy: {{ .Values.image.pullPolicy }} ports: - name: http containerPort: {{ .Values.service.port }} protocol: TCP + {{- with .Values.livenessProbe }} livenessProbe: - {{- toYaml .Values.livenessProbe | nindent 12 }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.readinessProbe }} readinessProbe: - {{- toYaml .Values.readinessProbe | nindent 12 }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.resources }} resources: - {{- toYaml .Values.resources | nindent 12 }} + {{- toYaml . | nindent 12 }} + {{- end }} {{- with .Values.volumeMounts }} volumeMounts: {{- toYaml . | nindent 12 }} @@ -437,7 +525,20 @@ spec: ` const defaultNotes = `1. Get the application URL by running these commands: -{{- if .Values.ingress.enabled }} +{{- if .Values.httpRoute.enabled }} +{{- if .Values.httpRoute.hostnames }} + export APP_HOSTNAME={{ .Values.httpRoute.hostnames | first }} +{{- else }} + export APP_HOSTNAME=$(kubectl get --namespace {{(first .Values.httpRoute.parentRefs).namespace | default .Release.Namespace }} gateway/{{ (first .Values.httpRoute.parentRefs).name }} -o jsonpath="{.spec.listeners[0].hostname}") + {{- end }} +{{- if and .Values.httpRoute.rules (first .Values.httpRoute.rules).matches (first (first .Values.httpRoute.rules).matches).path.value }} + echo "Visit http://$APP_HOSTNAME{{ (first (first .Values.httpRoute.rules).matches).path.value }} to use your application" + + NOTE: Your HTTPRoute depends on the listener configuration of your gateway and your HTTPRoute rules. + The rules can be set for path, method, header and query parameters. + You can check the gateway configuration with 'kubectl get --namespace {{(first .Values.httpRoute.parentRefs).namespace | default .Release.Namespace }} gateway/{{ (first .Values.httpRoute.parentRefs).name }} -o yaml' +{{- end }} +{{- else if .Values.ingress.enabled }} {{- range $host := .Values.ingress.hosts }} {{- range .paths }} http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} @@ -449,7 +550,7 @@ const defaultNotes = `1. Get the application URL by running these commands: echo http://$NODE_IP:$NODE_PORT {{- else if contains "LoadBalancer" .Values.service.type }} NOTE: It may take a few minutes for the LoadBalancer IP to be available. - You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include ".fullname" . }}' + You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include ".fullname" . }}' export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include ".fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") echo http://$SERVICE_IP:{{ .Values.service.port }} {{- else if contains "ClusterIP" .Values.service.type }} @@ -548,30 +649,30 @@ spec: var Stderr io.Writer = os.Stderr // CreateFrom creates a new chart, but scaffolds it from the src chart. -func CreateFrom(chartfile *chart.Metadata, dest, src string, opts helmopts.HelmOptions) error { - schart, err := loader.Load(src, opts) +func CreateFrom(chartfile *chart.Metadata, dest, src string) error { + schart, err := loader.Load(context.Background(), src) if err != nil { - return errors.Wrapf(err, "could not load %s", src) + return fmt.Errorf("could not load %s: %w", src, err) } schart.Metadata = chartfile - var updatedTemplates []*chart.File + var updatedTemplates []*common.File for _, template := range schart.Templates { newData := transform(string(template.Data), schart.Name()) - updatedTemplates = append(updatedTemplates, &chart.File{Name: template.Name, Data: newData}) + updatedTemplates = append(updatedTemplates, &common.File{Name: template.Name, ModTime: template.ModTime, Data: newData}) } schart.Templates = updatedTemplates b, err := yaml.Marshal(schart.Values) if err != nil { - return errors.Wrap(err, "reading values file") + return fmt.Errorf("reading values file: %w", err) } var m map[string]interface{} if err := yaml.Unmarshal(transform(string(b), schart.Name()), &m); err != nil { - return errors.Wrap(err, "transforming values file") + return fmt.Errorf("transforming values file: %w", err) } schart.Values = m @@ -615,14 +716,18 @@ func Create(name, dir string) (string, error) { if fi, err := os.Stat(path); err != nil { return path, err } else if !fi.IsDir() { - return path, errors.Errorf("no such directory %s", path) + return path, fmt.Errorf("no such directory %s", path) } cdir := filepath.Join(path, name) if fi, err := os.Stat(cdir); err == nil && !fi.IsDir() { - return cdir, errors.Errorf("file %s already exists and is not a directory", cdir) + return cdir, fmt.Errorf("file %s already exists and is not a directory", cdir) } + // Note: If adding a new template below (i.e., to `helm create`) which is disabled by default (similar to hpa and + // ingress below); or making an existing template disabled by default, add the enabling condition in + // `TestHelmCreateChart_CheckDeprecatedWarnings` in `pkg/lint/lint_test.go` to make it run through deprecation checks + // with latest Kubernetes version. files := []struct { path string content []byte @@ -630,12 +735,12 @@ func Create(name, dir string) (string, error) { { // Chart.yaml path: filepath.Join(cdir, ChartfileName), - content: []byte(fmt.Sprintf(defaultChartfile, name)), + content: fmt.Appendf(nil, defaultChartfile, name), }, { // values.yaml path: filepath.Join(cdir, ValuesfileName), - content: []byte(fmt.Sprintf(defaultValues, name)), + content: fmt.Appendf(nil, defaultValues, name), }, { // .helmignore @@ -647,6 +752,11 @@ func Create(name, dir string) (string, error) { path: filepath.Join(cdir, IngressFileName), content: transform(defaultIngress, name), }, + { + // httproute.yaml + path: filepath.Join(cdir, HTTPRouteFileName), + content: transform(defaultHTTPRoute, name), + }, { // deployment.yaml path: filepath.Join(cdir, DeploymentName), diff --git a/pkg/helm/pkg/chartutil/create_test.go b/pkg/helm/pkg/chart/v2/util/create_test.go similarity index 94% rename from pkg/helm/pkg/chartutil/create_test.go rename to pkg/helm/pkg/chart/v2/util/create_test.go index b4981a99..34bf6951 100644 --- a/pkg/helm/pkg/chartutil/create_test.go +++ b/pkg/helm/pkg/chart/v2/util/create_test.go @@ -14,16 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -package chartutil +package util import ( + "context" "bytes" "os" "path/filepath" "testing" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chart/loader" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/loader" ) func TestCreate(t *testing.T) { @@ -36,7 +37,7 @@ func TestCreate(t *testing.T) { dir := filepath.Join(tdir, "foo") - mychart, err := loader.LoadDir(c) + mychart, err := loader.LoadDir(context.Background(), c) if err != nil { t.Fatalf("Failed to load newly created chart %q: %s", c, err) } @@ -80,7 +81,7 @@ func TestCreateFrom(t *testing.T) { dir := filepath.Join(tdir, "foo") c := filepath.Join(tdir, cf.Name) - mychart, err := loader.LoadDir(c) + mychart, err := loader.LoadDir(context.Background(), c) if err != nil { t.Fatalf("Failed to load newly created chart %q: %s", c, err) } diff --git a/pkg/helm/pkg/chartutil/dependencies.go b/pkg/helm/pkg/chart/v2/util/dependencies.go similarity index 54% rename from pkg/helm/pkg/chartutil/dependencies.go rename to pkg/helm/pkg/chart/v2/util/dependencies.go index 41d86d27..6eb8881e 100644 --- a/pkg/helm/pkg/chartutil/dependencies.go +++ b/pkg/helm/pkg/chart/v2/util/dependencies.go @@ -13,55 +13,45 @@ See the License for the specific language governing permissions and limitations under the License. */ -package chartutil +package util import ( "errors" "fmt" - "log" + "log/slog" + "reflect" "strings" - "github.com/mitchellh/copystructure" - "github.com/werf/nelm/pkg/helm/pkg/chart" + "github.com/werf/nelm/pkg/helm/intern/copystructure" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + "github.com/werf/nelm/pkg/helm/pkg/chart/common/util" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" ) // ProcessDependencies checks through this chart's dependencies, processing accordingly. -// -// TODO: For Helm v4 this can be combined with or turned into ProcessDependenciesWithMerge func ProcessDependencies(c *chart.Chart, v *map[string]interface{}) error { - if err := processDependencyExportExtraValues(c, v, false); err != nil { - return err + if v == nil { + return fmt.Errorf("process dependencies: values map pointer is nil") } - if err := processDependencyEnabled(c, *v, ""); err != nil { - return err - } - - return processDependencyImportExportValues(c, false) -} - -// ProcessDependenciesWithMerge checks through this chart's dependencies, processing accordingly. -// It is similar to ProcessDependencies but it does not remove nil values during -// the import/export handling process. -func ProcessDependenciesWithMerge(c *chart.Chart, v *map[string]interface{}) error { if err := processDependencyExportExtraValues(c, v, true); err != nil { - return err + return fmt.Errorf("process dependency export extra values: %w", err) } if err := processDependencyEnabled(c, *v, ""); err != nil { - return err + return fmt.Errorf("process dependency enabled: %w", err) } return processDependencyImportExportValues(c, true) } // processDependencyConditions disables charts based on condition path value in values -func processDependencyConditions(reqs []*chart.Dependency, cvals Values, cpath string) { +func processDependencyConditions(reqs []*chart.Dependency, cvals common.Values, cpath string) { if reqs == nil { return } for _, r := range reqs { - for _, c := range strings.Split(strings.TrimSpace(r.Condition), ",") { + for c := range strings.SplitSeq(strings.TrimSpace(r.Condition), ",") { if len(c) > 0 { // retrieve value vv, err := cvals.PathValue(cpath + c) @@ -71,10 +61,10 @@ func processDependencyConditions(reqs []*chart.Dependency, cvals Values, cpath s r.Enabled = bv break } - log.Printf("Warning: Condition path '%s' for chart %s returned non-bool value", c, r.Name) - } else if _, ok := err.(ErrNoValue); !ok { + slog.Warn("returned non-bool value", "path", c, "chart", r.Name) + } else if _, ok := err.(common.ErrNoValue); !ok { // this is a real error - log.Printf("Warning: PathValue returned error %v", err) + slog.Warn("the method PathValue returned error", slog.Any("error", err)) } } } @@ -82,7 +72,7 @@ func processDependencyConditions(reqs []*chart.Dependency, cvals Values, cpath s } // processDependencyTags disables charts based on tags in values -func processDependencyTags(reqs []*chart.Dependency, cvals Values) { +func processDependencyTags(reqs []*chart.Dependency, cvals common.Values) { if reqs == nil { return } @@ -102,7 +92,7 @@ func processDependencyTags(reqs []*chart.Dependency, cvals Values) { hasFalse = true } } else { - log.Printf("Warning: Tag '%s' for chart %s returned non-bool value", k, r.Name) + slog.Warn("returned non-bool value", "tag", k, "chart", r.Name) } } } @@ -114,6 +104,7 @@ func processDependencyTags(reqs []*chart.Dependency, cvals Values) { } } +// getAliasDependency finds the chart for an alias dependency and copies parts that will be modified func getAliasDependency(charts []*chart.Chart, dep *chart.Dependency) *chart.Chart { for _, c := range charts { if c == nil { @@ -127,17 +118,38 @@ func getAliasDependency(charts []*chart.Chart, dep *chart.Dependency) *chart.Cha } out := *c - md := *c.Metadata - out.Metadata = &md + out.Metadata = copyMetadata(c.Metadata) + + // empty dependencies and shallow copy all dependencies, otherwise parent info may be corrupted if + // there is more than one dependency aliasing this chart + out.SetDependencies() + for _, dependency := range c.Dependencies() { + cpy := *dependency + out.AddDependency(&cpy) + } if dep.Alias != "" { - md.Name = dep.Alias + out.Metadata.Name = dep.Alias } return &out } return nil } +func copyMetadata(metadata *chart.Metadata) *chart.Metadata { + md := *metadata + + if md.Dependencies != nil { + dependencies := make([]*chart.Dependency, len(md.Dependencies)) + for i := range md.Dependencies { + dependency := *md.Dependencies[i] + dependencies[i] = &dependency + } + md.Dependencies = dependencies + } + return &md +} + // processDependencyEnabled removes disabled charts from dependencies func processDependencyEnabled(c *chart.Chart, v map[string]interface{}, path string) error { if c.Metadata.Dependencies == nil { @@ -148,7 +160,7 @@ func processDependencyEnabled(c *chart.Chart, v map[string]interface{}, path str // If any dependency is not a part of Chart.yaml // then this should be added to chartDependencies. // However, if the dependency is already specified in Chart.yaml - // we should not add it, as it would be anyways processed from Chart.yaml + // we should not add it, as it would be processed from Chart.yaml anyway. Loop: for _, existing := range c.Dependencies() { @@ -177,7 +189,7 @@ Loop: for _, lr := range c.Metadata.Dependencies { lr.Enabled = true } - cvals, err := CoalesceValues(c, v) + cvals, err := util.CoalesceValues(c, v) if err != nil { return err } @@ -232,6 +244,8 @@ func pathToMap(path string, data map[string]interface{}) map[string]interface{} return set(parsePath(path), data) } +func parsePath(key string) []string { return strings.Split(key, ".") } + func set(path []string, data map[string]interface{}) map[string]interface{} { if len(path) == 0 { return nil @@ -249,12 +263,12 @@ func processImportValues(c *chart.Chart, merge bool) error { return nil } // combine chart values and empty config to get Values - var cvals Values + var cvals common.Values var err error if merge { - cvals, err = MergeValues(c, nil) + cvals, err = util.MergeValues(c, nil) } else { - cvals, err = CoalesceValues(c, nil) + cvals, err = util.CoalesceValues(c, nil) } if err != nil { return err @@ -266,8 +280,8 @@ func processImportValues(c *chart.Chart, merge bool) error { for _, riv := range r.ImportValues { switch iv := riv.(type) { case map[string]interface{}: - child := iv["child"].(string) - parent := iv["parent"].(string) + child := fmt.Sprintf("%v", iv["child"]) + parent := fmt.Sprintf("%v", iv["parent"]) outiv = append(outiv, map[string]string{ "child": child, @@ -277,14 +291,18 @@ func processImportValues(c *chart.Chart, merge bool) error { // get child table vv, err := cvals.Table(r.Name + "." + child) if err != nil { - log.Printf("Warning: ImportValues missing table from chart %s: %v", r.Name, err) + slog.Warn( + "ImportValues missing table from chart", + slog.String("chart", r.Name), + slog.Any("error", err), + ) continue } // create value map from child to be merged into parent if merge { - b = MergeTables(b, pathToMap(parent, vv.AsMap())) + b = util.MergeTables(b, pathToMap(parent, vv.AsMap())) } else { - b = CoalesceTables(b, pathToMap(parent, vv.AsMap())) + b = util.CoalesceTables(b, pathToMap(parent, vv.AsMap())) } case string: child := "exports." + iv @@ -294,13 +312,13 @@ func processImportValues(c *chart.Chart, merge bool) error { }) vm, err := cvals.Table(r.Name + "." + child) if err != nil { - log.Printf("Warning: ImportValues missing table: %v", err) + slog.Warn("ImportValues missing table", slog.Any("error", err)) continue } if merge { - b = MergeTables(b, vm.AsMap()) + b = util.MergeTables(b, vm.AsMap()) } else { - b = CoalesceTables(b, vm.AsMap()) + b = util.CoalesceTables(b, vm.AsMap()) } } } @@ -315,14 +333,14 @@ func processImportValues(c *chart.Chart, merge bool) error { // deep copying the cvals as there are cases where pointers can end // up in the cvals when they are copied onto b in ways that break things. cvals = deepCopyMap(cvals) - c.Values = MergeTables(cvals, b) + c.Values = util.MergeTables(cvals, b) } else { // Trimming the nil values from cvals is needed for backwards compatibility. // Previously, the b value had been populated with cvals along with some // overrides. This caused the coalescing functionality to remove the // nil/null values. This trimming is for backwards compat. cvals = trimNilValues(cvals) - c.Values = CoalesceTables(cvals, b) + c.Values = util.CoalesceTables(cvals, b) } return nil @@ -355,6 +373,12 @@ func trimNilValues(vals map[string]interface{}) map[string]interface{} { return valsCopyMap } +// istable is a special-purpose function to see if the present thing matches the definition of a YAML table. +func istable(v interface{}) bool { + _, ok := v.(map[string]interface{}) + return ok +} + // processDependencyImportValues imports specified chart values from child to parent. func processDependencyImportValues(c *chart.Chart, merge bool) error { for _, d := range c.Dependencies() { @@ -366,321 +390,254 @@ func processDependencyImportValues(c *chart.Chart, merge bool) error { return processImportValues(c, merge) } -// Extend Chart Values according to export-values directive of its parent Chart. -func processExportValues(c *chart.Chart, merge bool) error { - if c.Parent() == nil || c.Parent().Metadata.Dependencies == nil { - return nil +func processDependencyImportExportValues(c *chart.Chart, merge bool) error { + if err := processDependencyExportValues(c, merge); err != nil { + return fmt.Errorf("process dependency export values: %w", err) } - // Get current chart as chart.Dependency object. - var cr *chart.Dependency - for _, r := range c.Parent().Metadata.Dependencies { - if r.Name == c.Name() { - cr = r - break - } + if err := processDependencyImportValues(c, merge); err != nil { + return fmt.Errorf("process dependency import values: %w", err) } - if cr == nil { - return nil - } + return nil +} - // Get parent chart values. - var pvals Values - var err error - if merge { - pvals, err = MergeValues(c.Parent(), nil) - if err != nil { - return err - } - } else { - pvals, err = CoalesceValues(c.Parent(), nil) - if err != nil { - return err - } +func processDependencyExportValues(c *chart.Chart, merge bool) error { + if err := processExportValues(c, merge); err != nil { + return fmt.Errorf("process export values: %w", err) } - // Get current chart values. - var cvals Values - if merge { - cvals, err = MergeValues(c, nil) - if err != nil { - return err - } - } else { - cvals, err = CoalesceValues(c, nil) - if err != nil { - return err + for _, d := range c.Dependencies() { + if err := processDependencyExportValues(d, merge); err != nil { + return fmt.Errorf("process dependency export values recursively: %w", err) } } - // Generate Values map to be merged into current chart, according to export-values directive. - exportedValues, err := getExportedValues(c.Parent().Name(), cr, pvals, merge) - if err != nil { - return err - } - - cv, err := copystructure.Copy(cvals) - if err != nil { - return err - } - - ev, err := copystructure.Copy(exportedValues) - if err != nil { - return err - } - - // Merge newly generated extra Values map into current chart Values. - if merge { - c.Values = MergeTables(ev.(map[string]interface{}), cv.(Values)) - } else { - c.Values = CoalesceTables(ev.(map[string]interface{}), cv.(Values)) - } + return nil +} - evForSync, err := copystructure.Copy(exportedValues) - if err != nil { - return err +func processDependencyExportExtraValues(c *chart.Chart, extraVals *map[string]interface{}, merge bool) error { + if err := processExportExtraValues(c, extraVals, merge); err != nil { + return fmt.Errorf("process export extra values: %w", err) } - // Make sure no parent chart will override our new extra Values in this chart. - if err := syncChartOverridesToParentsValues(c, evForSync.(map[string]interface{}), merge); err != nil { - return err + for _, d := range c.Dependencies() { + if err := processDependencyExportExtraValues(d, extraVals, merge); err != nil { + return fmt.Errorf("process dependency export extra values recursively: %w", err) + } } return nil } -// Get Values map with overrides destined for current chart and merge these overrides into all its parent charts -// Values, while prefixing the to be applied parent overrides with the relative path to the current chart. This is -// to avoid values from parent charts having precedence to the overrides passed to the current chart. -func syncChartOverridesToParentsValues(c *chart.Chart, overrides map[string]interface{}, merge bool) error { - if c.Parent() == nil { +func processExportValues(c *chart.Chart, merge bool) error { + if c.Parent() == nil || c.Parent().Metadata.Dependencies == nil { return nil } - // Get parent chart values. - var pvals Values - var err error - if merge { - pvals, err = MergeValues(c.Parent(), nil) - if err != nil { - return err - } - } else { - pvals, err = CoalesceValues(c.Parent(), nil) - if err != nil { - return err - } + r := dependencyForChart(c) + if r == nil { + return nil } - pv, err := copystructure.Copy(pvals) + pvals, err := valuesForChart(c.Parent(), merge) if err != nil { - return err + return fmt.Errorf("get parent values: %w", err) } - o, err := copystructure.Copy(overrides) + cvals, err := valuesForChart(c, merge) if err != nil { - return err + return fmt.Errorf("get chart values: %w", err) } - parentOverrides := pathToMap(c.Name(), o.(map[string]interface{})) - - po, err := copystructure.Copy(parentOverrides) + exportedValues, err := getExportedValues(c.Parent().Name(), r, pvals, merge) if err != nil { - return err + return fmt.Errorf("get exported values: %w", err) } if merge { - c.Parent().Values = MergeTables(po.(map[string]interface{}), pv.(Values)) + c.Values = util.MergeTables(deepCopyMap(exportedValues), deepCopyMap(cvals)) } else { - c.Parent().Values = CoalesceTables(po.(map[string]interface{}), pv.(Values)) + c.Values = util.CoalesceTables(deepCopyMap(exportedValues), deepCopyMap(cvals)) + } + + if err := syncChartOverridesToParentsValues(c, deepCopyMap(exportedValues), merge); err != nil { + return fmt.Errorf("sync chart overrides to parent values: %w", err) } - return syncChartOverridesToParentsValues(c.Parent(), parentOverrides, merge) + return nil } -// Extend extra Values overrides according to export-values directive, if needed. func processExportExtraValues(c *chart.Chart, extraVals *map[string]interface{}, merge bool) error { - if c.Parent() == nil || c.Parent().Metadata.Dependencies == nil { + if extraVals == nil || c.Parent() == nil || c.Parent().Metadata.Dependencies == nil { return nil } - // Get current Chart as chart.Dependency. - var cr *chart.Dependency - for _, r := range c.Parent().Metadata.Dependencies { - if r.Name == c.Name() { - cr = r - break - } - } - - if cr == nil { + r := dependencyForChart(c) + if r == nil { return nil } - for _, exportValue := range cr.ExportValues { + for _, exportValue := range dependencyExportValues(r) { parent, child, err := parseExportValues(exportValue) if err != nil { - log.Printf("Warning: invalid ExportValues defined in chart %q for its dependency %q: %s", c.Parent().Name(), cr.Name, err) + slog.Warn( + "invalid ExportValues definition", + slog.String("chart", c.Parent().Name()), + slog.String("dependency", r.Name), + slog.Any("error", err), + ) continue } headlessParentChartPath := stripFirstPathPart(c.Parent().ChartPath()) - var exportParentTablePath string + exportParentTablePath := parent if headlessParentChartPath != "" { exportParentTablePath = joinPath(headlessParentChartPath, parent) - } else { - exportParentTablePath = parent } - // If present, get extra Values overrides table from parent path, as defined in export-values. - extraParentVals, err := Values(*extraVals).Table(exportParentTablePath) + extraParentVals, err := common.Values(*extraVals).Table(exportParentTablePath) if err != nil { - var errNoTable ErrNoTable + var errNoTable common.ErrNoTable if errors.As(err, &errNoTable) { continue - } else { - return err } + return fmt.Errorf("read extra values table %q: %w", exportParentTablePath, err) } - var extraChildValsPath string + extraChildValsPath := stripFirstPathPart(c.ChartPath()) if child != "" { - extraChildValsPath = joinPath(stripFirstPathPart(c.ChartPath()), child) - } else { - extraChildValsPath = stripFirstPathPart(c.ChartPath()) + extraChildValsPath = joinPath(extraChildValsPath, child) } - // Do not overwrite anything — skip if something present in destination. - var errNoTable ErrNoTable - var errNoValue ErrNoValue - _, errTable := Values(*extraVals).Table(extraChildValsPath) - _, errValue := Values(*extraVals).PathValue(extraChildValsPath) + var errNoTable common.ErrNoTable + var errNoValue common.ErrNoValue + _, errTable := common.Values(*extraVals).Table(extraChildValsPath) + _, errValue := common.Values(*extraVals).PathValue(extraChildValsPath) if !(errors.As(errTable, &errNoTable) && errors.As(errValue, &errNoValue)) { continue } - // Create new Values map structure to be merged into extra Values overrides map. - extraChildVals, err := copystructure.Copy(pathToMap(extraChildValsPath, extraParentVals.AsMap())) - if err != nil { - return err - } - - // Merge new Values into existing extra Values overrides. + extraChildVals := pathToMap(extraChildValsPath, deepCopyMap(extraParentVals.AsMap())) if merge { - *extraVals = MergeTables(extraChildVals.(map[string]interface{}), *extraVals) + *extraVals = util.MergeTables(extraChildVals, *extraVals) } else { - *extraVals = CoalesceTables(extraChildVals.(map[string]interface{}), *extraVals) + *extraVals = util.CoalesceTables(extraChildVals, *extraVals) } } return nil } -// Generate Values map to be merged into child chart, according to export-values directive of parent chart. -func getExportedValues(parentName string, r *chart.Dependency, pvals Values, merge bool) (map[string]interface{}, error) { +func getExportedValues(parentName string, r *chart.Dependency, pvals common.Values, merge bool) (map[string]interface{}, error) { b := make(map[string]interface{}) - var exportValues []interface{} - for _, rev := range r.ExportValues { + var normalizedExportValues []interface{} + + for _, rev := range dependencyExportValues(r) { parent, child, err := parseExportValues(rev) if err != nil { - log.Printf("Warning: invalid ExportValues defined in chart %q for its dependency %q: %s", parentName, r.Name, err) + slog.Warn( + "invalid ExportValues definition", + slog.String("chart", parentName), + slog.String("dependency", r.Name), + slog.Any("error", err), + ) continue } - exportValues = append(exportValues, map[string]string{ + normalizedExportValues = append(normalizedExportValues, map[string]string{ "parent": parent, "child": child, }) - var childValMap map[string]interface{} - // Try to get parent table for parent path specified in export-values. - vm, err := pvals.Table(parent) - if err == nil { - // It IS a valid table. - if child == "" { - childValMap = vm.AsMap() - } else { - childValMap = pathToMap(child, vm.AsMap()) - } + childValMap, ok := valueMapFromExport(parentName, r.Name, pvals, parent, child) + if !ok { + continue + } + + if merge { + b = util.MergeTables(deepCopyMap(childValMap), b) } else { - // If it's not a table, it might be a simple value. - value, e := pvals.PathValue(parent) - if e != nil { - log.Printf("Warning: ExportValues defined in chart %q for its dependency %q can't get the parent path: %s", parentName, r.Name, err.Error()) - continue - } + b = util.CoalesceTables(deepCopyMap(childValMap), b) + } + } - childSlice := parsePath(child) - if len(childSlice) == 1 && childSlice[0] == "" { - log.Printf("Warning: in ExportValues defined in chart %q for its dependency %q you are trying to assign a primitive data type (string, int, etc) to the root of your dependent chart values. We will ignore this ExportValues, because this is most likely not what you want. Fix the ExportValues to hide this warning.", parentName, r.Name) - continue - } + setDependencyExportValues(r, normalizedExportValues) - childPath := joinPath(childSlice[:len(childSlice)-1]...) - childMap := map[string]interface{}{ - childSlice[len(childSlice)-1]: value, - } + return b, nil +} - if childPath != "" { - childValMap = pathToMap(childPath, childMap) - } else { - childValMap = childMap - } +func valueMapFromExport(parentChartName, dependencyName string, pvals common.Values, parent, child string) (map[string]interface{}, bool) { + vm, err := pvals.Table(parent) + if err == nil { + if child == "" { + return vm.AsMap(), true } + return pathToMap(child, vm.AsMap()), true + } - chValMap, err := copystructure.Copy(childValMap) - if err != nil { - return b, err - } + value, valueErr := pvals.PathValue(parent) + if valueErr != nil { + slog.Warn( + "ExportValues parent path not found", + slog.String("chart", parentChartName), + slog.String("dependency", dependencyName), + slog.String("path", parent), + slog.Any("error", err), + ) + return nil, false + } - // Merge new Values map for current export-values directive into other new Values maps for other export-values directives. - if merge { - b = MergeTables(chValMap.(map[string]interface{}), b) - } else { - b = CoalesceTables(chValMap.(map[string]interface{}), b) - } + childSlice := parsePath(child) + if len(childSlice) == 1 && childSlice[0] == "" { + slog.Warn( + "ExportValues cannot map primitive to root", + slog.String("chart", parentChartName), + slog.String("dependency", dependencyName), + ) + return nil, false } - // Set formatted export values. - r.ExportValues = exportValues + childPath := joinPath(childSlice[:len(childSlice)-1]...) + childMap := map[string]interface{}{childSlice[len(childSlice)-1]: value} + if childPath == "" { + return childMap, true + } - return b, nil + return pathToMap(childPath, childMap), true } -// Parse and validate export-values. func parseExportValues(rev interface{}) (string, string, error) { - var parent, child string + var parent string + var child string switch ev := rev.(type) { case map[string]interface{}: - var ok bool - parent, ok = ev["parent"].(string) + parentValue, ok := ev["parent"].(string) if !ok { return "", "", fmt.Errorf("parent must be a string") } - child, ok = ev["child"].(string) + childValue, ok := ev["child"].(string) if !ok { return "", "", fmt.Errorf("child must be a string") } - if strings.TrimSpace(parent) == "" || strings.TrimSpace(parent) == "." { - return "", "", fmt.Errorf("parent %q is not allowed", parent) + parent = strings.TrimSpace(parentValue) + child = strings.TrimSpace(childValue) + if parent == "" || parent == "." { + return "", "", fmt.Errorf("parent %q is not allowed", parentValue) } - - parent = strings.TrimSpace(parent) - child = strings.TrimSpace(child) - if child == "." { child = "" } case string: - switch parent = strings.TrimSpace(ev); parent { + s := strings.TrimSpace(ev) + switch s { case "", ".": parent = "exports" default: - parent = "exports." + parent + parent = "exports." + s } child = "" default: @@ -690,47 +647,121 @@ func parseExportValues(rev interface{}) (string, string, error) { return parent, child, nil } -func processDependencyImportExportValues(c *chart.Chart, merge bool) error { - if err := processDependencyExportValues(c, merge); err != nil { - return err +func syncChartOverridesToParentsValues(c *chart.Chart, overrides map[string]interface{}, merge bool) error { + if c.Parent() == nil { + return nil } - return processDependencyImportValues(c, merge) -} + pvals, err := valuesForChart(c.Parent(), merge) + if err != nil { + return fmt.Errorf("get parent values: %w", err) + } -// Update Values of Chart and its Dependencies according to export-values directive. -func processDependencyExportValues(c *chart.Chart, merge bool) error { - if err := processExportValues(c, merge); err != nil { - return err + parentOverrides := pathToMap(c.Name(), deepCopyMap(overrides)) + if merge { + c.Parent().Values = util.MergeTables(deepCopyMap(parentOverrides), deepCopyMap(pvals)) + } else { + c.Parent().Values = util.CoalesceTables(deepCopyMap(parentOverrides), deepCopyMap(pvals)) } - for _, d := range c.Dependencies() { - // recurse - if err := processDependencyExportValues(d, merge); err != nil { - return err - } + if err := syncChartOverridesToParentsValues(c.Parent(), parentOverrides, merge); err != nil { + return fmt.Errorf("sync chart overrides recursively: %w", err) } return nil } -// Update extra Values overrides according to export-values directive, if needed. -func processDependencyExportExtraValues(c *chart.Chart, extraVals *map[string]interface{}, merge bool) error { - if err := processExportExtraValues(c, extraVals, merge); err != nil { - return err +func valuesForChart(c *chart.Chart, merge bool) (common.Values, error) { + if merge { + vals, err := util.MergeValues(c, nil) + if err != nil { + return nil, err + } + return vals, nil } - for _, d := range c.Dependencies() { - // recurse - if err := processDependencyExportExtraValues(d, extraVals, merge); err != nil { - return err + vals, err := util.CoalesceValues(c, nil) + if err != nil { + return nil, err + } + + return vals, nil +} + +func dependencyForChart(c *chart.Chart) *chart.Dependency { + if c.Parent() == nil || c.Parent().Metadata == nil { + return nil + } + + for _, r := range c.Parent().Metadata.Dependencies { + if r != nil && r.Name == c.Name() { + return r } } return nil } +func dependencyExportValues(r *chart.Dependency) []interface{} { + if r == nil { + return nil + } + + rv := reflect.ValueOf(r) + if rv.Kind() != reflect.Ptr || rv.IsNil() { + return nil + } + + elem := rv.Elem() + if !elem.IsValid() { + return nil + } + + field := elem.FieldByName("ExportValues") + if !field.IsValid() || field.Kind() != reflect.Slice { + return nil + } + + result := make([]interface{}, 0, field.Len()) + for i := range field.Len() { + result = append(result, field.Index(i).Interface()) + } + + return result +} + +func setDependencyExportValues(r *chart.Dependency, values []interface{}) { + if r == nil { + return + } + + rv := reflect.ValueOf(r) + if rv.Kind() != reflect.Ptr || rv.IsNil() { + return + } + + elem := rv.Elem() + if !elem.IsValid() { + return + } + + field := elem.FieldByName("ExportValues") + if !field.IsValid() || !field.CanSet() || field.Kind() != reflect.Slice { + return + } + + field.Set(reflect.ValueOf(values)) +} + func stripFirstPathPart(path string) string { - pathParts := parsePath(path)[1:] - return joinPath(pathParts...) + pathParts := parsePath(path) + if len(pathParts) <= 1 { + return "" + } + + return joinPath(pathParts[1:]...) +} + +func joinPath(path ...string) string { + return strings.Join(path, ".") } diff --git a/pkg/helm/pkg/chart/v2/util/dependencies_test.go b/pkg/helm/pkg/chart/v2/util/dependencies_test.go new file mode 100644 index 00000000..dfd9b8af --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/dependencies_test.go @@ -0,0 +1,571 @@ +/* +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 util + +import ( + "context" + "os" + "path/filepath" + "sort" + "strconv" + "testing" + + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/loader" +) + +func loadChart(t *testing.T, path string) *chart.Chart { + t.Helper() + c, err := loader.Load(context.Background(), path) + if err != nil { + t.Fatalf("failed to load testdata: %s", err) + } + return c +} + +func TestLoadDependency(t *testing.T) { + tests := []*chart.Dependency{ + {Name: "alpine", Version: "0.1.0", Repository: "https://example.com/charts"}, + {Name: "mariner", Version: "4.3.2", Repository: "https://example.com/charts"}, + } + + check := func(deps []*chart.Dependency) { + if len(deps) != 2 { + t.Errorf("expected 2 dependencies, got %d", len(deps)) + } + for i, tt := range tests { + if deps[i].Name != tt.Name { + t.Errorf("expected dependency named %q, got %q", tt.Name, deps[i].Name) + } + if deps[i].Version != tt.Version { + t.Errorf("expected dependency named %q to have version %q, got %q", tt.Name, tt.Version, deps[i].Version) + } + if deps[i].Repository != tt.Repository { + t.Errorf("expected dependency named %q to have repository %q, got %q", tt.Name, tt.Repository, deps[i].Repository) + } + } + } + c := loadChart(t, "testdata/frobnitz") + check(c.Metadata.Dependencies) + check(c.Lock.Dependencies) +} + +func TestDependencyEnabled(t *testing.T) { + type M = map[string]interface{} + tests := []struct { + name string + v M + e []string // expected charts including duplicates in alphanumeric order + }{{ + "tags with no effect", + M{"tags": M{"nothinguseful": false}}, + []string{"parentchart", "parentchart.subchart1", "parentchart.subchart1.subcharta", "parentchart.subchart1.subchartb"}, + }, { + "tags disabling a group", + M{"tags": M{"front-end": false}}, + []string{"parentchart"}, + }, { + "tags disabling a group and enabling a different group", + M{"tags": M{"front-end": false, "back-end": true}}, + []string{"parentchart", "parentchart.subchart2", "parentchart.subchart2.subchartb", "parentchart.subchart2.subchartc"}, + }, { + "tags disabling only children, children still enabled since tag front-end=true in values.yaml", + M{"tags": M{"subcharta": false, "subchartb": false}}, + []string{"parentchart", "parentchart.subchart1", "parentchart.subchart1.subcharta", "parentchart.subchart1.subchartb"}, + }, { + "tags disabling all parents/children with additional tag re-enabling a parent", + M{"tags": M{"front-end": false, "subchart1": true, "back-end": false}}, + []string{"parentchart", "parentchart.subchart1"}, + }, { + "conditions enabling the parent charts, but back-end (b, c) is still disabled via values.yaml", + M{"subchart1": M{"enabled": true}, "subchart2": M{"enabled": true}}, + []string{"parentchart", "parentchart.subchart1", "parentchart.subchart1.subcharta", "parentchart.subchart1.subchartb", "parentchart.subchart2"}, + }, { + "conditions disabling the parent charts, effectively disabling children", + M{"subchart1": M{"enabled": false}, "subchart2": M{"enabled": false}}, + []string{"parentchart"}, + }, { + "conditions a child using the second condition path of child's condition", + M{"subchart1": M{"subcharta": M{"enabled": false}}}, + []string{"parentchart", "parentchart.subchart1", "parentchart.subchart1.subchartb"}, + }, { + "tags enabling a parent/child group with condition disabling one child", + M{"subchart2": M{"subchartc": M{"enabled": false}}, "tags": M{"back-end": true}}, + []string{"parentchart", "parentchart.subchart1", "parentchart.subchart1.subcharta", "parentchart.subchart1.subchartb", "parentchart.subchart2", "parentchart.subchart2.subchartb"}, + }, { + "tags will not enable a child if parent is explicitly disabled with condition", + M{"subchart1": M{"enabled": false}, "tags": M{"front-end": true}}, + []string{"parentchart"}, + }, { + "subcharts with alias also respect conditions", + M{"subchart1": M{"enabled": false}, "subchart2alias": M{"enabled": true, "subchartb": M{"enabled": true}}}, + []string{"parentchart", "parentchart.subchart2alias", "parentchart.subchart2alias.subchartb"}, + }} + + for _, tc := range tests { + c := loadChart(t, "testdata/subpop") + t.Run(tc.name, func(t *testing.T) { + if err := processDependencyEnabled(c, tc.v, ""); err != nil { + t.Fatalf("error processing enabled dependencies %v", err) + } + + names := extractChartNames(c) + if len(names) != len(tc.e) { + t.Fatalf("slice lengths do not match got %v, expected %v", len(names), len(tc.e)) + } + for i := range names { + if names[i] != tc.e[i] { + t.Fatalf("slice values do not match got %v, expected %v", names, tc.e) + } + } + }) + } +} + +// extractChartNames recursively searches chart dependencies returning all charts found +func extractChartNames(c *chart.Chart) []string { + var out []string + var fn func(c *chart.Chart) + fn = func(c *chart.Chart) { + out = append(out, c.ChartPath()) + for _, d := range c.Dependencies() { + fn(d) + } + } + fn(c) + sort.Strings(out) + return out +} + +func TestProcessDependencyImportValues(t *testing.T) { + c := loadChart(t, "testdata/subpop") + + e := make(map[string]string) + + e["imported-chart1.SC1bool"] = "true" + e["imported-chart1.SC1float"] = "3.14" + e["imported-chart1.SC1int"] = "100" + e["imported-chart1.SC1string"] = "dollywood" + e["imported-chart1.SC1extra1"] = "11" + e["imported-chart1.SPextra1"] = "helm rocks" + e["imported-chart1.SC1extra1"] = "11" + + e["imported-chartA.SCAbool"] = "false" + e["imported-chartA.SCAfloat"] = "3.1" + e["imported-chartA.SCAint"] = "55" + e["imported-chartA.SCAstring"] = "jabba" + e["imported-chartA.SPextra3"] = "1.337" + e["imported-chartA.SC1extra2"] = "1.337" + e["imported-chartA.SCAnested1.SCAnested2"] = "true" + + e["imported-chartA-B.SCAbool"] = "false" + e["imported-chartA-B.SCAfloat"] = "3.1" + e["imported-chartA-B.SCAint"] = "55" + e["imported-chartA-B.SCAstring"] = "jabba" + + e["imported-chartA-B.SCBbool"] = "true" + e["imported-chartA-B.SCBfloat"] = "7.77" + e["imported-chartA-B.SCBint"] = "33" + e["imported-chartA-B.SCBstring"] = "boba" + e["imported-chartA-B.SPextra5"] = "k8s" + e["imported-chartA-B.SC1extra5"] = "tiller" + + // These values are imported from the child chart to the parent. Parent + // values take precedence over imported values. This enables importing a + // large section from a child chart and overriding a selection from it. + e["overridden-chart1.SC1bool"] = "false" + e["overridden-chart1.SC1float"] = "3.141592" + e["overridden-chart1.SC1int"] = "99" + e["overridden-chart1.SC1string"] = "pollywog" + e["overridden-chart1.SPextra2"] = "42" + + e["overridden-chartA.SCAbool"] = "true" + e["overridden-chartA.SCAfloat"] = "41.3" + e["overridden-chartA.SCAint"] = "808" + e["overridden-chartA.SCAstring"] = "jabberwocky" + e["overridden-chartA.SPextra4"] = "true" + + // These values are imported from the child chart to the parent. Parent + // values take precedence over imported values. This enables importing a + // large section from a child chart and overriding a selection from it. + e["overridden-chartA-B.SCAbool"] = "true" + e["overridden-chartA-B.SCAfloat"] = "41.3" + e["overridden-chartA-B.SCAint"] = "808" + e["overridden-chartA-B.SCAstring"] = "jabberwocky" + e["overridden-chartA-B.SCBbool"] = "false" + e["overridden-chartA-B.SCBfloat"] = "1.99" + e["overridden-chartA-B.SCBint"] = "77" + e["overridden-chartA-B.SCBstring"] = "jango" + e["overridden-chartA-B.SPextra6"] = "111" + e["overridden-chartA-B.SCAextra1"] = "23" + e["overridden-chartA-B.SCBextra1"] = "13" + e["overridden-chartA-B.SC1extra6"] = "77" + + // `exports` style + e["SCBexported1B"] = "1965" + e["SC1extra7"] = "true" + e["SCBexported2A"] = "blaster" + e["global.SC1exported2.all.SC1exported3"] = "SC1expstr" + + if err := processDependencyImportValues(c, false); err != nil { + t.Fatalf("processing import values dependencies %v", err) + } + cc := common.Values(c.Values) + for kk, vv := range e { + pv, err := cc.PathValue(kk) + if err != nil { + t.Fatalf("retrieving import values table %v %v", kk, err) + } + + switch pv := pv.(type) { + case float64: + if s := strconv.FormatFloat(pv, 'f', -1, 64); s != vv { + t.Errorf("failed to match imported float value %v with expected %v for key %q", s, vv, kk) + } + case bool: + if b := strconv.FormatBool(pv); b != vv { + t.Errorf("failed to match imported bool value %v with expected %v for key %q", b, vv, kk) + } + default: + if pv != vv { + t.Errorf("failed to match imported string value %q with expected %q for key %q", pv, vv, kk) + } + } + } + + // Since this was processed with coalescing there should be no null values. + // Here we verify that. + _, err := cc.PathValue("ensurenull") + if err == nil { + t.Error("expect nil value not found but found it") + } + switch xerr := err.(type) { + case common.ErrNoValue: + // We found what we expected + default: + t.Errorf("expected an ErrNoValue but got %q instead", xerr) + } + + c = loadChart(t, "testdata/subpop") + if err := processDependencyImportValues(c, true); err != nil { + t.Fatalf("processing import values dependencies %v", err) + } + cc = common.Values(c.Values) + val, err := cc.PathValue("ensurenull") + if err != nil { + t.Error("expect value but ensurenull was not found") + } + if val != nil { + t.Errorf("expect nil value but got %q instead", val) + } +} + +func TestProcessDependencyImportValuesFromSharedDependencyToAliases(t *testing.T) { + c := loadChart(t, "testdata/chart-with-import-from-aliased-dependencies") + + if err := processDependencyEnabled(c, c.Values, ""); err != nil { + t.Fatalf("expected no errors but got %q", err) + } + if err := processDependencyImportValues(c, true); err != nil { + t.Fatalf("processing import values dependencies %v", err) + } + e := make(map[string]string) + + e["foo-defaults.defaultValue"] = "42" + e["bar-defaults.defaultValue"] = "42" + + e["foo.defaults.defaultValue"] = "42" + e["bar.defaults.defaultValue"] = "42" + + e["foo.grandchild.defaults.defaultValue"] = "42" + e["bar.grandchild.defaults.defaultValue"] = "42" + + cValues := common.Values(c.Values) + for kk, vv := range e { + pv, err := cValues.PathValue(kk) + if err != nil { + t.Fatalf("retrieving import values table %v %v", kk, err) + } + if pv != vv { + t.Errorf("failed to match imported value %v with expected %v", pv, vv) + } + } +} + +func TestProcessDependencyImportValuesMultiLevelPrecedence(t *testing.T) { + c := loadChart(t, "testdata/three-level-dependent-chart/umbrella") + + e := make(map[string]string) + + // The order of precedence should be: + // 1. User specified values (e.g CLI) + // 2. Parent chart values + // 3. Imported values + // 4. Sub-chart values + // The 4 app charts here deal with things differently: + // - app1 has a port value set in the umbrella chart. It does not import any + // values so the value from the umbrella chart should be used. + // - app2 has a value in the app chart and imports from the library. The + // app chart value should take precedence. + // - app3 has no value in the app chart and imports the value from the library + // chart. The library chart value should be used. + // - app4 has a value in the app chart and does not import the value from the + // library chart. The app charts value should be used. + e["app1.service.port"] = "3456" + e["app2.service.port"] = "8080" + e["app3.service.port"] = "9090" + e["app4.service.port"] = "1234" + if err := processDependencyImportValues(c, true); err != nil { + t.Fatalf("processing import values dependencies %v", err) + } + cc := common.Values(c.Values) + for kk, vv := range e { + pv, err := cc.PathValue(kk) + if err != nil { + t.Fatalf("retrieving import values table %v %v", kk, err) + } + + switch pv := pv.(type) { + case float64: + if s := strconv.FormatFloat(pv, 'f', -1, 64); s != vv { + t.Errorf("failed to match imported float value %v with expected %v", s, vv) + } + default: + if pv != vv { + t.Errorf("failed to match imported string value %q with expected %q", pv, vv) + } + } + } +} + +func TestProcessDependencyImportValuesForEnabledCharts(t *testing.T) { + c := loadChart(t, "testdata/import-values-from-enabled-subchart/parent-chart") + nameOverride := "parent-chart-prod" + + if err := processDependencyImportValues(c, true); err != nil { + t.Fatalf("processing import values dependencies %v", err) + } + + if len(c.Dependencies()) != 2 { + t.Fatalf("expected 2 dependencies for this chart, but got %d", len(c.Dependencies())) + } + + if err := processDependencyEnabled(c, c.Values, ""); err != nil { + t.Fatalf("expected no errors but got %q", err) + } + + if len(c.Dependencies()) != 1 { + t.Fatal("expected no changes in dependencies") + } + + if len(c.Metadata.Dependencies) != 1 { + t.Fatalf("expected 1 dependency specified in Chart.yaml, got %d", len(c.Metadata.Dependencies)) + } + + prodDependencyValues := c.Dependencies()[0].Values + if prodDependencyValues["nameOverride"] != nameOverride { + t.Fatalf("dependency chart name should be %s but got %s", nameOverride, prodDependencyValues["nameOverride"]) + } +} + +func TestGetAliasDependency(t *testing.T) { + c := loadChart(t, "testdata/frobnitz") + req := c.Metadata.Dependencies + + if len(req) == 0 { + t.Fatalf("there are no dependencies to test") + } + + // Success case + aliasChart := getAliasDependency(c.Dependencies(), req[0]) + if aliasChart == nil { + t.Fatalf("failed to get dependency chart for alias %s", req[0].Name) + } + if req[0].Alias != "" { + if aliasChart.Name() != req[0].Alias { + t.Fatalf("dependency chart name should be %s but got %s", req[0].Alias, aliasChart.Name()) + } + } else if aliasChart.Name() != req[0].Name { + t.Fatalf("dependency chart name should be %s but got %s", req[0].Name, aliasChart.Name()) + } + + if req[0].Version != "" { + if !IsCompatibleRange(req[0].Version, aliasChart.Metadata.Version) { + t.Fatalf("dependency chart version is not in the compatible range") + } + } + + // Failure case + req[0].Name = "something-else" + if aliasChart := getAliasDependency(c.Dependencies(), req[0]); aliasChart != nil { + t.Fatalf("expected no chart but got %s", aliasChart.Name()) + } + + req[0].Version = "something else which is not in the compatible range" + if IsCompatibleRange(req[0].Version, aliasChart.Metadata.Version) { + t.Fatalf("dependency chart version which is not in the compatible range should cause a failure other than a success ") + } +} + +func TestDependentChartAliases(t *testing.T) { + c := loadChart(t, "testdata/dependent-chart-alias") + req := c.Metadata.Dependencies + + if len(c.Dependencies()) != 2 { + t.Fatalf("expected 2 dependencies for this chart, but got %d", len(c.Dependencies())) + } + + if err := processDependencyEnabled(c, c.Values, ""); err != nil { + t.Fatalf("expected no errors but got %q", err) + } + + if len(c.Dependencies()) != 3 { + t.Fatal("expected alias dependencies to be added") + } + + if len(c.Dependencies()) != len(c.Metadata.Dependencies) { + t.Fatalf("expected number of chart dependencies %d, but got %d", len(c.Metadata.Dependencies), len(c.Dependencies())) + } + + aliasChart := getAliasDependency(c.Dependencies(), req[2]) + + if aliasChart == nil { + t.Fatalf("failed to get dependency chart for alias %s", req[2].Name) + } + if aliasChart.Parent() != c { + t.Fatalf("dependency chart has wrong parent, expected %s but got %s", c.Name(), aliasChart.Parent().Name()) + } + if req[2].Alias != "" { + if aliasChart.Name() != req[2].Alias { + t.Fatalf("dependency chart name should be %s but got %s", req[2].Alias, aliasChart.Name()) + } + } else if aliasChart.Name() != req[2].Name { + t.Fatalf("dependency chart name should be %s but got %s", req[2].Name, aliasChart.Name()) + } + + req[2].Name = "dummy-name" + if aliasChart := getAliasDependency(c.Dependencies(), req[2]); aliasChart != nil { + t.Fatalf("expected no chart but got %s", aliasChart.Name()) + } + +} + +func TestDependentChartWithSubChartsAbsentInDependency(t *testing.T) { + c := loadChart(t, "testdata/dependent-chart-no-requirements-yaml") + + if len(c.Dependencies()) != 2 { + t.Fatalf("expected 2 dependencies for this chart, but got %d", len(c.Dependencies())) + } + + if err := processDependencyEnabled(c, c.Values, ""); err != nil { + t.Fatalf("expected no errors but got %q", err) + } + + if len(c.Dependencies()) != 2 { + t.Fatal("expected no changes in dependencies") + } +} + +func TestDependentChartWithSubChartsHelmignore(t *testing.T) { + // FIXME what does this test? + loadChart(t, "testdata/dependent-chart-helmignore") +} + +func TestDependentChartsWithSubChartsSymlink(t *testing.T) { + joonix := filepath.Join("testdata", "joonix") + if err := os.Symlink(filepath.Join("..", "..", "frobnitz"), filepath.Join(joonix, "charts", "frobnitz")); err != nil { + t.Fatal(err) + } + defer os.RemoveAll(filepath.Join(joonix, "charts", "frobnitz")) + c := loadChart(t, joonix) + + if c.Name() != "joonix" { + t.Fatalf("unexpected chart name: %s", c.Name()) + } + if n := len(c.Dependencies()); n != 1 { + t.Fatalf("expected 1 dependency for this chart, but got %d", n) + } +} + +func TestDependentChartsWithSubchartsAllSpecifiedInDependency(t *testing.T) { + c := loadChart(t, "testdata/dependent-chart-with-all-in-requirements-yaml") + + if len(c.Dependencies()) != 2 { + t.Fatalf("expected 2 dependencies for this chart, but got %d", len(c.Dependencies())) + } + + if err := processDependencyEnabled(c, c.Values, ""); err != nil { + t.Fatalf("expected no errors but got %q", err) + } + + if len(c.Dependencies()) != 2 { + t.Fatal("expected no changes in dependencies") + } + + if len(c.Dependencies()) != len(c.Metadata.Dependencies) { + t.Fatalf("expected number of chart dependencies %d, but got %d", len(c.Metadata.Dependencies), len(c.Dependencies())) + } +} + +func TestDependentChartsWithSomeSubchartsSpecifiedInDependency(t *testing.T) { + c := loadChart(t, "testdata/dependent-chart-with-mixed-requirements-yaml") + + if len(c.Dependencies()) != 2 { + t.Fatalf("expected 2 dependencies for this chart, but got %d", len(c.Dependencies())) + } + + if err := processDependencyEnabled(c, c.Values, ""); err != nil { + t.Fatalf("expected no errors but got %q", err) + } + + if len(c.Dependencies()) != 2 { + t.Fatal("expected no changes in dependencies") + } + + if len(c.Metadata.Dependencies) != 1 { + t.Fatalf("expected 1 dependency specified in Chart.yaml, got %d", len(c.Metadata.Dependencies)) + } +} + +func validateDependencyTree(t *testing.T, c *chart.Chart) { + t.Helper() + for _, dependency := range c.Dependencies() { + if dependency.Parent() != c { + if dependency.Parent() != c { + t.Fatalf("dependency chart %s has wrong parent, expected %s but got %s", dependency.Name(), c.Name(), dependency.Parent().Name()) + } + } + // recurse entire tree + validateDependencyTree(t, dependency) + } +} + +func TestChartWithDependencyAliasedTwiceAndDoublyReferencedSubDependency(t *testing.T) { + c := loadChart(t, "testdata/chart-with-dependency-aliased-twice") + + if len(c.Dependencies()) != 1 { + t.Fatalf("expected one dependency for this chart, but got %d", len(c.Dependencies())) + } + + if err := processDependencyEnabled(c, c.Values, ""); err != nil { + t.Fatalf("expected no errors but got %q", err) + } + + if len(c.Dependencies()) != 2 { + t.Fatal("expected two dependencies after processing aliases") + } + validateDependencyTree(t, c) +} diff --git a/pkg/helm/pkg/chart/v2/util/deps_migration_ai_test.go b/pkg/helm/pkg/chart/v2/util/deps_migration_ai_test.go new file mode 100644 index 00000000..04f28158 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/deps_migration_ai_test.go @@ -0,0 +1,57 @@ +//go:build ai_tests + +package util + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" +) + +func TestAI_ProcessDependenciesCallable(t *testing.T) { + parent := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "parent", + Version: "1.0.0", + APIVersion: chart.APIVersionV2, + }, + Values: map[string]interface{}{ + "key": "value", + }, + } + + child := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "child", + Version: "0.1.0", + APIVersion: chart.APIVersionV2, + }, + Values: map[string]interface{}{}, + } + parent.SetDependencies(child) + + vals := map[string]interface{}{ + "key": "value", + } + + err := ProcessDependencies(parent, &vals) + require.NoError(t, err) + assert.NotNil(t, vals) +} + +func TestAI_ProcessDependenciesRejectsNilVals(t *testing.T) { + parent := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "parent", + Version: "1.0.0", + APIVersion: chart.APIVersionV2, + }, + } + + err := ProcessDependencies(parent, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "nil") +} diff --git a/pkg/helm/pkg/chartutil/doc.go b/pkg/helm/pkg/chart/v2/util/doc.go similarity index 86% rename from pkg/helm/pkg/chartutil/doc.go rename to pkg/helm/pkg/chart/v2/util/doc.go index 49c55ac5..32b709b4 100644 --- a/pkg/helm/pkg/chartutil/doc.go +++ b/pkg/helm/pkg/chart/v2/util/doc.go @@ -15,7 +15,7 @@ limitations under the License. */ /* -Package chartutil contains tools for working with charts. +package util contains tools for working with charts. Charts are described in the chart package (pkg/chart). This package provides utilities for serializing and deserializing charts. @@ -39,7 +39,7 @@ For accepting raw compressed tar file data from an io.Reader, the 'loader.LoadArchive()' will read in the data, uncompress it, and unpack it into a Chart. -When creating charts in memory, use the 'helm.sh/helm/pkg/chart' +When creating charts in memory, use the 'github.com/werf/nelm/pkg/helm/pkg/chart' package directly. */ -package chartutil // import "helm.sh/helm/v3/pkg/chartutil" +package util // import chartutil "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" diff --git a/pkg/helm/pkg/chartutil/expand.go b/pkg/helm/pkg/chart/v2/util/expand.go similarity index 81% rename from pkg/helm/pkg/chartutil/expand.go rename to pkg/helm/pkg/chart/v2/util/expand.go index 5dbf6a61..3537867a 100644 --- a/pkg/helm/pkg/chartutil/expand.go +++ b/pkg/helm/pkg/chart/v2/util/expand.go @@ -14,24 +14,25 @@ See the License for the specific language governing permissions and limitations under the License. */ -package chartutil +package util import ( + "errors" + "fmt" "io" "os" "path/filepath" securejoin "github.com/cyphar/filepath-securejoin" - "github.com/pkg/errors" "sigs.k8s.io/yaml" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chart/loader" + "github.com/werf/nelm/pkg/helm/pkg/chart/loader/archive" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" ) // Expand uncompresses and extracts a chart into the specified directory. func Expand(dir string, r io.Reader) error { - files, err := loader.LoadArchiveFiles(r) + files, err := archive.LoadArchiveFiles(r) if err != nil { return err } @@ -42,7 +43,7 @@ func Expand(dir string, r io.Reader) error { if file.Name == "Chart.yaml" { ch := &chart.Metadata{} if err := yaml.Unmarshal(file.Data, ch); err != nil { - return errors.Wrap(err, "cannot load Chart.yaml") + return fmt.Errorf("cannot load Chart.yaml: %w", err) } chartName = ch.Name } @@ -52,6 +53,9 @@ func Expand(dir string, r io.Reader) error { } // Find the base directory + // The directory needs to be cleaned prior to passing to SecureJoin or the location may end up + // being wrong or returning an error. This was introduced in v0.4.0. + dir = filepath.Clean(dir) chartdir, err := securejoin.SecureJoin(dir, chartName) if err != nil { return err diff --git a/pkg/helm/pkg/chart/v2/util/expand_test.go b/pkg/helm/pkg/chart/v2/util/expand_test.go new file mode 100644 index 00000000..280995f7 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/expand_test.go @@ -0,0 +1,124 @@ +/* +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 util + +import ( + "os" + "path/filepath" + "testing" +) + +func TestExpand(t *testing.T) { + dest := t.TempDir() + + reader, err := os.Open("testdata/frobnitz-1.2.3.tgz") + if err != nil { + t.Fatal(err) + } + + if err := Expand(dest, reader); err != nil { + t.Fatal(err) + } + + expectedChartPath := filepath.Join(dest, "frobnitz") + fi, err := os.Stat(expectedChartPath) + if err != nil { + t.Fatal(err) + } + if !fi.IsDir() { + t.Fatalf("expected a chart directory at %s", expectedChartPath) + } + + dir, err := os.Open(expectedChartPath) + if err != nil { + t.Fatal(err) + } + + fis, err := dir.Readdir(0) + if err != nil { + t.Fatal(err) + } + + expectLen := 11 + if len(fis) != expectLen { + t.Errorf("Expected %d files, but got %d", expectLen, len(fis)) + } + + for _, fi := range fis { + expect, err := os.Stat(filepath.Join("testdata", "frobnitz", fi.Name())) + if err != nil { + t.Fatal(err) + } + // os.Stat can return different values for directories, based on the OS + // for Linux, for example, os.Stat always returns the size of the directory + // (value-4096) regardless of the size of the contents of the directory + mode := expect.Mode() + if !mode.IsDir() { + if fi.Size() != expect.Size() { + t.Errorf("Expected %s to have size %d, got %d", fi.Name(), expect.Size(), fi.Size()) + } + } + } +} + +func TestExpandFile(t *testing.T) { + dest := t.TempDir() + + if err := ExpandFile(dest, "testdata/frobnitz-1.2.3.tgz"); err != nil { + t.Fatal(err) + } + + expectedChartPath := filepath.Join(dest, "frobnitz") + fi, err := os.Stat(expectedChartPath) + if err != nil { + t.Fatal(err) + } + if !fi.IsDir() { + t.Fatalf("expected a chart directory at %s", expectedChartPath) + } + + dir, err := os.Open(expectedChartPath) + if err != nil { + t.Fatal(err) + } + + fis, err := dir.Readdir(0) + if err != nil { + t.Fatal(err) + } + + expectLen := 11 + if len(fis) != expectLen { + t.Errorf("Expected %d files, but got %d", expectLen, len(fis)) + } + + for _, fi := range fis { + expect, err := os.Stat(filepath.Join("testdata", "frobnitz", fi.Name())) + if err != nil { + t.Fatal(err) + } + // os.Stat can return different values for directories, based on the OS + // for Linux, for example, os.Stat always returns the size of the directory + // (value-4096) regardless of the size of the contents of the directory + mode := expect.Mode() + if !mode.IsDir() { + if fi.Size() != expect.Size() { + t.Errorf("Expected %s to have size %d, got %d", fi.Name(), expect.Size(), fi.Size()) + } + } + } +} diff --git a/pkg/helm/pkg/chartutil/save.go b/pkg/helm/pkg/chart/v2/util/save.go similarity index 80% rename from pkg/helm/pkg/chartutil/save.go rename to pkg/helm/pkg/chart/v2/util/save.go index 13ea4f28..1cf4b5f7 100644 --- a/pkg/helm/pkg/chartutil/save.go +++ b/pkg/helm/pkg/chart/v2/util/save.go @@ -14,21 +14,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -package chartutil +package util import ( "archive/tar" "compress/gzip" "encoding/json" + "errors" "fmt" + "io/fs" "os" "path/filepath" "time" - "github.com/pkg/errors" "sigs.k8s.io/yaml" - "github.com/werf/nelm/pkg/helm/pkg/chart" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" ) var headerBytes = []byte("+aHR0cHM6Ly95b3V0dS5iZS96OVV6MWljandyTQo=") @@ -37,19 +39,14 @@ func SaveDir(c *chart.Chart, dest string) error { return SaveIntoDir(c, filepath.Join(dest, c.Name())) } -// SaveDir saves a chart as files in a directory. -// -// This takes the chart name, and creates a new subdirectory inside of the given dest -// directory, writing the chart's contents to that subdirectory. func SaveIntoDir(c *chart.Chart, dest string) error { - // Create the chart directory err := validateName(c.Name()) if err != nil { return err } outdir := dest if fi, err := os.Stat(outdir); err == nil && !fi.IsDir() { - return errors.Errorf("file %s already exists and is not a directory", outdir) + return fmt.Errorf("file %s already exists and is not a directory", outdir) } if err := os.MkdirAll(outdir, 0755); err != nil { return err @@ -68,7 +65,7 @@ func SaveIntoDir(c *chart.Chart, dest string) error { } filename := filepath.Join(outdir, "Chart.lock") if err := writeFile(filename, ldata); err != nil { - return fmt.Errorf("error writing %q: %s", filename, err) + return fmt.Errorf("write %q: %w", filename, err) } } } @@ -91,8 +88,7 @@ func SaveIntoDir(c *chart.Chart, dest string) error { } } - // Save templates, files, and runtime files (e.g., ts/ for TypeScript charts) - for _, o := range [][]*chart.File{c.Templates, c.Files, c.RuntimeFiles} { + for _, o := range [][]*common.File{c.Templates, c.Files, c.RuntimeFiles} { for _, f := range o { n := filepath.Join(outdir, f.Name) if err := writeFile(n, f.Data); err != nil { @@ -106,7 +102,7 @@ func SaveIntoDir(c *chart.Chart, dest string) error { for _, dep := range c.Dependencies() { // Here, we write each dependency as a tar file. if _, err := Save(dep, base); err != nil { - return errors.Wrapf(err, "saving %s", dep.ChartFullPath()) + return fmt.Errorf("saving %s: %w", dep.ChartFullPath(), err) } } return nil @@ -122,22 +118,22 @@ func SaveIntoDir(c *chart.Chart, dest string) error { // This returns the absolute path to the chart archive file. func Save(c *chart.Chart, outDir string) (string, error) { if err := c.Validate(); err != nil { - return "", errors.Wrap(err, "chart validation") + return "", fmt.Errorf("chart validation: %w", err) } filename := fmt.Sprintf("%s-%s.tgz", c.Name(), c.Metadata.Version) filename = filepath.Join(outDir, filename) dir := filepath.Dir(filename) if stat, err := os.Stat(dir); err != nil { - if os.IsNotExist(err) { + if errors.Is(err, fs.ErrNotExist) { if err2 := os.MkdirAll(dir, 0755); err2 != nil { return "", err2 } } else { - return "", errors.Wrapf(err, "stat %s", dir) + return "", fmt.Errorf("stat %s: %w", dir, err) } } else if !stat.IsDir() { - return "", errors.Errorf("is not a directory: %s", dir) + return "", fmt.Errorf("is not a directory: %s", dir) } f, err := os.Create(filename) @@ -147,8 +143,8 @@ func Save(c *chart.Chart, outDir string) (string, error) { // Wrap in gzip writer zipper := gzip.NewWriter(f) - zipper.Header.Extra = headerBytes - zipper.Header.Comment = "Helm" + zipper.Extra = headerBytes + zipper.Comment = "Helm" // Wrap in tar writer twriter := tar.NewWriter(zipper) @@ -190,7 +186,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error { if err != nil { return err } - if err := writeToTar(out, filepath.Join(base, ChartfileName), cdata); err != nil { + if err := writeToTar(out, filepath.Join(base, ChartfileName), cdata, c.ModTime); err != nil { return err } @@ -202,7 +198,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error { if err != nil { return err } - if err := writeToTar(out, filepath.Join(base, "Chart.lock"), ldata); err != nil { + if err := writeToTar(out, filepath.Join(base, "Chart.lock"), ldata, c.Lock.Generated); err != nil { return err } } @@ -211,7 +207,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error { // Save values.yaml for _, f := range c.Raw { if f.Name == ValuesfileName { - if err := writeToTar(out, filepath.Join(base, ValuesfileName), f.Data); err != nil { + if err := writeToTar(out, filepath.Join(base, ValuesfileName), f.Data, f.ModTime); err != nil { return err } } @@ -220,9 +216,9 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error { // Save values.schema.json if it exists if c.Schema != nil { if !json.Valid(c.Schema) { - return errors.New("Invalid JSON in " + SchemafileName) + return errors.New("invalid JSON in " + SchemafileName) } - if err := writeToTar(out, filepath.Join(base, SchemafileName), c.Schema); err != nil { + if err := writeToTar(out, filepath.Join(base, SchemafileName), c.Schema, c.SchemaModTime); err != nil { return err } } @@ -230,7 +226,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error { // Save templates for _, f := range c.Templates { n := filepath.Join(base, f.Name) - if err := writeToTar(out, n, f.Data); err != nil { + if err := writeToTar(out, n, f.Data, f.ModTime); err != nil { return err } } @@ -238,15 +234,14 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error { // Save files for _, f := range c.Files { n := filepath.Join(base, f.Name) - if err := writeToTar(out, n, f.Data); err != nil { + if err := writeToTar(out, n, f.Data, f.ModTime); err != nil { return err } } - // Save runtime files (e.g., ts/ directory for TypeScript charts) for _, f := range c.RuntimeFiles { n := filepath.Join(base, f.Name) - if err := writeToTar(out, n, f.Data); err != nil { + if err := writeToTar(out, n, f.Data, f.ModTime); err != nil { return err } } @@ -261,13 +256,16 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error { } // writeToTar writes a single file to a tar archive. -func writeToTar(out *tar.Writer, name string, body []byte) error { +func writeToTar(out *tar.Writer, name string, body []byte, modTime time.Time) error { // TODO: Do we need to create dummy parent directory names if none exist? h := &tar.Header{ Name: filepath.ToSlash(name), Mode: 0644, Size: int64(len(body)), - ModTime: time.Now(), + ModTime: modTime, + } + if h.ModTime.IsZero() { + h.ModTime = time.Now() } if err := out.WriteHeader(h); err != nil { return err @@ -282,7 +280,7 @@ func validateName(name string) error { nname := filepath.Base(name) if nname != name { - return ErrInvalidChartName{name} + return common.ErrInvalidChartName{Name: name} } return nil diff --git a/pkg/helm/pkg/chartutil/save_extended.go b/pkg/helm/pkg/chart/v2/util/save_extended.go similarity index 84% rename from pkg/helm/pkg/chartutil/save_extended.go rename to pkg/helm/pkg/chart/v2/util/save_extended.go index 37ff357d..75416eba 100644 --- a/pkg/helm/pkg/chartutil/save_extended.go +++ b/pkg/helm/pkg/chart/v2/util/save_extended.go @@ -1,10 +1,10 @@ -package chartutil +package util import ( "archive/tar" "compress/gzip" - "github.com/werf/nelm/pkg/helm/pkg/chart" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" ) type SaveIntoTarOptions struct { diff --git a/pkg/helm/pkg/chartutil/save_test.go b/pkg/helm/pkg/chart/v2/util/save_test.go similarity index 62% rename from pkg/helm/pkg/chartutil/save_test.go rename to pkg/helm/pkg/chart/v2/util/save_test.go index 47098aa3..a1335daa 100644 --- a/pkg/helm/pkg/chartutil/save_test.go +++ b/pkg/helm/pkg/chart/v2/util/save_test.go @@ -14,12 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -package chartutil +package util import ( + "context" "archive/tar" "bytes" "compress/gzip" + "crypto/sha256" + "errors" + "fmt" "io" "os" "path" @@ -29,8 +33,9 @@ import ( "testing" "time" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chart/loader" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/loader" ) func TestSave(t *testing.T) { @@ -47,8 +52,8 @@ func TestSave(t *testing.T) { Lock: &chart.Lock{ Digest: "testdigest", }, - Files: []*chart.File{ - {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, + Files: []*common.File{ + {Name: "scheherazade/shahryar.txt", ModTime: time.Now(), Data: []byte("1,001 Nights")}, }, Schema: []byte("{\n \"title\": \"Values\"\n}"), } @@ -65,7 +70,7 @@ func TestSave(t *testing.T) { t.Fatalf("Expected %q to end with .tgz", where) } - c2, err := loader.LoadFile(where) + c2, err := loader.LoadFile(context.Background(), where) if err != nil { t.Fatal(err) } @@ -94,7 +99,7 @@ func TestSave(t *testing.T) { if err != nil { t.Fatalf("Failed to save: %s", err) } - c2, err = loader.LoadFile(where) + c2, err = loader.LoadFile(context.Background(), where) if err != nil { t.Fatal(err) } @@ -116,8 +121,8 @@ func TestSave(t *testing.T) { Lock: &chart.Lock{ Digest: "testdigest", }, - Files: []*chart.File{ - {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, + Files: []*common.File{ + {Name: "scheherazade/shahryar.txt", ModTime: time.Now(), Data: []byte("1,001 Nights")}, }, } _, err := Save(c, tmp) @@ -152,14 +157,16 @@ func TestSavePreservesTimestamps(t *testing.T) { Name: "ahab", Version: "1.2.3", }, + ModTime: initialCreateTime, Values: map[string]interface{}{ "imageName": "testimage", "imageId": 42, }, - Files: []*chart.File{ - {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, + Files: []*common.File{ + {Name: "scheherazade/shahryar.txt", ModTime: initialCreateTime, Data: []byte("1,001 Nights")}, }, - Schema: []byte("{\n \"title\": \"Values\"\n}"), + Schema: []byte("{\n \"title\": \"Values\"\n}"), + SchemaModTime: initialCreateTime, } where, err := Save(c, tmp) @@ -172,8 +179,9 @@ func TestSavePreservesTimestamps(t *testing.T) { t.Fatalf("Failed to parse tar: %v", err) } + roundedTime := initialCreateTime.Round(time.Second) for _, header := range allHeaders { - if header.ModTime.Before(initialCreateTime) { + if !header.ModTime.Equal(roundedTime) { t.Fatalf("File timestamp not preserved: %v", header.ModTime) } } @@ -199,7 +207,7 @@ func retrieveAllHeadersFromTar(path string) ([]*tar.Header, error) { headers := []*tar.Header{} for { hd, err := tr.Next() - if err == io.EOF { + if errors.Is(err, io.EOF) { break } @@ -216,17 +224,18 @@ func retrieveAllHeadersFromTar(path string) ([]*tar.Header, error) { func TestSaveDir(t *testing.T) { tmp := t.TempDir() + modTime := time.Now() c := &chart.Chart{ Metadata: &chart.Metadata{ APIVersion: chart.APIVersionV1, Name: "ahab", Version: "1.2.3", }, - Files: []*chart.File{ - {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, + Files: []*common.File{ + {Name: "scheherazade/shahryar.txt", ModTime: modTime, Data: []byte("1,001 Nights")}, }, - Templates: []*chart.File{ - {Name: path.Join(TemplatesDir, "nested", "dir", "thing.yaml"), Data: []byte("abc: {{ .Values.abc }}")}, + Templates: []*common.File{ + {Name: path.Join(TemplatesDir, "nested", "dir", "thing.yaml"), ModTime: modTime, Data: []byte("abc: {{ .Values.abc }}")}, }, } @@ -234,7 +243,7 @@ func TestSaveDir(t *testing.T) { t.Fatalf("Failed to save: %s", err) } - c2, err := loader.LoadDir(tmp + "/ahab") + c2, err := loader.LoadDir(context.Background(), tmp + "/ahab") if err != nil { t.Fatal(err) } @@ -262,3 +271,92 @@ func TestSaveDir(t *testing.T) { t.Fatalf("Did not get expected error for chart named %q", c.Name()) } } + +func TestRepeatableSave(t *testing.T) { + tmp := t.TempDir() + defer os.RemoveAll(tmp) + modTime := time.Date(2021, 9, 1, 20, 34, 58, 651387237, time.UTC) + tests := []struct { + name string + chart *chart.Chart + want string + }{ + { + name: "Package 1 file", + chart: &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV2, + Name: "ahab", + Version: "1.2.3", + }, + ModTime: modTime, + Lock: &chart.Lock{ + Digest: "testdigest", + Generated: modTime, + }, + Files: []*common.File{ + {Name: "scheherazade/shahryar.txt", ModTime: modTime, Data: []byte("1,001 Nights")}, + }, + Schema: []byte("{\n \"title\": \"Values\"\n}"), + SchemaModTime: modTime, + }, + want: "fea2662522317b65c2788ff9e5fc446a9264830038dac618d4449493d99b3257", + }, + { + name: "Package 2 files", + chart: &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: chart.APIVersionV2, + Name: "ahab", + Version: "1.2.3", + }, + ModTime: modTime, + Lock: &chart.Lock{ + Digest: "testdigest", + Generated: modTime, + }, + Files: []*common.File{ + {Name: "scheherazade/shahryar.txt", ModTime: modTime, Data: []byte("1,001 Nights")}, + {Name: "scheherazade/dunyazad.txt", ModTime: modTime, Data: []byte("1,001 Nights again")}, + }, + Schema: []byte("{\n \"title\": \"Values\"\n}"), + SchemaModTime: modTime, + }, + want: "7ae92b2f274bb51ea3f1969e4187d78cc52b5f6f663b44b8fb3b40bcb8ee46f3", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // create package + dest := path.Join(tmp, "newdir") + where, err := Save(test.chart, dest) + if err != nil { + t.Fatalf("Failed to save: %s", err) + } + // get shasum for package + result, err := sha256Sum(where) + if err != nil { + t.Fatalf("Failed to check shasum: %s", err) + } + // assert that the package SHA is what we wanted. + if result != test.want { + t.Errorf("FormatName() result = %v, want %v", result, test.want) + } + }) + } +} + +func sha256Sum(filePath string) (string, error) { + f, err := os.Open(filePath) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + + return fmt.Sprintf("%x", h.Sum(nil)), nil +} diff --git a/pkg/helm/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/Chart.yaml new file mode 100644 index 00000000..d778f8fe --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/Chart.yaml @@ -0,0 +1,14 @@ +apiVersion: v2 +appVersion: 1.0.0 +name: chart-with-dependency-aliased-twice +type: application +version: 1.0.0 + +dependencies: + - name: child + alias: foo + version: 1.0.0 + - name: child + alias: bar + version: 1.0.0 + diff --git a/pkg/helm/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/Chart.yaml new file mode 100644 index 00000000..220fda66 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +appVersion: 1.0.0 +name: child +type: application +version: 1.0.0 + diff --git a/pkg/helm/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/Chart.yaml new file mode 100644 index 00000000..50e620a8 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +appVersion: 1.0.0 +name: grandchild +type: application +version: 1.0.0 + diff --git a/pkg/helm/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/templates/dummy.yaml b/pkg/helm/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/templates/dummy.yaml new file mode 100644 index 00000000..1830492e --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/templates/dummy.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Chart.Name }}-{{ .Values.from }} +data: + {{- toYaml .Values | nindent 2 }} + diff --git a/pkg/helm/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/templates/dummy.yaml b/pkg/helm/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/templates/dummy.yaml new file mode 100644 index 00000000..b5d55af7 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/templates/dummy.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Chart.Name }} +data: + {{- toYaml .Values | nindent 2 }} + diff --git a/pkg/helm/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/values.yaml new file mode 100644 index 00000000..695521a4 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/values.yaml @@ -0,0 +1,7 @@ +foo: + grandchild: + from: foo +bar: + grandchild: + from: bar + diff --git a/pkg/helm/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/Chart.yaml new file mode 100644 index 00000000..c408f0ca --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/Chart.yaml @@ -0,0 +1,20 @@ +apiVersion: v2 +appVersion: 1.0.0 +name: chart-with-dependency-aliased-twice +type: application +version: 1.0.0 + +dependencies: + - name: child + alias: foo + version: 1.0.0 + import-values: + - parent: foo-defaults + child: defaults + - name: child + alias: bar + version: 1.0.0 + import-values: + - parent: bar-defaults + child: defaults + diff --git a/pkg/helm/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/Chart.yaml new file mode 100644 index 00000000..ecdaf04d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/Chart.yaml @@ -0,0 +1,12 @@ +apiVersion: v2 +appVersion: 1.0.0 +name: child +type: application +version: 1.0.0 + +dependencies: + - name: grandchild + version: 1.0.0 + import-values: + - parent: defaults + child: defaults diff --git a/pkg/helm/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/Chart.yaml new file mode 100644 index 00000000..50e620a8 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +appVersion: 1.0.0 +name: grandchild +type: application +version: 1.0.0 + diff --git a/pkg/helm/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/values.yaml new file mode 100644 index 00000000..f51c594f --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/values.yaml @@ -0,0 +1,2 @@ +defaults: + defaultValue: "42" \ No newline at end of file diff --git a/pkg/helm/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/templates/dummy.yaml b/pkg/helm/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/templates/dummy.yaml new file mode 100644 index 00000000..3140f53d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/templates/dummy.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Chart.Name }} +data: + {{ .Values.defaults | toYaml }} + diff --git a/pkg/helm/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/templates/dummy.yaml b/pkg/helm/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/templates/dummy.yaml new file mode 100644 index 00000000..a2b62c95 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/templates/dummy.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Chart.Name }} +data: + {{ toYaml .Values.defaults | indent 2 }} + diff --git a/pkg/helm/pkg/chartutil/testdata/chartfiletest.yaml b/pkg/helm/pkg/chart/v2/util/testdata/chartfiletest.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/chartfiletest.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/chartfiletest.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/.helmignore b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/.helmignore new file mode 100644 index 00000000..9973a57b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/.helmignore @@ -0,0 +1 @@ +ignore/ diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/Chart.lock b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/Chart.lock new file mode 100644 index 00000000..6fcc2ed9 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/Chart.lock @@ -0,0 +1,8 @@ +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts +digest: invalid diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/INSTALL.txt b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/INSTALL.txt new file mode 100644 index 00000000..2010438c --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/INSTALL.txt @@ -0,0 +1 @@ +This is an install document. The client may display this. diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/LICENSE b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/LICENSE new file mode 100644 index 00000000..6121943b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/LICENSE @@ -0,0 +1 @@ +LICENSE placeholder. diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/README.md b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/README.md new file mode 100644 index 00000000..8cf4cc3d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/README.md @@ -0,0 +1,11 @@ +# Frobnitz + +This is an example chart. + +## Usage + +This is an example. It has no usage. + +## Development + +For developer info, see the top-level repository. diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/_ignore_me b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/_ignore_me new file mode 100644 index 00000000..2cecca68 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/_ignore_me @@ -0,0 +1 @@ +This should be ignored by the loader, but may be included in a chart. diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/README.md b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/README.md new file mode 100644 index 00000000..b30b949d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.toml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/charts/mast1/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/charts/mast1/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/charts/mast1/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/charts/mast1/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/charts/mast1/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/charts/mast1/values.yaml new file mode 100644 index 00000000..42c39c26 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/charts/mast1/values.yaml @@ -0,0 +1,4 @@ +# Default values for mast1. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name = "value" diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/charts/mast2-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..61cb62051110b55f3d08213dc81dcf0b1c2d8e53 GIT binary patch literal 252 zcmVDc zVQyr3R8em|NM&qo0PNJUs=_c72H?(liabH@pWIst-7YSIyL+rhEHrIN(t?QZE=F{y zgNRfS&$pa5Ly`mMk2OB%pV`*9knW7FlL-Joo@KED7*{C$d;N~z z37$S{+}wvSU9}|VtF|fRpv9Ve>8dWo|9?5B+RE}Y9CFh-x#(Bq8Vck^V=NUiPLCKa z8z5CF#JgK!4>;$4Fm+FUst4d+{(+nP|0&J+e}(;l^U4@w-{=?s0RR8vgVbLD3;+OM Cs&R<` literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/templates/alpine-pod.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/templates/alpine-pod.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/values.yaml new file mode 100644 index 00000000..6c2aab7b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: "my-alpine" diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/mariner-4.3.2.tgz b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/mariner-4.3.2.tgz new file mode 100644 index 0000000000000000000000000000000000000000..3190136b050e62c628b3c817fd963ac9dc4a9e25 GIT binary patch literal 967 zcmV;&133I2iwFR+9h6)E1MQb_y%a`Bd>#= zhbqf2w!b6}wZ9@rvIhtwzm?~C&+QLm+G2!l%`$_aUgS(@pdjdX3a%E}VXVc7`)dg( zL%IRN2}c1D3xoMi2w@WuWOMZ?5i&3FelBVyq_Ce_8fREdP%NDf<&d!wu3{&VUQNs{N%vK$Ha~k^gB2!0bO7r0ic0 zbqCp*X#j?=|H@GNONsuE)&Idbh>2MX(Z}|+=@*sLod*wS?A8Ugp#mMPuMO0NnZmos9_rr z3xpDL+otj~lRh?B4hHFTl+d5-8NBXmUXB~EyF^bx7m*+!*g>obczvGF|8xkWsHN8; z%#+wiWP{=2pC*98@$VNzzsll&G#D7&11-;D>HT0x|DV2?6}a~*p46@R|2l??e_8dX z@Bb>D3t~VC_*wjq2Dy!6J-_5ME%%J+xmsbK5a#qO>ZV?Wt`d|TDdrB^B&LqFr@lYdUA zs&4-JAg3&uc4dA0gv*bXU9QePa9^8wR{HovA)j@)JOAIkak0Jl)aKqA_3XLM#?H=V z-{tlG=xy^os=1Z><53;&+C4=zp|%rwv!)UyY=%k_t%Y|wNRQl`C6N_Z&S;S+~wb1=)2Uh_4I81`y>DO zoLPSt=btB&x{CUK`0DA&){efW_O36RxnsYZNT0r;JbTLR{H4M3C$8(Cee==aQ~Gbq p$`5v3?NB{4-j0XQHf literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/docs/README.md b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/docs/README.md new file mode 100644 index 00000000..d40747ca --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/docs/README.md @@ -0,0 +1 @@ +This is a placeholder for documentation. diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/icon.svg b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/icon.svg new file mode 100644 index 00000000..89213060 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/icon.svg @@ -0,0 +1,8 @@ + + + Example icon + + + diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/ignore/me.txt b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/ignore/me.txt new file mode 100644 index 00000000..e69de29b diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/templates/template.tpl b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/templates/template.tpl new file mode 100644 index 00000000..c651ee6a --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/templates/template.tpl @@ -0,0 +1 @@ +Hello {{.Name | default "world"}} diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/values.yaml new file mode 100644 index 00000000..61f50125 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-alias/values.yaml @@ -0,0 +1,6 @@ +# A values file contains configuration. + +name: "Some Name" + +section: + name: "Name in a section" diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/.helmignore b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/.helmignore new file mode 100644 index 00000000..8a71bc82 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/.helmignore @@ -0,0 +1,2 @@ +ignore/ +.* diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/.ignore_me b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/.ignore_me new file mode 100644 index 00000000..e69de29b diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/_ignore_me b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/_ignore_me new file mode 100644 index 00000000..2cecca68 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/_ignore_me @@ -0,0 +1 @@ +This should be ignored by the loader, but may be included in a chart. diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/README.md b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/README.md new file mode 100644 index 00000000..b30b949d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.toml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/values.yaml new file mode 100644 index 00000000..42c39c26 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/values.yaml @@ -0,0 +1,4 @@ +# Default values for mast1. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name = "value" diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast2-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..61cb62051110b55f3d08213dc81dcf0b1c2d8e53 GIT binary patch literal 252 zcmVDc zVQyr3R8em|NM&qo0PNJUs=_c72H?(liabH@pWIst-7YSIyL+rhEHrIN(t?QZE=F{y zgNRfS&$pa5Ly`mMk2OB%pV`*9knW7FlL-Joo@KED7*{C$d;N~z z37$S{+}wvSU9}|VtF|fRpv9Ve>8dWo|9?5B+RE}Y9CFh-x#(Bq8Vck^V=NUiPLCKa z8z5CF#JgK!4>;$4Fm+FUst4d+{(+nP|0&J+e}(;l^U4@w-{=?s0RR8vgVbLD3;+OM Cs&R<` literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/templates/alpine-pod.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..5bbae10a --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service}} + chartName: {{.Chart.Name}} + chartVersion: {{.Chart.Version | quote}} +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.3" + command: ["/bin/sleep","9000"] diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/values.yaml new file mode 100644 index 00000000..6c2aab7b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: "my-alpine" diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/templates/template.tpl b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/templates/template.tpl new file mode 100644 index 00000000..c651ee6a --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/templates/template.tpl @@ -0,0 +1 @@ +Hello {{.Name | default "world"}} diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/values.yaml new file mode 100644 index 00000000..61f50125 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-helmignore/values.yaml @@ -0,0 +1,6 @@ +# A values file contains configuration. + +name: "Some Name" + +section: + name: "Name in a section" diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/.helmignore b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/.helmignore new file mode 100644 index 00000000..9973a57b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/.helmignore @@ -0,0 +1 @@ +ignore/ diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/INSTALL.txt b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/INSTALL.txt new file mode 100644 index 00000000..2010438c --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/INSTALL.txt @@ -0,0 +1 @@ +This is an install document. The client may display this. diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/LICENSE b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/LICENSE new file mode 100644 index 00000000..6121943b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/LICENSE @@ -0,0 +1 @@ +LICENSE placeholder. diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/README.md b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/README.md new file mode 100644 index 00000000..8cf4cc3d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/README.md @@ -0,0 +1,11 @@ +# Frobnitz + +This is an example chart. + +## Usage + +This is an example. It has no usage. + +## Development + +For developer info, see the top-level repository. diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/_ignore_me b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/_ignore_me new file mode 100644 index 00000000..2cecca68 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/_ignore_me @@ -0,0 +1 @@ +This should be ignored by the loader, but may be included in a chart. diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/README.md b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/README.md new file mode 100644 index 00000000..b30b949d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.toml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/values.yaml new file mode 100644 index 00000000..42c39c26 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/values.yaml @@ -0,0 +1,4 @@ +# Default values for mast1. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name = "value" diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..61cb62051110b55f3d08213dc81dcf0b1c2d8e53 GIT binary patch literal 252 zcmVDc zVQyr3R8em|NM&qo0PNJUs=_c72H?(liabH@pWIst-7YSIyL+rhEHrIN(t?QZE=F{y zgNRfS&$pa5Ly`mMk2OB%pV`*9knW7FlL-Joo@KED7*{C$d;N~z z37$S{+}wvSU9}|VtF|fRpv9Ve>8dWo|9?5B+RE}Y9CFh-x#(Bq8Vck^V=NUiPLCKa z8z5CF#JgK!4>;$4Fm+FUst4d+{(+nP|0&J+e}(;l^U4@w-{=?s0RR8vgVbLD3;+OM Cs&R<` literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/templates/alpine-pod.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..5bbae10a --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service}} + chartName: {{.Chart.Name}} + chartVersion: {{.Chart.Version | quote}} +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.3" + command: ["/bin/sleep","9000"] diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/values.yaml new file mode 100644 index 00000000..6c2aab7b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: "my-alpine" diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/mariner-4.3.2.tgz b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/mariner-4.3.2.tgz new file mode 100644 index 0000000000000000000000000000000000000000..3190136b050e62c628b3c817fd963ac9dc4a9e25 GIT binary patch literal 967 zcmV;&133I2iwFR+9h6)E1MQb_y%a`Bd>#= zhbqf2w!b6}wZ9@rvIhtwzm?~C&+QLm+G2!l%`$_aUgS(@pdjdX3a%E}VXVc7`)dg( zL%IRN2}c1D3xoMi2w@WuWOMZ?5i&3FelBVyq_Ce_8fREdP%NDf<&d!wu3{&VUQNs{N%vK$Ha~k^gB2!0bO7r0ic0 zbqCp*X#j?=|H@GNONsuE)&Idbh>2MX(Z}|+=@*sLod*wS?A8Ugp#mMPuMO0NnZmos9_rr z3xpDL+otj~lRh?B4hHFTl+d5-8NBXmUXB~EyF^bx7m*+!*g>obczvGF|8xkWsHN8; z%#+wiWP{=2pC*98@$VNzzsll&G#D7&11-;D>HT0x|DV2?6}a~*p46@R|2l??e_8dX z@Bb>D3t~VC_*wjq2Dy!6J-_5ME%%J+xmsbK5a#qO>ZV?Wt`d|TDdrB^B&LqFr@lYdUA zs&4-JAg3&uc4dA0gv*bXU9QePa9^8wR{HovA)j@)JOAIkak0Jl)aKqA_3XLM#?H=V z-{tlG=xy^os=1Z><53;&+C4=zp|%rwv!)UyY=%k_t%Y|wNRQl`C6N_Z&S;S+~wb1=)2Uh_4I81`y>DO zoLPSt=btB&x{CUK`0DA&){efW_O36RxnsYZNT0r;JbTLR{H4M3C$8(Cee==aQ~Gbq p$`5v3?NB{4-j0XQHf literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/docs/README.md b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/docs/README.md new file mode 100644 index 00000000..d40747ca --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/docs/README.md @@ -0,0 +1 @@ +This is a placeholder for documentation. diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/icon.svg b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/icon.svg new file mode 100644 index 00000000..89213060 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/icon.svg @@ -0,0 +1,8 @@ + + + Example icon + + + diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/ignore/me.txt b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/ignore/me.txt new file mode 100644 index 00000000..e69de29b diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/templates/template.tpl b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/templates/template.tpl new file mode 100644 index 00000000..c651ee6a --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/templates/template.tpl @@ -0,0 +1 @@ +Hello {{.Name | default "world"}} diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/values.yaml new file mode 100644 index 00000000..61f50125 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/values.yaml @@ -0,0 +1,6 @@ +# A values file contains configuration. + +name: "Some Name" + +section: + name: "Name in a section" diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/.helmignore b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/.helmignore new file mode 100644 index 00000000..9973a57b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/.helmignore @@ -0,0 +1 @@ +ignore/ diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/INSTALL.txt b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/INSTALL.txt new file mode 100644 index 00000000..2010438c --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/INSTALL.txt @@ -0,0 +1 @@ +This is an install document. The client may display this. diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/LICENSE b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/LICENSE new file mode 100644 index 00000000..6121943b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/LICENSE @@ -0,0 +1 @@ +LICENSE placeholder. diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/README.md b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/README.md new file mode 100644 index 00000000..8cf4cc3d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/README.md @@ -0,0 +1,11 @@ +# Frobnitz + +This is an example chart. + +## Usage + +This is an example. It has no usage. + +## Development + +For developer info, see the top-level repository. diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/_ignore_me b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/_ignore_me new file mode 100644 index 00000000..2cecca68 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/_ignore_me @@ -0,0 +1 @@ +This should be ignored by the loader, but may be included in a chart. diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/README.md b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/README.md new file mode 100644 index 00000000..b30b949d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.toml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/values.yaml new file mode 100644 index 00000000..42c39c26 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/values.yaml @@ -0,0 +1,4 @@ +# Default values for mast1. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name = "value" diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..61cb62051110b55f3d08213dc81dcf0b1c2d8e53 GIT binary patch literal 252 zcmVDc zVQyr3R8em|NM&qo0PNJUs=_c72H?(liabH@pWIst-7YSIyL+rhEHrIN(t?QZE=F{y zgNRfS&$pa5Ly`mMk2OB%pV`*9knW7FlL-Joo@KED7*{C$d;N~z z37$S{+}wvSU9}|VtF|fRpv9Ve>8dWo|9?5B+RE}Y9CFh-x#(Bq8Vck^V=NUiPLCKa z8z5CF#JgK!4>;$4Fm+FUst4d+{(+nP|0&J+e}(;l^U4@w-{=?s0RR8vgVbLD3;+OM Cs&R<` literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/templates/alpine-pod.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..5bbae10a --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service}} + chartName: {{.Chart.Name}} + chartVersion: {{.Chart.Version | quote}} +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.3" + command: ["/bin/sleep","9000"] diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/values.yaml new file mode 100644 index 00000000..6c2aab7b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: "my-alpine" diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/mariner-4.3.2.tgz b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/mariner-4.3.2.tgz new file mode 100644 index 0000000000000000000000000000000000000000..3190136b050e62c628b3c817fd963ac9dc4a9e25 GIT binary patch literal 967 zcmV;&133I2iwFR+9h6)E1MQb_y%a`Bd>#= zhbqf2w!b6}wZ9@rvIhtwzm?~C&+QLm+G2!l%`$_aUgS(@pdjdX3a%E}VXVc7`)dg( zL%IRN2}c1D3xoMi2w@WuWOMZ?5i&3FelBVyq_Ce_8fREdP%NDf<&d!wu3{&VUQNs{N%vK$Ha~k^gB2!0bO7r0ic0 zbqCp*X#j?=|H@GNONsuE)&Idbh>2MX(Z}|+=@*sLod*wS?A8Ugp#mMPuMO0NnZmos9_rr z3xpDL+otj~lRh?B4hHFTl+d5-8NBXmUXB~EyF^bx7m*+!*g>obczvGF|8xkWsHN8; z%#+wiWP{=2pC*98@$VNzzsll&G#D7&11-;D>HT0x|DV2?6}a~*p46@R|2l??e_8dX z@Bb>D3t~VC_*wjq2Dy!6J-_5ME%%J+xmsbK5a#qO>ZV?Wt`d|TDdrB^B&LqFr@lYdUA zs&4-JAg3&uc4dA0gv*bXU9QePa9^8wR{HovA)j@)JOAIkak0Jl)aKqA_3XLM#?H=V z-{tlG=xy^os=1Z><53;&+C4=zp|%rwv!)UyY=%k_t%Y|wNRQl`C6N_Z&S;S+~wb1=)2Uh_4I81`y>DO zoLPSt=btB&x{CUK`0DA&){efW_O36RxnsYZNT0r;JbTLR{H4M3C$8(Cee==aQ~Gbq p$`5v3?NB{4-j0XQHf literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/docs/README.md b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/docs/README.md new file mode 100644 index 00000000..d40747ca --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/docs/README.md @@ -0,0 +1 @@ +This is a placeholder for documentation. diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/icon.svg b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/icon.svg new file mode 100644 index 00000000..89213060 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/icon.svg @@ -0,0 +1,8 @@ + + + Example icon + + + diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/ignore/me.txt b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/ignore/me.txt new file mode 100644 index 00000000..e69de29b diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/templates/template.tpl b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/templates/template.tpl new file mode 100644 index 00000000..c651ee6a --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/templates/template.tpl @@ -0,0 +1 @@ +Hello {{.Name | default "world"}} diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/values.yaml new file mode 100644 index 00000000..61f50125 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/values.yaml @@ -0,0 +1,6 @@ +# A values file contains configuration. + +name: "Some Name" + +section: + name: "Name in a section" diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/.helmignore b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/.helmignore new file mode 100644 index 00000000..9973a57b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/.helmignore @@ -0,0 +1 @@ +ignore/ diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/INSTALL.txt b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/INSTALL.txt new file mode 100644 index 00000000..2010438c --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/INSTALL.txt @@ -0,0 +1 @@ +This is an install document. The client may display this. diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/LICENSE b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/LICENSE new file mode 100644 index 00000000..6121943b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/LICENSE @@ -0,0 +1 @@ +LICENSE placeholder. diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/README.md b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/README.md new file mode 100644 index 00000000..8cf4cc3d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/README.md @@ -0,0 +1,11 @@ +# Frobnitz + +This is an example chart. + +## Usage + +This is an example. It has no usage. + +## Development + +For developer info, see the top-level repository. diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/_ignore_me b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/_ignore_me new file mode 100644 index 00000000..2cecca68 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/_ignore_me @@ -0,0 +1 @@ +This should be ignored by the loader, but may be included in a chart. diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/README.md b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/README.md new file mode 100644 index 00000000..b30b949d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.toml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/values.yaml new file mode 100644 index 00000000..42c39c26 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/values.yaml @@ -0,0 +1,4 @@ +# Default values for mast1. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name = "value" diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..61cb62051110b55f3d08213dc81dcf0b1c2d8e53 GIT binary patch literal 252 zcmVDc zVQyr3R8em|NM&qo0PNJUs=_c72H?(liabH@pWIst-7YSIyL+rhEHrIN(t?QZE=F{y zgNRfS&$pa5Ly`mMk2OB%pV`*9knW7FlL-Joo@KED7*{C$d;N~z z37$S{+}wvSU9}|VtF|fRpv9Ve>8dWo|9?5B+RE}Y9CFh-x#(Bq8Vck^V=NUiPLCKa z8z5CF#JgK!4>;$4Fm+FUst4d+{(+nP|0&J+e}(;l^U4@w-{=?s0RR8vgVbLD3;+OM Cs&R<` literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/templates/alpine-pod.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..5bbae10a --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service}} + chartName: {{.Chart.Name}} + chartVersion: {{.Chart.Version | quote}} +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.3" + command: ["/bin/sleep","9000"] diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/values.yaml new file mode 100644 index 00000000..6c2aab7b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: "my-alpine" diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/mariner-4.3.2.tgz b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/mariner-4.3.2.tgz new file mode 100644 index 0000000000000000000000000000000000000000..3190136b050e62c628b3c817fd963ac9dc4a9e25 GIT binary patch literal 967 zcmV;&133I2iwFR+9h6)E1MQb_y%a`Bd>#= zhbqf2w!b6}wZ9@rvIhtwzm?~C&+QLm+G2!l%`$_aUgS(@pdjdX3a%E}VXVc7`)dg( zL%IRN2}c1D3xoMi2w@WuWOMZ?5i&3FelBVyq_Ce_8fREdP%NDf<&d!wu3{&VUQNs{N%vK$Ha~k^gB2!0bO7r0ic0 zbqCp*X#j?=|H@GNONsuE)&Idbh>2MX(Z}|+=@*sLod*wS?A8Ugp#mMPuMO0NnZmos9_rr z3xpDL+otj~lRh?B4hHFTl+d5-8NBXmUXB~EyF^bx7m*+!*g>obczvGF|8xkWsHN8; z%#+wiWP{=2pC*98@$VNzzsll&G#D7&11-;D>HT0x|DV2?6}a~*p46@R|2l??e_8dX z@Bb>D3t~VC_*wjq2Dy!6J-_5ME%%J+xmsbK5a#qO>ZV?Wt`d|TDdrB^B&LqFr@lYdUA zs&4-JAg3&uc4dA0gv*bXU9QePa9^8wR{HovA)j@)JOAIkak0Jl)aKqA_3XLM#?H=V z-{tlG=xy^os=1Z><53;&+C4=zp|%rwv!)UyY=%k_t%Y|wNRQl`C6N_Z&S;S+~wb1=)2Uh_4I81`y>DO zoLPSt=btB&x{CUK`0DA&){efW_O36RxnsYZNT0r;JbTLR{H4M3C$8(Cee==aQ~Gbq p$`5v3?NB{4-j0XQHf literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/docs/README.md b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/docs/README.md new file mode 100644 index 00000000..d40747ca --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/docs/README.md @@ -0,0 +1 @@ +This is a placeholder for documentation. diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/icon.svg b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/icon.svg new file mode 100644 index 00000000..89213060 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/icon.svg @@ -0,0 +1,8 @@ + + + Example icon + + + diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/ignore/me.txt b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/ignore/me.txt new file mode 100644 index 00000000..e69de29b diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/templates/template.tpl b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/templates/template.tpl new file mode 100644 index 00000000..c651ee6a --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/templates/template.tpl @@ -0,0 +1 @@ +Hello {{.Name | default "world"}} diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/values.yaml new file mode 100644 index 00000000..61f50125 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/values.yaml @@ -0,0 +1,6 @@ +# A values file contains configuration. + +name: "Some Name" + +section: + name: "Name in a section" diff --git a/pkg/helm/pkg/repo/testdata/repository/frobnitz-1.2.3.tgz b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz-1.2.3.tgz similarity index 100% rename from pkg/helm/pkg/repo/testdata/repository/frobnitz-1.2.3.tgz rename to pkg/helm/pkg/chart/v2/util/testdata/frobnitz-1.2.3.tgz diff --git a/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/.helmignore b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/.helmignore new file mode 100644 index 00000000..9973a57b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/.helmignore @@ -0,0 +1 @@ +ignore/ diff --git a/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/Chart.lock b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/Chart.lock new file mode 100644 index 00000000..6fcc2ed9 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/Chart.lock @@ -0,0 +1,8 @@ +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts +digest: invalid diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/frobnitz/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/INSTALL.txt b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/INSTALL.txt new file mode 100644 index 00000000..2010438c --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/INSTALL.txt @@ -0,0 +1 @@ +This is an install document. The client may display this. diff --git a/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/LICENSE b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/LICENSE new file mode 100644 index 00000000..6121943b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/LICENSE @@ -0,0 +1 @@ +LICENSE placeholder. diff --git a/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/README.md b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/README.md new file mode 100644 index 00000000..8cf4cc3d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/README.md @@ -0,0 +1,11 @@ +# Frobnitz + +This is an example chart. + +## Usage + +This is an example. It has no usage. + +## Development + +For developer info, see the top-level repository. diff --git a/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/_ignore_me b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/_ignore_me new file mode 100644 index 00000000..2cecca68 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/_ignore_me @@ -0,0 +1 @@ +This should be ignored by the loader, but may be included in a chart. diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz/charts/alpine/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz/charts/alpine/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/README.md b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/README.md new file mode 100644 index 00000000..b30b949d --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.toml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml new file mode 100644 index 00000000..42c39c26 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml @@ -0,0 +1,4 @@ +# Default values for mast1. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name = "value" diff --git a/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..61cb62051110b55f3d08213dc81dcf0b1c2d8e53 GIT binary patch literal 252 zcmVDc zVQyr3R8em|NM&qo0PNJUs=_c72H?(liabH@pWIst-7YSIyL+rhEHrIN(t?QZE=F{y zgNRfS&$pa5Ly`mMk2OB%pV`*9knW7FlL-Joo@KED7*{C$d;N~z z37$S{+}wvSU9}|VtF|fRpv9Ve>8dWo|9?5B+RE}Y9CFh-x#(Bq8Vck^V=NUiPLCKa z8z5CF#JgK!4>;$4Fm+FUst4d+{(+nP|0&J+e}(;l^U4@w-{=?s0RR8vgVbLD3;+OM Cs&R<` literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..5bbae10a --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service}} + chartName: {{.Chart.Name}} + chartVersion: {{.Chart.Version | quote}} +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.3" + command: ["/bin/sleep","9000"] diff --git a/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/values.yaml new file mode 100644 index 00000000..6c2aab7b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: "my-alpine" diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz/charts/mariner/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz/charts/mariner/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/Chart.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/frobnitz/charts/mariner/charts/albatross/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/charts/albatross/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/frobnitz/charts/mariner/charts/albatross/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/charts/albatross/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/charts/albatross/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/charts/albatross/values.yaml new file mode 100644 index 00000000..3121cd7c --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/charts/albatross/values.yaml @@ -0,0 +1,4 @@ +albatross: "true" + +global: + author: Coleridge diff --git a/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/templates/placeholder.tpl b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/templates/placeholder.tpl new file mode 100644 index 00000000..29c11843 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/templates/placeholder.tpl @@ -0,0 +1 @@ +# This is a placeholder. diff --git a/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/values.yaml new file mode 100644 index 00000000..b0ccb008 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/values.yaml @@ -0,0 +1,7 @@ +# Default values for . +# This is a YAML-formatted file. https://github.com/toml-lang/toml +# Declare name/value pairs to be passed into your templates. +# name: "value" + +: + test: true diff --git a/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/docs/README.md b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/docs/README.md new file mode 100644 index 00000000..d40747ca --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/docs/README.md @@ -0,0 +1 @@ +This is a placeholder for documentation. diff --git a/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/icon.svg b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/icon.svg new file mode 100644 index 00000000..89213060 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/icon.svg @@ -0,0 +1,8 @@ + + + Example icon + + + diff --git a/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/ignore/me.txt b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/ignore/me.txt new file mode 100644 index 00000000..e69de29b diff --git a/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/templates/template.tpl b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/templates/template.tpl new file mode 100644 index 00000000..c651ee6a --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/templates/template.tpl @@ -0,0 +1 @@ +Hello {{.Name | default "world"}} diff --git a/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/values.yaml new file mode 100644 index 00000000..61f50125 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz/values.yaml @@ -0,0 +1,6 @@ +# A values file contains configuration. + +name: "Some Name" + +section: + name: "Name in a section" diff --git a/pkg/helm/pkg/chart/v2/util/testdata/frobnitz_backslash-1.2.3.tgz b/pkg/helm/pkg/chart/v2/util/testdata/frobnitz_backslash-1.2.3.tgz new file mode 100644 index 0000000000000000000000000000000000000000..6929659514d35d2a2fe1098c1c097194378cc3b5 GIT binary patch literal 3496 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 WNRS{wf&>XZ!T$k(as0gicmMzx5%RPE literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/chart/v2/util/testdata/genfrob.sh b/pkg/helm/pkg/chart/v2/util/testdata/genfrob.sh new file mode 100755 index 00000000..35fdd59f --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/genfrob.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +# Pack the albatross chart into the mariner chart. +echo "Packing albatross into mariner" +tar -zcvf mariner/charts/albatross-0.1.0.tgz albatross + +echo "Packing mariner into frobnitz" +tar -zcvf frobnitz/charts/mariner-4.3.2.tgz mariner +tar -zcvf frobnitz_backslash/charts/mariner-4.3.2.tgz mariner + +# Pack the frobnitz chart. +echo "Packing frobnitz" +tar --exclude=ignore/* -zcvf frobnitz-1.2.3.tgz frobnitz +tar --exclude=ignore/* -zcvf frobnitz_backslash-1.2.3.tgz frobnitz_backslash diff --git a/pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/Chart.lock b/pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/Chart.lock new file mode 100644 index 00000000..b2f17fb3 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/Chart.lock @@ -0,0 +1,9 @@ +dependencies: +- name: dev + repository: file://envs/dev + version: v0.1.0 +- name: prod + repository: file://envs/prod + version: v0.1.0 +digest: sha256:9403fc24f6cf9d6055820126cf7633b4bd1fed3c77e4880c674059f536346182 +generated: "2020-02-03T10:38:51.180474+01:00" diff --git a/pkg/helm/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/Chart.yaml similarity index 96% rename from pkg/helm/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/Chart.yaml index a6544ce6..24b26d9e 100644 --- a/pkg/helm/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/Chart.yaml +++ b/pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/Chart.yaml @@ -19,4 +19,4 @@ dependencies: tags: - prod import-values: - - data + - data \ No newline at end of file diff --git a/pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/charts/dev-v0.1.0.tgz b/pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/charts/dev-v0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..d28e1621c86a56affb0617a912930d982ee5d09c GIT binary patch literal 333 zcmV-T0kZxdiwG0|00000|0w_~VMtOiV@ORlOnEsqVl!4SWK%V1T2nbTPgYhoO;>Dc zVQyr3R8em|NM&qo0PK}PYr`-Mg>%lY5bWGeZqs!5+TB+M-CZQ2GbE0Y9n>MvEZ~_~beXUgrQc z1sW=VuODc zVQyr3R8em|NM&qo0PK~)YJ)%!hCTZf13f1l6W36$d4NhGy$?F13%a|^u9EiYi-y+X zrIcVxVZX~T{~R1){(qg==KlCX61K0@waFSFA{Kc*RYY7?#Dhw*eUYg{p-|-sX1h%7 z6TnrrSY98ByIH2oEUQmBkeoRjtJ5jyR=-iu i)>JGtn?PqS;UNZ5Boc}Ioc90#0RR69wG({+3;+PL5}8~8 literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/Chart.yaml similarity index 68% rename from pkg/helm/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/Chart.yaml index 16287c84..80a52f53 100644 --- a/pkg/helm/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/Chart.yaml +++ b/pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v2 name: dev version: v0.1.0 -appVersion: v0.1.0 +appVersion: v0.1.0 \ No newline at end of file diff --git a/pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/values.yaml new file mode 100644 index 00000000..38f03484 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/values.yaml @@ -0,0 +1,9 @@ +# Dev values parent-chart +nameOverride: parent-chart-dev +exports: + data: + resources: + autoscaler: + minReplicas: 1 + maxReplicas: 3 + targetCPUUtilizationPercentage: 80 diff --git a/pkg/helm/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/Chart.yaml similarity index 68% rename from pkg/helm/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/Chart.yaml index 4eb6aac8..bda4be45 100644 --- a/pkg/helm/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/Chart.yaml +++ b/pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v2 name: prod version: v0.1.0 -appVersion: v0.1.0 +appVersion: v0.1.0 \ No newline at end of file diff --git a/pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/values.yaml new file mode 100644 index 00000000..10cc756b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/values.yaml @@ -0,0 +1,9 @@ +# Prod values parent-chart +nameOverride: parent-chart-prod +exports: + data: + resources: + autoscaler: + minReplicas: 2 + maxReplicas: 5 + targetCPUUtilizationPercentage: 90 diff --git a/pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/templates/autoscaler.yaml b/pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/templates/autoscaler.yaml new file mode 100644 index 00000000..976e5a8f --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/templates/autoscaler.yaml @@ -0,0 +1,16 @@ +################################################################################################### +# parent-chart horizontal pod autoscaler +################################################################################################### +apiVersion: autoscaling/v1 +kind: HorizontalPodAutoscaler +metadata: + name: {{ .Release.Name }}-autoscaler + namespace: {{ .Release.Namespace }} +spec: + scaleTargetRef: + apiVersion: apps/v1beta1 + kind: Deployment + name: {{ .Release.Name }} + minReplicas: {{ required "A valid .Values.resources.autoscaler.minReplicas entry required!" .Values.resources.autoscaler.minReplicas }} + maxReplicas: {{ required "A valid .Values.resources.autoscaler.maxReplicas entry required!" .Values.resources.autoscaler.maxReplicas }} + targetCPUUtilizationPercentage: {{ required "A valid .Values.resources.autoscaler.targetCPUUtilizationPercentage!" .Values.resources.autoscaler.targetCPUUtilizationPercentage }} \ No newline at end of file diff --git a/pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/values.yaml new file mode 100644 index 00000000..b812f0a3 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/values.yaml @@ -0,0 +1,10 @@ +# Default values for parent-chart. +nameOverride: parent-chart +tags: + dev: false + prod: true +resources: + autoscaler: + minReplicas: 0 + maxReplicas: 0 + targetCPUUtilizationPercentage: 99 \ No newline at end of file diff --git a/pkg/helm/pkg/chartutil/testdata/joonix/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/joonix/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/joonix/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/joonix/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/joonix/charts/.gitkeep b/pkg/helm/pkg/chart/v2/util/testdata/joonix/charts/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/Chart.yaml similarity index 72% rename from pkg/helm/pkg/chartutil/testdata/subpop/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/subpop/Chart.yaml index e4cf05b7..27118672 100644 --- a/pkg/helm/pkg/chartutil/testdata/subpop/Chart.yaml +++ b/pkg/helm/pkg/chart/v2/util/testdata/subpop/Chart.yaml @@ -25,16 +25,6 @@ dependencies: parent: . - SCBexported2 - SC1exported1 - export-values: - - parent: exported-parent - child: exported-parent - - parent: exported-overridden-parent - child: exported-overridden-parent - - parent: exported-single-value-parent - child: exported-single-value-parent - - parent: exported-passthrough - child: subcharta.exported-passthrough - - exported-short-parent - name: subchart2 repository: http://localhost:10191 diff --git a/pkg/helm/pkg/chart/v2/util/testdata/subpop/README.md b/pkg/helm/pkg/chart/v2/util/testdata/subpop/README.md new file mode 100644 index 00000000..e43fbfe9 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/subpop/README.md @@ -0,0 +1,18 @@ +## Subpop + +This chart is for testing the processing of enabled/disabled charts +via conditions and tags. + +Currently there are three levels: + +```` +parent +-1 tags: front-end, subchart1 +--A tags: front-end, subchartA +--B tags: front-end, subchartB +-2 tags: back-end, subchart2 +--B tags: back-end, subchartB +--C tags: back-end, subchartC +```` + +Tags and conditions are currently in requirements.yaml files. \ No newline at end of file diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/Chart.yaml similarity index 86% rename from pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/Chart.yaml index a0be53c4..9d8c03ee 100644 --- a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/Chart.yaml +++ b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/Chart.yaml @@ -17,10 +17,6 @@ dependencies: parent: overridden-chartA - child: SCAdata parent: imported-chartA-B - export-values: - - parent: exported-overridden-chart1 - child: exported-overridden-chart1 - - exported-short-chart1 - name: subchartb repository: http://localhost:10191 diff --git a/pkg/helm/cmd/helm/testdata/testcharts/subchart/charts/subchartA/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartA/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/subchart/charts/subchartA/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartA/Chart.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart2/templates/service.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartA/templates/service.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart2/templates/service.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartA/templates/service.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartA/values.yaml similarity index 55% rename from pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA/values.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartA/values.yaml index aa637642..f0381ae6 100644 --- a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA/values.yaml +++ b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartA/values.yaml @@ -15,15 +15,3 @@ SCAdata: SCAnested1: SCAnested2: true -exported-overridden-chart1: - SCAbool: true - SCAfloat: 33.3 - SCAint: 333 - SCAstring: "exported-from-chart1" - SPExtra15: "should-be-unchanged" - -exported-passthrough: - SPExtra18: "should-be-unchanged" - -exported-short-chart1: - SPExtra16: "should-be-unchanged" diff --git a/pkg/helm/cmd/helm/testdata/testcharts/subchart/charts/subchartB/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartB/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/subchart/charts/subchartB/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartB/Chart.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/noreqs/templates/service.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartB/templates/service.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/subpop/noreqs/templates/service.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartB/templates/service.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartB/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartB/values.yaml new file mode 100644 index 00000000..774fdd75 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartB/values.yaml @@ -0,0 +1,35 @@ +# Default values for subchart. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +service: + name: nginx + type: ClusterIP + externalPort: 80 + internalPort: 80 + +SCBdata: + SCBbool: true + SCBfloat: 7.77 + SCBint: 33 + SCBstring: "boba" + +exports: + SCBexported1: + SCBexported1A: + SCBexported1B: 1965 + + SCBexported2: + SCBexported2A: "blaster" + +global: + kolla: + nova: + api: + all: + port: 8774 + metadata: + all: + port: 8775 + + + diff --git a/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/crds/crdA.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/crds/crdA.yaml new file mode 100644 index 00000000..fca77fd4 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/crds/crdA.yaml @@ -0,0 +1,13 @@ +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: testCRDs +spec: + group: testCRDGroups + names: + kind: TestCRD + listKind: TestCRDList + plural: TestCRDs + shortNames: + - tc + singular: authconfig diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/templates/NOTES.txt b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/templates/NOTES.txt similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/templates/NOTES.txt rename to pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/templates/NOTES.txt diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/templates/service.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/templates/service.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/templates/service.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/templates/service.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/templates/subdir/role.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/templates/subdir/role.yaml new file mode 100644 index 00000000..91b954e5 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/templates/subdir/role.yaml @@ -0,0 +1,7 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ .Chart.Name }}-role +rules: +- resources: ["*"] + verbs: ["get","list","watch"] diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/templates/subdir/rolebinding.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/templates/subdir/rolebinding.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/templates/subdir/rolebinding.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/templates/subdir/rolebinding.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/templates/subdir/serviceaccount.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/templates/subdir/serviceaccount.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/templates/subdir/serviceaccount.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/templates/subdir/serviceaccount.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/values.yaml new file mode 100644 index 00000000..a974e316 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart1/values.yaml @@ -0,0 +1,55 @@ +# Default values for subchart. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +# subchart1 +service: + name: nginx + type: ClusterIP + externalPort: 80 + internalPort: 80 + + +SC1data: + SC1bool: true + SC1float: 3.14 + SC1int: 100 + SC1string: "dollywood" + SC1extra1: 11 + +imported-chartA: + SC1extra2: 1.337 + +overridden-chartA: + SCAbool: true + SCAfloat: 3.14 + SCAint: 100 + SCAstring: "jabbathehut" + SC1extra3: true + +imported-chartA-B: + SC1extra5: "tiller" + +overridden-chartA-B: + SCAbool: true + SCAfloat: 3.33 + SCAint: 555 + SCAstring: "wormwood" + SCAextra1: 23 + + SCBbool: true + SCBfloat: 0.25 + SCBint: 98 + SCBstring: "murkwood" + SCBextra1: 13 + + SC1extra6: 77 + +SCBexported1A: + SC1extra7: true + +exports: + SC1exported1: + global: + SC1exported2: + all: + SC1exported3: "SC1expstr" \ No newline at end of file diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart2/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart2/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/Chart.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartB/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartB/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartB/templates/service.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartB/templates/service.yaml new file mode 100644 index 00000000..fb3dfc44 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartB/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: subchart2-{{ .Chart.Name }} + labels: + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.externalPort }} + targetPort: {{ .Values.service.internalPort }} + protocol: TCP + name: subchart2-{{ .Values.service.name }} + selector: + app.kubernetes.io/name: {{ .Chart.Name }} diff --git a/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartB/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartB/values.yaml new file mode 100644 index 00000000..5e5b2106 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartB/values.yaml @@ -0,0 +1,21 @@ +# Default values for subchart. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +replicaCount: 1 +image: + repository: nginx + tag: stable + pullPolicy: IfNotPresent +service: + name: nginx + type: ClusterIP + externalPort: 80 + internalPort: 80 +resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi + diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartC/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartC/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartC/templates/service.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartC/templates/service.yaml new file mode 100644 index 00000000..27501e1e --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartC/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Chart.Name }} + labels: + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.externalPort }} + targetPort: {{ .Values.service.internalPort }} + protocol: TCP + name: {{ .Values.service.name }} + selector: + app.kubernetes.io/name: {{ .Chart.Name }} diff --git a/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartC/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartC/values.yaml new file mode 100644 index 00000000..5e5b2106 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartC/values.yaml @@ -0,0 +1,21 @@ +# Default values for subchart. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +replicaCount: 1 +image: + repository: nginx + tag: stable + pullPolicy: IfNotPresent +service: + name: nginx + type: ClusterIP + externalPort: 80 + internalPort: 80 +resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi + diff --git a/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/templates/service.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/templates/service.yaml new file mode 100644 index 00000000..27501e1e --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Chart.Name }} + labels: + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.externalPort }} + targetPort: {{ .Values.service.internalPort }} + protocol: TCP + name: {{ .Values.service.name }} + selector: + app.kubernetes.io/name: {{ .Chart.Name }} diff --git a/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/values.yaml new file mode 100644 index 00000000..5e5b2106 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/subpop/charts/subchart2/values.yaml @@ -0,0 +1,21 @@ +# Default values for subchart. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +replicaCount: 1 +image: + repository: nginx + tag: stable + pullPolicy: IfNotPresent +service: + name: nginx + type: ClusterIP + externalPort: 80 + internalPort: 80 +resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi + diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/noreqs/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/noreqs/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/subpop/noreqs/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/subpop/noreqs/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/subpop/noreqs/templates/service.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/noreqs/templates/service.yaml new file mode 100644 index 00000000..27501e1e --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/subpop/noreqs/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Chart.Name }} + labels: + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.externalPort }} + targetPort: {{ .Values.service.internalPort }} + protocol: TCP + name: {{ .Values.service.name }} + selector: + app.kubernetes.io/name: {{ .Chart.Name }} diff --git a/pkg/helm/pkg/chart/v2/util/testdata/subpop/noreqs/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/noreqs/values.yaml new file mode 100644 index 00000000..4ed3b7ad --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/subpop/noreqs/values.yaml @@ -0,0 +1,26 @@ +# Default values for subchart. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +replicaCount: 1 +image: + repository: nginx + tag: stable + pullPolicy: IfNotPresent +service: + name: nginx + type: ClusterIP + externalPort: 80 + internalPort: 80 +resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi + + +# switch-like +tags: + front-end: true + back-end: false diff --git a/pkg/helm/pkg/chart/v2/util/testdata/subpop/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/subpop/values.yaml new file mode 100644 index 00000000..ba70ed40 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/subpop/values.yaml @@ -0,0 +1,45 @@ +# parent/values.yaml + +imported-chart1: + SPextra1: "helm rocks" + +overridden-chart1: + SC1bool: false + SC1float: 3.141592 + SC1int: 99 + SC1string: "pollywog" + SPextra2: 42 + + +imported-chartA: + SPextra3: 1.337 + +overridden-chartA: + SCAbool: true + SCAfloat: 41.3 + SCAint: 808 + SCAstring: "jabberwocky" + SPextra4: true + +imported-chartA-B: + SPextra5: "k8s" + +overridden-chartA-B: + SCAbool: true + SCAfloat: 41.3 + SCAint: 808 + SCAstring: "jabberwocky" + SCBbool: false + SCBfloat: 1.99 + SCBint: 77 + SCBstring: "jango" + SPextra6: 111 + +tags: + front-end: true + back-end: false + +subchart2alias: + enabled: false + +ensurenull: null diff --git a/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/README.md b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/README.md new file mode 100644 index 00000000..536bb979 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/README.md @@ -0,0 +1,16 @@ +# Three Level Dependent Chart + +This chart is for testing the processing of multi-level dependencies. + +Consists of the following charts: + +- Library Chart +- App Chart (Uses Library Chart as dependency, 2x: app1/app2) +- Umbrella Chart (Has all the app charts as dependencies) + +The precedence is as follows: `library < app < umbrella` + +Catches two use-cases: + +- app overwriting library (app2) +- umbrella overwriting app and library (app1) diff --git a/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/Chart.yaml new file mode 100644 index 00000000..e5dbe313 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/Chart.yaml @@ -0,0 +1,19 @@ +apiVersion: v2 +name: umbrella +description: A Helm chart for Kubernetes +type: application +version: 0.1.0 + +dependencies: +- name: app1 + version: 0.1.0 + condition: app1.enabled +- name: app2 + version: 0.1.0 + condition: app2.enabled +- name: app3 + version: 0.1.0 + condition: app3.enabled +- name: app4 + version: 0.1.0 + condition: app4.enabled diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/Chart.yaml similarity index 61% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/Chart.yaml index 5ee35ce6..388245e3 100644 --- a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/Chart.yaml +++ b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/Chart.yaml @@ -5,7 +5,7 @@ type: application version: 0.1.0 dependencies: - - name: library - version: 0.1.0 - import-values: - - defaults +- name: library + version: 0.1.0 + import-values: + - defaults diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/templates/service.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/templates/service.yaml new file mode 100644 index 00000000..3fd398b5 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/templates/service.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Service +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http diff --git a/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/values.yaml new file mode 100644 index 00000000..0c08b6cd --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/values.yaml @@ -0,0 +1,5 @@ +exports: + defaults: + service: + type: ClusterIP + port: 9090 diff --git a/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/templates/service.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/templates/service.yaml new file mode 100644 index 00000000..8ed8ddf1 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/templates/service.yaml @@ -0,0 +1 @@ +{{- include "library.service" . }} diff --git a/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/values.yaml new file mode 100644 index 00000000..3728aa93 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/values.yaml @@ -0,0 +1,3 @@ +service: + type: ClusterIP + port: 1234 diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/Chart.yaml similarity index 61% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/Chart.yaml index 83810173..fea2768c 100644 --- a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/Chart.yaml +++ b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/Chart.yaml @@ -5,7 +5,7 @@ type: application version: 0.1.0 dependencies: - - name: library - version: 0.1.0 - import-values: - - defaults +- name: library + version: 0.1.0 + import-values: + - defaults diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/templates/service.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/templates/service.yaml new file mode 100644 index 00000000..3fd398b5 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/templates/service.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Service +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http diff --git a/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/values.yaml new file mode 100644 index 00000000..0c08b6cd --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/values.yaml @@ -0,0 +1,5 @@ +exports: + defaults: + service: + type: ClusterIP + port: 9090 diff --git a/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/templates/service.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/templates/service.yaml new file mode 100644 index 00000000..8ed8ddf1 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/templates/service.yaml @@ -0,0 +1 @@ +{{- include "library.service" . }} diff --git a/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/values.yaml new file mode 100644 index 00000000..98bd6d24 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/values.yaml @@ -0,0 +1,3 @@ +service: + type: ClusterIP + port: 8080 diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/Chart.yaml similarity index 61% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/Chart.yaml index 3b2ba7a8..a42f5877 100644 --- a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/Chart.yaml +++ b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/Chart.yaml @@ -5,7 +5,7 @@ type: application version: 0.1.0 dependencies: - - name: library - version: 0.1.0 - import-values: - - defaults +- name: library + version: 0.1.0 + import-values: + - defaults diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/templates/service.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/templates/service.yaml new file mode 100644 index 00000000..3fd398b5 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/templates/service.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Service +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http diff --git a/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/values.yaml new file mode 100644 index 00000000..0c08b6cd --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/values.yaml @@ -0,0 +1,5 @@ +exports: + defaults: + service: + type: ClusterIP + port: 9090 diff --git a/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/templates/service.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/templates/service.yaml new file mode 100644 index 00000000..8ed8ddf1 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/templates/service.yaml @@ -0,0 +1 @@ +{{- include "library.service" . }} diff --git a/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/values.yaml new file mode 100644 index 00000000..b738e2a5 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/values.yaml @@ -0,0 +1,2 @@ +service: + type: ClusterIP diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/Chart.yaml similarity index 75% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/Chart.yaml index 7d9ca107..574bfdfd 100644 --- a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/Chart.yaml +++ b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/Chart.yaml @@ -5,5 +5,5 @@ type: application version: 0.1.0 dependencies: - - name: library - version: 0.1.0 +- name: library + version: 0.1.0 diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/Chart.yaml rename to pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/Chart.yaml diff --git a/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/templates/service.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/templates/service.yaml new file mode 100644 index 00000000..3fd398b5 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/templates/service.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Service +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http diff --git a/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/values.yaml new file mode 100644 index 00000000..0c08b6cd --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/values.yaml @@ -0,0 +1,5 @@ +exports: + defaults: + service: + type: ClusterIP + port: 9090 diff --git a/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/templates/service.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/templates/service.yaml new file mode 100644 index 00000000..8ed8ddf1 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/templates/service.yaml @@ -0,0 +1 @@ +{{- include "library.service" . }} diff --git a/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/values.yaml new file mode 100644 index 00000000..3728aa93 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/values.yaml @@ -0,0 +1,3 @@ +service: + type: ClusterIP + port: 1234 diff --git a/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/values.yaml b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/values.yaml new file mode 100644 index 00000000..de0bafa5 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/values.yaml @@ -0,0 +1,14 @@ +app1: + enabled: true + service: + type: ClusterIP + port: 3456 + +app2: + enabled: true + +app3: + enabled: true + +app4: + enabled: true diff --git a/pkg/helm/pkg/chart/v2/util/validate_name.go b/pkg/helm/pkg/chart/v2/util/validate_name.go new file mode 100644 index 00000000..6595e085 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/validate_name.go @@ -0,0 +1,111 @@ +/* +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 util + +import ( + "errors" + "fmt" + "regexp" +) + +// validName is a regular expression for resource names. +// +// According to the Kubernetes help text, the regular expression it uses is: +// +// [a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)* +// +// This follows the above regular expression (but requires a full string match, not partial). +// +// The Kubernetes documentation is here, though it is not entirely correct: +// https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names +var validName = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`) + +var ( + // errMissingName indicates that a release (name) was not provided. + errMissingName = errors.New("no name provided") + + // errInvalidName indicates that an invalid release name was provided + errInvalidName = fmt.Errorf( + "invalid release name, must match regex %s and the length must not be longer than 53", + validName.String()) + + // errInvalidKubernetesName indicates that the name does not meet the Kubernetes + // restrictions on metadata names. + errInvalidKubernetesName = fmt.Errorf( + "invalid metadata name, must match regex %s and the length must not be longer than 253", + validName.String()) +) + +const ( + // According to the Kubernetes docs (https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#rfc-1035-label-names) + // some resource names have a max length of 63 characters while others have a max + // length of 253 characters. As we cannot be sure the resources used in a chart, we + // therefore need to limit it to 63 chars and reserve 10 chars for additional part to name + // of the resource. The reason is that chart maintainers can use release name as part of + // the resource name (and some additional chars). + maxReleaseNameLen = 53 + // maxMetadataNameLen is the maximum length Kubernetes allows for any name. + maxMetadataNameLen = 253 +) + +// ValidateReleaseName performs checks for an entry for a Helm release name +// +// For Helm to allow a name, it must be below a certain character count (53) and also match +// a regular expression. +// +// According to the Kubernetes help text, the regular expression it uses is: +// +// [a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)* +// +// This follows the above regular expression (but requires a full string match, not partial). +// +// The Kubernetes documentation is here, though it is not entirely correct: +// https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names +func ValidateReleaseName(name string) error { + // This case is preserved for backwards compatibility + if name == "" { + return errMissingName + + } + if len(name) > maxReleaseNameLen || !validName.MatchString(name) { + return errInvalidName + } + return nil +} + +// ValidateMetadataName validates the name field of a Kubernetes metadata object. +// +// Empty strings, strings longer than 253 chars, or strings that don't match the regexp +// will fail. +// +// According to the Kubernetes help text, the regular expression it uses is: +// +// [a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)* +// +// This follows the above regular expression (but requires a full string match, not partial). +// +// The Kubernetes documentation is here, though it is not entirely correct: +// https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names +// +// Deprecated: remove in Helm 4. Name validation now uses rules defined in +// pkg/lint/rules.validateMetadataNameFunc() +func ValidateMetadataName(name string) error { + if name == "" || len(name) > maxMetadataNameLen || !validName.MatchString(name) { + return errInvalidKubernetesName + } + return nil +} diff --git a/pkg/helm/pkg/chart/v2/util/validate_name_test.go b/pkg/helm/pkg/chart/v2/util/validate_name_test.go new file mode 100644 index 00000000..cfc62a0f --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/validate_name_test.go @@ -0,0 +1,91 @@ +/* +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 util + +import "testing" + +// TestValidateReleaseName is a regression test for ValidateName +// +// Kubernetes has strict naming conventions for resource names. This test represents +// those conventions. +// +// See https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names +// +// NOTE: At the time of this writing, the docs above say that names cannot begin with +// digits. However, `kubectl`'s regular expression explicit allows this, and +// Kubernetes (at least as of 1.18) also accepts resources whose names begin with digits. +func TestValidateReleaseName(t *testing.T) { + names := map[string]bool{ + "": false, + "foo": true, + "foo.bar1234baz.seventyone": true, + "FOO": false, + "123baz": true, + "foo.BAR.baz": false, + "one-two": true, + "-two": false, + "one_two": false, + "a..b": false, + "%^&#$%*@^*@&#^": false, + "example:com": false, + "example%%com": false, + "a1111111111111111111111111111111111111111111111111111111111z": false, + } + for input, expectPass := range names { + if err := ValidateReleaseName(input); (err == nil) != expectPass { + st := "fail" + if expectPass { + st = "succeed" + } + t.Errorf("Expected %q to %s", input, st) + } + } +} + +func TestValidateMetadataName(t *testing.T) { + names := map[string]bool{ + "": false, + "foo": true, + "foo.bar1234baz.seventyone": true, + "FOO": false, + "123baz": true, + "foo.BAR.baz": false, + "one-two": true, + "-two": false, + "one_two": false, + "a..b": false, + "%^&#$%*@^*@&#^": false, + "example:com": false, + "example%%com": false, + "a1111111111111111111111111111111111111111111111111111111111z": true, + "a1111111111111111111111111111111111111111111111111111111111z" + + "a1111111111111111111111111111111111111111111111111111111111z" + + "a1111111111111111111111111111111111111111111111111111111111z" + + "a1111111111111111111111111111111111111111111111111111111111z" + + "a1111111111111111111111111111111111111111111111111111111111z" + + "a1111111111111111111111111111111111111111111111111111111111z": false, + } + for input, expectPass := range names { + if err := ValidateMetadataName(input); (err == nil) != expectPass { + st := "fail" + if expectPass { + st = "succeed" + } + t.Errorf("Expected %q to %s", input, st) + } + } +} diff --git a/pkg/helm/pkg/chartutil/capabilities.go b/pkg/helm/pkg/chartutil/capabilities.go deleted file mode 100644 index 8197e38f..00000000 --- a/pkg/helm/pkg/chartutil/capabilities.go +++ /dev/null @@ -1,126 +0,0 @@ -/* -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 chartutil - -import ( - "fmt" - "strconv" - - "github.com/Masterminds/semver/v3" - "k8s.io/client-go/kubernetes/scheme" - - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" - - helmversion "github.com/werf/nelm/pkg/helm/internal/version" -) - -var ( - // The Kubernetes version can be set by LDFLAGS. In order to do that the value - // must be a string. - k8sVersionMajor = "1" - k8sVersionMinor = "20" - - // DefaultVersionSet is the default version set, which includes only Core V1 ("v1"). - DefaultVersionSet = allKnownVersions() - - // DefaultCapabilities is the default set of capabilities. - DefaultCapabilities = &Capabilities{ - KubeVersion: KubeVersion{ - Version: fmt.Sprintf("v%s.%s.0", k8sVersionMajor, k8sVersionMinor), - Major: k8sVersionMajor, - Minor: k8sVersionMinor, - }, - APIVersions: DefaultVersionSet, - HelmVersion: helmversion.Get(), - } -) - -// Capabilities describes the capabilities of the Kubernetes cluster. -type Capabilities struct { - // KubeVersion is the Kubernetes version. - KubeVersion KubeVersion - // APIversions are supported Kubernetes API versions. - APIVersions VersionSet - // HelmVersion is the build information for this helm version - HelmVersion helmversion.BuildInfo -} - -func (capabilities *Capabilities) Copy() *Capabilities { - return &Capabilities{ - KubeVersion: capabilities.KubeVersion, - APIVersions: capabilities.APIVersions, - HelmVersion: capabilities.HelmVersion, - } -} - -// KubeVersion is the Kubernetes version. -type KubeVersion struct { - Version string // Kubernetes version - Major string // Kubernetes major version - Minor string // Kubernetes minor version -} - -// String implements fmt.Stringer -func (kv *KubeVersion) String() string { return kv.Version } - -// GitVersion returns the Kubernetes version string. -// -// Deprecated: use KubeVersion.Version. -func (kv *KubeVersion) GitVersion() string { return kv.Version } - -// ParseKubeVersion parses kubernetes version from string -func ParseKubeVersion(version string) (*KubeVersion, error) { - sv, err := semver.NewVersion(version) - if err != nil { - return nil, err - } - return &KubeVersion{ - Version: "v" + sv.String(), - Major: strconv.FormatUint(sv.Major(), 10), - Minor: strconv.FormatUint(sv.Minor(), 10), - }, nil -} - -// VersionSet is a set of Kubernetes API versions. -type VersionSet []string - -// Has returns true if the version string is in the set. -// -// vs.Has("apps/v1") -func (v VersionSet) Has(apiVersion string) bool { - for _, x := range v { - if x == apiVersion { - return true - } - } - return false -} - -func allKnownVersions() VersionSet { - // We should register the built in extension APIs as well so CRDs are - // supported in the default version set. This has caused problems with `helm - // template` in the past, so let's be safe - apiextensionsv1beta1.AddToScheme(scheme.Scheme) - apiextensionsv1.AddToScheme(scheme.Scheme) - - groups := scheme.Scheme.PrioritizedVersionsAllGroups() - vs := make(VersionSet, 0, len(groups)) - for _, gv := range groups { - vs = append(vs, gv.String()) - } - return vs -} diff --git a/pkg/helm/pkg/chartutil/exports.go b/pkg/helm/pkg/chartutil/exports.go deleted file mode 100644 index af77164e..00000000 --- a/pkg/helm/pkg/chartutil/exports.go +++ /dev/null @@ -1,15 +0,0 @@ -package chartutil - -import ( - "log" - - "github.com/werf/nelm/pkg/helm/pkg/chart" -) - -func CoalesceChartValues(c *chart.Chart, v map[string]interface{}, merge bool) { - coalesceValues(log.Printf, c, v, "", merge) -} - -func CoalesceChartDeps(chrt *chart.Chart, dest map[string]interface{}, merge bool) (map[string]interface{}, error) { - return coalesceDeps(log.Printf, chrt, dest, "", merge) -} diff --git a/pkg/helm/pkg/chartutil/jsonschema.go b/pkg/helm/pkg/chartutil/jsonschema.go deleted file mode 100644 index c86afcf6..00000000 --- a/pkg/helm/pkg/chartutil/jsonschema.go +++ /dev/null @@ -1,93 +0,0 @@ -/* -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 chartutil - -import ( - "bytes" - "fmt" - "strings" - - "github.com/pkg/errors" - "github.com/xeipuuv/gojsonschema" - "sigs.k8s.io/yaml" - - "github.com/werf/nelm/pkg/helm/pkg/chart" -) - -// ValidateAgainstSchema checks that values does not violate the structure laid out in schema -func ValidateAgainstSchema(chrt *chart.Chart, values map[string]interface{}) error { - var sb strings.Builder - if chrt.Schema != nil { - err := ValidateAgainstSingleSchema(values, chrt.Schema) - if err != nil { - sb.WriteString(fmt.Sprintf("%s:\n", chrt.Name())) - sb.WriteString(err.Error()) - } - } - - // For each dependency, recursively call this function with the coalesced values - for _, subchart := range chrt.Dependencies() { - subchartValues := values[subchart.Name()].(map[string]interface{}) - if err := ValidateAgainstSchema(subchart, subchartValues); err != nil { - sb.WriteString(err.Error()) - } - } - - if sb.Len() > 0 { - return errors.New(sb.String()) - } - - return nil -} - -// ValidateAgainstSingleSchema checks that values does not violate the structure laid out in this schema -func ValidateAgainstSingleSchema(values Values, schemaJSON []byte) (reterr error) { - defer func() { - if r := recover(); r != nil { - reterr = fmt.Errorf("unable to validate schema: %s", r) - } - }() - - valuesData, err := yaml.Marshal(values) - if err != nil { - return err - } - valuesJSON, err := yaml.YAMLToJSON(valuesData) - if err != nil { - return err - } - if bytes.Equal(valuesJSON, []byte("null")) { - valuesJSON = []byte("{}") - } - schemaLoader := gojsonschema.NewBytesLoader(schemaJSON) - valuesLoader := gojsonschema.NewBytesLoader(valuesJSON) - - result, err := gojsonschema.Validate(schemaLoader, valuesLoader) - if err != nil { - return err - } - - if !result.Valid() { - var sb strings.Builder - for _, desc := range result.Errors() { - sb.WriteString(fmt.Sprintf("- %s\n", desc)) - } - return errors.New(sb.String()) - } - - return nil -} diff --git a/pkg/helm/pkg/chartutil/jsonschema_test.go b/pkg/helm/pkg/chartutil/jsonschema_test.go deleted file mode 100644 index 6ecdf396..00000000 --- a/pkg/helm/pkg/chartutil/jsonschema_test.go +++ /dev/null @@ -1,167 +0,0 @@ -/* -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 chartutil - -import ( - "os" - "testing" - - "github.com/werf/nelm/pkg/helm/pkg/chart" -) - -func TestValidateAgainstSingleSchema(t *testing.T) { - values, err := ReadValuesFile("./testdata/test-values.yaml") - if err != nil { - t.Fatalf("Error reading YAML file: %s", err) - } - schema, err := os.ReadFile("./testdata/test-values.schema.json") - if err != nil { - t.Fatalf("Error reading YAML file: %s", err) - } - - if err := ValidateAgainstSingleSchema(values, schema); err != nil { - t.Errorf("Error validating Values against Schema: %s", err) - } -} - -func TestValidateAgainstInvalidSingleSchema(t *testing.T) { - values, err := ReadValuesFile("./testdata/test-values.yaml") - if err != nil { - t.Fatalf("Error reading YAML file: %s", err) - } - schema, err := os.ReadFile("./testdata/test-values-invalid.schema.json") - if err != nil { - t.Fatalf("Error reading YAML file: %s", err) - } - - var errString string - if err := ValidateAgainstSingleSchema(values, schema); err == nil { - t.Fatalf("Expected an error, but got nil") - } else { - errString = err.Error() - } - - expectedErrString := "unable to validate schema: runtime error: invalid " + - "memory address or nil pointer dereference" - if errString != expectedErrString { - t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString) - } -} - -func TestValidateAgainstSingleSchemaNegative(t *testing.T) { - values, err := ReadValuesFile("./testdata/test-values-negative.yaml") - if err != nil { - t.Fatalf("Error reading YAML file: %s", err) - } - schema, err := os.ReadFile("./testdata/test-values.schema.json") - if err != nil { - t.Fatalf("Error reading YAML file: %s", err) - } - - var errString string - if err := ValidateAgainstSingleSchema(values, schema); err == nil { - t.Fatalf("Expected an error, but got nil") - } else { - errString = err.Error() - } - - expectedErrString := `- (root): employmentInfo is required -- age: Must be greater than or equal to 0 -` - if errString != expectedErrString { - t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString) - } -} - -const subchartSchema = `{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Values", - "type": "object", - "properties": { - "age": { - "description": "Age", - "minimum": 0, - "type": "integer" - } - }, - "required": [ - "age" - ] -} -` - -func TestValidateAgainstSchema(t *testing.T) { - subchartJSON := []byte(subchartSchema) - subchart := &chart.Chart{ - Metadata: &chart.Metadata{ - Name: "subchart", - }, - Schema: subchartJSON, - } - chrt := &chart.Chart{ - Metadata: &chart.Metadata{ - Name: "chrt", - }, - } - chrt.AddDependency(subchart) - - vals := map[string]interface{}{ - "name": "John", - "subchart": map[string]interface{}{ - "age": 25, - }, - } - - if err := ValidateAgainstSchema(chrt, vals); err != nil { - t.Errorf("Error validating Values against Schema: %s", err) - } -} - -func TestValidateAgainstSchemaNegative(t *testing.T) { - subchartJSON := []byte(subchartSchema) - subchart := &chart.Chart{ - Metadata: &chart.Metadata{ - Name: "subchart", - }, - Schema: subchartJSON, - } - chrt := &chart.Chart{ - Metadata: &chart.Metadata{ - Name: "chrt", - }, - } - chrt.AddDependency(subchart) - - vals := map[string]interface{}{ - "name": "John", - "subchart": map[string]interface{}{}, - } - - var errString string - if err := ValidateAgainstSchema(chrt, vals); err == nil { - t.Fatalf("Expected an error, but got nil") - } else { - errString = err.Error() - } - - expectedErrString := `subchart: -- (root): age is required -` - if errString != expectedErrString { - t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString) - } -} diff --git a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/Chart.yaml b/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/Chart.yaml deleted file mode 100644 index cde3b9ba..00000000 --- a/pkg/helm/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/Chart.yaml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: v2 -name: umbrella -description: A Helm chart for Kubernetes -type: application -version: 0.1.0 - -dependencies: - - name: app1 - version: 0.1.0 - condition: app1.enabled - - name: app2 - version: 0.1.0 - condition: app2.enabled - - name: app3 - version: 0.1.0 - condition: app3.enabled - - name: app4 - version: 0.1.0 - condition: app4.enabled diff --git a/pkg/helm/pkg/cli/environment.go b/pkg/helm/pkg/cli/environment.go index f25e561d..679abfed 100644 --- a/pkg/helm/pkg/cli/environment.go +++ b/pkg/helm/pkg/cli/environment.go @@ -34,8 +34,9 @@ import ( "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/client-go/rest" - "github.com/werf/nelm/pkg/helm/internal/version" + "github.com/werf/nelm/pkg/helm/intern/version" "github.com/werf/nelm/pkg/helm/pkg/helmpath" + "github.com/werf/nelm/pkg/helm/pkg/kube" ) // defaultMaxHistory sets the maximum number of releases to 0: unlimited @@ -44,13 +45,13 @@ const defaultMaxHistory = 10 // defaultBurstLimit sets the default client-side throttling limit const defaultBurstLimit = 100 -// defaultQPS sets the default QPS value to 0 to to use library defaults unless specified +// defaultQPS sets the default QPS value to 0 to use library defaults unless specified const defaultQPS = float32(0) // EnvSettings describes all of the environment settings. type EnvSettings struct { namespace string - config genericclioptions.RESTClientGetter + config *genericclioptions.ConfigFlags // KubeConfig is the path to the kubeconfig file KubeConfig string @@ -88,12 +89,17 @@ type EnvSettings struct { BurstLimit int // QPS is queries per second which may be used to avoid throttling. QPS float32 + // ColorMode controls colorized output (never, auto, always) + ColorMode string + // ContentCache is the location where cached charts are stored + ContentCache string } func New() *EnvSettings { env := &EnvSettings{ namespace: os.Getenv("HELM_NAMESPACE"), MaxHistory: envIntOr("HELM_MAX_HISTORY", defaultMaxHistory), + KubeConfig: os.Getenv("KUBECONFIG"), KubeContext: os.Getenv("HELM_KUBECONTEXT"), KubeToken: os.Getenv("HELM_KUBETOKEN"), KubeAsUser: os.Getenv("HELM_KUBEASUSER"), @@ -106,13 +112,15 @@ func New() *EnvSettings { RegistryConfig: envOr("HELM_REGISTRY_CONFIG", helmpath.ConfigPath("registry/config.json")), RepositoryConfig: envOr("HELM_REPOSITORY_CONFIG", helmpath.ConfigPath("repositories.yaml")), RepositoryCache: envOr("HELM_REPOSITORY_CACHE", helmpath.CachePath("repository")), + ContentCache: envOr("HELM_CONTENT_CACHE", helmpath.CachePath("content")), BurstLimit: envIntOr("HELM_BURST_LIMIT", defaultBurstLimit), QPS: envFloat32Or("HELM_QPS", defaultQPS), + ColorMode: envColorMode(), } env.Debug, _ = strconv.ParseBool(os.Getenv("HELM_DEBUG")) // bind to kubernetes config flags - env.config = &genericclioptions.ConfigFlags{ + config := &genericclioptions.ConfigFlags{ Namespace: &env.namespace, Context: &env.KubeContext, BearerToken: &env.KubeToken, @@ -127,12 +135,17 @@ func New() *EnvSettings { config.Burst = env.BurstLimit config.QPS = env.QPS config.Wrap(func(rt http.RoundTripper) http.RoundTripper { - return &retryingRoundTripper{wrapped: rt} + return &kube.RetryingRoundTripper{Wrapped: rt} }) config.UserAgent = version.GetUserAgent() return config }, } + if env.BurstLimit != defaultBurstLimit { + config = config.WithDiscoveryBurst(env.BurstLimit) + } + env.config = config + return env } @@ -151,9 +164,12 @@ func (s *EnvSettings) AddFlags(fs *pflag.FlagSet) { fs.BoolVar(&s.Debug, "debug", s.Debug, "enable verbose output") fs.StringVar(&s.RegistryConfig, "registry-config", s.RegistryConfig, "path to the registry config file") fs.StringVar(&s.RepositoryConfig, "repository-config", s.RepositoryConfig, "path to the file containing repository names and URLs") - fs.StringVar(&s.RepositoryCache, "repository-cache", s.RepositoryCache, "path to the file containing cached repository indexes") + fs.StringVar(&s.RepositoryCache, "repository-cache", s.RepositoryCache, "path to the directory containing cached repository indexes") + fs.StringVar(&s.ContentCache, "content-cache", s.ContentCache, "path to the directory containing cached content (e.g. charts)") fs.IntVar(&s.BurstLimit, "burst-limit", s.BurstLimit, "client-side default throttling limit") fs.Float32Var(&s.QPS, "qps", s.QPS, "queries per second used when communicating with the Kubernetes API, not including bursting") + fs.StringVar(&s.ColorMode, "color", s.ColorMode, "use colored output (never, auto, always)") + fs.StringVar(&s.ColorMode, "colour", s.ColorMode, "use colored output (never, auto, always)") } func envOr(name, def string) string { @@ -207,6 +223,23 @@ func envCSV(name string) (ls []string) { return } +func envColorMode() string { + // Check NO_COLOR environment variable first (standard) + if v, ok := os.LookupEnv("NO_COLOR"); ok && v != "" { + return "never" + } + // Check HELM_COLOR environment variable + if v, ok := os.LookupEnv("HELM_COLOR"); ok { + v = strings.ToLower(v) + switch v { + case "never", "auto", "always": + return v + } + } + // Default to auto + return "auto" +} + func (s *EnvSettings) EnvVars() map[string]string { envvars := map[string]string{ "HELM_BIN": os.Args[0], @@ -217,6 +250,7 @@ func (s *EnvSettings) EnvVars() map[string]string { "HELM_PLUGINS": s.PluginsDirectory, "HELM_REGISTRY_CONFIG": s.RegistryConfig, "HELM_REPOSITORY_CACHE": s.RepositoryCache, + "HELM_CONTENT_CACHE": s.ContentCache, "HELM_REPOSITORY_CONFIG": s.RepositoryConfig, "HELM_NAMESPACE": s.Namespace(), "HELM_MAX_HISTORY": strconv.Itoa(s.MaxHistory), @@ -241,13 +275,14 @@ func (s *EnvSettings) EnvVars() map[string]string { // Namespace gets the namespace from the configuration func (s *EnvSettings) Namespace() string { + if s.config != nil { + if ns, _, err := s.config.ToRawKubeConfigLoader().Namespace(); err == nil { + return ns + } + } if s.namespace != "" { return s.namespace } - - if ns, _, err := s.config.ToRawKubeConfigLoader().Namespace(); err == nil { - return ns - } return "default" } @@ -260,3 +295,9 @@ func (s *EnvSettings) SetNamespace(namespace string) { func (s *EnvSettings) RESTClientGetter() genericclioptions.RESTClientGetter { return s.config } + +// ShouldDisableColor returns true if color output should be disabled. +// Color is only enabled when ColorMode is explicitly set to "always". +func (s *EnvSettings) ShouldDisableColor() bool { + return s.ColorMode != "always" +} diff --git a/pkg/helm/pkg/cli/environment_test.go b/pkg/helm/pkg/cli/environment_test.go index e8518e16..2ebd6fbe 100644 --- a/pkg/helm/pkg/cli/environment_test.go +++ b/pkg/helm/pkg/cli/environment_test.go @@ -24,7 +24,7 @@ import ( "github.com/spf13/pflag" - "github.com/werf/nelm/pkg/helm/internal/version" + "github.com/werf/nelm/pkg/helm/intern/version" ) func TestSetNamespace(t *testing.T) { @@ -38,7 +38,6 @@ func TestSetNamespace(t *testing.T) { if settings.namespace != "testns" { t.Errorf("Expected namespace testns, got %s", settings.namespace) } - } func TestEnvSettings(t *testing.T) { @@ -111,6 +110,14 @@ func TestEnvSettings(t *testing.T) { kubeTLSServer: "example.org", kubeInsecure: true, }, + { + name: "invalid kubeconfig", + ns: "testns", + args: "--namespace=testns --kubeconfig=/path/to/fake/file", + maxhistory: defaultMaxHistory, + burstLimit: defaultBurstLimit, + qps: defaultQPS, + }, } for _, tt := range tests { @@ -118,7 +125,7 @@ func TestEnvSettings(t *testing.T) { defer resetEnv()() for k, v := range tt.envvars { - os.Setenv(k, v) + t.Setenv(k, v) } flags := pflag.NewFlagSet("testing", pflag.ContinueOnError) @@ -225,10 +232,7 @@ func TestEnvOrBool(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.env != "" { - t.Cleanup(func() { - os.Unsetenv(tt.env) - }) - os.Setenv(tt.env, tt.val) + t.Setenv(tt.env, tt.val) } actual := envBoolOr(tt.env, tt.def) if actual != tt.expected { diff --git a/pkg/helm/pkg/cli/exports.go b/pkg/helm/pkg/cli/exports.go deleted file mode 100644 index 7de52a37..00000000 --- a/pkg/helm/pkg/cli/exports.go +++ /dev/null @@ -1,13 +0,0 @@ -package cli - -import "k8s.io/cli-runtime/pkg/genericclioptions" - -var EnvOr = envOr - -func (s *EnvSettings) GetNamespaceP() *string { - return &s.namespace -} - -func (s *EnvSettings) GetConfigP() *genericclioptions.RESTClientGetter { - return &s.config -} diff --git a/pkg/helm/pkg/cli/output/output.go b/pkg/helm/pkg/cli/output/output.go index a46c977a..28d50374 100644 --- a/pkg/helm/pkg/cli/output/output.go +++ b/pkg/helm/pkg/cli/output/output.go @@ -22,7 +22,6 @@ import ( "io" "github.com/gosuri/uitable" - "github.com/pkg/errors" "sigs.k8s.io/yaml" ) @@ -73,7 +72,7 @@ func (o Format) Write(out io.Writer, w Writer) error { } // ParseFormat takes a raw string and returns the matching Format. -// If the format does not exists, ErrInvalidFormatType is returned +// If the format does not exist, ErrInvalidFormatType is returned func ParseFormat(s string) (out Format, err error) { switch s { case Table.String(): @@ -107,7 +106,7 @@ func EncodeJSON(out io.Writer, obj interface{}) error { enc := json.NewEncoder(out) err := enc.Encode(obj) if err != nil { - return errors.Wrap(err, "unable to write JSON output") + return fmt.Errorf("unable to write JSON output: %w", err) } return nil } @@ -117,12 +116,12 @@ func EncodeJSON(out io.Writer, obj interface{}) error { func EncodeYAML(out io.Writer, obj interface{}) error { raw, err := yaml.Marshal(obj) if err != nil { - return errors.Wrap(err, "unable to write YAML output") + return fmt.Errorf("unable to write YAML output: %w", err) } _, err = out.Write(raw) if err != nil { - return errors.Wrap(err, "unable to write YAML output") + return fmt.Errorf("unable to write YAML output: %w", err) } return nil } @@ -134,7 +133,7 @@ func EncodeTable(out io.Writer, table *uitable.Table) error { raw = append(raw, []byte("\n")...) _, err := out.Write(raw) if err != nil { - return errors.Wrap(err, "unable to write table output") + return fmt.Errorf("unable to write table output: %w", err) } return nil } diff --git a/pkg/helm/pkg/cli/values/options.go b/pkg/helm/pkg/cli/values/options.go index 5d94729c..1c99960f 100644 --- a/pkg/helm/pkg/cli/values/options.go +++ b/pkg/helm/pkg/cli/values/options.go @@ -17,19 +17,19 @@ limitations under the License. package values import ( + "bytes" "context" + "encoding/json" + "fmt" "io" "net/url" "os" "strings" - "github.com/pkg/errors" - "sigs.k8s.io/yaml" - + "github.com/werf/nelm/pkg/common" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/loader" "github.com/werf/nelm/pkg/helm/pkg/getter" "github.com/werf/nelm/pkg/helm/pkg/strvals" - "github.com/werf/nelm/pkg/helm/pkg/werf/file" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" ) // Options captures the different ways to specify values @@ -44,51 +44,61 @@ type Options struct { // MergeValues merges values from files specified via -f/--values and directly // via --set-json, --set, --set-string, or --set-file, marshaling them to YAML -func (opts *Options) MergeValues(p getter.Providers, options helmopts.HelmOptions) (map[string]interface{}, error) { +func (opts *Options) MergeValues(ctx context.Context, p getter.Providers) (map[string]interface{}, error) { base := map[string]interface{}{} + ho := common.HelmOptionsFromContext(ctx) + // User specified a values files via -f/--values for _, filePath := range opts.ValueFiles { - currentMap := map[string]interface{}{} - - var bytes []byte + var raw []byte var err error - if options.ChartLoadOpts.ChartType == helmopts.ChartTypeChart && file.ChartFileReader != nil { - bytes, err = file.ChartFileReader.ReadChartFile(context.Background(), filePath) - if err != nil { - return nil, err - } - } else if data, err := readFile(filePath, p); err != nil { - return nil, err + if ho.ChartLoadOpts.ChartType == common.LegacyChartTypeChart && common.ChartFileReader != nil { + raw, err = common.ChartFileReader.ReadChartFile(ctx, filePath) } else { - bytes = data + raw, err = readFile(filePath, p) + } + if err != nil { + return nil, err } - if err := yaml.Unmarshal(bytes, ¤tMap); err != nil { - return nil, errors.Wrapf(err, "failed to parse %s", filePath) + currentMap, err := loader.LoadValues(bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", filePath, err) } // Merge with the previous map - base = mergeMaps(base, currentMap) + base = loader.MergeMaps(base, currentMap) } // User specified a value via --set-json for _, value := range opts.JSONValues { - if err := strvals.ParseJSON(value, base); err != nil { - return nil, errors.Errorf("failed parsing --set-json data %s", value) + trimmedValue := strings.TrimSpace(value) + if len(trimmedValue) > 0 && trimmedValue[0] == '{' { + // If value is JSON object format, parse it as map + var jsonMap map[string]interface{} + if err := json.Unmarshal([]byte(trimmedValue), &jsonMap); err != nil { + return nil, fmt.Errorf("failed parsing --set-json data JSON: %s", value) + } + base = loader.MergeMaps(base, jsonMap) + } else { + // Otherwise, parse it as key=value format + if err := strvals.ParseJSON(value, base); err != nil { + return nil, fmt.Errorf("failed parsing --set-json data %s", value) + } } } // User specified a value via --set for _, value := range opts.Values { if err := strvals.ParseInto(value, base); err != nil { - return nil, errors.Wrap(err, "failed parsing --set data") + return nil, fmt.Errorf("failed parsing --set data: %w", err) } } // User specified a value via --set-string for _, value := range opts.StringValues { if err := strvals.ParseIntoString(value, base); err != nil { - return nil, errors.Wrap(err, "failed parsing --set-string data") + return nil, fmt.Errorf("failed parsing --set-string data: %w", err) } } @@ -97,8 +107,8 @@ func (opts *Options) MergeValues(p getter.Providers, options helmopts.HelmOption reader := func(rs []rune) (interface{}, error) { var bytes []byte var err error - if options.ChartLoadOpts.ChartType == helmopts.ChartTypeChart && file.ChartFileReader != nil { - bytes, err = file.ChartFileReader.ReadChartFile(context.Background(), string(rs)) + if ho.ChartLoadOpts.ChartType == common.LegacyChartTypeChart && common.ChartFileReader != nil { + bytes, err = common.ChartFileReader.ReadChartFile(ctx, string(rs)) } else { bytes, err = readFile(string(rs), p) } @@ -109,39 +119,20 @@ func (opts *Options) MergeValues(p getter.Providers, options helmopts.HelmOption return string(bytes), nil } if err := strvals.ParseIntoFile(value, base, reader); err != nil { - return nil, errors.Wrap(err, "failed parsing --set-file data") + return nil, fmt.Errorf("failed parsing --set-file data: %w", err) } } // User specified a value via --set-literal for _, value := range opts.LiteralValues { if err := strvals.ParseLiteralInto(value, base); err != nil { - return nil, errors.Wrap(err, "failed parsing --set-literal data") + return nil, fmt.Errorf("failed parsing --set-literal data: %w", err) } } return base, nil } -func mergeMaps(a, b map[string]interface{}) map[string]interface{} { - out := make(map[string]interface{}, len(a)) - for k, v := range a { - out[k] = v - } - for k, v := range b { - if v, ok := v.(map[string]interface{}); ok { - if bv, ok := out[k]; ok { - if bv, ok := bv.(map[string]interface{}); ok { - out[k] = mergeMaps(bv, v) - continue - } - } - } - out[k] = v - } - return out -} - // readFile load a file from stdin, the local directory, or a remote file with a url. func readFile(filePath string, p getter.Providers) ([]byte, error) { if strings.TrimSpace(filePath) == "-" { @@ -161,5 +152,5 @@ func readFile(filePath string, p getter.Providers) ([]byte, error) { if err != nil { return nil, err } - return data.Bytes(), err + return data.Bytes(), nil } diff --git a/pkg/helm/pkg/cli/values/options_test.go b/pkg/helm/pkg/cli/values/options_test.go index 97df6ef8..5f036287 100644 --- a/pkg/helm/pkg/cli/values/options_test.go +++ b/pkg/helm/pkg/cli/values/options_test.go @@ -17,68 +17,276 @@ limitations under the License. package values import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" "reflect" + "strings" "testing" "github.com/werf/nelm/pkg/helm/pkg/getter" ) -func TestMergeValues(t *testing.T) { - nestedMap := map[string]interface{}{ - "foo": "bar", - "baz": map[string]string{ - "cool": "stuff", - }, +// mockGetter implements getter.Getter for testing +type mockGetter struct { + content []byte + err error +} + +func (m *mockGetter) Get(_ string, _ ...getter.Option) (*bytes.Buffer, error) { + if m.err != nil { + return nil, m.err } - anotherNestedMap := map[string]interface{}{ - "foo": "bar", - "baz": map[string]string{ - "cool": "things", - "awesome": "stuff", + return bytes.NewBuffer(m.content), nil +} + +// mockProvider creates a test provider +func mockProvider(schemes []string, content []byte, err error) getter.Provider { + return getter.Provider{ + Schemes: schemes, + New: func(_ ...getter.Option) (getter.Getter, error) { + return &mockGetter{content: content, err: err}, nil }, } - flatMap := map[string]interface{}{ - "foo": "bar", - "baz": "stuff", - } - anotherFlatMap := map[string]interface{}{ - "testing": "fun", - } +} - testMap := mergeMaps(flatMap, nestedMap) - equal := reflect.DeepEqual(testMap, nestedMap) - if !equal { - t.Errorf("Expected a nested map to overwrite a flat value. Expected: %v, got %v", nestedMap, testMap) +func TestReadFile(t *testing.T) { + tests := []struct { + name string + filePath string + providers getter.Providers + setupFunc func(*testing.T) (string, func()) // setup temp files, return cleanup + expectError bool + expectStdin bool + expectedData []byte + }{ + { + name: "stdin input with dash", + filePath: "-", + providers: getter.Providers{}, + expectStdin: true, + expectError: false, + }, + { + name: "stdin input with whitespace", + filePath: " - ", + providers: getter.Providers{}, + expectStdin: true, + expectError: false, + }, + { + name: "invalid URL parsing", + filePath: "://invalid-url", + providers: getter.Providers{}, + expectError: true, + }, + { + name: "local file - existing", + filePath: "test.txt", + providers: getter.Providers{}, + setupFunc: func(t *testing.T) (string, func()) { + t.Helper() + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "test.txt") + content := []byte("local file content") + err := os.WriteFile(filePath, content, 0644) + if err != nil { + t.Fatal(err) + } + return filePath, func() {} // cleanup handled by t.TempDir() + }, + expectError: false, + expectedData: []byte("local file content"), + }, + { + name: "local file - non-existent", + filePath: "/non/existent/file.txt", + providers: getter.Providers{}, + expectError: true, + }, + { + name: "remote file with http scheme - success", + filePath: "http://example.com/values.yaml", + providers: getter.Providers{ + mockProvider([]string{"http", "https"}, []byte("remote content"), nil), + }, + expectError: false, + expectedData: []byte("remote content"), + }, + { + name: "remote file with https scheme - success", + filePath: "https://example.com/values.yaml", + providers: getter.Providers{ + mockProvider([]string{"http", "https"}, []byte("https content"), nil), + }, + expectError: false, + expectedData: []byte("https content"), + }, + { + name: "remote file with custom scheme - success", + filePath: "oci://registry.example.com/chart", + providers: getter.Providers{ + mockProvider([]string{"oci"}, []byte("oci content"), nil), + }, + expectError: false, + expectedData: []byte("oci content"), + }, + { + name: "remote file - getter error", + filePath: "http://example.com/values.yaml", + providers: getter.Providers{ + mockProvider([]string{"http"}, nil, errors.New("network error")), + }, + expectError: true, + }, + { + name: "unsupported scheme fallback to local file", + filePath: "ftp://example.com/file.txt", + providers: getter.Providers{ + mockProvider([]string{"http"}, []byte("should not be used"), nil), + }, + setupFunc: func(t *testing.T) (string, func()) { + t.Helper() + // Create a local file named "ftp://example.com/file.txt" + // This tests the fallback behavior when scheme is not supported + tmpDir := t.TempDir() + fileName := "ftp_file.txt" // Valid filename for filesystem + filePath := filepath.Join(tmpDir, fileName) + content := []byte("local fallback content") + err := os.WriteFile(filePath, content, 0644) + if err != nil { + t.Fatal(err) + } + return filePath, func() {} + }, + expectError: false, + expectedData: []byte("local fallback content"), + }, + { + name: "empty file path", + filePath: "", + providers: getter.Providers{}, + expectError: true, // Empty path should cause error + }, + { + name: "multiple providers - correct selection", + filePath: "custom://example.com/resource", + providers: getter.Providers{ + mockProvider([]string{"http", "https"}, []byte("wrong content"), nil), + mockProvider([]string{"custom"}, []byte("correct content"), nil), + mockProvider([]string{"oci"}, []byte("also wrong"), nil), + }, + expectError: false, + expectedData: []byte("correct content"), + }, } - testMap = mergeMaps(nestedMap, flatMap) - equal = reflect.DeepEqual(testMap, flatMap) - if !equal { - t.Errorf("Expected a flat value to overwrite a map. Expected: %v, got %v", flatMap, testMap) - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var actualFilePath string + var cleanup func() + + if tt.setupFunc != nil { + actualFilePath, cleanup = tt.setupFunc(t) + defer cleanup() + } else { + actualFilePath = tt.filePath + } - testMap = mergeMaps(nestedMap, anotherNestedMap) - equal = reflect.DeepEqual(testMap, anotherNestedMap) - if !equal { - t.Errorf("Expected a nested map to overwrite another nested map. Expected: %v, got %v", anotherNestedMap, testMap) + // Handle stdin test case + if tt.expectStdin { + // Save original stdin + originalStdin := os.Stdin + defer func() { os.Stdin = originalStdin }() + + // Create a pipe for stdin + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer r.Close() + defer w.Close() + + // Replace stdin with our pipe + os.Stdin = r + + // Write test data to stdin + testData := []byte("stdin test data") + go func() { + defer w.Close() + w.Write(testData) + }() + + // Test the function + got, err := readFile(actualFilePath, tt.providers) + if err != nil { + t.Errorf("readFile() error = %v, expected no error for stdin", err) + return + } + + if !bytes.Equal(got, testData) { + t.Errorf("readFile() = %v, want %v", got, testData) + } + return + } + + // Regular test cases + got, err := readFile(actualFilePath, tt.providers) + if (err != nil) != tt.expectError { + t.Errorf("readFile() error = %v, expectError %v", err, tt.expectError) + return + } + + if !tt.expectError && tt.expectedData != nil { + if !bytes.Equal(got, tt.expectedData) { + t.Errorf("readFile() = %v, want %v", got, tt.expectedData) + } + } + }) } +} - testMap = mergeMaps(anotherFlatMap, anotherNestedMap) - expectedMap := map[string]interface{}{ - "testing": "fun", - "foo": "bar", - "baz": map[string]string{ - "cool": "things", - "awesome": "stuff", +// TestReadFileErrorMessages tests specific error scenarios and their messages +func TestReadFileErrorMessages(t *testing.T) { + tests := []struct { + name string + filePath string + providers getter.Providers + wantErr string + }{ + { + name: "URL parse error", + filePath: "://invalid", + providers: getter.Providers{}, + wantErr: "missing protocol scheme", + }, + { + name: "getter error with message", + filePath: "http://example.com/file", + providers: getter.Providers{mockProvider([]string{"http"}, nil, fmt.Errorf("connection refused"))}, + wantErr: "connection refused", }, } - equal = reflect.DeepEqual(testMap, expectedMap) - if !equal { - t.Errorf("Expected a map with different keys to merge properly with another map. Expected: %v, got %v", expectedMap, testMap) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := readFile(tt.filePath, tt.providers) + if err == nil { + t.Errorf("readFile() expected error containing %q, got nil", tt.wantErr) + return + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("readFile() error = %v, want error containing %q", err, tt.wantErr) + } + }) } } -func TestReadFile(t *testing.T) { +// Original test case - keeping for backward compatibility +func TestReadFileOriginal(t *testing.T) { var p getter.Providers filePath := "%a.txt" _, err := readFile(filePath, p) @@ -86,3 +294,97 @@ func TestReadFile(t *testing.T) { t.Errorf("Expected error when has special strings") } } + +func TestMergeValuesCLI(t *testing.T) { + tests := []struct { + name string + opts Options + expected map[string]interface{} + wantErr bool + }{ + { + name: "set-json object", + opts: Options{ + JSONValues: []string{`{"foo": {"bar": "baz"}}`}, + }, + expected: map[string]interface{}{ + "foo": map[string]interface{}{ + "bar": "baz", + }, + }, + }, + { + name: "set-json key=value", + opts: Options{ + JSONValues: []string{"foo.bar=[1,2,3]"}, + }, + expected: map[string]interface{}{ + "foo": map[string]interface{}{ + "bar": []interface{}{1.0, 2.0, 3.0}, + }, + }, + }, + { + name: "set regular value", + opts: Options{ + Values: []string{"foo=bar"}, + }, + expected: map[string]interface{}{ + "foo": "bar", + }, + }, + { + name: "set string value", + opts: Options{ + StringValues: []string{"foo=123"}, + }, + expected: map[string]interface{}{ + "foo": "123", + }, + }, + { + name: "set literal value", + opts: Options{ + LiteralValues: []string{"foo=true"}, + }, + expected: map[string]interface{}{ + "foo": "true", + }, + }, + { + name: "multiple options", + opts: Options{ + Values: []string{"a=foo"}, + StringValues: []string{"b=bar"}, + JSONValues: []string{`{"c": "foo1"}`}, + LiteralValues: []string{"d=bar1"}, + }, + expected: map[string]interface{}{ + "a": "foo", + "b": "bar", + "c": "foo1", + "d": "bar1", + }, + }, + { + name: "invalid json", + opts: Options{ + JSONValues: []string{`{invalid`}, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.opts.MergeValues(context.Background(), getter.Providers{}) + if (err != nil) != tt.wantErr { + t.Errorf("MergeValues() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && !reflect.DeepEqual(got, tt.expected) { + t.Errorf("MergeValues() = %v, want %v", got, tt.expected) + } + }) + } +} diff --git a/pkg/helm/cmd/helm/completion.go b/pkg/helm/pkg/cmd/completion.go similarity index 83% rename from pkg/helm/cmd/helm/completion.go rename to pkg/helm/pkg/cmd/completion.go index 4be6e927..abf4f18a 100644 --- a/pkg/helm/cmd/helm/completion.go +++ b/pkg/helm/pkg/cmd/completion.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "fmt" @@ -23,7 +23,7 @@ import ( "github.com/spf13/cobra" - "github.com/werf/nelm/pkg/helm/cmd/helm/require" + "github.com/werf/nelm/pkg/helm/pkg/cmd/require" ) const completionDesc = ` @@ -102,8 +102,8 @@ func newCompletionCmd(out io.Writer) *cobra.Command { Short: "generate autocompletion script for bash", Long: bashCompDesc, Args: require.NoArgs, - ValidArgsFunction: noCompletions, - RunE: func(cmd *cobra.Command, args []string) error { + ValidArgsFunction: noMoreArgsCompFunc, + RunE: func(cmd *cobra.Command, _ []string) error { return runCompletionBash(out, cmd) }, } @@ -114,8 +114,8 @@ func newCompletionCmd(out io.Writer) *cobra.Command { Short: "generate autocompletion script for zsh", Long: zshCompDesc, Args: require.NoArgs, - ValidArgsFunction: noCompletions, - RunE: func(cmd *cobra.Command, args []string) error { + ValidArgsFunction: noMoreArgsCompFunc, + RunE: func(cmd *cobra.Command, _ []string) error { return runCompletionZsh(out, cmd) }, } @@ -126,8 +126,8 @@ func newCompletionCmd(out io.Writer) *cobra.Command { Short: "generate autocompletion script for fish", Long: fishCompDesc, Args: require.NoArgs, - ValidArgsFunction: noCompletions, - RunE: func(cmd *cobra.Command, args []string) error { + ValidArgsFunction: noMoreArgsCompFunc, + RunE: func(cmd *cobra.Command, _ []string) error { return runCompletionFish(out, cmd) }, } @@ -138,8 +138,8 @@ func newCompletionCmd(out io.Writer) *cobra.Command { Short: "generate autocompletion script for powershell", Long: powershellCompDesc, Args: require.NoArgs, - ValidArgsFunction: noCompletions, - RunE: func(cmd *cobra.Command, args []string) error { + ValidArgsFunction: noMoreArgsCompFunc, + RunE: func(cmd *cobra.Command, _ []string) error { return runCompletionPowershell(out, cmd) }, } @@ -209,7 +209,15 @@ func runCompletionPowershell(out io.Writer, cmd *cobra.Command) error { return cmd.Root().GenPowerShellCompletionWithDesc(out) } -// Function to disable file completion -func noCompletions(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { - return nil, cobra.ShellCompDirectiveNoFileComp +// noMoreArgsCompFunc deactivates file completion when doing argument shell completion. +// It also provides some ActiveHelp to indicate no more arguments are accepted. +func noMoreArgsCompFunc(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return noMoreArgsComp() +} + +// noMoreArgsComp deactivates file completion when doing argument shell completion. +// It also provides some ActiveHelp to indicate no more arguments are accepted. +func noMoreArgsComp() ([]string, cobra.ShellCompDirective) { + activeHelpMsg := "This command does not take any more arguments (but may accept flags)." + return cobra.AppendActiveHelp(nil, activeHelpMsg), cobra.ShellCompDirectiveNoFileComp } diff --git a/pkg/helm/cmd/helm/completion_test.go b/pkg/helm/pkg/cmd/completion_test.go similarity index 91% rename from pkg/helm/cmd/helm/completion_test.go rename to pkg/helm/pkg/cmd/completion_test.go index 88f22ff3..f91da976 100644 --- a/pkg/helm/cmd/helm/completion_test.go +++ b/pkg/helm/pkg/cmd/completion_test.go @@ -14,23 +14,25 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "fmt" "strings" "testing" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/release" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/release/common" + release "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) // Check if file completion should be performed according to parameter 'shouldBePerformed' func checkFileCompletion(t *testing.T, cmdName string, shouldBePerformed bool) { + t.Helper() storage := storageFixture() storage.Create(&release.Release{ Name: "myrelease", - Info: &release.Info{Status: release.StatusDeployed}, + Info: &release.Info{Status: common.StatusDeployed}, Chart: &chart.Chart{ Metadata: &chart.Metadata{ Name: "Myrelease-Chart", @@ -64,6 +66,7 @@ func TestCompletionFileCompletion(t *testing.T) { } func checkReleaseCompletion(t *testing.T, cmdName string, multiReleasesAllowed bool) { + t.Helper() multiReleaseTestGolden := "output/empty_nofile_comp.txt" if multiReleasesAllowed { multiReleaseTestGolden = "output/release_list_repeat_comp.txt" diff --git a/pkg/helm/pkg/cmd/create.go b/pkg/helm/pkg/cmd/create.go new file mode 100644 index 00000000..8b014474 --- /dev/null +++ b/pkg/helm/pkg/cmd/create.go @@ -0,0 +1,113 @@ +/* +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 cmd + +import ( + "fmt" + "io" + "path/filepath" + + "github.com/spf13/cobra" + + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + chartutil "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" + "github.com/werf/nelm/pkg/helm/pkg/cmd/require" + "github.com/werf/nelm/pkg/helm/pkg/helmpath" +) + +const createDesc = ` +This command creates a chart directory along with the common files and +directories used in a chart. + +For example, 'helm create foo' will create a directory structure that looks +something like this: + + foo/ + ├── .helmignore # Contains patterns to ignore when packaging Helm charts. + ├── Chart.yaml # Information about your chart + ├── values.yaml # The default values for your templates + ├── charts/ # Charts that this chart depends on + └── templates/ # The template files + └── tests/ # The test files + +'helm create' takes a path for an argument. If directories in the given path +do not exist, Helm will attempt to create them as it goes. If the given +destination exists and there are files in that directory, conflicting files +will be overwritten, but other files will be left alone. +` + +type createOptions struct { + starter string // --starter + name string + starterDir string +} + +func newCreateCmd(out io.Writer) *cobra.Command { + o := &createOptions{} + + cmd := &cobra.Command{ + Use: "create NAME", + Short: "create a new chart with the given name", + Long: createDesc, + Args: require.ExactArgs(1), + ValidArgsFunction: func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + // Allow file completion when completing the argument for the name + // which could be a path + return nil, cobra.ShellCompDirectiveDefault + } + // No more completions, so disable file completion + return noMoreArgsComp() + }, + RunE: func(_ *cobra.Command, args []string) error { + o.name = args[0] + o.starterDir = helmpath.DataPath("starters") + return o.run(out) + }, + } + + cmd.Flags().StringVarP(&o.starter, "starter", "p", "", "the name or absolute path to Helm starter scaffold") + return cmd +} + +func (o *createOptions) run(out io.Writer) error { + fmt.Fprintf(out, "Creating %s\n", o.name) + + chartname := filepath.Base(o.name) + cfile := &chart.Metadata{ + Name: chartname, + Description: "A Helm chart for Kubernetes", + Type: "application", + Version: "0.1.0", + AppVersion: "0.1.0", + APIVersion: chart.APIVersionV2, + } + + if o.starter != "" { + // Create from the starter + lstarter := filepath.Join(o.starterDir, o.starter) + // If path is absolute, we don't want to prefix it with helm starters folder + if filepath.IsAbs(o.starter) { + lstarter = o.starter + } + return chartutil.CreateFrom(cfile, filepath.Dir(o.name), lstarter) + } + + chartutil.Stderr = out + _, err := chartutil.Create(chartname, filepath.Dir(o.name)) + return err +} diff --git a/pkg/helm/pkg/cmd/create_test.go b/pkg/helm/pkg/cmd/create_test.go new file mode 100644 index 00000000..7c845acd --- /dev/null +++ b/pkg/helm/pkg/cmd/create_test.go @@ -0,0 +1,193 @@ +/* +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 cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/werf/nelm/pkg/helm/intern/test/ensure" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/loader" + chartutil "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" + "github.com/werf/nelm/pkg/helm/pkg/helmpath" +) + +func TestCreateCmd(t *testing.T) { + t.Chdir(t.TempDir()) + ensure.HelmHome(t) + cname := "testchart" + + // Run a create + if _, _, err := executeActionCommand("create " + cname); err != nil { + t.Fatalf("Failed to run create: %s", err) + } + + // Test that the chart is there + if fi, err := os.Stat(cname); err != nil { + t.Fatalf("no chart directory: %s", err) + } else if !fi.IsDir() { + t.Fatalf("chart is not directory") + } + + c, err := loader.LoadDir(context.Background(), cname) + if err != nil { + t.Fatal(err) + } + + if c.Name() != cname { + t.Errorf("Expected %q name, got %q", cname, c.Name()) + } + if c.Metadata.APIVersion != chart.APIVersionV2 { + t.Errorf("Wrong API version: %q", c.Metadata.APIVersion) + } +} + +func TestCreateStarterCmd(t *testing.T) { + t.Chdir(t.TempDir()) + ensure.HelmHome(t) + cname := "testchart" + defer resetEnv()() + // Create a starter. + starterchart := helmpath.DataPath("starters") + os.MkdirAll(starterchart, 0o755) + if dest, err := chartutil.Create("starterchart", starterchart); err != nil { + t.Fatalf("Could not create chart: %s", err) + } else { + t.Logf("Created %s", dest) + } + tplpath := filepath.Join(starterchart, "starterchart", "templates", "foo.tpl") + if err := os.WriteFile(tplpath, []byte("test"), 0o644); err != nil { + t.Fatalf("Could not write template: %s", err) + } + + // Run a create + if _, _, err := executeActionCommand(fmt.Sprintf("create --starter=starterchart %s", cname)); err != nil { + t.Errorf("Failed to run create: %s", err) + return + } + + // Test that the chart is there + if fi, err := os.Stat(cname); err != nil { + t.Fatalf("no chart directory: %s", err) + } else if !fi.IsDir() { + t.Fatalf("chart is not directory") + } + + c, err := loader.LoadDir(context.Background(), cname) + if err != nil { + t.Fatal(err) + } + + if c.Name() != cname { + t.Errorf("Expected %q name, got %q", cname, c.Name()) + } + if c.Metadata.APIVersion != chart.APIVersionV2 { + t.Errorf("Wrong API version: %q", c.Metadata.APIVersion) + } + + expectedNumberOfTemplates := 10 + if l := len(c.Templates); l != expectedNumberOfTemplates { + t.Errorf("Expected %d templates, got %d", expectedNumberOfTemplates, l) + } + + found := false + for _, tpl := range c.Templates { + if tpl.Name == "templates/foo.tpl" { + found = true + if data := string(tpl.Data); data != "test" { + t.Errorf("Expected template 'test', got %q", data) + } + } + } + if !found { + t.Error("Did not find foo.tpl") + } +} + +func TestCreateStarterAbsoluteCmd(t *testing.T) { + t.Chdir(t.TempDir()) + defer resetEnv()() + ensure.HelmHome(t) + cname := "testchart" + + // Create a starter. + starterchart := helmpath.DataPath("starters") + os.MkdirAll(starterchart, 0o755) + if dest, err := chartutil.Create("starterchart", starterchart); err != nil { + t.Fatalf("Could not create chart: %s", err) + } else { + t.Logf("Created %s", dest) + } + tplpath := filepath.Join(starterchart, "starterchart", "templates", "foo.tpl") + if err := os.WriteFile(tplpath, []byte("test"), 0o644); err != nil { + t.Fatalf("Could not write template: %s", err) + } + + starterChartPath := filepath.Join(starterchart, "starterchart") + + // Run a create + if _, _, err := executeActionCommand(fmt.Sprintf("create --starter=%s %s", starterChartPath, cname)); err != nil { + t.Errorf("Failed to run create: %s", err) + return + } + + // Test that the chart is there + if fi, err := os.Stat(cname); err != nil { + t.Fatalf("no chart directory: %s", err) + } else if !fi.IsDir() { + t.Fatalf("chart is not directory") + } + + c, err := loader.LoadDir(context.Background(), cname) + if err != nil { + t.Fatal(err) + } + + if c.Name() != cname { + t.Errorf("Expected %q name, got %q", cname, c.Name()) + } + if c.Metadata.APIVersion != chart.APIVersionV2 { + t.Errorf("Wrong API version: %q", c.Metadata.APIVersion) + } + + expectedNumberOfTemplates := 10 + if l := len(c.Templates); l != expectedNumberOfTemplates { + t.Errorf("Expected %d templates, got %d", expectedNumberOfTemplates, l) + } + + found := false + for _, tpl := range c.Templates { + if tpl.Name == "templates/foo.tpl" { + found = true + if data := string(tpl.Data); data != "test" { + t.Errorf("Expected template 'test', got %q", data) + } + } + } + if !found { + t.Error("Did not find foo.tpl") + } +} + +func TestCreateFileCompletion(t *testing.T) { + checkFileCompletion(t, "create", true) + checkFileCompletion(t, "create myname", false) +} diff --git a/pkg/helm/cmd/helm/dependency.go b/pkg/helm/pkg/cmd/dependency.go similarity index 71% rename from pkg/helm/cmd/helm/dependency.go rename to pkg/helm/pkg/cmd/dependency.go index 8b9b62b0..95281df4 100644 --- a/pkg/helm/cmd/helm/dependency.go +++ b/pkg/helm/pkg/cmd/dependency.go @@ -13,17 +13,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "io" "path/filepath" "github.com/spf13/cobra" + "github.com/spf13/pflag" - "github.com/werf/nelm/pkg/helm/cmd/helm/require" "github.com/werf/nelm/pkg/helm/pkg/action" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" + "github.com/werf/nelm/pkg/helm/pkg/cmd/require" ) const dependencyDesc = ` @@ -94,7 +94,7 @@ func newDependencyCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { cmd.AddCommand(newDependencyListCmd(out)) cmd.AddCommand(newDependencyUpdateCmd(cfg, out)) - cmd.AddCommand(newDependencyBuildCmd(cfg, out)) + cmd.AddCommand(newDependencyBuildCmd(out)) return cmd } @@ -107,12 +107,12 @@ func newDependencyListCmd(out io.Writer) *cobra.Command { Short: "list the dependencies for the given chart", Long: dependencyListDesc, Args: require.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { chartpath := "." if len(args) > 0 { chartpath = filepath.Clean(args[0]) } - return client.List(chartpath, out, helmopts.HelmOptions{}) + return client.List(chartpath, out) }, } @@ -121,3 +121,16 @@ func newDependencyListCmd(out io.Writer) *cobra.Command { f.UintVar(&client.ColumnWidth, "max-col-width", 80, "maximum column width for output table") return cmd } + +func addDependencySubcommandFlags(f *pflag.FlagSet, client *action.Dependency) { + f.BoolVar(&client.Verify, "verify", false, "verify the packages against signatures") + f.StringVar(&client.Keyring, "keyring", defaultKeyring(), "keyring containing public keys") + f.BoolVar(&client.SkipRefresh, "skip-refresh", false, "do not refresh the local repository cache") + f.StringVar(&client.Username, "username", "", "chart repository username where to locate the requested chart") + f.StringVar(&client.Password, "password", "", "chart repository password where to locate the requested chart") + f.StringVar(&client.CertFile, "cert-file", "", "identify HTTPS client using this SSL certificate file") + f.StringVar(&client.KeyFile, "key-file", "", "identify HTTPS client using this SSL key file") + f.BoolVar(&client.InsecureSkipTLSVerify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the chart download") + f.BoolVar(&client.PlainHTTP, "plain-http", false, "use insecure HTTP connections for the chart download") + f.StringVar(&client.CaFile, "ca-file", "", "verify certificates of HTTPS-enabled servers using this CA bundle") +} diff --git a/pkg/helm/cmd/helm/dependency_build.go b/pkg/helm/pkg/cmd/dependency_build.go similarity index 76% rename from pkg/helm/cmd/helm/dependency_build.go rename to pkg/helm/pkg/cmd/dependency_build.go index cc75749e..923abe7f 100644 --- a/pkg/helm/cmd/helm/dependency_build.go +++ b/pkg/helm/pkg/cmd/dependency_build.go @@ -13,9 +13,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( + "context" "fmt" "io" "os" @@ -24,11 +25,10 @@ import ( "github.com/spf13/cobra" "k8s.io/client-go/util/homedir" - "github.com/werf/nelm/pkg/helm/cmd/helm/require" "github.com/werf/nelm/pkg/helm/pkg/action" + "github.com/werf/nelm/pkg/helm/pkg/cmd/require" "github.com/werf/nelm/pkg/helm/pkg/downloader" "github.com/werf/nelm/pkg/helm/pkg/getter" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" ) const dependencyBuildDesc = ` @@ -42,7 +42,7 @@ If no lock file is found, 'helm dependency build' will mirror the behavior of 'helm dependency update'. ` -func newDependencyBuildCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { +func newDependencyBuildCmd(out io.Writer) *cobra.Command { client := action.NewDependency() cmd := &cobra.Command{ @@ -50,34 +50,33 @@ func newDependencyBuildCmd(cfg *action.Configuration, out io.Writer) *cobra.Comm Short: "rebuild the charts/ directory based on the Chart.lock file", Long: dependencyBuildDesc, Args: require.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { chartpath := "." if len(args) > 0 { chartpath = filepath.Clean(args[0]) } + registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile, + client.InsecureSkipTLSVerify, client.PlainHTTP, client.Username, client.Password) + if err != nil { + return fmt.Errorf("missing registry client: %w", err) + } + man := &downloader.Manager{ Out: out, ChartPath: chartpath, Keyring: client.Keyring, SkipUpdate: client.SkipRefresh, Getters: getter.All(settings), - RegistryClient: cfg.RegistryClient, + RegistryClient: registryClient, RepositoryConfig: settings.RepositoryConfig, RepositoryCache: settings.RepositoryCache, + ContentCache: settings.ContentCache, Debug: settings.Debug, } if client.Verify { man.Verify = downloader.VerifyIfPossible } - - opts := helmopts.HelmOptions{ - ChartLoadOpts: helmopts.ChartLoadOptions{ - DepDownloader: man, - NoSecrets: true, - }, - } - - err := man.Build(opts) + err = man.Build(context.Background()) if e, ok := err.(downloader.ErrRepoNotFound); ok { return fmt.Errorf("%s. Please add the missing repos via 'helm repo add'", e.Error()) } @@ -86,9 +85,7 @@ func newDependencyBuildCmd(cfg *action.Configuration, out io.Writer) *cobra.Comm } f := cmd.Flags() - f.BoolVar(&client.Verify, "verify", false, "verify the packages against signatures") - f.StringVar(&client.Keyring, "keyring", defaultKeyring(), "keyring containing public keys") - f.BoolVar(&client.SkipRefresh, "skip-refresh", false, "do not refresh the local repository cache") + addDependencySubcommandFlags(f, client) return cmd } diff --git a/pkg/helm/cmd/helm/dependency_build_test.go b/pkg/helm/pkg/cmd/dependency_build_test.go similarity index 87% rename from pkg/helm/cmd/helm/dependency_build_test.go rename to pkg/helm/pkg/cmd/dependency_build_test.go index c0204721..3971ba7c 100644 --- a/pkg/helm/cmd/helm/dependency_build_test.go +++ b/pkg/helm/pkg/cmd/dependency_build_test.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "fmt" @@ -22,18 +22,18 @@ import ( "strings" "testing" - "github.com/werf/nelm/pkg/helm/pkg/chartutil" + chartutil "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" "github.com/werf/nelm/pkg/helm/pkg/provenance" - "github.com/werf/nelm/pkg/helm/pkg/repo" - "github.com/werf/nelm/pkg/helm/pkg/repo/repotest" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1/repotest" ) func TestDependencyBuildCmd(t *testing.T) { - srv, err := repotest.NewTempServerWithCleanup(t, "testdata/testcharts/*.tgz") + srv := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/testcharts/*.tgz"), + ) defer srv.Stop() - if err != nil { - t.Fatal(err) - } rootDir := srv.Root() srv.LinkIndices() @@ -58,7 +58,7 @@ func TestDependencyBuildCmd(t *testing.T) { createTestingChart(t, rootDir, chartname, srv.URL()) repoFile := filepath.Join(rootDir, "repositories.yaml") - cmd := fmt.Sprintf("dependency build '%s' --repository-config %s --repository-cache %s", filepath.Join(rootDir, chartname), repoFile, rootDir) + cmd := fmt.Sprintf("dependency build '%s' --repository-config %s --repository-cache %s --plain-http", filepath.Join(rootDir, chartname), repoFile, rootDir) _, out, err := executeActionCommand(cmd) // In the first pass, we basically want the same results as an update. @@ -117,7 +117,7 @@ func TestDependencyBuildCmd(t *testing.T) { t.Errorf("mismatched versions. Expected %q, got %q", "0.1.0", v) } - skipRefreshCmd := fmt.Sprintf("dependency build '%s' --skip-refresh --repository-config %s --repository-cache %s", filepath.Join(rootDir, chartname), repoFile, rootDir) + skipRefreshCmd := fmt.Sprintf("dependency build '%s' --skip-refresh --repository-config %s --repository-cache %s --plain-http", filepath.Join(rootDir, chartname), repoFile, rootDir) _, out, err = executeActionCommand(skipRefreshCmd) // In this pass, we check --skip-refresh option becomes effective. @@ -134,7 +134,7 @@ func TestDependencyBuildCmd(t *testing.T) { if err := chartutil.SaveDir(c, dir()); err != nil { t.Fatal(err) } - cmd = fmt.Sprintf("dependency build '%s' --repository-config %s --repository-cache %s --registry-config %s/config.json", + cmd = fmt.Sprintf("dependency build '%s' --repository-config %s --repository-cache %s --registry-config %s/config.json --plain-http", dir(ociChartName), dir("repositories.yaml"), dir(), diff --git a/pkg/helm/cmd/helm/dependency_test.go b/pkg/helm/pkg/cmd/dependency_test.go similarity index 99% rename from pkg/helm/cmd/helm/dependency_test.go rename to pkg/helm/pkg/cmd/dependency_test.go index b0b6f3b0..d6bcebf1 100644 --- a/pkg/helm/cmd/helm/dependency_test.go +++ b/pkg/helm/pkg/cmd/dependency_test.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "runtime" diff --git a/pkg/helm/cmd/helm/dependency_update.go b/pkg/helm/pkg/cmd/dependency_update.go similarity index 75% rename from pkg/helm/cmd/helm/dependency_update.go rename to pkg/helm/pkg/cmd/dependency_update.go index f6daa45c..a89ba707 100644 --- a/pkg/helm/cmd/helm/dependency_update.go +++ b/pkg/helm/pkg/cmd/dependency_update.go @@ -13,19 +13,20 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( + "context" + "fmt" "io" "path/filepath" "github.com/spf13/cobra" - "github.com/werf/nelm/pkg/helm/cmd/helm/require" "github.com/werf/nelm/pkg/helm/pkg/action" + "github.com/werf/nelm/pkg/helm/pkg/cmd/require" "github.com/werf/nelm/pkg/helm/pkg/downloader" "github.com/werf/nelm/pkg/helm/pkg/getter" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" ) const dependencyUpDesc = ` @@ -44,7 +45,7 @@ in the Chart.yaml file, but (b) at the wrong version. ` // newDependencyUpdateCmd creates a new dependency update command. -func newDependencyUpdateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { +func newDependencyUpdateCmd(_ *action.Configuration, out io.Writer) *cobra.Command { client := action.NewDependency() cmd := &cobra.Command{ @@ -53,41 +54,38 @@ func newDependencyUpdateCmd(cfg *action.Configuration, out io.Writer) *cobra.Com Short: "update charts/ based on the contents of Chart.yaml", Long: dependencyUpDesc, Args: require.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { chartpath := "." if len(args) > 0 { chartpath = filepath.Clean(args[0]) } + registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile, + client.InsecureSkipTLSVerify, client.PlainHTTP, client.Username, client.Password) + if err != nil { + return fmt.Errorf("missing registry client: %w", err) + } + man := &downloader.Manager{ Out: out, ChartPath: chartpath, Keyring: client.Keyring, SkipUpdate: client.SkipRefresh, Getters: getter.All(settings), - RegistryClient: cfg.RegistryClient, + RegistryClient: registryClient, RepositoryConfig: settings.RepositoryConfig, RepositoryCache: settings.RepositoryCache, + ContentCache: settings.ContentCache, Debug: settings.Debug, } if client.Verify { man.Verify = downloader.VerifyAlways } - - opts := helmopts.HelmOptions{ - ChartLoadOpts: helmopts.ChartLoadOptions{ - DepDownloader: man, - NoSecrets: true, - }, - } - - return man.Update(opts) + return man.Update(context.Background()) }, } f := cmd.Flags() - f.BoolVar(&client.Verify, "verify", false, "verify the packages against signatures") - f.StringVar(&client.Keyring, "keyring", defaultKeyring(), "keyring containing public keys") - f.BoolVar(&client.SkipRefresh, "skip-refresh", false, "do not refresh the local repository cache") + addDependencySubcommandFlags(f, client) return cmd } diff --git a/pkg/helm/cmd/helm/dependency_update_test.go b/pkg/helm/pkg/cmd/dependency_update_test.go similarity index 82% rename from pkg/helm/cmd/helm/dependency_update_test.go rename to pkg/helm/pkg/cmd/dependency_update_test.go index f715bb0b..3f57c662 100644 --- a/pkg/helm/cmd/helm/dependency_update_test.go +++ b/pkg/helm/pkg/cmd/dependency_update_test.go @@ -13,29 +13,31 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( + "errors" "fmt" + "io/fs" "os" "path/filepath" "strings" "testing" - "github.com/werf/nelm/pkg/helm/internal/test/ensure" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chartutil" + "github.com/werf/nelm/pkg/helm/intern/test/ensure" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + chartutil "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" "github.com/werf/nelm/pkg/helm/pkg/helmpath" "github.com/werf/nelm/pkg/helm/pkg/provenance" - "github.com/werf/nelm/pkg/helm/pkg/repo" - "github.com/werf/nelm/pkg/helm/pkg/repo/repotest" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1/repotest" ) func TestDependencyUpdateCmd(t *testing.T) { - srv, err := repotest.NewTempServerWithCleanup(t, "testdata/testcharts/*.tgz") - if err != nil { - t.Fatal(err) - } + srv := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/testcharts/*.tgz"), + ) defer srv.Stop() t.Logf("Listening on directory %s", srv.Root()) @@ -43,6 +45,7 @@ func TestDependencyUpdateCmd(t *testing.T) { if err != nil { t.Fatal(err) } + contentCache := t.TempDir() ociChartName := "oci-depending-chart" c := createTestingMetadataForOCI(ociChartName, ociSrv.RegistryURL) @@ -67,7 +70,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()), + fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s --content-cache %s --plain-http", dir(chartname), dir("repositories.yaml"), dir(), contentCache), ) if err != nil { t.Logf("Output: %s", out) @@ -110,7 +113,7 @@ 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 --content-cache %s --plain-http", dir(chartname), dir("repositories.yaml"), dir(), contentCache)) if err != nil { t.Logf("Output: %s", out) t.Fatal(err) @@ -131,11 +134,12 @@ func TestDependencyUpdateCmd(t *testing.T) { if err := chartutil.SaveDir(c, dir()); err != nil { t.Fatal(err) } - cmd := fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s --registry-config %s/config.json", + cmd := fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s --registry-config %s/config.json --content-cache %s --plain-http", dir(ociChartName), dir("repositories.yaml"), dir(), - dir()) + dir(), + contentCache) _, out, err = executeActionCommand(cmd) if err != nil { t.Logf("Output: %s", out) @@ -151,10 +155,10 @@ func TestDependencyUpdateCmd_DoNotDeleteOldChartsOnError(t *testing.T) { defer resetEnv()() ensure.HelmHome(t) - srv, err := repotest.NewTempServerWithCleanup(t, "testdata/testcharts/*.tgz") - if err != nil { - t.Fatal(err) - } + srv := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/testcharts/*.tgz"), + ) defer srv.Stop() t.Logf("Listening on directory %s", srv.Root()) @@ -169,7 +173,7 @@ 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 --plain-http", dir(chartname), dir("repositories.yaml"), dir())) if err != nil { t.Logf("Output: %s", output) t.Fatal(err) @@ -177,8 +181,9 @@ func TestDependencyUpdateCmd_DoNotDeleteOldChartsOnError(t *testing.T) { // Chart repo is down srv.Stop() + contentCache := t.TempDir() - _, 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 --content-cache %s --plain-http", dir(chartname), dir("repositories.yaml"), dir(), contentCache)) if err == nil { t.Logf("Output: %s", output) t.Fatal("Expected error, got nil") @@ -200,8 +205,9 @@ func TestDependencyUpdateCmd_DoNotDeleteOldChartsOnError(t *testing.T) { } } - // Make sure tmpcharts is deleted - if _, err := os.Stat(filepath.Join(dir(chartname), "tmpcharts")); !os.IsNotExist(err) { + // Make sure tmpcharts-x is deleted + tmpPath := filepath.Join(dir(chartname), fmt.Sprintf("tmpcharts-%d", os.Getpid())) + if _, err := os.Stat(tmpPath); !errors.Is(err, fs.ErrNotExist) { t.Fatalf("tmpcharts dir still exists") } } @@ -229,9 +235,11 @@ func TestDependencyUpdateCmd_WithRepoThatWasNotAdded(t *testing.T) { t.Fatal(err) } + contentCache := t.TempDir() + _, out, err := executeActionCommand( - fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s", dir(chartname), - dir("repositories.yaml"), dir()), + fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s --content-cache %s", dir(chartname), + dir("repositories.yaml"), dir(), contentCache), ) if err != nil { @@ -247,10 +255,11 @@ func TestDependencyUpdateCmd_WithRepoThatWasNotAdded(t *testing.T) { } func setupMockRepoServer(t *testing.T) *repotest.Server { - srv, err := repotest.NewTempServerWithCleanup(t, "testdata/testcharts/*.tgz") - if err != nil { - t.Fatal(err) - } + t.Helper() + srv := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/testcharts/*.tgz"), + ) t.Logf("Listening on directory %s", srv.Root()) diff --git a/pkg/helm/pkg/cmd/env.go b/pkg/helm/pkg/cmd/env.go new file mode 100644 index 00000000..91592529 --- /dev/null +++ b/pkg/helm/pkg/cmd/env.go @@ -0,0 +1,76 @@ +/* +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 cmd + +import ( + "fmt" + "io" + "sort" + + "github.com/spf13/cobra" + + "github.com/werf/nelm/pkg/helm/pkg/cmd/require" +) + +var envHelp = ` +Env prints out all the environment information in use by Helm. +` + +func newEnvCmd(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "env", + Short: "helm client environment information", + Long: envHelp, + Args: require.MaximumNArgs(1), + ValidArgsFunction: func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + keys := getSortedEnvVarKeys() + return keys, cobra.ShellCompDirectiveNoFileComp + } + + return noMoreArgsComp() + }, + Run: func(_ *cobra.Command, args []string) { + envVars := settings.EnvVars() + + if len(args) == 0 { + // Sort the variables by alphabetical order. + // This allows for a constant output across calls to 'helm env'. + keys := getSortedEnvVarKeys() + + for _, k := range keys { + fmt.Fprintf(out, "%s=\"%s\"\n", k, envVars[k]) + } + } else { + fmt.Fprintf(out, "%s\n", envVars[args[0]]) + } + }, + } + return cmd +} + +func getSortedEnvVarKeys() []string { + envVars := settings.EnvVars() + + var keys []string + for k := range envVars { + keys = append(keys, k) + } + sort.Strings(keys) + + return keys +} diff --git a/pkg/helm/pkg/cmd/env_test.go b/pkg/helm/pkg/cmd/env_test.go new file mode 100644 index 00000000..c5d7af1b --- /dev/null +++ b/pkg/helm/pkg/cmd/env_test.go @@ -0,0 +1,35 @@ +/* +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 cmd + +import ( + "testing" +) + +func TestEnv(t *testing.T) { + tests := []cmdTestCase{{ + name: "completion for env", + cmd: "__complete env ''", + golden: "output/env-comp.txt", + }} + runTestCmd(t, tests) +} + +func TestEnvFileCompletion(t *testing.T) { + checkFileCompletion(t, "env", false) + checkFileCompletion(t, "env HELM_BIN", false) +} diff --git a/pkg/helm/cmd/helm/flags.go b/pkg/helm/pkg/cmd/flags.go similarity index 69% rename from pkg/helm/cmd/helm/flags.go rename to pkg/helm/pkg/cmd/flags.go index c80e8f11..d77f889f 100644 --- a/pkg/helm/cmd/helm/flags.go +++ b/pkg/helm/pkg/cmd/flags.go @@ -14,32 +14,32 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "flag" "fmt" "log" + "log/slog" "path/filepath" "sort" "strings" "github.com/spf13/cobra" "github.com/spf13/pflag" + "k8s.io/klog/v2" "github.com/werf/nelm/pkg/helm/pkg/action" "github.com/werf/nelm/pkg/helm/pkg/cli/output" "github.com/werf/nelm/pkg/helm/pkg/cli/values" "github.com/werf/nelm/pkg/helm/pkg/helmpath" - "github.com/werf/nelm/pkg/helm/pkg/postrender" - "github.com/werf/nelm/pkg/helm/pkg/repo" + "github.com/werf/nelm/pkg/helm/pkg/kube" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1" ) const ( - outputFlag = "output" - postRenderFlag = "post-renderer" - postRenderArgsFlag = "post-renderer-args" + outputFlag = "output" ) func addValueOptionsFlags(f *pflag.FlagSet, v *values.Options) { @@ -47,10 +47,56 @@ func addValueOptionsFlags(f *pflag.FlagSet, v *values.Options) { f.StringArrayVar(&v.Values, "set", []string{}, "set values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2)") f.StringArrayVar(&v.StringValues, "set-string", []string{}, "set STRING values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2)") f.StringArrayVar(&v.FileValues, "set-file", []string{}, "set values from respective files specified via the command line (can specify multiple or separate values with commas: key1=path1,key2=path2)") - f.StringArrayVar(&v.JSONValues, "set-json", []string{}, "set JSON values on the command line (can specify multiple or separate values with commas: key1=jsonval1,key2=jsonval2)") + f.StringArrayVar(&v.JSONValues, "set-json", []string{}, "set JSON values on the command line (can specify multiple or separate values with commas: key1=jsonval1,key2=jsonval2 or using json format: {\"key1\": jsonval1, \"key2\": \"jsonval2\"})") f.StringArrayVar(&v.LiteralValues, "set-literal", []string{}, "set a literal STRING value on the command line") } +func AddWaitFlag(cmd *cobra.Command, wait *kube.WaitStrategy) { + cmd.Flags().Var( + newWaitValue(kube.HookOnlyStrategy, wait), + "wait", + "if specified, wait until resources are ready (up to --timeout). Values: 'watcher', 'hookOnly', and 'legacy'.", + ) + // Sets the strategy to use the watcher strategy if `--wait` is used without an argument + cmd.Flags().Lookup("wait").NoOptDefVal = string(kube.StatusWatcherStrategy) +} + +type waitValue kube.WaitStrategy + +func newWaitValue(defaultValue kube.WaitStrategy, ws *kube.WaitStrategy) *waitValue { + *ws = defaultValue + return (*waitValue)(ws) +} + +func (ws *waitValue) String() string { + if ws == nil { + return "" + } + return string(*ws) +} + +func (ws *waitValue) Set(s string) error { + switch s { + case string(kube.StatusWatcherStrategy), string(kube.LegacyStrategy), string(kube.HookOnlyStrategy): + *ws = waitValue(s) + return nil + case "true": + slog.Warn("--wait=true is deprecated (boolean value) and can be replaced with --wait=watcher") + *ws = waitValue(kube.StatusWatcherStrategy) + return nil + case "false": + slog.Warn("--wait=false is deprecated (boolean value) and can be replaced with --wait=hookOnly") + *ws = waitValue(kube.HookOnlyStrategy) + return nil + default: + return fmt.Errorf("invalid wait input %q. Valid inputs are %s, %s, and %s", s, kube.StatusWatcherStrategy, kube.HookOnlyStrategy, kube.LegacyStrategy) + } +} + +func (ws *waitValue) Type() string { + return "WaitStrategy" +} + func addChartPathOptionsFlags(f *pflag.FlagSet, c *action.ChartPathOptions) { f.StringVar(&c.Version, "version", "", "specify a version constraint for the chart version to use. This constraint can be a specific tag (e.g. 1.1.1) or it may reference a valid range (e.g. ^2.0.0). If this is not specified, the latest version is used") f.BoolVar(&c.Verify, "verify", false, "verify the package before using it") @@ -60,7 +106,7 @@ func addChartPathOptionsFlags(f *pflag.FlagSet, c *action.ChartPathOptions) { f.StringVar(&c.Password, "password", "", "chart repository password where to locate the requested chart") f.StringVar(&c.CertFile, "cert-file", "", "identify HTTPS client using this SSL certificate file") f.StringVar(&c.KeyFile, "key-file", "", "identify HTTPS client using this SSL key file") - f.BoolVar(&c.InsecureSkipTLSverify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the chart download") + f.BoolVar(&c.InsecureSkipTLSVerify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the chart download") f.BoolVar(&c.PlainHTTP, "plain-http", false, "use insecure HTTP connections for the chart download") f.StringVar(&c.CaFile, "ca-file", "", "verify certificates of HTTPS-enabled servers using this CA bundle") f.BoolVar(&c.PassCredentialsAll, "pass-credentials", false, "pass credentials to all domains") @@ -72,7 +118,7 @@ func bindOutputFlag(cmd *cobra.Command, varRef *output.Format) { cmd.Flags().VarP(newOutputValue(output.Table, varRef), outputFlag, "o", fmt.Sprintf("prints the output in the specified format. Allowed values: %s", strings.Join(output.Formats(), ", "))) - err := cmd.RegisterFlagCompletionFunc(outputFlag, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + err := cmd.RegisterFlagCompletionFunc(outputFlag, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { var formatNames []string for format, desc := range output.FormatsWithDesc() { formatNames = append(formatNames, fmt.Sprintf("%s\t%s", format, desc)) @@ -115,86 +161,6 @@ func (o *outputValue) Set(s string) error { return nil } -func bindPostRenderFlag(cmd *cobra.Command, varRef *postrender.PostRenderer) { - p := &postRendererOptions{varRef, "", []string{}} - cmd.Flags().Var(&postRendererString{p}, postRenderFlag, "the path to an executable to be used for post rendering. If it exists in $PATH, the binary will be used, otherwise it will try to look for the executable at the given path") - cmd.Flags().Var(&postRendererArgsSlice{p}, postRenderArgsFlag, "an argument to the post-renderer (can specify multiple)") -} - -type postRendererOptions struct { - renderer *postrender.PostRenderer - binaryPath string - args []string -} - -type postRendererString struct { - options *postRendererOptions -} - -func (p *postRendererString) String() string { - return p.options.binaryPath -} - -func (p *postRendererString) Type() string { - return "postRendererString" -} - -func (p *postRendererString) Set(val string) error { - if val == "" { - return nil - } - p.options.binaryPath = val - pr, err := postrender.NewExec(p.options.binaryPath, p.options.args...) - if err != nil { - return err - } - *p.options.renderer = pr - return nil -} - -type postRendererArgsSlice struct { - options *postRendererOptions -} - -func (p *postRendererArgsSlice) String() string { - return "[" + strings.Join(p.options.args, ",") + "]" -} - -func (p *postRendererArgsSlice) Type() string { - return "postRendererArgsSlice" -} - -func (p *postRendererArgsSlice) Set(val string) error { - - // a post-renderer defined by a user may accept empty arguments - p.options.args = append(p.options.args, val) - - if p.options.binaryPath == "" { - return nil - } - // overwrite if already create PostRenderer by `post-renderer` flags - pr, err := postrender.NewExec(p.options.binaryPath, p.options.args...) - if err != nil { - return err - } - *p.options.renderer = pr - return nil -} - -func (p *postRendererArgsSlice) Append(val string) error { - p.options.args = append(p.options.args, val) - return nil -} - -func (p *postRendererArgsSlice) Replace(val []string) error { - p.options.args = val - return nil -} - -func (p *postRendererArgsSlice) GetSlice() []string { - return p.options.args -} - func compVersionFlag(chartRef string, _ string) ([]string, cobra.ShellCompDirective) { chartInfo := strings.Split(chartRef, "/") if len(chartInfo) != 2 { @@ -209,7 +175,7 @@ func compVersionFlag(chartRef string, _ string) ([]string, cobra.ShellCompDirect var versions []string if indexFile, err := repo.LoadIndexFile(path); err == nil { for _, details := range indexFile.Entries[chartName] { - appVersion := details.Metadata.AppVersion + appVersion := details.AppVersion appVersionDesc := "" if appVersion != "" { appVersionDesc = fmt.Sprintf("App: %s, ", appVersion) @@ -220,10 +186,10 @@ func compVersionFlag(chartRef string, _ string) ([]string, cobra.ShellCompDirect createdDesc = fmt.Sprintf("Created: %s ", created) } deprecated := "" - if details.Metadata.Deprecated { + if details.Deprecated { deprecated = "(deprecated)" } - versions = append(versions, fmt.Sprintf("%s\t%s%s%s", details.Metadata.Version, appVersionDesc, createdDesc, deprecated)) + versions = append(versions, fmt.Sprintf("%s\t%s%s%s", details.Version, appVersionDesc, createdDesc, deprecated)) } } diff --git a/pkg/helm/pkg/cmd/helpers.go b/pkg/helm/pkg/cmd/helpers.go new file mode 100644 index 00000000..ce218094 --- /dev/null +++ b/pkg/helm/pkg/cmd/helpers.go @@ -0,0 +1,83 @@ +/* +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 cmd + +import ( + "fmt" + "log/slog" + "strconv" + + "github.com/spf13/cobra" + + "github.com/werf/nelm/pkg/helm/pkg/action" +) + +func addDryRunFlag(cmd *cobra.Command) { + // --dry-run options with expected outcome: + // - Not set means no dry run and server is contacted. + // - Set with no value, a value of client, or a value of true and the server is not contacted + // - Set with a value of false, none, or false and the server is contacted + // The true/false part is meant to reflect some legacy behavior while none is equal to "". + f := cmd.Flags() + f.String( + "dry-run", + "none", + `simulates the operation without persisting changes. Must be one of: "none" (default), "client", or "server". '--dry-run=none' executes the operation normally and persists changes (no simulation). '--dry-run=client' simulates the operation client-side only and avoids cluster connections. '--dry-run=server' simulates the operation on the server, requiring cluster connectivity.`) + f.Lookup("dry-run").NoOptDefVal = "unset" +} + +// Determine the `action.DryRunStrategy` given -dry-run=` flag (or absence of) +// Legacy usage of the flag: boolean values, and `--dry-run` (without value) are supported, and log warnings emitted +func cmdGetDryRunFlagStrategy(cmd *cobra.Command, isTemplate bool) (action.DryRunStrategy, error) { + + f := cmd.Flag("dry-run") + v := f.Value.String() + + switch v { + case f.NoOptDefVal: + slog.Warn(`--dry-run is deprecated and should be replaced with '--dry-run=client'`) + return action.DryRunClient, nil + case string(action.DryRunClient): + return action.DryRunClient, nil + case string(action.DryRunServer): + return action.DryRunServer, nil + case string(action.DryRunNone): + if isTemplate { + // Special case hack for `helm template`, which is always a dry run + return action.DryRunNone, fmt.Errorf(`invalid dry-run value (%q). Must be "server" or "client"`, v) + } + return action.DryRunNone, nil + } + + b, err := strconv.ParseBool(v) + if err != nil { + return action.DryRunNone, fmt.Errorf(`invalid dry-run value (%q). Must be "none", "server", or "client"`, v) + } + + if isTemplate && !b { + // Special case for `helm template`, which is always a dry run + return action.DryRunNone, fmt.Errorf(`invalid dry-run value (%q). Must be "server" or "client"`, v) + } + + result := action.DryRunNone + if b { + result = action.DryRunClient + } + slog.Warn(fmt.Sprintf(`boolean '--dry-run=%v' flag is deprecated and must be replaced with '--dry-run=%s'`, v, result)) + + return result, nil +} diff --git a/pkg/helm/cmd/helm/helm_test.go b/pkg/helm/pkg/cmd/helpers_test.go similarity index 57% rename from pkg/helm/cmd/helm/helm_test.go rename to pkg/helm/pkg/cmd/helpers_test.go index 857e7045..a36e31a9 100644 --- a/pkg/helm/cmd/helm/helm_test.go +++ b/pkg/helm/pkg/cmd/helpers_test.go @@ -14,29 +14,30 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "bytes" + "fmt" "io" "os" - "os/exec" - "runtime" "strings" "testing" + "time" shellwords "github.com/mattn/go-shellwords" "github.com/spf13/cobra" - "github.com/werf/nelm/pkg/helm/internal/test" + "github.com/werf/nelm/pkg/helm/intern/test" "github.com/werf/nelm/pkg/helm/pkg/action" - "github.com/werf/nelm/pkg/helm/pkg/chartutil" + chartcommon "github.com/werf/nelm/pkg/helm/pkg/chart/common" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" "github.com/werf/nelm/pkg/helm/pkg/cli" kubefake "github.com/werf/nelm/pkg/helm/pkg/kube/fake" - "github.com/werf/nelm/pkg/helm/pkg/release" + "github.com/werf/nelm/pkg/helm/pkg/release/common" + release "github.com/werf/nelm/pkg/helm/pkg/release/v1" "github.com/werf/nelm/pkg/helm/pkg/storage" "github.com/werf/nelm/pkg/helm/pkg/storage/driver" - "github.com/werf/nelm/pkg/helm/pkg/time" ) func testTimestamper() time.Time { return time.Unix(242085845, 0).UTC() } @@ -93,11 +94,10 @@ func executeActionCommandStdinC(store *storage.Storage, in *os.File, cmd string) actionConfig := &action.Configuration{ Releases: store, KubeClient: &kubefake.PrintingKubeClient{Out: io.Discard}, - Capabilities: chartutil.DefaultCapabilities, - Log: func(format string, v ...interface{}) {}, + Capabilities: chartcommon.DefaultCapabilities, } - root, err := newRootCmd(actionConfig, buf, args) + root, err := newRootCmdWithConfig(actionConfig, buf, args, SetupLogging) if err != nil { return nil, "", err } @@ -107,6 +107,10 @@ func executeActionCommandStdinC(store *storage.Storage, in *os.File, cmd string) root.SetArgs(args) oldStdin := os.Stdin + defer func() { + os.Stdin = oldStdin + }() + if in != nil { root.SetIn(in) os.Stdin = in @@ -119,8 +123,6 @@ func executeActionCommandStdinC(store *storage.Storage, in *os.File, cmd string) result := buf.String() - os.Stdin = oldStdin - return c, result, err } @@ -153,70 +155,72 @@ func resetEnv() func() { } } -func testChdir(t *testing.T, dir string) func() { +func outputFlagCompletionTest(t *testing.T, cmdName string) { t.Helper() - old, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - if err := os.Chdir(dir); err != nil { - t.Fatal(err) - } - return func() { os.Chdir(old) } -} - -func TestPluginExitCode(t *testing.T) { - if os.Getenv("RUN_MAIN_FOR_TESTING") == "1" { - os.Args = []string{"helm", "exitwith", "2"} - - // We DO call helm's main() here. So this looks like a normal `helm` process. - main() - - // As main calls os.Exit, we never reach this line. - // But the test called this block of code catches and verifies the exit code. - return + releasesMockWithStatus := func(info *release.Info, hooks ...*release.Hook) []*release.Release { + info.LastDeployed = time.Unix(1452902400, 0).UTC() + return []*release.Release{{ + Name: "athos", + Namespace: "default", + Info: info, + Chart: &chart.Chart{}, + Hooks: hooks, + }, { + Name: "porthos", + Namespace: "default", + Info: info, + Chart: &chart.Chart{}, + Hooks: hooks, + }, { + Name: "aramis", + Namespace: "default", + Info: info, + Chart: &chart.Chart{}, + Hooks: hooks, + }, { + Name: "dartagnan", + Namespace: "gascony", + Info: info, + Chart: &chart.Chart{}, + Hooks: hooks, + }} } - // Currently, plugins assume a Linux subsystem. Skip the execution - // tests until this is fixed - if runtime.GOOS != "windows" { - // Do a second run of this specific test(TestPluginExitCode) with RUN_MAIN_FOR_TESTING=1 set, - // So that the second run is able to run main() and this first run can verify the exit status returned by that. - // - // This technique originates from https://talks.golang.org/2014/testing.slide#23. - cmd := exec.Command(os.Args[0], "-test.run=TestPluginExitCode") - cmd.Env = append( - os.Environ(), - "RUN_MAIN_FOR_TESTING=1", - // See pkg/cli/environment.go for which envvars can be used for configuring these passes - // and also see plugin_test.go for how a plugin env can be set up. - // We just does the same setup as plugin_test.go via envvars - "HELM_PLUGINS=testdata/helmhome/helm/plugins", - "HELM_REPOSITORY_CONFIG=testdata/helmhome/helm/repositories.yaml", - "HELM_REPOSITORY_CACHE=testdata/helmhome/helm/repository", - ) - stdout := &bytes.Buffer{} - stderr := &bytes.Buffer{} - cmd.Stdout = stdout - cmd.Stderr = stderr - err := cmd.Run() - exiterr, ok := err.(*exec.ExitError) - - if !ok { - t.Fatalf("Unexpected error returned by os.Exit: %T", err) - } - - if stdout.String() != "" { - t.Errorf("Expected no write to stdout: Got %q", stdout.String()) - } - - expectedStderr := "Error: plugin \"exitwith\" exited with error\n" - if stderr.String() != expectedStderr { - t.Errorf("Expected %q written to stderr: Got %q", expectedStderr, stderr.String()) - } - - if exiterr.ExitCode() != 2 { - t.Errorf("Expected exit code 2: Got %d", exiterr.ExitCode()) - } - } + tests := []cmdTestCase{{ + name: "completion for output flag long and before arg", + cmd: fmt.Sprintf("__complete %s --output ''", cmdName), + golden: "output/output-comp.txt", + rels: releasesMockWithStatus(&release.Info{ + Status: common.StatusDeployed, + }), + }, { + name: "completion for output flag long and after arg", + cmd: fmt.Sprintf("__complete %s aramis --output ''", cmdName), + golden: "output/output-comp.txt", + rels: releasesMockWithStatus(&release.Info{ + Status: common.StatusDeployed, + }), + }, { + name: "completion for output flag short and before arg", + cmd: fmt.Sprintf("__complete %s -o ''", cmdName), + golden: "output/output-comp.txt", + rels: releasesMockWithStatus(&release.Info{ + Status: common.StatusDeployed, + }), + }, { + name: "completion for output flag short and after arg", + cmd: fmt.Sprintf("__complete %s aramis -o ''", cmdName), + golden: "output/output-comp.txt", + rels: releasesMockWithStatus(&release.Info{ + Status: common.StatusDeployed, + }), + }, { + name: "completion for output flag, no filter", + cmd: fmt.Sprintf("__complete %s --output jso", cmdName), + golden: "output/output-comp.txt", + rels: releasesMockWithStatus(&release.Info{ + Status: common.StatusDeployed, + }), + }} + runTestCmd(t, tests) } diff --git a/pkg/helm/cmd/helm/history.go b/pkg/helm/pkg/cmd/history.go similarity index 60% rename from pkg/helm/cmd/helm/history.go rename to pkg/helm/pkg/cmd/history.go index c379d26e..902a0628 100644 --- a/pkg/helm/cmd/helm/history.go +++ b/pkg/helm/pkg/cmd/history.go @@ -14,9 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( + "encoding/json" "fmt" "io" "strconv" @@ -25,13 +26,12 @@ import ( "github.com/gosuri/uitable" "github.com/spf13/cobra" - "github.com/werf/nelm/pkg/helm/cmd/helm/require" "github.com/werf/nelm/pkg/helm/pkg/action" - "github.com/werf/nelm/pkg/helm/pkg/chart" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" "github.com/werf/nelm/pkg/helm/pkg/cli/output" - "github.com/werf/nelm/pkg/helm/pkg/release" - "github.com/werf/nelm/pkg/helm/pkg/releaseutil" - helmtime "github.com/werf/nelm/pkg/helm/pkg/time" + "github.com/werf/nelm/pkg/helm/pkg/cmd/require" + release "github.com/werf/nelm/pkg/helm/pkg/release/v1" + releaseutil "github.com/werf/nelm/pkg/helm/pkg/release/v1/util" ) var historyHelp = ` @@ -60,13 +60,13 @@ func newHistoryCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { Short: "fetch release history", Aliases: []string{"hist"}, Args: require.ExactArgs(1), - ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) != 0 { - return nil, cobra.ShellCompDirectiveNoFileComp + return noMoreArgsComp() } return compListReleases(toComplete, args, cfg) }, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { history, err := getHistory(client, args[0]) if err != nil { return err @@ -84,12 +84,81 @@ func newHistoryCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } type releaseInfo struct { - Revision int `json:"revision"` - Updated helmtime.Time `json:"updated"` - Status string `json:"status"` - Chart string `json:"chart"` - AppVersion string `json:"app_version"` - Description string `json:"description"` + Revision int `json:"revision"` + Updated time.Time `json:"updated,omitzero"` + Status string `json:"status"` + Chart string `json:"chart"` + AppVersion string `json:"app_version"` + Description string `json:"description"` +} + +// releaseInfoJSON is used for custom JSON marshaling/unmarshaling +type releaseInfoJSON struct { + Revision int `json:"revision"` + Updated *time.Time `json:"updated,omitempty"` + Status string `json:"status"` + Chart string `json:"chart"` + AppVersion string `json:"app_version"` + Description string `json:"description"` +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +// It handles empty string time fields by treating them as zero values. +func (r *releaseInfo) UnmarshalJSON(data []byte) error { + // First try to unmarshal into a map to handle empty string time fields + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + // Replace empty string time fields with nil + if val, ok := raw["updated"]; ok { + if str, ok := val.(string); ok && str == "" { + raw["updated"] = nil + } + } + + // Re-marshal with cleaned data + cleaned, err := json.Marshal(raw) + if err != nil { + return err + } + + // Unmarshal into temporary struct with pointer time field + var tmp releaseInfoJSON + if err := json.Unmarshal(cleaned, &tmp); err != nil { + return err + } + + // Copy values to releaseInfo struct + r.Revision = tmp.Revision + if tmp.Updated != nil { + r.Updated = *tmp.Updated + } + r.Status = tmp.Status + r.Chart = tmp.Chart + r.AppVersion = tmp.AppVersion + r.Description = tmp.Description + + return nil +} + +// MarshalJSON implements the json.Marshaler interface. +// It omits zero-value time fields from the JSON output. +func (r releaseInfo) MarshalJSON() ([]byte, error) { + tmp := releaseInfoJSON{ + Revision: r.Revision, + Status: r.Status, + Chart: r.Chart, + AppVersion: r.AppVersion, + Description: r.Description, + } + + if !r.Updated.IsZero() { + tmp.Updated = &r.Updated + } + + return json.Marshal(tmp) } type releaseHistory []releaseInfo @@ -112,7 +181,11 @@ func (r releaseHistory) WriteTable(out io.Writer) error { } func getHistory(client *action.History, name string) (releaseHistory, error) { - hist, err := client.Run(name) + histi, err := client.Run(name) + if err != nil { + return nil, err + } + hist, err := releaseListToV1List(histi) if err != nil { return nil, err } @@ -136,7 +209,7 @@ func getHistory(client *action.History, name string) (releaseHistory, error) { func getReleaseHistory(rls []*release.Release) (history releaseHistory) { for i := len(rls) - 1; i >= 0; i-- { r := rls[i] - c := formatChartname(r.Chart) + c := formatChartName(r.Chart) s := r.Info.Status.String() v := r.Version d := r.Info.Description @@ -151,6 +224,7 @@ func getReleaseHistory(rls []*release.Release) (history releaseHistory) { } if !r.Info.LastDeployed.IsZero() { rInfo.Updated = r.Info.LastDeployed + } history = append(history, rInfo) } @@ -158,7 +232,7 @@ func getReleaseHistory(rls []*release.Release) (history releaseHistory) { return history } -func formatChartname(c *chart.Chart) string { +func formatChartName(c *chart.Chart) string { if c == nil || c.Metadata == nil { // This is an edge case that has happened in prod, though we don't // know how: https://github.com/helm/helm/issues/1347 @@ -176,22 +250,19 @@ func formatAppVersion(c *chart.Chart) string { return c.AppVersion() } -func min(x, y int) int { - if x < y { - return x - } - return y -} - func compListRevisions(_ string, cfg *action.Configuration, releaseName string) ([]string, cobra.ShellCompDirective) { client := action.NewHistory(cfg) var revisions []string - if hist, err := client.Run(releaseName); err == nil { - for _, release := range hist { - appVersion := fmt.Sprintf("App: %s", release.Chart.Metadata.AppVersion) - chartDesc := fmt.Sprintf("Chart: %s-%s", release.Chart.Metadata.Name, release.Chart.Metadata.Version) - revisions = append(revisions, fmt.Sprintf("%s\t%s, %s", strconv.Itoa(release.Version), appVersion, chartDesc)) + if histi, err := client.Run(releaseName); err == nil { + hist, err := releaseListToV1List(histi) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + for _, version := range hist { + appVersion := fmt.Sprintf("App: %s", version.Chart.Metadata.AppVersion) + chartDesc := fmt.Sprintf("Chart: %s-%s", version.Chart.Metadata.Name, version.Chart.Metadata.Version) + revisions = append(revisions, fmt.Sprintf("%s\t%s, %s", strconv.Itoa(version.Version), appVersion, chartDesc)) } return revisions, cobra.ShellCompDirectiveNoFileComp } diff --git a/pkg/helm/pkg/cmd/history_test.go b/pkg/helm/pkg/cmd/history_test.go new file mode 100644 index 00000000..27856eb3 --- /dev/null +++ b/pkg/helm/pkg/cmd/history_test.go @@ -0,0 +1,333 @@ +/* +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 cmd + +import ( + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/werf/nelm/pkg/helm/pkg/release/common" + release "github.com/werf/nelm/pkg/helm/pkg/release/v1" +) + +func TestHistoryCmd(t *testing.T) { + mk := func(name string, vers int, status common.Status) *release.Release { + return release.Mock(&release.MockReleaseOptions{ + Name: name, + Version: vers, + Status: status, + }) + } + + tests := []cmdTestCase{{ + name: "get history for release", + cmd: "history angry-bird", + rels: []*release.Release{ + mk("angry-bird", 4, common.StatusDeployed), + mk("angry-bird", 3, common.StatusSuperseded), + mk("angry-bird", 2, common.StatusSuperseded), + mk("angry-bird", 1, common.StatusSuperseded), + }, + golden: "output/history.txt", + }, { + name: "get history with max limit set", + cmd: "history angry-bird --max 2", + rels: []*release.Release{ + mk("angry-bird", 4, common.StatusDeployed), + mk("angry-bird", 3, common.StatusSuperseded), + }, + golden: "output/history-limit.txt", + }, { + name: "get history with yaml output format", + cmd: "history angry-bird --output yaml", + rels: []*release.Release{ + mk("angry-bird", 4, common.StatusDeployed), + mk("angry-bird", 3, common.StatusSuperseded), + }, + golden: "output/history.yaml", + }, { + name: "get history with json output format", + cmd: "history angry-bird --output json", + rels: []*release.Release{ + mk("angry-bird", 4, common.StatusDeployed), + mk("angry-bird", 3, common.StatusSuperseded), + }, + golden: "output/history.json", + }} + runTestCmd(t, tests) +} + +func TestHistoryOutputCompletion(t *testing.T) { + outputFlagCompletionTest(t, "history") +} + +func revisionFlagCompletionTest(t *testing.T, cmdName string) { + t.Helper() + mk := func(name string, vers int, status common.Status) *release.Release { + return release.Mock(&release.MockReleaseOptions{ + Name: name, + Version: vers, + Status: status, + }) + } + + releases := []*release.Release{ + mk("musketeers", 11, common.StatusDeployed), + mk("musketeers", 10, common.StatusSuperseded), + mk("musketeers", 9, common.StatusSuperseded), + mk("musketeers", 8, common.StatusSuperseded), + } + + tests := []cmdTestCase{{ + name: "completion for revision flag", + cmd: fmt.Sprintf("__complete %s musketeers --revision ''", cmdName), + rels: releases, + golden: "output/revision-comp.txt", + }, { + name: "completion for revision flag, no filter", + cmd: fmt.Sprintf("__complete %s musketeers --revision 1", cmdName), + rels: releases, + golden: "output/revision-comp.txt", + }, { + name: "completion for revision flag with too few args", + cmd: fmt.Sprintf("__complete %s --revision ''", cmdName), + rels: releases, + golden: "output/revision-wrong-args-comp.txt", + }, { + name: "completion for revision flag with too many args", + cmd: fmt.Sprintf("__complete %s three musketeers --revision ''", cmdName), + rels: releases, + golden: "output/revision-wrong-args-comp.txt", + }} + runTestCmd(t, tests) +} + +func TestHistoryCompletion(t *testing.T) { + checkReleaseCompletion(t, "history", false) +} + +func TestHistoryFileCompletion(t *testing.T) { + checkFileCompletion(t, "history", false) + checkFileCompletion(t, "history myrelease", false) +} + +func TestReleaseInfoMarshalJSON(t *testing.T) { + updated := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + info releaseInfo + expected string + }{ + { + name: "all fields populated", + info: releaseInfo{ + Revision: 1, + Updated: updated, + Status: "deployed", + Chart: "mychart-1.0.0", + AppVersion: "1.0.0", + Description: "Initial install", + }, + expected: `{"revision":1,"updated":"2025-10-08T12:00:00Z","status":"deployed","chart":"mychart-1.0.0","app_version":"1.0.0","description":"Initial install"}`, + }, + { + name: "without updated time", + info: releaseInfo{ + Revision: 2, + Status: "superseded", + Chart: "mychart-1.0.1", + AppVersion: "1.0.1", + Description: "Upgraded", + }, + expected: `{"revision":2,"status":"superseded","chart":"mychart-1.0.1","app_version":"1.0.1","description":"Upgraded"}`, + }, + { + name: "with zero revision", + info: releaseInfo{ + Revision: 0, + Updated: updated, + Status: "failed", + Chart: "mychart-1.0.0", + AppVersion: "1.0.0", + Description: "Install failed", + }, + expected: `{"revision":0,"updated":"2025-10-08T12:00:00Z","status":"failed","chart":"mychart-1.0.0","app_version":"1.0.0","description":"Install failed"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(&tt.info) + require.NoError(t, err) + assert.JSONEq(t, tt.expected, string(data)) + }) + } +} + +func TestReleaseInfoUnmarshalJSON(t *testing.T) { + updated := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + input string + expected releaseInfo + wantErr bool + }{ + { + name: "all fields populated", + input: `{"revision":1,"updated":"2025-10-08T12:00:00Z","status":"deployed","chart":"mychart-1.0.0","app_version":"1.0.0","description":"Initial install"}`, + expected: releaseInfo{ + Revision: 1, + Updated: updated, + Status: "deployed", + Chart: "mychart-1.0.0", + AppVersion: "1.0.0", + Description: "Initial install", + }, + }, + { + name: "empty string updated field", + input: `{"revision":2,"updated":"","status":"superseded","chart":"mychart-1.0.1","app_version":"1.0.1","description":"Upgraded"}`, + expected: releaseInfo{ + Revision: 2, + Status: "superseded", + Chart: "mychart-1.0.1", + AppVersion: "1.0.1", + Description: "Upgraded", + }, + }, + { + name: "missing updated field", + input: `{"revision":3,"status":"deployed","chart":"mychart-1.0.2","app_version":"1.0.2","description":"Upgraded"}`, + expected: releaseInfo{ + Revision: 3, + Status: "deployed", + Chart: "mychart-1.0.2", + AppVersion: "1.0.2", + Description: "Upgraded", + }, + }, + { + name: "null updated field", + input: `{"revision":4,"updated":null,"status":"failed","chart":"mychart-1.0.3","app_version":"1.0.3","description":"Failed"}`, + expected: releaseInfo{ + Revision: 4, + Status: "failed", + Chart: "mychart-1.0.3", + AppVersion: "1.0.3", + Description: "Failed", + }, + }, + { + name: "invalid time format", + input: `{"revision":5,"updated":"invalid-time","status":"deployed","chart":"mychart-1.0.4","app_version":"1.0.4","description":"Test"}`, + wantErr: true, + }, + { + name: "zero revision", + input: `{"revision":0,"updated":"2025-10-08T12:00:00Z","status":"pending-install","chart":"mychart-1.0.0","app_version":"1.0.0","description":"Installing"}`, + expected: releaseInfo{ + Revision: 0, + Updated: updated, + Status: "pending-install", + Chart: "mychart-1.0.0", + AppVersion: "1.0.0", + Description: "Installing", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var info releaseInfo + err := json.Unmarshal([]byte(tt.input), &info) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.expected.Revision, info.Revision) + assert.Equal(t, tt.expected.Updated.Unix(), info.Updated.Unix()) + assert.Equal(t, tt.expected.Status, info.Status) + assert.Equal(t, tt.expected.Chart, info.Chart) + assert.Equal(t, tt.expected.AppVersion, info.AppVersion) + assert.Equal(t, tt.expected.Description, info.Description) + }) + } +} + +func TestReleaseInfoRoundTrip(t *testing.T) { + updated := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + + original := releaseInfo{ + Revision: 1, + Updated: updated, + Status: "deployed", + Chart: "mychart-1.0.0", + AppVersion: "1.0.0", + Description: "Initial install", + } + + data, err := json.Marshal(&original) + require.NoError(t, err) + + var decoded releaseInfo + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, original.Revision, decoded.Revision) + assert.Equal(t, original.Updated.Unix(), decoded.Updated.Unix()) + assert.Equal(t, original.Status, decoded.Status) + assert.Equal(t, original.Chart, decoded.Chart) + assert.Equal(t, original.AppVersion, decoded.AppVersion) + assert.Equal(t, original.Description, decoded.Description) +} + +func TestReleaseInfoEmptyStringRoundTrip(t *testing.T) { + // This test specifically verifies that empty string time fields + // are handled correctly during parsing + input := `{"revision":1,"updated":"","status":"deployed","chart":"mychart-1.0.0","app_version":"1.0.0","description":"Test"}` + + var info releaseInfo + err := json.Unmarshal([]byte(input), &info) + require.NoError(t, err) + + // Verify time field is zero value + assert.True(t, info.Updated.IsZero()) + assert.Equal(t, 1, info.Revision) + assert.Equal(t, "deployed", info.Status) + + // Marshal back and verify empty time field is omitted + data, err := json.Marshal(&info) + require.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(data, &result) + require.NoError(t, err) + + // Zero time value should be omitted + assert.NotContains(t, result, "updated") + assert.Equal(t, float64(1), result["revision"]) + assert.Equal(t, "deployed", result["status"]) + assert.Equal(t, "mychart-1.0.0", result["chart"]) +} diff --git a/pkg/helm/pkg/cmd/list.go b/pkg/helm/pkg/cmd/list.go new file mode 100644 index 00000000..dd75f631 --- /dev/null +++ b/pkg/helm/pkg/cmd/list.go @@ -0,0 +1,289 @@ +/* +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 cmd + +import ( + "fmt" + "io" + "os" + "slices" + "strconv" + + "github.com/gosuri/uitable" + "github.com/spf13/cobra" + + coloroutput "github.com/werf/nelm/pkg/helm/intern/cli/output" + "github.com/werf/nelm/pkg/helm/pkg/action" + "github.com/werf/nelm/pkg/helm/pkg/cli/output" + "github.com/werf/nelm/pkg/helm/pkg/cmd/require" + "github.com/werf/nelm/pkg/helm/pkg/release/common" + release "github.com/werf/nelm/pkg/helm/pkg/release/v1" +) + +var listHelp = ` +This command lists all of the releases for a specified namespace (uses current namespace context if namespace not specified). + +By default, it lists all releases in any status. Individual status filters like '--deployed', '--failed', +'--pending', '--uninstalled', '--superseded', and '--uninstalling' can be used +to show only releases in specific states. Such flags can be combined: +'--deployed --failed'. + +By default, items are sorted alphabetically. Use the '-d' flag to sort by +release date. + +If the --filter flag is provided, it will be treated as a filter. Filters are +regular expressions (Perl compatible) that are applied to the list of releases. +Only items that match the filter will be returned. + + $ helm list --filter 'ara[a-z]+' + NAME UPDATED CHART + maudlin-arachnid 2020-06-18 14:17:46.125134977 +0000 UTC alpine-0.1.0 + +If no results are found, 'helm list' will exit 0, but with no output (or in +the case of no '-q' flag, only headers). + +By default, up to 256 items may be returned. To limit this, use the '--max' flag. +Setting '--max' to 0 will not return all results. Rather, it will return the +server's default, which may be much higher than 256. Pairing the '--max' +flag with the '--offset' flag allows you to page through results. +` + +func newListCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + client := action.NewList(cfg) + var outfmt output.Format + + cmd := &cobra.Command{ + Use: "list", + Short: "list releases", + Long: listHelp, + Aliases: []string{"ls"}, + Args: require.NoArgs, + ValidArgsFunction: noMoreArgsCompFunc, + RunE: func(cmd *cobra.Command, _ []string) error { + if client.AllNamespaces { + if err := cfg.Init(settings.RESTClientGetter(), "", os.Getenv("HELM_DRIVER")); err != nil { + return err + } + } + client.SetStateMask() + + resultsi, err := client.Run() + if err != nil { + return err + } + results, err := releaseListToV1List(resultsi) + if err != nil { + return err + } + + if client.Short { + names := make([]string, 0, len(results)) + for _, res := range results { + names = append(names, res.Name) + } + + outputFlag := cmd.Flag("output") + + switch outputFlag.Value.String() { + case "json": + output.EncodeJSON(out, names) + return nil + case "yaml": + output.EncodeYAML(out, names) + return nil + case "table": + for _, res := range results { + fmt.Fprintln(out, res.Name) + } + return nil + } + } + + return outfmt.Write(out, newReleaseListWriter(results, client.TimeFormat, client.NoHeaders, settings.ShouldDisableColor())) + }, + } + + f := cmd.Flags() + f.BoolVarP(&client.Short, "short", "q", false, "output short (quiet) listing format") + f.BoolVarP(&client.NoHeaders, "no-headers", "", false, "don't print headers when using the default output format") + f.StringVar(&client.TimeFormat, "time-format", "", `format time using golang time formatter. Example: --time-format "2006-01-02 15:04:05Z0700"`) + f.BoolVarP(&client.ByDate, "date", "d", false, "sort by release date") + f.BoolVarP(&client.SortReverse, "reverse", "r", false, "reverse the sort order") + f.BoolVar(&client.Uninstalled, "uninstalled", false, "show uninstalled releases (if 'helm uninstall --keep-history' was used)") + f.BoolVar(&client.Superseded, "superseded", false, "show superseded releases") + f.BoolVar(&client.Uninstalling, "uninstalling", false, "show releases that are currently being uninstalled") + f.BoolVar(&client.Deployed, "deployed", false, "show deployed releases") + f.BoolVar(&client.Failed, "failed", false, "show failed releases") + f.BoolVar(&client.Pending, "pending", false, "show pending releases") + f.BoolVarP(&client.AllNamespaces, "all-namespaces", "A", false, "list releases across all namespaces") + f.IntVarP(&client.Limit, "max", "m", 256, "maximum number of releases to fetch") + f.IntVar(&client.Offset, "offset", 0, "next release index in the list, used to offset from start value") + f.StringVarP(&client.Filter, "filter", "f", "", "a regular expression (Perl compatible). Any releases that match the expression will be included in the results") + f.StringVarP(&client.Selector, "selector", "l", "", "Selector (label query) to filter on, supports '=', '==', and '!='.(e.g. -l key1=value1,key2=value2). Works only for secret(default) and configmap storage backends.") + bindOutputFlag(cmd, &outfmt) + + return cmd +} + +type releaseElement struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Revision string `json:"revision"` + Updated string `json:"updated"` + Status string `json:"status"` + Chart string `json:"chart"` + AppVersion string `json:"app_version"` +} + +type releaseListWriter struct { + releases []releaseElement + noHeaders bool + noColor bool +} + +func newReleaseListWriter(releases []*release.Release, timeFormat string, noHeaders bool, noColor bool) *releaseListWriter { + // Initialize the array so no results returns an empty array instead of null + elements := make([]releaseElement, 0, len(releases)) + for _, r := range releases { + element := releaseElement{ + Name: r.Name, + Namespace: r.Namespace, + Revision: strconv.Itoa(r.Version), + Status: r.Info.Status.String(), + Chart: formatChartName(r.Chart), + AppVersion: formatAppVersion(r.Chart), + } + + t := "-" + if tspb := r.Info.LastDeployed; !tspb.IsZero() { + if timeFormat != "" { + t = tspb.Format(timeFormat) + } else { + t = tspb.String() + } + } + element.Updated = t + + elements = append(elements, element) + } + return &releaseListWriter{elements, noHeaders, noColor} +} + +func (w *releaseListWriter) WriteTable(out io.Writer) error { + table := uitable.New() + if !w.noHeaders { + table.AddRow( + coloroutput.ColorizeHeader("NAME", w.noColor), + coloroutput.ColorizeHeader("NAMESPACE", w.noColor), + coloroutput.ColorizeHeader("REVISION", w.noColor), + coloroutput.ColorizeHeader("UPDATED", w.noColor), + coloroutput.ColorizeHeader("STATUS", w.noColor), + coloroutput.ColorizeHeader("CHART", w.noColor), + coloroutput.ColorizeHeader("APP VERSION", w.noColor), + ) + } + for _, r := range w.releases { + // Parse the status string back to a release.Status to use color + var status common.Status + switch r.Status { + case "deployed": + status = common.StatusDeployed + case "failed": + status = common.StatusFailed + case "pending-install": + status = common.StatusPendingInstall + case "pending-upgrade": + status = common.StatusPendingUpgrade + case "pending-rollback": + status = common.StatusPendingRollback + case "uninstalling": + status = common.StatusUninstalling + case "uninstalled": + status = common.StatusUninstalled + case "superseded": + status = common.StatusSuperseded + case "unknown": + status = common.StatusUnknown + default: + status = common.Status(r.Status) + } + table.AddRow(r.Name, coloroutput.ColorizeNamespace(r.Namespace, w.noColor), r.Revision, r.Updated, coloroutput.ColorizeStatus(status, w.noColor), r.Chart, r.AppVersion) + } + return output.EncodeTable(out, table) +} + +func (w *releaseListWriter) WriteJSON(out io.Writer) error { + return output.EncodeJSON(out, w.releases) +} + +func (w *releaseListWriter) WriteYAML(out io.Writer) error { + return output.EncodeYAML(out, w.releases) +} + +// Returns all releases from 'releases', except those with names matching 'ignoredReleases' +func filterReleases(releases []*release.Release, ignoredReleaseNames []string) []*release.Release { + // if ignoredReleaseNames is nil, just return releases + if ignoredReleaseNames == nil { + return releases + } + + var filteredReleases []*release.Release + for _, rel := range releases { + found := slices.Contains(ignoredReleaseNames, rel.Name) + if !found { + filteredReleases = append(filteredReleases, rel) + } + } + + return filteredReleases +} + +// Provide dynamic auto-completion for release names +func compListReleases(toComplete string, ignoredReleaseNames []string, cfg *action.Configuration) ([]string, cobra.ShellCompDirective) { + cobra.CompDebugln(fmt.Sprintf("compListReleases with toComplete %s", toComplete), settings.Debug) + + client := action.NewList(cfg) + client.All = true + client.Limit = 0 + // Do not filter so as to get the entire list of releases. + // This will allow zsh and fish to match completion choices + // on other criteria then prefix. For example: + // helm status ingress + // can match + // helm status nginx-ingress + // + // client.Filter = fmt.Sprintf("^%s", toComplete) + + client.SetStateMask() + releasesi, err := client.Run() + if err != nil { + return nil, cobra.ShellCompDirectiveDefault + } + releases, err := releaseListToV1List(releasesi) + if err != nil { + return nil, cobra.ShellCompDirectiveDefault + } + + var choices []string + filteredReleases := filterReleases(releases, ignoredReleaseNames) + for _, rel := range filteredReleases { + choices = append(choices, + fmt.Sprintf("%s\t%s-%s -> %s", rel.Name, rel.Chart.Metadata.Name, rel.Chart.Metadata.Version, rel.Info.Status.String())) + } + + return choices, cobra.ShellCompDirectiveNoFileComp +} diff --git a/pkg/helm/pkg/cmd/list_test.go b/pkg/helm/pkg/cmd/list_test.go new file mode 100644 index 00000000..ddcfc6e4 --- /dev/null +++ b/pkg/helm/pkg/cmd/list_test.go @@ -0,0 +1,617 @@ +/* +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 cmd + +import ( + "testing" + "time" + + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/release/common" + release "github.com/werf/nelm/pkg/helm/pkg/release/v1" +) + +func TestListCmd(t *testing.T) { + defaultNamespace := "default" + + sampleTimeSeconds := int64(1452902400) + timestamp1 := time.Unix(sampleTimeSeconds+1, 0).UTC() + timestamp2 := time.Unix(sampleTimeSeconds+2, 0).UTC() + timestamp3 := time.Unix(sampleTimeSeconds+3, 0).UTC() + timestamp4 := time.Unix(sampleTimeSeconds+4, 0).UTC() + chartInfo := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "chickadee", + Version: "1.0.0", + AppVersion: "0.0.1", + }, + } + + releaseFixture := []*release.Release{ + { + Name: "starlord", + Version: 1, + Namespace: defaultNamespace, + Info: &release.Info{ + LastDeployed: timestamp1, + Status: common.StatusSuperseded, + }, + Chart: chartInfo, + }, + { + Name: "starlord", + Version: 2, + Namespace: defaultNamespace, + Info: &release.Info{ + LastDeployed: timestamp1, + Status: common.StatusDeployed, + }, + Chart: chartInfo, + }, + { + Name: "groot", + Version: 1, + Namespace: defaultNamespace, + Info: &release.Info{ + LastDeployed: timestamp1, + Status: common.StatusUninstalled, + }, + Chart: chartInfo, + }, + { + Name: "gamora", + Version: 1, + Namespace: defaultNamespace, + Info: &release.Info{ + LastDeployed: timestamp1, + Status: common.StatusSuperseded, + }, + Chart: chartInfo, + }, + { + Name: "rocket", + Version: 1, + Namespace: defaultNamespace, + Info: &release.Info{ + LastDeployed: timestamp2, + Status: common.StatusFailed, + }, + Chart: chartInfo, + }, + { + Name: "drax", + Version: 1, + Namespace: defaultNamespace, + Info: &release.Info{ + LastDeployed: timestamp1, + Status: common.StatusUninstalling, + }, + Chart: chartInfo, + }, + { + Name: "thanos", + Version: 1, + Namespace: defaultNamespace, + Info: &release.Info{ + LastDeployed: timestamp1, + Status: common.StatusPendingInstall, + }, + Chart: chartInfo, + }, + { + Name: "hummingbird", + Version: 1, + Namespace: defaultNamespace, + Info: &release.Info{ + LastDeployed: timestamp3, + Status: common.StatusDeployed, + }, + Chart: chartInfo, + }, + { + Name: "iguana", + Version: 2, + Namespace: defaultNamespace, + Info: &release.Info{ + LastDeployed: timestamp4, + Status: common.StatusDeployed, + }, + Chart: chartInfo, + }, + { + Name: "starlord", + Version: 2, + Namespace: "milano", + Info: &release.Info{ + LastDeployed: timestamp1, + Status: common.StatusDeployed, + }, + Chart: chartInfo, + }, + } + + tests := []cmdTestCase{{ + name: "list releases", + cmd: "list", + golden: "output/list-all.txt", + rels: releaseFixture, + }, { + name: "list without headers", + cmd: "list --no-headers", + golden: "output/list-all-no-headers.txt", + rels: releaseFixture, + }, { + name: "list releases sorted by release date", + cmd: "list --date", + golden: "output/list-all-date.txt", + rels: releaseFixture, + }, { + name: "list failed releases", + cmd: "list --failed", + golden: "output/list-failed.txt", + rels: releaseFixture, + }, { + name: "list filtered releases", + cmd: "list --filter='.*'", + golden: "output/list-all.txt", + rels: releaseFixture, + }, { + name: "list releases, limited to one release", + cmd: "list --max 1", + golden: "output/list-all-max.txt", + rels: releaseFixture, + }, { + name: "list releases, offset by one", + cmd: "list --offset 1", + golden: "output/list-all-offset.txt", + rels: releaseFixture, + }, { + name: "list pending releases", + cmd: "list --pending", + golden: "output/list-pending.txt", + rels: releaseFixture, + }, { + name: "list releases in reverse order", + cmd: "list --reverse", + golden: "output/list-all-reverse.txt", + rels: releaseFixture, + }, { + name: "list releases sorted by reversed release date", + cmd: "list --date --reverse", + golden: "output/list-all-date-reversed.txt", + rels: releaseFixture, + }, { + name: "list releases in short output format", + cmd: "list --short", + golden: "output/list-all-short.txt", + rels: releaseFixture, + }, { + name: "list releases in short output format", + cmd: "list --short --output yaml", + golden: "output/list-all-short-yaml.txt", + rels: releaseFixture, + }, { + name: "list releases in short output format", + cmd: "list --short --output json", + golden: "output/list-all-short-json.txt", + rels: releaseFixture, + }, { + name: "list deployed and failed releases only", + cmd: "list --deployed --failed", + golden: "output/list.txt", + rels: releaseFixture, + }, { + name: "list superseded releases", + cmd: "list --superseded", + golden: "output/list-superseded.txt", + rels: releaseFixture, + }, { + name: "list uninstalled releases", + cmd: "list --uninstalled", + golden: "output/list-uninstalled.txt", + rels: releaseFixture, + }, { + name: "list releases currently uninstalling", + cmd: "list --uninstalling", + golden: "output/list-uninstalling.txt", + rels: releaseFixture, + }, { + name: "list releases in another namespace", + cmd: "list -n milano", + golden: "output/list-namespace.txt", + rels: releaseFixture, + }} + runTestCmd(t, tests) +} + +func TestListOutputCompletion(t *testing.T) { + outputFlagCompletionTest(t, "list") +} + +func TestListFileCompletion(t *testing.T) { + checkFileCompletion(t, "list", false) +} + +func TestListOutputFormats(t *testing.T) { + defaultNamespace := "default" + timestamp := time.Unix(1452902400, 0).UTC() + chartInfo := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test-chart", + Version: "1.0.0", + AppVersion: "0.0.1", + }, + } + + releaseFixture := []*release.Release{ + { + Name: "test-release", + Version: 1, + Namespace: defaultNamespace, + Info: &release.Info{ + LastDeployed: timestamp, + Status: common.StatusDeployed, + }, + Chart: chartInfo, + }, + } + + tests := []cmdTestCase{{ + name: "list releases in json format", + cmd: "list --output json", + golden: "output/list-json.txt", + rels: releaseFixture, + }, { + name: "list releases in yaml format", + cmd: "list --output yaml", + golden: "output/list-yaml.txt", + rels: releaseFixture, + }} + runTestCmd(t, tests) +} + +func TestReleaseListWriter(t *testing.T) { + timestamp := time.Unix(1452902400, 0).UTC() + chartInfo := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test-chart", + Version: "1.0.0", + AppVersion: "0.0.1", + }, + } + + releases := []*release.Release{ + { + Name: "test-release", + Version: 1, + Namespace: "default", + Info: &release.Info{ + LastDeployed: timestamp, + Status: common.StatusDeployed, + }, + Chart: chartInfo, + }, + } + + tests := []struct { + name string + releases []*release.Release + timeFormat string + noHeaders bool + noColor bool + }{ + { + name: "empty releases list", + releases: []*release.Release{}, + timeFormat: "", + noHeaders: false, + noColor: false, + }, + { + name: "custom time format", + releases: releases, + timeFormat: "2006-01-02", + noHeaders: false, + noColor: false, + }, + { + name: "no headers", + releases: releases, + timeFormat: "", + noHeaders: true, + noColor: false, + }, + { + name: "no color", + releases: releases, + timeFormat: "", + noHeaders: false, + noColor: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + writer := newReleaseListWriter(tt.releases, tt.timeFormat, tt.noHeaders, tt.noColor) + + if writer == nil { + t.Error("Expected writer to be non-nil") + } else { + if len(writer.releases) != len(tt.releases) { + t.Errorf("Expected %d releases, got %d", len(tt.releases), len(writer.releases)) + } + } + }) + } +} + +func TestReleaseListWriterMethods(t *testing.T) { + timestamp := time.Unix(1452902400, 0).UTC() + zeroTimestamp := time.Time{} + chartInfo := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test-chart", + Version: "1.0.0", + AppVersion: "0.0.1", + }, + } + + releases := []*release.Release{ + { + Name: "test-release", + Version: 1, + Namespace: "default", + Info: &release.Info{ + LastDeployed: timestamp, + Status: common.StatusDeployed, + }, + Chart: chartInfo, + }, + { + Name: "zero-time-release", + Version: 1, + Namespace: "default", + Info: &release.Info{ + LastDeployed: zeroTimestamp, + Status: common.StatusFailed, + }, + Chart: chartInfo, + }, + } + + tests := []struct { + name string + status common.Status + }{ + {"deployed", common.StatusDeployed}, + {"failed", common.StatusFailed}, + {"pending-install", common.StatusPendingInstall}, + {"pending-upgrade", common.StatusPendingUpgrade}, + {"pending-rollback", common.StatusPendingRollback}, + {"uninstalling", common.StatusUninstalling}, + {"uninstalled", common.StatusUninstalled}, + {"superseded", common.StatusSuperseded}, + {"unknown", common.StatusUnknown}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testReleases := []*release.Release{ + { + Name: "test-release", + Version: 1, + Namespace: "default", + Info: &release.Info{ + LastDeployed: timestamp, + Status: tt.status, + }, + Chart: chartInfo, + }, + } + + writer := newReleaseListWriter(testReleases, "", false, false) + + var buf []byte + out := &bytesWriter{buf: &buf} + + err := writer.WriteJSON(out) + if err != nil { + t.Errorf("WriteJSON failed: %v", err) + } + + err = writer.WriteYAML(out) + if err != nil { + t.Errorf("WriteYAML failed: %v", err) + } + + err = writer.WriteTable(out) + if err != nil { + t.Errorf("WriteTable failed: %v", err) + } + }) + } + + writer := newReleaseListWriter(releases, "", false, false) + + var buf []byte + out := &bytesWriter{buf: &buf} + + err := writer.WriteJSON(out) + if err != nil { + t.Errorf("WriteJSON failed: %v", err) + } + + err = writer.WriteYAML(out) + if err != nil { + t.Errorf("WriteYAML failed: %v", err) + } + + err = writer.WriteTable(out) + if err != nil { + t.Errorf("WriteTable failed: %v", err) + } +} + +func TestFilterReleases(t *testing.T) { + releases := []*release.Release{ + {Name: "release1"}, + {Name: "release2"}, + {Name: "release3"}, + } + + tests := []struct { + name string + releases []*release.Release + ignoredReleaseNames []string + expectedCount int + }{ + { + name: "nil ignored list", + releases: releases, + ignoredReleaseNames: nil, + expectedCount: 3, + }, + { + name: "empty ignored list", + releases: releases, + ignoredReleaseNames: []string{}, + expectedCount: 3, + }, + { + name: "filter one release", + releases: releases, + ignoredReleaseNames: []string{"release1"}, + expectedCount: 2, + }, + { + name: "filter multiple releases", + releases: releases, + ignoredReleaseNames: []string{"release1", "release3"}, + expectedCount: 1, + }, + { + name: "filter non-existent release", + releases: releases, + ignoredReleaseNames: []string{"non-existent"}, + expectedCount: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := filterReleases(tt.releases, tt.ignoredReleaseNames) + if len(result) != tt.expectedCount { + t.Errorf("Expected %d releases, got %d", tt.expectedCount, len(result)) + } + }) + } +} + +type bytesWriter struct { + buf *[]byte +} + +func (b *bytesWriter) Write(p []byte) (n int, err error) { + *b.buf = append(*b.buf, p...) + return len(p), nil +} + +func TestListCustomTimeFormat(t *testing.T) { + defaultNamespace := "default" + timestamp := time.Unix(1452902400, 0).UTC() + chartInfo := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test-chart", + Version: "1.0.0", + AppVersion: "0.0.1", + }, + } + + releaseFixture := []*release.Release{ + { + Name: "test-release", + Version: 1, + Namespace: defaultNamespace, + Info: &release.Info{ + LastDeployed: timestamp, + Status: common.StatusDeployed, + }, + Chart: chartInfo, + }, + } + + tests := []cmdTestCase{{ + name: "list releases with custom time format", + cmd: "list --time-format '2006-01-02 15:04:05'", + golden: "output/list-time-format.txt", + rels: releaseFixture, + }} + runTestCmd(t, tests) +} + +func TestListStatusMapping(t *testing.T) { + defaultNamespace := "default" + timestamp := time.Unix(1452902400, 0).UTC() + chartInfo := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test-chart", + Version: "1.0.0", + AppVersion: "0.0.1", + }, + } + + testCases := []struct { + name string + status common.Status + }{ + {"deployed", common.StatusDeployed}, + {"failed", common.StatusFailed}, + {"pending-install", common.StatusPendingInstall}, + {"pending-upgrade", common.StatusPendingUpgrade}, + {"pending-rollback", common.StatusPendingRollback}, + {"uninstalling", common.StatusUninstalling}, + {"uninstalled", common.StatusUninstalled}, + {"superseded", common.StatusSuperseded}, + {"unknown", common.StatusUnknown}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + releaseFixture := []*release.Release{ + { + Name: "test-release", + Version: 1, + Namespace: defaultNamespace, + Info: &release.Info{ + LastDeployed: timestamp, + Status: tc.status, + }, + Chart: chartInfo, + }, + } + + writer := newReleaseListWriter(releaseFixture, "", false, false) + if len(writer.releases) != 1 { + t.Errorf("Expected 1 release, got %d", len(writer.releases)) + } + + if writer.releases[0].Status != tc.status.String() { + t.Errorf("Expected status %s, got %s", tc.status.String(), writer.releases[0].Status) + } + }) + } +} diff --git a/pkg/helm/cmd/helm/package.go b/pkg/helm/pkg/cmd/package.go similarity index 70% rename from pkg/helm/cmd/helm/package.go rename to pkg/helm/pkg/cmd/package.go index 076832e5..5fa977f2 100644 --- a/pkg/helm/cmd/helm/package.go +++ b/pkg/helm/pkg/cmd/package.go @@ -14,22 +14,22 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( + "context" + "errors" "fmt" "io" "os" "path/filepath" - "github.com/pkg/errors" "github.com/spf13/cobra" "github.com/werf/nelm/pkg/helm/pkg/action" "github.com/werf/nelm/pkg/helm/pkg/cli/values" "github.com/werf/nelm/pkg/helm/pkg/downloader" "github.com/werf/nelm/pkg/helm/pkg/getter" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" ) const packageDesc = ` @@ -48,7 +48,7 @@ If '--keyring' is not specified, Helm usually defaults to the public keyring unless your environment is otherwise configured. ` -func newPackageCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { +func newPackageCmd(out io.Writer) *cobra.Command { client := action.NewPackage() valueOpts := &values.Options{} @@ -56,9 +56,9 @@ func newPackageCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { Use: "package [CHART_PATH] [...]", Short: "package a chart directory into a chart archive", Long: packageDesc, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { if len(args) == 0 { - return errors.Errorf("need at least one argument, the path to the chart") + return fmt.Errorf("need at least one argument, the path to the chart") } if client.Sign { if client.Key == "" { @@ -68,22 +68,21 @@ func newPackageCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { return errors.New("--keyring is required for signing a package") } } - - opts := helmopts.HelmOptions{ - ChartLoadOpts: helmopts.ChartLoadOptions{ - NoSecrets: true, - }, - } - client.RepositoryConfig = settings.RepositoryConfig client.RepositoryCache = settings.RepositoryCache p := getter.All(settings) - vals, err := valueOpts.MergeValues(p, opts) + vals, err := valueOpts.MergeValues(context.Background(), p) if err != nil { return err } - for i := 0; i < len(args); i++ { + registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile, + client.InsecureSkipTLSVerify, client.PlainHTTP, client.Username, client.Password) + if err != nil { + return fmt.Errorf("missing registry client: %w", err) + } + + for i := range args { path, err := filepath.Abs(args[i]) if err != nil { return err @@ -99,19 +98,17 @@ func newPackageCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { Keyring: client.Keyring, Getters: p, Debug: settings.Debug, - RegistryClient: cfg.RegistryClient, + RegistryClient: registryClient, RepositoryConfig: settings.RepositoryConfig, RepositoryCache: settings.RepositoryCache, + ContentCache: settings.ContentCache, } - opts.ChartLoadOpts.DepDownloader = downloadManager - - if err := downloadManager.Update(opts); err != nil { + if err := downloadManager.Update(context.Background()); err != nil { return err } } - - p, err := client.Run(path, vals, opts) + p, err := client.Run(path, vals) if err != nil { return err } @@ -130,6 +127,13 @@ func newPackageCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f.StringVar(&client.AppVersion, "app-version", "", "set the appVersion on the chart to this version") f.StringVarP(&client.Destination, "destination", "d", ".", "location to write the chart.") f.BoolVarP(&client.DependencyUpdate, "dependency-update", "u", false, `update dependencies from "Chart.yaml" to dir "charts/" before packaging`) + f.StringVar(&client.Username, "username", "", "chart repository username where to locate the requested chart") + f.StringVar(&client.Password, "password", "", "chart repository password where to locate the requested chart") + f.StringVar(&client.CertFile, "cert-file", "", "identify HTTPS client using this SSL certificate file") + f.StringVar(&client.KeyFile, "key-file", "", "identify HTTPS client using this SSL key file") + f.BoolVar(&client.InsecureSkipTLSVerify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the chart download") + f.BoolVar(&client.PlainHTTP, "plain-http", false, "use insecure HTTP connections for the chart download") + f.StringVar(&client.CaFile, "ca-file", "", "verify certificates of HTTPS-enabled servers using this CA bundle") return cmd } diff --git a/pkg/helm/cmd/helm/package_test.go b/pkg/helm/pkg/cmd/package_test.go similarity index 94% rename from pkg/helm/cmd/helm/package_test.go rename to pkg/helm/pkg/cmd/package_test.go index 22631399..d3f1d99a 100644 --- a/pkg/helm/cmd/helm/package_test.go +++ b/pkg/helm/pkg/cmd/package_test.go @@ -13,9 +13,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( + "context" "fmt" "os" "path/filepath" @@ -23,8 +24,9 @@ import ( "strings" "testing" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chart/loader" + "github.com/werf/nelm/pkg/helm/intern/test/ensure" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/loader" ) func TestPackage(t *testing.T) { @@ -110,10 +112,10 @@ func TestPackage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cachePath := t.TempDir() - defer testChdir(t, cachePath)() + t.Chdir(t.TempDir()) + ensure.HelmHome(t) - if err := os.MkdirAll("toot", 0777); err != nil { + if err := os.MkdirAll("toot", 0o777); err != nil { t.Fatal(err) } @@ -182,7 +184,7 @@ func TestSetAppVersion(t *testing.T) { } else if fi.Size() == 0 { t.Errorf("file %q has zero bytes.", chartPath) } - ch, err = loader.Load(chartPath) + ch, err = loader.Load(context.Background(), chartPath) if err != nil { t.Fatalf("unexpected error loading packaged chart: %v", err) } diff --git a/pkg/helm/pkg/cmd/printer.go b/pkg/helm/pkg/cmd/printer.go new file mode 100644 index 00000000..30238f5b --- /dev/null +++ b/pkg/helm/pkg/cmd/printer.go @@ -0,0 +1,30 @@ +/* +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 cmd + +import ( + "io" + "text/template" +) + +func tpl(t string, vals map[string]interface{}, out io.Writer) error { + tt, err := template.New("_").Parse(t) + if err != nil { + return err + } + return tt.Execute(out, vals) +} diff --git a/pkg/helm/cmd/helm/pull.go b/pkg/helm/pkg/cmd/pull.go similarity index 84% rename from pkg/helm/cmd/helm/pull.go rename to pkg/helm/pkg/cmd/pull.go index f5abcde1..fa42b0a6 100644 --- a/pkg/helm/cmd/helm/pull.go +++ b/pkg/helm/pkg/cmd/pull.go @@ -14,17 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "fmt" "io" "log" + "log/slog" "github.com/spf13/cobra" - "github.com/werf/nelm/pkg/helm/cmd/helm/require" "github.com/werf/nelm/pkg/helm/pkg/action" + "github.com/werf/nelm/pkg/helm/pkg/cmd/require" ) const pullDesc = ` @@ -43,7 +44,7 @@ result in an error, and the chart will not be saved locally. ` func newPullCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { - client := action.NewPullWithOpts(action.WithConfig(cfg)) + client := action.NewPull(action.WithConfig(cfg)) cmd := &cobra.Command{ Use: "pull [chart URL | repo/chartname] [...]", @@ -51,27 +52,27 @@ func newPullCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { Aliases: []string{"fetch"}, Long: pullDesc, Args: require.MinimumNArgs(1), - ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) != 0 { return nil, cobra.ShellCompDirectiveNoFileComp } return compListCharts(toComplete, false) }, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { client.Settings = settings if client.Version == "" && client.Devel { - debug("setting version to >0.0.0-0") + slog.Debug("setting version to >0.0.0-0") client.Version = ">0.0.0-0" } registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile, - client.InsecureSkipTLSverify, client.PlainHTTP) + client.InsecureSkipTLSVerify, client.PlainHTTP, client.Username, client.Password) if err != nil { return fmt.Errorf("missing registry client: %w", err) } client.SetRegistryClient(registryClient) - for i := 0; i < len(args); i++ { + for i := range args { output, err := client.Run(args[i]) if err != nil { return err @@ -90,7 +91,7 @@ func newPullCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f.StringVarP(&client.DestDir, "destination", "d", ".", "location to write the chart. If this and untardir are specified, untardir is appended to this") addChartPathOptionsFlags(f, &client.ChartPathOptions) - err := cmd.RegisterFlagCompletionFunc("version", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + err := cmd.RegisterFlagCompletionFunc("version", func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) != 1 { return nil, cobra.ShellCompDirectiveNoFileComp } diff --git a/pkg/helm/cmd/helm/pull_test.go b/pkg/helm/pkg/cmd/pull_test.go similarity index 59% rename from pkg/helm/cmd/helm/pull_test.go rename to pkg/helm/pkg/cmd/pull_test.go index b3087740..d60b5660 100644 --- a/pkg/helm/cmd/helm/pull_test.go +++ b/pkg/helm/pkg/cmd/pull_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "fmt" @@ -22,16 +22,17 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" - "github.com/werf/nelm/pkg/helm/pkg/repo/repotest" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1/repotest" ) func TestPullCmd(t *testing.T) { - srv, err := repotest.NewTempServerWithCleanup(t, "testdata/testcharts/*.tgz*") - if err != nil { - t.Fatal(err) - } + srv := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/testcharts/*.tgz*"), + ) defer srv.Stop() ociSrv, err := repotest.NewOCIServer(t, srv.Root()) @@ -106,16 +107,16 @@ func TestPullCmd(t *testing.T) { { name: "Fetch untar when file with same name existed", args: "test/test1 --untar --untardir test1", - existFile: "test1", + existFile: "test1/test1", wantError: true, - wantErrorMsg: fmt.Sprintf("failed to untar: a file or directory with the name %s already exists", filepath.Join(srv.Root(), "test1")), + wantErrorMsg: fmt.Sprintf("failed to untar: a file or directory with the name %s already exists", filepath.Join(srv.Root(), "test1", "test1")), }, { name: "Fetch untar when dir with same name existed", - args: "test/test2 --untar --untardir test2", - existDir: "test2", + args: "test/test --untar --untardir test2", + existDir: "test2/test", wantError: true, - wantErrorMsg: fmt.Sprintf("failed to untar: a file or directory with the name %s already exists", filepath.Join(srv.Root(), "test2")), + wantErrorMsg: fmt.Sprintf("failed to untar: a file or directory with the name %s already exists", filepath.Join(srv.Root(), "test2", "test")), }, { name: "Fetch, verify, untar", @@ -147,6 +148,18 @@ func TestPullCmd(t *testing.T) { failExpect: "Failed to fetch chart version", wantError: true, }, + { + name: "Chart fetch using repo URL with untardir", + args: "signtest --version=0.1.0 --untar --untardir repo-url-test --repo " + srv.URL(), + expectFile: "./signtest", + expectDir: true, + }, + { + name: "Chart fetch using repo URL with untardir and previous pull", + args: "signtest --version=0.1.0 --untar --untardir repo-url-test --repo " + srv.URL(), + failExpect: "failed to untar", + wantError: true, + }, { name: "Fetch OCI Chart", args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart --version 0.1.0", ociSrv.RegistryURL), @@ -166,9 +179,10 @@ func TestPullCmd(t *testing.T) { }, { name: "OCI Fetch untar when dir with same name existed", - args: fmt.Sprintf("oci-test-chart oci://%s/u/ocitestuser/oci-dependent-chart --version 0.1.0 --untar --untardir ocitest2 --untar --untardir ocitest2", ociSrv.RegistryURL), + args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart --version 0.1.0 --untar --untardir ocitest2", ociSrv.RegistryURL), + existDir: "ocitest2/oci-dependent-chart", wantError: true, - wantErrorMsg: fmt.Sprintf("failed to untar: a file or directory with the name %s already exists", filepath.Join(srv.Root(), "ocitest2")), + wantErrorMsg: fmt.Sprintf("failed to untar: a file or directory with the name %s already exists", filepath.Join(srv.Root(), "ocitest2", "oci-dependent-chart")), }, { name: "Fail fetching non-existent OCI chart", @@ -177,37 +191,47 @@ func TestPullCmd(t *testing.T) { wantError: true, }, { - name: "Fail fetching OCI chart without version specified", - args: fmt.Sprintf("oci://%s/u/ocitestuser/nosuchthing", ociSrv.RegistryURL), - wantErrorMsg: "Error: --version flag is explicitly required for OCI registries", - wantError: true, + name: "Fail fetching OCI chart without version specified", + args: fmt.Sprintf("oci://%s/u/ocitestuser/nosuchthing", ociSrv.RegistryURL), + wantError: true, }, { - name: "Fail fetching OCI chart without version specified", - args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart:0.1.0", ociSrv.RegistryURL), - wantErrorMsg: "Error: --version flag is explicitly required for OCI registries", - wantError: true, + name: "Fetching OCI chart without version option specified", + args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart:0.1.0", ociSrv.RegistryURL), + expectFile: "./oci-dependent-chart-0.1.0.tgz", }, { - name: "Fail fetching OCI chart without version specified", - args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart:0.1.0 --version 0.1.0", ociSrv.RegistryURL), - wantError: true, + name: "Fetching OCI chart with version specified", + args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart:0.1.0 --version 0.1.0", ociSrv.RegistryURL), + expectFile: "./oci-dependent-chart-0.1.0.tgz", + }, + { + name: "Fail fetching OCI chart with version mismatch", + args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart:0.2.0 --version 0.1.0", ociSrv.RegistryURL), + wantErrorMsg: "chart reference and version mismatch: 0.1.0 is not 0.2.0", + wantError: true, }, } + contentCache := t.TempDir() + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { outdir := srv.Root() - cmd := fmt.Sprintf("fetch %s -d '%s' --repository-config %s --repository-cache %s --registry-config %s", + cmd := fmt.Sprintf("fetch %s -d '%s' --repository-config %s --repository-cache %s --registry-config %s --content-cache %s --plain-http", tt.args, outdir, filepath.Join(outdir, "repositories.yaml"), outdir, filepath.Join(outdir, "config.json"), + contentCache, ) // Create file or Dir before helm pull --untar, see: https://github.com/helm/helm/issues/7182 if tt.existFile != "" { file := filepath.Join(outdir, tt.existFile) + if err := os.MkdirAll(filepath.Dir(file), 0755); err != nil { + t.Fatal(err) + } _, err := os.Create(file) if err != nil { t.Fatal(err) @@ -215,7 +239,7 @@ func TestPullCmd(t *testing.T) { } if tt.existDir != "" { file := filepath.Join(outdir, tt.existDir) - err := os.Mkdir(file, 0755) + err := os.MkdirAll(file, 0755) if err != nil { t.Fatal(err) } @@ -223,8 +247,8 @@ func TestPullCmd(t *testing.T) { _, out, err := executeActionCommand(cmd) if err != nil { if tt.wantError { - if tt.wantErrorMsg != "" && tt.wantErrorMsg == err.Error() { - t.Fatalf("Actual error %s, not equal to expected error %s", err, tt.wantErrorMsg) + if tt.wantErrorMsg != "" && tt.wantErrorMsg != err.Error() { + t.Fatalf("Actual error '%s', not equal to expected error '%s'", err, tt.wantErrorMsg) } return } @@ -251,19 +275,88 @@ func TestPullCmd(t *testing.T) { } } -func TestPullWithCredentialsCmd(t *testing.T) { - srv, err := repotest.NewTempServerWithCleanup(t, "testdata/testcharts/*.tgz*") - if err != nil { - t.Fatal(err) +// runPullTests is a helper function to run pull command tests with common logic +func runPullTests(t *testing.T, tests []struct { + name string + args string + existFile string + existDir string + wantError bool + wantErrorMsg string + expectFile string + expectDir bool +}, outdir string, additionalFlags string) { + t.Helper() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := fmt.Sprintf("pull %s -d '%s' --repository-config %s --repository-cache %s --registry-config %s %s", + tt.args, + outdir, + filepath.Join(outdir, "repositories.yaml"), + outdir, + filepath.Join(outdir, "config.json"), + additionalFlags, + ) + // Create file or Dir before helm pull --untar, see: https://github.com/helm/helm/issues/7182 + if tt.existFile != "" { + file := filepath.Join(outdir, tt.existFile) + _, err := os.Create(file) + if err != nil { + t.Fatal(err) + } + } + if tt.existDir != "" { + file := filepath.Join(outdir, tt.existDir) + err := os.MkdirAll(file, 0755) + if err != nil { + t.Fatal(err) + } + } + _, _, err := executeActionCommand(cmd) + if tt.wantError && err == nil { + t.Fatalf("%q: expected error but got none", tt.name) + } + if err != nil { + if tt.wantError { + if tt.wantErrorMsg != "" && tt.wantErrorMsg != err.Error() { + t.Fatalf("Actual error '%s', not equal to expected error '%s'", err, tt.wantErrorMsg) + } + return + } + t.Fatalf("%q reported error: %s", tt.name, err) + } + + ef := filepath.Join(outdir, tt.expectFile) + fi, err := os.Stat(ef) + if err != nil { + t.Errorf("%q: expected a file at %s. %s", tt.name, ef, err) + } + if fi.IsDir() != tt.expectDir { + t.Errorf("%q: expected directory=%t, but it's not.", tt.name, tt.expectDir) + } + }) } - defer srv.Stop() +} - srv.WithMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - username, password, ok := r.BasicAuth() - if !ok || username != "username" || password != "password" { - t.Errorf("Expected request to use basic auth and for username == 'username' and password == 'password', got '%v', '%s', '%s'", ok, username, password) - } - })) +// buildOCIURL is a helper function to build OCI URLs with credentials +func buildOCIURL(registryURL, chartName, version, username, password string) string { + baseURL := fmt.Sprintf("oci://%s/u/ocitestuser/%s", registryURL, chartName) + if version != "" { + baseURL += fmt.Sprintf(" --version %s", version) + } + if username != "" && password != "" { + baseURL += fmt.Sprintf(" --username %s --password %s", username, password) + } + return baseURL +} + +func TestPullWithCredentialsCmd(t *testing.T) { + srv := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/testcharts/*.tgz*"), + repotest.WithMiddleware(repotest.BasicAuthMiddleware(t)), + ) + defer srv.Stop() srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.FileServer(http.Dir(srv.Root())).ServeHTTP(w, r) @@ -312,52 +405,7 @@ func TestPullWithCredentialsCmd(t *testing.T) { }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - outdir := srv.Root() - cmd := fmt.Sprintf("pull %s -d '%s' --repository-config %s --repository-cache %s --registry-config %s", - tt.args, - outdir, - filepath.Join(outdir, "repositories.yaml"), - outdir, - filepath.Join(outdir, "config.json"), - ) - // Create file or Dir before helm pull --untar, see: https://github.com/helm/helm/issues/7182 - if tt.existFile != "" { - file := filepath.Join(outdir, tt.existFile) - _, err := os.Create(file) - if err != nil { - t.Fatal(err) - } - } - if tt.existDir != "" { - file := filepath.Join(outdir, tt.existDir) - err := os.Mkdir(file, 0755) - if err != nil { - t.Fatal(err) - } - } - _, _, err := executeActionCommand(cmd) - if err != nil { - if tt.wantError { - if tt.wantErrorMsg != "" && tt.wantErrorMsg == err.Error() { - t.Fatalf("Actual error %s, not equal to expected error %s", err, tt.wantErrorMsg) - } - return - } - t.Fatalf("%q reported error: %s", tt.name, err) - } - - ef := filepath.Join(outdir, tt.expectFile) - fi, err := os.Stat(ef) - if err != nil { - t.Errorf("%q: expected a file at %s. %s", tt.name, ef, err) - } - if fi.IsDir() != tt.expectDir { - t.Errorf("%q: expected directory=%t, but it's not.", tt.name, tt.expectDir) - } - }) - } + runPullTests(t, tests, srv.Root(), "") } func TestPullVersionCompletion(t *testing.T) { @@ -390,7 +438,123 @@ func TestPullVersionCompletion(t *testing.T) { runTestCmd(t, tests) } +func TestPullWithCredentialsCmdOCIRegistry(t *testing.T) { + srv := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/testcharts/*.tgz*"), + ) + defer srv.Stop() + + ociSrv, err := repotest.NewOCIServer(t, srv.Root()) + if err != nil { + t.Fatal(err) + } + ociSrv.Run(t) + + if err := srv.LinkIndices(); err != nil { + t.Fatal(err) + } + + // all flags will get "-d outdir" appended. + tests := []struct { + name string + args string + existFile string + existDir string + wantError bool + wantErrorMsg string + expectFile string + expectDir bool + }{ + { + name: "OCI Chart fetch with credentials", + args: buildOCIURL(ociSrv.RegistryURL, "oci-dependent-chart", "0.1.0", ociSrv.TestUsername, ociSrv.TestPassword), + expectFile: "./oci-dependent-chart-0.1.0.tgz", + }, + { + name: "OCI Chart fetch with credentials and untar", + args: buildOCIURL(ociSrv.RegistryURL, "oci-dependent-chart", "0.1.0", ociSrv.TestUsername, ociSrv.TestPassword) + " --untar", + expectFile: "./oci-dependent-chart", + expectDir: true, + }, + { + name: "OCI Chart fetch with credentials and untardir", + args: buildOCIURL(ociSrv.RegistryURL, "oci-dependent-chart", "0.1.0", ociSrv.TestUsername, ociSrv.TestPassword) + " --untar --untardir ocitest-credentials", + expectFile: "./ocitest-credentials", + expectDir: true, + }, + { + name: "Fail fetching OCI chart with wrong credentials", + args: buildOCIURL(ociSrv.RegistryURL, "oci-dependent-chart", "0.1.0", "wronguser", "wrongpass"), + wantError: true, + }, + { + name: "Fail fetching non-existent OCI chart with credentials", + args: buildOCIURL(ociSrv.RegistryURL, "nosuchthing", "0.1.0", ociSrv.TestUsername, ociSrv.TestPassword), + wantError: true, + }, + { + name: "Fail fetching OCI chart without version specified", + args: buildOCIURL(ociSrv.RegistryURL, "nosuchthing", "", ociSrv.TestUsername, ociSrv.TestPassword), + wantError: true, + }, + } + + runPullTests(t, tests, srv.Root(), "--plain-http") +} + func TestPullFileCompletion(t *testing.T) { checkFileCompletion(t, "pull", false) checkFileCompletion(t, "pull repo/chart", false) } + +// TestPullOCIWithTagAndDigest tests pulling an OCI chart with both tag and digest specified. +// This is a regression test for https://github.com/helm/helm/issues/31600 +func TestPullOCIWithTagAndDigest(t *testing.T) { + srv := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/testcharts/*.tgz*"), + ) + defer srv.Stop() + + ociSrv, err := repotest.NewOCIServer(t, srv.Root()) + if err != nil { + t.Fatal(err) + } + result := ociSrv.RunWithReturn(t) + + contentCache := t.TempDir() + outdir := t.TempDir() + + // Test: pull with tag and digest (the fixed bug from issue #31600) + // Previously this failed with "encoding/hex: invalid byte: U+0073 's'" + ref := fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart:0.1.0@%s", + ociSrv.RegistryURL, result.PushedChart.Manifest.Digest) + + cmd := fmt.Sprintf("pull %s -d '%s' --registry-config %s --content-cache %s --plain-http", + ref, + outdir, + filepath.Join(srv.Root(), "config.json"), + contentCache, + ) + + _, _, err = executeActionCommand(cmd) + if err != nil { + t.Fatalf("pull with tag+digest failed: %v", err) + } + + // Verify the file was downloaded + // When digest is present, the filename uses the digest format (e.g. chart@sha256-hex.tgz) + expectedFile := filepath.Join(outdir, "oci-dependent-chart-0.1.0.tgz") + if _, err := os.Stat(expectedFile); err != nil { + // Try the digest-based filename; parse algorithm:hex to avoid fixed-offset assumptions + algorithm, digestPart, ok := strings.Cut(result.PushedChart.Manifest.Digest, ":") + if !ok { + t.Fatalf("digest must be in algorithm:hex format, got %q", result.PushedChart.Manifest.Digest) + } + expectedFile = filepath.Join(outdir, fmt.Sprintf("oci-dependent-chart@%s-%s.tgz", algorithm, digestPart)) + if _, err := os.Stat(expectedFile); err != nil { + t.Errorf("expected chart file not found: %v", err) + } + } +} diff --git a/pkg/helm/cmd/helm/push.go b/pkg/helm/pkg/cmd/push.go similarity index 75% rename from pkg/helm/cmd/helm/push.go rename to pkg/helm/pkg/cmd/push.go index 8a15375a..a142d794 100644 --- a/pkg/helm/cmd/helm/push.go +++ b/pkg/helm/pkg/cmd/push.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "fmt" @@ -22,10 +22,9 @@ import ( "github.com/spf13/cobra" - "github.com/werf/nelm/pkg/helm/cmd/helm/require" "github.com/werf/nelm/pkg/helm/pkg/action" + "github.com/werf/nelm/pkg/helm/pkg/cmd/require" "github.com/werf/nelm/pkg/helm/pkg/pusher" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" ) const pushDesc = ` @@ -39,8 +38,10 @@ type registryPushOptions struct { certFile string keyFile string caFile string - insecureSkipTLSverify bool + insecureSkipTLSVerify bool plainHTTP bool + password string + username string } func newPushCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { @@ -51,7 +52,7 @@ func newPushCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { Short: "push a chart to remote", Long: pushDesc, Args: require.MinimumNArgs(2), - ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + ValidArgsFunction: func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { if len(args) == 0 { // Do file completion for the chart file to push return nil, cobra.ShellCompDirectiveDefault @@ -66,10 +67,13 @@ func newPushCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } return comps, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace } - return nil, cobra.ShellCompDirectiveNoFileComp + return noMoreArgsComp() }, - RunE: func(cmd *cobra.Command, args []string) error { - registryClient, err := newRegistryClient(o.certFile, o.keyFile, o.caFile, o.insecureSkipTLSverify, o.plainHTTP) + RunE: func(_ *cobra.Command, args []string) error { + registryClient, err := newRegistryClient( + o.certFile, o.keyFile, o.caFile, o.insecureSkipTLSVerify, o.plainHTTP, o.username, o.password, + ) + if err != nil { return fmt.Errorf("missing registry client: %w", err) } @@ -78,18 +82,11 @@ func newPushCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { remote := args[1] client := action.NewPushWithOpts(action.WithPushConfig(cfg), action.WithTLSClientConfig(o.certFile, o.keyFile, o.caFile), - action.WithInsecureSkipTLSVerify(o.insecureSkipTLSverify), + action.WithInsecureSkipTLSVerify(o.insecureSkipTLSVerify), action.WithPlainHTTP(o.plainHTTP), action.WithPushOptWriter(out)) client.Settings = settings - - opts := helmopts.HelmOptions{ - ChartLoadOpts: helmopts.ChartLoadOptions{ - NoSecrets: true, - }, - } - - output, err := client.Run(chartRef, remote, opts) + output, err := client.Run(chartRef, remote) if err != nil { return err } @@ -102,8 +99,10 @@ func newPushCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f.StringVar(&o.certFile, "cert-file", "", "identify registry client using this SSL certificate file") f.StringVar(&o.keyFile, "key-file", "", "identify registry client using this SSL key file") f.StringVar(&o.caFile, "ca-file", "", "verify certificates of HTTPS-enabled servers using this CA bundle") - f.BoolVar(&o.insecureSkipTLSverify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the chart upload") + f.BoolVar(&o.insecureSkipTLSVerify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the chart upload") f.BoolVar(&o.plainHTTP, "plain-http", false, "use insecure HTTP connections for the chart upload") + f.StringVar(&o.username, "username", "", "chart repository username where to locate the requested chart") + f.StringVar(&o.password, "password", "", "chart repository password where to locate the requested chart") return cmd } diff --git a/pkg/helm/cmd/helm/push_test.go b/pkg/helm/pkg/cmd/push_test.go similarity index 98% rename from pkg/helm/cmd/helm/push_test.go rename to pkg/helm/pkg/cmd/push_test.go index 896ff36b..80d08b48 100644 --- a/pkg/helm/cmd/helm/push_test.go +++ b/pkg/helm/pkg/cmd/push_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "testing" diff --git a/pkg/helm/cmd/helm/registry.go b/pkg/helm/pkg/cmd/registry.go similarity index 98% rename from pkg/helm/cmd/helm/registry.go rename to pkg/helm/pkg/cmd/registry.go index 48d746c7..bcee4293 100644 --- a/pkg/helm/cmd/helm/registry.go +++ b/pkg/helm/pkg/cmd/registry.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "io" diff --git a/pkg/helm/cmd/helm/registry_login.go b/pkg/helm/pkg/cmd/registry_login.go similarity index 87% rename from pkg/helm/cmd/helm/registry_login.go rename to pkg/helm/pkg/cmd/registry_login.go index a3c3d8ae..32f1deba 100644 --- a/pkg/helm/cmd/helm/registry_login.go +++ b/pkg/helm/pkg/cmd/registry_login.go @@ -14,25 +14,30 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "bufio" "errors" "fmt" "io" + "log/slog" "os" "strings" "github.com/moby/term" "github.com/spf13/cobra" - "github.com/werf/nelm/pkg/helm/cmd/helm/require" "github.com/werf/nelm/pkg/helm/pkg/action" + "github.com/werf/nelm/pkg/helm/pkg/cmd/require" ) const registryLoginDesc = ` Authenticate to a remote registry. + +For example for Github Container Registry: + + echo "$GITHUB_TOKEN" | helm registry login ghcr.io -u $GITHUB_USER --password-stdin ` type registryLoginOptions struct { @@ -43,6 +48,7 @@ type registryLoginOptions struct { keyFile string caFile string insecure bool + plainHTTP bool } func newRegistryLoginCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { @@ -53,8 +59,8 @@ func newRegistryLoginCmd(cfg *action.Configuration, out io.Writer) *cobra.Comman Short: "login to a registry", Long: registryLoginDesc, Args: require.MinimumNArgs(1), - ValidArgsFunction: noCompletions, - RunE: func(cmd *cobra.Command, args []string) error { + ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(_ *cobra.Command, args []string) error { hostname := args[0] username, password, err := getUsernamePassword(o.username, o.password, o.passwordFromStdinOpt) @@ -66,7 +72,8 @@ func newRegistryLoginCmd(cfg *action.Configuration, out io.Writer) *cobra.Comman action.WithCertFile(o.certFile), action.WithKeyFile(o.keyFile), action.WithCAFile(o.caFile), - action.WithInsecure(o.insecure)) + action.WithInsecure(o.insecure), + action.WithPlainHTTPLogin(o.plainHTTP)) }, } @@ -78,6 +85,7 @@ func newRegistryLoginCmd(cfg *action.Configuration, out io.Writer) *cobra.Comman f.StringVar(&o.certFile, "cert-file", "", "identify registry client using this SSL certificate file") f.StringVar(&o.keyFile, "key-file", "", "identify registry client using this SSL key file") f.StringVar(&o.caFile, "ca-file", "", "verify certificates of HTTPS-enabled servers using this CA bundle") + f.BoolVar(&o.plainHTTP, "plain-http", false, "use insecure HTTP connections for the chart upload") return cmd } @@ -119,7 +127,7 @@ func getUsernamePassword(usernameOpt string, passwordOpt string, passwordFromStd } } } else { - warning("Using --password via the CLI is insecure. Use --password-stdin.") + slog.Warn("using --password via the CLI is insecure. Use --password-stdin") } return username, password, nil diff --git a/pkg/helm/cmd/helm/registry_login_test.go b/pkg/helm/pkg/cmd/registry_login_test.go similarity index 98% rename from pkg/helm/cmd/helm/registry_login_test.go rename to pkg/helm/pkg/cmd/registry_login_test.go index 9130240f..6e4f2116 100644 --- a/pkg/helm/cmd/helm/registry_login_test.go +++ b/pkg/helm/pkg/cmd/registry_login_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "testing" diff --git a/pkg/helm/cmd/helm/registry_logout.go b/pkg/helm/pkg/cmd/registry_logout.go similarity index 87% rename from pkg/helm/cmd/helm/registry_logout.go rename to pkg/helm/pkg/cmd/registry_logout.go index 3c3d5738..29681f69 100644 --- a/pkg/helm/cmd/helm/registry_logout.go +++ b/pkg/helm/pkg/cmd/registry_logout.go @@ -14,15 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "io" "github.com/spf13/cobra" - "github.com/werf/nelm/pkg/helm/cmd/helm/require" "github.com/werf/nelm/pkg/helm/pkg/action" + "github.com/werf/nelm/pkg/helm/pkg/cmd/require" ) const registryLogoutDesc = ` @@ -35,8 +35,8 @@ func newRegistryLogoutCmd(cfg *action.Configuration, out io.Writer) *cobra.Comma Short: "logout from a registry", Long: registryLogoutDesc, Args: require.MinimumNArgs(1), - ValidArgsFunction: noCompletions, - RunE: func(cmd *cobra.Command, args []string) error { + ValidArgsFunction: cobra.NoFileCompletions, + RunE: func(_ *cobra.Command, args []string) error { hostname := args[0] return action.NewRegistryLogout(cfg).Run(out, hostname) }, diff --git a/pkg/helm/cmd/helm/registry_logout_test.go b/pkg/helm/pkg/cmd/registry_logout_test.go similarity index 98% rename from pkg/helm/cmd/helm/registry_logout_test.go rename to pkg/helm/pkg/cmd/registry_logout_test.go index 2531df26..31a21b27 100644 --- a/pkg/helm/cmd/helm/registry_logout_test.go +++ b/pkg/helm/pkg/cmd/registry_logout_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "testing" diff --git a/pkg/helm/cmd/helm/repo.go b/pkg/helm/pkg/cmd/repo.go similarity index 85% rename from pkg/helm/cmd/helm/repo.go rename to pkg/helm/pkg/cmd/repo.go index a566cfb4..1ad22604 100644 --- a/pkg/helm/cmd/helm/repo.go +++ b/pkg/helm/pkg/cmd/repo.go @@ -14,16 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( + "errors" "io" - "os" + "io/fs" - "github.com/pkg/errors" "github.com/spf13/cobra" - "github.com/werf/nelm/pkg/helm/cmd/helm/require" + "github.com/werf/nelm/pkg/helm/pkg/cmd/require" ) var repoHelm = ` @@ -41,12 +41,14 @@ func newRepoCmd(out io.Writer) *cobra.Command { } cmd.AddCommand(newRepoAddCmd(out)) + cmd.AddCommand(newRepoListCmd(out)) cmd.AddCommand(newRepoRemoveCmd(out)) + cmd.AddCommand(newRepoIndexCmd(out)) cmd.AddCommand(newRepoUpdateCmd(out)) return cmd } func isNotExist(err error) bool { - return os.IsNotExist(errors.Cause(err)) + return errors.Is(err, fs.ErrNotExist) } diff --git a/pkg/helm/cmd/helm/repo_add.go b/pkg/helm/pkg/cmd/repo_add.go similarity index 81% rename from pkg/helm/cmd/helm/repo_add.go rename to pkg/helm/pkg/cmd/repo_add.go index ab2c0fc9..cea00253 100644 --- a/pkg/helm/cmd/helm/repo_add.go +++ b/pkg/helm/pkg/cmd/repo_add.go @@ -14,26 +14,27 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "context" + "errors" "fmt" "io" + "io/fs" "os" "path/filepath" "strings" "time" "github.com/gofrs/flock" - "github.com/pkg/errors" "github.com/spf13/cobra" "golang.org/x/term" "sigs.k8s.io/yaml" - "github.com/werf/nelm/pkg/helm/cmd/helm/require" + "github.com/werf/nelm/pkg/helm/pkg/cmd/require" "github.com/werf/nelm/pkg/helm/pkg/getter" - "github.com/werf/nelm/pkg/helm/pkg/repo" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1" ) // Repositories that have been permanently deleted and no longer work @@ -51,28 +52,31 @@ type repoAddOptions struct { passCredentialsAll bool forceUpdate bool allowDeprecatedRepos bool + timeout time.Duration certFile string keyFile string caFile string - insecureSkipTLSverify bool + insecureSkipTLSVerify bool repoFile string repoCache string - - // Deprecated, but cannot be removed until Helm 4 - deprecatedNoUpdate bool } func newRepoAddCmd(out io.Writer) *cobra.Command { o := &repoAddOptions{} cmd := &cobra.Command{ - Use: "add [NAME] [URL]", - Short: "add a chart repository", - Args: require.ExactArgs(2), - ValidArgsFunction: noCompletions, - RunE: func(cmd *cobra.Command, args []string) error { + Use: "add [NAME] [URL]", + Short: "add a chart repository", + Args: require.ExactArgs(2), + ValidArgsFunction: func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) > 1 { + return noMoreArgsComp() + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + RunE: func(_ *cobra.Command, args []string) error { o.name = args[0] o.url = args[1] o.repoFile = settings.RepositoryConfig @@ -87,13 +91,13 @@ func newRepoAddCmd(out io.Writer) *cobra.Command { f.StringVar(&o.password, "password", "", "chart repository password") f.BoolVarP(&o.passwordFromStdinOpt, "password-stdin", "", false, "read chart repository password from stdin") f.BoolVar(&o.forceUpdate, "force-update", false, "replace (overwrite) the repo if it already exists") - f.BoolVar(&o.deprecatedNoUpdate, "no-update", false, "Ignored. Formerly, it would disabled forced updates. It is deprecated by force-update.") f.StringVar(&o.certFile, "cert-file", "", "identify HTTPS client using this SSL certificate file") f.StringVar(&o.keyFile, "key-file", "", "identify HTTPS client using this SSL key file") f.StringVar(&o.caFile, "ca-file", "", "verify certificates of HTTPS-enabled servers using this CA bundle") - f.BoolVar(&o.insecureSkipTLSverify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the repository") + f.BoolVar(&o.insecureSkipTLSVerify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the repository") f.BoolVar(&o.allowDeprecatedRepos, "allow-deprecated-repos", false, "by default, this command will not allow adding official repos that have been permanently deleted. This disables that behavior") f.BoolVar(&o.passCredentialsAll, "pass-credentials", false, "pass credentials to all domains") + f.DurationVar(&o.timeout, "timeout", getter.DefaultHTTPTimeout*time.Second, "time to wait for the index file download to complete") return cmd } @@ -134,7 +138,7 @@ func (o *repoAddOptions) run(out io.Writer) error { } b, err := os.ReadFile(o.repoFile) - if err != nil && !os.IsNotExist(err) { + if err != nil && !errors.Is(err, fs.ErrNotExist) { return err } @@ -173,12 +177,12 @@ func (o *repoAddOptions) run(out io.Writer) error { CertFile: o.certFile, KeyFile: o.keyFile, CAFile: o.caFile, - InsecureSkipTLSverify: o.insecureSkipTLSverify, + InsecureSkipTLSVerify: o.insecureSkipTLSVerify, } // Check if the repo name is legal if strings.Contains(o.name, "/") { - return errors.Errorf("repository name (%s) contains '/', please specify a different name without '/'", o.name) + return fmt.Errorf("repository name (%s) contains '/', please specify a different name without '/'", o.name) } // If the repo exists do one of two things: @@ -187,10 +191,9 @@ func (o *repoAddOptions) run(out io.Writer) error { if !o.forceUpdate && f.Has(o.name) { existing := f.Get(o.name) if c != *existing { - // The input coming in for the name is different from what is already // configured. Return an error. - return errors.Errorf("repository name (%s) already exists, please specify a different name", o.name) + return fmt.Errorf("repository name (%s) already exists, please specify a different name", o.name) } // The add is idempotent so do nothing @@ -198,7 +201,7 @@ func (o *repoAddOptions) run(out io.Writer) error { return nil } - r, err := repo.NewChartRepository(&c, getter.All(settings)) + r, err := repo.NewChartRepository(&c, getter.All(settings, getter.WithTimeout(o.timeout))) if err != nil { return err } @@ -207,12 +210,12 @@ func (o *repoAddOptions) run(out io.Writer) error { r.CachePath = o.repoCache } if _, err := r.DownloadIndexFile(); err != nil { - return errors.Wrapf(err, "looks like %q is not a valid chart repository or cannot be reached", o.url) + return fmt.Errorf("looks like %q is not a valid chart repository or cannot be reached: %w", o.url, err) } f.Update(&c) - if err := f.WriteFile(o.repoFile, 0600); err != nil { + if err := f.WriteFile(o.repoFile, 0o600); err != nil { return err } fmt.Fprintf(out, "%q has been added to your repositories\n", o.name) diff --git a/pkg/helm/cmd/helm/repo_add_test.go b/pkg/helm/pkg/cmd/repo_add_test.go similarity index 79% rename from pkg/helm/cmd/helm/repo_add_test.go rename to pkg/helm/pkg/cmd/repo_add_test.go index 438bac71..9b65cc2f 100644 --- a/pkg/helm/cmd/helm/repo_add_test.go +++ b/pkg/helm/pkg/cmd/repo_add_test.go @@ -14,11 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( + "errors" "fmt" "io" + "io/fs" "os" "path/filepath" "strings" @@ -29,27 +31,26 @@ import ( "github.com/werf/nelm/pkg/helm/pkg/helmpath" "github.com/werf/nelm/pkg/helm/pkg/helmpath/xdg" - "github.com/werf/nelm/pkg/helm/pkg/repo" - "github.com/werf/nelm/pkg/helm/pkg/repo/repotest" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1/repotest" ) func TestRepoAddCmd(t *testing.T) { - srv, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") - if err != nil { - t.Fatal(err) - } + srv := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/testserver/*.*"), + ) defer srv.Stop() // A second test server is setup to verify URL changing - srv2, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") - if err != nil { - t.Fatal(err) - } + srv2 := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/testserver/*.*"), + ) defer srv2.Stop() tmpdir := filepath.Join(t.TempDir(), "path-component.yaml/data") - err = os.MkdirAll(tmpdir, 0777) - if err != nil { + if err := os.MkdirAll(tmpdir, 0o777); err != nil { t.Fatal(err) } repoFile := filepath.Join(tmpdir, "repositories.yaml") @@ -81,10 +82,10 @@ func TestRepoAddCmd(t *testing.T) { } func TestRepoAdd(t *testing.T) { - ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") - if err != nil { - t.Fatal(err) - } + ts := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/testserver/*.*"), + ) defer ts.Stop() rootDir := t.TempDir() @@ -93,13 +94,12 @@ func TestRepoAdd(t *testing.T) { const testRepoName = "test-name" o := &repoAddOptions{ - name: testRepoName, - url: ts.URL(), - forceUpdate: false, - deprecatedNoUpdate: true, - repoFile: repoFile, + name: testRepoName, + url: ts.URL(), + forceUpdate: false, + repoFile: repoFile, } - os.Setenv(xdg.CacheHomeEnvVar, rootDir) + t.Setenv(xdg.CacheHomeEnvVar, rootDir) if err := o.run(io.Discard); err != nil { t.Error(err) @@ -115,11 +115,11 @@ func TestRepoAdd(t *testing.T) { } idx := filepath.Join(helmpath.CachePath("repository"), helmpath.CacheIndexFile(testRepoName)) - if _, err := os.Stat(idx); os.IsNotExist(err) { + if _, err := os.Stat(idx); errors.Is(err, fs.ErrNotExist) { t.Errorf("Error cache index file was not created for repository %s", testRepoName) } idx = filepath.Join(helmpath.CachePath("repository"), helmpath.CacheChartsFile(testRepoName)) - if _, err := os.Stat(idx); os.IsNotExist(err) { + if _, err := os.Stat(idx); errors.Is(err, fs.ErrNotExist) { t.Errorf("Error cache charts file was not created for repository %s", testRepoName) } @@ -135,10 +135,10 @@ func TestRepoAdd(t *testing.T) { } func TestRepoAddCheckLegalName(t *testing.T) { - ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") - if err != nil { - t.Fatal(err) - } + ts := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/testserver/*.*"), + ) defer ts.Stop() defer resetEnv()() @@ -148,13 +148,12 @@ func TestRepoAddCheckLegalName(t *testing.T) { repoFile := filepath.Join(t.TempDir(), "repositories.yaml") o := &repoAddOptions{ - name: testRepoName, - url: ts.URL(), - forceUpdate: false, - deprecatedNoUpdate: true, - repoFile: repoFile, + name: testRepoName, + url: ts.URL(), + forceUpdate: false, + repoFile: repoFile, } - os.Setenv(xdg.CacheHomeEnvVar, rootDir) + t.Setenv(xdg.CacheHomeEnvVar, rootDir) wantErrorMsg := fmt.Sprintf("repository name (%s) contains '/', please specify a different name without '/'", testRepoName) @@ -192,23 +191,23 @@ func TestRepoAddConcurrentHiddenFile(t *testing.T) { } func repoAddConcurrent(t *testing.T, testName, repoFile string) { - ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") - if err != nil { - t.Fatal(err) - } + t.Helper() + ts := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/testserver/*.*"), + ) defer ts.Stop() var wg sync.WaitGroup wg.Add(3) - for i := 0; i < 3; i++ { + for i := range 3 { go func(name string) { defer wg.Done() o := &repoAddOptions{ - name: name, - url: ts.URL(), - deprecatedNoUpdate: true, - forceUpdate: false, - repoFile: repoFile, + name: name, + url: ts.URL(), + forceUpdate: false, + repoFile: repoFile, } if err := o.run(io.Discard); err != nil { t.Error(err) @@ -228,7 +227,7 @@ func repoAddConcurrent(t *testing.T, testName, repoFile string) { } var name string - for i := 0; i < 3; i++ { + for i := range 3 { name = fmt.Sprintf("%s-%d", testName, i) if !f.Has(name) { t.Errorf("%s was not successfully inserted into %s: %s", name, repoFile, f.Repositories[0]) @@ -243,7 +242,11 @@ func TestRepoAddFileCompletion(t *testing.T) { } func TestRepoAddWithPasswordFromStdin(t *testing.T) { - srv := repotest.NewTempServerWithCleanupAndBasicAuth(t, "testdata/testserver/*.*") + srv := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/testserver/*.*"), + repotest.WithMiddleware(repotest.BasicAuthMiddleware(t)), + ) defer srv.Stop() defer resetEnv()() diff --git a/pkg/helm/pkg/cmd/repo_index.go b/pkg/helm/pkg/cmd/repo_index.go new file mode 100644 index 00000000..fcdce458 --- /dev/null +++ b/pkg/helm/pkg/cmd/repo_index.go @@ -0,0 +1,122 @@ +/* +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 cmd + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/werf/nelm/pkg/helm/pkg/cmd/require" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1" +) + +const repoIndexDesc = ` +Read the current directory, generate an index file based on the charts found +and write the result to 'index.yaml' in the current directory. + +This tool is used for creating an 'index.yaml' file for a chart repository. To +set an absolute URL to the charts, use '--url' flag. + +To merge the generated index with an existing index file, use the '--merge' +flag. In this case, the charts found in the current directory will be merged +into the index passed in with --merge, with local charts taking priority over +existing charts. +` + +type repoIndexOptions struct { + dir string + url string + merge string + json bool +} + +func newRepoIndexCmd(out io.Writer) *cobra.Command { + o := &repoIndexOptions{} + + cmd := &cobra.Command{ + Use: "index [DIR]", + Short: "generate an index file given a directory containing packaged charts", + Long: repoIndexDesc, + Args: require.ExactArgs(1), + ValidArgsFunction: func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + // Allow file completion when completing the argument for the directory + return nil, cobra.ShellCompDirectiveDefault + } + // No more completions, so disable file completion + return noMoreArgsComp() + }, + RunE: func(_ *cobra.Command, args []string) error { + o.dir = args[0] + return o.run(out) + }, + } + + f := cmd.Flags() + f.StringVar(&o.url, "url", "", "url of chart repository") + f.StringVar(&o.merge, "merge", "", "merge the generated index into the given index") + f.BoolVar(&o.json, "json", false, "output in JSON format") + + return cmd +} + +func (i *repoIndexOptions) run(_ io.Writer) error { + path, err := filepath.Abs(i.dir) + if err != nil { + return err + } + + return index(path, i.url, i.merge, i.json) +} + +func index(dir, url, mergeTo string, json bool) error { + out := filepath.Join(dir, "index.yaml") + + i, err := repo.IndexDirectory(dir, url) + if err != nil { + return err + } + if mergeTo != "" { + // if index.yaml is missing then create an empty one to merge into + var i2 *repo.IndexFile + if _, err := os.Stat(mergeTo); errors.Is(err, fs.ErrNotExist) { + i2 = repo.NewIndexFile() + writeIndexFile(i2, mergeTo, json) + } else { + i2, err = repo.LoadIndexFile(mergeTo) + if err != nil { + return fmt.Errorf("merge failed: %w", err) + } + } + i.Merge(i2) + } + i.SortEntries() + return writeIndexFile(i, out, json) +} + +func writeIndexFile(i *repo.IndexFile, out string, json bool) error { + if json { + return i.WriteJSONFile(out, 0o644) + } + return i.WriteFile(out, 0o644) +} diff --git a/pkg/helm/pkg/cmd/repo_index_test.go b/pkg/helm/pkg/cmd/repo_index_test.go new file mode 100644 index 00000000..0f4ee95d --- /dev/null +++ b/pkg/helm/pkg/cmd/repo_index_test.go @@ -0,0 +1,194 @@ +/* +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 cmd + +import ( + "bytes" + "encoding/json" + "io" + "os" + "path/filepath" + "testing" + + "github.com/werf/nelm/pkg/helm/pkg/repo/v1" +) + +func TestRepoIndexCmd(t *testing.T) { + + dir := t.TempDir() + + comp := filepath.Join(dir, "compressedchart-0.1.0.tgz") + if err := linkOrCopy("testdata/testcharts/compressedchart-0.1.0.tgz", comp); err != nil { + t.Fatal(err) + } + comp2 := filepath.Join(dir, "compressedchart-0.2.0.tgz") + if err := linkOrCopy("testdata/testcharts/compressedchart-0.2.0.tgz", comp2); err != nil { + t.Fatal(err) + } + + buf := bytes.NewBuffer(nil) + c := newRepoIndexCmd(buf) + + if err := c.RunE(c, []string{dir}); err != nil { + t.Error(err) + } + + destIndex := filepath.Join(dir, "index.yaml") + + index, err := repo.LoadIndexFile(destIndex) + if err != nil { + t.Fatal(err) + } + + if len(index.Entries) != 1 { + t.Errorf("expected 1 entry, got %d: %#v", len(index.Entries), index.Entries) + } + + vs := index.Entries["compressedchart"] + if len(vs) != 2 { + t.Errorf("expected 2 versions, got %d: %#v", len(vs), vs) + } + + expectedVersion := "0.2.0" + if vs[0].Version != expectedVersion { + t.Errorf("expected %q, got %q", expectedVersion, vs[0].Version) + } + + b, err := os.ReadFile(destIndex) + if err != nil { + t.Fatal(err) + } + if json.Valid(b) { + t.Error("did not expect index file to be valid json") + } + + // Test with `--json` + + c.ParseFlags([]string{"--json", "true"}) + if err := c.RunE(c, []string{dir}); err != nil { + t.Error(err) + } + + if b, err = os.ReadFile(destIndex); err != nil { + t.Fatal(err) + } + if !json.Valid(b) { + t.Error("index file is not valid json") + } + + // Test with `--merge` + + // Remove first two charts. + if err := os.Remove(comp); err != nil { + t.Fatal(err) + } + if err := os.Remove(comp2); err != nil { + t.Fatal(err) + } + // Add a new chart and a new version of an existing chart + if err := linkOrCopy("testdata/testcharts/reqtest-0.1.0.tgz", filepath.Join(dir, "reqtest-0.1.0.tgz")); err != nil { + t.Fatal(err) + } + if err := linkOrCopy("testdata/testcharts/compressedchart-0.3.0.tgz", filepath.Join(dir, "compressedchart-0.3.0.tgz")); err != nil { + t.Fatal(err) + } + + c.ParseFlags([]string{"--merge", destIndex}) + if err := c.RunE(c, []string{dir}); err != nil { + t.Error(err) + } + + index, err = repo.LoadIndexFile(destIndex) + if err != nil { + t.Fatal(err) + } + + if len(index.Entries) != 2 { + t.Errorf("expected 2 entries, got %d: %#v", len(index.Entries), index.Entries) + } + + vs = index.Entries["compressedchart"] + if len(vs) != 3 { + t.Errorf("expected 3 versions, got %d: %#v", len(vs), vs) + } + + expectedVersion = "0.3.0" + if vs[0].Version != expectedVersion { + t.Errorf("expected %q, got %q", expectedVersion, vs[0].Version) + } + + // test that index.yaml gets generated on merge even when it doesn't exist + if err := os.Remove(destIndex); err != nil { + t.Fatal(err) + } + + c.ParseFlags([]string{"--merge", destIndex}) + if err := c.RunE(c, []string{dir}); err != nil { + t.Error(err) + } + + index, err = repo.LoadIndexFile(destIndex) + if err != nil { + t.Fatal(err) + } + + // verify it didn't create an empty index.yaml and the merged happened + if len(index.Entries) != 2 { + t.Errorf("expected 2 entries, got %d: %#v", len(index.Entries), index.Entries) + } + + vs = index.Entries["compressedchart"] + if len(vs) != 1 { + t.Errorf("expected 1 versions, got %d: %#v", len(vs), vs) + } + + expectedVersion = "0.3.0" + if vs[0].Version != expectedVersion { + t.Errorf("expected %q, got %q", expectedVersion, vs[0].Version) + } +} + +func linkOrCopy(source, target string) error { + if err := os.Link(source, target); err != nil { + return copyFile(source, target) + } + + return nil +} + +func copyFile(dst, src string) error { + i, err := os.Open(dst) + if err != nil { + return err + } + defer i.Close() + + o, err := os.Create(src) + if err != nil { + return err + } + defer o.Close() + + _, err = io.Copy(o, i) + + return err +} + +func TestRepoIndexFileCompletion(t *testing.T) { + checkFileCompletion(t, "repo index", true) + checkFileCompletion(t, "repo index mydir", false) +} diff --git a/pkg/helm/pkg/cmd/repo_list.go b/pkg/helm/pkg/cmd/repo_list.go new file mode 100644 index 00000000..ebb465d8 --- /dev/null +++ b/pkg/helm/pkg/cmd/repo_list.go @@ -0,0 +1,149 @@ +/* +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 cmd + +import ( + "fmt" + "io" + + "github.com/gosuri/uitable" + "github.com/spf13/cobra" + + "github.com/werf/nelm/pkg/helm/pkg/cli/output" + "github.com/werf/nelm/pkg/helm/pkg/cmd/require" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1" +) + +func newRepoListCmd(out io.Writer) *cobra.Command { + var outfmt output.Format + var noHeaders bool + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "list chart repositories", + Args: require.NoArgs, + ValidArgsFunction: noMoreArgsCompFunc, + RunE: func(cmd *cobra.Command, _ []string) error { + // The error is silently ignored. If no repository file exists, it cannot be loaded, + // or the file isn't the right format to be parsed the error is ignored. The + // repositories will be 0. + f, _ := repo.LoadFile(settings.RepositoryConfig) + if len(f.Repositories) == 0 && outfmt != output.JSON && outfmt != output.YAML { + fmt.Fprintln(cmd.ErrOrStderr(), "no repositories to show") + return nil + } + + w := &repoListWriter{ + repos: f.Repositories, + noHeaders: noHeaders, + } + + return outfmt.Write(out, w) + }, + } + + cmd.Flags().BoolVar(&noHeaders, "no-headers", false, "suppress headers in the output") + bindOutputFlag(cmd, &outfmt) + return cmd +} + +type repositoryElement struct { + Name string `json:"name"` + URL string `json:"url"` +} + +type repoListWriter struct { + repos []*repo.Entry + noHeaders bool +} + +func (r *repoListWriter) WriteTable(out io.Writer) error { + table := uitable.New() + if !r.noHeaders { + table.AddRow("NAME", "URL") + } + for _, re := range r.repos { + table.AddRow(re.Name, re.URL) + } + return output.EncodeTable(out, table) +} + +func (r *repoListWriter) WriteJSON(out io.Writer) error { + return r.encodeByFormat(out, output.JSON) +} + +func (r *repoListWriter) WriteYAML(out io.Writer) error { + return r.encodeByFormat(out, output.YAML) +} + +func (r *repoListWriter) encodeByFormat(out io.Writer, format output.Format) error { + // Initialize the array so no results returns an empty array instead of null + repolist := make([]repositoryElement, 0, len(r.repos)) + + for _, re := range r.repos { + repolist = append(repolist, repositoryElement{Name: re.Name, URL: re.URL}) + } + + switch format { + case output.JSON: + return output.EncodeJSON(out, repolist) + case output.YAML: + return output.EncodeYAML(out, repolist) + default: + // Because this is a non-exported function and only called internally by + // WriteJSON and WriteYAML, we shouldn't get invalid types + return nil + } +} + +// Returns all repos from repos, except those with names matching ignoredRepoNames +// Inspired by https://stackoverflow.com/a/28701031/893211 +func filterRepos(repos []*repo.Entry, ignoredRepoNames []string) []*repo.Entry { + // if ignoredRepoNames is nil, just return repo + if ignoredRepoNames == nil { + return repos + } + + filteredRepos := make([]*repo.Entry, 0) + + ignored := make(map[string]bool, len(ignoredRepoNames)) + for _, repoName := range ignoredRepoNames { + ignored[repoName] = true + } + + for _, repo := range repos { + if _, removed := ignored[repo.Name]; !removed { + filteredRepos = append(filteredRepos, repo) + } + } + + return filteredRepos +} + +// Provide dynamic auto-completion for repo names +func compListRepos(_ string, ignoredRepoNames []string) []string { + var rNames []string + + f, err := repo.LoadFile(settings.RepositoryConfig) + if err == nil && len(f.Repositories) > 0 { + filteredRepos := filterRepos(f.Repositories, ignoredRepoNames) + for _, repo := range filteredRepos { + rNames = append(rNames, fmt.Sprintf("%s\t%s", repo.Name, repo.URL)) + } + } + return rNames +} diff --git a/pkg/helm/pkg/cmd/repo_list_test.go b/pkg/helm/pkg/cmd/repo_list_test.go new file mode 100644 index 00000000..94cdf396 --- /dev/null +++ b/pkg/helm/pkg/cmd/repo_list_test.go @@ -0,0 +1,60 @@ +/* +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 cmd + +import ( + "fmt" + "path/filepath" + "testing" +) + +func TestRepoListOutputCompletion(t *testing.T) { + outputFlagCompletionTest(t, "repo list") +} + +func TestRepoListFileCompletion(t *testing.T) { + checkFileCompletion(t, "repo list", false) +} + +func TestRepoList(t *testing.T) { + rootDir := t.TempDir() + repoFile := filepath.Join(rootDir, "repositories.yaml") + repoFile2 := "testdata/repositories.yaml" + + tests := []cmdTestCase{ + { + name: "list with no repos", + cmd: fmt.Sprintf("repo list --repository-config %s --repository-cache %s", repoFile, rootDir), + golden: "output/repo-list-empty.txt", + wantError: false, + }, + { + name: "list with repos", + cmd: fmt.Sprintf("repo list --repository-config %s --repository-cache %s", repoFile2, rootDir), + golden: "output/repo-list.txt", + wantError: false, + }, + { + name: "list without headers", + cmd: fmt.Sprintf("repo list --repository-config %s --repository-cache %s --no-headers", repoFile2, rootDir), + golden: "output/repo-list-no-headers.txt", + wantError: false, + }, + } + + runTestCmd(t, tests) +} diff --git a/pkg/helm/cmd/helm/repo_remove.go b/pkg/helm/pkg/cmd/repo_remove.go similarity index 81% rename from pkg/helm/cmd/helm/repo_remove.go rename to pkg/helm/pkg/cmd/repo_remove.go index 9bc50309..df855a9f 100644 --- a/pkg/helm/cmd/helm/repo_remove.go +++ b/pkg/helm/pkg/cmd/repo_remove.go @@ -14,20 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( + "errors" "fmt" "io" + "io/fs" "os" "path/filepath" - "github.com/pkg/errors" "github.com/spf13/cobra" - "github.com/werf/nelm/pkg/helm/cmd/helm/require" + "github.com/werf/nelm/pkg/helm/pkg/cmd/require" "github.com/werf/nelm/pkg/helm/pkg/helmpath" - "github.com/werf/nelm/pkg/helm/pkg/repo" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1" ) type repoRemoveOptions struct { @@ -44,10 +45,10 @@ func newRepoRemoveCmd(out io.Writer) *cobra.Command { Aliases: []string{"rm"}, Short: "remove one or more chart repositories", Args: require.MinimumNArgs(1), - ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return compListRepos(toComplete, args), cobra.ShellCompDirectiveNoFileComp }, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { o.repoFile = settings.RepositoryConfig o.repoCache = settings.RepositoryCache o.names = args @@ -65,7 +66,7 @@ func (o *repoRemoveOptions) run(out io.Writer) error { for _, name := range o.names { if !r.Remove(name) { - return errors.Errorf("no repo named %q found", name) + return fmt.Errorf("no repo named %q found", name) } if err := r.WriteFile(o.repoFile, 0600); err != nil { return err @@ -87,10 +88,10 @@ func removeRepoCache(root, name string) error { } idx = filepath.Join(root, helmpath.CacheIndexFile(name)) - if _, err := os.Stat(idx); os.IsNotExist(err) { + if _, err := os.Stat(idx); errors.Is(err, fs.ErrNotExist) { return nil } else if err != nil { - return errors.Wrapf(err, "can't remove index file %s", idx) + return fmt.Errorf("can't remove index file %s: %w", idx, err) } return os.Remove(idx) } diff --git a/pkg/helm/cmd/helm/repo_remove_test.go b/pkg/helm/pkg/cmd/repo_remove_test.go similarity index 94% rename from pkg/helm/cmd/helm/repo_remove_test.go rename to pkg/helm/pkg/cmd/repo_remove_test.go index 80fa6ced..c86bef9d 100644 --- a/pkg/helm/cmd/helm/repo_remove_test.go +++ b/pkg/helm/pkg/cmd/repo_remove_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "bytes" @@ -25,15 +25,15 @@ import ( "testing" "github.com/werf/nelm/pkg/helm/pkg/helmpath" - "github.com/werf/nelm/pkg/helm/pkg/repo" - "github.com/werf/nelm/pkg/helm/pkg/repo/repotest" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1/repotest" ) func TestRepoRemove(t *testing.T) { - ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") - if err != nil { - t.Fatal(err) - } + ts := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/testserver/*.*"), + ) defer ts.Stop() rootDir := t.TempDir() @@ -153,6 +153,7 @@ func createCacheFiles(rootDir string, repoName string) (cacheIndexFile string, c } func testCacheFiles(t *testing.T, cacheIndexFile string, cacheChartsFile string, repoName string) { + t.Helper() if _, err := os.Stat(cacheIndexFile); err == nil { t.Errorf("Error cache index file was not removed for repository %s", repoName) } @@ -162,10 +163,11 @@ func testCacheFiles(t *testing.T, cacheIndexFile string, cacheChartsFile string, } func TestRepoRemoveCompletion(t *testing.T) { - ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") - if err != nil { - t.Fatal(err) - } + ts := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/testserver/*.*"), + ) + defer ts.Stop() rootDir := t.TempDir() diff --git a/pkg/helm/cmd/helm/repo_test.go b/pkg/helm/pkg/cmd/repo_test.go similarity index 98% rename from pkg/helm/cmd/helm/repo_test.go rename to pkg/helm/pkg/cmd/repo_test.go index bc63d7b4..6b89a66c 100644 --- a/pkg/helm/cmd/helm/repo_test.go +++ b/pkg/helm/pkg/cmd/repo_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "testing" diff --git a/pkg/helm/cmd/helm/repo_update.go b/pkg/helm/pkg/cmd/repo_update.go similarity index 69% rename from pkg/helm/cmd/helm/repo_update.go rename to pkg/helm/pkg/cmd/repo_update.go index 51a93841..f5547fc2 100644 --- a/pkg/helm/cmd/helm/repo_update.go +++ b/pkg/helm/pkg/cmd/repo_update.go @@ -14,19 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( + "errors" "fmt" "io" + "slices" "sync" + "time" - "github.com/pkg/errors" "github.com/spf13/cobra" - "github.com/werf/nelm/pkg/helm/cmd/helm/require" + "github.com/werf/nelm/pkg/helm/pkg/cmd/require" "github.com/werf/nelm/pkg/helm/pkg/getter" - "github.com/werf/nelm/pkg/helm/pkg/repo" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1" ) const updateDesc = ` @@ -41,11 +43,11 @@ To update all the repositories, use 'helm repo update'. var errNoRepositories = errors.New("no repositories found. You must add one before updating") type repoUpdateOptions struct { - update func([]*repo.ChartRepository, io.Writer, bool) error - repoFile string - repoCache string - names []string - failOnRepoUpdateFail bool + update func([]*repo.ChartRepository, io.Writer) error + repoFile string + repoCache string + names []string + timeout time.Duration } func newRepoUpdateCmd(out io.Writer) *cobra.Command { @@ -57,10 +59,10 @@ func newRepoUpdateCmd(out io.Writer) *cobra.Command { Short: "update information of available charts locally from chart repositories", Long: updateDesc, Args: require.MinimumNArgs(0), - ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return compListRepos(toComplete, args), cobra.ShellCompDirectiveNoFileComp }, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { o.repoFile = settings.RepositoryConfig o.repoCache = settings.RepositoryCache o.names = args @@ -69,10 +71,7 @@ func newRepoUpdateCmd(out io.Writer) *cobra.Command { } f := cmd.Flags() - - // Adding this flag for Helm 3 as stop gap functionality for https://github.com/helm/helm/issues/10016. - // This should be deprecated in Helm 4 by update to the behaviour of `helm repo update` command. - f.BoolVar(&o.failOnRepoUpdateFail, "fail-on-repo-update-fail", false, "update fails if any of the repository updates fail") + f.DurationVar(&o.timeout, "timeout", getter.DefaultHTTPTimeout*time.Second, "time to wait for the index file download to complete") return cmd } @@ -83,7 +82,7 @@ func (o *repoUpdateOptions) run(out io.Writer) error { case isNotExist(err): return errNoRepositories case err != nil: - return errors.Wrapf(err, "failed loading file: %s", o.repoFile) + return fmt.Errorf("failed loading file: %s: %w", o.repoFile, err) case len(f.Repositories) == 0: return errNoRepositories } @@ -100,7 +99,7 @@ func (o *repoUpdateOptions) run(out io.Writer) error { for _, cfg := range f.Repositories { if updateAllRepos || isRepoRequested(cfg.Name, o.names) { - r, err := repo.NewChartRepository(cfg, getter.All(settings)) + r, err := repo.NewChartRepository(cfg, getter.All(settings, getter.WithTimeout(o.timeout))) if err != nil { return err } @@ -111,29 +110,44 @@ func (o *repoUpdateOptions) run(out io.Writer) error { } } - return o.update(repos, out, o.failOnRepoUpdateFail) + return o.update(repos, out) } -func updateCharts(repos []*repo.ChartRepository, out io.Writer, failOnRepoUpdateFail bool) error { +func updateCharts(repos []*repo.ChartRepository, out io.Writer) error { fmt.Fprintln(out, "Hang tight while we grab the latest from your chart repositories...") var wg sync.WaitGroup - var repoFailList []string + failRepoURLChan := make(chan string, len(repos)) + + writeMutex := sync.Mutex{} for _, re := range repos { wg.Add(1) go func(re *repo.ChartRepository) { defer wg.Done() if _, err := re.DownloadIndexFile(); err != nil { + writeMutex.Lock() + defer writeMutex.Unlock() fmt.Fprintf(out, "...Unable to get an update from the %q chart repository (%s):\n\t%s\n", re.Config.Name, re.Config.URL, err) - repoFailList = append(repoFailList, re.Config.URL) + failRepoURLChan <- re.Config.URL } else { + writeMutex.Lock() + defer writeMutex.Unlock() fmt.Fprintf(out, "...Successfully got an update from the %q chart repository\n", re.Config.Name) } }(re) } - wg.Wait() - if len(repoFailList) > 0 && failOnRepoUpdateFail { - return fmt.Errorf("Failed to update the following repositories: %s", + go func() { + wg.Wait() + close(failRepoURLChan) + }() + + var repoFailList []string + for url := range failRepoURLChan { + repoFailList = append(repoFailList, url) + } + + if len(repoFailList) > 0 { + return fmt.Errorf("failed to update the following repositories: %s", repoFailList) } @@ -151,17 +165,12 @@ func checkRequestedRepos(requestedRepos []string, validRepos []*repo.Entry) erro } } if !found { - return errors.Errorf("no repositories found matching '%s'. Nothing will be updated", requestedRepo) + return fmt.Errorf("no repositories found matching '%s'. Nothing will be updated", requestedRepo) } } return nil } func isRepoRequested(repoName string, requestedRepos []string) bool { - for _, requestedRepo := range requestedRepos { - if repoName == requestedRepo { - return true - } - } - return false + return slices.Contains(requestedRepos, repoName) } diff --git a/pkg/helm/cmd/helm/repo_update_test.go b/pkg/helm/pkg/cmd/repo_update_test.go similarity index 74% rename from pkg/helm/cmd/helm/repo_update_test.go rename to pkg/helm/pkg/cmd/repo_update_test.go index 143e5d90..411e46fa 100644 --- a/pkg/helm/cmd/helm/repo_update_test.go +++ b/pkg/helm/pkg/cmd/repo_update_test.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "bytes" @@ -24,17 +24,17 @@ import ( "strings" "testing" - "github.com/werf/nelm/pkg/helm/internal/test/ensure" + "github.com/werf/nelm/pkg/helm/intern/test/ensure" "github.com/werf/nelm/pkg/helm/pkg/getter" - "github.com/werf/nelm/pkg/helm/pkg/repo" - "github.com/werf/nelm/pkg/helm/pkg/repo/repotest" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1/repotest" ) func TestUpdateCmd(t *testing.T) { var out bytes.Buffer // Instead of using the HTTP updater, we provide our own for this test. // The TestUpdateCharts test verifies the HTTP behavior independently. - updater := func(repos []*repo.ChartRepository, out io.Writer, failOnRepoUpdateFail bool) error { + updater := func(repos []*repo.ChartRepository, out io.Writer) error { for _, re := range repos { fmt.Fprintln(out, re.Config.Name) } @@ -59,7 +59,7 @@ func TestUpdateCmdMultiple(t *testing.T) { var out bytes.Buffer // Instead of using the HTTP updater, we provide our own for this test. // The TestUpdateCharts test verifies the HTTP behavior independently. - updater := func(repos []*repo.ChartRepository, out io.Writer, failOnRepoUpdateFail bool) error { + updater := func(repos []*repo.ChartRepository, out io.Writer) error { for _, re := range repos { fmt.Fprintln(out, re.Config.Name) } @@ -85,7 +85,7 @@ func TestUpdateCmdInvalid(t *testing.T) { var out bytes.Buffer // Instead of using the HTTP updater, we provide our own for this test. // The TestUpdateCharts test verifies the HTTP behavior independently. - updater := func(repos []*repo.ChartRepository, out io.Writer, failOnRepoUpdateFail bool) error { + updater := func(repos []*repo.ChartRepository, out io.Writer) error { for _, re := range repos { fmt.Fprintln(out, re.Config.Name) } @@ -106,10 +106,11 @@ func TestUpdateCustomCacheCmd(t *testing.T) { cachePath := filepath.Join(rootDir, "updcustomcache") os.Mkdir(cachePath, os.ModePerm) - ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") - if err != nil { - t.Fatal(err) - } + ts := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/testserver/*.*"), + ) + defer ts.Stop() o := &repoUpdateOptions{ @@ -130,10 +131,9 @@ func TestUpdateCharts(t *testing.T) { defer resetEnv()() ensure.HelmHome(t) - ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") - if err != nil { - t.Fatal(err) - } + ts := repotest.NewTempServer(t, + repotest.WithChartSourceGlob("testdata/testserver/*.*"), + ) defer ts.Stop() r, err := repo.NewChartRepository(&repo.Entry{ @@ -145,7 +145,7 @@ func TestUpdateCharts(t *testing.T) { } b := bytes.NewBuffer(nil) - updateCharts([]*repo.ChartRepository{r}, b, false) + updateCharts([]*repo.ChartRepository{r}, b) got := b.String() if strings.Contains(got, "Unable to get an update") { @@ -161,51 +161,25 @@ func TestRepoUpdateFileCompletion(t *testing.T) { checkFileCompletion(t, "repo update repo1", false) } -func TestUpdateChartsFail(t *testing.T) { +func TestUpdateChartsFailWithError(t *testing.T) { defer resetEnv()() ensure.HelmHome(t) - ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") - if err != nil { - t.Fatal(err) - } + ts := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/testserver/*.*"), + ) defer ts.Stop() var invalidURL = ts.URL() + "55" - r, err := repo.NewChartRepository(&repo.Entry{ + r1, err := repo.NewChartRepository(&repo.Entry{ Name: "charts", URL: invalidURL, }, getter.All(settings)) if err != nil { t.Error(err) } - - b := bytes.NewBuffer(nil) - if err := updateCharts([]*repo.ChartRepository{r}, b, false); err != nil { - t.Error("Repo update should not return error if update of repository fails") - } - - got := b.String() - if !strings.Contains(got, "Unable to get an update") { - t.Errorf("Repo should have failed update but instead got: %q", got) - } - if !strings.Contains(got, "Update Complete.") { - t.Error("Update was not successful") - } -} - -func TestUpdateChartsFailWithError(t *testing.T) { - defer resetEnv()() - ensure.HelmHome(t) - - ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") - if err != nil { - t.Fatal(err) - } - defer ts.Stop() - - var invalidURL = ts.URL() + "55" - r, err := repo.NewChartRepository(&repo.Entry{ + r2, err := repo.NewChartRepository(&repo.Entry{ Name: "charts", URL: invalidURL, }, getter.All(settings)) @@ -214,12 +188,12 @@ func TestUpdateChartsFailWithError(t *testing.T) { } b := bytes.NewBuffer(nil) - err = updateCharts([]*repo.ChartRepository{r}, b, true) + err = updateCharts([]*repo.ChartRepository{r1, r2}, b) if err == nil { t.Error("Repo update should return error because update of repository fails and 'fail-on-repo-update-fail' flag set") return } - var expectedErr = "Failed to update the following repositories" + var expectedErr = "failed to update the following repositories" var receivedErr = err.Error() if !strings.Contains(receivedErr, expectedErr) { t.Errorf("Expected error (%s) but got (%s) instead", expectedErr, receivedErr) diff --git a/pkg/helm/cmd/helm/require/args.go b/pkg/helm/pkg/cmd/require/args.go similarity index 94% rename from pkg/helm/cmd/helm/require/args.go rename to pkg/helm/pkg/cmd/require/args.go index cfa8a016..f5e0888f 100644 --- a/pkg/helm/cmd/helm/require/args.go +++ b/pkg/helm/pkg/cmd/require/args.go @@ -16,14 +16,15 @@ limitations under the License. package require import ( - "github.com/pkg/errors" + "fmt" + "github.com/spf13/cobra" ) // NoArgs returns an error if any args are included. func NoArgs(cmd *cobra.Command, args []string) error { if len(args) > 0 { - return errors.Errorf( + return fmt.Errorf( "%q accepts no arguments\n\nUsage: %s", cmd.CommandPath(), cmd.UseLine(), @@ -36,7 +37,7 @@ func NoArgs(cmd *cobra.Command, args []string) error { func ExactArgs(n int) cobra.PositionalArgs { return func(cmd *cobra.Command, args []string) error { if len(args) != n { - return errors.Errorf( + return fmt.Errorf( "%q requires %d %s\n\nUsage: %s", cmd.CommandPath(), n, @@ -52,7 +53,7 @@ func ExactArgs(n int) cobra.PositionalArgs { func MaximumNArgs(n int) cobra.PositionalArgs { return func(cmd *cobra.Command, args []string) error { if len(args) > n { - return errors.Errorf( + return fmt.Errorf( "%q accepts at most %d %s\n\nUsage: %s", cmd.CommandPath(), n, @@ -68,7 +69,7 @@ func MaximumNArgs(n int) cobra.PositionalArgs { func MinimumNArgs(n int) cobra.PositionalArgs { return func(cmd *cobra.Command, args []string) error { if len(args) < n { - return errors.Errorf( + return fmt.Errorf( "%q requires at least %d %s\n\nUsage: %s", cmd.CommandPath(), n, diff --git a/pkg/helm/cmd/helm/require/args_test.go b/pkg/helm/pkg/cmd/require/args_test.go similarity index 93% rename from pkg/helm/cmd/helm/require/args_test.go rename to pkg/helm/pkg/cmd/require/args_test.go index 5a84a42d..3dbd4df7 100644 --- a/pkg/helm/cmd/helm/require/args_test.go +++ b/pkg/helm/pkg/cmd/require/args_test.go @@ -63,6 +63,7 @@ type testCase struct { } func runTestCases(t *testing.T, testCases []testCase) { + t.Helper() for i, tc := range testCases { t.Run(fmt.Sprint(i), func(t *testing.T) { cmd := &cobra.Command{ @@ -70,8 +71,13 @@ func runTestCases(t *testing.T, testCases []testCase) { Run: func(*cobra.Command, []string) {}, Args: tc.validateFunc, } - cmd.SetArgs(tc.args) - cmd.SetOutput(io.Discard) + if tc.args != nil { + cmd.SetArgs(tc.args) + } else { + cmd.SetArgs([]string{}) + } + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) err := cmd.Execute() if tc.wantError == "" { diff --git a/pkg/helm/cmd/helm/root.go b/pkg/helm/pkg/cmd/root.go similarity index 55% rename from pkg/helm/cmd/helm/root.go rename to pkg/helm/pkg/cmd/root.go index c36bae3f..52e58b83 100644 --- a/pkg/helm/cmd/helm/root.go +++ b/pkg/helm/pkg/cmd/root.go @@ -14,24 +14,35 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm // import "helm.sh/helm/v3/cmd/helm" +package cmd // import "github.com/werf/nelm/pkg/helm/pkg/cmd" import ( "context" "fmt" "io" "log" + "log/slog" + "net/http" "os" "strings" + "github.com/fatih/color" "github.com/spf13/cobra" + "sigs.k8s.io/yaml" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/tools/clientcmd" + "github.com/werf/nelm/pkg/helm/intern/logging" + "github.com/werf/nelm/pkg/helm/intern/tlsutil" "github.com/werf/nelm/pkg/helm/pkg/action" + "github.com/werf/nelm/pkg/helm/pkg/cli" + kubefake "github.com/werf/nelm/pkg/helm/pkg/kube/fake" "github.com/werf/nelm/pkg/helm/pkg/registry" - "github.com/werf/nelm/pkg/helm/pkg/repo" + ri "github.com/werf/nelm/pkg/helm/pkg/release" + release "github.com/werf/nelm/pkg/helm/pkg/release/v1" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1" + "github.com/werf/nelm/pkg/helm/pkg/storage/driver" ) var globalUsage = `The Kubernetes package manager @@ -71,6 +82,8 @@ Environment variables: | $HELM_KUBETLS_SERVER_NAME | set the server name used to validate the Kubernetes API server certificate | | $HELM_BURST_LIMIT | set the default burst limit in the case the server contains many CRDs (default 100, -1 to disable) | | $HELM_QPS | set the Queries Per Second in cases where a high number of calls exceed the option for higher burst values | +| $HELM_COLOR | set color output mode. Allowed values: never, always, auto (default: never) | +| $NO_COLOR | set to any non-empty value to disable all colored output (overrides $HELM_COLOR) | Helm stores cache, configuration, and data based on the following configuration order: @@ -87,20 +100,112 @@ By default, the default directories depend on the Operating System. The defaults | Windows | %TEMP%\helm | %APPDATA%\helm | %APPDATA%\helm | ` -func newRootCmd(actionConfig *action.Configuration, out io.Writer, args []string) (*cobra.Command, error) { +var settings = cli.New() + +var Settings = settings + +func NewRootCmd(out io.Writer, args []string, logSetup func(bool)) (*cobra.Command, error) { + actionConfig := action.NewConfiguration() + cmd, err := newRootCmdWithConfig(actionConfig, out, args, logSetup) + if err != nil { + return nil, err + } + cobra.OnInitialize(func() { + helmDriver := os.Getenv("HELM_DRIVER") + if err := actionConfig.Init(settings.RESTClientGetter(), settings.Namespace(), helmDriver); err != nil { + log.Fatal(err) + } + if helmDriver == "memory" { + loadReleasesInMemory(actionConfig) + } + actionConfig.SetHookOutputFunc(hookOutputWriter) + }) + return cmd, nil +} + +// SetupLogging sets up Helm logging used by the Helm client. +// This function is passed to the NewRootCmd function to enable logging. Any other +// application that uses the NewRootCmd function to setup all the Helm commands may +// use this function to setup logging or their own. Using a custom logging setup function +// enables applications using Helm commands to integrate with their existing logging +// system. +// The debug argument is the value if Helm is set for debugging (i.e. --debug flag) +func SetupLogging(debug bool) { + logger := logging.NewLogger(func() bool { return debug }) + slog.SetDefault(logger) +} + +// configureColorOutput configures the color output based on the ColorMode setting +func configureColorOutput(settings *cli.EnvSettings) { + switch settings.ColorMode { + case "never": + color.NoColor = true + case "always": + color.NoColor = false + case "auto": + // Let fatih/color handle automatic detection + // It will check if output is a terminal and NO_COLOR env var + // We don't need to do anything here + } +} + +func newRootCmdWithConfig(actionConfig *action.Configuration, out io.Writer, args []string, logSetup func(bool)) (*cobra.Command, error) { cmd := &cobra.Command{ - Use: "helm", - Short: "The Helm package manager for Kubernetes.", - Long: globalUsage, - SilenceUsage: true, + Use: "helm", + Short: "The Helm package manager for Kubernetes.", + Long: globalUsage, + SilenceUsage: true, + PersistentPreRun: func(_ *cobra.Command, _ []string) {}, + PersistentPostRun: func(_ *cobra.Command, _ []string) {}, } + flags := cmd.PersistentFlags() settings.AddFlags(flags) addKlogFlags(flags) + // We can safely ignore any errors that flags.Parse encounters since + // those errors will be caught later during the call to cmd.Execution. + // This call is required to gather configuration information prior to + // execution. + flags.ParseErrorsAllowlist.UnknownFlags = true + flags.Parse(args) + + logSetup(settings.Debug) + + // newRootCmdWithConfig is only called from NewRootCmd. NewRootCmd sets up + // NewConfiguration without a custom logger. So, the slog default is used. logSetup + // can change the default logger to the one in the logger package. This happens for + // the Helm client. This means the actionConfig logger is different from the slog + // default logger. If they are different we sync the actionConfig logger to the slog + // current default one. + if actionConfig.Logger() != slog.Default() { + actionConfig.SetLogger(slog.Default().Handler()) + } + + // Validate color mode setting + switch settings.ColorMode { + case "never", "auto", "always": + // Valid color mode + default: + return nil, fmt.Errorf("invalid color mode %q: must be one of: never, auto, always", settings.ColorMode) + } + + // Configure color output based on ColorMode setting + configureColorOutput(settings) + + // Setup shell completion for the color flag + _ = cmd.RegisterFlagCompletionFunc("color", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{"never", "auto", "always"}, cobra.ShellCompDirectiveNoFileComp + }) + + // Setup shell completion for the colour flag + _ = cmd.RegisterFlagCompletionFunc("colour", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{"never", "auto", "always"}, cobra.ShellCompDirectiveNoFileComp + }) + // Setup shell completion for the namespace flag - err := cmd.RegisterFlagCompletionFunc("namespace", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + err := cmd.RegisterFlagCompletionFunc("namespace", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { if client, err := actionConfig.KubernetesClientSet(); err == nil { // Choose a long enough timeout that the user notices something is not working // but short enough that the user is not made to wait very long @@ -117,12 +222,13 @@ func newRootCmd(actionConfig *action.Configuration, out io.Writer, args []string } return nil, cobra.ShellCompDirectiveDefault }) + if err != nil { log.Fatal(err) } // Setup shell completion for the kube-context flag - err = cmd.RegisterFlagCompletionFunc("kube-context", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + err = cmd.RegisterFlagCompletionFunc("kube-context", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { cobra.CompDebugln("About to get the different kube-contexts", settings.Debug) loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() @@ -140,53 +246,91 @@ func newRootCmd(actionConfig *action.Configuration, out io.Writer, args []string } return nil, cobra.ShellCompDirectiveNoFileComp }) + if err != nil { log.Fatal(err) } - // We can safely ignore any errors that flags.Parse encounters since - // those errors will be caught later during the call to cmd.Execution. - // This call is required to gather configuration information prior to - // execution. - flags.ParseErrorsWhitelist.UnknownFlags = true - // flags.Parse(args) - - registryClient, err := newDefaultRegistryClient(false) + registryClient, err := newDefaultRegistryClient(false, "", "") if err != nil { return nil, err } actionConfig.RegistryClient = registryClient - // Add subcommands cmd.AddCommand( + newCreateCmd(out), newDependencyCmd(actionConfig, out), newPullCmd(actionConfig, out), - newPackageCmd(actionConfig, out), + newShowCmd(actionConfig, out), + newPackageCmd(out), newRepoCmd(out), newSearchCmd(out), newVerifyCmd(out), newHistoryCmd(actionConfig, out), + newStatusCmd(actionConfig, out), newCompletionCmd(out), + newEnvCmd(out), + newVersionCmd(out), + ) + cmd.AddCommand( newRegistryCmd(actionConfig, out), newPushCmd(actionConfig, out), ) - // Find and add plugins - // loadPlugins(cmd, out) - - // Check permissions on critical files - checkPerms() - // Check for expired repositories checkForExpiredRepos(settings.RepositoryConfig) return cmd, nil } +// This function loads releases into the memory storage if the +// environment variable is properly set. +func loadReleasesInMemory(actionConfig *action.Configuration) { + filePaths := strings.Split(os.Getenv("HELM_MEMORY_DRIVER_DATA"), ":") + if len(filePaths) == 0 { + return + } + + store := actionConfig.Releases + mem, ok := store.Driver.(*driver.Memory) + if !ok { + // For an unexpected reason we are not dealing with the memory storage driver. + return + } + + actionConfig.KubeClient = &kubefake.PrintingKubeClient{Out: io.Discard} + + for _, path := range filePaths { + b, err := os.ReadFile(path) + if err != nil { + log.Fatal("Unable to read memory driver data", err) + } + + releases := []*release.Release{} + if err := yaml.Unmarshal(b, &releases); err != nil { + log.Fatal("Unable to unmarshal memory driver data: ", err) + } + + for _, rel := range releases { + if err := store.Create(rel); err != nil { + log.Fatal(err) + } + } + } + // Must reset namespace to the proper one + mem.SetNamespace(settings.Namespace()) +} + +// hookOutputWriter provides the writer for writing hook logs. +func hookOutputWriter(_, _, _ string) io.Writer { + return log.Writer() +} + func checkForExpiredRepos(repofile string) { + expiredRepos := []struct { name string old string @@ -205,7 +349,7 @@ func checkForExpiredRepos(repofile string) { } // parse repo file. - // Ignore the error because it is okay for a repo file to be unparseable at this + // Ignore the error because it is okay for a repo file to be unparsable at this // stage. Later checks will trap the error and respond accordingly. repoFile, err := repo.LoadFile(repofile) if err != nil { @@ -230,29 +374,33 @@ func checkForExpiredRepos(repofile string) { ) } } + } -func newRegistryClient(certFile, keyFile, caFile string, insecureSkipTLSverify, plainHTTP bool) (*registry.Client, error) { - if certFile != "" && keyFile != "" || caFile != "" || insecureSkipTLSverify { - registryClient, err := newRegistryClientWithTLS(certFile, keyFile, caFile, insecureSkipTLSverify) +func newRegistryClient( + certFile, keyFile, caFile string, insecureSkipTLSVerify, plainHTTP bool, username, password string, +) (*registry.Client, error) { + if certFile != "" && keyFile != "" || caFile != "" || insecureSkipTLSVerify { + registryClient, err := newRegistryClientWithTLS(certFile, keyFile, caFile, insecureSkipTLSVerify, username, password) if err != nil { return nil, err } return registryClient, nil } - registryClient, err := newDefaultRegistryClient(plainHTTP) + registryClient, err := newDefaultRegistryClient(plainHTTP, username, password) if err != nil { return nil, err } return registryClient, nil } -func newDefaultRegistryClient(plainHTTP bool) (*registry.Client, error) { +func newDefaultRegistryClient(plainHTTP bool, username, password string) (*registry.Client, error) { opts := []registry.ClientOption{ registry.ClientOptDebug(settings.Debug), registry.ClientOptEnableCache(true), registry.ClientOptWriter(os.Stderr), registry.ClientOptCredentialsFile(settings.RegistryConfig), + registry.ClientOptBasicAuth(username, password), } if plainHTTP { opts = append(opts, registry.ClientOptPlainHTTP()) @@ -266,13 +414,68 @@ func newDefaultRegistryClient(plainHTTP bool) (*registry.Client, error) { return registryClient, nil } -func newRegistryClientWithTLS(certFile, keyFile, caFile string, insecureSkipTLSverify bool) (*registry.Client, error) { +func newRegistryClientWithTLS( + certFile, keyFile, caFile string, insecureSkipTLSVerify bool, username, password string, +) (*registry.Client, error) { + tlsConf, err := tlsutil.NewTLSConfig( + tlsutil.WithInsecureSkipVerify(insecureSkipTLSVerify), + tlsutil.WithCertKeyPairFiles(certFile, keyFile), + tlsutil.WithCAFile(caFile), + ) + + if err != nil { + return nil, fmt.Errorf("can't create TLS config for client: %w", err) + } + // Create a new registry client - registryClient, err := registry.NewRegistryClientWithTLS(os.Stderr, certFile, keyFile, caFile, insecureSkipTLSverify, - settings.RegistryConfig, settings.Debug, + registryClient, err := registry.NewClient( + registry.ClientOptDebug(settings.Debug), + registry.ClientOptEnableCache(true), + registry.ClientOptWriter(os.Stderr), + registry.ClientOptCredentialsFile(settings.RegistryConfig), + registry.ClientOptHTTPClient(&http.Client{ + Transport: &http.Transport{ + TLSClientConfig: tlsConf, + Proxy: http.ProxyFromEnvironment, + }, + }), + registry.ClientOptBasicAuth(username, password), ) if err != nil { return nil, err } return registryClient, nil } + +type CommandError struct { + error + ExitCode int +} + +// releaserToV1Release is a helper function to convert a v1 release passed by interface +// into the type object. +func releaserToV1Release(rel ri.Releaser) (*release.Release, error) { + switch r := rel.(type) { + case release.Release: + return &r, nil + case *release.Release: + return r, nil + case nil: + return nil, nil + default: + return nil, fmt.Errorf("unsupported release type: %T", rel) + } +} + +func releaseListToV1List(ls []ri.Releaser) ([]*release.Release, error) { + rls := make([]*release.Release, 0, len(ls)) + for _, val := range ls { + rel, err := releaserToV1Release(val) + if err != nil { + return nil, err + } + rls = append(rls, rel) + } + + return rls, nil +} diff --git a/pkg/helm/cmd/helm/root_test.go b/pkg/helm/pkg/cmd/root_test.go similarity index 85% rename from pkg/helm/cmd/helm/root_test.go rename to pkg/helm/pkg/cmd/root_test.go index 1bc13b35..b4e89ae2 100644 --- a/pkg/helm/cmd/helm/root_test.go +++ b/pkg/helm/pkg/cmd/root_test.go @@ -14,14 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( + "bytes" + "log/slog" "os" "path/filepath" "testing" - "github.com/werf/nelm/pkg/helm/internal/test/ensure" + "github.com/werf/nelm/pkg/helm/intern/test/ensure" + "github.com/werf/nelm/pkg/helm/pkg/action" "github.com/werf/nelm/pkg/helm/pkg/helmpath" "github.com/werf/nelm/pkg/helm/pkg/helmpath/xdg" ) @@ -80,7 +83,7 @@ func TestRootCmd(t *testing.T) { ensure.HelmHome(t) for k, v := range tt.envvars { - os.Setenv(k, v) + t.Setenv(k, v) } if _, _, err := executeActionCommand(tt.args); err != nil { @@ -129,3 +132,20 @@ func TestUnknownSubCmd(t *testing.T) { // func TestRootFileCompletion(t *testing.T) { // checkFileCompletion(t, "", false) // } + +func TestRootCmdLogger(t *testing.T) { + args := []string{} + buf := new(bytes.Buffer) + actionConfig := action.NewConfiguration() + _, err := newRootCmdWithConfig(actionConfig, buf, args, SetupLogging) + if err != nil { + t.Errorf("expected no error, got: '%v'", err) + } + + l1 := actionConfig.Logger() + l2 := slog.Default() + + if l1.Handler() != l2.Handler() { + t.Error("expected actionConfig logger to be the slog default logger") + } +} diff --git a/pkg/helm/cmd/helm/search.go b/pkg/helm/pkg/cmd/search.go similarity index 98% rename from pkg/helm/cmd/helm/search.go rename to pkg/helm/pkg/cmd/search.go index 9a71f4cd..4d110286 100644 --- a/pkg/helm/cmd/helm/search.go +++ b/pkg/helm/pkg/cmd/search.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "io" diff --git a/pkg/helm/cmd/helm/search/search.go b/pkg/helm/pkg/cmd/search/search.go similarity index 99% rename from pkg/helm/cmd/helm/search/search.go rename to pkg/helm/pkg/cmd/search/search.go index 3fc8bc9c..c89a220f 100644 --- a/pkg/helm/cmd/helm/search/search.go +++ b/pkg/helm/pkg/cmd/search/search.go @@ -31,7 +31,7 @@ import ( "github.com/Masterminds/semver/v3" - "github.com/werf/nelm/pkg/helm/pkg/repo" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1" ) // Result is a search result. diff --git a/pkg/helm/cmd/helm/search/search_test.go b/pkg/helm/pkg/cmd/search/search_test.go similarity index 97% rename from pkg/helm/cmd/helm/search/search_test.go rename to pkg/helm/pkg/cmd/search/search_test.go index 3d6ca30c..1c44b9e0 100644 --- a/pkg/helm/cmd/helm/search/search_test.go +++ b/pkg/helm/pkg/cmd/search/search_test.go @@ -20,8 +20,8 @@ import ( "strings" "testing" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/repo" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1" ) func TestSortScore(t *testing.T) { @@ -39,13 +39,13 @@ func TestSortScore(t *testing.T) { SortScore(in) // Test Score - for i := 0; i < len(expectScore); i++ { + for i := range expectScore { if expectScore[i] != in[i].Score { t.Errorf("Sort error on index %d: expected %d, got %d", i, expectScore[i], in[i].Score) } } // Test Name - for i := 0; i < len(expect); i++ { + for i := range expect { if expect[i] != in[i].Name { t.Errorf("Sort error: expected %s, got %s", expect[i], in[i].Name) } diff --git a/pkg/helm/cmd/helm/search_hub.go b/pkg/helm/pkg/cmd/search_hub.go similarity index 93% rename from pkg/helm/cmd/helm/search_hub.go rename to pkg/helm/pkg/cmd/search_hub.go index 1e6fa1c6..6f327743 100644 --- a/pkg/helm/cmd/helm/search_hub.go +++ b/pkg/helm/pkg/cmd/search_hub.go @@ -14,18 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "fmt" "io" + "log/slog" "strings" "github.com/gosuri/uitable" - "github.com/pkg/errors" "github.com/spf13/cobra" - "github.com/werf/nelm/pkg/helm/internal/monocular" + "github.com/werf/nelm/pkg/helm/intern/monocular" "github.com/werf/nelm/pkg/helm/pkg/cli/output" ) @@ -64,7 +64,7 @@ func newSearchHubCmd(out io.Writer) *cobra.Command { Use: "hub [KEYWORD]", Short: "search for charts in the Artifact Hub or your own hub instance", Long: searchHubDesc, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { return o.run(out, args) }, } @@ -83,13 +83,13 @@ func newSearchHubCmd(out io.Writer) *cobra.Command { func (o *searchHubOptions) run(out io.Writer, args []string) error { c, err := monocular.New(o.searchEndpoint) if err != nil { - return errors.Wrap(err, fmt.Sprintf("unable to create connection to %q", o.searchEndpoint)) + return fmt.Errorf("unable to create connection to %q: %w", o.searchEndpoint, err) } q := strings.Join(args, " ") results, err := c.Search(q) if err != nil { - debug("%s", err) + slog.Debug("search failed", slog.Any("error", err)) return fmt.Errorf("unable to perform search against %q", o.searchEndpoint) } @@ -190,9 +190,10 @@ func (h *hubSearchWriter) encodeByFormat(out io.Writer, format output.Format) er return output.EncodeJSON(out, chartList) case output.YAML: return output.EncodeYAML(out, chartList) + default: + // Because this is a non-exported function and only called internally by + // WriteJSON and WriteYAML, we shouldn't get invalid types + return nil } - // Because this is a non-exported function and only called internally by - // WriteJSON and WriteYAML, we shouldn't get invalid types - return nil } diff --git a/pkg/helm/cmd/helm/search_hub_test.go b/pkg/helm/pkg/cmd/search_hub_test.go similarity index 99% rename from pkg/helm/cmd/helm/search_hub_test.go rename to pkg/helm/pkg/cmd/search_hub_test.go index 5c8951b0..8e056f77 100644 --- a/pkg/helm/cmd/helm/search_hub_test.go +++ b/pkg/helm/pkg/cmd/search_hub_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "fmt" @@ -27,7 +27,7 @@ func TestSearchHubCmd(t *testing.T) { // Setup a mock search service var searchResult = `{"data":[{"id":"stable/phpmyadmin","type":"chart","attributes":{"name":"phpmyadmin","repo":{"name":"stable","url":"https://charts.helm.sh/stable"},"description":"phpMyAdmin is an mysql administration frontend","home":"https://www.phpmyadmin.net/","keywords":["mariadb","mysql","phpmyadmin"],"maintainers":[{"name":"Bitnami","email":"containers@bitnami.com"}],"sources":["https://github.com/bitnami/bitnami-docker-phpmyadmin"],"icon":""},"links":{"self":"/v1/charts/stable/phpmyadmin"},"relationships":{"latestChartVersion":{"data":{"version":"3.0.0","app_version":"4.9.0-1","created":"2019-08-08T17:57:31.38Z","digest":"119c499251bffd4b06ff0cd5ac98c2ce32231f84899fb4825be6c2d90971c742","urls":["https://charts.helm.sh/stable/phpmyadmin-3.0.0.tgz"],"readme":"/v1/assets/stable/phpmyadmin/versions/3.0.0/README.md","values":"/v1/assets/stable/phpmyadmin/versions/3.0.0/values.yaml"},"links":{"self":"/v1/charts/stable/phpmyadmin/versions/3.0.0"}}}},{"id":"bitnami/phpmyadmin","type":"chart","attributes":{"name":"phpmyadmin","repo":{"name":"bitnami","url":"https://charts.bitnami.com"},"description":"phpMyAdmin is an mysql administration frontend","home":"https://www.phpmyadmin.net/","keywords":["mariadb","mysql","phpmyadmin"],"maintainers":[{"name":"Bitnami","email":"containers@bitnami.com"}],"sources":["https://github.com/bitnami/bitnami-docker-phpmyadmin"],"icon":""},"links":{"self":"/v1/charts/bitnami/phpmyadmin"},"relationships":{"latestChartVersion":{"data":{"version":"3.0.0","app_version":"4.9.0-1","created":"2019-08-08T18:34:13.341Z","digest":"66d77cf6d8c2b52c488d0a294cd4996bd5bad8dc41d3829c394498fb401c008a","urls":["https://charts.bitnami.com/bitnami/phpmyadmin-3.0.0.tgz"],"readme":"/v1/assets/bitnami/phpmyadmin/versions/3.0.0/README.md","values":"/v1/assets/bitnami/phpmyadmin/versions/3.0.0/values.yaml"},"links":{"self":"/v1/charts/bitnami/phpmyadmin/versions/3.0.0"}}}}]}` - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { fmt.Fprintln(w, searchResult) })) defer ts.Close() @@ -57,7 +57,7 @@ func TestSearchHubListRepoCmd(t *testing.T) { // Setup a mock search service var searchResult = `{"data":[{"id":"stable/phpmyadmin","type":"chart","attributes":{"name":"phpmyadmin","repo":{"name":"stable","url":"https://charts.helm.sh/stable"},"description":"phpMyAdmin is an mysql administration frontend","home":"https://www.phpmyadmin.net/","keywords":["mariadb","mysql","phpmyadmin"],"maintainers":[{"name":"Bitnami","email":"containers@bitnami.com"}],"sources":["https://github.com/bitnami/bitnami-docker-phpmyadmin"],"icon":""},"links":{"self":"/v1/charts/stable/phpmyadmin"},"relationships":{"latestChartVersion":{"data":{"version":"3.0.0","app_version":"4.9.0-1","created":"2019-08-08T17:57:31.38Z","digest":"119c499251bffd4b06ff0cd5ac98c2ce32231f84899fb4825be6c2d90971c742","urls":["https://charts.helm.sh/stable/phpmyadmin-3.0.0.tgz"],"readme":"/v1/assets/stable/phpmyadmin/versions/3.0.0/README.md","values":"/v1/assets/stable/phpmyadmin/versions/3.0.0/values.yaml"},"links":{"self":"/v1/charts/stable/phpmyadmin/versions/3.0.0"}}}},{"id":"bitnami/phpmyadmin","type":"chart","attributes":{"name":"phpmyadmin","repo":{"name":"bitnami","url":"https://charts.bitnami.com"},"description":"phpMyAdmin is an mysql administration frontend","home":"https://www.phpmyadmin.net/","keywords":["mariadb","mysql","phpmyadmin"],"maintainers":[{"name":"Bitnami","email":"containers@bitnami.com"}],"sources":["https://github.com/bitnami/bitnami-docker-phpmyadmin"],"icon":""},"links":{"self":"/v1/charts/bitnami/phpmyadmin"},"relationships":{"latestChartVersion":{"data":{"version":"3.0.0","app_version":"4.9.0-1","created":"2019-08-08T18:34:13.341Z","digest":"66d77cf6d8c2b52c488d0a294cd4996bd5bad8dc41d3829c394498fb401c008a","urls":["https://charts.bitnami.com/bitnami/phpmyadmin-3.0.0.tgz"],"readme":"/v1/assets/bitnami/phpmyadmin/versions/3.0.0/README.md","values":"/v1/assets/bitnami/phpmyadmin/versions/3.0.0/values.yaml"},"links":{"self":"/v1/charts/bitnami/phpmyadmin/versions/3.0.0"}}}}]}` - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { fmt.Fprintln(w, searchResult) })) defer ts.Close() @@ -155,7 +155,7 @@ func TestSearchHubCmd_FailOnNoResponseTests(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Setup a mock search service - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { fmt.Fprintln(w, tt.response) })) defer ts.Close() diff --git a/pkg/helm/cmd/helm/search_repo.go b/pkg/helm/pkg/cmd/search_repo.go similarity index 94% rename from pkg/helm/cmd/helm/search_repo.go rename to pkg/helm/pkg/cmd/search_repo.go index aee4f50e..b386757f 100644 --- a/pkg/helm/cmd/helm/search_repo.go +++ b/pkg/helm/pkg/cmd/search_repo.go @@ -14,26 +14,27 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "bufio" "bytes" + "errors" "fmt" "io" + "log/slog" "os" "path/filepath" "strings" "github.com/Masterminds/semver/v3" "github.com/gosuri/uitable" - "github.com/pkg/errors" "github.com/spf13/cobra" - "github.com/werf/nelm/pkg/helm/cmd/helm/search" "github.com/werf/nelm/pkg/helm/pkg/cli/output" + "github.com/werf/nelm/pkg/helm/pkg/cmd/search" "github.com/werf/nelm/pkg/helm/pkg/helmpath" - "github.com/werf/nelm/pkg/helm/pkg/repo" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1" ) const searchRepoDesc = ` @@ -81,7 +82,7 @@ func newSearchRepoCmd(out io.Writer) *cobra.Command { Use: "repo [keyword]", Short: "search repositories for a keyword in charts", Long: searchRepoDesc, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, args []string) error { o.repoFile = settings.RepositoryConfig o.repoCacheDir = settings.RepositoryCache return o.run(out, args) @@ -130,17 +131,17 @@ func (o *searchRepoOptions) run(out io.Writer, args []string) error { } func (o *searchRepoOptions) setupSearchedVersion() { - debug("Original chart version: %q", o.version) + slog.Debug("original chart version", "version", o.version) if o.version != "" { return } if o.devel { // search for releases and prereleases (alpha, beta, and release candidate releases). - debug("setting version to >0.0.0-0") + slog.Debug("setting version to >0.0.0-0") o.version = ">0.0.0-0" - } else { // search only for stable releases, prerelease versions will be skip - debug("setting version to >0.0.0") + } else { // search only for stable releases, prerelease versions will be skipped + slog.Debug("setting version to >0.0.0") o.version = ">0.0.0" } } @@ -152,7 +153,7 @@ func (o *searchRepoOptions) applyConstraint(res []*search.Result) ([]*search.Res constraint, err := semver.NewConstraint(o.version) if err != nil { - return res, errors.Wrap(err, "an invalid version/constraint format") + return res, fmt.Errorf("an invalid version/constraint format: %w", err) } data := res[:0] @@ -189,8 +190,7 @@ func (o *searchRepoOptions) buildIndex() (*search.Index, error) { f := filepath.Join(o.repoCacheDir, helmpath.CacheIndexFile(n)) ind, err := repo.LoadIndexFile(f) if err != nil { - warning("Repo %q is corrupt or missing. Try 'helm repo update'.", n) - warning("%s", err) + slog.Warn("repo is corrupt or missing", slog.String("repo", n), slog.Any("error", err)) continue } @@ -260,11 +260,11 @@ func (r *repoSearchWriter) encodeByFormat(out io.Writer, format output.Format) e return output.EncodeJSON(out, chartList) case output.YAML: return output.EncodeYAML(out, chartList) + default: + // Because this is a non-exported function and only called internally by + // WriteJSON and WriteYAML, we shouldn't get invalid types + return nil } - - // Because this is a non-exported function and only called internally by - // WriteJSON and WriteYAML, we shouldn't get invalid types - return nil } // Provides the list of charts that are part of the specified repo, and that starts with 'prefix'. @@ -287,7 +287,7 @@ func compListChartsOfRepo(repoName string, prefix string) []string { if isNotExist(err) { // If there is no cached charts file, fallback to the full index file. // This is much slower but can happen after the caching feature is first - // installed but before the user does a 'helm repo update' to generate the + // installed but before the user does a 'helm repo update' to generate the // first cached charts file. path = filepath.Join(settings.RepositoryCache, helmpath.CacheIndexFile(repoName)) if indexFile, err := repo.LoadIndexFile(path); err == nil { diff --git a/pkg/helm/cmd/helm/search_repo_test.go b/pkg/helm/pkg/cmd/search_repo_test.go similarity index 99% rename from pkg/helm/cmd/helm/search_repo_test.go rename to pkg/helm/pkg/cmd/search_repo_test.go index 6bd11bb3..e7f104e0 100644 --- a/pkg/helm/cmd/helm/search_repo_test.go +++ b/pkg/helm/pkg/cmd/search_repo_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "testing" diff --git a/pkg/helm/cmd/helm/search_test.go b/pkg/helm/pkg/cmd/search_test.go similarity index 98% rename from pkg/helm/cmd/helm/search_test.go rename to pkg/helm/pkg/cmd/search_test.go index 636bf5b2..a0e5d84c 100644 --- a/pkg/helm/cmd/helm/search_test.go +++ b/pkg/helm/pkg/cmd/search_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import "testing" diff --git a/pkg/helm/pkg/cmd/show.go b/pkg/helm/pkg/cmd/show.go new file mode 100644 index 00000000..7e458805 --- /dev/null +++ b/pkg/helm/pkg/cmd/show.go @@ -0,0 +1,236 @@ +/* +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 cmd + +import ( + "fmt" + "io" + "log" + "log/slog" + + "github.com/spf13/cobra" + + "github.com/werf/nelm/pkg/helm/pkg/action" + "github.com/werf/nelm/pkg/helm/pkg/cmd/require" +) + +const showDesc = ` +This command consists of multiple subcommands to display information about a chart +` + +const showAllDesc = ` +This command inspects a chart (directory, file, or URL) and displays all its content +(values.yaml, Chart.yaml, README) +` + +const showValuesDesc = ` +This command inspects a chart (directory, file, or URL) and displays the contents +of the values.yaml file +` + +const showChartDesc = ` +This command inspects a chart (directory, file, or URL) and displays the contents +of the Chart.yaml file +` + +const readmeChartDesc = ` +This command inspects a chart (directory, file, or URL) and displays the contents +of the README file +` + +const showCRDsDesc = ` +This command inspects a chart (directory, file, or URL) and displays the contents +of the CustomResourceDefinition files +` + +func newShowCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + client := action.NewShow(action.ShowAll, cfg) + + showCommand := &cobra.Command{ + Use: "show", + Short: "show information of a chart", + Aliases: []string{"inspect"}, + Long: showDesc, + Args: require.NoArgs, + } + + // Function providing dynamic auto-completion + validArgsFunc := func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return noMoreArgsComp() + } + return compListCharts(toComplete, true) + } + + all := &cobra.Command{ + Use: "all [CHART]", + Short: "show all information of the chart", + Long: showAllDesc, + Args: require.ExactArgs(1), + ValidArgsFunction: validArgsFunc, + RunE: func(_ *cobra.Command, args []string) error { + client.OutputFormat = action.ShowAll + err := addRegistryClient(client) + if err != nil { + return err + } + output, err := runShow(args, client) + if err != nil { + return err + } + fmt.Fprint(out, output) + return nil + }, + } + + valuesSubCmd := &cobra.Command{ + Use: "values [CHART]", + Short: "show the chart's values", + Long: showValuesDesc, + Args: require.ExactArgs(1), + ValidArgsFunction: validArgsFunc, + RunE: func(_ *cobra.Command, args []string) error { + client.OutputFormat = action.ShowValues + err := addRegistryClient(client) + if err != nil { + return err + } + output, err := runShow(args, client) + if err != nil { + return err + } + fmt.Fprint(out, output) + return nil + }, + } + + chartSubCmd := &cobra.Command{ + Use: "chart [CHART]", + Short: "show the chart's definition", + Long: showChartDesc, + Args: require.ExactArgs(1), + ValidArgsFunction: validArgsFunc, + RunE: func(_ *cobra.Command, args []string) error { + client.OutputFormat = action.ShowChart + err := addRegistryClient(client) + if err != nil { + return err + } + output, err := runShow(args, client) + if err != nil { + return err + } + fmt.Fprint(out, output) + return nil + }, + } + + readmeSubCmd := &cobra.Command{ + Use: "readme [CHART]", + Short: "show the chart's README", + Long: readmeChartDesc, + Args: require.ExactArgs(1), + ValidArgsFunction: validArgsFunc, + RunE: func(_ *cobra.Command, args []string) error { + client.OutputFormat = action.ShowReadme + err := addRegistryClient(client) + if err != nil { + return err + } + output, err := runShow(args, client) + if err != nil { + return err + } + fmt.Fprint(out, output) + return nil + }, + } + + crdsSubCmd := &cobra.Command{ + Use: "crds [CHART]", + Short: "show the chart's CRDs", + Long: showCRDsDesc, + Args: require.ExactArgs(1), + ValidArgsFunction: validArgsFunc, + RunE: func(_ *cobra.Command, args []string) error { + client.OutputFormat = action.ShowCRDs + err := addRegistryClient(client) + if err != nil { + return err + } + output, err := runShow(args, client) + if err != nil { + return err + } + fmt.Fprint(out, output) + return nil + }, + } + + cmds := []*cobra.Command{all, readmeSubCmd, valuesSubCmd, chartSubCmd, crdsSubCmd} + for _, subCmd := range cmds { + addShowFlags(subCmd, client) + showCommand.AddCommand(subCmd) + } + + return showCommand +} + +func addShowFlags(subCmd *cobra.Command, client *action.Show) { + f := subCmd.Flags() + + f.BoolVar(&client.Devel, "devel", false, "use development versions, too. Equivalent to version '>0.0.0-0'. If --version is set, this is ignored") + if subCmd.Name() == "values" { + f.StringVar(&client.JSONPathTemplate, "jsonpath", "", "supply a JSONPath expression to filter the output") + } + addChartPathOptionsFlags(f, &client.ChartPathOptions) + + err := subCmd.RegisterFlagCompletionFunc("version", func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 1 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return compVersionFlag(args[0], toComplete) + }) + + if err != nil { + log.Fatal(err) + } +} + +func runShow(args []string, client *action.Show) (string, error) { + slog.Debug("original chart version", "version", client.Version) + if client.Version == "" && client.Devel { + slog.Debug("setting version to >0.0.0-0") + client.Version = ">0.0.0-0" + } + + cp, err := client.LocateChart(args[0], settings) + if err != nil { + return "", err + } + return client.Run(cp) +} + +func addRegistryClient(client *action.Show) error { + registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile, + client.InsecureSkipTLSVerify, client.PlainHTTP, client.Username, client.Password) + if err != nil { + return fmt.Errorf("missing registry client: %w", err) + } + client.SetRegistryClient(registryClient) + return nil +} diff --git a/pkg/helm/pkg/cmd/show_test.go b/pkg/helm/pkg/cmd/show_test.go new file mode 100644 index 00000000..7f773ce8 --- /dev/null +++ b/pkg/helm/pkg/cmd/show_test.go @@ -0,0 +1,158 @@ +/* +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 cmd + +import ( + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/werf/nelm/pkg/helm/pkg/repo/v1/repotest" +) + +func TestShowPreReleaseChart(t *testing.T) { + srv := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/testcharts/*.tgz*"), + ) + defer srv.Stop() + + if err := srv.LinkIndices(); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + args string + flags string + fail bool + expectedErr string + }{ + { + name: "show pre-release chart", + args: "test/pre-release-chart", + fail: true, + expectedErr: "chart \"pre-release-chart\" matching not found in test index. (try 'helm repo update'): no chart version found for pre-release-chart-", + }, + { + name: "show pre-release chart", + args: "test/pre-release-chart", + fail: true, + flags: "--version 1.0.0", + expectedErr: "chart \"pre-release-chart\" matching 1.0.0 not found in test index. (try 'helm repo update'): no chart version found for pre-release-chart-1.0.0", + }, + { + name: "show pre-release chart with 'devel' flag", + args: "test/pre-release-chart", + flags: "--devel", + fail: false, + }, + } + + contentTmp := t.TempDir() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + outdir := srv.Root() + cmd := fmt.Sprintf("show all '%s' %s --repository-config %s --repository-cache %s --content-cache %s", + tt.args, + tt.flags, + filepath.Join(outdir, "repositories.yaml"), + outdir, + contentTmp, + ) + //_, out, err := executeActionCommand(cmd) + _, _, err := executeActionCommand(cmd) + if err != nil { + if tt.fail { + if !strings.Contains(err.Error(), tt.expectedErr) { + t.Errorf("%q expected error: %s, got: %s", tt.name, tt.expectedErr, err.Error()) + } + return + } + t.Errorf("%q reported error: %s", tt.name, err) + } + }) + } +} + +func TestShowVersionCompletion(t *testing.T) { + repoFile := "testdata/helmhome/helm/repositories.yaml" + repoCache := "testdata/helmhome/helm/repository" + + repoSetup := fmt.Sprintf("--repository-config %s --repository-cache %s", repoFile, repoCache) + + tests := []cmdTestCase{{ + name: "completion for show version flag", + cmd: fmt.Sprintf("%s __complete show chart testing/alpine --version ''", repoSetup), + golden: "output/version-comp.txt", + }, { + name: "completion for show version flag, no filter", + cmd: fmt.Sprintf("%s __complete show chart testing/alpine --version 0.3", repoSetup), + golden: "output/version-comp.txt", + }, { + name: "completion for show version flag too few args", + cmd: fmt.Sprintf("%s __complete show chart --version ''", repoSetup), + golden: "output/version-invalid-comp.txt", + }, { + name: "completion for show version flag too many args", + cmd: fmt.Sprintf("%s __complete show chart testing/alpine badarg --version ''", repoSetup), + golden: "output/version-invalid-comp.txt", + }, { + name: "completion for show version flag invalid chart", + cmd: fmt.Sprintf("%s __complete show chart invalid/invalid --version ''", repoSetup), + golden: "output/version-invalid-comp.txt", + }, { + name: "completion for show version flag with all", + cmd: fmt.Sprintf("%s __complete show all testing/alpine --version ''", repoSetup), + golden: "output/version-comp.txt", + }, { + name: "completion for show version flag with readme", + cmd: fmt.Sprintf("%s __complete show readme testing/alpine --version ''", repoSetup), + golden: "output/version-comp.txt", + }, { + name: "completion for show version flag with values", + cmd: fmt.Sprintf("%s __complete show values testing/alpine --version ''", repoSetup), + golden: "output/version-comp.txt", + }} + runTestCmd(t, tests) +} + +func TestShowFileCompletion(t *testing.T) { + checkFileCompletion(t, "show", false) +} + +func TestShowAllFileCompletion(t *testing.T) { + checkFileCompletion(t, "show all", true) +} + +func TestShowChartFileCompletion(t *testing.T) { + checkFileCompletion(t, "show chart", true) +} + +func TestShowReadmeFileCompletion(t *testing.T) { + checkFileCompletion(t, "show readme", true) +} + +func TestShowValuesFileCompletion(t *testing.T) { + checkFileCompletion(t, "show values", true) +} + +func TestShowCRDsFileCompletion(t *testing.T) { + checkFileCompletion(t, "show crds", true) +} diff --git a/pkg/helm/pkg/cmd/status.go b/pkg/helm/pkg/cmd/status.go new file mode 100644 index 00000000..6879711e --- /dev/null +++ b/pkg/helm/pkg/cmd/status.go @@ -0,0 +1,257 @@ +/* +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 cmd + +import ( + "bytes" + "fmt" + "io" + "log" + "strings" + "time" + + "github.com/spf13/cobra" + + "k8s.io/kubectl/pkg/cmd/get" + + coloroutput "github.com/werf/nelm/pkg/helm/intern/cli/output" + "github.com/werf/nelm/pkg/helm/pkg/action" + "github.com/werf/nelm/pkg/helm/pkg/chart/common/util" + "github.com/werf/nelm/pkg/helm/pkg/cli/output" + "github.com/werf/nelm/pkg/helm/pkg/cmd/require" + "github.com/werf/nelm/pkg/helm/pkg/release" + releasev1 "github.com/werf/nelm/pkg/helm/pkg/release/v1" +) + +// NOTE: Keep the list of statuses up-to-date with pkg/release/status.go. +var statusHelp = ` +This command shows the status of a named release. +The status consists of: +- last deployment time +- k8s namespace in which the release lives +- state of the release (can be: unknown, deployed, uninstalled, superseded, failed, uninstalling, pending-install, pending-upgrade or pending-rollback) +- revision of the release +- description of the release (can be completion message or error message) +- list of resources that this release consists of +- details on last test suite run, if applicable +- additional notes provided by the chart +` + +func newStatusCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + client := action.NewStatus(cfg) + var outfmt output.Format + + cmd := &cobra.Command{ + Use: "status RELEASE_NAME", + Short: "display the status of the named release", + Long: statusHelp, + Args: require.ExactArgs(1), + ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return noMoreArgsComp() + } + return compListReleases(toComplete, args, cfg) + }, + RunE: func(_ *cobra.Command, args []string) error { + // When the output format is a table the resources should be fetched + // and displayed as a table. When YAML or JSON the resources will be + // returned. This mirrors the handling in kubectl. + if outfmt == output.Table { + client.ShowResourcesTable = true + } + reli, err := client.Run(args[0]) + if err != nil { + return err + } + rel, err := releaserToV1Release(reli) + if err != nil { + return err + } + + // strip chart metadata from the output + rel.Chart = nil + + return outfmt.Write(out, &statusPrinter{ + release: rel, + debug: false, + showMetadata: false, + hideNotes: false, + noColor: settings.ShouldDisableColor(), + }) + }, + } + + f := cmd.Flags() + + f.IntVar(&client.Version, "revision", 0, "if set, display the status of the named release with revision") + + err := cmd.RegisterFlagCompletionFunc("revision", func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 1 { + return compListRevisions(toComplete, cfg, args[0]) + } + return nil, cobra.ShellCompDirectiveNoFileComp + }) + if err != nil { + log.Fatal(err) + } + + bindOutputFlag(cmd, &outfmt) + + return cmd +} + +type statusPrinter struct { + release release.Releaser + debug bool + showMetadata bool + hideNotes bool + noColor bool +} + +func (s statusPrinter) getV1Release() *releasev1.Release { + switch rel := s.release.(type) { + case releasev1.Release: + return &rel + case *releasev1.Release: + return rel + } + return &releasev1.Release{} +} + +func (s statusPrinter) WriteJSON(out io.Writer) error { + return output.EncodeJSON(out, s.getV1Release()) +} + +func (s statusPrinter) WriteYAML(out io.Writer) error { + return output.EncodeYAML(out, s.getV1Release()) +} + +func (s statusPrinter) WriteTable(out io.Writer) error { + if s.release == nil { + return nil + } + rel := s.getV1Release() + _, _ = fmt.Fprintf(out, "NAME: %s\n", rel.Name) + if !rel.Info.LastDeployed.IsZero() { + _, _ = fmt.Fprintf(out, "LAST DEPLOYED: %s\n", rel.Info.LastDeployed.Format(time.ANSIC)) + } + _, _ = fmt.Fprintf(out, "NAMESPACE: %s\n", coloroutput.ColorizeNamespace(rel.Namespace, s.noColor)) + _, _ = fmt.Fprintf(out, "STATUS: %s\n", coloroutput.ColorizeStatus(rel.Info.Status, s.noColor)) + _, _ = fmt.Fprintf(out, "REVISION: %d\n", rel.Version) + if s.showMetadata { + _, _ = fmt.Fprintf(out, "CHART: %s\n", rel.Chart.Metadata.Name) + _, _ = fmt.Fprintf(out, "VERSION: %s\n", rel.Chart.Metadata.Version) + _, _ = fmt.Fprintf(out, "APP_VERSION: %s\n", rel.Chart.Metadata.AppVersion) + } + _, _ = fmt.Fprintf(out, "DESCRIPTION: %s\n", rel.Info.Description) + + if len(rel.Info.Resources) > 0 { + buf := new(bytes.Buffer) + printFlags := get.NewHumanPrintFlags() + typePrinter, _ := printFlags.ToPrinter("") + printer := &get.TablePrinter{Delegate: typePrinter} + + var keys []string + for key := range rel.Info.Resources { + keys = append(keys, key) + } + + for _, t := range keys { + _, _ = fmt.Fprintf(buf, "==> %s\n", t) + + vk := rel.Info.Resources[t] + for _, resource := range vk { + if err := printer.PrintObj(resource, buf); err != nil { + _, _ = fmt.Fprintf(buf, "failed to print object type %s: %v\n", t, err) + } + } + + buf.WriteString("\n") + } + + _, _ = fmt.Fprintf(out, "RESOURCES:\n%s\n", buf.String()) + } + + executions := executionsByHookEvent(rel) + if tests, ok := executions[releasev1.HookTest]; !ok || len(tests) == 0 { + _, _ = fmt.Fprintln(out, "TEST SUITE: None") + } else { + for _, h := range tests { + // Don't print anything if hook has not been initiated + if h.LastRun.StartedAt.IsZero() { + continue + } + _, _ = fmt.Fprintf(out, "TEST SUITE: %s\n%s\n%s\n%s\n", + h.Name, + fmt.Sprintf("Last Started: %s", h.LastRun.StartedAt.Format(time.ANSIC)), + fmt.Sprintf("Last Completed: %s", h.LastRun.CompletedAt.Format(time.ANSIC)), + fmt.Sprintf("Phase: %s", h.LastRun.Phase), + ) + } + } + + if s.debug { + _, _ = fmt.Fprintln(out, "USER-SUPPLIED VALUES:") + err := output.EncodeYAML(out, rel.Config) + if err != nil { + return err + } + // Print an extra newline + _, _ = fmt.Fprintln(out) + + cfg, err := util.CoalesceValues(rel.Chart, rel.Config) + if err != nil { + return err + } + + _, _ = fmt.Fprintln(out, "COMPUTED VALUES:") + err = output.EncodeYAML(out, cfg.AsMap()) + if err != nil { + return err + } + // Print an extra newline + _, _ = fmt.Fprintln(out) + } + + if strings.EqualFold(rel.Info.Description, "Dry run complete") || s.debug { + _, _ = fmt.Fprintln(out, "HOOKS:") + for _, h := range rel.Hooks { + _, _ = fmt.Fprintf(out, "---\n# Source: %s\n%s\n", h.Path, h.Manifest) + } + _, _ = fmt.Fprintf(out, "MANIFEST:\n%s\n", rel.Manifest) + } + + // Hide notes from output - option in install and upgrades + if !s.hideNotes && len(rel.Info.Notes) > 0 { + _, _ = fmt.Fprintf(out, "NOTES:\n%s\n", strings.TrimSpace(rel.Info.Notes)) + } + return nil +} + +func executionsByHookEvent(rel *releasev1.Release) map[releasev1.HookEvent][]*releasev1.Hook { + result := make(map[releasev1.HookEvent][]*releasev1.Hook) + for _, h := range rel.Hooks { + for _, e := range h.Events { + executions, ok := result[e] + if !ok { + executions = []*releasev1.Hook{} + } + result[e] = append(executions, h) + } + } + return result +} diff --git a/pkg/helm/pkg/cmd/status_test.go b/pkg/helm/pkg/cmd/status_test.go new file mode 100644 index 00000000..d0635d54 --- /dev/null +++ b/pkg/helm/pkg/cmd/status_test.go @@ -0,0 +1,220 @@ +/* +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 cmd + +import ( + "testing" + "time" + + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/release/common" + release "github.com/werf/nelm/pkg/helm/pkg/release/v1" +) + +func TestStatusCmd(t *testing.T) { + releasesMockWithStatus := func(info *release.Info, hooks ...*release.Hook) []*release.Release { + info.LastDeployed = time.Unix(1452902400, 0).UTC() + return []*release.Release{{ + Name: "flummoxed-chickadee", + Namespace: "default", + Info: info, + Chart: &chart.Chart{Metadata: &chart.Metadata{Name: "name", Version: "1.2.3", AppVersion: "3.2.1"}}, + Hooks: hooks, + }} + } + + tests := []cmdTestCase{{ + name: "get status of a deployed release", + cmd: "status flummoxed-chickadee", + golden: "output/status.txt", + rels: releasesMockWithStatus(&release.Info{ + Status: common.StatusDeployed, + }), + }, { + name: "get status of a deployed release, with desc", + cmd: "status flummoxed-chickadee", + golden: "output/status-with-desc.txt", + rels: releasesMockWithStatus(&release.Info{ + Status: common.StatusDeployed, + Description: "Mock description", + }), + }, { + name: "get status of a deployed release with notes", + cmd: "status flummoxed-chickadee", + golden: "output/status-with-notes.txt", + rels: releasesMockWithStatus(&release.Info{ + Status: common.StatusDeployed, + Notes: "release notes", + }), + }, { + name: "get status of a deployed release with notes in json", + cmd: "status flummoxed-chickadee -o json", + golden: "output/status.json", + rels: releasesMockWithStatus(&release.Info{ + Status: common.StatusDeployed, + Notes: "release notes", + }), + }, { + name: "get status of a deployed release with resources", + cmd: "status flummoxed-chickadee", + golden: "output/status-with-resources.txt", + rels: releasesMockWithStatus( + &release.Info{ + Status: common.StatusDeployed, + }, + ), + }, { + name: "get status of a deployed release with resources in json", + cmd: "status flummoxed-chickadee -o json", + golden: "output/status-with-resources.json", + rels: releasesMockWithStatus( + &release.Info{ + Status: common.StatusDeployed, + }, + ), + }, { + name: "get status of a deployed release with test suite", + cmd: "status flummoxed-chickadee", + golden: "output/status-with-test-suite.txt", + rels: releasesMockWithStatus( + &release.Info{ + Status: common.StatusDeployed, + }, + &release.Hook{ + Name: "never-run-test", + Events: []release.HookEvent{release.HookTest}, + }, + &release.Hook{ + Name: "passing-test", + Events: []release.HookEvent{release.HookTest}, + LastRun: release.HookExecution{ + StartedAt: mustParseTime("2006-01-02T15:04:05Z"), + CompletedAt: mustParseTime("2006-01-02T15:04:07Z"), + Phase: release.HookPhaseSucceeded, + }, + }, + &release.Hook{ + Name: "failing-test", + Events: []release.HookEvent{release.HookTest}, + LastRun: release.HookExecution{ + StartedAt: mustParseTime("2006-01-02T15:10:05Z"), + CompletedAt: mustParseTime("2006-01-02T15:10:07Z"), + Phase: release.HookPhaseFailed, + }, + }, + &release.Hook{ + Name: "passing-pre-install", + Events: []release.HookEvent{release.HookPreInstall}, + LastRun: release.HookExecution{ + StartedAt: mustParseTime("2006-01-02T15:00:05Z"), + CompletedAt: mustParseTime("2006-01-02T15:00:07Z"), + Phase: release.HookPhaseSucceeded, + }, + }, + ), + }} + runTestCmd(t, tests) +} + +func mustParseTime(t string) time.Time { + res, _ := time.Parse(time.RFC3339, t) + return res +} + +func TestStatusCompletion(t *testing.T) { + rels := []*release.Release{ + { + Name: "athos", + Namespace: "default", + Info: &release.Info{ + Status: common.StatusDeployed, + }, + Chart: &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "Athos-chart", + Version: "1.2.3", + }, + }, + }, { + Name: "porthos", + Namespace: "default", + Info: &release.Info{ + Status: common.StatusFailed, + }, + Chart: &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "Porthos-chart", + Version: "111.222.333", + }, + }, + }, { + Name: "aramis", + Namespace: "default", + Info: &release.Info{ + Status: common.StatusUninstalled, + }, + Chart: &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "Aramis-chart", + Version: "0.0.0", + }, + }, + }, { + Name: "dartagnan", + Namespace: "gascony", + Info: &release.Info{ + Status: common.StatusUnknown, + }, + Chart: &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "Dartagnan-chart", + Version: "1.2.3-prerelease", + }, + }, + }} + + tests := []cmdTestCase{{ + name: "completion for status", + cmd: "__complete status a", + golden: "output/status-comp.txt", + rels: rels, + }, { + name: "completion for status with too many arguments", + cmd: "__complete status dartagnan ''", + golden: "output/status-wrong-args-comp.txt", + rels: rels, + }, { + name: "completion for status with global flag", + cmd: "__complete status --debug a", + golden: "output/status-comp.txt", + rels: rels, + }} + runTestCmd(t, tests) +} + +func TestStatusRevisionCompletion(t *testing.T) { + revisionFlagCompletionTest(t, "status") +} + +func TestStatusOutputCompletion(t *testing.T) { + outputFlagCompletionTest(t, "status") +} + +func TestStatusFileCompletion(t *testing.T) { + checkFileCompletion(t, "status", false) + checkFileCompletion(t, "status myrelease", false) +} diff --git a/pkg/helm/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/completion.yaml b/pkg/helm/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/completion.yaml new file mode 100644 index 00000000..e0b161c6 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/completion.yaml @@ -0,0 +1,19 @@ +name: wrongname +commands: + - name: empty + - name: full + commands: + - name: more + validArgs: + - one + - two + flags: + - b + - ball + - name: less + flags: + - a + - all +flags: +- z +- q diff --git a/pkg/helm/cmd/helm/testdata/helm home with space/helm/plugins/fullenv/fullenv.sh b/pkg/helm/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/fullenv.sh similarity index 100% rename from pkg/helm/cmd/helm/testdata/helm home with space/helm/plugins/fullenv/fullenv.sh rename to pkg/helm/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/fullenv.sh diff --git a/pkg/helm/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml b/pkg/helm/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml new file mode 100644 index 00000000..a58544b0 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: v1 +name: fullenv +type: cli/v1 +runtime: subprocess +config: + shortHelp: "show env vars" + longHelp: "show all env vars" + ignoreFlags: false +runtimeConfig: + platformCommand: + - command: "$HELM_PLUGIN_DIR/fullenv.sh" diff --git a/pkg/helm/cmd/helm/testdata/helm home with space/helm/repositories.yaml b/pkg/helm/pkg/cmd/testdata/helm home with space/helm/repositories.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/helm home with space/helm/repositories.yaml rename to pkg/helm/pkg/cmd/testdata/helm home with space/helm/repositories.yaml diff --git a/pkg/helm/pkg/cmd/testdata/helm home with space/helm/repository/test-name-charts.txt b/pkg/helm/pkg/cmd/testdata/helm home with space/helm/repository/test-name-charts.txt new file mode 100644 index 00000000..e69de29b diff --git a/pkg/helm/cmd/helm/testdata/helm home with space/helm/repository/test-name-index.yaml b/pkg/helm/pkg/cmd/testdata/helm home with space/helm/repository/test-name-index.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/helm home with space/helm/repository/test-name-index.yaml rename to pkg/helm/pkg/cmd/testdata/helm home with space/helm/repository/test-name-index.yaml diff --git a/pkg/helm/cmd/helm/testdata/helm home with space/helm/repository/testing-index.yaml b/pkg/helm/pkg/cmd/testdata/helm home with space/helm/repository/testing-index.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/helm home with space/helm/repository/testing-index.yaml rename to pkg/helm/pkg/cmd/testdata/helm home with space/helm/repository/testing-index.yaml diff --git a/pkg/helm/cmd/helm/testdata/helm-test-key.pub b/pkg/helm/pkg/cmd/testdata/helm-test-key.pub similarity index 100% rename from pkg/helm/cmd/helm/testdata/helm-test-key.pub rename to pkg/helm/pkg/cmd/testdata/helm-test-key.pub diff --git a/pkg/helm/cmd/helm/testdata/helm-test-key.secret b/pkg/helm/pkg/cmd/testdata/helm-test-key.secret similarity index 100% rename from pkg/helm/cmd/helm/testdata/helm-test-key.secret rename to pkg/helm/pkg/cmd/testdata/helm-test-key.secret diff --git a/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/args/args.sh b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/args/args.sh new file mode 100755 index 00000000..6c62be8b --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/args/args.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +echo "$@" diff --git a/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/args/plugin.complete b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/args/plugin.complete similarity index 100% rename from pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/args/plugin.complete rename to pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/args/plugin.complete diff --git a/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/args/plugin.yaml b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/args/plugin.yaml new file mode 100644 index 00000000..4156e7f1 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/args/plugin.yaml @@ -0,0 +1,11 @@ +name: args +type: cli/v1 +apiVersion: v1 +runtime: subprocess +config: + shortHelp: "echo args" + longHelp: "This echos args" + ignoreFlags: false +runtimeConfig: + platformCommand: + - command: "$HELM_PLUGIN_DIR/args.sh" diff --git a/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/echo/completion.yaml b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/echo/completion.yaml new file mode 100644 index 00000000..e69de29b diff --git a/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/echo/plugin.complete b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/echo/plugin.complete similarity index 100% rename from pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/echo/plugin.complete rename to pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/echo/plugin.complete diff --git a/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/echo/plugin.yaml b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/echo/plugin.yaml new file mode 100644 index 00000000..a0a0b525 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/echo/plugin.yaml @@ -0,0 +1,11 @@ +name: echo +type: cli/v1 +apiVersion: v1 +runtime: subprocess +config: + shortHelp: "echo stuff" + longHelp: "This echos stuff" + ignoreFlags: false +runtimeConfig: + platformCommand: + - command: "echo hello" diff --git a/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/exitwith/completion.yaml b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/completion.yaml similarity index 64% rename from pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/exitwith/completion.yaml rename to pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/completion.yaml index 8bb0777c..e5bf440f 100644 --- a/pkg/helm/cmd/helm/testdata/helmhome/helm/plugins/exitwith/completion.yaml +++ b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/completion.yaml @@ -1,5 +1,5 @@ commands: - name: code flags: - - a - - b + - a + - b diff --git a/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/exitwith.sh b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/exitwith.sh new file mode 100755 index 00000000..9cf68da6 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/exitwith.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +exit "$1" diff --git a/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/plugin.yaml b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/plugin.yaml new file mode 100644 index 00000000..ba950825 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/plugin.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: v1 +name: exitwith +type: cli/v1 +runtime: subprocess +config: + shortHelp: "exitwith code" + longHelp: "This exits with the specified exit code" + ignoreFlags: false +runtimeConfig: + platformCommand: + - command: "$HELM_PLUGIN_DIR/exitwith.sh" diff --git a/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/completion.yaml b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/completion.yaml new file mode 100644 index 00000000..e0b161c6 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/completion.yaml @@ -0,0 +1,19 @@ +name: wrongname +commands: + - name: empty + - name: full + commands: + - name: more + validArgs: + - one + - two + flags: + - b + - ball + - name: less + flags: + - a + - all +flags: +- z +- q diff --git a/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/fullenv.sh b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/fullenv.sh new file mode 100755 index 00000000..cc0c64a6 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/fullenv.sh @@ -0,0 +1,7 @@ +#!/bin/sh +echo HELM_PLUGIN_NAME=${HELM_PLUGIN_NAME} +echo HELM_PLUGIN_DIR=${HELM_PLUGIN_DIR} +echo HELM_PLUGINS=${HELM_PLUGINS} +echo HELM_REPOSITORY_CONFIG=${HELM_REPOSITORY_CONFIG} +echo HELM_REPOSITORY_CACHE=${HELM_REPOSITORY_CACHE} +echo HELM_BIN=${HELM_BIN} diff --git a/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/plugin.yaml b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/plugin.yaml new file mode 100644 index 00000000..a58544b0 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/plugin.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: v1 +name: fullenv +type: cli/v1 +runtime: subprocess +config: + shortHelp: "show env vars" + longHelp: "show all env vars" + ignoreFlags: false +runtimeConfig: + platformCommand: + - command: "$HELM_PLUGIN_DIR/fullenv.sh" diff --git a/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/plugin.yaml b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/plugin.yaml new file mode 100644 index 00000000..b6e8afa5 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/plugin.yaml @@ -0,0 +1,9 @@ +--- +apiVersion: v1 +name: "postrenderer-v1" +version: "1.2.3" +type: postrenderer/v1 +runtime: subprocess +runtimeConfig: + platformCommand: + - command: "${HELM_PLUGIN_DIR}/sed-test.sh" diff --git a/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/sed-test.sh b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/sed-test.sh new file mode 100755 index 00000000..a016e398 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/sed-test.sh @@ -0,0 +1,6 @@ +#!/bin/sh +if [ $# -eq 0 ]; then + sed s/FOOTEST/BARTEST/g <&0 +else + sed s/FOOTEST/"$*"/g <&0 +fi diff --git a/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/shortenv/completion.yaml b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/shortenv/completion.yaml new file mode 100644 index 00000000..027573ed --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/shortenv/completion.yaml @@ -0,0 +1,13 @@ +name: shortenv +commands: + - name: list + flags: + - a + - all + - log + - name: remove + validArgs: + - all + - one +flags: +- global diff --git a/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/shortenv/plugin-name.sh b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/shortenv/plugin-name.sh new file mode 100755 index 00000000..9e823ac1 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/shortenv/plugin-name.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo HELM_PLUGIN_NAME=${HELM_PLUGIN_NAME} diff --git a/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/shortenv/plugin.yaml b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/shortenv/plugin.yaml new file mode 100644 index 00000000..5fe053ed --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/shortenv/plugin.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: v1 +name: shortenv +type: cli/v1 +runtime: subprocess +config: + shortHelp: "env stuff" + longHelp: "show the env" + ignoreFlags: false +runtimeConfig: + platformCommand: + - command: ${HELM_PLUGIN_DIR}/plugin-name.sh diff --git a/pkg/helm/pkg/cmd/testdata/helmhome/helm/repositories.yaml b/pkg/helm/pkg/cmd/testdata/helmhome/helm/repositories.yaml new file mode 100644 index 00000000..3835aaa5 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/helmhome/helm/repositories.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +generated: 2016-10-03T16:03:10.640376913-06:00 +repositories: +- cache: testing-index.yaml + name: testing + url: http://example.com/charts diff --git a/pkg/helm/pkg/cmd/testdata/helmhome/helm/repository/test-name-charts.txt b/pkg/helm/pkg/cmd/testdata/helmhome/helm/repository/test-name-charts.txt new file mode 100644 index 00000000..e69de29b diff --git a/pkg/helm/cmd/helm/testdata/helmhome/helm/repository/test-name-index.yaml b/pkg/helm/pkg/cmd/testdata/helmhome/helm/repository/test-name-index.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/helmhome/helm/repository/test-name-index.yaml rename to pkg/helm/pkg/cmd/testdata/helmhome/helm/repository/test-name-index.yaml diff --git a/pkg/helm/cmd/helm/testdata/helmhome/helm/repository/testing-index.yaml b/pkg/helm/pkg/cmd/testdata/helmhome/helm/repository/testing-index.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/helmhome/helm/repository/testing-index.yaml rename to pkg/helm/pkg/cmd/testdata/helmhome/helm/repository/testing-index.yaml diff --git a/pkg/helm/cmd/helm/testdata/output/chart-with-subchart-update.txt b/pkg/helm/pkg/cmd/testdata/output/chart-with-subchart-update.txt similarity index 80% rename from pkg/helm/cmd/helm/testdata/output/chart-with-subchart-update.txt rename to pkg/helm/pkg/cmd/testdata/output/chart-with-subchart-update.txt index dd8d3c35..5b2083e1 100644 --- a/pkg/helm/cmd/helm/testdata/output/chart-with-subchart-update.txt +++ b/pkg/helm/pkg/cmd/testdata/output/chart-with-subchart-update.txt @@ -1,10 +1,9 @@ NAME: updeps LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -LAST PHASE: rollout -LAST STAGE: 0 NAMESPACE: default STATUS: deployed REVISION: 1 +DESCRIPTION: Install complete TEST SUITE: None NOTES: PARENT NOTES diff --git a/pkg/helm/cmd/helm/testdata/output/dependency-list-archive.txt b/pkg/helm/pkg/cmd/testdata/output/dependency-list-archive.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/dependency-list-archive.txt rename to pkg/helm/pkg/cmd/testdata/output/dependency-list-archive.txt diff --git a/pkg/helm/cmd/helm/testdata/output/dependency-list-no-chart-linux.txt b/pkg/helm/pkg/cmd/testdata/output/dependency-list-no-chart-linux.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/dependency-list-no-chart-linux.txt rename to pkg/helm/pkg/cmd/testdata/output/dependency-list-no-chart-linux.txt diff --git a/pkg/helm/cmd/helm/testdata/output/dependency-list-no-requirements-linux.txt b/pkg/helm/pkg/cmd/testdata/output/dependency-list-no-requirements-linux.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/dependency-list-no-requirements-linux.txt rename to pkg/helm/pkg/cmd/testdata/output/dependency-list-no-requirements-linux.txt diff --git a/pkg/helm/cmd/helm/testdata/output/dependency-list.txt b/pkg/helm/pkg/cmd/testdata/output/dependency-list.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/dependency-list.txt rename to pkg/helm/pkg/cmd/testdata/output/dependency-list.txt diff --git a/pkg/helm/cmd/helm/testdata/output/deprecated-chart.txt b/pkg/helm/pkg/cmd/testdata/output/deprecated-chart.txt similarity index 77% rename from pkg/helm/cmd/helm/testdata/output/deprecated-chart.txt rename to pkg/helm/pkg/cmd/testdata/output/deprecated-chart.txt index 7452b096..fcf5cc0e 100644 --- a/pkg/helm/cmd/helm/testdata/output/deprecated-chart.txt +++ b/pkg/helm/pkg/cmd/testdata/output/deprecated-chart.txt @@ -1,8 +1,7 @@ NAME: aeneas LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -LAST PHASE: rollout -LAST STAGE: 0 NAMESPACE: default STATUS: deployed REVISION: 1 +DESCRIPTION: Install complete TEST SUITE: None diff --git a/pkg/helm/cmd/helm/testdata/output/docs-type-comp.txt b/pkg/helm/pkg/cmd/testdata/output/docs-type-comp.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/docs-type-comp.txt rename to pkg/helm/pkg/cmd/testdata/output/docs-type-comp.txt diff --git a/pkg/helm/cmd/helm/testdata/output/empty_default_comp.txt b/pkg/helm/pkg/cmd/testdata/output/empty_default_comp.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/empty_default_comp.txt rename to pkg/helm/pkg/cmd/testdata/output/empty_default_comp.txt diff --git a/pkg/helm/pkg/cmd/testdata/output/empty_nofile_comp.txt b/pkg/helm/pkg/cmd/testdata/output/empty_nofile_comp.txt new file mode 100644 index 00000000..3c537283 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/empty_nofile_comp.txt @@ -0,0 +1,3 @@ +_activeHelp_ This command does not take any more arguments (but may accept flags). +:4 +Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/pkg/helm/cmd/helm/testdata/output/env-comp.txt b/pkg/helm/pkg/cmd/testdata/output/env-comp.txt similarity index 95% rename from pkg/helm/cmd/helm/testdata/output/env-comp.txt rename to pkg/helm/pkg/cmd/testdata/output/env-comp.txt index 8f9c53fc..9d38ee46 100644 --- a/pkg/helm/cmd/helm/testdata/output/env-comp.txt +++ b/pkg/helm/pkg/cmd/testdata/output/env-comp.txt @@ -2,6 +2,7 @@ HELM_BIN HELM_BURST_LIMIT HELM_CACHE_HOME HELM_CONFIG_HOME +HELM_CONTENT_CACHE HELM_DATA_HOME HELM_DEBUG HELM_KUBEAPISERVER diff --git a/pkg/helm/cmd/helm/testdata/output/get-all-no-args.txt b/pkg/helm/pkg/cmd/testdata/output/get-all-no-args.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/get-all-no-args.txt rename to pkg/helm/pkg/cmd/testdata/output/get-all-no-args.txt diff --git a/pkg/helm/cmd/helm/testdata/output/get-hooks-no-args.txt b/pkg/helm/pkg/cmd/testdata/output/get-hooks-no-args.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/get-hooks-no-args.txt rename to pkg/helm/pkg/cmd/testdata/output/get-hooks-no-args.txt diff --git a/pkg/helm/cmd/helm/testdata/output/get-hooks.txt b/pkg/helm/pkg/cmd/testdata/output/get-hooks.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/get-hooks.txt rename to pkg/helm/pkg/cmd/testdata/output/get-hooks.txt diff --git a/pkg/helm/cmd/helm/testdata/output/get-manifest-no-args.txt b/pkg/helm/pkg/cmd/testdata/output/get-manifest-no-args.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/get-manifest-no-args.txt rename to pkg/helm/pkg/cmd/testdata/output/get-manifest-no-args.txt diff --git a/pkg/helm/cmd/helm/testdata/output/get-manifest.txt b/pkg/helm/pkg/cmd/testdata/output/get-manifest.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/get-manifest.txt rename to pkg/helm/pkg/cmd/testdata/output/get-manifest.txt diff --git a/pkg/helm/cmd/helm/testdata/output/get-metadata-args.txt b/pkg/helm/pkg/cmd/testdata/output/get-metadata-args.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/get-metadata-args.txt rename to pkg/helm/pkg/cmd/testdata/output/get-metadata-args.txt diff --git a/pkg/helm/pkg/cmd/testdata/output/get-metadata.json b/pkg/helm/pkg/cmd/testdata/output/get-metadata.json new file mode 100644 index 00000000..9166f87a --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/get-metadata.json @@ -0,0 +1 @@ +{"name":"thomas-guide","chart":"foo","version":"0.1.0-beta.1","appVersion":"1.0","annotations":{"category":"web-apps","supported":"true"},"labels":{"key1":"value1"},"dependencies":[{"name":"cool-plugin","version":"1.0.0","repository":"https://coolplugin.io/charts","condition":"coolPlugin.enabled","enabled":true},{"name":"crds","version":"2.7.1","repository":"","condition":"crds.enabled"}],"namespace":"default","revision":1,"status":"deployed","deployedAt":"1977-09-02T22:04:05Z"} diff --git a/pkg/helm/cmd/helm/testdata/output/get-metadata.txt b/pkg/helm/pkg/cmd/testdata/output/get-metadata.txt similarity index 51% rename from pkg/helm/cmd/helm/testdata/output/get-metadata.txt rename to pkg/helm/pkg/cmd/testdata/output/get-metadata.txt index b91f1b86..b3cb73ee 100644 --- a/pkg/helm/cmd/helm/testdata/output/get-metadata.txt +++ b/pkg/helm/pkg/cmd/testdata/output/get-metadata.txt @@ -2,7 +2,11 @@ NAME: thomas-guide CHART: foo VERSION: 0.1.0-beta.1 APP_VERSION: 1.0 +ANNOTATIONS: category=web-apps,supported=true +LABELS: key1=value1 +DEPENDENCIES: cool-plugin,crds NAMESPACE: default REVISION: 1 STATUS: deployed DEPLOYED_AT: 1977-09-02T22:04:05Z +APPLY_METHOD: client-side apply (defaulted) diff --git a/pkg/helm/pkg/cmd/testdata/output/get-metadata.yaml b/pkg/helm/pkg/cmd/testdata/output/get-metadata.yaml new file mode 100644 index 00000000..98f56783 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/get-metadata.yaml @@ -0,0 +1,23 @@ +annotations: + category: web-apps + supported: "true" +appVersion: "1.0" +chart: foo +dependencies: +- condition: coolPlugin.enabled + enabled: true + name: cool-plugin + repository: https://coolplugin.io/charts + version: 1.0.0 +- condition: crds.enabled + name: crds + repository: "" + version: 2.7.1 +deployedAt: "1977-09-02T22:04:05Z" +labels: + key1: value1 +name: thomas-guide +namespace: default +revision: 1 +status: deployed +version: 0.1.0-beta.1 diff --git a/pkg/helm/cmd/helm/testdata/output/get-notes-no-args.txt b/pkg/helm/pkg/cmd/testdata/output/get-notes-no-args.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/get-notes-no-args.txt rename to pkg/helm/pkg/cmd/testdata/output/get-notes-no-args.txt diff --git a/pkg/helm/cmd/helm/testdata/output/get-notes.txt b/pkg/helm/pkg/cmd/testdata/output/get-notes.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/get-notes.txt rename to pkg/helm/pkg/cmd/testdata/output/get-notes.txt diff --git a/pkg/helm/cmd/helm/testdata/output/get-release-template.txt b/pkg/helm/pkg/cmd/testdata/output/get-release-template.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/get-release-template.txt rename to pkg/helm/pkg/cmd/testdata/output/get-release-template.txt diff --git a/pkg/helm/cmd/helm/testdata/output/get-release.txt b/pkg/helm/pkg/cmd/testdata/output/get-release.txt similarity index 94% rename from pkg/helm/cmd/helm/testdata/output/get-release.txt rename to pkg/helm/pkg/cmd/testdata/output/get-release.txt index 12b4a407..dbca662c 100644 --- a/pkg/helm/cmd/helm/testdata/output/get-release.txt +++ b/pkg/helm/pkg/cmd/testdata/output/get-release.txt @@ -6,6 +6,7 @@ REVISION: 1 CHART: foo VERSION: 0.1.0-beta.1 APP_VERSION: 1.0 +DESCRIPTION: Release mock TEST SUITE: None USER-SUPPLIED VALUES: name: value diff --git a/pkg/helm/cmd/helm/testdata/output/get-values-all.txt b/pkg/helm/pkg/cmd/testdata/output/get-values-all.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/get-values-all.txt rename to pkg/helm/pkg/cmd/testdata/output/get-values-all.txt diff --git a/pkg/helm/cmd/helm/testdata/output/get-values-args.txt b/pkg/helm/pkg/cmd/testdata/output/get-values-args.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/get-values-args.txt rename to pkg/helm/pkg/cmd/testdata/output/get-values-args.txt diff --git a/pkg/helm/cmd/helm/testdata/output/get-values.txt b/pkg/helm/pkg/cmd/testdata/output/get-values.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/get-values.txt rename to pkg/helm/pkg/cmd/testdata/output/get-values.txt diff --git a/pkg/helm/cmd/helm/testdata/output/history-limit.txt b/pkg/helm/pkg/cmd/testdata/output/history-limit.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/history-limit.txt rename to pkg/helm/pkg/cmd/testdata/output/history-limit.txt diff --git a/pkg/helm/cmd/helm/testdata/output/history.json b/pkg/helm/pkg/cmd/testdata/output/history.json similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/history.json rename to pkg/helm/pkg/cmd/testdata/output/history.json diff --git a/pkg/helm/cmd/helm/testdata/output/history.txt b/pkg/helm/pkg/cmd/testdata/output/history.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/history.txt rename to pkg/helm/pkg/cmd/testdata/output/history.txt diff --git a/pkg/helm/cmd/helm/testdata/output/history.yaml b/pkg/helm/pkg/cmd/testdata/output/history.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/history.yaml rename to pkg/helm/pkg/cmd/testdata/output/history.yaml diff --git a/pkg/helm/cmd/helm/testdata/output/install-and-replace.txt b/pkg/helm/pkg/cmd/testdata/output/install-and-replace.txt similarity index 77% rename from pkg/helm/cmd/helm/testdata/output/install-and-replace.txt rename to pkg/helm/pkg/cmd/testdata/output/install-and-replace.txt index 7452b096..fcf5cc0e 100644 --- a/pkg/helm/cmd/helm/testdata/output/install-and-replace.txt +++ b/pkg/helm/pkg/cmd/testdata/output/install-and-replace.txt @@ -1,8 +1,7 @@ NAME: aeneas LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -LAST PHASE: rollout -LAST STAGE: 0 NAMESPACE: default STATUS: deployed REVISION: 1 +DESCRIPTION: Install complete TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/output/install-and-take-ownership.txt b/pkg/helm/pkg/cmd/testdata/output/install-and-take-ownership.txt new file mode 100644 index 00000000..413329ae --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/install-and-take-ownership.txt @@ -0,0 +1,7 @@ +NAME: aeneas-take-ownership +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 1 +DESCRIPTION: Install complete +TEST SUITE: None diff --git a/pkg/helm/cmd/helm/testdata/output/install-chart-bad-type.txt b/pkg/helm/pkg/cmd/testdata/output/install-chart-bad-type.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/install-chart-bad-type.txt rename to pkg/helm/pkg/cmd/testdata/output/install-chart-bad-type.txt diff --git a/pkg/helm/pkg/cmd/testdata/output/install-dry-run-with-secret-hidden.txt b/pkg/helm/pkg/cmd/testdata/output/install-dry-run-with-secret-hidden.txt new file mode 100644 index 00000000..eb770967 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/install-dry-run-with-secret-hidden.txt @@ -0,0 +1,21 @@ +NAME: secrets +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: pending-install +REVISION: 1 +DESCRIPTION: Dry run complete +TEST SUITE: None +HOOKS: +MANIFEST: +--- +# Source: chart-with-secret/templates/secret.yaml +# HIDDEN: The Secret output has been suppressed +--- +# Source: chart-with-secret/templates/configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-configmap +data: + foo: bar + diff --git a/pkg/helm/pkg/cmd/testdata/output/install-dry-run-with-secret.txt b/pkg/helm/pkg/cmd/testdata/output/install-dry-run-with-secret.txt new file mode 100644 index 00000000..d22c1437 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/install-dry-run-with-secret.txt @@ -0,0 +1,26 @@ +NAME: secrets +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: pending-install +REVISION: 1 +DESCRIPTION: Dry run complete +TEST SUITE: None +HOOKS: +MANIFEST: +--- +# Source: chart-with-secret/templates/secret.yaml +apiVersion: v1 +kind: Secret +metadata: + name: test-secret +stringData: + foo: bar +--- +# Source: chart-with-secret/templates/configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-configmap +data: + foo: bar + diff --git a/pkg/helm/pkg/cmd/testdata/output/install-hide-secret.txt b/pkg/helm/pkg/cmd/testdata/output/install-hide-secret.txt new file mode 100644 index 00000000..165f14f7 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/install-hide-secret.txt @@ -0,0 +1 @@ +Error: INSTALLATION FAILED: hiding Kubernetes secrets requires a dry-run mode diff --git a/pkg/helm/cmd/helm/testdata/output/install-lib-chart.txt b/pkg/helm/pkg/cmd/testdata/output/install-lib-chart.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/install-lib-chart.txt rename to pkg/helm/pkg/cmd/testdata/output/install-lib-chart.txt diff --git a/pkg/helm/cmd/helm/testdata/output/install-name-template.txt b/pkg/helm/pkg/cmd/testdata/output/install-name-template.txt similarity index 77% rename from pkg/helm/cmd/helm/testdata/output/install-name-template.txt rename to pkg/helm/pkg/cmd/testdata/output/install-name-template.txt index d5e20ba8..bcc5f87b 100644 --- a/pkg/helm/cmd/helm/testdata/output/install-name-template.txt +++ b/pkg/helm/pkg/cmd/testdata/output/install-name-template.txt @@ -1,8 +1,7 @@ NAME: foobar LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -LAST PHASE: rollout -LAST STAGE: 0 NAMESPACE: default STATUS: deployed REVISION: 1 +DESCRIPTION: Install complete TEST SUITE: None diff --git a/pkg/helm/cmd/helm/testdata/output/install-no-args.txt b/pkg/helm/pkg/cmd/testdata/output/install-no-args.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/install-no-args.txt rename to pkg/helm/pkg/cmd/testdata/output/install-no-args.txt diff --git a/pkg/helm/cmd/helm/testdata/output/install-no-hooks.txt b/pkg/helm/pkg/cmd/testdata/output/install-no-hooks.txt similarity index 77% rename from pkg/helm/cmd/helm/testdata/output/install-no-hooks.txt rename to pkg/helm/pkg/cmd/testdata/output/install-no-hooks.txt index 7452b096..fcf5cc0e 100644 --- a/pkg/helm/cmd/helm/testdata/output/install-no-hooks.txt +++ b/pkg/helm/pkg/cmd/testdata/output/install-no-hooks.txt @@ -1,8 +1,7 @@ NAME: aeneas LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -LAST PHASE: rollout -LAST STAGE: 0 NAMESPACE: default STATUS: deployed REVISION: 1 +DESCRIPTION: Install complete TEST SUITE: None diff --git a/pkg/helm/cmd/helm/testdata/output/install-with-multiple-values-files.txt b/pkg/helm/pkg/cmd/testdata/output/install-with-multiple-values-files.txt similarity index 77% rename from pkg/helm/cmd/helm/testdata/output/install-with-multiple-values-files.txt rename to pkg/helm/pkg/cmd/testdata/output/install-with-multiple-values-files.txt index c8e7b04d..1116cb90 100644 --- a/pkg/helm/cmd/helm/testdata/output/install-with-multiple-values-files.txt +++ b/pkg/helm/pkg/cmd/testdata/output/install-with-multiple-values-files.txt @@ -1,8 +1,7 @@ NAME: virgil LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -LAST PHASE: rollout -LAST STAGE: 0 NAMESPACE: default STATUS: deployed REVISION: 1 +DESCRIPTION: Install complete TEST SUITE: None diff --git a/pkg/helm/cmd/helm/testdata/output/install-with-multiple-values.txt b/pkg/helm/pkg/cmd/testdata/output/install-with-multiple-values.txt similarity index 77% rename from pkg/helm/cmd/helm/testdata/output/install-with-multiple-values.txt rename to pkg/helm/pkg/cmd/testdata/output/install-with-multiple-values.txt index c8e7b04d..1116cb90 100644 --- a/pkg/helm/cmd/helm/testdata/output/install-with-multiple-values.txt +++ b/pkg/helm/pkg/cmd/testdata/output/install-with-multiple-values.txt @@ -1,8 +1,7 @@ NAME: virgil LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -LAST PHASE: rollout -LAST STAGE: 0 NAMESPACE: default STATUS: deployed REVISION: 1 +DESCRIPTION: Install complete TEST SUITE: None diff --git a/pkg/helm/cmd/helm/testdata/output/install-with-timeout.txt b/pkg/helm/pkg/cmd/testdata/output/install-with-timeout.txt similarity index 77% rename from pkg/helm/cmd/helm/testdata/output/install-with-timeout.txt rename to pkg/helm/pkg/cmd/testdata/output/install-with-timeout.txt index d5e20ba8..bcc5f87b 100644 --- a/pkg/helm/cmd/helm/testdata/output/install-with-timeout.txt +++ b/pkg/helm/pkg/cmd/testdata/output/install-with-timeout.txt @@ -1,8 +1,7 @@ NAME: foobar LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -LAST PHASE: rollout -LAST STAGE: 0 NAMESPACE: default STATUS: deployed REVISION: 1 +DESCRIPTION: Install complete TEST SUITE: None diff --git a/pkg/helm/cmd/helm/testdata/output/install-with-values-file.txt b/pkg/helm/pkg/cmd/testdata/output/install-with-values-file.txt similarity index 77% rename from pkg/helm/cmd/helm/testdata/output/install-with-values-file.txt rename to pkg/helm/pkg/cmd/testdata/output/install-with-values-file.txt index c8e7b04d..1116cb90 100644 --- a/pkg/helm/cmd/helm/testdata/output/install-with-values-file.txt +++ b/pkg/helm/pkg/cmd/testdata/output/install-with-values-file.txt @@ -1,8 +1,7 @@ NAME: virgil LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -LAST PHASE: rollout -LAST STAGE: 0 NAMESPACE: default STATUS: deployed REVISION: 1 +DESCRIPTION: Install complete TEST SUITE: None diff --git a/pkg/helm/cmd/helm/testdata/output/install-with-values.txt b/pkg/helm/pkg/cmd/testdata/output/install-with-values.txt similarity index 77% rename from pkg/helm/cmd/helm/testdata/output/install-with-values.txt rename to pkg/helm/pkg/cmd/testdata/output/install-with-values.txt index c8e7b04d..1116cb90 100644 --- a/pkg/helm/cmd/helm/testdata/output/install-with-values.txt +++ b/pkg/helm/pkg/cmd/testdata/output/install-with-values.txt @@ -1,8 +1,7 @@ NAME: virgil LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -LAST PHASE: rollout -LAST STAGE: 0 NAMESPACE: default STATUS: deployed REVISION: 1 +DESCRIPTION: Install complete TEST SUITE: None diff --git a/pkg/helm/cmd/helm/testdata/output/install-with-wait-for-jobs.txt b/pkg/helm/pkg/cmd/testdata/output/install-with-wait-for-jobs.txt similarity index 77% rename from pkg/helm/cmd/helm/testdata/output/install-with-wait-for-jobs.txt rename to pkg/helm/pkg/cmd/testdata/output/install-with-wait-for-jobs.txt index 6e93294d..c5676c61 100644 --- a/pkg/helm/cmd/helm/testdata/output/install-with-wait-for-jobs.txt +++ b/pkg/helm/pkg/cmd/testdata/output/install-with-wait-for-jobs.txt @@ -1,8 +1,7 @@ NAME: apollo LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -LAST PHASE: rollout -LAST STAGE: 0 NAMESPACE: default STATUS: deployed REVISION: 1 +DESCRIPTION: Install complete TEST SUITE: None diff --git a/pkg/helm/cmd/helm/testdata/output/install-with-wait.txt b/pkg/helm/pkg/cmd/testdata/output/install-with-wait.txt similarity index 77% rename from pkg/helm/cmd/helm/testdata/output/install-with-wait.txt rename to pkg/helm/pkg/cmd/testdata/output/install-with-wait.txt index 6e93294d..c5676c61 100644 --- a/pkg/helm/cmd/helm/testdata/output/install-with-wait.txt +++ b/pkg/helm/pkg/cmd/testdata/output/install-with-wait.txt @@ -1,8 +1,7 @@ NAME: apollo LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -LAST PHASE: rollout -LAST STAGE: 0 NAMESPACE: default STATUS: deployed REVISION: 1 +DESCRIPTION: Install complete TEST SUITE: None diff --git a/pkg/helm/cmd/helm/testdata/output/install.txt b/pkg/helm/pkg/cmd/testdata/output/install.txt similarity index 77% rename from pkg/helm/cmd/helm/testdata/output/install.txt rename to pkg/helm/pkg/cmd/testdata/output/install.txt index 7452b096..fcf5cc0e 100644 --- a/pkg/helm/cmd/helm/testdata/output/install.txt +++ b/pkg/helm/pkg/cmd/testdata/output/install.txt @@ -1,8 +1,7 @@ NAME: aeneas LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -LAST PHASE: rollout -LAST STAGE: 0 NAMESPACE: default STATUS: deployed REVISION: 1 +DESCRIPTION: Install complete TEST SUITE: None diff --git a/pkg/helm/cmd/helm/testdata/output/issue-9027.txt b/pkg/helm/pkg/cmd/testdata/output/issue-9027.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/issue-9027.txt rename to pkg/helm/pkg/cmd/testdata/output/issue-9027.txt diff --git a/pkg/helm/cmd/helm/testdata/output/lint-chart-with-bad-subcharts-with-subcharts.txt b/pkg/helm/pkg/cmd/testdata/output/lint-chart-with-bad-subcharts-with-subcharts.txt similarity index 67% rename from pkg/helm/cmd/helm/testdata/output/lint-chart-with-bad-subcharts-with-subcharts.txt rename to pkg/helm/pkg/cmd/testdata/output/lint-chart-with-bad-subcharts-with-subcharts.txt index d43c7c36..67ed58ec 100644 --- a/pkg/helm/cmd/helm/testdata/output/lint-chart-with-bad-subcharts-with-subcharts.txt +++ b/pkg/helm/pkg/cmd/testdata/output/lint-chart-with-bad-subcharts-with-subcharts.txt @@ -1,19 +1,21 @@ ==> Linting testdata/testcharts/chart-with-bad-subcharts [INFO] Chart.yaml: icon is recommended -[ERROR] templates/: error unpacking bad-subchart in chart-with-bad-subcharts: validation: chart.metadata.name is required +[WARNING] templates/: directory does not exist [ERROR] : unable to load chart - error unpacking bad-subchart in chart-with-bad-subcharts: validation: chart.metadata.name is required + error unpacking subchart bad-subchart in chart-with-bad-subcharts: validation: chart.metadata.name is required ==> Linting testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart [ERROR] Chart.yaml: name is required [ERROR] Chart.yaml: apiVersion is required. The value must be either "v1" or "v2" [ERROR] Chart.yaml: version is required [INFO] Chart.yaml: icon is recommended -[ERROR] templates/: validation: chart.metadata.name is required +[WARNING] Chart.yaml: version '' is not a valid SemVerV2 +[WARNING] templates/: directory does not exist [ERROR] : unable to load chart validation: chart.metadata.name is required ==> Linting testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart [INFO] Chart.yaml: icon is recommended +[WARNING] templates/: directory does not exist Error: 3 chart(s) linted, 2 chart(s) failed diff --git a/pkg/helm/pkg/cmd/testdata/output/lint-chart-with-bad-subcharts.txt b/pkg/helm/pkg/cmd/testdata/output/lint-chart-with-bad-subcharts.txt new file mode 100644 index 00000000..5a1c388b --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/lint-chart-with-bad-subcharts.txt @@ -0,0 +1,7 @@ +==> Linting testdata/testcharts/chart-with-bad-subcharts +[INFO] Chart.yaml: icon is recommended +[WARNING] templates/: directory does not exist +[ERROR] : unable to load chart + error unpacking subchart bad-subchart in chart-with-bad-subcharts: validation: chart.metadata.name is required + +Error: 1 chart(s) linted, 1 chart(s) failed diff --git a/pkg/helm/cmd/helm/testdata/output/lint-chart-with-deprecated-api-old-k8s.txt b/pkg/helm/pkg/cmd/testdata/output/lint-chart-with-deprecated-api-old-k8s.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/lint-chart-with-deprecated-api-old-k8s.txt rename to pkg/helm/pkg/cmd/testdata/output/lint-chart-with-deprecated-api-old-k8s.txt diff --git a/pkg/helm/cmd/helm/testdata/output/lint-chart-with-deprecated-api-strict.txt b/pkg/helm/pkg/cmd/testdata/output/lint-chart-with-deprecated-api-strict.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/lint-chart-with-deprecated-api-strict.txt rename to pkg/helm/pkg/cmd/testdata/output/lint-chart-with-deprecated-api-strict.txt diff --git a/pkg/helm/cmd/helm/testdata/output/lint-chart-with-deprecated-api.txt b/pkg/helm/pkg/cmd/testdata/output/lint-chart-with-deprecated-api.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/lint-chart-with-deprecated-api.txt rename to pkg/helm/pkg/cmd/testdata/output/lint-chart-with-deprecated-api.txt diff --git a/pkg/helm/cmd/helm/testdata/output/lint-quiet-with-error.txt b/pkg/helm/pkg/cmd/testdata/output/lint-quiet-with-error.txt similarity index 74% rename from pkg/helm/cmd/helm/testdata/output/lint-quiet-with-error.txt rename to pkg/helm/pkg/cmd/testdata/output/lint-quiet-with-error.txt index e3d29a5a..0731a07d 100644 --- a/pkg/helm/cmd/helm/testdata/output/lint-quiet-with-error.txt +++ b/pkg/helm/pkg/cmd/testdata/output/lint-quiet-with-error.txt @@ -1,7 +1,7 @@ ==> Linting testdata/testcharts/chart-bad-requirements [ERROR] Chart.yaml: unable to parse YAML error converting YAML to JSON: yaml: line 6: did not find expected '-' indicator -[ERROR] templates/: cannot load Chart.yaml: error converting YAML to JSON: yaml: line 6: did not find expected '-' indicator +[WARNING] templates/: directory does not exist [ERROR] : unable to load chart cannot load Chart.yaml: error converting YAML to JSON: yaml: line 6: did not find expected '-' indicator diff --git a/pkg/helm/pkg/cmd/testdata/output/lint-quiet-with-warning.txt b/pkg/helm/pkg/cmd/testdata/output/lint-quiet-with-warning.txt new file mode 100644 index 00000000..ebf6c198 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/lint-quiet-with-warning.txt @@ -0,0 +1,4 @@ +==> Linting testdata/testcharts/chart-with-only-crds +[WARNING] templates/: directory does not exist + +1 chart(s) linted, 0 chart(s) failed diff --git a/pkg/helm/pkg/cmd/testdata/output/lint-quiet.txt b/pkg/helm/pkg/cmd/testdata/output/lint-quiet.txt new file mode 100644 index 00000000..e69de29b diff --git a/pkg/helm/pkg/cmd/testdata/output/list-all-date-reversed.txt b/pkg/helm/pkg/cmd/testdata/output/list-all-date-reversed.txt new file mode 100644 index 00000000..d185334a --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/list-all-date-reversed.txt @@ -0,0 +1,9 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 +hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 +rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 +thanos default 1 2016-01-16 00:00:01 +0000 UTC pending-install chickadee-1.0.0 0.0.1 +starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 +groot default 1 2016-01-16 00:00:01 +0000 UTC uninstalled chickadee-1.0.0 0.0.1 +gamora default 1 2016-01-16 00:00:01 +0000 UTC superseded chickadee-1.0.0 0.0.1 +drax default 1 2016-01-16 00:00:01 +0000 UTC uninstalling chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-all-date.txt b/pkg/helm/pkg/cmd/testdata/output/list-all-date.txt new file mode 100644 index 00000000..5e5f9efe --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/list-all-date.txt @@ -0,0 +1,9 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +drax default 1 2016-01-16 00:00:01 +0000 UTC uninstalling chickadee-1.0.0 0.0.1 +gamora default 1 2016-01-16 00:00:01 +0000 UTC superseded chickadee-1.0.0 0.0.1 +groot default 1 2016-01-16 00:00:01 +0000 UTC uninstalled chickadee-1.0.0 0.0.1 +starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 +thanos default 1 2016-01-16 00:00:01 +0000 UTC pending-install chickadee-1.0.0 0.0.1 +rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 +hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 +iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/cmd/helm/testdata/output/list-uninstalling.txt b/pkg/helm/pkg/cmd/testdata/output/list-all-max.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/list-uninstalling.txt rename to pkg/helm/pkg/cmd/testdata/output/list-all-max.txt diff --git a/pkg/helm/pkg/cmd/testdata/output/list-all-no-headers.txt b/pkg/helm/pkg/cmd/testdata/output/list-all-no-headers.txt new file mode 100644 index 00000000..33581d8c --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/list-all-no-headers.txt @@ -0,0 +1,8 @@ +drax default 1 2016-01-16 00:00:01 +0000 UTC uninstalling chickadee-1.0.0 0.0.1 +gamora default 1 2016-01-16 00:00:01 +0000 UTC superseded chickadee-1.0.0 0.0.1 +groot default 1 2016-01-16 00:00:01 +0000 UTC uninstalled chickadee-1.0.0 0.0.1 +hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 +iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 +rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 +starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 +thanos default 1 2016-01-16 00:00:01 +0000 UTC pending-install chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-all-offset.txt b/pkg/helm/pkg/cmd/testdata/output/list-all-offset.txt new file mode 100644 index 00000000..e17fd7b0 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/list-all-offset.txt @@ -0,0 +1,8 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +gamora default 1 2016-01-16 00:00:01 +0000 UTC superseded chickadee-1.0.0 0.0.1 +groot default 1 2016-01-16 00:00:01 +0000 UTC uninstalled chickadee-1.0.0 0.0.1 +hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 +iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 +rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 +starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 +thanos default 1 2016-01-16 00:00:01 +0000 UTC pending-install chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-all-reverse.txt b/pkg/helm/pkg/cmd/testdata/output/list-all-reverse.txt new file mode 100644 index 00000000..31bb3de9 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/list-all-reverse.txt @@ -0,0 +1,9 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +thanos default 1 2016-01-16 00:00:01 +0000 UTC pending-install chickadee-1.0.0 0.0.1 +starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 +rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 +iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 +hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 +groot default 1 2016-01-16 00:00:01 +0000 UTC uninstalled chickadee-1.0.0 0.0.1 +gamora default 1 2016-01-16 00:00:01 +0000 UTC superseded chickadee-1.0.0 0.0.1 +drax default 1 2016-01-16 00:00:01 +0000 UTC uninstalling chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-all-short-json.txt b/pkg/helm/pkg/cmd/testdata/output/list-all-short-json.txt new file mode 100644 index 00000000..6dac52c4 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/list-all-short-json.txt @@ -0,0 +1 @@ +["drax","gamora","groot","hummingbird","iguana","rocket","starlord","thanos"] diff --git a/pkg/helm/pkg/cmd/testdata/output/list-all-short-yaml.txt b/pkg/helm/pkg/cmd/testdata/output/list-all-short-yaml.txt new file mode 100644 index 00000000..2ae0e88a --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/list-all-short-yaml.txt @@ -0,0 +1,8 @@ +- drax +- gamora +- groot +- hummingbird +- iguana +- rocket +- starlord +- thanos diff --git a/pkg/helm/pkg/cmd/testdata/output/list-all-short.txt b/pkg/helm/pkg/cmd/testdata/output/list-all-short.txt new file mode 100644 index 00000000..52871d8b --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/list-all-short.txt @@ -0,0 +1,8 @@ +drax +gamora +groot +hummingbird +iguana +rocket +starlord +thanos diff --git a/pkg/helm/cmd/helm/testdata/output/list-all.txt b/pkg/helm/pkg/cmd/testdata/output/list-all.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/list-all.txt rename to pkg/helm/pkg/cmd/testdata/output/list-all.txt diff --git a/pkg/helm/cmd/helm/testdata/output/list-date-reversed.txt b/pkg/helm/pkg/cmd/testdata/output/list-date-reversed.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/list-date-reversed.txt rename to pkg/helm/pkg/cmd/testdata/output/list-date-reversed.txt diff --git a/pkg/helm/cmd/helm/testdata/output/list-date.txt b/pkg/helm/pkg/cmd/testdata/output/list-date.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/list-date.txt rename to pkg/helm/pkg/cmd/testdata/output/list-date.txt diff --git a/pkg/helm/cmd/helm/testdata/output/list-failed.txt b/pkg/helm/pkg/cmd/testdata/output/list-failed.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/list-failed.txt rename to pkg/helm/pkg/cmd/testdata/output/list-failed.txt diff --git a/pkg/helm/cmd/helm/testdata/output/list-filter.txt b/pkg/helm/pkg/cmd/testdata/output/list-filter.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/list-filter.txt rename to pkg/helm/pkg/cmd/testdata/output/list-filter.txt diff --git a/pkg/helm/pkg/cmd/testdata/output/list-json.txt b/pkg/helm/pkg/cmd/testdata/output/list-json.txt new file mode 100644 index 00000000..89e4d9dc --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/list-json.txt @@ -0,0 +1 @@ +[{"name":"test-release","namespace":"default","revision":"1","updated":"2016-01-16 00:00:00 +0000 UTC","status":"deployed","chart":"test-chart-1.0.0","app_version":"0.0.1"}] diff --git a/pkg/helm/cmd/helm/testdata/output/list-max.txt b/pkg/helm/pkg/cmd/testdata/output/list-max.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/list-max.txt rename to pkg/helm/pkg/cmd/testdata/output/list-max.txt diff --git a/pkg/helm/cmd/helm/testdata/output/list-namespace.txt b/pkg/helm/pkg/cmd/testdata/output/list-namespace.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/list-namespace.txt rename to pkg/helm/pkg/cmd/testdata/output/list-namespace.txt diff --git a/pkg/helm/cmd/helm/testdata/output/list-no-headers.txt b/pkg/helm/pkg/cmd/testdata/output/list-no-headers.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/list-no-headers.txt rename to pkg/helm/pkg/cmd/testdata/output/list-no-headers.txt diff --git a/pkg/helm/cmd/helm/testdata/output/list-offset.txt b/pkg/helm/pkg/cmd/testdata/output/list-offset.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/list-offset.txt rename to pkg/helm/pkg/cmd/testdata/output/list-offset.txt diff --git a/pkg/helm/cmd/helm/testdata/output/list-pending.txt b/pkg/helm/pkg/cmd/testdata/output/list-pending.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/list-pending.txt rename to pkg/helm/pkg/cmd/testdata/output/list-pending.txt diff --git a/pkg/helm/cmd/helm/testdata/output/list-reverse.txt b/pkg/helm/pkg/cmd/testdata/output/list-reverse.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/list-reverse.txt rename to pkg/helm/pkg/cmd/testdata/output/list-reverse.txt diff --git a/pkg/helm/cmd/helm/testdata/output/list-short-json.txt b/pkg/helm/pkg/cmd/testdata/output/list-short-json.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/list-short-json.txt rename to pkg/helm/pkg/cmd/testdata/output/list-short-json.txt diff --git a/pkg/helm/cmd/helm/testdata/output/list-short-yaml.txt b/pkg/helm/pkg/cmd/testdata/output/list-short-yaml.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/list-short-yaml.txt rename to pkg/helm/pkg/cmd/testdata/output/list-short-yaml.txt diff --git a/pkg/helm/cmd/helm/testdata/output/list-short.txt b/pkg/helm/pkg/cmd/testdata/output/list-short.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/list-short.txt rename to pkg/helm/pkg/cmd/testdata/output/list-short.txt diff --git a/pkg/helm/cmd/helm/testdata/output/list-superseded.txt b/pkg/helm/pkg/cmd/testdata/output/list-superseded.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/list-superseded.txt rename to pkg/helm/pkg/cmd/testdata/output/list-superseded.txt diff --git a/pkg/helm/pkg/cmd/testdata/output/list-time-format.txt b/pkg/helm/pkg/cmd/testdata/output/list-time-format.txt new file mode 100644 index 00000000..4d493da7 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/list-time-format.txt @@ -0,0 +1,2 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +test-release default 1 2016-01-16 00:00:00 deployed test-chart-1.0.0 0.0.1 diff --git a/pkg/helm/cmd/helm/testdata/output/list-uninstalled.txt b/pkg/helm/pkg/cmd/testdata/output/list-uninstalled.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/list-uninstalled.txt rename to pkg/helm/pkg/cmd/testdata/output/list-uninstalled.txt diff --git a/pkg/helm/pkg/cmd/testdata/output/list-uninstalling.txt b/pkg/helm/pkg/cmd/testdata/output/list-uninstalling.txt new file mode 100644 index 00000000..92289639 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/list-uninstalling.txt @@ -0,0 +1,2 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +drax default 1 2016-01-16 00:00:01 +0000 UTC uninstalling chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-yaml.txt b/pkg/helm/pkg/cmd/testdata/output/list-yaml.txt new file mode 100644 index 00000000..9e1d41f3 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/list-yaml.txt @@ -0,0 +1,7 @@ +- app_version: 0.0.1 + chart: test-chart-1.0.0 + name: test-release + namespace: default + revision: "1" + status: deployed + updated: 2016-01-16 00:00:00 +0000 UTC diff --git a/pkg/helm/cmd/helm/testdata/output/list.txt b/pkg/helm/pkg/cmd/testdata/output/list.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/list.txt rename to pkg/helm/pkg/cmd/testdata/output/list.txt diff --git a/pkg/helm/cmd/helm/testdata/output/object-order.txt b/pkg/helm/pkg/cmd/testdata/output/object-order.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/object-order.txt rename to pkg/helm/pkg/cmd/testdata/output/object-order.txt diff --git a/pkg/helm/cmd/helm/testdata/output/output-comp.txt b/pkg/helm/pkg/cmd/testdata/output/output-comp.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/output-comp.txt rename to pkg/helm/pkg/cmd/testdata/output/output-comp.txt diff --git a/pkg/helm/cmd/helm/testdata/output/plugin_args_comp.txt b/pkg/helm/pkg/cmd/testdata/output/plugin_args_comp.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/plugin_args_comp.txt rename to pkg/helm/pkg/cmd/testdata/output/plugin_args_comp.txt diff --git a/pkg/helm/cmd/helm/testdata/output/plugin_args_flag_comp.txt b/pkg/helm/pkg/cmd/testdata/output/plugin_args_flag_comp.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/plugin_args_flag_comp.txt rename to pkg/helm/pkg/cmd/testdata/output/plugin_args_flag_comp.txt diff --git a/pkg/helm/cmd/helm/testdata/output/plugin_args_many_args_comp.txt b/pkg/helm/pkg/cmd/testdata/output/plugin_args_many_args_comp.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/plugin_args_many_args_comp.txt rename to pkg/helm/pkg/cmd/testdata/output/plugin_args_many_args_comp.txt diff --git a/pkg/helm/cmd/helm/testdata/output/plugin_args_ns_comp.txt b/pkg/helm/pkg/cmd/testdata/output/plugin_args_ns_comp.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/plugin_args_ns_comp.txt rename to pkg/helm/pkg/cmd/testdata/output/plugin_args_ns_comp.txt diff --git a/pkg/helm/cmd/helm/testdata/output/plugin_echo_no_directive.txt b/pkg/helm/pkg/cmd/testdata/output/plugin_echo_no_directive.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/plugin_echo_no_directive.txt rename to pkg/helm/pkg/cmd/testdata/output/plugin_echo_no_directive.txt diff --git a/pkg/helm/cmd/helm/testdata/output/plugin_list_comp.txt b/pkg/helm/pkg/cmd/testdata/output/plugin_list_comp.txt similarity index 88% rename from pkg/helm/cmd/helm/testdata/output/plugin_list_comp.txt rename to pkg/helm/pkg/cmd/testdata/output/plugin_list_comp.txt index 833efc5e..1dff4355 100644 --- a/pkg/helm/cmd/helm/testdata/output/plugin_list_comp.txt +++ b/pkg/helm/pkg/cmd/testdata/output/plugin_list_comp.txt @@ -1,7 +1,7 @@ args echo args echo echo stuff -env env stuff exitwith exitwith code fullenv show env vars +shortenv env stuff :4 Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/pkg/helm/cmd/helm/testdata/output/plugin_repeat_comp.txt b/pkg/helm/pkg/cmd/testdata/output/plugin_repeat_comp.txt similarity index 86% rename from pkg/helm/cmd/helm/testdata/output/plugin_repeat_comp.txt rename to pkg/helm/pkg/cmd/testdata/output/plugin_repeat_comp.txt index 3fa05f0b..b46c1b7d 100644 --- a/pkg/helm/cmd/helm/testdata/output/plugin_repeat_comp.txt +++ b/pkg/helm/pkg/cmd/testdata/output/plugin_repeat_comp.txt @@ -1,6 +1,6 @@ echo echo stuff -env env stuff exitwith exitwith code fullenv show env vars +shortenv env stuff :4 Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/pkg/helm/cmd/helm/testdata/output/release_list_comp.txt b/pkg/helm/pkg/cmd/testdata/output/release_list_comp.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/release_list_comp.txt rename to pkg/helm/pkg/cmd/testdata/output/release_list_comp.txt diff --git a/pkg/helm/cmd/helm/testdata/output/release_list_repeat_comp.txt b/pkg/helm/pkg/cmd/testdata/output/release_list_repeat_comp.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/release_list_repeat_comp.txt rename to pkg/helm/pkg/cmd/testdata/output/release_list_repeat_comp.txt diff --git a/pkg/helm/cmd/helm/testdata/output/repo-add.txt b/pkg/helm/pkg/cmd/testdata/output/repo-add.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/repo-add.txt rename to pkg/helm/pkg/cmd/testdata/output/repo-add.txt diff --git a/pkg/helm/cmd/helm/testdata/output/repo-add2.txt b/pkg/helm/pkg/cmd/testdata/output/repo-add2.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/repo-add2.txt rename to pkg/helm/pkg/cmd/testdata/output/repo-add2.txt diff --git a/pkg/helm/pkg/cmd/testdata/output/repo-list-empty.txt b/pkg/helm/pkg/cmd/testdata/output/repo-list-empty.txt new file mode 100644 index 00000000..c6edb659 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/repo-list-empty.txt @@ -0,0 +1 @@ +no repositories to show diff --git a/pkg/helm/pkg/cmd/testdata/output/repo-list-no-headers.txt b/pkg/helm/pkg/cmd/testdata/output/repo-list-no-headers.txt new file mode 100644 index 00000000..13491aeb --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/repo-list-no-headers.txt @@ -0,0 +1,3 @@ +charts https://charts.helm.sh/stable +firstexample http://firstexample.com +secondexample http://secondexample.com diff --git a/pkg/helm/pkg/cmd/testdata/output/repo-list.txt b/pkg/helm/pkg/cmd/testdata/output/repo-list.txt new file mode 100644 index 00000000..edbd0ecc --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/repo-list.txt @@ -0,0 +1,4 @@ +NAME URL +charts https://charts.helm.sh/stable +firstexample http://firstexample.com +secondexample http://secondexample.com diff --git a/pkg/helm/cmd/helm/testdata/output/repo_list_comp.txt b/pkg/helm/pkg/cmd/testdata/output/repo_list_comp.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/repo_list_comp.txt rename to pkg/helm/pkg/cmd/testdata/output/repo_list_comp.txt diff --git a/pkg/helm/cmd/helm/testdata/output/repo_repeat_comp.txt b/pkg/helm/pkg/cmd/testdata/output/repo_repeat_comp.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/repo_repeat_comp.txt rename to pkg/helm/pkg/cmd/testdata/output/repo_repeat_comp.txt diff --git a/pkg/helm/cmd/helm/testdata/output/revision-comp.txt b/pkg/helm/pkg/cmd/testdata/output/revision-comp.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/revision-comp.txt rename to pkg/helm/pkg/cmd/testdata/output/revision-comp.txt diff --git a/pkg/helm/cmd/helm/testdata/output/revision-wrong-args-comp.txt b/pkg/helm/pkg/cmd/testdata/output/revision-wrong-args-comp.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/revision-wrong-args-comp.txt rename to pkg/helm/pkg/cmd/testdata/output/revision-wrong-args-comp.txt diff --git a/pkg/helm/cmd/helm/testdata/output/rollback-comp.txt b/pkg/helm/pkg/cmd/testdata/output/rollback-comp.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/rollback-comp.txt rename to pkg/helm/pkg/cmd/testdata/output/rollback-comp.txt diff --git a/pkg/helm/cmd/helm/testdata/output/rollback-no-args.txt b/pkg/helm/pkg/cmd/testdata/output/rollback-no-args.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/rollback-no-args.txt rename to pkg/helm/pkg/cmd/testdata/output/rollback-no-args.txt diff --git a/pkg/helm/cmd/helm/testdata/output/rollback-no-revision.txt b/pkg/helm/pkg/cmd/testdata/output/rollback-no-revision.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/rollback-no-revision.txt rename to pkg/helm/pkg/cmd/testdata/output/rollback-no-revision.txt diff --git a/pkg/helm/cmd/helm/testdata/output/rollback-non-existent-version.txt b/pkg/helm/pkg/cmd/testdata/output/rollback-non-existent-version.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/rollback-non-existent-version.txt rename to pkg/helm/pkg/cmd/testdata/output/rollback-non-existent-version.txt diff --git a/pkg/helm/cmd/helm/testdata/output/rollback-timeout.txt b/pkg/helm/pkg/cmd/testdata/output/rollback-timeout.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/rollback-timeout.txt rename to pkg/helm/pkg/cmd/testdata/output/rollback-timeout.txt diff --git a/pkg/helm/cmd/helm/testdata/output/rollback-wait-for-jobs.txt b/pkg/helm/pkg/cmd/testdata/output/rollback-wait-for-jobs.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/rollback-wait-for-jobs.txt rename to pkg/helm/pkg/cmd/testdata/output/rollback-wait-for-jobs.txt diff --git a/pkg/helm/cmd/helm/testdata/output/rollback-wait.txt b/pkg/helm/pkg/cmd/testdata/output/rollback-wait.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/rollback-wait.txt rename to pkg/helm/pkg/cmd/testdata/output/rollback-wait.txt diff --git a/pkg/helm/pkg/cmd/testdata/output/rollback-wrong-args-comp.txt b/pkg/helm/pkg/cmd/testdata/output/rollback-wrong-args-comp.txt new file mode 100644 index 00000000..3c537283 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/rollback-wrong-args-comp.txt @@ -0,0 +1,3 @@ +_activeHelp_ This command does not take any more arguments (but may accept flags). +:4 +Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/pkg/helm/cmd/helm/testdata/output/rollback.txt b/pkg/helm/pkg/cmd/testdata/output/rollback.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/rollback.txt rename to pkg/helm/pkg/cmd/testdata/output/rollback.txt diff --git a/pkg/helm/cmd/helm/testdata/output/schema-negative-cli.txt b/pkg/helm/pkg/cmd/testdata/output/schema-negative-cli.txt similarity index 73% rename from pkg/helm/cmd/helm/testdata/output/schema-negative-cli.txt rename to pkg/helm/pkg/cmd/testdata/output/schema-negative-cli.txt index c4a5cc51..12bcc510 100644 --- a/pkg/helm/cmd/helm/testdata/output/schema-negative-cli.txt +++ b/pkg/helm/pkg/cmd/testdata/output/schema-negative-cli.txt @@ -1,4 +1,4 @@ Error: INSTALLATION FAILED: values don't meet the specifications of the schema(s) in the following chart(s): empty: -- age: Must be greater than or equal to 0 +- at '/age': minimum: got -5, want 0 diff --git a/pkg/helm/cmd/helm/testdata/output/schema-negative.txt b/pkg/helm/pkg/cmd/testdata/output/schema-negative.txt similarity index 59% rename from pkg/helm/cmd/helm/testdata/output/schema-negative.txt rename to pkg/helm/pkg/cmd/testdata/output/schema-negative.txt index 929af551..daf13263 100644 --- a/pkg/helm/cmd/helm/testdata/output/schema-negative.txt +++ b/pkg/helm/pkg/cmd/testdata/output/schema-negative.txt @@ -1,5 +1,5 @@ Error: INSTALLATION FAILED: values don't meet the specifications of the schema(s) in the following chart(s): empty: -- (root): employmentInfo is required -- age: Must be greater than or equal to 0 +- at '': missing property 'employmentInfo' +- at '/age': minimum: got -5, want 0 diff --git a/pkg/helm/cmd/helm/testdata/output/schema.txt b/pkg/helm/pkg/cmd/testdata/output/schema.txt similarity index 77% rename from pkg/helm/cmd/helm/testdata/output/schema.txt rename to pkg/helm/pkg/cmd/testdata/output/schema.txt index 60e4d026..f5fe6376 100644 --- a/pkg/helm/cmd/helm/testdata/output/schema.txt +++ b/pkg/helm/pkg/cmd/testdata/output/schema.txt @@ -1,8 +1,7 @@ NAME: schema LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -LAST PHASE: rollout -LAST STAGE: 0 NAMESPACE: default STATUS: deployed REVISION: 1 +DESCRIPTION: Install complete TEST SUITE: None diff --git a/pkg/helm/cmd/helm/testdata/output/search-constraint-single.txt b/pkg/helm/pkg/cmd/testdata/output/search-constraint-single.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/search-constraint-single.txt rename to pkg/helm/pkg/cmd/testdata/output/search-constraint-single.txt diff --git a/pkg/helm/cmd/helm/testdata/output/search-constraint.txt b/pkg/helm/pkg/cmd/testdata/output/search-constraint.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/search-constraint.txt rename to pkg/helm/pkg/cmd/testdata/output/search-constraint.txt diff --git a/pkg/helm/cmd/helm/testdata/output/search-multiple-devel-release.txt b/pkg/helm/pkg/cmd/testdata/output/search-multiple-devel-release.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/search-multiple-devel-release.txt rename to pkg/helm/pkg/cmd/testdata/output/search-multiple-devel-release.txt diff --git a/pkg/helm/cmd/helm/testdata/output/search-multiple-stable-release.txt b/pkg/helm/pkg/cmd/testdata/output/search-multiple-stable-release.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/search-multiple-stable-release.txt rename to pkg/helm/pkg/cmd/testdata/output/search-multiple-stable-release.txt diff --git a/pkg/helm/cmd/helm/testdata/output/search-multiple-versions-constraints.txt b/pkg/helm/pkg/cmd/testdata/output/search-multiple-versions-constraints.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/search-multiple-versions-constraints.txt rename to pkg/helm/pkg/cmd/testdata/output/search-multiple-versions-constraints.txt diff --git a/pkg/helm/cmd/helm/testdata/output/search-multiple-versions.txt b/pkg/helm/pkg/cmd/testdata/output/search-multiple-versions.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/search-multiple-versions.txt rename to pkg/helm/pkg/cmd/testdata/output/search-multiple-versions.txt diff --git a/pkg/helm/cmd/helm/testdata/output/search-not-found-error.txt b/pkg/helm/pkg/cmd/testdata/output/search-not-found-error.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/search-not-found-error.txt rename to pkg/helm/pkg/cmd/testdata/output/search-not-found-error.txt diff --git a/pkg/helm/cmd/helm/testdata/output/search-not-found.txt b/pkg/helm/pkg/cmd/testdata/output/search-not-found.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/search-not-found.txt rename to pkg/helm/pkg/cmd/testdata/output/search-not-found.txt diff --git a/pkg/helm/cmd/helm/testdata/output/search-output-json.txt b/pkg/helm/pkg/cmd/testdata/output/search-output-json.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/search-output-json.txt rename to pkg/helm/pkg/cmd/testdata/output/search-output-json.txt diff --git a/pkg/helm/cmd/helm/testdata/output/search-output-yaml.txt b/pkg/helm/pkg/cmd/testdata/output/search-output-yaml.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/search-output-yaml.txt rename to pkg/helm/pkg/cmd/testdata/output/search-output-yaml.txt diff --git a/pkg/helm/cmd/helm/testdata/output/search-regex.txt b/pkg/helm/pkg/cmd/testdata/output/search-regex.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/search-regex.txt rename to pkg/helm/pkg/cmd/testdata/output/search-regex.txt diff --git a/pkg/helm/cmd/helm/testdata/output/search-versions-constraint.txt b/pkg/helm/pkg/cmd/testdata/output/search-versions-constraint.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/search-versions-constraint.txt rename to pkg/helm/pkg/cmd/testdata/output/search-versions-constraint.txt diff --git a/pkg/helm/cmd/helm/testdata/output/status-comp.txt b/pkg/helm/pkg/cmd/testdata/output/status-comp.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/status-comp.txt rename to pkg/helm/pkg/cmd/testdata/output/status-comp.txt diff --git a/pkg/helm/cmd/helm/testdata/output/status-with-desc.txt b/pkg/helm/pkg/cmd/testdata/output/status-with-desc.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/status-with-desc.txt rename to pkg/helm/pkg/cmd/testdata/output/status-with-desc.txt diff --git a/pkg/helm/cmd/helm/testdata/output/status-with-notes.txt b/pkg/helm/pkg/cmd/testdata/output/status-with-notes.txt similarity index 91% rename from pkg/helm/cmd/helm/testdata/output/status-with-notes.txt rename to pkg/helm/pkg/cmd/testdata/output/status-with-notes.txt index e992ce91..f05be6c1 100644 --- a/pkg/helm/cmd/helm/testdata/output/status-with-notes.txt +++ b/pkg/helm/pkg/cmd/testdata/output/status-with-notes.txt @@ -3,6 +3,7 @@ LAST DEPLOYED: Sat Jan 16 00:00:00 2016 NAMESPACE: default STATUS: deployed REVISION: 0 +DESCRIPTION: TEST SUITE: None NOTES: release notes diff --git a/pkg/helm/pkg/cmd/testdata/output/status-with-resources.json b/pkg/helm/pkg/cmd/testdata/output/status-with-resources.json new file mode 100644 index 00000000..af512bfd --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/status-with-resources.json @@ -0,0 +1 @@ +{"name":"flummoxed-chickadee","info":{"last_deployed":"2016-01-16T00:00:00Z","status":"deployed"},"namespace":"default"} diff --git a/pkg/helm/cmd/helm/testdata/output/status-with-resources.txt b/pkg/helm/pkg/cmd/testdata/output/status-with-resources.txt similarity index 90% rename from pkg/helm/cmd/helm/testdata/output/status-with-resources.txt rename to pkg/helm/pkg/cmd/testdata/output/status-with-resources.txt index a326c3db..20763acd 100644 --- a/pkg/helm/cmd/helm/testdata/output/status-with-resources.txt +++ b/pkg/helm/pkg/cmd/testdata/output/status-with-resources.txt @@ -3,4 +3,5 @@ LAST DEPLOYED: Sat Jan 16 00:00:00 2016 NAMESPACE: default STATUS: deployed REVISION: 0 +DESCRIPTION: TEST SUITE: None diff --git a/pkg/helm/cmd/helm/testdata/output/status-with-test-suite.txt b/pkg/helm/pkg/cmd/testdata/output/status-with-test-suite.txt similarity index 96% rename from pkg/helm/cmd/helm/testdata/output/status-with-test-suite.txt rename to pkg/helm/pkg/cmd/testdata/output/status-with-test-suite.txt index 58c67e10..7c1ade45 100644 --- a/pkg/helm/cmd/helm/testdata/output/status-with-test-suite.txt +++ b/pkg/helm/pkg/cmd/testdata/output/status-with-test-suite.txt @@ -3,6 +3,7 @@ LAST DEPLOYED: Sat Jan 16 00:00:00 2016 NAMESPACE: default STATUS: deployed REVISION: 0 +DESCRIPTION: TEST SUITE: passing-test Last Started: Mon Jan 2 15:04:05 2006 Last Completed: Mon Jan 2 15:04:07 2006 diff --git a/pkg/helm/pkg/cmd/testdata/output/status-wrong-args-comp.txt b/pkg/helm/pkg/cmd/testdata/output/status-wrong-args-comp.txt new file mode 100644 index 00000000..3c537283 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/status-wrong-args-comp.txt @@ -0,0 +1,3 @@ +_activeHelp_ This command does not take any more arguments (but may accept flags). +:4 +Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/pkg/helm/pkg/cmd/testdata/output/status.json b/pkg/helm/pkg/cmd/testdata/output/status.json new file mode 100644 index 00000000..4727dd10 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/status.json @@ -0,0 +1 @@ +{"name":"flummoxed-chickadee","info":{"last_deployed":"2016-01-16T00:00:00Z","status":"deployed","notes":"release notes"},"namespace":"default"} diff --git a/pkg/helm/cmd/helm/testdata/output/status.txt b/pkg/helm/pkg/cmd/testdata/output/status.txt similarity index 90% rename from pkg/helm/cmd/helm/testdata/output/status.txt rename to pkg/helm/pkg/cmd/testdata/output/status.txt index a326c3db..20763acd 100644 --- a/pkg/helm/cmd/helm/testdata/output/status.txt +++ b/pkg/helm/pkg/cmd/testdata/output/status.txt @@ -3,4 +3,5 @@ LAST DEPLOYED: Sat Jan 16 00:00:00 2016 NAMESPACE: default STATUS: deployed REVISION: 0 +DESCRIPTION: TEST SUITE: None diff --git a/pkg/helm/cmd/helm/testdata/output/subchart-schema-cli-negative.txt b/pkg/helm/pkg/cmd/testdata/output/subchart-schema-cli-negative.txt similarity index 75% rename from pkg/helm/cmd/helm/testdata/output/subchart-schema-cli-negative.txt rename to pkg/helm/pkg/cmd/testdata/output/subchart-schema-cli-negative.txt index 7396b4bf..179550f6 100644 --- a/pkg/helm/cmd/helm/testdata/output/subchart-schema-cli-negative.txt +++ b/pkg/helm/pkg/cmd/testdata/output/subchart-schema-cli-negative.txt @@ -1,4 +1,4 @@ Error: INSTALLATION FAILED: values don't meet the specifications of the schema(s) in the following chart(s): subchart-with-schema: -- age: Must be greater than or equal to 0 +- at '/age': minimum: got -25, want 0 diff --git a/pkg/helm/cmd/helm/testdata/output/subchart-schema-cli.txt b/pkg/helm/pkg/cmd/testdata/output/subchart-schema-cli.txt similarity index 77% rename from pkg/helm/cmd/helm/testdata/output/subchart-schema-cli.txt rename to pkg/helm/pkg/cmd/testdata/output/subchart-schema-cli.txt index 60e4d026..f5fe6376 100644 --- a/pkg/helm/cmd/helm/testdata/output/subchart-schema-cli.txt +++ b/pkg/helm/pkg/cmd/testdata/output/subchart-schema-cli.txt @@ -1,8 +1,7 @@ NAME: schema LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -LAST PHASE: rollout -LAST STAGE: 0 NAMESPACE: default STATUS: deployed REVISION: 1 +DESCRIPTION: Install complete TEST SUITE: None diff --git a/pkg/helm/cmd/helm/testdata/output/subchart-schema-negative.txt b/pkg/helm/pkg/cmd/testdata/output/subchart-schema-negative.txt similarity index 69% rename from pkg/helm/cmd/helm/testdata/output/subchart-schema-negative.txt rename to pkg/helm/pkg/cmd/testdata/output/subchart-schema-negative.txt index 7b1f654a..7522ef3e 100644 --- a/pkg/helm/cmd/helm/testdata/output/subchart-schema-negative.txt +++ b/pkg/helm/pkg/cmd/testdata/output/subchart-schema-negative.txt @@ -1,6 +1,6 @@ Error: INSTALLATION FAILED: values don't meet the specifications of the schema(s) in the following chart(s): chart-without-schema: -- (root): lastname is required +- at '': missing property 'lastname' subchart-with-schema: -- (root): age is required +- at '': missing property 'age' diff --git a/pkg/helm/cmd/helm/testdata/output/template-chart-bad-type.txt b/pkg/helm/pkg/cmd/testdata/output/template-chart-bad-type.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/template-chart-bad-type.txt rename to pkg/helm/pkg/cmd/testdata/output/template-chart-bad-type.txt diff --git a/pkg/helm/cmd/helm/testdata/output/template-chart-with-template-lib-archive-dep.txt b/pkg/helm/pkg/cmd/testdata/output/template-chart-with-template-lib-archive-dep.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/template-chart-with-template-lib-archive-dep.txt rename to pkg/helm/pkg/cmd/testdata/output/template-chart-with-template-lib-archive-dep.txt diff --git a/pkg/helm/cmd/helm/testdata/output/template-chart-with-template-lib-dep.txt b/pkg/helm/pkg/cmd/testdata/output/template-chart-with-template-lib-dep.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/template-chart-with-template-lib-dep.txt rename to pkg/helm/pkg/cmd/testdata/output/template-chart-with-template-lib-dep.txt diff --git a/pkg/helm/cmd/helm/testdata/output/template-lib-chart.txt b/pkg/helm/pkg/cmd/testdata/output/template-lib-chart.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/template-lib-chart.txt rename to pkg/helm/pkg/cmd/testdata/output/template-lib-chart.txt diff --git a/pkg/helm/cmd/helm/testdata/output/template-name-template.txt b/pkg/helm/pkg/cmd/testdata/output/template-name-template.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/template-name-template.txt rename to pkg/helm/pkg/cmd/testdata/output/template-name-template.txt diff --git a/pkg/helm/cmd/helm/testdata/output/template-no-args.txt b/pkg/helm/pkg/cmd/testdata/output/template-no-args.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/template-no-args.txt rename to pkg/helm/pkg/cmd/testdata/output/template-no-args.txt diff --git a/pkg/helm/cmd/helm/testdata/output/template-set.txt b/pkg/helm/pkg/cmd/testdata/output/template-set.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/template-set.txt rename to pkg/helm/pkg/cmd/testdata/output/template-set.txt diff --git a/pkg/helm/cmd/helm/testdata/output/template-show-only-glob.txt b/pkg/helm/pkg/cmd/testdata/output/template-show-only-glob.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/template-show-only-glob.txt rename to pkg/helm/pkg/cmd/testdata/output/template-show-only-glob.txt diff --git a/pkg/helm/cmd/helm/testdata/output/template-show-only-multiple.txt b/pkg/helm/pkg/cmd/testdata/output/template-show-only-multiple.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/template-show-only-multiple.txt rename to pkg/helm/pkg/cmd/testdata/output/template-show-only-multiple.txt diff --git a/pkg/helm/cmd/helm/testdata/output/template-show-only-one.txt b/pkg/helm/pkg/cmd/testdata/output/template-show-only-one.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/template-show-only-one.txt rename to pkg/helm/pkg/cmd/testdata/output/template-show-only-one.txt diff --git a/pkg/helm/cmd/helm/testdata/output/template-skip-tests.txt b/pkg/helm/pkg/cmd/testdata/output/template-skip-tests.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/template-skip-tests.txt rename to pkg/helm/pkg/cmd/testdata/output/template-skip-tests.txt diff --git a/pkg/helm/cmd/helm/testdata/output/template-subchart-cm-set-file.txt b/pkg/helm/pkg/cmd/testdata/output/template-subchart-cm-set-file.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/template-subchart-cm-set-file.txt rename to pkg/helm/pkg/cmd/testdata/output/template-subchart-cm-set-file.txt diff --git a/pkg/helm/cmd/helm/testdata/output/template-subchart-cm-set.txt b/pkg/helm/pkg/cmd/testdata/output/template-subchart-cm-set.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/template-subchart-cm-set.txt rename to pkg/helm/pkg/cmd/testdata/output/template-subchart-cm-set.txt diff --git a/pkg/helm/cmd/helm/testdata/output/template-subchart-cm.txt b/pkg/helm/pkg/cmd/testdata/output/template-subchart-cm.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/template-subchart-cm.txt rename to pkg/helm/pkg/cmd/testdata/output/template-subchart-cm.txt diff --git a/pkg/helm/cmd/helm/testdata/output/template-values-files.txt b/pkg/helm/pkg/cmd/testdata/output/template-values-files.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/template-values-files.txt rename to pkg/helm/pkg/cmd/testdata/output/template-values-files.txt diff --git a/pkg/helm/cmd/helm/testdata/output/template-with-api-version.txt b/pkg/helm/pkg/cmd/testdata/output/template-with-api-version.txt similarity index 98% rename from pkg/helm/cmd/helm/testdata/output/template-with-api-version.txt rename to pkg/helm/pkg/cmd/testdata/output/template-with-api-version.txt index 7e1c3500..8b6074cd 100644 --- a/pkg/helm/cmd/helm/testdata/output/template-with-api-version.txt +++ b/pkg/helm/pkg/cmd/testdata/output/template-with-api-version.txt @@ -75,6 +75,7 @@ metadata: kube-version/minor: "20" kube-version/version: "v1.20.0" kube-api-version/test: v1 + kube-api-version/test2: v2 spec: type: ClusterIP ports: diff --git a/pkg/helm/cmd/helm/testdata/output/template-with-crds.txt b/pkg/helm/pkg/cmd/testdata/output/template-with-crds.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/template-with-crds.txt rename to pkg/helm/pkg/cmd/testdata/output/template-with-crds.txt diff --git a/pkg/helm/cmd/helm/testdata/output/template-with-invalid-yaml-debug.txt b/pkg/helm/pkg/cmd/testdata/output/template-with-invalid-yaml-debug.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/template-with-invalid-yaml-debug.txt rename to pkg/helm/pkg/cmd/testdata/output/template-with-invalid-yaml-debug.txt diff --git a/pkg/helm/cmd/helm/testdata/output/template-with-invalid-yaml.txt b/pkg/helm/pkg/cmd/testdata/output/template-with-invalid-yaml.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/template-with-invalid-yaml.txt rename to pkg/helm/pkg/cmd/testdata/output/template-with-invalid-yaml.txt diff --git a/pkg/helm/cmd/helm/testdata/output/template-with-kube-version.txt b/pkg/helm/pkg/cmd/testdata/output/template-with-kube-version.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/template-with-kube-version.txt rename to pkg/helm/pkg/cmd/testdata/output/template-with-kube-version.txt diff --git a/pkg/helm/cmd/helm/testdata/output/template.txt b/pkg/helm/pkg/cmd/testdata/output/template.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/template.txt rename to pkg/helm/pkg/cmd/testdata/output/template.txt diff --git a/pkg/helm/cmd/helm/testdata/output/uninstall-keep-history.txt b/pkg/helm/pkg/cmd/testdata/output/uninstall-keep-history-earlier-deployed.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/uninstall-keep-history.txt rename to pkg/helm/pkg/cmd/testdata/output/uninstall-keep-history-earlier-deployed.txt diff --git a/pkg/helm/cmd/helm/testdata/output/uninstall-no-hooks.txt b/pkg/helm/pkg/cmd/testdata/output/uninstall-keep-history.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/uninstall-no-hooks.txt rename to pkg/helm/pkg/cmd/testdata/output/uninstall-keep-history.txt diff --git a/pkg/helm/cmd/helm/testdata/output/uninstall-multiple.txt b/pkg/helm/pkg/cmd/testdata/output/uninstall-multiple.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/uninstall-multiple.txt rename to pkg/helm/pkg/cmd/testdata/output/uninstall-multiple.txt diff --git a/pkg/helm/cmd/helm/testdata/output/uninstall-no-args.txt b/pkg/helm/pkg/cmd/testdata/output/uninstall-no-args.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/uninstall-no-args.txt rename to pkg/helm/pkg/cmd/testdata/output/uninstall-no-args.txt diff --git a/pkg/helm/cmd/helm/testdata/output/uninstall-timeout.txt b/pkg/helm/pkg/cmd/testdata/output/uninstall-no-hooks.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/uninstall-timeout.txt rename to pkg/helm/pkg/cmd/testdata/output/uninstall-no-hooks.txt diff --git a/pkg/helm/cmd/helm/testdata/output/uninstall-wait.txt b/pkg/helm/pkg/cmd/testdata/output/uninstall-timeout.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/uninstall-wait.txt rename to pkg/helm/pkg/cmd/testdata/output/uninstall-timeout.txt diff --git a/pkg/helm/cmd/helm/testdata/output/uninstall.txt b/pkg/helm/pkg/cmd/testdata/output/uninstall-wait.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/uninstall.txt rename to pkg/helm/pkg/cmd/testdata/output/uninstall-wait.txt diff --git a/pkg/helm/pkg/cmd/testdata/output/uninstall.txt b/pkg/helm/pkg/cmd/testdata/output/uninstall.txt new file mode 100644 index 00000000..f5454b88 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/uninstall.txt @@ -0,0 +1 @@ +release "aeneas" uninstalled diff --git a/pkg/helm/pkg/cmd/testdata/output/upgrade-and-take-ownership.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-and-take-ownership.txt new file mode 100644 index 00000000..59267651 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/upgrade-and-take-ownership.txt @@ -0,0 +1,8 @@ +Release "funny-bunny" has been upgraded. Happy Helming! +NAME: funny-bunny +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 3 +DESCRIPTION: Upgrade complete +TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/output/upgrade-uninstalled-with-keep-history.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-uninstalled-with-keep-history.txt new file mode 100644 index 00000000..d5c42d15 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/upgrade-uninstalled-with-keep-history.txt @@ -0,0 +1,8 @@ +Release "funny-bunny" does not exist. Installing it now. +NAME: funny-bunny +LAST DEPLOYED: Fri Sep 2 22:04:05 1977 +NAMESPACE: default +STATUS: deployed +REVISION: 3 +DESCRIPTION: Install complete +TEST SUITE: None diff --git a/pkg/helm/cmd/helm/testdata/output/upgrade-with-bad-dependencies.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-bad-dependencies.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/upgrade-with-bad-dependencies.txt rename to pkg/helm/pkg/cmd/testdata/output/upgrade-with-bad-dependencies.txt diff --git a/pkg/helm/cmd/helm/testdata/output/upgrade-with-bad-or-missing-existing-release.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-bad-or-missing-existing-release.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/upgrade-with-bad-or-missing-existing-release.txt rename to pkg/helm/pkg/cmd/testdata/output/upgrade-with-bad-or-missing-existing-release.txt diff --git a/pkg/helm/cmd/helm/testdata/output/upgrade-with-dependency-update.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-dependency-update.txt similarity index 85% rename from pkg/helm/cmd/helm/testdata/output/upgrade-with-dependency-update.txt rename to pkg/helm/pkg/cmd/testdata/output/upgrade-with-dependency-update.txt index 73174063..d1517a68 100644 --- a/pkg/helm/cmd/helm/testdata/output/upgrade-with-dependency-update.txt +++ b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-dependency-update.txt @@ -1,11 +1,10 @@ Release "funny-bunny" has been upgraded. Happy Helming! NAME: funny-bunny LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -LAST PHASE: rollout -LAST STAGE: 0 NAMESPACE: default STATUS: deployed REVISION: 3 +DESCRIPTION: Upgrade complete TEST SUITE: None NOTES: PARENT NOTES diff --git a/pkg/helm/cmd/helm/testdata/output/upgrade-with-install-timeout.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-install-timeout.txt similarity index 84% rename from pkg/helm/cmd/helm/testdata/output/upgrade-with-install-timeout.txt rename to pkg/helm/pkg/cmd/testdata/output/upgrade-with-install-timeout.txt index d428c2eb..b159dc3b 100644 --- a/pkg/helm/cmd/helm/testdata/output/upgrade-with-install-timeout.txt +++ b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-install-timeout.txt @@ -1,9 +1,8 @@ Release "crazy-bunny" has been upgraded. Happy Helming! NAME: crazy-bunny LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -LAST PHASE: rollout -LAST STAGE: 0 NAMESPACE: default STATUS: deployed REVISION: 2 +DESCRIPTION: Upgrade complete TEST SUITE: None diff --git a/pkg/helm/cmd/helm/testdata/output/upgrade-with-install.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-install.txt similarity index 83% rename from pkg/helm/cmd/helm/testdata/output/upgrade-with-install.txt rename to pkg/helm/pkg/cmd/testdata/output/upgrade-with-install.txt index 2fc53807..7dc2fce6 100644 --- a/pkg/helm/cmd/helm/testdata/output/upgrade-with-install.txt +++ b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-install.txt @@ -1,9 +1,8 @@ Release "zany-bunny" has been upgraded. Happy Helming! NAME: zany-bunny LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -LAST PHASE: rollout -LAST STAGE: 0 NAMESPACE: default STATUS: deployed REVISION: 2 +DESCRIPTION: Upgrade complete TEST SUITE: None diff --git a/pkg/helm/cmd/helm/testdata/output/upgrade-with-missing-dependencies.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-missing-dependencies.txt similarity index 69% rename from pkg/helm/cmd/helm/testdata/output/upgrade-with-missing-dependencies.txt rename to pkg/helm/pkg/cmd/testdata/output/upgrade-with-missing-dependencies.txt index adf2ae89..b2c154a8 100644 --- a/pkg/helm/cmd/helm/testdata/output/upgrade-with-missing-dependencies.txt +++ b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-missing-dependencies.txt @@ -1 +1 @@ -Error: An error occurred while checking for chart dependencies. You may need to run `helm dependency build` to fetch missing dependencies: found in Chart.yaml, but missing in charts/ directory: reqsubchart2 +Error: an error occurred while checking for chart dependencies. You may need to run `helm dependency build` to fetch missing dependencies: found in Chart.yaml, but missing in charts/ directory: reqsubchart2 diff --git a/pkg/helm/cmd/helm/testdata/output/upgrade-with-pending-install.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-pending-install.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/upgrade-with-pending-install.txt rename to pkg/helm/pkg/cmd/testdata/output/upgrade-with-pending-install.txt diff --git a/pkg/helm/cmd/helm/testdata/output/upgrade-with-reset-values.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-reset-values.txt similarity index 84% rename from pkg/helm/cmd/helm/testdata/output/upgrade-with-reset-values.txt rename to pkg/helm/pkg/cmd/testdata/output/upgrade-with-reset-values.txt index 16b1e624..d02993a5 100644 --- a/pkg/helm/cmd/helm/testdata/output/upgrade-with-reset-values.txt +++ b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-reset-values.txt @@ -1,9 +1,8 @@ Release "funny-bunny" has been upgraded. Happy Helming! NAME: funny-bunny LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -LAST PHASE: rollout -LAST STAGE: 0 NAMESPACE: default STATUS: deployed REVISION: 5 +DESCRIPTION: Upgrade complete TEST SUITE: None diff --git a/pkg/helm/cmd/helm/testdata/output/upgrade-with-reset-values2.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-reset-values2.txt similarity index 84% rename from pkg/helm/cmd/helm/testdata/output/upgrade-with-reset-values2.txt rename to pkg/helm/pkg/cmd/testdata/output/upgrade-with-reset-values2.txt index 68f7fc03..7780c4fd 100644 --- a/pkg/helm/cmd/helm/testdata/output/upgrade-with-reset-values2.txt +++ b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-reset-values2.txt @@ -1,9 +1,8 @@ Release "funny-bunny" has been upgraded. Happy Helming! NAME: funny-bunny LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -LAST PHASE: rollout -LAST STAGE: 0 NAMESPACE: default STATUS: deployed REVISION: 6 +DESCRIPTION: Upgrade complete TEST SUITE: None diff --git a/pkg/helm/cmd/helm/testdata/output/upgrade-with-timeout.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-timeout.txt similarity index 84% rename from pkg/helm/cmd/helm/testdata/output/upgrade-with-timeout.txt rename to pkg/helm/pkg/cmd/testdata/output/upgrade-with-timeout.txt index 68eb7f05..b1edac3a 100644 --- a/pkg/helm/cmd/helm/testdata/output/upgrade-with-timeout.txt +++ b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-timeout.txt @@ -1,9 +1,8 @@ Release "funny-bunny" has been upgraded. Happy Helming! NAME: funny-bunny LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -LAST PHASE: rollout -LAST STAGE: 0 NAMESPACE: default STATUS: deployed REVISION: 4 +DESCRIPTION: Upgrade complete TEST SUITE: None diff --git a/pkg/helm/cmd/helm/testdata/output/upgrade-with-wait-for-jobs.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-wait-for-jobs.txt similarity index 84% rename from pkg/helm/cmd/helm/testdata/output/upgrade-with-wait-for-jobs.txt rename to pkg/helm/pkg/cmd/testdata/output/upgrade-with-wait-for-jobs.txt index bf453ceb..21784413 100644 --- a/pkg/helm/cmd/helm/testdata/output/upgrade-with-wait-for-jobs.txt +++ b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-wait-for-jobs.txt @@ -1,9 +1,8 @@ Release "crazy-bunny" has been upgraded. Happy Helming! NAME: crazy-bunny LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -LAST PHASE: rollout -LAST STAGE: 0 NAMESPACE: default STATUS: deployed REVISION: 3 +DESCRIPTION: Upgrade complete TEST SUITE: None diff --git a/pkg/helm/cmd/helm/testdata/output/upgrade-with-wait.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-wait.txt similarity index 84% rename from pkg/helm/cmd/helm/testdata/output/upgrade-with-wait.txt rename to pkg/helm/pkg/cmd/testdata/output/upgrade-with-wait.txt index bf453ceb..21784413 100644 --- a/pkg/helm/cmd/helm/testdata/output/upgrade-with-wait.txt +++ b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-wait.txt @@ -1,9 +1,8 @@ Release "crazy-bunny" has been upgraded. Happy Helming! NAME: crazy-bunny LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -LAST PHASE: rollout -LAST STAGE: 0 NAMESPACE: default STATUS: deployed REVISION: 3 +DESCRIPTION: Upgrade complete TEST SUITE: None diff --git a/pkg/helm/cmd/helm/testdata/output/upgrade.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade.txt similarity index 84% rename from pkg/helm/cmd/helm/testdata/output/upgrade.txt rename to pkg/helm/pkg/cmd/testdata/output/upgrade.txt index 68ec3dc3..59267651 100644 --- a/pkg/helm/cmd/helm/testdata/output/upgrade.txt +++ b/pkg/helm/pkg/cmd/testdata/output/upgrade.txt @@ -1,9 +1,8 @@ Release "funny-bunny" has been upgraded. Happy Helming! NAME: funny-bunny LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -LAST PHASE: rollout -LAST STAGE: 0 NAMESPACE: default STATUS: deployed REVISION: 3 +DESCRIPTION: Upgrade complete TEST SUITE: None diff --git a/pkg/helm/cmd/helm/testdata/output/values.json b/pkg/helm/pkg/cmd/testdata/output/values.json similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/values.json rename to pkg/helm/pkg/cmd/testdata/output/values.json diff --git a/pkg/helm/cmd/helm/testdata/output/values.yaml b/pkg/helm/pkg/cmd/testdata/output/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/values.yaml rename to pkg/helm/pkg/cmd/testdata/output/values.yaml diff --git a/pkg/helm/cmd/helm/testdata/output/version-comp.txt b/pkg/helm/pkg/cmd/testdata/output/version-comp.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/version-comp.txt rename to pkg/helm/pkg/cmd/testdata/output/version-comp.txt diff --git a/pkg/helm/cmd/helm/testdata/output/version-invalid-comp.txt b/pkg/helm/pkg/cmd/testdata/output/version-invalid-comp.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/output/version-invalid-comp.txt rename to pkg/helm/pkg/cmd/testdata/output/version-invalid-comp.txt diff --git a/pkg/helm/pkg/cmd/testdata/output/version-short.txt b/pkg/helm/pkg/cmd/testdata/output/version-short.txt new file mode 100644 index 00000000..8cf4318f --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/version-short.txt @@ -0,0 +1 @@ +v4.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/version-template.txt b/pkg/helm/pkg/cmd/testdata/output/version-template.txt new file mode 100644 index 00000000..8fd8b496 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/version-template.txt @@ -0,0 +1 @@ +Version: v4.1 \ No newline at end of file diff --git a/pkg/helm/pkg/cmd/testdata/output/version.txt b/pkg/helm/pkg/cmd/testdata/output/version.txt new file mode 100644 index 00000000..1f4cf4d4 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/output/version.txt @@ -0,0 +1 @@ +version.BuildInfo{Version:"v4.1", GitCommit:"", GitTreeState:"", GoVersion:"", KubeClientVersion:"v1.20"} diff --git a/pkg/helm/cmd/helm/testdata/password b/pkg/helm/pkg/cmd/testdata/password similarity index 100% rename from pkg/helm/cmd/helm/testdata/password rename to pkg/helm/pkg/cmd/testdata/password diff --git a/pkg/helm/pkg/cmd/testdata/plugins.yaml b/pkg/helm/pkg/cmd/testdata/plugins.yaml new file mode 100644 index 00000000..69086973 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/plugins.yaml @@ -0,0 +1,3 @@ +plugins: +- name: testplugin + url: testdata/testplugin diff --git a/pkg/helm/cmd/helm/testdata/repositories.yaml b/pkg/helm/pkg/cmd/testdata/repositories.yaml similarity index 99% rename from pkg/helm/cmd/helm/testdata/repositories.yaml rename to pkg/helm/pkg/cmd/testdata/repositories.yaml index b8e775b5..6be26b77 100644 --- a/pkg/helm/cmd/helm/testdata/repositories.yaml +++ b/pkg/helm/pkg/cmd/testdata/repositories.yaml @@ -6,3 +6,4 @@ repositories: url: "http://firstexample.com" - name: secondexample url: "http://secondexample.com" + diff --git a/pkg/helm/cmd/helm/testdata/testcharts/alpine/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/alpine/Chart.yaml similarity index 81% rename from pkg/helm/cmd/helm/testdata/testcharts/alpine/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/alpine/Chart.yaml index b1c5d7d7..1d6bad82 100644 --- a/pkg/helm/cmd/helm/testdata/testcharts/alpine/Chart.yaml +++ b/pkg/helm/pkg/cmd/testdata/testcharts/alpine/Chart.yaml @@ -4,5 +4,5 @@ description: Deploy a basic Alpine Linux pod home: https://helm.sh/helm name: alpine sources: - - https://github.com/helm/helm +- https://github.com/helm/helm version: 0.1.0 diff --git a/pkg/helm/cmd/helm/testdata/testcharts/alpine/README.md b/pkg/helm/pkg/cmd/testdata/testcharts/alpine/README.md similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/alpine/README.md rename to pkg/helm/pkg/cmd/testdata/testcharts/alpine/README.md diff --git a/pkg/helm/cmd/helm/testdata/testcharts/alpine/extra_values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/alpine/extra_values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/alpine/extra_values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/alpine/extra_values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/alpine/more_values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/alpine/more_values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/alpine/more_values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/alpine/more_values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/alpine/templates/alpine-pod.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/alpine/templates/alpine-pod.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/alpine/templates/alpine-pod.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/alpine/templates/alpine-pod.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/alpine/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/alpine/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/alpine/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/alpine/values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-bad-requirements/.helmignore b/pkg/helm/pkg/cmd/testdata/testcharts/chart-bad-requirements/.helmignore similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-bad-requirements/.helmignore rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-bad-requirements/.helmignore diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-bad-requirements/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-bad-requirements/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-bad-requirements/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-bad-requirements/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/.helmignore b/pkg/helm/pkg/cmd/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/.helmignore similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/.helmignore rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/.helmignore diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-bad-requirements/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-bad-requirements/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-bad-requirements/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-bad-requirements/values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-bad-type/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-bad-type/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-bad-type/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-bad-type/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-bad-type/README.md b/pkg/helm/pkg/cmd/testdata/testcharts/chart-bad-type/README.md similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-bad-type/README.md rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-bad-type/README.md diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-bad-type/extra_values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-bad-type/extra_values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-bad-type/extra_values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-bad-type/extra_values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-bad-type/more_values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-bad-type/more_values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-bad-type/more_values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-bad-type/more_values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-bad-type/templates/alpine-pod.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-bad-type/templates/alpine-pod.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-bad-type/templates/alpine-pod.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-bad-type/templates/alpine-pod.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-bad-type/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-bad-type/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-bad-type/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-bad-type/values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-missing-deps/.helmignore b/pkg/helm/pkg/cmd/testdata/testcharts/chart-missing-deps/.helmignore similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-missing-deps/.helmignore rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-missing-deps/.helmignore diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-missing-deps/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-missing-deps/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-missing-deps/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-missing-deps/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-missing-deps/charts/reqsubchart/.helmignore b/pkg/helm/pkg/cmd/testdata/testcharts/chart-missing-deps/charts/reqsubchart/.helmignore similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-missing-deps/charts/reqsubchart/.helmignore rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-missing-deps/charts/reqsubchart/.helmignore diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-missing-deps/charts/reqsubchart/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-missing-deps/charts/reqsubchart/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-missing-deps/charts/reqsubchart/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-missing-deps/charts/reqsubchart/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-missing-deps/charts/reqsubchart/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-missing-deps/charts/reqsubchart/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-missing-deps/charts/reqsubchart/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-missing-deps/charts/reqsubchart/values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-missing-deps/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-missing-deps/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-missing-deps/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-missing-deps/values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart/Chart.yaml diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart/values.yaml new file mode 100644 index 00000000..e69de29b diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/Chart.yaml similarity index 80% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/Chart.yaml index a3de9214..895433e3 100644 --- a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/Chart.yaml +++ b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 description: Good subchart name: good-subchart -version: 0.0.1 +version: 0.0.1 \ No newline at end of file diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/values.yaml new file mode 100644 index 00000000..e69de29b diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/requirements.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/requirements.yaml similarity index 80% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/requirements.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/requirements.yaml index cc6dfc06..de2fbb4d 100644 --- a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/requirements.yaml +++ b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/requirements.yaml @@ -2,4 +2,4 @@ dependencies: - name: good-subchart version: 0.0.1 - name: bad-subchart - version: 0.0.1 + version: 0.0.1 \ No newline at end of file diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/values.yaml new file mode 100644 index 00000000..e69de29b diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-deprecated-api/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-deprecated-api/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-deprecated-api/templates/horizontalpodautoscaler.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/templates/horizontalpodautoscaler.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-deprecated-api/templates/horizontalpodautoscaler.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/templates/horizontalpodautoscaler.yaml diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/values.yaml new file mode 100644 index 00000000..e69de29b diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-lib-dep/.helmignore b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-lib-dep/.helmignore similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-lib-dep/.helmignore rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-lib-dep/.helmignore diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-lib-dep/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-lib-dep/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-lib-dep/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-lib-dep/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-lib-dep/charts/common-0.0.5.tgz b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-lib-dep/charts/common-0.0.5.tgz similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-lib-dep/charts/common-0.0.5.tgz rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-lib-dep/charts/common-0.0.5.tgz diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/NOTES.txt b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-lib-dep/templates/NOTES.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/NOTES.txt rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-lib-dep/templates/NOTES.txt diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/_helpers.tpl b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-lib-dep/templates/_helpers.tpl similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/_helpers.tpl rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-lib-dep/templates/_helpers.tpl diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/deployment.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-lib-dep/templates/deployment.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/deployment.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-lib-dep/templates/deployment.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/ingress.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-lib-dep/templates/ingress.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/ingress.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-lib-dep/templates/ingress.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/service.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-lib-dep/templates/service.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/service.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-lib-dep/templates/service.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-lib-dep/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-lib-dep/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-lib-dep/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-lib-dep/values.yaml diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-only-crds/.helmignore b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-only-crds/.helmignore new file mode 100644 index 00000000..0e8a0eb3 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-only-crds/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-only-crds/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-only-crds/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-only-crds/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-only-crds/Chart.yaml diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-only-crds/crds/test-crd.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-only-crds/crds/test-crd.yaml new file mode 100644 index 00000000..1d7350f1 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-only-crds/crds/test-crd.yaml @@ -0,0 +1,19 @@ +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: tests.test.io +spec: + group: test.io + names: + kind: Test + listKind: TestList + plural: tests + singular: test + scope: Namespaced + versions: + - name : v1alpha2 + served: true + storage: true + - name : v1alpha1 + served: true + storage: false diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/templates/empty.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/templates/empty.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/templates/empty.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/templates/empty.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.schema.json b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.schema.json similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.schema.json rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.schema.json diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.yaml new file mode 100644 index 00000000..e69de29b diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/templates/empty.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/templates/empty.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/templates/empty.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/templates/empty.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/values.schema.json b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/values.schema.json similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/values.schema.json rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/values.schema.json diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-negative-skip-validation/Chart.yaml similarity index 73% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-negative-skip-validation/Chart.yaml index b65566e5..395d24f6 100644 --- a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema/Chart.yaml +++ b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-negative-skip-validation/Chart.yaml @@ -3,5 +3,5 @@ description: Empty testing chart home: https://k8s.io/helm name: empty sources: - - https://github.com/kubernetes/helm +- https://github.com/kubernetes/helm version: 0.1.0 diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema-negative/templates/empty.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-negative-skip-validation/templates/empty.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema-negative/templates/empty.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-negative-skip-validation/templates/empty.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema/values.schema.json b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-negative-skip-validation/values.schema.json similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema/values.schema.json rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-negative-skip-validation/values.schema.json diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-negative-skip-validation/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-negative-skip-validation/values.yaml new file mode 100644 index 00000000..5a1250bf --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-negative-skip-validation/values.yaml @@ -0,0 +1,14 @@ +firstname: John +lastname: Doe +age: -5 +likesCoffee: true +addresses: + - city: Springfield + street: Main + number: 12345 + - city: New York + street: Broadway + number: 67890 +phoneNumbers: + - "(888) 888-8888" + - "(555) 555-5555" diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema-negative/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-negative/Chart.yaml similarity index 73% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema-negative/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-negative/Chart.yaml index b65566e5..395d24f6 100644 --- a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema-negative/Chart.yaml +++ b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-negative/Chart.yaml @@ -3,5 +3,5 @@ description: Empty testing chart home: https://k8s.io/helm name: empty sources: - - https://github.com/kubernetes/helm +- https://github.com/kubernetes/helm version: 0.1.0 diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema/templates/empty.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-negative/templates/empty.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema/templates/empty.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-negative/templates/empty.yaml diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-negative/values.schema.json b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-negative/values.schema.json new file mode 100644 index 00000000..4df89bbe --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-negative/values.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "addresses": { + "description": "List of addresses", + "items": { + "properties": { + "city": { + "type": "string" + }, + "number": { + "type": "number" + }, + "street": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "age": { + "description": "Age", + "minimum": 0, + "type": "integer" + }, + "employmentInfo": { + "properties": { + "salary": { + "minimum": 0, + "type": "number" + }, + "title": { + "type": "string" + } + }, + "required": [ + "salary" + ], + "type": "object" + }, + "firstname": { + "description": "First name", + "type": "string" + }, + "lastname": { + "type": "string" + }, + "likesCoffee": { + "type": "boolean" + }, + "phoneNumbers": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "firstname", + "lastname", + "addresses", + "employmentInfo" + ], + "title": "Values", + "type": "object" +} diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-negative/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-negative/values.yaml new file mode 100644 index 00000000..5a1250bf --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema-negative/values.yaml @@ -0,0 +1,14 @@ +firstname: John +lastname: Doe +age: -5 +likesCoffee: true +addresses: + - city: Springfield + street: Main + number: 12345 + - city: New York + street: Broadway + number: 67890 +phoneNumbers: + - "(888) 888-8888" + - "(555) 555-5555" diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema/Chart.yaml new file mode 100644 index 00000000..395d24f6 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +description: Empty testing chart +home: https://k8s.io/helm +name: empty +sources: +- https://github.com/kubernetes/helm +version: 0.1.0 diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema/extra-values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema/extra-values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-schema/extra-values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema/extra-values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/empty/templates/empty.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema/templates/empty.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/empty/templates/empty.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema/templates/empty.yaml diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema/values.schema.json b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema/values.schema.json new file mode 100644 index 00000000..4df89bbe --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema/values.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "addresses": { + "description": "List of addresses", + "items": { + "properties": { + "city": { + "type": "string" + }, + "number": { + "type": "number" + }, + "street": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "age": { + "description": "Age", + "minimum": 0, + "type": "integer" + }, + "employmentInfo": { + "properties": { + "salary": { + "minimum": 0, + "type": "number" + }, + "title": { + "type": "string" + } + }, + "required": [ + "salary" + ], + "type": "object" + }, + "firstname": { + "description": "First name", + "type": "string" + }, + "lastname": { + "type": "string" + }, + "likesCoffee": { + "type": "boolean" + }, + "phoneNumbers": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "firstname", + "lastname", + "addresses", + "employmentInfo" + ], + "title": "Values", + "type": "object" +} diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema/values.yaml new file mode 100644 index 00000000..042dea66 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-schema/values.yaml @@ -0,0 +1,17 @@ +firstname: John +lastname: Doe +age: 25 +likesCoffee: true +employmentInfo: + title: Software Developer + salary: 100000 +addresses: + - city: Springfield + street: Main + number: 12345 + - city: New York + street: Broadway + number: 67890 +phoneNumbers: + - "(888) 888-8888" + - "(555) 555-5555" diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-secret/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-secret/Chart.yaml new file mode 100644 index 00000000..46d069e1 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-secret/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v2 +description: Chart with Kubernetes Secret +name: chart-with-secret +version: 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-secret/templates/configmap.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-secret/templates/configmap.yaml new file mode 100644 index 00000000..ce9c27d5 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-secret/templates/configmap.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-configmap +data: + foo: bar diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-secret/templates/secret.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-secret/templates/secret.yaml new file mode 100644 index 00000000..b1e1cff5 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-secret/templates/secret.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Secret +metadata: + name: test-secret +stringData: + foo: bar diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-subchart-notes/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-subchart-notes/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-subchart-notes/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-subchart-notes/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/templates/NOTES.txt b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/templates/NOTES.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/templates/NOTES.txt rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/templates/NOTES.txt diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-subchart-notes/templates/NOTES.txt b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-subchart-notes/templates/NOTES.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-subchart-notes/templates/NOTES.txt rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-subchart-notes/templates/NOTES.txt diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-subchart-update/Chart.lock b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-subchart-update/Chart.lock similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-subchart-update/Chart.lock rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-subchart-update/Chart.lock diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-subchart-update/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-subchart-update/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-subchart-update/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-subchart-update/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/templates/NOTES.txt b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/templates/NOTES.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/templates/NOTES.txt rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/templates/NOTES.txt diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-subchart-update/templates/NOTES.txt b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-subchart-update/templates/NOTES.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-subchart-update/templates/NOTES.txt rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-subchart-update/templates/NOTES.txt diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/.helmignore b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/.helmignore similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/.helmignore rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/.helmignore diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/charts/common-0.0.5.tgz b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/charts/common-0.0.5.tgz similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/charts/common-0.0.5.tgz rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/charts/common-0.0.5.tgz diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/templates/NOTES.txt b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/templates/NOTES.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/templates/NOTES.txt rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/templates/NOTES.txt diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/templates/_helpers.tpl b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/templates/_helpers.tpl similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/templates/_helpers.tpl rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/templates/_helpers.tpl diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/templates/deployment.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/templates/deployment.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/templates/deployment.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/templates/deployment.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/templates/ingress.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/templates/ingress.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/templates/ingress.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/templates/ingress.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/templates/service.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/templates/service.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/templates/service.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/templates/service.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/.helmignore b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/.helmignore similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/.helmignore rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/.helmignore diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/.helmignore b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/.helmignore similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/.helmignore rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/.helmignore diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/Chart.yaml similarity index 61% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/Chart.yaml index d1b52b18..ba14ca08 100755 --- a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/Chart.yaml +++ b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/Chart.yaml @@ -3,10 +3,10 @@ appVersion: 0.0.5 description: Common chartbuilding components and helpers home: https://helm.sh maintainers: - - email: technosophos@gmail.com - name: technosophos - - email: adnan@bitnami.com - name: prydonius +- email: technosophos@gmail.com + name: technosophos +- email: adnan@bitnami.com + name: prydonius name: common version: 0.0.5 type: library diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/README.md b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/README.md similarity index 99% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/README.md rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/README.md index 0e06414d..cafadcd7 100755 --- a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/README.md +++ b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/README.md @@ -48,7 +48,7 @@ Note that the `common.service` template defines two parameters: - A template name containing the service definition overrides A limitation of the Go template library is that a template can only take a -single argument. The `list` function is used to workaround this by constructing +single argument. The `list` function is used to work around this by constructing a list or array of arguments that is passed to the template. The `common.service` template is responsible for rendering the templates with diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_chartref.tpl b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_chartref.tpl similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_chartref.tpl rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_chartref.tpl diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_configmap.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_configmap.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_configmap.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_configmap.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_container.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_container.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_container.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_container.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_deployment.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_deployment.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_deployment.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_deployment.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_envvar.tpl b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_envvar.tpl similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_envvar.tpl rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_envvar.tpl diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_fullname.tpl b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_fullname.tpl similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_fullname.tpl rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_fullname.tpl diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_ingress.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_ingress.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_ingress.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_ingress.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata_annotations.tpl b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata_annotations.tpl similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata_annotations.tpl rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata_annotations.tpl diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata_labels.tpl b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata_labels.tpl similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata_labels.tpl rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata_labels.tpl diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_name.tpl b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_name.tpl similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_name.tpl rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_name.tpl diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_persistentvolumeclaim.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_persistentvolumeclaim.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_persistentvolumeclaim.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_persistentvolumeclaim.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_secret.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_secret.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_secret.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_secret.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_service.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_service.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_service.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_service.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_util.tpl b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_util.tpl similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_util.tpl rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_util.tpl diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_volume.tpl b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_volume.tpl similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_volume.tpl rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_volume.tpl diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/configmap.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/configmap.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/configmap.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/configmap.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/templates/NOTES.txt b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/templates/NOTES.txt similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/templates/NOTES.txt rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/templates/NOTES.txt diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/templates/_helpers.tpl b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/templates/_helpers.tpl similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/templates/_helpers.tpl rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/templates/_helpers.tpl diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/templates/deployment.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/templates/deployment.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/templates/deployment.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/templates/deployment.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/templates/ingress.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/templates/ingress.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/templates/ingress.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/templates/ingress.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/templates/service.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/templates/service.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/templates/service.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/templates/service.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-with-invalid-yaml/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-yaml/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-with-invalid-yaml/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-yaml/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-with-invalid-yaml/README.md b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-yaml/README.md similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-with-invalid-yaml/README.md rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-yaml/README.md diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-with-invalid-yaml/templates/alpine-pod.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-yaml/templates/alpine-pod.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-with-invalid-yaml/templates/alpine-pod.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-yaml/templates/alpine-pod.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-with-invalid-yaml/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-yaml/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/chart-with-template-with-invalid-yaml/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-yaml/values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/compressedchart-0.1.0.tar.gz b/pkg/helm/pkg/cmd/testdata/testcharts/compressedchart-0.1.0.tar.gz similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/compressedchart-0.1.0.tar.gz rename to pkg/helm/pkg/cmd/testdata/testcharts/compressedchart-0.1.0.tar.gz diff --git a/pkg/helm/cmd/helm/testdata/testcharts/compressedchart-0.1.0.tgz b/pkg/helm/pkg/cmd/testdata/testcharts/compressedchart-0.1.0.tgz similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/compressedchart-0.1.0.tgz rename to pkg/helm/pkg/cmd/testdata/testcharts/compressedchart-0.1.0.tgz diff --git a/pkg/helm/cmd/helm/testdata/testcharts/compressedchart-0.2.0.tgz b/pkg/helm/pkg/cmd/testdata/testcharts/compressedchart-0.2.0.tgz similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/compressedchart-0.2.0.tgz rename to pkg/helm/pkg/cmd/testdata/testcharts/compressedchart-0.2.0.tgz diff --git a/pkg/helm/cmd/helm/testdata/testcharts/compressedchart-0.3.0.tgz b/pkg/helm/pkg/cmd/testdata/testcharts/compressedchart-0.3.0.tgz similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/compressedchart-0.3.0.tgz rename to pkg/helm/pkg/cmd/testdata/testcharts/compressedchart-0.3.0.tgz diff --git a/pkg/helm/cmd/helm/testdata/testcharts/compressedchart-with-hyphens-0.1.0.tgz b/pkg/helm/pkg/cmd/testdata/testcharts/compressedchart-with-hyphens-0.1.0.tgz similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/compressedchart-with-hyphens-0.1.0.tgz rename to pkg/helm/pkg/cmd/testdata/testcharts/compressedchart-with-hyphens-0.1.0.tgz diff --git a/pkg/helm/cmd/helm/testdata/testcharts/deprecated/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/deprecated/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/deprecated/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/deprecated/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/deprecated/README.md b/pkg/helm/pkg/cmd/testdata/testcharts/deprecated/README.md similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/deprecated/README.md rename to pkg/helm/pkg/cmd/testdata/testcharts/deprecated/README.md diff --git a/pkg/helm/cmd/helm/testdata/testcharts/empty/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/empty/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/empty/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/empty/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/empty/README.md b/pkg/helm/pkg/cmd/testdata/testcharts/empty/README.md similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/empty/README.md rename to pkg/helm/pkg/cmd/testdata/testcharts/empty/README.md diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/empty/templates/empty.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/empty/templates/empty.yaml new file mode 100644 index 00000000..c80812f6 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/empty/templates/empty.yaml @@ -0,0 +1 @@ +# This file is intentionally blank diff --git a/pkg/helm/cmd/helm/testdata/testcharts/empty/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/empty/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/empty/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/empty/values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/issue-7233/.helmignore b/pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/.helmignore similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/issue-7233/.helmignore rename to pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/.helmignore diff --git a/pkg/helm/cmd/helm/testdata/testcharts/issue-7233/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/issue-7233/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/Chart.yaml diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/charts/alpine-0.1.0.tgz b/pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/charts/alpine-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..21e50abe68ecea525af6daacd6ce4880658ff4be GIT binary patch literal 1166 zcmV;91abQxiwG0|00000|0w_~VMtOiV@ORlOnEsqVl!4SWK%V1T2nbTPgYhoO;>Dc zVQyr3R8em|NM&qo0PI-bZ`-yL&$IrDgZf&_^sfZQ zBPlDM>;HWqDLJvSCIePG`=NUgM3Eof9q)5@6pE5d8vZXwZIdk);^ONIV~kx+r~b(p z>pt0dHoF+jMwjerIvZcH3pN^0XX6WCC(z}QRK}vdV1Ie9u5tem!IFO>ZMc$i*o-19 z%fo9tN#4X!Mkdv~w0`I|=lIXY ztjqt)tIMAM=a3Jcmc@3^F!QDL>E*oA@ZVBV3bbT~VPH_I?5kyiXZSy!OvfGm&!&@} z|7Vd-{lDcho5M$yMFm;R(BeD-2(>%j?UEZ3goYBAHun3$ZkK!tl_Lzq5rDuY3FDRl zyn-Kcf*8v(xviF@C0Q~FSHl7&t|=QVw{chi+(2b01FOJlOmCsid76V&N;FWJZ$2mk zgf-uggw!{!@ZL6ySCxR3(tugPrGYwEP@}XC(hxzNqivvcFv9>siQ4kC5~%%N4oZ_L zl{9=Q$l)b^(Ni|e;el(uC%xe*!DsljQkG!9f7%sX8jCWe7*ZuSBweQk1>TYYZh_Ej zy-zp+wt6>!RsjkDY6UhY_~wA>3g?qzO_@Xr1$nH#w~8I^;{HwmKde>W;i? z<>RG?7H+qLBwRDD4^8Gn=Wz4bYOFGD!#P7`aNQ`_HkSTF2+V+dAC}?0*(L-gK=46kxW};+=QT5iWd&&c^=Oj^@n&NV~?# z9VH`h+gjw3w9gZRCc)ImEopyJyuh_fIiVWOzYsg&R8v(H=+f3t@o>rI&1z*KfajmlWA4_IR%UpIu&c`+qj-@Be3!SIs4U4%cmt`#*^y01M;Jy|$KLZvXgJ5yr zgDxQBno_IuwoMf-uWd+gTP2+?ZJC3b+Z-@TRpmaZm8)=AkR@$e@CO&%1+2KBd*Dae z*7$_(^5mk^4KJWoGKr!eRRt+Z@WoLGun!#(Ob_O^6O!S<;+H=5gEai7JFD@R6a58v z_WnPabof6Vv02ambBHBlT@4QZ0elz?LKysgVzigjNyC5F44#Vqe}ezr`~T!>Jn8v= g4msw(3xl5iz4X#cFaHJkJpcgz|0RoI5da_n0GV+#p8x;= literal 0 HcmV?d00001 diff --git a/pkg/helm/cmd/helm/testdata/testcharts/issue-7233/requirements.lock b/pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/requirements.lock similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/issue-7233/requirements.lock rename to pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/requirements.lock diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/requirements.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/requirements.yaml new file mode 100644 index 00000000..f0195cb1 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/requirements.yaml @@ -0,0 +1,4 @@ +dependencies: +- name: alpine + version: 0.1.0 + repository: file://../alpine diff --git a/pkg/helm/cmd/helm/testdata/testcharts/issue-7233/templates/configmap.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/templates/configmap.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/issue-7233/templates/configmap.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/templates/configmap.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/issue-7233/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/issue-7233/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/issue-9027/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/issue-9027/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/issue-9027/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/issue-9027/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/issue-9027/charts/subchart/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/issue-9027/charts/subchart/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/issue-9027/charts/subchart/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/issue-9027/charts/subchart/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/issue-9027/charts/subchart/templates/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/issue-9027/charts/subchart/templates/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/issue-9027/charts/subchart/templates/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/issue-9027/charts/subchart/templates/values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/issue-9027/charts/subchart/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/issue-9027/charts/subchart/values.yaml similarity index 99% rename from pkg/helm/cmd/helm/testdata/testcharts/issue-9027/charts/subchart/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/issue-9027/charts/subchart/values.yaml index 0b9d1ae8..0da52421 100644 --- a/pkg/helm/cmd/helm/testdata/testcharts/issue-9027/charts/subchart/values.yaml +++ b/pkg/helm/pkg/cmd/testdata/testcharts/issue-9027/charts/subchart/values.yaml @@ -7,6 +7,7 @@ global: key5: 5 key6: 6 + hash: key1: 1 key2: 2 diff --git a/pkg/helm/cmd/helm/testdata/testcharts/issue-9027/templates/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/issue-9027/templates/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/issue-9027/templates/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/issue-9027/templates/values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/issue-9027/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/issue-9027/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/issue-9027/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/issue-9027/values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/issue1979/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/issue1979/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/issue1979/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/issue1979/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/issue1979/README.md b/pkg/helm/pkg/cmd/testdata/testcharts/issue1979/README.md similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/issue1979/README.md rename to pkg/helm/pkg/cmd/testdata/testcharts/issue1979/README.md diff --git a/pkg/helm/cmd/helm/testdata/testcharts/issue1979/extra_values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/issue1979/extra_values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/issue1979/extra_values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/issue1979/extra_values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/issue1979/more_values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/issue1979/more_values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/issue1979/more_values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/issue1979/more_values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/issue1979/templates/alpine-pod.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/issue1979/templates/alpine-pod.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/issue1979/templates/alpine-pod.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/issue1979/templates/alpine-pod.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/issue1979/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/issue1979/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/issue1979/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/issue1979/values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/lib-chart/.helmignore b/pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/.helmignore similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/lib-chart/.helmignore rename to pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/.helmignore diff --git a/pkg/helm/cmd/helm/testdata/testcharts/lib-chart/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/Chart.yaml similarity index 61% rename from pkg/helm/cmd/helm/testdata/testcharts/lib-chart/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/Chart.yaml index 52cb0dfd..4dcddc85 100755 --- a/pkg/helm/cmd/helm/testdata/testcharts/lib-chart/Chart.yaml +++ b/pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/Chart.yaml @@ -5,8 +5,8 @@ version: 0.0.5 appVersion: 0.0.5 home: https://helm.sh maintainers: - - name: technosophos - email: technosophos@gmail.com - - name: prydonius - email: adnan@bitnami.com +- name: technosophos + email: technosophos@gmail.com +- name: prydonius + email: adnan@bitnami.com type: Library diff --git a/pkg/helm/cmd/helm/testdata/testcharts/lib-chart/README.md b/pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/README.md similarity index 99% rename from pkg/helm/cmd/helm/testdata/testcharts/lib-chart/README.md rename to pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/README.md index 87b753f2..f69ff1c0 100644 --- a/pkg/helm/cmd/helm/testdata/testcharts/lib-chart/README.md +++ b/pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/README.md @@ -48,7 +48,7 @@ Note that the `common.service` template defines two parameters: - A template name containing the service definition overrides A limitation of the Go template library is that a template can only take a -single argument. The `list` function is used to workaround this by constructing +single argument. The `list` function is used to work around this by constructing a list or array of arguments that is passed to the template. The `common.service` template is responsible for rendering the templates with diff --git a/pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_chartref.tpl b/pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_chartref.tpl similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_chartref.tpl rename to pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_chartref.tpl diff --git a/pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_configmap.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_configmap.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_configmap.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_configmap.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_container.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_container.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_container.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_container.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_deployment.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_deployment.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_deployment.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_deployment.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_envvar.tpl b/pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_envvar.tpl similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_envvar.tpl rename to pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_envvar.tpl diff --git a/pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_fullname.tpl b/pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_fullname.tpl similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_fullname.tpl rename to pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_fullname.tpl diff --git a/pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_ingress.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_ingress.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_ingress.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_ingress.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_metadata.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_metadata.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_metadata.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_metadata.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_metadata_annotations.tpl b/pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_metadata_annotations.tpl similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_metadata_annotations.tpl rename to pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_metadata_annotations.tpl diff --git a/pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_metadata_labels.tpl b/pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_metadata_labels.tpl similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_metadata_labels.tpl rename to pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_metadata_labels.tpl diff --git a/pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_name.tpl b/pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_name.tpl similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_name.tpl rename to pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_name.tpl diff --git a/pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_persistentvolumeclaim.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_persistentvolumeclaim.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_persistentvolumeclaim.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_persistentvolumeclaim.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_secret.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_secret.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_secret.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_secret.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_service.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_service.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_service.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_service.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_util.tpl b/pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_util.tpl similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_util.tpl rename to pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_util.tpl diff --git a/pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_volume.tpl b/pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_volume.tpl similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/lib-chart/templates/_volume.tpl rename to pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/templates/_volume.tpl diff --git a/pkg/helm/cmd/helm/testdata/testcharts/lib-chart/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/lib-chart/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/lib-chart/values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/object-order/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/object-order/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/object-order/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/object-order/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/object-order/templates/01-a.yml b/pkg/helm/pkg/cmd/testdata/testcharts/object-order/templates/01-a.yml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/object-order/templates/01-a.yml rename to pkg/helm/pkg/cmd/testdata/testcharts/object-order/templates/01-a.yml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/object-order/templates/02-b.yml b/pkg/helm/pkg/cmd/testdata/testcharts/object-order/templates/02-b.yml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/object-order/templates/02-b.yml rename to pkg/helm/pkg/cmd/testdata/testcharts/object-order/templates/02-b.yml diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/object-order/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/object-order/values.yaml new file mode 100644 index 00000000..e69de29b diff --git a/pkg/helm/cmd/helm/testdata/testcharts/oci-dependent-chart-0.1.0.tgz b/pkg/helm/pkg/cmd/testdata/testcharts/oci-dependent-chart-0.1.0.tgz similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/oci-dependent-chart-0.1.0.tgz rename to pkg/helm/pkg/cmd/testdata/testcharts/oci-dependent-chart-0.1.0.tgz diff --git a/pkg/helm/cmd/helm/testdata/testcharts/pre-release-chart-0.1.0-alpha.tgz b/pkg/helm/pkg/cmd/testdata/testcharts/pre-release-chart-0.1.0-alpha.tgz similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/pre-release-chart-0.1.0-alpha.tgz rename to pkg/helm/pkg/cmd/testdata/testcharts/pre-release-chart-0.1.0-alpha.tgz diff --git a/pkg/helm/cmd/helm/testdata/testcharts/reqtest-0.1.0.tgz b/pkg/helm/pkg/cmd/testdata/testcharts/reqtest-0.1.0.tgz similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/reqtest-0.1.0.tgz rename to pkg/helm/pkg/cmd/testdata/testcharts/reqtest-0.1.0.tgz diff --git a/pkg/helm/cmd/helm/testdata/testcharts/reqtest/.helmignore b/pkg/helm/pkg/cmd/testdata/testcharts/reqtest/.helmignore similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/reqtest/.helmignore rename to pkg/helm/pkg/cmd/testdata/testcharts/reqtest/.helmignore diff --git a/pkg/helm/cmd/helm/testdata/testcharts/reqtest/Chart.lock b/pkg/helm/pkg/cmd/testdata/testcharts/reqtest/Chart.lock similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/reqtest/Chart.lock rename to pkg/helm/pkg/cmd/testdata/testcharts/reqtest/Chart.lock diff --git a/pkg/helm/cmd/helm/testdata/testcharts/reqtest/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/reqtest/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/reqtest/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/reqtest/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/.helmignore b/pkg/helm/pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart/.helmignore similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/.helmignore rename to pkg/helm/pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart/.helmignore diff --git a/pkg/helm/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart/values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/.helmignore b/pkg/helm/pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart2/.helmignore similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/.helmignore rename to pkg/helm/pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart2/.helmignore diff --git a/pkg/helm/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart2/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart2/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart2/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart2/values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart3-0.2.0.tgz b/pkg/helm/pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart3-0.2.0.tgz similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart3-0.2.0.tgz rename to pkg/helm/pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart3-0.2.0.tgz diff --git a/pkg/helm/cmd/helm/testdata/testcharts/reqtest/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/reqtest/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/reqtest/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/reqtest/values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/signtest-0.1.0.tgz b/pkg/helm/pkg/cmd/testdata/testcharts/signtest-0.1.0.tgz similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/signtest-0.1.0.tgz rename to pkg/helm/pkg/cmd/testdata/testcharts/signtest-0.1.0.tgz diff --git a/pkg/helm/cmd/helm/testdata/testcharts/signtest-0.1.0.tgz.prov b/pkg/helm/pkg/cmd/testdata/testcharts/signtest-0.1.0.tgz.prov similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/signtest-0.1.0.tgz.prov rename to pkg/helm/pkg/cmd/testdata/testcharts/signtest-0.1.0.tgz.prov diff --git a/pkg/helm/cmd/helm/testdata/testcharts/signtest/.helmignore b/pkg/helm/pkg/cmd/testdata/testcharts/signtest/.helmignore similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/signtest/.helmignore rename to pkg/helm/pkg/cmd/testdata/testcharts/signtest/.helmignore diff --git a/pkg/helm/cmd/helm/testdata/testcharts/signtest/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/signtest/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/signtest/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/signtest/Chart.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/signtest/alpine/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/signtest/alpine/Chart.yaml similarity index 78% rename from pkg/helm/cmd/helm/testdata/testcharts/signtest/alpine/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/signtest/alpine/Chart.yaml index 5269b5cf..eec26122 100644 --- a/pkg/helm/cmd/helm/testdata/testcharts/signtest/alpine/Chart.yaml +++ b/pkg/helm/pkg/cmd/testdata/testcharts/signtest/alpine/Chart.yaml @@ -3,5 +3,5 @@ description: Deploy a basic Alpine Linux pod home: https://helm.sh/helm name: alpine sources: - - https://github.com/helm/helm +- https://github.com/helm/helm version: 0.1.0 diff --git a/pkg/helm/cmd/helm/testdata/testcharts/signtest/alpine/README.md b/pkg/helm/pkg/cmd/testdata/testcharts/signtest/alpine/README.md similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/signtest/alpine/README.md rename to pkg/helm/pkg/cmd/testdata/testcharts/signtest/alpine/README.md diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/signtest/alpine/templates/alpine-pod.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/signtest/alpine/templates/alpine-pod.yaml new file mode 100644 index 00000000..5bbae10a --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/signtest/alpine/templates/alpine-pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + app.kubernetes.io/managed-by: {{.Release.Service}} + chartName: {{.Chart.Name}} + chartVersion: {{.Chart.Version | quote}} +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.3" + command: ["/bin/sleep","9000"] diff --git a/pkg/helm/cmd/helm/testdata/testcharts/signtest/alpine/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/signtest/alpine/values.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/signtest/alpine/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/signtest/alpine/values.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/signtest/templates/pod.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/signtest/templates/pod.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/signtest/templates/pod.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/signtest/templates/pod.yaml diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/signtest/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/signtest/values.yaml new file mode 100644 index 00000000..e69de29b diff --git a/pkg/helm/cmd/helm/testdata/testcharts/subchart/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/Chart.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/subchart/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/subchart/Chart.yaml diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/charts/subchartA/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/subchart/charts/subchartA/Chart.yaml diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/subchart/charts/subchartA/templates/service.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/charts/subchartA/templates/service.yaml new file mode 100644 index 00000000..27501e1e --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/charts/subchartA/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Chart.Name }} + labels: + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.externalPort }} + targetPort: {{ .Values.service.internalPort }} + protocol: TCP + name: {{ .Values.service.name }} + selector: + app.kubernetes.io/name: {{ .Chart.Name }} diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/subchart/charts/subchartA/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/charts/subchartA/values.yaml new file mode 100644 index 00000000..f0381ae6 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/charts/subchartA/values.yaml @@ -0,0 +1,17 @@ +# Default values for subchart. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +# subchartA +service: + name: apache + type: ClusterIP + externalPort: 80 + internalPort: 80 +SCAdata: + SCAbool: false + SCAfloat: 3.1 + SCAint: 55 + SCAstring: "jabba" + SCAnested1: + SCAnested2: true + diff --git a/pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/charts/subchartB/Chart.yaml similarity index 100% rename from pkg/helm/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/Chart.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/subchart/charts/subchartB/Chart.yaml diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/subchart/charts/subchartB/templates/service.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/charts/subchartB/templates/service.yaml new file mode 100644 index 00000000..27501e1e --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/charts/subchartB/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Chart.Name }} + labels: + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.externalPort }} + targetPort: {{ .Values.service.internalPort }} + protocol: TCP + name: {{ .Values.service.name }} + selector: + app.kubernetes.io/name: {{ .Chart.Name }} diff --git a/pkg/helm/cmd/helm/testdata/testcharts/subchart/charts/subchartB/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/charts/subchartB/values.yaml similarity index 93% rename from pkg/helm/cmd/helm/testdata/testcharts/subchart/charts/subchartB/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/subchart/charts/subchartB/values.yaml index a9e55df2..0ada0aad 100644 --- a/pkg/helm/cmd/helm/testdata/testcharts/subchart/charts/subchartB/values.yaml +++ b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/charts/subchartB/values.yaml @@ -20,9 +20,9 @@ exports: SCBexported2: SCBexported2A: "blaster" - - configmap: - configmap: + + configmap: + configmap: value: "bar" global: @@ -34,3 +34,6 @@ global: metadata: all: port: 8775 + + + diff --git a/pkg/helm/cmd/helm/testdata/testcharts/subchart/crds/crdA.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/crds/crdA.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/subchart/crds/crdA.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/subchart/crds/crdA.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/subchart/extra_values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/extra_values.yaml similarity index 86% rename from pkg/helm/cmd/helm/testdata/testcharts/subchart/extra_values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/subchart/extra_values.yaml index 892ee073..5976bd17 100644 --- a/pkg/helm/cmd/helm/testdata/testcharts/subchart/extra_values.yaml +++ b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/extra_values.yaml @@ -2,4 +2,4 @@ configmap: enabled: true - value: "qux" + value: "qux" \ No newline at end of file diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/subchart/templates/NOTES.txt b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/templates/NOTES.txt new file mode 100644 index 00000000..4bdf443f --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/templates/NOTES.txt @@ -0,0 +1 @@ +Sample notes for {{ .Chart.Name }} \ No newline at end of file diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/subchart/templates/service.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/templates/service.yaml new file mode 100644 index 00000000..19c931cc --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/templates/service.yaml @@ -0,0 +1,25 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Chart.Name }} + labels: + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + app.kubernetes.io/instance: "{{ .Release.Name }}" + kube-version/major: "{{ .Capabilities.KubeVersion.Major }}" + kube-version/minor: "{{ .Capabilities.KubeVersion.Minor }}" + kube-version/version: "v{{ .Capabilities.KubeVersion.Major }}.{{ .Capabilities.KubeVersion.Minor }}.0" +{{- if .Capabilities.APIVersions.Has "helm.k8s.io/test" }} + kube-api-version/test: v1 +{{- end }} +{{- if .Capabilities.APIVersions.Has "helm.k8s.io/test2" }} + kube-api-version/test2: v2 +{{- end }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.externalPort }} + targetPort: {{ .Values.service.internalPort }} + protocol: TCP + name: {{ .Values.service.name }} + selector: + app.kubernetes.io/name: {{ .Chart.Name }} diff --git a/pkg/helm/cmd/helm/testdata/testcharts/subchart/templates/subdir/configmap.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/templates/subdir/configmap.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/subchart/templates/subdir/configmap.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/subchart/templates/subdir/configmap.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/subchart/templates/subdir/role.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/templates/subdir/role.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/subchart/templates/subdir/role.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/subchart/templates/subdir/role.yaml diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/subchart/templates/subdir/rolebinding.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/templates/subdir/rolebinding.yaml new file mode 100644 index 00000000..5d193f1a --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/templates/subdir/rolebinding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ .Chart.Name }}-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ .Chart.Name }}-role +subjects: +- kind: ServiceAccount + name: {{ .Chart.Name }}-sa + namespace: default diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/subchart/templates/subdir/serviceaccount.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/templates/subdir/serviceaccount.yaml new file mode 100644 index 00000000..7126c7d8 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/templates/subdir/serviceaccount.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Chart.Name }}-sa diff --git a/pkg/helm/cmd/helm/testdata/testcharts/subchart/templates/tests/test-config.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/templates/tests/test-config.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/subchart/templates/tests/test-config.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/subchart/templates/tests/test-config.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/subchart/templates/tests/test-nothing.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/templates/tests/test-nothing.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/subchart/templates/tests/test-nothing.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/subchart/templates/tests/test-nothing.yaml diff --git a/pkg/helm/cmd/helm/testdata/testcharts/subchart/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/values.yaml similarity index 99% rename from pkg/helm/cmd/helm/testdata/testcharts/subchart/values.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/subchart/values.yaml index 07446f0c..bcbebb5c 100644 --- a/pkg/helm/cmd/helm/testdata/testcharts/subchart/values.yaml +++ b/pkg/helm/pkg/cmd/testdata/testcharts/subchart/values.yaml @@ -8,6 +8,7 @@ service: externalPort: 80 internalPort: 80 + SC1data: SC1bool: true SC1float: 3.14 diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/test-0.1.0.tgz b/pkg/helm/pkg/cmd/testdata/testcharts/test-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..9ed772a7f924ffc4e280de8faa67ff88d857f9ca GIT binary patch literal 319 zcmV-F0l@wriwG0|00000|0w_~VMtOiV@ORlOnEsqVl!4SWK%V1T2nbTPgYhoO;>Dc zVQyr3R8em|NM&qo0PK~)O2jY_hI{T)1iW`A+a?QpRj+z*@ji5?8c5TUOpCs~Y($V; zdeC-J@?D0xbY|fH8m7Rz+gJ2ly<=~SH2?t6O%p2sq!nb{6jDj3Wv!Ju6d?CfHHCmx zwn>-*qc7mcv(n@K2soN&^1%%5C}vrnDYV{<9QzXm7VRY)q8q%J9HSTQ;5uts7MJ80 z_fk6S=@`3m{`wMBvV!v3w`G=z{?BM93akA9mi&i$pZedxA^X1tH{y;uoGgpa5Jc0X z%-7y5vjx$Arhc@@6m}1fr~9Xh-}pCYEc(CKiT|}$I{Uu`-`;rR0;9EbuCn`?OeXX1 R`3L|2|NmE1eCz-a007`FmKp#6 literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/test/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/test/Chart.yaml new file mode 100644 index 00000000..53e47c82 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/test/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: Test chart for untar conflict testing +name: test +version: 0.1.0 diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/test/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/test/values.yaml new file mode 100644 index 00000000..2f01ba53 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/test/values.yaml @@ -0,0 +1 @@ +# Default values for test diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/test1-0.1.0.tgz b/pkg/helm/pkg/cmd/testdata/testcharts/test1-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..60e00324cd0c127c4449507407a2cbadf5d1cd5b GIT binary patch literal 327 zcmV-N0l5AjiwG0|00000|0w_~VMtOiV@ORlOnEsqVl!4SWK%V1T2nbTPgYhoO;>Dc zVQyr3R8em|NM&qo0PK~)PJ}QJhB@;TO}w)+uwb%R^{NLq-Y4v!O-do{K=kbmpqEC& zL0RLb-=!Hy7$*6@j<6Tp-Mo;K?2THJYybd2wcpPt0E8!CQ6^X-6j)S%MFN=TIVK9W z_?eKw3%LY-+$$XJpFq8SV&`>d4{F5p#J+aAm$^Q&7gbNuP)+C5V1=BjJKGpti<*+7 z?Q?3W<-=TII(~i%GRz?T>)|4+EdGyV2KHNB0Bin3RfYcVt33LD4zAQKH#8VgO9p1s z=TLnACXHfW42%E5ZEr}}@9rOtcaNF8$}jd<^}h`LFRt@4`hO0l9giPNURy6@44<;E ZpGNN^kx1lncn<&o|Ns7?^pyY*006??l&Js! literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/test1/Chart.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/test1/Chart.yaml new file mode 100644 index 00000000..3dc8fbbf --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/test1/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: Test chart for untar conflict testing +name: test1 +version: 0.1.0 diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/test1/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/test1/values.yaml new file mode 100644 index 00000000..823016ff --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/test1/values.yaml @@ -0,0 +1,2 @@ +# Default values for test1# Default values for test1 + diff --git a/pkg/helm/cmd/helm/testdata/testcharts/upgradetest/templates/configmap.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/upgradetest/templates/configmap.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testcharts/upgradetest/templates/configmap.yaml rename to pkg/helm/pkg/cmd/testdata/testcharts/upgradetest/templates/configmap.yaml diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/upgradetest/values.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/upgradetest/values.yaml new file mode 100644 index 00000000..c429f41f --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/upgradetest/values.yaml @@ -0,0 +1 @@ +favoriteDrink: beer \ No newline at end of file diff --git a/pkg/helm/pkg/cmd/testdata/testplugin/plugin.yaml b/pkg/helm/pkg/cmd/testdata/testplugin/plugin.yaml new file mode 100644 index 00000000..3ee5d04f --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testplugin/plugin.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: v1 +name: testplugin +type: cli/v1 +runtime: subprocess +config: + shortHelp: "echo test" + longHelp: "This echos test" + ignoreFlags: false +runtimeConfig: + platformCommand: + - command: "echo test" diff --git a/pkg/helm/cmd/helm/testdata/testserver/index.yaml b/pkg/helm/pkg/cmd/testdata/testserver/index.yaml similarity index 100% rename from pkg/helm/cmd/helm/testdata/testserver/index.yaml rename to pkg/helm/pkg/cmd/testdata/testserver/index.yaml diff --git a/pkg/helm/cmd/helm/testdata/testserver/repository/repositories.yaml b/pkg/helm/pkg/cmd/testdata/testserver/repository/repositories.yaml similarity index 55% rename from pkg/helm/cmd/helm/testdata/testserver/repository/repositories.yaml rename to pkg/helm/pkg/cmd/testdata/testserver/repository/repositories.yaml index 010464cc..271301c9 100644 --- a/pkg/helm/cmd/helm/testdata/testserver/repository/repositories.yaml +++ b/pkg/helm/pkg/cmd/testdata/testserver/repository/repositories.yaml @@ -1,6 +1,6 @@ apiVersion: v1 generated: 2016-10-04T13:50:02.87649685-06:00 repositories: - - cache: "" - name: test - url: http://127.0.0.1:49216 +- cache: "" + name: test + url: http://127.0.0.1:49216 diff --git a/pkg/helm/cmd/helm/verify.go b/pkg/helm/pkg/cmd/verify.go similarity index 83% rename from pkg/helm/cmd/helm/verify.go rename to pkg/helm/pkg/cmd/verify.go index d34da118..72cf7771 100644 --- a/pkg/helm/cmd/helm/verify.go +++ b/pkg/helm/pkg/cmd/verify.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "fmt" @@ -21,8 +21,8 @@ import ( "github.com/spf13/cobra" - "github.com/werf/nelm/pkg/helm/cmd/helm/require" "github.com/werf/nelm/pkg/helm/pkg/action" + "github.com/werf/nelm/pkg/helm/pkg/cmd/require" ) const verifyDesc = ` @@ -44,21 +44,21 @@ func newVerifyCmd(out io.Writer) *cobra.Command { Short: "verify that a chart at the given path has been signed and is valid", Long: verifyDesc, Args: require.ExactArgs(1), - ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + ValidArgsFunction: func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { if len(args) == 0 { // Allow file completion when completing the argument for the path return nil, cobra.ShellCompDirectiveDefault } // No more completions, so disable file completion - return nil, cobra.ShellCompDirectiveNoFileComp + return noMoreArgsComp() }, - RunE: func(cmd *cobra.Command, args []string) error { - err := client.Run(args[0]) + RunE: func(_ *cobra.Command, args []string) error { + result, err := client.Run(args[0]) if err != nil { return err } - fmt.Fprint(out, client.Out) + fmt.Fprint(out, result) return nil }, diff --git a/pkg/helm/cmd/helm/verify_test.go b/pkg/helm/pkg/cmd/verify_test.go similarity index 99% rename from pkg/helm/cmd/helm/verify_test.go rename to pkg/helm/pkg/cmd/verify_test.go index 4ffe546f..ae373afd 100644 --- a/pkg/helm/cmd/helm/verify_test.go +++ b/pkg/helm/pkg/cmd/verify_test.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package helm +package cmd import ( "fmt" diff --git a/pkg/helm/pkg/cmd/version.go b/pkg/helm/pkg/cmd/version.go new file mode 100644 index 00000000..44843d95 --- /dev/null +++ b/pkg/helm/pkg/cmd/version.go @@ -0,0 +1,101 @@ +/* +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 cmd + +import ( + "fmt" + "io" + "text/template" + + "github.com/spf13/cobra" + + "github.com/werf/nelm/pkg/helm/intern/version" + "github.com/werf/nelm/pkg/helm/pkg/cmd/require" +) + +const versionDesc = ` +Show the version for Helm. + +This will print a representation the version of Helm. +The output will look something like this: + +version.BuildInfo{Version:"v3.2.1", GitCommit:"fe51cd1e31e6a202cba7dead9552a6d418ded79a", GitTreeState:"clean", GoVersion:"go1.13.10"} + +- Version is the semantic version of the release. +- GitCommit is the SHA for the commit that this version was built from. +- GitTreeState is "clean" if there are no local code changes when this binary was + built, and "dirty" if the binary was built from locally modified code. +- GoVersion is the version of Go that was used to compile Helm. + +When using the --template flag the following properties are available to use in +the template: + +- .Version contains the semantic version of Helm +- .GitCommit is the git commit +- .GitTreeState is the state of the git tree when Helm was built +- .GoVersion contains the version of Go that Helm was compiled with + +For example, --template='Version: {{.Version}}' outputs 'Version: v3.2.1'. +` + +type versionOptions struct { + short bool + template string +} + +func newVersionCmd(out io.Writer) *cobra.Command { + o := &versionOptions{} + + cmd := &cobra.Command{ + Use: "version", + Short: "print the helm version information", + Long: versionDesc, + Args: require.NoArgs, + ValidArgsFunction: noMoreArgsCompFunc, + RunE: func(_ *cobra.Command, _ []string) error { + return o.run(out) + }, + } + f := cmd.Flags() + f.BoolVar(&o.short, "short", false, "print the version number") + f.StringVar(&o.template, "template", "", "template for version string format") + + return cmd +} + +func (o *versionOptions) run(out io.Writer) error { + if o.template != "" { + tt, err := template.New("_").Parse(o.template) + if err != nil { + return err + } + return tt.Execute(out, version.Get()) + } + fmt.Fprintln(out, formatVersion(o.short)) + return nil +} + +func formatVersion(short bool) string { + v := version.Get() + if short { + if len(v.GitCommit) >= 7 { + return fmt.Sprintf("%s+g%s", v.Version, v.GitCommit[:7]) + } + return version.GetVersion() + } + return fmt.Sprintf("%#v", v) +} diff --git a/pkg/helm/pkg/cmd/version_test.go b/pkg/helm/pkg/cmd/version_test.go new file mode 100644 index 00000000..9551de76 --- /dev/null +++ b/pkg/helm/pkg/cmd/version_test.go @@ -0,0 +1,41 @@ +/* +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 cmd + +import ( + "testing" +) + +func TestVersion(t *testing.T) { + tests := []cmdTestCase{{ + name: "default", + cmd: "version", + golden: "output/version.txt", + }, { + name: "short", + cmd: "version --short", + golden: "output/version-short.txt", + }, { + name: "template", + cmd: "version --template='Version: {{.Version}}'", + golden: "output/version-template.txt", + }} + runTestCmd(t, tests) +} + +func TestVersionFileCompletion(t *testing.T) { + checkFileCompletion(t, "version", false) +} diff --git a/pkg/helm/pkg/downloader/cache.go b/pkg/helm/pkg/downloader/cache.go new file mode 100644 index 00000000..7d4f2858 --- /dev/null +++ b/pkg/helm/pkg/downloader/cache.go @@ -0,0 +1,89 @@ +/* +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 downloader + +import ( + "crypto/sha256" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + + "github.com/werf/nelm/pkg/helm/intern/fileutil" +) + +// Cache describes a cache that can get and put chart data. +// The cache key is the sha256 has of the content. sha256 is used in Helm for +// digests in index files providing a common key for checking content. +type Cache interface { + // Get returns a reader for the given key. + Get(key [sha256.Size]byte, cacheType string) (string, error) + // Put stores the given reader for the given key. + Put(key [sha256.Size]byte, data io.Reader, cacheType string) (string, error) +} + +// CacheChart specifies the content is a chart +var CacheChart = ".chart" + +// CacheProv specifies the content is a provenance file +var CacheProv = ".prov" + +// TODO: The cache assumes files because much of Helm assumes files. Convert +// Helm to pass content around instead of file locations. + +// DiskCache is a cache that stores data on disk. +type DiskCache struct { + Root string +} + +// Get returns a reader for the given key. +func (c *DiskCache) Get(key [sha256.Size]byte, cacheType string) (string, error) { + p := c.fileName(key, cacheType) + fi, err := os.Stat(p) + if err != nil { + return "", err + } + // Empty files treated as not exist because there is no content. + if fi.Size() == 0 { + return p, os.ErrNotExist + } + // directories should never happen unless something outside helm is operating + // on this content. + if fi.IsDir() { + return p, errors.New("is a directory") + } + return p, nil +} + +// Put stores the given reader for the given key. +// It returns the path to the stored file. +func (c *DiskCache) Put(key [sha256.Size]byte, data io.Reader, cacheType string) (string, error) { + // TODO: verify the key and digest of the key are the same. + p := c.fileName(key, cacheType) + if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { + slog.Error("failed to create cache directory") + return p, err + } + return p, fileutil.AtomicWriteFile(p, data, 0644) +} + +// fileName generates the filename in a structured manner where the first part is the +// directory and the full hash is the filename. +func (c *DiskCache) fileName(id [sha256.Size]byte, cacheType string) string { + return filepath.Join(c.Root, fmt.Sprintf("%02x", id[0]), fmt.Sprintf("%x", id)+cacheType) +} diff --git a/pkg/helm/pkg/downloader/cache_test.go b/pkg/helm/pkg/downloader/cache_test.go new file mode 100644 index 00000000..340c77ab --- /dev/null +++ b/pkg/helm/pkg/downloader/cache_test.go @@ -0,0 +1,122 @@ +/* +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 downloader + +import ( + "bytes" + "crypto/sha256" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// compiler check to ensure DiskCache implements the Cache interface. +var _ Cache = (*DiskCache)(nil) + +func TestDiskCache_PutAndGet(t *testing.T) { + // Setup a temporary directory for the cache + tmpDir := t.TempDir() + cache := &DiskCache{Root: tmpDir} + + // Test data + content := []byte("hello world") + key := sha256.Sum256(content) + + // --- Test case 1: Put and Get a regular file (prov=false) --- + t.Run("PutAndGetTgz", func(t *testing.T) { + // Put the data into the cache + path, err := cache.Put(key, bytes.NewReader(content), CacheChart) + require.NoError(t, err, "Put should not return an error") + + // Verify the file exists at the returned path + _, err = os.Stat(path) + require.NoError(t, err, "File should exist after Put") + + // Get the file from the cache + retrievedPath, err := cache.Get(key, CacheChart) + require.NoError(t, err, "Get should not return an error for existing file") + assert.Equal(t, path, retrievedPath, "Get should return the same path as Put") + + // Verify content + data, err := os.ReadFile(retrievedPath) + require.NoError(t, err) + assert.Equal(t, content, data, "Content of retrieved file should match original content") + }) + + // --- Test case 2: Put and Get a provenance file (prov=true) --- + t.Run("PutAndGetProv", func(t *testing.T) { + provContent := []byte("provenance data") + provKey := sha256.Sum256(provContent) + + path, err := cache.Put(provKey, bytes.NewReader(provContent), CacheProv) + require.NoError(t, err) + + retrievedPath, err := cache.Get(provKey, CacheProv) + require.NoError(t, err) + assert.Equal(t, path, retrievedPath) + + data, err := os.ReadFile(retrievedPath) + require.NoError(t, err) + assert.Equal(t, provContent, data) + }) + + // --- Test case 3: Get a non-existent file --- + t.Run("GetNonExistent", func(t *testing.T) { + nonExistentKey := sha256.Sum256([]byte("does not exist")) + _, err := cache.Get(nonExistentKey, CacheChart) + assert.ErrorIs(t, err, os.ErrNotExist, "Get for a non-existent key should return os.ErrNotExist") + }) + + // --- Test case 4: Put an empty file --- + t.Run("PutEmptyFile", func(t *testing.T) { + emptyContent := []byte{} + emptyKey := sha256.Sum256(emptyContent) + + path, err := cache.Put(emptyKey, bytes.NewReader(emptyContent), CacheChart) + require.NoError(t, err) + + // Get should return ErrNotExist for empty files + _, err = cache.Get(emptyKey, CacheChart) + assert.ErrorIs(t, err, os.ErrNotExist, "Get for an empty file should return os.ErrNotExist") + + // But the file should exist + _, err = os.Stat(path) + require.NoError(t, err, "Empty file should still exist on disk") + }) + + // --- Test case 5: Get a directory --- + t.Run("GetDirectory", func(t *testing.T) { + dirKey := sha256.Sum256([]byte("i am a directory")) + dirPath := cache.fileName(dirKey, CacheChart) + err := os.MkdirAll(dirPath, 0755) + require.NoError(t, err) + + _, err = cache.Get(dirKey, CacheChart) + assert.EqualError(t, err, "is a directory") + }) +} + +func TestDiskCache_fileName(t *testing.T) { + cache := &DiskCache{Root: "/tmp/cache"} + key := sha256.Sum256([]byte("some data")) + + assert.Equal(t, filepath.Join("/tmp/cache", "13", "1307990e6ba5ca145eb35e99182a9bec46531bc54ddf656a602c780fa0240dee.chart"), cache.fileName(key, CacheChart)) + assert.Equal(t, filepath.Join("/tmp/cache", "13", "1307990e6ba5ca145eb35e99182a9bec46531bc54ddf656a602c780fa0240dee.prov"), cache.fileName(key, CacheProv)) +} diff --git a/pkg/helm/pkg/downloader/chart_downloader.go b/pkg/helm/pkg/downloader/chart_downloader.go index 4dcf66f4..80ddd6e3 100644 --- a/pkg/helm/pkg/downloader/chart_downloader.go +++ b/pkg/helm/pkg/downloader/chart_downloader.go @@ -16,23 +16,27 @@ limitations under the License. package downloader import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" "fmt" "io" + "io/fs" + "log/slog" "net/url" "os" "path/filepath" "strings" - "github.com/Masterminds/semver/v3" - "github.com/pkg/errors" - - "github.com/werf/nelm/pkg/helm/internal/fileutil" - "github.com/werf/nelm/pkg/helm/internal/urlutil" + "github.com/werf/nelm/pkg/helm/intern/fileutil" + ifs "github.com/werf/nelm/pkg/helm/intern/third_party/dep/fs" + "github.com/werf/nelm/pkg/helm/intern/urlutil" "github.com/werf/nelm/pkg/helm/pkg/getter" "github.com/werf/nelm/pkg/helm/pkg/helmpath" "github.com/werf/nelm/pkg/helm/pkg/provenance" "github.com/werf/nelm/pkg/helm/pkg/registry" - "github.com/werf/nelm/pkg/helm/pkg/repo" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1" ) // VerificationStrategy describes a strategy for determining whether to verify a chart. @@ -73,6 +77,14 @@ type ChartDownloader struct { RegistryClient *registry.Client RepositoryConfig string RepositoryCache string + + // ContentCache is the location where Cache stores its files by default + // In previous versions of Helm the charts were put in the RepositoryCache. The + // repositories and charts are stored in 2 different caches. + ContentCache string + + // Cache specifies the cache implementation to use. + Cache Cache } // DownloadTo retrieves a chart. Depending on the settings, it may also download a provenance file. @@ -87,7 +99,14 @@ type ChartDownloader struct { // Returns a string path to the location where the file was downloaded and a verification // (if provenance was verified), or an error if something bad happened. func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *provenance.Verification, error) { - u, err := c.ResolveChartVersion(ref, version) + if c.Cache == nil { + if c.ContentCache == "" { + return "", nil, errors.New("content cache must be set") + } + c.Cache = &DiskCache{Root: c.ContentCache} + slog.Debug("set up default downloader cache") + } + hash, u, err := c.ResolveChartVersion(ref, version) if err != nil { return "", nil, err } @@ -97,9 +116,42 @@ func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *proven return "", nil, err } - data, err := g.Get(u.String(), c.Options...) - if err != nil { - return "", nil, err + // Check the cache for the content. Otherwise download it. + // Note, this process will pull from the cache but does not automatically populate + // the cache with the file it downloads. + var data *bytes.Buffer + var found bool + var digest []byte + var digest32 [32]byte + if hash != "" { + // if there is a hash, populate the other formats + // Strip the algorithm prefix (e.g., "sha256:") if present + digest, err = hex.DecodeString(stripDigestAlgorithm(hash)) + if err != nil { + return "", nil, err + } + if len(digest) != 32 { + return "", nil, fmt.Errorf("invalid digest length: %d", len(digest)) + } + + copy(digest32[:], digest) + if pth, err := c.Cache.Get(digest32, CacheChart); err == nil { + fdata, err := os.ReadFile(pth) + if err == nil { + found = true + data = bytes.NewBuffer(fdata) + slog.Debug("found chart in cache", "id", hash) + } + } + } + + if !found { + c.Options = append(c.Options, getter.WithAcceptHeader("application/gzip,application/octet-stream")) + + data, err = g.Get(u.String(), c.Options...) + if err != nil { + return "", nil, err + } } name := filepath.Base(u.Path) @@ -109,28 +161,48 @@ func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *proven } destfile := filepath.Join(dest, name) - if err := fileutil.AtomicWriteFile(destfile, data, 0644); err != nil { + + // Use PlatformAtomicWriteFile to handle platform-specific concurrency concerns + // (Windows requires locking to avoid "Access Denied" errors when multiple + // processes write the same file) + if err := fileutil.PlatformAtomicWriteFile(destfile, data, 0644); err != nil { return destfile, nil, err } // If provenance is requested, verify it. ver := &provenance.Verification{} if c.Verify > VerifyNever { - body, err := g.Get(u.String() + ".prov") - if err != nil { - if c.Verify == VerifyAlways { - return destfile, ver, errors.Errorf("failed to fetch provenance %q", u.String()+".prov") + found = false + var body *bytes.Buffer + if hash != "" { + if pth, err := c.Cache.Get(digest32, CacheProv); err == nil { + fdata, err := os.ReadFile(pth) + if err == nil { + found = true + body = bytes.NewBuffer(fdata) + slog.Debug("found provenance in cache", "id", hash) + } + } + } + if !found { + body, err = g.Get(u.String() + ".prov") + if err != nil { + if c.Verify == VerifyAlways { + return destfile, ver, fmt.Errorf("failed to fetch provenance %q", u.String()+".prov") + } + fmt.Fprintf(c.Out, "WARNING: Verification not found for %s: %s\n", ref, err) + return destfile, ver, nil } - fmt.Fprintf(c.Out, "WARNING: Verification not found for %s: %s\n", ref, err) - return destfile, ver, nil } provfile := destfile + ".prov" - if err := fileutil.AtomicWriteFile(provfile, body, 0644); err != nil { + + // Use PlatformAtomicWriteFile for the provenance file as well + if err := fileutil.PlatformAtomicWriteFile(provfile, body, 0644); err != nil { return destfile, nil, err } if c.Verify != VerifyLater { - ver, err = VerifyChart(destfile, c.Keyring) + ver, err = VerifyChart(destfile, destfile+".prov", c.Keyring) if err != nil { // Fail always in this case, since it means the verification step // failed. @@ -141,43 +213,144 @@ func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *proven return destfile, ver, nil } -func (c *ChartDownloader) getOciURI(ref, version string, u *url.URL) (*url.URL, error) { - var tag string - var err error - - // Evaluate whether an explicit version has been provided. Otherwise, determine version to use - _, errSemVer := semver.NewVersion(version) - if errSemVer == nil { - tag = version - } else { - // Retrieve list of repository tags - tags, err := c.RegistryClient.Tags(strings.TrimPrefix(ref, fmt.Sprintf("%s://", registry.OCIScheme))) - if err != nil { - return nil, err +// DownloadToCache retrieves resources while using a content based cache. +func (c *ChartDownloader) DownloadToCache(ref, version string) (string, *provenance.Verification, error) { + if c.Cache == nil { + if c.ContentCache == "" { + return "", nil, errors.New("content cache must be set") + } + c.Cache = &DiskCache{Root: c.ContentCache} + slog.Debug("set up default downloader cache") + } + + digestString, u, err := c.ResolveChartVersion(ref, version) + if err != nil { + return "", nil, err + } + + g, err := c.Getters.ByScheme(u.Scheme) + if err != nil { + return "", nil, err + } + + c.Options = append(c.Options, getter.WithAcceptHeader("application/gzip,application/octet-stream")) + + // Check the cache for the file + // Strip the algorithm prefix (e.g., "sha256:") if present + digest, err := hex.DecodeString(stripDigestAlgorithm(digestString)) + if err != nil { + return "", nil, fmt.Errorf("unable to decode digest: %w", err) + } + if digestString != "" && len(digest) != 32 { + return "", nil, fmt.Errorf("invalid digest length: %d", len(digest)) + } + var digest32 [32]byte + copy(digest32[:], digest) + + var pth string + // only fetch from the cache if we have a digest + if len(digest) > 0 { + pth, err = c.Cache.Get(digest32, CacheChart) + if err == nil { + slog.Debug("found chart in cache", "id", digestString) + } + } + if len(digest) == 0 || err != nil { + slog.Debug("attempting to download chart", "ref", ref, "version", version) + if err != nil && !os.IsNotExist(err) { + return "", nil, err } - if len(tags) == 0 { - return nil, errors.Errorf("Unable to locate any tags in provided repository: %s", ref) + + // Get file not in the cache + data, gerr := g.Get(u.String(), c.Options...) + if gerr != nil { + return "", nil, gerr + } + + // Generate the digest + if len(digest) == 0 { + digest32 = sha256.Sum256(data.Bytes()) } - // Determine if version provided - // If empty, try to get the highest available tag - // If exact version, try to find it - // If semver constraint string, try to find a match - tag, err = registry.GetTagMatchingVersionOrConstraint(tags, version) + pth, err = c.Cache.Put(digest32, data, CacheChart) if err != nil { - return nil, err + return "", nil, err } + slog.Debug("put downloaded chart in cache", "id", hex.EncodeToString(digest32[:])) } - u.Path = fmt.Sprintf("%s:%s", u.Path, tag) + // If provenance is requested, verify it. + ver := &provenance.Verification{} + if c.Verify > VerifyNever { + + ppth, err := c.Cache.Get(digest32, CacheProv) + if err == nil { + slog.Debug("found provenance in cache", "id", digestString) + } else { + if !os.IsNotExist(err) { + return pth, ver, err + } + + body, err := g.Get(u.String() + ".prov") + if err != nil { + if c.Verify == VerifyAlways { + return pth, ver, fmt.Errorf("failed to fetch provenance %q", u.String()+".prov") + } + fmt.Fprintf(c.Out, "WARNING: Verification not found for %s: %s\n", ref, err) + return pth, ver, nil + } + + ppth, err = c.Cache.Put(digest32, body, CacheProv) + if err != nil { + return "", nil, err + } + slog.Debug("put downloaded provenance file in cache", "id", hex.EncodeToString(digest32[:])) + } + + if c.Verify != VerifyLater { + + // provenance files pin to a specific name so this needs to be accounted for + // when verifying. + // Note, this does make an assumption that the name/version is unique to a + // hash when a provenance file is used. If this isn't true, this section of code + // will need to be reworked. + name := filepath.Base(u.Path) + if u.Scheme == registry.OCIScheme { + idx := strings.LastIndexByte(name, ':') + name = fmt.Sprintf("%s-%s.tgz", name[:idx], name[idx+1:]) + } + + // Copy chart to a known location with the right name for verification and then + // clean it up. + tmpdir := filepath.Dir(filepath.Join(c.ContentCache, "tmp")) + if err := os.MkdirAll(tmpdir, 0755); err != nil { + return pth, ver, err + } + tmpfile := filepath.Join(tmpdir, name) + err = ifs.CopyFile(pth, tmpfile) + if err != nil { + return pth, ver, err + } + // Not removing the tmp dir itself because a concurrent process may be using it + defer os.RemoveAll(tmpfile) - return u, err + ver, err = VerifyChart(tmpfile, ppth, c.Keyring) + if err != nil { + // Fail always in this case, since it means the verification step + // failed. + return pth, ver, err + } + } + } + return pth, ver, nil } // ResolveChartVersion resolves a chart reference to a URL. // -// It returns the URL and sets the ChartDownloader's Options that can fetch -// the URL using the appropriate Getter. +// It returns: +// - A hash of the content if available +// - The URL and sets the ChartDownloader's Options that can fetch the URL using the appropriate Getter. +// - An error if there is one // // A reference may be an HTTP URL, an oci reference URL, a 'reponame/chartname' // reference, or a local path. @@ -189,19 +362,26 @@ func (c *ChartDownloader) getOciURI(ref, version string, u *url.URL) (*url.URL, // - If version is non-empty, this will return the URL for that version // - If version is empty, this will return the URL for the latest version // - If no version can be found, an error is returned -func (c *ChartDownloader) ResolveChartVersion(ref, version string) (*url.URL, error) { +// +// TODO: support OCI hash +func (c *ChartDownloader) ResolveChartVersion(ref, version string) (string, *url.URL, error) { u, err := url.Parse(ref) if err != nil { - return nil, errors.Errorf("invalid chart URL format: %s", ref) + return "", nil, fmt.Errorf("invalid chart URL format: %s", ref) } if registry.IsOCI(u.String()) { - return c.getOciURI(ref, version, u) + if c.RegistryClient == nil { + return "", nil, fmt.Errorf("unable to lookup ref %s at version '%s', missing registry client", ref, version) + } + + digest, OCIref, err := c.RegistryClient.ValidateReference(ref, version, u) + return digest, OCIref, err } rf, err := loadRepoConfig(c.RepositoryConfig) if err != nil { - return u, err + return "", u, err } if u.IsAbs() && len(u.Host) > 0 && len(u.Path) > 0 { @@ -218,9 +398,9 @@ func (c *ChartDownloader) ResolveChartVersion(ref, version string) (*url.URL, er if err == ErrNoOwnerRepo { // Make sure to add the ref URL as the URL for the getter c.Options = append(c.Options, getter.WithURL(ref)) - return u, nil + return "", u, nil } - return u, err + return "", u, err } // If we get here, we don't need to go through the next phase of looking @@ -239,21 +419,20 @@ func (c *ChartDownloader) ResolveChartVersion(ref, version string) (*url.URL, er getter.WithPassCredentialsAll(rc.PassCredentialsAll), ) } - return u, nil + return "", u, nil } // See if it's of the form: repo/path_to_chart p := strings.SplitN(u.Path, "/", 2) if len(p) < 2 { - return u, errors.Errorf("non-absolute URLs should be in form of repo_name/path_to_chart, got: %s", u) + return "", u, fmt.Errorf("non-absolute URLs should be in form of repo_name/path_to_chart, got: %s", u) } repoName := p[0] chartName := p[1] rc, err := pickChartRepositoryConfigByName(repoName, rf.Repositories) - if err != nil { - return u, err + return "", u, err } // Now that we have the chart repository information we can use that URL @@ -262,7 +441,7 @@ func (c *ChartDownloader) ResolveChartVersion(ref, version string) (*url.URL, er r, err := repo.NewChartRepository(rc, c.Getters) if err != nil { - return u, err + return "", u, err } if r != nil && r.Config != nil { @@ -281,33 +460,33 @@ func (c *ChartDownloader) ResolveChartVersion(ref, version string) (*url.URL, er idxFile := filepath.Join(c.RepositoryCache, helmpath.CacheIndexFile(r.Config.Name)) i, err := repo.LoadIndexFile(idxFile) if err != nil { - return u, errors.Wrap(err, "no cached repo found. (try 'helm repo update')") + return "", u, fmt.Errorf("no cached repo found. (try 'helm repo update'): %w", err) } cv, err := i.Get(chartName, version) if err != nil { - return u, errors.Wrapf(err, "chart %q matching %s not found in %s index. (try 'helm repo update')", chartName, version, r.Config.Name) + return "", u, fmt.Errorf("chart %q matching %s not found in %s index. (try 'helm repo update'): %w", chartName, version, r.Config.Name, err) } if len(cv.URLs) == 0 { - return u, errors.Errorf("chart %q has no downloadable URLs", ref) + return "", u, fmt.Errorf("chart %q has no downloadable URLs", ref) } // TODO: Seems that picking first URL is not fully correct resolvedURL, err := repo.ResolveReferenceURL(rc.URL, cv.URLs[0]) - if err != nil { - return u, errors.Errorf("invalid chart URL format: %s", ref) + return cv.Digest, u, fmt.Errorf("invalid chart URL format: %s", ref) } - return url.Parse(resolvedURL) + loc, err := url.Parse(resolvedURL) + return cv.Digest, loc, err } // VerifyChart takes a path to a chart archive and a keyring, and verifies the chart. // // It assumes that a chart archive file is accompanied by a provenance file whose // name is the archive file name plus the ".prov" extension. -func VerifyChart(path, keyring string) (*provenance.Verification, error) { +func VerifyChart(path, provfile, keyring string) (*provenance.Verification, error) { // For now, error out if it's not a tar file. switch fi, err := os.Stat(path); { case err != nil: @@ -318,16 +497,26 @@ func VerifyChart(path, keyring string) (*provenance.Verification, error) { return nil, errors.New("chart must be a tgz file") } - provfile := path + ".prov" if _, err := os.Stat(provfile); err != nil { - return nil, errors.Wrapf(err, "could not load provenance file %s", provfile) + return nil, fmt.Errorf("could not load provenance file %s: %w", provfile, err) } sig, err := provenance.NewFromKeyring(keyring, "") if err != nil { - return nil, errors.Wrap(err, "failed to load keyring") + return nil, fmt.Errorf("failed to load keyring: %w", err) + } + + // Read archive and provenance files + archiveData, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read chart archive: %w", err) + } + provData, err := os.ReadFile(provfile) + if err != nil { + return nil, fmt.Errorf("failed to read provenance file: %w", err) } - return sig.Verify(path, provfile) + + return sig.Verify(archiveData, provData, filepath.Base(path)) } // isTar tests whether the given file is a tar file. @@ -342,12 +531,12 @@ func pickChartRepositoryConfigByName(name string, cfgs []*repo.Entry) (*repo.Ent for _, rc := range cfgs { if rc.Name == name { if rc.URL == "" { - return nil, errors.Errorf("no URL found for repository %s", name) + return nil, fmt.Errorf("no URL found for repository %s", name) } return rc, nil } } - return nil, errors.Errorf("repo %s not found", name) + return nil, fmt.Errorf("repo %s not found", name) } // scanReposForURL scans all repos to find which repo contains the given URL. @@ -380,7 +569,7 @@ func (c *ChartDownloader) scanReposForURL(u string, rf *repo.File) (*repo.Entry, idxFile := filepath.Join(c.RepositoryCache, helmpath.CacheIndexFile(r.Config.Name)) i, err := repo.LoadIndexFile(idxFile) if err != nil { - return nil, errors.Wrap(err, "no cached repo found. (try 'helm repo update')") + return nil, fmt.Errorf("no cached repo found. (try 'helm repo update'): %w", err) } for _, entry := range i.Entries { @@ -399,32 +588,17 @@ func (c *ChartDownloader) scanReposForURL(u string, rf *repo.File) (*repo.Entry, func loadRepoConfig(file string) (*repo.File, error) { r, err := repo.LoadFile(file) - if err != nil && !os.IsNotExist(errors.Cause(err)) { + if err != nil && !errors.Is(err, fs.ErrNotExist) { return nil, err } return r, nil } -type VerificationStrategyString string - -const ( - VerificationStrategyStringNever VerificationStrategyString = "never" - VerificationStrategyStringIfPossible VerificationStrategyString = "if-possible" - VerificationStrategyStringAlways VerificationStrategyString = "always" - VerificationStrategyStringLater VerificationStrategyString = "later" -) - -func (s VerificationStrategyString) ToVerificationStrategy() VerificationStrategy { - switch s { - case VerificationStrategyStringNever: - return VerifyNever - case VerificationStrategyStringIfPossible: - return VerifyIfPossible - case VerificationStrategyStringAlways: - return VerifyAlways - case VerificationStrategyStringLater: - return VerifyLater - default: - panic("unknown VerificationStrategyString value") +// stripDigestAlgorithm removes the algorithm prefix (e.g., "sha256:") from a digest string. +// If no prefix is present, the original string is returned unchanged. +func stripDigestAlgorithm(digest string) string { + if idx := strings.Index(digest, ":"); idx >= 0 { + return digest[idx+1:] } + return digest } diff --git a/pkg/helm/pkg/downloader/chart_downloader_test.go b/pkg/helm/pkg/downloader/chart_downloader_test.go index 16467743..f8bdbfbf 100644 --- a/pkg/helm/pkg/downloader/chart_downloader_test.go +++ b/pkg/helm/pkg/downloader/chart_downloader_test.go @@ -16,15 +16,21 @@ limitations under the License. package downloader import ( + "crypto/sha256" + "encoding/hex" "os" "path/filepath" "testing" - "github.com/werf/nelm/pkg/helm/internal/test/ensure" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/werf/nelm/pkg/helm/intern/test/ensure" "github.com/werf/nelm/pkg/helm/pkg/cli" "github.com/werf/nelm/pkg/helm/pkg/getter" - "github.com/werf/nelm/pkg/helm/pkg/repo" - "github.com/werf/nelm/pkg/helm/pkg/repo/repotest" + "github.com/werf/nelm/pkg/helm/pkg/registry" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1/repotest" ) const ( @@ -46,6 +52,7 @@ func TestResolveChartRef(t *testing.T) { {name: "reference, querystring repo", ref: "testing-querystring/alpine", expect: "http://example.com/alpine-1.2.3.tgz?key=value"}, {name: "reference, testing-relative repo", ref: "testing-relative/foo", expect: "http://example.com/helm/charts/foo-1.2.3.tgz"}, {name: "reference, testing-relative repo", ref: "testing-relative/bar", expect: "http://example.com/helm/bar-1.2.3.tgz"}, + {name: "reference, testing-relative repo", ref: "testing-relative/baz", expect: "http://example.com/path/to/baz-1.2.3.tgz"}, {name: "reference, testing-relative-trailing-slash repo", ref: "testing-relative-trailing-slash/foo", expect: "http://example.com/helm/charts/foo-1.2.3.tgz"}, {name: "reference, testing-relative-trailing-slash repo", ref: "testing-relative-trailing-slash/bar", expect: "http://example.com/helm/bar-1.2.3.tgz"}, {name: "encoded URL", ref: "encoded-url/foobar", expect: "http://example.com/with%2Fslash/charts/foobar-4.2.1.tgz"}, @@ -53,12 +60,23 @@ func TestResolveChartRef(t *testing.T) { {name: "full URL, file", ref: "file:///foo-1.2.3.tgz", fail: true}, {name: "invalid", ref: "invalid-1.2.3", fail: true}, {name: "not found", ref: "nosuchthing/invalid-1.2.3", fail: true}, + {name: "ref with tag", ref: "oci://example.com/helm-charts/nginx:15.4.2", expect: "oci://example.com/helm-charts/nginx:15.4.2"}, + {name: "no repository", ref: "oci://", fail: true}, + {name: "oci ref", ref: "oci://example.com/helm-charts/nginx", version: "15.4.2", expect: "oci://example.com/helm-charts/nginx:15.4.2"}, + {name: "oci ref with sha256 and version mismatch", ref: "oci://example.com/install/by/sha:0.1.1@sha256:d234555386402a5867ef0169fefe5486858b6d8d209eaf32fd26d29b16807fd6", version: "0.1.2", fail: true}, + } + + // Create a mock registry client for OCI references + registryClient, err := registry.NewClient() + if err != nil { + t.Fatal(err) } c := ChartDownloader{ Out: os.Stderr, RepositoryConfig: repoConfig, RepositoryCache: repoCache, + RegistryClient: registryClient, Getters: getter.All(&cli.EnvSettings{ RepositoryConfig: repoConfig, RepositoryCache: repoCache, @@ -66,7 +84,7 @@ func TestResolveChartRef(t *testing.T) { } for _, tt := range tests { - u, err := c.ResolveChartVersion(tt.ref, tt.version) + _, u, err := c.ResolveChartVersion(tt.ref, tt.version) if err != nil { if tt.fail { continue @@ -118,7 +136,7 @@ func TestResolveChartOpts(t *testing.T) { continue } - u, err := c.ResolveChartVersion(tt.ref, tt.version) + _, u, err := c.ResolveChartVersion(tt.ref, tt.version) if err != nil { t.Errorf("%s: failed with error %s", tt.name, err) continue @@ -142,7 +160,7 @@ func TestResolveChartOpts(t *testing.T) { } func TestVerifyChart(t *testing.T) { - v, err := VerifyChart("testdata/signtest-0.1.0.tgz", "testdata/helm-test-key.pub") + v, err := VerifyChart("testdata/signtest-0.1.0.tgz", "testdata/signtest-0.1.0.tgz.prov", "testdata/helm-test-key.pub") if err != nil { t.Fatal(err) } @@ -171,7 +189,11 @@ func TestIsTar(t *testing.T) { } func TestDownloadTo(t *testing.T) { - srv := repotest.NewTempServerWithCleanupAndBasicAuth(t, "testdata/*.tgz*") + srv := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/*.tgz*"), + repotest.WithMiddleware(repotest.BasicAuthMiddleware(t)), + ) defer srv.Stop() if err := srv.CreateIndex(); err != nil { t.Fatal(err) @@ -181,15 +203,19 @@ func TestDownloadTo(t *testing.T) { t.Fatal(err) } + contentCache := t.TempDir() + c := ChartDownloader{ Out: os.Stderr, Verify: VerifyAlways, Keyring: "testdata/helm-test-key.pub", RepositoryConfig: repoConfig, RepositoryCache: repoCache, + ContentCache: contentCache, Getters: getter.All(&cli.EnvSettings{ RepositoryConfig: repoConfig, RepositoryCache: repoCache, + ContentCache: contentCache, }), Options: []getter.Option{ getter.WithBasicAuth("username", "password"), @@ -218,12 +244,11 @@ func TestDownloadTo(t *testing.T) { func TestDownloadTo_TLS(t *testing.T) { // Set up mock server w/ tls enabled - srv, err := repotest.NewTempServerWithCleanup(t, "testdata/*.tgz*") - srv.Stop() - if err != nil { - t.Fatal(err) - } - srv.StartTLS() + srv := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/*.tgz*"), + repotest.WithTLSConfig(repotest.MakeTestTLSConfig(t, "../../testdata")), + ) defer srv.Stop() if err := srv.CreateIndex(); err != nil { t.Fatal(err) @@ -234,6 +259,7 @@ func TestDownloadTo_TLS(t *testing.T) { repoConfig := filepath.Join(srv.Root(), "repositories.yaml") repoCache := srv.Root() + contentCache := t.TempDir() c := ChartDownloader{ Out: os.Stderr, @@ -241,11 +267,19 @@ func TestDownloadTo_TLS(t *testing.T) { Keyring: "testdata/helm-test-key.pub", RepositoryConfig: repoConfig, RepositoryCache: repoCache, + ContentCache: contentCache, Getters: getter.All(&cli.EnvSettings{ RepositoryConfig: repoConfig, RepositoryCache: repoCache, + ContentCache: contentCache, }), - Options: []getter.Option{}, + Options: []getter.Option{ + getter.WithTLSClientConfig( + "", + "", + filepath.Join("../../testdata/rootca.crt"), + ), + }, } cname := "test/signtest" dest := srv.Root() @@ -274,23 +308,26 @@ func TestDownloadTo_VerifyLater(t *testing.T) { dest := t.TempDir() // Set up a fake repo - srv, err := repotest.NewTempServerWithCleanup(t, "testdata/*.tgz*") - if err != nil { - t.Fatal(err) - } + srv := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/*.tgz*"), + ) defer srv.Stop() if err := srv.LinkIndices(); err != nil { t.Fatal(err) } + contentCache := t.TempDir() c := ChartDownloader{ Out: os.Stderr, Verify: VerifyLater, RepositoryConfig: repoConfig, RepositoryCache: repoCache, + ContentCache: contentCache, Getters: getter.All(&cli.EnvSettings{ RepositoryConfig: repoConfig, RepositoryCache: repoCache, + ContentCache: contentCache, }), } cname := "/signtest-0.1.0.tgz" @@ -344,3 +381,139 @@ func TestScanReposForURL(t *testing.T) { t.Fatalf("expected ErrNoOwnerRepo, got %v", err) } } + +func TestDownloadToCache(t *testing.T) { + srv := repotest.NewTempServer(t, + repotest.WithChartSourceGlob("testdata/*.tgz*"), + ) + defer srv.Stop() + if err := srv.CreateIndex(); err != nil { + t.Fatal(err) + } + if err := srv.LinkIndices(); err != nil { + t.Fatal(err) + } + + // The repo file needs to point to our server. + repoFile := filepath.Join(srv.Root(), "repositories.yaml") + repoCache := srv.Root() + contentCache := t.TempDir() + + c := ChartDownloader{ + Out: os.Stderr, + Verify: VerifyNever, + RepositoryConfig: repoFile, + RepositoryCache: repoCache, + Getters: getter.All(&cli.EnvSettings{ + RepositoryConfig: repoFile, + RepositoryCache: repoCache, + ContentCache: contentCache, + }), + Cache: &DiskCache{Root: contentCache}, + } + + // Case 1: Chart not in cache, download it. + t.Run("download and cache chart", func(t *testing.T) { + // Clear cache for this test + os.RemoveAll(contentCache) + os.MkdirAll(contentCache, 0755) + c.Cache = &DiskCache{Root: contentCache} + + pth, v, err := c.DownloadToCache("test/signtest", "0.1.0") + require.NoError(t, err) + require.NotNil(t, v) + + // Check that the file exists at the returned path + _, err = os.Stat(pth) + require.NoError(t, err, "chart should exist at returned path") + + // Check that it's in the cache + digest, _, err := c.ResolveChartVersion("test/signtest", "0.1.0") + require.NoError(t, err) + digestBytes, err := hex.DecodeString(digest) + require.NoError(t, err) + var digestArray [sha256.Size]byte + copy(digestArray[:], digestBytes) + + cachePath, err := c.Cache.Get(digestArray, CacheChart) + require.NoError(t, err, "chart should now be in cache") + require.Equal(t, pth, cachePath) + }) + + // Case 2: Chart is in cache, get from cache. + t.Run("get chart from cache", func(t *testing.T) { + // The cache should be populated from the previous test. + // To prove it's coming from cache, we can stop the server. + // But repotest doesn't support restarting. + // Let's just call it again and assume it works if it's fast and doesn't error. + pth, v, err := c.DownloadToCache("test/signtest", "0.1.0") + require.NoError(t, err) + require.NotNil(t, v) + + _, err = os.Stat(pth) + require.NoError(t, err, "chart should exist at returned path") + }) + + // Case 3: Download with verification + t.Run("download and verify", func(t *testing.T) { + // Clear cache + os.RemoveAll(contentCache) + os.MkdirAll(contentCache, 0755) + c.Cache = &DiskCache{Root: contentCache} + c.Verify = VerifyAlways + c.Keyring = "testdata/helm-test-key.pub" + + _, v, err := c.DownloadToCache("test/signtest", "0.1.0") + require.NoError(t, err) + require.NotNil(t, v) + require.NotEmpty(t, v.FileHash, "verification should have a file hash") + + // Check that both chart and prov are in cache + digest, _, err := c.ResolveChartVersion("test/signtest", "0.1.0") + require.NoError(t, err) + digestBytes, err := hex.DecodeString(digest) + require.NoError(t, err) + var digestArray [sha256.Size]byte + copy(digestArray[:], digestBytes) + + _, err = c.Cache.Get(digestArray, CacheChart) + require.NoError(t, err, "chart should be in cache") + _, err = c.Cache.Get(digestArray, CacheProv) + require.NoError(t, err, "provenance file should be in cache") + + // Reset for other tests + c.Verify = VerifyNever + c.Keyring = "" + }) +} + +func TestStripDigestAlgorithm(t *testing.T) { + tests := map[string]struct { + input string + expected string + }{ + "sha256 prefixed digest": { + input: "sha256:aef46c66a7f2d5a12a7e3f54a64790daf5c9a9e66af3f46955efdaa6c900341d", + expected: "aef46c66a7f2d5a12a7e3f54a64790daf5c9a9e66af3f46955efdaa6c900341d", + }, + "sha512 prefixed digest": { + input: "sha512:abcdef1234567890", + expected: "abcdef1234567890", + }, + "plain hex digest without prefix": { + input: "aef46c66a7f2d5a12a7e3f54a64790daf5c9a9e66af3f46955efdaa6c900341d", + expected: "aef46c66a7f2d5a12a7e3f54a64790daf5c9a9e66af3f46955efdaa6c900341d", + }, + "empty string": { + input: "", + expected: "", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + result := stripDigestAlgorithm(tt.input) + assert.Equalf(t, tt.expected, result, "stripDigestAlgorithm(%q) = %q, want %q", tt.input, result, tt.expected) + }) + } +} diff --git a/pkg/helm/pkg/downloader/chart_downloader_windows_test.go b/pkg/helm/pkg/downloader/chart_downloader_windows_test.go new file mode 100644 index 00000000..734dd8e4 --- /dev/null +++ b/pkg/helm/pkg/downloader/chart_downloader_windows_test.go @@ -0,0 +1,131 @@ +//go:build windows + +/* +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 downloader + +import ( + "os" + "path/filepath" + "sync" + "testing" + + "github.com/werf/nelm/pkg/helm/pkg/cli" + "github.com/werf/nelm/pkg/helm/pkg/getter" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1/repotest" +) + +// TestParallelDownloadTo tests that parallel downloads to the same file +// don't cause "Access Denied" errors on Windows. This test is Windows-specific +// because the file locking behavior is only needed on Windows. +func TestParallelDownloadTo(t *testing.T) { + // Set up a simple test server with a chart + srv := repotest.NewTempServer(t, repotest.WithChartSourceGlob("testdata/*.tgz")) + defer srv.Stop() + + if err := srv.CreateIndex(); err != nil { + t.Fatal(err) + } + + dest := t.TempDir() + cacheDir := t.TempDir() + + c := ChartDownloader{ + Out: os.Stderr, + RepositoryConfig: repoConfig, + RepositoryCache: repoCache, + ContentCache: cacheDir, + Cache: &DiskCache{Root: cacheDir}, + Getters: getter.All(&cli.EnvSettings{ + RepositoryConfig: repoConfig, + RepositoryCache: repoCache, + ContentCache: cacheDir, + }), + } + + // Use a direct URL to bypass repository lookup + chartURL := srv.URL() + "/local-subchart-0.1.0.tgz" + + // Number of parallel downloads to attempt + numDownloads := 10 + var wg sync.WaitGroup + errors := make([]error, numDownloads) + + // Launch multiple goroutines to download the same chart simultaneously + for i := 0; i < numDownloads; i++ { + wg.Add(1) + go func(index int) { + defer wg.Done() + _, _, err := c.DownloadTo(chartURL, "", dest) + errors[index] = err + }(i) + } + + wg.Wait() + + // Check if any download failed + failedCount := 0 + for i, err := range errors { + if err != nil { + t.Logf("Download %d failed: %v", i, err) + failedCount++ + } + } + + // With the file locking fix, all parallel downloads should succeed + if failedCount > 0 { + t.Errorf("Parallel downloads failed: %d out of %d downloads failed due to concurrent file access", failedCount, numDownloads) + } + + // Verify the file exists and is valid + expectedFile := filepath.Join(dest, "local-subchart-0.1.0.tgz") + info, err := os.Stat(expectedFile) + if err != nil { + t.Errorf("Expected file %s does not exist: %v", expectedFile, err) + } else { + // Verify the file is not empty + if info.Size() == 0 { + t.Errorf("Downloaded file %s is empty (0 bytes)", expectedFile) + } + + // Verify the file has the expected size (should match the source file) + sourceFile := "testdata/local-subchart-0.1.0.tgz" + sourceInfo, err := os.Stat(sourceFile) + if err == nil && info.Size() != sourceInfo.Size() { + t.Errorf("Downloaded file size (%d bytes) doesn't match source file size (%d bytes)", + info.Size(), sourceInfo.Size()) + } + + // Verify it's a valid tar.gz file by checking the magic bytes + file, err := os.Open(expectedFile) + if err == nil { + defer file.Close() + // gzip magic bytes are 0x1f 0x8b + magic := make([]byte, 2) + if n, err := file.Read(magic); err == nil && n == 2 { + if magic[0] != 0x1f || magic[1] != 0x8b { + t.Errorf("Downloaded file is not a valid gzip file (magic bytes: %x)", magic) + } + } + } + + // Verify no lock file was left behind + lockFile := expectedFile + ".lock" + if _, err := os.Stat(lockFile); err == nil { + t.Errorf("Lock file %s was not cleaned up", lockFile) + } + } +} diff --git a/pkg/helm/pkg/downloader/manager.go b/pkg/helm/pkg/downloader/manager.go index b7190898..4a4c0ed0 100644 --- a/pkg/helm/pkg/downloader/manager.go +++ b/pkg/helm/pkg/downloader/manager.go @@ -16,34 +16,34 @@ limitations under the License. package downloader import ( + "context" "crypto" "encoding/hex" + "errors" "fmt" "io" + stdfs "io/fs" "log" "net/url" "os" - "path" "path/filepath" "regexp" "strings" "sync" "github.com/Masterminds/semver/v3" - "github.com/pkg/errors" "sigs.k8s.io/yaml" - "github.com/werf/nelm/pkg/helm/internal/resolver" - "github.com/werf/nelm/pkg/helm/internal/third_party/dep/fs" - "github.com/werf/nelm/pkg/helm/internal/urlutil" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chart/loader" - "github.com/werf/nelm/pkg/helm/pkg/chartutil" + "github.com/werf/nelm/pkg/helm/intern/resolver" + "github.com/werf/nelm/pkg/helm/intern/third_party/dep/fs" + "github.com/werf/nelm/pkg/helm/intern/urlutil" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/loader" + chartutil "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" "github.com/werf/nelm/pkg/helm/pkg/getter" "github.com/werf/nelm/pkg/helm/pkg/helmpath" "github.com/werf/nelm/pkg/helm/pkg/registry" - "github.com/werf/nelm/pkg/helm/pkg/repo" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1" ) // ErrRepoNotFound indicates that chart repositories can't be found in local repo cache. @@ -77,8 +77,14 @@ type Manager struct { RepositoryConfig string RepositoryCache string - // AllowMissingRepos allows usage of dependency build without adding repos AllowMissingRepos bool + + // ContentCache is a location where a cache of charts can be stored + ContentCache string +} + +func (m *Manager) SetChartPath(path string) { + m.ChartPath = path } // Build rebuilds a local charts directory from a lockfile. @@ -86,8 +92,8 @@ type Manager struct { // If the lockfile is not present, this will run a Manager.Update() // // If SkipUpdate is set, this will not update the repository. -func (m *Manager) Build(opts helmopts.HelmOptions) error { - c, err := m.loadChartDir(opts) +func (m *Manager) Build(ctx context.Context) error { + c, err := m.loadChartDir(ctx) if err != nil { return err } @@ -96,7 +102,7 @@ func (m *Manager) Build(opts helmopts.HelmOptions) error { // an update. lock := c.Lock if lock == nil { - return m.Update(opts) + return m.Update(ctx) } // Check that all of the repos we're dependent on actually exist. @@ -145,13 +151,13 @@ func (m *Manager) Build(opts helmopts.HelmOptions) error { if !m.SkipUpdate { // For each repo in the file, update the cached copy of that repo - if err := m.UpdateRepositories(); err != nil { + if err := m.UpdateRepositories(ctx); err != nil { return err } } // Now we need to fetch every package here into charts/ - return m.downloadAll(lock.Dependencies, opts) + return m.downloadAll(ctx, lock.Dependencies) } // Update updates a local charts directory. @@ -159,8 +165,8 @@ func (m *Manager) Build(opts helmopts.HelmOptions) error { // It first reads the Chart.yaml file, and then attempts to // negotiate versions based on that. It will download the versions // from remote chart repositories unless SkipUpdate is true. -func (m *Manager) Update(opts helmopts.HelmOptions) error { - c, err := m.loadChartDir(opts) +func (m *Manager) Update(ctx context.Context) error { + c, err := m.loadChartDir(ctx) if err != nil { return err } @@ -183,7 +189,7 @@ func (m *Manager) Update(opts helmopts.HelmOptions) error { // has some information about them and, when possible, the index files // locally. // TODO(mattfarina): Repositories should be explicitly added by end users - // rather than automattic. In Helm v4 require users to add repositories. They + // rather than automatic. In Helm v4 require users to add repositories. They // should have to add them in order to make sure they are aware of the // repositories and opt-in to any locations, for security. repoNames, err = m.ensureMissingRepos(repoNames, req) @@ -194,20 +200,20 @@ func (m *Manager) Update(opts helmopts.HelmOptions) error { // For each of the repositories Helm is configured to know about, update // the index information locally. if !m.SkipUpdate { - if err := m.UpdateRepositories(); err != nil { + if err := m.UpdateRepositories(ctx); err != nil { return err } } // Now we need to find out which version of a chart best satisfies the // dependencies in the Chart.yaml - lock, err := m.resolve(req, repoNames, opts) + lock, err := m.resolve(ctx, req, repoNames) if err != nil { return err } // Now we need to fetch every package here into charts/ - if err := m.downloadAll(lock.Dependencies, opts); err != nil { + if err := m.downloadAll(ctx, lock.Dependencies); err != nil { return err } @@ -228,42 +234,42 @@ func (m *Manager) Update(opts helmopts.HelmOptions) error { return writeLock(m.ChartPath, lock, c.Metadata.APIVersion == chart.APIVersionV1) } -func (m *Manager) loadChartDir(opts helmopts.HelmOptions) (*chart.Chart, error) { +func (m *Manager) loadChartDir(ctx context.Context) (*chart.Chart, error) { if fi, err := os.Stat(m.ChartPath); err != nil { - return nil, errors.Wrapf(err, "could not find %s", m.ChartPath) + return nil, fmt.Errorf("could not find %s: %w", m.ChartPath, err) } else if !fi.IsDir() { return nil, errors.New("only unpacked charts can be updated") } - return loader.LoadDir(m.ChartPath, opts) + return loader.LoadDir(ctx, m.ChartPath) } // resolve takes a list of dependencies and translates them into an exact version to download. // // This returns a lock file, which has all of the dependencies normalized to a specific version. -func (m *Manager) resolve(req []*chart.Dependency, repoNames map[string]string, opts helmopts.HelmOptions) (*chart.Lock, error) { +func (m *Manager) resolve(ctx context.Context, req []*chart.Dependency, repoNames map[string]string) (*chart.Lock, error) { res := resolver.New(m.ChartPath, m.RepositoryCache, m.RegistryClient) - return res.Resolve(req, repoNames, opts) + return res.Resolve(ctx, req, repoNames) } // downloadAll takes a list of dependencies and downloads them into charts/ // // It will delete versions of the chart that exist on disk and might cause // a conflict. -func (m *Manager) downloadAll(deps []*chart.Dependency, opts helmopts.HelmOptions) error { +func (m *Manager) downloadAll(ctx context.Context, deps []*chart.Dependency) error { repos, err := m.loadChartRepositories() if err != nil { return err } destPath := filepath.Join(m.ChartPath, "charts") - tmpPath := filepath.Join(m.ChartPath, "tmpcharts") + tmpPath := filepath.Join(m.ChartPath, fmt.Sprintf("tmpcharts-%d", os.Getpid())) // Check if 'charts' directory is not actually a directory. If it does not exist, create it. if fi, err := os.Stat(destPath); err == nil { if !fi.IsDir() { - return errors.Errorf("%q is not a directory", destPath) + return fmt.Errorf("%q is not a directory", destPath) } - } else if os.IsNotExist(err) { + } else if errors.Is(err, stdfs.ErrNotExist) { if err := os.MkdirAll(destPath, 0755); err != nil { return err } @@ -286,7 +292,7 @@ func (m *Manager) downloadAll(deps []*chart.Dependency, opts helmopts.HelmOption fmt.Fprintf(m.Out, "Dependency %s did not declare a repository. Assuming it exists in the charts directory\n", dep.Name) // NOTE: we are only validating the local dependency conforms to the constraints. No copying to tmpPath is necessary. chartPath := filepath.Join(destPath, dep.Name) - ch, err := loader.LoadDir(chartPath, opts) + ch, err := loader.LoadDir(ctx, chartPath) if err != nil { return fmt.Errorf("unable to load chart '%s': %v", chartPath, err) } @@ -311,7 +317,7 @@ func (m *Manager) downloadAll(deps []*chart.Dependency, opts helmopts.HelmOption if m.Debug { fmt.Fprintf(m.Out, "Archiving %s from repo %s\n", dep.Name, dep.Repository) } - ver, err := tarFromLocalDir(m.ChartPath, dep.Name, dep.Repository, dep.Version, tmpPath, opts) + ver, err := tarFromLocalDir(ctx, m.ChartPath, dep.Name, dep.Repository, dep.Version, tmpPath) if err != nil { saveError = err break @@ -322,9 +328,9 @@ func (m *Manager) downloadAll(deps []*chart.Dependency, opts helmopts.HelmOption // Any failure to resolve/download a chart should fail: // https://github.com/helm/helm/issues/1439 - churl, username, password, insecureskiptlsverify, passcredentialsall, caFile, certFile, keyFile, err := m.findChartURL(dep.Name, dep.Version, dep.Repository, repos) + churl, username, password, insecureSkipTLSVerify, passCredentialsAll, caFile, certFile, keyFile, err := m.findChartURL(dep.Name, dep.Version, dep.Repository, repos) if err != nil { - saveError = errors.Wrapf(err, "could not find %s", churl) + saveError = fmt.Errorf("could not find %s: %w", churl, err) break } @@ -341,12 +347,13 @@ func (m *Manager) downloadAll(deps []*chart.Dependency, opts helmopts.HelmOption Keyring: m.Keyring, RepositoryConfig: m.RepositoryConfig, RepositoryCache: m.RepositoryCache, + ContentCache: m.ContentCache, RegistryClient: m.RegistryClient, Getters: m.Getters, Options: []getter.Option{ getter.WithBasicAuth(username, password), - getter.WithPassCredentialsAll(passcredentialsall), - getter.WithInsecureSkipVerifyTLS(insecureskiptlsverify), + getter.WithPassCredentialsAll(passCredentialsAll), + getter.WithInsecureSkipVerifyTLS(insecureSkipTLSVerify), getter.WithTLSClientConfig(certFile, keyFile, caFile), }, } @@ -355,7 +362,7 @@ func (m *Manager) downloadAll(deps []*chart.Dependency, opts helmopts.HelmOption if registry.IsOCI(churl) { churl, version, err = parseOCIRef(churl) if err != nil { - return errors.Wrapf(err, "could not parse OCI reference") + return fmt.Errorf("could not parse OCI reference: %w", err) } dl.Options = append(dl.Options, getter.WithRegistryClient(m.RegistryClient), @@ -363,7 +370,7 @@ func (m *Manager) downloadAll(deps []*chart.Dependency, opts helmopts.HelmOption } if _, _, err = dl.DownloadTo(churl, version, tmpPath); err != nil { - saveError = errors.Wrapf(err, "could not download %s", churl) + saveError = fmt.Errorf("could not download %s: %w", churl, err) break } @@ -373,7 +380,7 @@ func (m *Manager) downloadAll(deps []*chart.Dependency, opts helmopts.HelmOption // TODO: this should probably be refactored to be a []error, so we can capture and provide more information rather than "last error wins". if saveError == nil { // now we can move all downloaded charts to destPath and delete outdated dependencies - if err := m.safeMoveDeps(deps, tmpPath, destPath, opts); err != nil { + if err := m.safeMoveDeps(ctx, deps, tmpPath, destPath); err != nil { return err } } else { @@ -387,7 +394,7 @@ func parseOCIRef(chartRef string) (string, string, error) { refTagRegexp := regexp.MustCompile(`^(oci://[^:]+(:[0-9]{1,5})?[^:]+):(.*)$`) caps := refTagRegexp.FindStringSubmatch(chartRef) if len(caps) != 4 { - return "", "", errors.Errorf("improperly formatted oci chart reference: %s", chartRef) + return "", "", fmt.Errorf("improperly formatted oci chart reference: %s", chartRef) } chartRef = caps[1] tag := caps[3] @@ -395,7 +402,7 @@ func parseOCIRef(chartRef string) (string, string, error) { return chartRef, tag, nil } -// safeMoveDep moves all dependencies in the source and moves them into dest. +// safeMoveDeps moves all dependencies in the source and moves them into dest. // // It does this by first matching the file name to an expected pattern, then loading // the file to verify that it is a chart. @@ -406,7 +413,7 @@ func parseOCIRef(chartRef string) (string, string, error) { // // This will only return errors that should stop processing entirely. Other errors // will emit log messages or be ignored. -func (m *Manager) safeMoveDeps(deps []*chart.Dependency, source, dest string, opts helmopts.HelmOptions) error { +func (m *Manager) safeMoveDeps(ctx context.Context, deps []*chart.Dependency, source, dest string) error { existsInSourceDirectory := map[string]bool{} isLocalDependency := map[string]bool{} sourceFiles, err := os.ReadDir(source) @@ -433,7 +440,7 @@ func (m *Manager) safeMoveDeps(deps []*chart.Dependency, source, dest string, op sourcefile := filepath.Join(source, filename) destfile := filepath.Join(dest, filename) existsInSourceDirectory[filename] = true - if _, err := loader.LoadFile(sourcefile, opts); err != nil { + if _, err := loader.LoadFile(ctx, sourcefile); err != nil { fmt.Fprintf(m.Out, "Could not verify %s for moving: %s (Skipping)", sourcefile, err) continue } @@ -449,7 +456,7 @@ func (m *Manager) safeMoveDeps(deps []*chart.Dependency, source, dest string, op for _, file := range destFiles { if !file.IsDir() && !existsInSourceDirectory[file.Name()] { fname := filepath.Join(dest, file.Name()) - ch, err := loader.LoadFile(fname, opts) + ch, err := loader.LoadFile(ctx, fname) if err != nil { fmt.Fprintf(m.Out, "Could not verify %s for deletion: %s (Skipping)\n", fname, err) continue @@ -569,7 +576,7 @@ func (m *Manager) ensureMissingRepos(repoNames map[string]string, deps []*chart. func (m *Manager) resolveRepoNames(deps []*chart.Dependency) (map[string]string, error) { rf, err := loadRepoConfig(m.RepositoryConfig) if err != nil { - if os.IsNotExist(err) { + if errors.Is(err, stdfs.ErrNotExist) { return make(map[string]string), nil } return nil, err @@ -652,7 +659,7 @@ repository, use "https://charts.example.com/" or "@example" instead of } // UpdateRepositories updates all of the local repos to the latest. -func (m *Manager) UpdateRepositories() error { +func (m *Manager) UpdateRepositories(ctx context.Context) error { rf, err := loadRepoConfig(m.RepositoryConfig) if err != nil { return err @@ -669,10 +676,28 @@ func (m *Manager) UpdateRepositories() error { return nil } +// Filter out duplicate repos by URL, including those with trailing slashes. +func dedupeRepos(repos []*repo.Entry) []*repo.Entry { + seen := make(map[string]*repo.Entry) + for _, r := range repos { + // Normalize URL by removing trailing slashes. + seenURL := strings.TrimSuffix(r.URL, "/") + seen[seenURL] = r + } + var unique []*repo.Entry + for _, r := range seen { + unique = append(unique, r) + } + return unique +} + func (m *Manager) parallelRepoUpdate(repos []*repo.Entry) error { var wg sync.WaitGroup - for _, c := range repos { + + localRepos := dedupeRepos(repos) + + for _, c := range localRepos { r, err := repo.NewChartRepository(c, m.Getters) if err != nil { return err @@ -713,13 +738,12 @@ func (m *Manager) parallelRepoUpdate(repos []*repo.Entry) error { // repoURL is the repository to search // // If it finds a URL that is "relative", it will prepend the repoURL. -func (m *Manager) findChartURL(name, version, repoURL string, repos map[string]*repo.ChartRepository) (url, username, password string, insecureskiptlsverify, passcredentialsall bool, caFile, certFile, keyFile string, err error) { +func (m *Manager) findChartURL(name, version, repoURL string, repos map[string]*repo.ChartRepository) (url, username, password string, insecureSkipTLSVerify, passCredentialsAll bool, caFile, certFile, keyFile string, err error) { if registry.IsOCI(repoURL) { return fmt.Sprintf("%s/%s:%s", repoURL, name, version), "", "", false, false, "", "", "", nil } for _, cr := range repos { - if urlutil.Equal(repoURL, cr.Config.URL) { var entry repo.ChartVersions entry, err = findEntryByName(name, cr) @@ -736,15 +760,15 @@ func (m *Manager) findChartURL(name, version, repoURL string, repos map[string]* //nolint:nakedret return } - url, err = normalizeURL(repoURL, ve.URLs[0]) + url, err = repo.ResolveReferenceURL(repoURL, ve.URLs[0]) if err != nil { //nolint:nakedret return } username = cr.Config.Username password = cr.Config.Password - passcredentialsall = cr.Config.PassCredentialsAll - insecureskiptlsverify = cr.Config.InsecureSkipTLSverify + passCredentialsAll = cr.Config.PassCredentialsAll + insecureSkipTLSVerify = cr.Config.InsecureSkipTLSVerify caFile = cr.Config.CAFile certFile = cr.Config.CertFile keyFile = cr.Config.KeyFile @@ -752,11 +776,11 @@ func (m *Manager) findChartURL(name, version, repoURL string, repos map[string]* return } } - url, err = repo.FindChartInRepoURL(repoURL, name, version, certFile, keyFile, caFile, m.Getters) + url, err = repo.FindChartInRepoURL(repoURL, name, m.Getters, repo.WithChartVersion(version), repo.WithClientTLS(certFile, keyFile, caFile)) if err == nil { return url, username, password, false, false, "", "", "", err } - err = errors.Errorf("chart %s not found in %s: %s", name, repoURL, err) + err = fmt.Errorf("chart %s not found in %s: %w", name, repoURL, err) return url, username, password, false, false, "", "", "", err } @@ -802,24 +826,6 @@ func versionEquals(v1, v2 string) bool { return sv1.Equal(sv2) } -func normalizeURL(baseURL, urlOrPath string) (string, error) { - u, err := url.Parse(urlOrPath) - if err != nil { - return urlOrPath, err - } - if u.IsAbs() { - return u.String(), nil - } - u2, err := url.Parse(baseURL) - if err != nil { - return urlOrPath, errors.Wrap(err, "base URL failed to parse") - } - - u2.RawPath = path.Join(u2.RawPath, urlOrPath) - u2.Path = path.Join(u2.Path, urlOrPath) - return u2.String(), nil -} - // loadChartRepositories reads the repositories.yaml, and then builds a map of // ChartRepositories. // @@ -830,7 +836,7 @@ func (m *Manager) loadChartRepositories() (map[string]*repo.ChartRepository, err // Load repositories.yaml file rf, err := loadRepoConfig(m.RepositoryConfig) if err != nil { - return indices, errors.Wrapf(err, "failed to load %s", m.RepositoryConfig) + return indices, fmt.Errorf("failed to load %s: %w", m.RepositoryConfig, err) } for _, re := range rf.Repositories { @@ -862,13 +868,27 @@ func writeLock(chartpath string, lock *chart.Lock, legacyLockfile bool) error { lockfileName = "requirements.lock" } dest := filepath.Join(chartpath, lockfileName) + + info, err := os.Lstat(dest) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("error getting info for %q: %w", dest, err) + } else if err == nil { + if info.Mode()&os.ModeSymlink != 0 { + link, err := os.Readlink(dest) + if err != nil { + return fmt.Errorf("error reading symlink for %q: %w", dest, err) + } + return fmt.Errorf("the %s file is a symlink to %q", lockfileName, link) + } + } + return os.WriteFile(dest, data, 0644) } // archive a dep chart from local directory and save it into destPath -func tarFromLocalDir(chartpath, name, repo, version, destPath string, opts helmopts.HelmOptions) (string, error) { +func tarFromLocalDir(ctx context.Context, chartpath, name, repo, version, destPath string) (string, error) { if !strings.HasPrefix(repo, "file://") { - return "", errors.Errorf("wrong format: chart %s repository %s", name, repo) + return "", fmt.Errorf("wrong format: chart %s repository %s", name, repo) } origPath, err := resolver.GetLocalPath(repo, chartpath) @@ -876,14 +896,14 @@ func tarFromLocalDir(chartpath, name, repo, version, destPath string, opts helmo return "", err } - ch, err := loader.LoadDir(origPath, opts) + ch, err := loader.LoadDir(ctx, origPath) if err != nil { return "", err } constraint, err := semver.NewConstraint(version) if err != nil { - return "", errors.Wrapf(err, "dependency %s has an invalid version/constraint format", name) + return "", fmt.Errorf("dependency %s has an invalid version/constraint format: %w", name, err) } v, err := semver.NewVersion(ch.Metadata.Version) @@ -896,7 +916,7 @@ func tarFromLocalDir(chartpath, name, repo, version, destPath string, opts helmo return ch.Metadata.Version, err } - return "", errors.Errorf("can't get a valid version for dependency %s", name) + return "", fmt.Errorf("can't get a valid version for dependency %s", name) } // The prefix to use for cache keys created by the manager for repo names @@ -913,9 +933,3 @@ func key(name string) (string, error) { } return hex.EncodeToString(hash.Sum(nil)), nil } - -func (m *Manager) SetChartPath(path string) { - m.ChartPath = path -} - -var _ helmopts.DepDownloader = (*Manager)(nil) diff --git a/pkg/helm/pkg/downloader/manager_test.go b/pkg/helm/pkg/downloader/manager_test.go index a0bd7da3..5ca10582 100644 --- a/pkg/helm/pkg/downloader/manager_test.go +++ b/pkg/helm/pkg/downloader/manager_test.go @@ -17,16 +17,24 @@ package downloader import ( "bytes" + "context" + "errors" + "io/fs" "os" "path/filepath" "reflect" "testing" + "time" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chart/loader" - "github.com/werf/nelm/pkg/helm/pkg/chartutil" + "github.com/stretchr/testify/assert" + "sigs.k8s.io/yaml" + + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/loader" + chartutil "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" "github.com/werf/nelm/pkg/helm/pkg/getter" - "github.com/werf/nelm/pkg/helm/pkg/repo/repotest" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1/repotest" ) func TestVersionEquals(t *testing.T) { @@ -48,26 +56,6 @@ func TestVersionEquals(t *testing.T) { } } -func TestNormalizeURL(t *testing.T) { - tests := []struct { - name, base, path, expect string - }{ - {name: "basic URL", base: "https://example.com", path: "http://helm.sh/foo", expect: "http://helm.sh/foo"}, - {name: "relative path", base: "https://helm.sh/charts", path: "foo", expect: "https://helm.sh/charts/foo"}, - {name: "Encoded path", base: "https://helm.sh/a%2Fb/charts", path: "foo", expect: "https://helm.sh/a%2Fb/charts/foo"}, - } - - for _, tt := range tests { - got, err := normalizeURL(tt.base, tt.path) - if err != nil { - t.Errorf("%s: error %s", tt.name, err) - continue - } else if got != tt.expect { - t.Errorf("%s: expected %q, got %q", tt.name, tt.expect, got) - } - } -} - func TestFindChartURL(t *testing.T) { var b bytes.Buffer m := &Manager{ @@ -129,6 +117,31 @@ func TestFindChartURL(t *testing.T) { if passcredentialsall != false { t.Errorf("Unexpected passcredentialsall %t", passcredentialsall) } + + name = "foo" + version = "1.2.3" + repoURL = "http://example.com/helm" + + churl, username, password, insecureSkipTLSVerify, passcredentialsall, _, _, _, err = m.findChartURL(name, version, repoURL, repos) + if err != nil { + t.Fatal(err) + } + + if churl != "http://example.com/helm/charts/foo-1.2.3.tgz" { + t.Errorf("Unexpected URL %q", churl) + } + if username != "" { + t.Errorf("Unexpected username %q", username) + } + if password != "" { + t.Errorf("Unexpected password %q", password) + } + if passcredentialsall != false { + t.Errorf("Unexpected passcredentialsall %t", passcredentialsall) + } + if insecureSkipTLSVerify { + t.Errorf("Unexpected insecureSkipTLSVerify %t", insecureSkipTLSVerify) + } } func TestGetRepoNames(t *testing.T) { @@ -224,7 +237,7 @@ func TestDownloadAll(t *testing.T) { RepositoryCache: repoCache, ChartPath: chartPath, } - signtest, err := loader.LoadDir(filepath.Join("testdata", "signtest")) + signtest, err := loader.LoadDir(context.Background(), filepath.Join("testdata", "signtest")) if err != nil { t.Fatal(err) } @@ -232,7 +245,7 @@ func TestDownloadAll(t *testing.T) { t.Fatal(err) } - local, err := loader.LoadDir(filepath.Join("testdata", "local-subchart")) + local, err := loader.LoadDir(context.Background(), filepath.Join("testdata", "local-subchart")) if err != nil { t.Fatal(err) } @@ -255,11 +268,11 @@ func TestDownloadAll(t *testing.T) { if err := os.MkdirAll(filepath.Join(chartPath, "tmpcharts"), 0755); err != nil { t.Fatal(err) } - if err := m.downloadAll([]*chart.Dependency{signDep, localDep}); err != nil { + if err := m.downloadAll(context.Background(), []*chart.Dependency{signDep, localDep}); err != nil { t.Error(err) } - if _, err := os.Stat(filepath.Join(chartPath, "charts", "signtest-0.1.0.tgz")); os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(chartPath, "charts", "signtest-0.1.0.tgz")); errors.Is(err, fs.ErrNotExist) { t.Error(err) } @@ -284,7 +297,7 @@ version: 0.1.0` Version: "0.1.0", } - err = m.downloadAll([]*chart.Dependency{badLocalDep}) + err = m.downloadAll(context.Background(), []*chart.Dependency{badLocalDep}) if err == nil { t.Fatal("Expected error for bad dependency name") } @@ -292,10 +305,10 @@ version: 0.1.0` func TestUpdateBeforeBuild(t *testing.T) { // Set up a fake repo - srv, err := repotest.NewTempServerWithCleanup(t, "testdata/*.tgz*") - if err != nil { - t.Fatal(err) - } + srv := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/*.tgz*"), + ) defer srv.Stop() if err := srv.LinkIndices(); err != nil { t.Fatal(err) @@ -347,13 +360,11 @@ func TestUpdateBeforeBuild(t *testing.T) { } // Update before Build. see issue: https://github.com/helm/helm/issues/7101 - err = m.Update() - if err != nil { + if err := m.Update(context.Background()); err != nil { t.Fatal(err) } - err = m.Build() - if err != nil { + if err := m.Build(context.Background()); err != nil { t.Fatal(err) } } @@ -363,10 +374,10 @@ func TestUpdateBeforeBuild(t *testing.T) { // to be fetched. func TestUpdateWithNoRepo(t *testing.T) { // Set up a fake repo - srv, err := repotest.NewTempServerWithCleanup(t, "testdata/*.tgz*") - if err != nil { - t.Fatal(err) - } + srv := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/*.tgz*"), + ) defer srv.Stop() if err := srv.LinkIndices(); err != nil { t.Fatal(err) @@ -422,8 +433,7 @@ func TestUpdateWithNoRepo(t *testing.T) { } // Test the update - err = m.Update() - if err != nil { + if err := m.Update(context.Background()); err != nil { t.Fatal(err) } } @@ -435,11 +445,12 @@ func TestUpdateWithNoRepo(t *testing.T) { // Parent chart includes local-subchart 0.1.0 subchart from a fake repository, by default. // If each of these main fields (name, version, repository) is not supplied by dep param, default value will be used. func checkBuildWithOptionalFields(t *testing.T, chartName string, dep chart.Dependency) { + t.Helper() // Set up a fake repo - srv, err := repotest.NewTempServerWithCleanup(t, "testdata/*.tgz*") - if err != nil { - t.Fatal(err) - } + srv := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/*.tgz*"), + ) defer srv.Stop() if err := srv.LinkIndices(); err != nil { t.Fatal(err) @@ -478,23 +489,23 @@ func checkBuildWithOptionalFields(t *testing.T, chartName string, dep chart.Depe Schemes: []string{"http", "https"}, New: getter.NewHTTPGetter, }} + contentCache := t.TempDir() m := &Manager{ ChartPath: dir(chartName), Out: b, Getters: g, RepositoryConfig: dir("repositories.yaml"), RepositoryCache: dir(), + ContentCache: contentCache, } // First build will update dependencies and create Chart.lock file. - err = m.Build() - if err != nil { + if err := m.Build(context.Background()); err != nil { t.Fatal(err) } // Second build should be passed. See PR #6655. - err = m.Build() - if err != nil { + if err := m.Build(context.Background()); err != nil { t.Fatal(err) } } @@ -598,3 +609,162 @@ func TestKey(t *testing.T) { } } } + +// Test dedupeRepos tests that the dedupeRepos function correctly deduplicates +func TestDedupeRepos(t *testing.T) { + tests := []struct { + name string + repos []*repo.Entry + want []*repo.Entry + }{ + { + name: "no duplicates", + repos: []*repo.Entry{ + { + URL: "https://example.com/charts", + }, + { + URL: "https://example.com/charts2", + }, + }, + want: []*repo.Entry{ + { + URL: "https://example.com/charts", + }, + { + URL: "https://example.com/charts2", + }, + }, + }, + { + name: "duplicates", + repos: []*repo.Entry{ + { + URL: "https://example.com/charts", + }, + { + URL: "https://example.com/charts", + }, + }, + want: []*repo.Entry{ + { + URL: "https://example.com/charts", + }, + }, + }, + { + name: "duplicates with trailing slash", + repos: []*repo.Entry{ + { + URL: "https://example.com/charts", + }, + { + URL: "https://example.com/charts/", + }, + }, + want: []*repo.Entry{ + { + // the last one wins + URL: "https://example.com/charts/", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := dedupeRepos(tt.repos) + assert.ElementsMatch(t, tt.want, got) + }) + } +} + +func TestWriteLock(t *testing.T) { + fixedTime, err := time.Parse(time.RFC3339, "2025-07-04T00:00:00Z") + assert.NoError(t, err) + lock := &chart.Lock{ + Generated: fixedTime, + Digest: "sha256:12345", + Dependencies: []*chart.Dependency{ + { + Name: "fantastic-chart", + Version: "1.2.3", + Repository: "https://example.com/charts", + }, + }, + } + expectedContent, err := yaml.Marshal(lock) + assert.NoError(t, err) + + t.Run("v2 lock file", func(t *testing.T) { + dir := t.TempDir() + err := writeLock(dir, lock, false) + assert.NoError(t, err) + + lockfilePath := filepath.Join(dir, "Chart.lock") + _, err = os.Stat(lockfilePath) + assert.NoError(t, err, "Chart.lock should exist") + + content, err := os.ReadFile(lockfilePath) + assert.NoError(t, err) + assert.Equal(t, expectedContent, content) + + // Check that requirements.lock does not exist + _, err = os.Stat(filepath.Join(dir, "requirements.lock")) + assert.Error(t, err) + assert.True(t, os.IsNotExist(err)) + }) + + t.Run("v1 lock file", func(t *testing.T) { + dir := t.TempDir() + err := writeLock(dir, lock, true) + assert.NoError(t, err) + + lockfilePath := filepath.Join(dir, "requirements.lock") + _, err = os.Stat(lockfilePath) + assert.NoError(t, err, "requirements.lock should exist") + + content, err := os.ReadFile(lockfilePath) + assert.NoError(t, err) + assert.Equal(t, expectedContent, content) + + // Check that Chart.lock does not exist + _, err = os.Stat(filepath.Join(dir, "Chart.lock")) + assert.Error(t, err) + assert.True(t, os.IsNotExist(err)) + }) + + t.Run("overwrite existing lock file", func(t *testing.T) { + dir := t.TempDir() + lockfilePath := filepath.Join(dir, "Chart.lock") + assert.NoError(t, os.WriteFile(lockfilePath, []byte("old content"), 0644)) + + err = writeLock(dir, lock, false) + assert.NoError(t, err) + + content, err := os.ReadFile(lockfilePath) + assert.NoError(t, err) + assert.Equal(t, expectedContent, content) + }) + + t.Run("lock file is a symlink", func(t *testing.T) { + dir := t.TempDir() + dummyFile := filepath.Join(dir, "dummy.txt") + assert.NoError(t, os.WriteFile(dummyFile, []byte("dummy"), 0644)) + + lockfilePath := filepath.Join(dir, "Chart.lock") + assert.NoError(t, os.Symlink(dummyFile, lockfilePath)) + + err = writeLock(dir, lock, false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "the Chart.lock file is a symlink to") + }) + + t.Run("chart path is not a directory", func(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "not-a-dir") + assert.NoError(t, os.WriteFile(filePath, []byte("file"), 0644)) + + err = writeLock(filePath, lock, false) + assert.Error(t, err) + }) +} diff --git a/pkg/helm/pkg/downloader/testdata/repository/testing-relative-index.yaml b/pkg/helm/pkg/downloader/testdata/repository/testing-relative-index.yaml index ba27ed25..9524daf6 100644 --- a/pkg/helm/pkg/downloader/testdata/repository/testing-relative-index.yaml +++ b/pkg/helm/pkg/downloader/testdata/repository/testing-relative-index.yaml @@ -26,3 +26,16 @@ entries: version: 1.2.3 checksum: 0e6661f193211d7a5206918d42f5c2a9470b737d apiVersion: v2 + baz: + - name: baz + description: Baz Chart With Absolute Path + home: https://helm.sh/helm + keywords: [] + maintainers: [] + sources: + - https://github.com/helm/charts + urls: + - /path/to/baz-1.2.3.tgz + version: 1.2.3 + checksum: 0e6661f193211d7a5206918d42f5c2a9470b737d + apiVersion: v2 diff --git a/pkg/helm/pkg/downloader/testdata/signtest/alpine/Chart.yaml b/pkg/helm/pkg/downloader/testdata/signtest/alpine/Chart.yaml index 5269b5cf..eec26122 100644 --- a/pkg/helm/pkg/downloader/testdata/signtest/alpine/Chart.yaml +++ b/pkg/helm/pkg/downloader/testdata/signtest/alpine/Chart.yaml @@ -3,5 +3,5 @@ description: Deploy a basic Alpine Linux pod home: https://helm.sh/helm name: alpine sources: - - https://github.com/helm/helm +- https://github.com/helm/helm version: 0.1.0 diff --git a/pkg/helm/pkg/engine/doc.go b/pkg/helm/pkg/engine/doc.go index 6b3443aa..c70f3f34 100644 --- a/pkg/helm/pkg/engine/doc.go +++ b/pkg/helm/pkg/engine/doc.go @@ -21,4 +21,4 @@ When Helm renders templates it does so with additional functions and different modes (e.g., strict, lint mode). This package handles the helm specific implementation. */ -package engine // import "helm.sh/helm/v3/pkg/engine" +package engine // import "github.com/werf/nelm/pkg/helm/pkg/engine" diff --git a/pkg/helm/pkg/engine/engine.go b/pkg/helm/pkg/engine/engine.go index cea761e5..6824baa6 100644 --- a/pkg/helm/pkg/engine/engine.go +++ b/pkg/helm/pkg/engine/engine.go @@ -17,8 +17,12 @@ limitations under the License. package engine import ( + "context" + "errors" "fmt" "log" + "log/slog" + "maps" "path" "path/filepath" "regexp" @@ -28,18 +32,20 @@ import ( "unicode" "github.com/davecgh/go-spew/spew" - "github.com/pkg/errors" - "github.com/samber/lo" + "github.com/werf/common-go/pkg/util" "k8s.io/client-go/rest" - "github.com/werf/common-go/pkg/util" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chartutil" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" - "github.com/werf/nelm/pkg/helm/pkg/werf/secrets/gotmplfunctions" - "github.com/werf/nelm/pkg/helm/pkg/werf/secrets/runtimedata" + nelmcommon "github.com/werf/nelm/pkg/common" + v3 "github.com/werf/nelm/pkg/helm/intern/chart/v3" + ci "github.com/werf/nelm/pkg/helm/pkg/chart" + chartcommon "github.com/werf/nelm/pkg/helm/pkg/chart/common" + v2 "github.com/werf/nelm/pkg/helm/pkg/chart/v2" ) +type secretFilesRuntimeData interface { + GetDecryptedSecretFilesData() map[string]string +} + // Engine is an implementation of the Helm rendering implementation for templates. type Engine struct { // If strict is enabled, template rendering will fail if a template references @@ -51,6 +57,9 @@ type Engine struct { clientProvider *ClientProvider // EnableDNS tells the engine to allow DNS lookups when rendering templates EnableDNS bool + // CustomTemplateFuncs is defined by users to provide custom template funcs + CustomTemplateFuncs template.FuncMap + secretsRuntimeData chartcommon.RuntimeData } // New creates a new instance of Engine using the passed in rest config. @@ -80,35 +89,36 @@ func New(config *rest.Config) Engine { // that section of the values will be passed into the "foo" chart. And if that // section contains a value named "bar", that value will be passed on to the // bar chart during render time. -func (e Engine) Render(chrt *chart.Chart, values chartutil.Values, opts helmopts.HelmOptions) (map[string]string, error) { - tmap := allTemplates(chrt, values, opts) - return e.render(tmap, chrt.SecretsRuntimeData, opts) +func (e Engine) Render(ctx context.Context, chrt ci.Charter, values chartcommon.Values) (map[string]string, error) { + e.secretsRuntimeData = secretsRuntimeDataFromChart(chrt) + tmap := allTemplates(chrt, values) + return e.render(ctx, tmap) } // Render takes a chart, optional values, and value overrides, and attempts to // render the Go templates using the default options. -func Render(chrt *chart.Chart, values chartutil.Values, opts helmopts.HelmOptions) (map[string]string, error) { - return new(Engine).Render(chrt, values, opts) +func Render(ctx context.Context, chrt ci.Charter, values chartcommon.Values) (map[string]string, error) { + return new(Engine).Render(ctx, chrt, values) } // RenderWithClient takes a chart, optional values, and value overrides, and attempts to // render the Go templates using the default options. This engine is client aware and so can have template // functions that interact with the client. -func RenderWithClient(chrt *chart.Chart, values chartutil.Values, config *rest.Config, opts helmopts.HelmOptions) (map[string]string, error) { +func RenderWithClient(ctx context.Context, chrt ci.Charter, values chartcommon.Values, config *rest.Config) (map[string]string, error) { var clientProvider ClientProvider = clientProviderFromConfig{config} return Engine{ clientProvider: &clientProvider, - }.Render(chrt, values, opts) + }.Render(ctx, chrt, values) } // RenderWithClientProvider takes a chart, optional values, and value overrides, and attempts to // render the Go templates using the default options. This engine is client aware and so can have template // functions that interact with the client. // This function differs from RenderWithClient in that it lets you customize the way a dynamic client is constructed. -func RenderWithClientProvider(chrt *chart.Chart, values chartutil.Values, clientProvider ClientProvider, opts helmopts.HelmOptions) (map[string]string, error) { +func RenderWithClientProvider(ctx context.Context, chrt ci.Charter, values chartcommon.Values, clientProvider ClientProvider) (map[string]string, error) { return Engine{ clientProvider: &clientProvider, - }.Render(chrt, values, opts) + }.Render(ctx, chrt, values) } // renderable is an object that can be rendered. @@ -116,7 +126,7 @@ type renderable struct { // tpl is the current template. tpl string // vals are the values to be supplied to the template. - vals chartutil.Values + vals chartcommon.Values // namespace prefix to the templates of the current chart basePath string } @@ -127,6 +137,8 @@ const recursionMaxNums = 1000 var warnRegex = regexp.MustCompile(warnStartDelim + `((?s).*)` + warnEndDelim) +var Debug bool + func warnWrap(warn string) string { return warnStartDelim + warn + warnEndDelim } @@ -138,7 +150,9 @@ func includeFun(t *template.Template, includedNames map[string]int) func(string, var buf strings.Builder if v, ok := includedNames[name]; ok { if v > recursionMaxNums { - return "", errors.Wrapf(fmt.Errorf("unable to execute template"), "rendering template has a nested reference name: %s", name) + return "", fmt.Errorf( + "rendering template has a nested reference name: %s: %w", + name, errors.New("unable to execute template")) } includedNames[name]++ } else { @@ -152,7 +166,6 @@ func includeFun(t *template.Template, includedNames map[string]int) func(string, templateName: name, }, Debug, err) } - return buf.String(), nil } } @@ -161,14 +174,9 @@ func includeFun(t *template.Template, includedNames map[string]int) func(string, // defined by their enclosing contexts. func tplFun(parent *template.Template, includedNames map[string]int, strict bool) func(string, interface{}) (string, error) { return func(tpl string, vals interface{}) (string, error) { - // No templating required if plain text with no templates passed. - if !strings.Contains(tpl, "{{") && !strings.Contains(tpl, "}}") { - return tpl, nil - } - t, err := parent.Clone() if err != nil { - return "", errors.Wrapf(err, "cannot clone template") + return "", fmt.Errorf("cannot clone template: %w", err) } // Re-inject the missingkey option, see text/template issue https://github.com/golang/go/issues/43022 @@ -183,15 +191,14 @@ func tplFun(parent *template.Template, includedNames map[string]int, strict bool // Re-inject 'include' so that it can close over our clone of t; // this lets any 'define's inside tpl be 'include'd. t.Funcs(template.FuncMap{ - "include": includeFun(t, includedNames), - "tpl": tplFun(t, includedNames, strict), - + "include": includeFun(t, includedNames), + "tpl": tplFun(t, includedNames, strict), "include_debug": includeDebugFun(t, includedNames), "tpl_debug": tplDebugFun(t, includedNames, strict), }) // We need a .New template, as template text which is just blanks - // or comments after parsing out defines just addes new named + // or comments after parsing out defines just adds new named // template definitions without changing the main template. // https://pkg.go.dev/text/template#Template.Parse // Use the parent's name for lack of a better way to identify the tpl @@ -220,34 +227,63 @@ func tplFun(parent *template.Template, includedNames map[string]int, strict bool } // initFunMap creates the Engine's FuncMap and adds context-specific functions. -func (e Engine) initFunMap(t *template.Template, secretsRuntimeData runtimedata.RuntimeData, opts helmopts.HelmOptions) { +func (e Engine) initFunMap(ctx context.Context, t *template.Template) { funcMap := funcMap() includedNames := make(map[string]int) // Add the template-rendering functions here so we can close over t. - funcMap["include"] = includeFun(t, includedNames) - funcMap["tpl"] = tplFun(t, includedNames, e.Strict) - + include := includeFun(t, includedNames) + tpl := tplFun(t, includedNames, e.Strict) + funcMap["include"] = include + funcMap["tpl"] = tpl funcMap["include_debug"] = includeDebugFun(t, includedNames) funcMap["tpl_debug"] = tplDebugFun(t, includedNames, e.Strict) + funcMap["dump_debug"] = func(value interface{}) string { + if Debug { + log.Printf("-- dump_debug result:\n%s\n\n", spew.Sdump(value)) + } + + return "" + } + funcMap["printf_debug"] = func(format string, args ...interface{}) string { + if Debug { + log.Printf("-- printf_debug format %q result:\n%s\n\n", format, fmt.Sprintf(format, args...)) + } + + return "" + } + switch nelmcommon.HelmOptionsFromContext(ctx).ChartLoadOpts.ChartType { + case nelmcommon.LegacyChartTypeSubchart: + funcMap["werf_secret_file"] = func(_ string) (string, error) { + return "", errors.New("werf_secret_file is not available for subcharts") + } + default: + if e.secretsRuntimeData != nil { + setupWerfSecretFile(e.secretsRuntimeData, funcMap) + } else { + funcMap["werf_secret_file"] = func(_ string) (string, error) { + return "", errors.New("werf_secret_file is not available for this chart type") + } + } + } // Add the `required` function here so we can use lintMode funcMap["required"] = func(warn string, val interface{}) (interface{}, error) { if val == nil { if e.LintMode { // Don't fail on missing required values when linting - log.Printf("[INFO] Missing required value: %s", warn) + slog.Warn("missing required value", "message", warn) return "", nil } - return val, errors.Errorf(warnWrap(warn)) + return val, errors.New(warnWrap(warn)) } else if _, ok := val.(string); ok { if val == "" { if e.LintMode { // Don't fail on missing required values when linting - log.Printf("[INFO] Missing required value: %s", warn) + slog.Warn("missing required values", "message", warn) return "", nil } - return val, errors.Errorf(warnWrap(warn)) + return val, errors.New(warnWrap(warn)) } } return val, nil @@ -257,7 +293,7 @@ func (e Engine) initFunMap(t *template.Template, secretsRuntimeData runtimedata. funcMap["fail"] = func(msg string) (string, error) { if e.LintMode { // Don't fail when linting - log.Printf("[INFO] Fail: %s", msg) + slog.Info("funcMap fail", "message", msg) return "", nil } return "", errors.New(warnWrap(msg)) @@ -272,40 +308,19 @@ func (e Engine) initFunMap(t *template.Template, secretsRuntimeData runtimedata. // When DNS lookups are not enabled override the sprig function and return // an empty string. if !e.EnableDNS { - funcMap["getHostByName"] = func(name string) string { + funcMap["getHostByName"] = func(_ string) string { return "" } } - funcMap["printf_debug"] = func(format string, args ...interface{}) string { - if Debug { - log.Printf("-- printf_debug format %q result:\n%s\n\n", format, fmt.Sprintf(format, args...)) - } - - return "" - } - - funcMap["dump_debug"] = func(obj interface{}) string { - if Debug { - log.Printf("-- dump_debug result:\n%s\n\n", spew.Sdump(obj)) - } - - return "" - } - - switch opts.ChartLoadOpts.ChartType { - case helmopts.ChartTypeBundle, helmopts.ChartTypeChart, helmopts.ChartTypeChartStub: - gotmplfunctions.SetupWerfSecretFile(secretsRuntimeData, funcMap) - case helmopts.ChartTypeSubchart: - default: - panic("unknown extender type") - } + // Set custom template funcs + maps.Copy(funcMap, e.CustomTemplateFuncs) t.Funcs(funcMap) } // render takes a map of templates/values and renders them. -func (e Engine) render(tpls map[string]renderable, secretsRuntimeData runtimedata.RuntimeData, opts helmopts.HelmOptions) (rendered map[string]string, err error) { +func (e Engine) render(ctx context.Context, tpls map[string]renderable) (rendered map[string]string, err error) { // Basically, what we do here is start with an empty parent template and then // build up a list of templates -- one for each file. Once all of the templates // have been parsed, we loop through again and execute every template. @@ -315,7 +330,7 @@ func (e Engine) render(tpls map[string]renderable, secretsRuntimeData runtimedat // template engine. defer func() { if r := recover(); r != nil { - err = errors.Errorf("rendering template failed: %v", r) + err = fmt.Errorf("rendering template failed: %v", r) } }() t := template.New("gotpl") @@ -327,7 +342,7 @@ func (e Engine) render(tpls map[string]renderable, secretsRuntimeData runtimedat t.Option("missingkey=zero") } - e.initFunMap(t, secretsRuntimeData, opts) + e.initFunMap(ctx, t) // We want to parse the templates in a predictable order. The order favors // higher-level (in file system) templates over deeply nested templates. @@ -352,13 +367,13 @@ func (e Engine) render(tpls map[string]renderable, secretsRuntimeData runtimedat } // At render time, add information about the template that is being rendered. vals := tpls[filename].vals - vals["Template"] = chartutil.Values{"Name": filename, "BasePath": tpls[filename].basePath} + vals["Template"] = chartcommon.Values{"Name": filename, "BasePath": tpls[filename].basePath} var buf strings.Builder if err := t.ExecuteTemplate(&buf, filename, vals); err != nil { return map[string]string{}, detailedTemplateError(t, detailedTemplateErrorData{ templateName: filename, templateContent: tpls[filename].tpl, - }, Debug, cleanupExecError(filename, err)) + }, Debug, reformatExecErrorMsg(filename, err)) } // Work around the issue where Go will emit "" even if Options(missing=zero) @@ -381,129 +396,192 @@ func cleanupParseError(filename string, err error) error { location := tokens[1] // The remaining tokens make up a stacktrace-like chain, ending with the relevant error errMsg := tokens[len(tokens)-1] - return fmt.Errorf("parse error at (%s): %s", string(location), errMsg) + return fmt.Errorf("parse error at (%s): %s", location, errMsg) } -func cleanupExecError(filename string, err error) error { - if _, isExecError := err.(template.ExecError); !isExecError { - return err +type TraceableError struct { + location string + message string + executedFunction string +} + +func (t TraceableError) String() string { + var errorString strings.Builder + if t.location != "" { + _, _ = fmt.Fprintf(&errorString, "%s\n ", t.location) + } + if t.executedFunction != "" { + _, _ = fmt.Fprintf(&errorString, "%s\n ", t.executedFunction) } + if t.message != "" { + _, _ = fmt.Fprintf(&errorString, "%s\n", t.message) + } + return errorString.String() +} - tokens := strings.SplitN(err.Error(), ": ", 3) - if len(tokens) != 3 { - // This might happen if a non-templating error occurs - return fmt.Errorf("execution error in (%s): %s", filename, err) +// parseTemplateExecErrorString parses a template execution error string from text/template +// without using regular expressions. It returns a TraceableError and true if parsing succeeded. +func parseTemplateExecErrorString(s string) (TraceableError, bool) { + const prefix = "template: " + if !strings.HasPrefix(s, prefix) { + return TraceableError{}, false } + remainder := s[len(prefix):] - // The first token is "template" - // The second token is either "filename:lineno" or "filename:lineNo:columnNo" - location := tokens[1] + // Special case: "template: no template %q associated with template %q" + // Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=191 + traceableError, done := parseTemplateNoTemplateError(s, remainder) + if done { + return traceableError, true + } - parts := warnRegex.FindStringSubmatch(tokens[2]) - if len(parts) >= 2 { - return fmt.Errorf("execution error at (%s): %s", string(location), parts[1]) + // Executing form: ": executing \"\" at <>: [ template:...]" + // Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=141 + traceableError, done = parseTemplateExecutingAtErrorType(remainder) + if done { + return traceableError, true } - return err + // Simple form: ": " + // Use LastIndex to avoid splitting colons within line:col info. + // Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=138 + traceableError, done = parseTemplateSimpleErrorString(remainder) + if done { + return traceableError, true + } + + return TraceableError{}, false } -func sortTemplates(tpls map[string]renderable) []string { - keys := make([]string, len(tpls)) - i := 0 - for key := range tpls { - keys[i] = key - i++ +// Special case: "template: no template %q associated with template %q" +// Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=191 +func parseTemplateNoTemplateError(s string, remainder string) (TraceableError, bool) { + if strings.HasPrefix(remainder, "no template ") { + return TraceableError{message: s}, true } - sort.Sort(sort.Reverse(byPathLen(keys))) - return keys + return TraceableError{}, false } -type byPathLen []string - -func (p byPathLen) Len() int { return len(p) } -func (p byPathLen) Swap(i, j int) { p[j], p[i] = p[i], p[j] } -func (p byPathLen) Less(i, j int) bool { - a, b := p[i], p[j] - ca, cb := strings.Count(a, "/"), strings.Count(b, "/") - if ca == cb { - return strings.Compare(a, b) == -1 +// Simple form: ": " +// Use LastIndex to avoid splitting colons within line:col info. +// Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=138 +func parseTemplateSimpleErrorString(remainder string) (TraceableError, bool) { + if sep := strings.LastIndex(remainder, ": "); sep != -1 { + templateName := remainder[:sep] + errMsg := remainder[sep+2:] + if cut := strings.Index(errMsg, " template:"); cut != -1 { + errMsg = errMsg[:cut] + } + return TraceableError{location: templateName, message: errMsg}, true } - return ca < cb + return TraceableError{}, false } -// allTemplates returns all templates for a chart and its dependencies. -// -// As it goes, it also prepares the values in a scope-sensitive manner. -func allTemplates(c *chart.Chart, vals chartutil.Values, opts helmopts.HelmOptions) map[string]renderable { - templates := make(map[string]renderable) - recAllTpls(c, templates, vals, opts) - return templates +// Executing form: ": executing \"\" at <>: [ template:...]" +// Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=141 +func parseTemplateExecutingAtErrorType(remainder string) (TraceableError, bool) { + if idx := strings.Index(remainder, ": executing "); idx != -1 { + templateName := remainder[:idx] + after := remainder[idx+len(": executing "):] + if len(after) == 0 || after[0] != '"' { + return TraceableError{}, false + } + // find closing quote for function name + endQuote := strings.IndexByte(after[1:], '"') + if endQuote == -1 { + return TraceableError{}, false + } + endQuote++ // account for offset we started at 1 + functionName := after[1:endQuote] + afterFunc := after[endQuote+1:] + + // expect: " at <" then location then ">: " then message + const atPrefix = " at <" + if !strings.HasPrefix(afterFunc, atPrefix) { + return TraceableError{}, false + } + afterAt := afterFunc[len(atPrefix):] + endLoc := strings.Index(afterAt, ">: ") + if endLoc == -1 { + return TraceableError{}, false + } + locationName := afterAt[:endLoc] + errMsg := afterAt[endLoc+len(">: "):] + + // trim chained next error starting with space + "template:" if present + if cut := strings.Index(errMsg, " template:"); cut != -1 { + errMsg = errMsg[:cut] + } + return TraceableError{ + location: templateName, + message: errMsg, + executedFunction: "executing \"" + functionName + "\" at <" + locationName + ">:", + }, true + } + return TraceableError{}, false } -// recAllTpls recurses through the templates in a chart. -// -// As it recurses, it also sets the values to be appropriate for the template -// scope. -func recAllTpls(c *chart.Chart, templates map[string]renderable, vals chartutil.Values, opts helmopts.HelmOptions) map[string]interface{} { - subCharts := make(map[string]interface{}) - chartMetaData := struct { - chart.Metadata - IsRoot bool - }{*c.Metadata, c.IsRoot()} +// reformatExecErrorMsg takes an error message for template rendering and formats it into a formatted +// multi-line error string +func reformatExecErrorMsg(filename string, err error) error { + // This function parses the error message produced by text/template package. + // If it can parse out details from that error message such as the line number, template it failed on, + // and error description, then it will construct a new error that displays these details in a structured way. + // If there are issues with parsing the error message, the err passed into the function should return instead. + var execError template.ExecError + if !errors.As(err, &execError) { + return err + } - next := map[string]interface{}{ - "Chart": chartMetaData, - "Files": newFiles(c.Files), - "Release": vals["Release"], - "Capabilities": vals["Capabilities"], - "Values": make(chartutil.Values), - "Subcharts": subCharts, - "Runtime": vals["Runtime"], + tokens := strings.SplitN(err.Error(), ": ", 3) + if len(tokens) != 3 { + // This might happen if a non-templating error occurs + return fmt.Errorf("execution error in (%s): %s", filename, err) } - next = lo.Assign(opts.ChartLoadOpts.DefaultRootContext, next) + // The first token is "template" + // The second token is either "filename:lineno" or "filename:lineNo:columnNo" + location := tokens[1] - // If there is a {{.Values.ThisChart}} in the parent metadata, - // copy that into the {{.Values}} for this template. - if c.IsRoot() { - next["Values"] = vals["Values"] - } else if vs, err := vals.Table("Values." + c.Name()); err == nil { - next["Values"] = vs + parts := warnRegex.FindStringSubmatch(tokens[2]) + if len(parts) >= 2 { + return fmt.Errorf("execution error at (%s): %s", location, parts[1]) } - - for _, child := range c.Dependencies() { - subCharts[child.Name()] = recAllTpls(child, templates, next, opts) + current := err + var fileLocations []TraceableError + for current != nil { + if tr, ok := parseTemplateExecErrorString(current.Error()); ok { + if len(fileLocations) == 0 || fileLocations[len(fileLocations)-1] != tr { + fileLocations = append(fileLocations, tr) + } + } else { + return err + } + current = errors.Unwrap(current) } - newParentID := c.ChartFullPath() - for _, t := range c.Templates { - if t == nil { - continue - } - if !isTemplateValid(c, t.Name) { - continue - } - templates[path.Join(newParentID, t.Name)] = renderable{ - tpl: string(t.Data), - vals: next, - basePath: path.Join(newParentID, "templates"), - } + var finalErrorString strings.Builder + for _, fileLocation := range fileLocations { + _, _ = fmt.Fprintf(&finalErrorString, "%s", fileLocation.String()) } - return next + return errors.New(strings.TrimSpace(finalErrorString.String())) } -// isTemplateValid returns true if the template is valid for the chart type -func isTemplateValid(ch *chart.Chart, templateName string) bool { - if isLibraryChart(ch) { - return strings.HasPrefix(filepath.Base(templateName), "_") - } - return true +var TemplateErrHint = `Set log level to "debug" to get more details about this error.` + +type detailedTemplateErrorData struct { + funcName string + templateName string + templateContent string } -// isLibraryChart returns true if the chart is a library chart -func isLibraryChart(c *chart.Chart) bool { - return strings.EqualFold(c.Metadata.Type, "library") +func templateContentFromTree(tmpl *template.Template, name string) (string, error) { + t := tmpl.Lookup(name) + if t == nil || t.Tree == nil || t.Tree.Root == nil { + return "", fmt.Errorf("template %q not found", name) + } + return strings.TrimSpace(t.Tree.Root.String()), nil } func detailedTemplateError(tmpl *template.Template, d detailedTemplateErrorData, debug bool, err error) error { @@ -511,12 +589,10 @@ func detailedTemplateError(tmpl *template.Template, d detailedTemplateErrorData, if d.templateContent == "" { d.templateContent, _ = templateContentFromTree(tmpl, d.templateName) } - var funcNameMsg string if d.funcName != "" { funcNameMsg = fmt.Sprintf(" Function name: %q\n", d.funcName) } - return fmt.Errorf( "%w\n\nDetails:\n%s Template name: %q\n Template content:\n%s", err, @@ -525,35 +601,23 @@ func detailedTemplateError(tmpl *template.Template, d detailedTemplateErrorData, strings.TrimRightFunc(util.NumerateLines(d.templateContent, 1), unicode.IsSpace), ) } - + if d.funcName == "" || d.funcName == "include" || d.funcName == "tpl" { + return err + } if strings.Contains(err.Error(), TemplateErrHint) { return err } - return fmt.Errorf("%w\n%s", err, TemplateErrHint) } -type detailedTemplateErrorData struct { - funcName string - templateName string - templateContent string -} - -func templateContentFromTree(tmpl *template.Template, name string) (string, error) { - t := tmpl.Lookup(name) - if t == nil || t.Tree == nil || t.Tree.Root == nil { - return "", fmt.Errorf("template %q not found", name) - } - - return strings.TrimSpace(t.Tree.Root.String()), nil -} - func includeDebugFun(t *template.Template, includedNames map[string]int) func(string, interface{}) (string, error) { return func(name string, data interface{}) (string, error) { var buf strings.Builder if v, ok := includedNames[name]; ok { if v > recursionMaxNums { - return "", errors.Wrapf(fmt.Errorf("unable to execute template"), "rendering template has a nested reference name: %s", name) + return "", fmt.Errorf( + "rendering template has a nested reference name: %s: %w", + name, errors.New("unable to execute template")) } includedNames[name]++ } else { @@ -589,41 +653,28 @@ func includeDebugFun(t *template.Template, includedNames map[string]int) func(st func tplDebugFun(parent *template.Template, includedNames map[string]int, strict bool) func(string, interface{}) (string, error) { return func(tpl string, vals interface{}) (string, error) { - // No templating required if plain text with no templates passed. if !strings.Contains(tpl, "{{") && !strings.Contains(tpl, "}}") { return tpl, nil } t, err := parent.Clone() if err != nil { - return "", errors.Wrapf(err, "cannot clone template") + return "", fmt.Errorf("cannot clone template: %w", err) } - // Re-inject the missingkey option, see text/template issue https://github.com/golang/go/issues/43022 - // We have to go by strict from our engine configuration, as the option fields are private in Template. - // TODO: Remove workaround (and the strict parameter) once we build only with golang versions with a fix. if strict { t.Option("missingkey=error") } else { t.Option("missingkey=zero") } - // Re-inject 'include' so that it can close over our clone of t; - // this lets any 'define's inside tpl be 'include'd. t.Funcs(template.FuncMap{ - "include": includeFun(t, includedNames), - "tpl": tplFun(t, includedNames, strict), - + "include": includeFun(t, includedNames), + "tpl": tplFun(t, includedNames, strict), "include_debug": includeDebugFun(t, includedNames), "tpl_debug": tplDebugFun(t, includedNames, strict), }) - // We need a .New template, as template text which is just blanks - // or comments after parsing out defines just addes new named - // template definitions without changing the main template. - // https://pkg.go.dev/text/template#Template.Parse - // Use the parent's name for lack of a better way to identify the tpl - // text string. (Maybe we could use a hash appended to the name?) t, err = t.New(parent.Name()).Parse(tpl) if err != nil { return "", detailedTemplateError(t, detailedTemplateErrorData{ @@ -646,7 +697,6 @@ func tplDebugFun(parent *template.Template, includedNames map[string]int, strict }, Debug, err) } - // See comment in renderWithReferences explaining the hack. result := strings.ReplaceAll(buf.String(), "", "") if Debug { @@ -657,7 +707,130 @@ func tplDebugFun(parent *template.Template, includedNames map[string]int, strict } } -var ( - TemplateErrHint = `Set log level to "debug" to get more details about this error.` - Debug bool -) +func sortTemplates(tpls map[string]renderable) []string { + keys := make([]string, len(tpls)) + i := 0 + for key := range tpls { + keys[i] = key + i++ + } + sort.Sort(sort.Reverse(byPathLen(keys))) + return keys +} + +type byPathLen []string + +func (p byPathLen) Len() int { return len(p) } +func (p byPathLen) Swap(i, j int) { p[j], p[i] = p[i], p[j] } +func (p byPathLen) Less(i, j int) bool { + a, b := p[i], p[j] + ca, cb := strings.Count(a, "/"), strings.Count(b, "/") + if ca == cb { + return strings.Compare(a, b) == -1 + } + return ca < cb +} + +// allTemplates returns all templates for a chart and its dependencies. +// +// As it goes, it also prepares the values in a scope-sensitive manner. +func allTemplates(c ci.Charter, vals chartcommon.Values) map[string]renderable { + templates := make(map[string]renderable) + recAllTpls(c, templates, vals) + return templates +} + +// recAllTpls recurses through the templates in a chart. +// +// As it recurses, it also sets the values to be appropriate for the template +// scope. +func recAllTpls(c ci.Charter, templates map[string]renderable, values chartcommon.Values) map[string]interface{} { + vals := values.AsMap() + subCharts := make(map[string]interface{}) + accessor, err := ci.NewAccessor(c) + if err != nil { + slog.Error("error accessing chart", "error", err) + } + chartMetaData := accessor.MetadataAsMap() + chartMetaData["IsRoot"] = accessor.IsRoot() + + next := map[string]interface{}{ + "Chart": chartMetaData, + "Files": newFiles(accessor.Files()), + "Release": vals["Release"], + "Capabilities": vals["Capabilities"], + "Values": make(chartcommon.Values), + "Subcharts": subCharts, + } + + // If there is a {{.Values.ThisChart}} in the parent metadata, + // copy that into the {{.Values}} for this template. + if accessor.IsRoot() { + next["Values"] = vals["Values"] + } else if vs, err := values.Table("Values." + accessor.Name()); err == nil { + next["Values"] = vs + } + + for _, child := range accessor.Dependencies() { + // TODO: Handle error + sub, _ := ci.NewAccessor(child) + subCharts[sub.Name()] = recAllTpls(child, templates, next) + } + + newParentID := accessor.ChartFullPath() + for _, t := range accessor.Templates() { + if t == nil { + continue + } + if !isTemplateValid(accessor, t.Name) { + continue + } + templates[path.Join(newParentID, t.Name)] = renderable{ + tpl: string(t.Data), + vals: next, + basePath: path.Join(newParentID, "templates"), + } + } + + return next +} + +// isTemplateValid returns true if the template is valid for the chart type +func isTemplateValid(accessor ci.Accessor, templateName string) bool { + if accessor.IsLibraryChart() { + return strings.HasPrefix(filepath.Base(templateName), "_") + } + return true +} + +func setupWerfSecretFile(secretsRuntimeData secretFilesRuntimeData, funcMap template.FuncMap) { + funcMap["werf_secret_file"] = func(secretRelativePath string) (string, error) { + if path.IsAbs(secretRelativePath) { + return "", fmt.Errorf("expected relative secret file path, given path %v", secretRelativePath) + } + + decodedData, ok := secretsRuntimeData.GetDecryptedSecretFilesData()[secretRelativePath] + + if !ok { + var secretFiles []string + for key := range secretsRuntimeData.GetDecryptedSecretFilesData() { + secretFiles = append(secretFiles, key) + } + + return "", fmt.Errorf("secret file %q not found, you may use one of the following: %q", secretRelativePath, strings.Join(secretFiles, "', '")) + } + + return decodedData, nil + } +} + +func secretsRuntimeDataFromChart(chrt ci.Charter) chartcommon.RuntimeData { + switch c := chrt.(type) { + case *v2.Chart: + return c.SecretsRuntimeData + case *v3.Chart: + return c.SecretsRuntimeData + default: + return nil + } +} diff --git a/pkg/helm/pkg/engine/engine_migration_ai_test.go b/pkg/helm/pkg/engine/engine_migration_ai_test.go new file mode 100644 index 00000000..56679fd8 --- /dev/null +++ b/pkg/helm/pkg/engine/engine_migration_ai_test.go @@ -0,0 +1,83 @@ +//go:build ai_tests + +package engine + +import ( + "context" + "testing" + "text/template" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" +) + +func TestAI_DebugFuncsExistInFuncMap(t *testing.T) { + e := Engine{} + tpl := template.New("test") + e.initFunMap(context.Background(), tpl) + + funcs := []string{"dump_debug", "printf_debug", "include_debug", "tpl_debug"} + for _, name := range funcs { + found := false + for _, tmpl := range tpl.Templates() { + _ = tmpl + } + testTpl := template.New("check_" + name) + testTpl.Funcs(template.FuncMap{name: func() string { return "" }}) + _ = testTpl + + execTpl, err := tpl.Parse("{{ " + name + " }}") + if err == nil && execTpl != nil { + found = true + } + assert.True(t, found, "function %q should be registered in FuncMap", name) + } +} + +func TestAI_WerfSecretFileFuncExists(t *testing.T) { + e := Engine{} + tpl := template.New("test") + e.initFunMap(context.Background(), tpl) + + parsed, err := tpl.Parse(`{{ werf_secret_file "test.txt" }}`) + require.NoError(t, err) + assert.NotNil(t, parsed) +} + +func TestAI_EngineRendersSimpleTemplate(t *testing.T) { + c := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "test-chart", + Version: "0.1.0", + APIVersion: chart.APIVersionV2, + }, + Templates: []*common.File{ + {Name: "templates/hello.yaml", Data: []byte("greeting: {{ .Values.hello }}")}, + }, + Values: map[string]interface{}{ + "hello": "world", + }, + } + + vals := common.Values{ + "Values": c.Values, + "Release": map[string]interface{}{"Name": "test", "Namespace": "default", "IsInstall": true, "IsUpgrade": false, "Service": "Helm"}, + "Chart": map[string]interface{}{"Name": "test-chart", "Version": "0.1.0"}, + } + + out, err := Render(context.Background(), c, vals) + require.NoError(t, err) + require.NotEmpty(t, out) + + found := false + for _, v := range out { + if v == "greeting: world" { + found = true + break + } + } + assert.True(t, found, "rendered output should contain 'greeting: world', got %v", out) +} diff --git a/pkg/helm/pkg/engine/engine_test.go b/pkg/helm/pkg/engine/engine_test.go index 71edafd9..1b3e298a 100644 --- a/pkg/helm/pkg/engine/engine_test.go +++ b/pkg/helm/pkg/engine/engine_test.go @@ -17,12 +17,16 @@ limitations under the License. package engine import ( + "context" "fmt" "path" "strings" "sync" "testing" "text/template" + "time" + + "github.com/stretchr/testify/assert" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -30,8 +34,9 @@ import ( "k8s.io/client-go/dynamic" "k8s.io/client-go/dynamic/fake" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chartutil" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + "github.com/werf/nelm/pkg/helm/pkg/chart/common/util" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" ) func TestSortTemplates(t *testing.T) { @@ -78,7 +83,7 @@ func TestFuncMap(t *testing.T) { } // Test for Engine-specific template functions. - expect := []string{"include", "required", "tpl", "toYaml", "fromYaml", "toToml", "toJson", "fromJson", "lookup"} + expect := []string{"include", "required", "tpl", "toYaml", "fromYaml", "toToml", "fromToml", "toJson", "fromJson", "lookup"} for _, f := range expect { if _, ok := fns[f]; !ok { t.Errorf("Expected add-on function %q", f) @@ -87,17 +92,18 @@ func TestFuncMap(t *testing.T) { } func TestRender(t *testing.T) { + modTime := time.Now() c := &chart.Chart{ Metadata: &chart.Metadata{ Name: "moby", Version: "1.2.3", }, - Templates: []*chart.File{ - {Name: "templates/test1", Data: []byte("{{.Values.outer | title }} {{.Values.inner | title}}")}, - {Name: "templates/test2", Data: []byte("{{.Values.global.callme | lower }}")}, - {Name: "templates/test3", Data: []byte("{{.noValue}}")}, - {Name: "templates/test4", Data: []byte("{{toJson .Values}}")}, - {Name: "templates/test5", Data: []byte("{{getHostByName \"helm.sh\"}}")}, + Templates: []*common.File{ + {Name: "templates/test1", ModTime: modTime, Data: []byte("{{.Values.outer | title }} {{.Values.inner | title}}")}, + {Name: "templates/test2", ModTime: modTime, Data: []byte("{{.Values.global.callme | lower }}")}, + {Name: "templates/test3", ModTime: modTime, Data: []byte("{{.noValue}}")}, + {Name: "templates/test4", ModTime: modTime, Data: []byte("{{toJson .Values}}")}, + {Name: "templates/test5", ModTime: modTime, Data: []byte("{{getHostByName \"helm.sh\"}}")}, }, Values: map[string]interface{}{"outer": "DEFAULT", "inner": "DEFAULT"}, } @@ -112,11 +118,11 @@ func TestRender(t *testing.T) { }, } - v, err := chartutil.CoalesceValues(c, vals) + v, err := util.CoalesceValues(c, vals) if err != nil { t.Fatalf("Failed to coalesce values: %s", err) } - out, err := Render(c, v) + out, err := Render(context.Background(), c, v) if err != nil { t.Errorf("Failed to render templates: %s", err) } @@ -137,14 +143,16 @@ func TestRender(t *testing.T) { } func TestRenderRefsOrdering(t *testing.T) { + modTime := time.Now() + parentChart := &chart.Chart{ Metadata: &chart.Metadata{ Name: "parent", Version: "1.2.3", }, - Templates: []*chart.File{ - {Name: "templates/_helpers.tpl", Data: []byte(`{{- define "test" -}}parent value{{- end -}}`)}, - {Name: "templates/test.yaml", Data: []byte(`{{ tpl "{{ include \"test\" . }}" . }}`)}, + Templates: []*common.File{ + {Name: "templates/_helpers.tpl", ModTime: modTime, Data: []byte(`{{- define "test" -}}parent value{{- end -}}`)}, + {Name: "templates/test.yaml", ModTime: modTime, Data: []byte(`{{ tpl "{{ include \"test\" . }}" . }}`)}, }, } childChart := &chart.Chart{ @@ -152,8 +160,8 @@ func TestRenderRefsOrdering(t *testing.T) { Name: "child", Version: "1.2.3", }, - Templates: []*chart.File{ - {Name: "templates/_helpers.tpl", Data: []byte(`{{- define "test" -}}child value{{- end -}}`)}, + Templates: []*common.File{ + {Name: "templates/_helpers.tpl", ModTime: modTime, Data: []byte(`{{- define "test" -}}child value{{- end -}}`)}, }, } parentChart.AddDependency(childChart) @@ -162,8 +170,8 @@ func TestRenderRefsOrdering(t *testing.T) { "parent/templates/test.yaml": "parent value", } - for i := 0; i < 100; i++ { - out, err := Render(parentChart, chartutil.Values{}) + for i := range 100 { + out, err := Render(context.Background(), parentChart, common.Values{}) if err != nil { t.Fatalf("Failed to render templates: %s", err) } @@ -179,7 +187,7 @@ func TestRenderRefsOrdering(t *testing.T) { func TestRenderInternals(t *testing.T) { // Test the internals of the rendering tool. - vals := chartutil.Values{"Name": "one", "Value": "two"} + vals := common.Values{"Name": "one", "Value": "two"} tpls := map[string]renderable{ "one": {tpl: `Hello {{title .Name}}`, vals: vals}, "two": {tpl: `Goodbye {{upper .Value}}`, vals: vals}, @@ -188,7 +196,7 @@ func TestRenderInternals(t *testing.T) { "three": {tpl: `{{template "two" dict "Value" "three"}}`, vals: vals}, } - out, err := new(Engine).render(tpls, nil) + out, err := new(Engine).render(context.Background(), tpls) if err != nil { t.Fatalf("Failed template rendering: %s", err) } @@ -216,8 +224,8 @@ func TestRenderWithDNS(t *testing.T) { Name: "moby", Version: "1.2.3", }, - Templates: []*chart.File{ - {Name: "templates/test1", Data: []byte("{{getHostByName \"helm.sh\"}}")}, + Templates: []*common.File{ + {Name: "templates/test1", ModTime: time.Now(), Data: []byte("{{getHostByName \"helm.sh\"}}")}, }, Values: map[string]interface{}{}, } @@ -226,14 +234,14 @@ func TestRenderWithDNS(t *testing.T) { "Values": map[string]interface{}{}, } - v, err := chartutil.CoalesceValues(c, vals) + v, err := util.CoalesceValues(c, vals) if err != nil { t.Fatalf("Failed to coalesce values: %s", err) } var e Engine e.EnableDNS = true - out, err := e.Render(c, v) + out, err := e.Render(context.Background(), c, v) if err != nil { t.Errorf("Failed to render templates: %s", err) } @@ -352,10 +360,12 @@ func TestRenderWithClientProvider(t *testing.T) { Values: map[string]interface{}{}, } + modTime := time.Now() for name, exp := range cases { - c.Templates = append(c.Templates, &chart.File{ - Name: path.Join("templates", name), - Data: []byte(exp.template), + c.Templates = append(c.Templates, &common.File{ + Name: path.Join("templates", name), + ModTime: modTime, + Data: []byte(exp.template), }) } @@ -363,12 +373,12 @@ func TestRenderWithClientProvider(t *testing.T) { "Values": map[string]interface{}{}, } - v, err := chartutil.CoalesceValues(c, vals) + v, err := util.CoalesceValues(c, vals) if err != nil { t.Fatalf("Failed to coalesce values: %s", err) } - out, err := RenderWithClientProvider(c, v, provider) + out, err := RenderWithClientProvider(context.Background(), c, v, provider) if err != nil { t.Errorf("Failed to render templates: %s", err) } @@ -389,8 +399,8 @@ func TestRenderWithClientProvider_error(t *testing.T) { Name: "moby", Version: "1.2.3", }, - Templates: []*chart.File{ - {Name: "templates/error", Data: []byte(`{{ lookup "v1" "Error" "" "" }}`)}, + Templates: []*common.File{ + {Name: "templates/error", ModTime: time.Now(), Data: []byte(`{{ lookup "v1" "Error" "" "" }}`)}, }, Values: map[string]interface{}{}, } @@ -399,7 +409,7 @@ func TestRenderWithClientProvider_error(t *testing.T) { "Values": map[string]interface{}{}, } - v, err := chartutil.CoalesceValues(c, vals) + v, err := util.CoalesceValues(c, vals) if err != nil { t.Fatalf("Failed to coalesce values: %s", err) } @@ -412,7 +422,7 @@ func TestRenderWithClientProvider_error(t *testing.T) { }, }, } - _, err = RenderWithClientProvider(c, v, provider) + _, err = RenderWithClientProvider(context.Background(), c, v, provider) if err == nil || !strings.Contains(err.Error(), "kaboom") { t.Errorf("Expected error from client provider when rendering, got %q", err) } @@ -422,7 +432,7 @@ func TestParallelRenderInternals(t *testing.T) { // Make sure that we can use one Engine to run parallel template renders. e := new(Engine) var wg sync.WaitGroup - for i := 0; i < 20; i++ { + for i := range 20 { wg.Add(1) go func(i int) { tt := fmt.Sprintf("expect-%d", i) @@ -432,7 +442,7 @@ func TestParallelRenderInternals(t *testing.T) { vals: map[string]interface{}{"val": tt}, }, } - out, err := e.render(tpls, nil) + out, err := e.render(context.Background(), tpls) if err != nil { t.Errorf("Failed to render %s: %s", tt, err) } @@ -446,12 +456,12 @@ func TestParallelRenderInternals(t *testing.T) { } func TestParseErrors(t *testing.T) { - vals := chartutil.Values{"Values": map[string]interface{}{}} + vals := common.Values{"Values": map[string]interface{}{}} tplsUndefinedFunction := map[string]renderable{ "undefined_function": {tpl: `{{foo}}`, vals: vals}, } - _, err := new(Engine).render(tplsUndefinedFunction, nil) + _, err := new(Engine).render(context.Background(), tplsUndefinedFunction) if err == nil { t.Fatalf("Expected failures while rendering: %s", err) } @@ -462,7 +472,7 @@ func TestParseErrors(t *testing.T) { } func TestExecErrors(t *testing.T) { - vals := chartutil.Values{"Values": map[string]interface{}{}} + vals := common.Values{"Values": map[string]interface{}{}} cases := []struct { name string tpls map[string]renderable @@ -514,7 +524,7 @@ linebreak`, for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { - _, err := new(Engine).render(tt.tpls, nil) + _, err := new(Engine).render(context.Background(), tt.tpls) if err == nil { t.Fatalf("Expected failures while rendering: %s", err) } @@ -526,13 +536,13 @@ linebreak`, } func TestFailErrors(t *testing.T) { - vals := chartutil.Values{"Values": map[string]interface{}{}} + vals := common.Values{"Values": map[string]interface{}{}} failtpl := `All your base are belong to us{{ fail "This is an error" }}` tplsFailed := map[string]renderable{ "failtpl": {tpl: failtpl, vals: vals}, } - _, err := new(Engine).render(tplsFailed, nil) + _, err := new(Engine).render(context.Background(), tplsFailed) if err == nil { t.Fatalf("Expected failures while rendering: %s", err) } @@ -543,7 +553,7 @@ func TestFailErrors(t *testing.T) { var e Engine e.LintMode = true - out, err := e.render(tplsFailed, nil) + out, err := e.render(context.Background(), tplsFailed) if err != nil { t.Fatal(err) } @@ -555,52 +565,54 @@ func TestFailErrors(t *testing.T) { } func TestAllTemplates(t *testing.T) { + modTime := time.Now() ch1 := &chart.Chart{ Metadata: &chart.Metadata{Name: "ch1"}, - Templates: []*chart.File{ - {Name: "templates/foo", Data: []byte("foo")}, - {Name: "templates/bar", Data: []byte("bar")}, + Templates: []*common.File{ + {Name: "templates/foo", ModTime: modTime, Data: []byte("foo")}, + {Name: "templates/bar", ModTime: modTime, Data: []byte("bar")}, }, } dep1 := &chart.Chart{ Metadata: &chart.Metadata{Name: "laboratory mice"}, - Templates: []*chart.File{ - {Name: "templates/pinky", Data: []byte("pinky")}, - {Name: "templates/brain", Data: []byte("brain")}, + Templates: []*common.File{ + {Name: "templates/pinky", ModTime: modTime, Data: []byte("pinky")}, + {Name: "templates/brain", ModTime: modTime, Data: []byte("brain")}, }, } ch1.AddDependency(dep1) dep2 := &chart.Chart{ Metadata: &chart.Metadata{Name: "same thing we do every night"}, - Templates: []*chart.File{ - {Name: "templates/innermost", Data: []byte("innermost")}, + Templates: []*common.File{ + {Name: "templates/innermost", ModTime: modTime, Data: []byte("innermost")}, }, } dep1.AddDependency(dep2) - tpls := allTemplates(ch1, chartutil.Values{}) + tpls := allTemplates(ch1, common.Values{}) if len(tpls) != 5 { t.Errorf("Expected 5 charts, got %d", len(tpls)) } } func TestChartValuesContainsIsRoot(t *testing.T) { + modTime := time.Now() ch1 := &chart.Chart{ Metadata: &chart.Metadata{Name: "parent"}, - Templates: []*chart.File{ - {Name: "templates/isroot", Data: []byte("{{.Chart.IsRoot}}")}, + Templates: []*common.File{ + {Name: "templates/isroot", ModTime: modTime, Data: []byte("{{.Chart.IsRoot}}")}, }, } dep1 := &chart.Chart{ Metadata: &chart.Metadata{Name: "child"}, - Templates: []*chart.File{ - {Name: "templates/isroot", Data: []byte("{{.Chart.IsRoot}}")}, + Templates: []*common.File{ + {Name: "templates/isroot", ModTime: modTime, Data: []byte("{{.Chart.IsRoot}}")}, }, } ch1.AddDependency(dep1) - out, err := Render(ch1, chartutil.Values{}) + out, err := Render(context.Background(), ch1, common.Values{}) if err != nil { t.Fatalf("failed to render templates: %s", err) } @@ -618,20 +630,21 @@ func TestChartValuesContainsIsRoot(t *testing.T) { func TestRenderDependency(t *testing.T) { deptpl := `{{define "myblock"}}World{{end}}` toptpl := `Hello {{template "myblock"}}` + modTime := time.Now() ch := &chart.Chart{ Metadata: &chart.Metadata{Name: "outerchart"}, - Templates: []*chart.File{ - {Name: "templates/outer", Data: []byte(toptpl)}, + Templates: []*common.File{ + {Name: "templates/outer", ModTime: modTime, Data: []byte(toptpl)}, }, } ch.AddDependency(&chart.Chart{ Metadata: &chart.Metadata{Name: "innerchart"}, - Templates: []*chart.File{ - {Name: "templates/inner", Data: []byte(deptpl)}, + Templates: []*common.File{ + {Name: "templates/inner", ModTime: modTime, Data: []byte(deptpl)}, }, }) - out, err := Render(ch, map[string]interface{}{}) + out, err := Render(context.Background(), ch, map[string]interface{}{}) if err != nil { t.Fatalf("failed to render chart: %s", err) } @@ -656,19 +669,20 @@ func TestRenderNestedValues(t *testing.T) { // Ensure subcharts scopes are working. subchartspath := "templates/subcharts.tpl" + modTime := time.Now() deepest := &chart.Chart{ Metadata: &chart.Metadata{Name: "deepest"}, - Templates: []*chart.File{ - {Name: deepestpath, Data: []byte(`And this same {{.Values.what}} that smiles {{.Values.global.when}}`)}, - {Name: checkrelease, Data: []byte(`Tomorrow will be {{default "happy" .Release.Name }}`)}, + Templates: []*common.File{ + {Name: deepestpath, ModTime: modTime, Data: []byte(`And this same {{.Values.what}} that smiles {{.Values.global.when}}`)}, + {Name: checkrelease, ModTime: modTime, Data: []byte(`Tomorrow will be {{default "happy" .Release.Name }}`)}, }, Values: map[string]interface{}{"what": "milkshake", "where": "here"}, } inner := &chart.Chart{ Metadata: &chart.Metadata{Name: "herrick"}, - Templates: []*chart.File{ - {Name: innerpath, Data: []byte(`Old {{.Values.who}} is still a-flyin'`)}, + Templates: []*common.File{ + {Name: innerpath, ModTime: modTime, Data: []byte(`Old {{.Values.who}} is still a-flyin'`)}, }, Values: map[string]interface{}{"who": "Robert", "what": "glasses"}, } @@ -676,9 +690,9 @@ func TestRenderNestedValues(t *testing.T) { outer := &chart.Chart{ Metadata: &chart.Metadata{Name: "top"}, - Templates: []*chart.File{ - {Name: outerpath, Data: []byte(`Gather ye {{.Values.what}} while ye may`)}, - {Name: subchartspath, Data: []byte(`The glorious Lamp of {{.Subcharts.herrick.Subcharts.deepest.Values.where}}, the {{.Subcharts.herrick.Values.what}}`)}, + Templates: []*common.File{ + {Name: outerpath, ModTime: modTime, Data: []byte(`Gather ye {{.Values.what}} while ye may`)}, + {Name: subchartspath, ModTime: modTime, Data: []byte(`The glorious Lamp of {{.Subcharts.herrick.Subcharts.deepest.Values.where}}, the {{.Subcharts.herrick.Values.what}}`)}, }, Values: map[string]interface{}{ "what": "stinkweed", @@ -704,22 +718,22 @@ func TestRenderNestedValues(t *testing.T) { }, } - tmp, err := chartutil.CoalesceValues(outer, injValues) + tmp, err := util.CoalesceValues(outer, injValues) if err != nil { t.Fatalf("Failed to coalesce values: %s", err) } - inject := chartutil.Values{ + inject := common.Values{ "Values": tmp, "Chart": outer.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "dyin", }, } t.Logf("Calculated values: %v", inject) - out, err := Render(outer, inject) + out, err := Render(context.Background(), outer, inject) if err != nil { t.Fatalf("failed to render templates: %s", err) } @@ -751,38 +765,39 @@ func TestRenderNestedValues(t *testing.T) { } func TestRenderBuiltinValues(t *testing.T) { + modTime := time.Now() inner := &chart.Chart{ - Metadata: &chart.Metadata{Name: "Latium"}, - Templates: []*chart.File{ - {Name: "templates/Lavinia", Data: []byte(`{{.Template.Name}}{{.Chart.Name}}{{.Release.Name}}`)}, - {Name: "templates/From", Data: []byte(`{{.Files.author | printf "%s"}} {{.Files.Get "book/title.txt"}}`)}, + Metadata: &chart.Metadata{Name: "Latium", APIVersion: chart.APIVersionV2}, + Templates: []*common.File{ + {Name: "templates/Lavinia", ModTime: modTime, Data: []byte(`{{.Template.Name}}{{.Chart.Name}}{{.Release.Name}}`)}, + {Name: "templates/From", ModTime: modTime, Data: []byte(`{{.Files.author | printf "%s"}} {{.Files.Get "book/title.txt"}}`)}, }, - Files: []*chart.File{ - {Name: "author", Data: []byte("Virgil")}, - {Name: "book/title.txt", Data: []byte("Aeneid")}, + Files: []*common.File{ + {Name: "author", ModTime: modTime, Data: []byte("Virgil")}, + {Name: "book/title.txt", ModTime: modTime, Data: []byte("Aeneid")}, }, } outer := &chart.Chart{ - Metadata: &chart.Metadata{Name: "Troy"}, - Templates: []*chart.File{ - {Name: "templates/Aeneas", Data: []byte(`{{.Template.Name}}{{.Chart.Name}}{{.Release.Name}}`)}, - {Name: "templates/Amata", Data: []byte(`{{.Subcharts.Latium.Chart.Name}} {{.Subcharts.Latium.Files.author | printf "%s"}}`)}, + Metadata: &chart.Metadata{Name: "Troy", APIVersion: chart.APIVersionV2}, + Templates: []*common.File{ + {Name: "templates/Aeneas", ModTime: modTime, Data: []byte(`{{.Template.Name}}{{.Chart.Name}}{{.Release.Name}}`)}, + {Name: "templates/Amata", ModTime: modTime, Data: []byte(`{{.Subcharts.Latium.Chart.Name}} {{.Subcharts.Latium.Files.author | printf "%s"}}`)}, }, } outer.AddDependency(inner) - inject := chartutil.Values{ + inject := common.Values{ "Values": "", "Chart": outer.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "Aeneid", }, } t.Logf("Calculated values: %v", outer) - out, err := Render(outer, inject) + out, err := Render(context.Background(), outer, inject) if err != nil { t.Fatalf("failed to render templates: %s", err) } @@ -802,32 +817,33 @@ func TestRenderBuiltinValues(t *testing.T) { } func TestAlterFuncMap_include(t *testing.T) { + modTime := time.Now() c := &chart.Chart{ Metadata: &chart.Metadata{Name: "conrad"}, - Templates: []*chart.File{ - {Name: "templates/quote", Data: []byte(`{{include "conrad/templates/_partial" . | indent 2}} dead.`)}, - {Name: "templates/_partial", Data: []byte(`{{.Release.Name}} - he`)}, + Templates: []*common.File{ + {Name: "templates/quote", ModTime: modTime, Data: []byte(`{{include "conrad/templates/_partial" . | indent 2}} dead.`)}, + {Name: "templates/_partial", ModTime: modTime, Data: []byte(`{{.Release.Name}} - he`)}, }, } // Check nested reference in include FuncMap d := &chart.Chart{ Metadata: &chart.Metadata{Name: "nested"}, - Templates: []*chart.File{ - {Name: "templates/quote", Data: []byte(`{{include "nested/templates/quote" . | indent 2}} dead.`)}, - {Name: "templates/_partial", Data: []byte(`{{.Release.Name}} - he`)}, + Templates: []*common.File{ + {Name: "templates/quote", ModTime: modTime, Data: []byte(`{{include "nested/templates/quote" . | indent 2}} dead.`)}, + {Name: "templates/_partial", ModTime: modTime, Data: []byte(`{{.Release.Name}} - he`)}, }, } - v := chartutil.Values{ + v := common.Values{ "Values": "", "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "Mistah Kurtz", }, } - out, err := Render(c, v) + out, err := Render(context.Background(), c, v) if err != nil { t.Fatal(err) } @@ -837,7 +853,7 @@ func TestAlterFuncMap_include(t *testing.T) { t.Errorf("Expected %q, got %q (%v)", expect, got, out) } - _, err = Render(d, v) + _, err = Render(context.Background(), d, v) expectErrName := "nested/templates/quote" if err == nil { t.Errorf("Expected err of nested reference name: %v", expectErrName) @@ -845,26 +861,27 @@ func TestAlterFuncMap_include(t *testing.T) { } func TestAlterFuncMap_require(t *testing.T) { + modTime := time.Now() c := &chart.Chart{ Metadata: &chart.Metadata{Name: "conan"}, - Templates: []*chart.File{ - {Name: "templates/quote", Data: []byte(`All your base are belong to {{ required "A valid 'who' is required" .Values.who }}`)}, - {Name: "templates/bases", Data: []byte(`All {{ required "A valid 'bases' is required" .Values.bases }} of them!`)}, + Templates: []*common.File{ + {Name: "templates/quote", ModTime: modTime, Data: []byte(`All your base are belong to {{ required "A valid 'who' is required" .Values.who }}`)}, + {Name: "templates/bases", ModTime: modTime, Data: []byte(`All {{ required "A valid 'bases' is required" .Values.bases }} of them!`)}, }, } - v := chartutil.Values{ - "Values": chartutil.Values{ + v := common.Values{ + "Values": common.Values{ "who": "us", "bases": 2, }, "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "That 90s meme", }, } - out, err := Render(c, v) + out, err := Render(context.Background(), c, v) if err != nil { t.Fatal(err) } @@ -880,18 +897,18 @@ func TestAlterFuncMap_require(t *testing.T) { // test required without passing in needed values with lint mode on // verifies lint replaces required with an empty string (should not fail) - lintValues := chartutil.Values{ - "Values": chartutil.Values{ + lintValues := common.Values{ + "Values": common.Values{ "who": "us", }, "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "That 90s meme", }, } var e Engine e.LintMode = true - out, err = e.Render(c, lintValues) + out, err = e.Render(context.Background(), c, lintValues) if err != nil { t.Fatal(err) } @@ -909,22 +926,22 @@ func TestAlterFuncMap_require(t *testing.T) { func TestAlterFuncMap_tpl(t *testing.T) { c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplFunction"}, - Templates: []*chart.File{ - {Name: "templates/base", Data: []byte(`Evaluate tpl {{tpl "Value: {{ .Values.value}}" .}}`)}, + Templates: []*common.File{ + {Name: "templates/base", ModTime: time.Now(), Data: []byte(`Evaluate tpl {{tpl "Value: {{ .Values.value}}" .}}`)}, }, } - v := chartutil.Values{ - "Values": chartutil.Values{ + v := common.Values{ + "Values": common.Values{ "value": "myvalue", }, "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "TestRelease", }, } - out, err := Render(c, v) + out, err := Render(context.Background(), c, v) if err != nil { t.Fatal(err) } @@ -938,22 +955,22 @@ func TestAlterFuncMap_tpl(t *testing.T) { func TestAlterFuncMap_tplfunc(t *testing.T) { c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplFunction"}, - Templates: []*chart.File{ - {Name: "templates/base", Data: []byte(`Evaluate tpl {{tpl "Value: {{ .Values.value | quote}}" .}}`)}, + Templates: []*common.File{ + {Name: "templates/base", ModTime: time.Now(), Data: []byte(`Evaluate tpl {{tpl "Value: {{ .Values.value | quote}}" .}}`)}, }, } - v := chartutil.Values{ - "Values": chartutil.Values{ + v := common.Values{ + "Values": common.Values{ "value": "myvalue", }, "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "TestRelease", }, } - out, err := Render(c, v) + out, err := Render(context.Background(), c, v) if err != nil { t.Fatal(err) } @@ -965,24 +982,25 @@ func TestAlterFuncMap_tplfunc(t *testing.T) { } func TestAlterFuncMap_tplinclude(t *testing.T) { + modTime := time.Now() c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplFunction"}, - Templates: []*chart.File{ - {Name: "templates/base", Data: []byte(`{{ tpl "{{include ` + "`" + `TplFunction/templates/_partial` + "`" + ` . | quote }}" .}}`)}, - {Name: "templates/_partial", Data: []byte(`{{.Template.Name}}`)}, + Templates: []*common.File{ + {Name: "templates/base", ModTime: modTime, Data: []byte(`{{ tpl "{{include ` + "`" + `TplFunction/templates/_partial` + "`" + ` . | quote }}" .}}`)}, + {Name: "templates/_partial", ModTime: modTime, Data: []byte(`{{.Template.Name}}`)}, }, } - v := chartutil.Values{ - "Values": chartutil.Values{ + v := common.Values{ + "Values": common.Values{ "value": "myvalue", }, "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "TestRelease", }, } - out, err := Render(c, v) + out, err := Render(context.Background(), c, v) if err != nil { t.Fatal(err) } @@ -995,24 +1013,26 @@ func TestAlterFuncMap_tplinclude(t *testing.T) { } func TestRenderRecursionLimit(t *testing.T) { + modTime := time.Now() + // endless recursion should produce an error c := &chart.Chart{ Metadata: &chart.Metadata{Name: "bad"}, - Templates: []*chart.File{ - {Name: "templates/base", Data: []byte(`{{include "recursion" . }}`)}, - {Name: "templates/recursion", Data: []byte(`{{define "recursion"}}{{include "recursion" . }}{{end}}`)}, + Templates: []*common.File{ + {Name: "templates/base", ModTime: modTime, Data: []byte(`{{include "recursion" . }}`)}, + {Name: "templates/recursion", ModTime: modTime, Data: []byte(`{{define "recursion"}}{{include "recursion" . }}{{end}}`)}, }, } - v := chartutil.Values{ + v := common.Values{ "Values": "", "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "TestRelease", }, } expectErr := "rendering template has a nested reference name: recursion: unable to execute template" - _, err := Render(c, v) + _, err := Render(context.Background(), c, v) if err == nil || !strings.HasSuffix(err.Error(), expectErr) { t.Errorf("Expected err with suffix: %s", expectErr) } @@ -1021,26 +1041,26 @@ func TestRenderRecursionLimit(t *testing.T) { times := 4000 phrase := "All work and no play makes Jack a dull boy" printFunc := `{{define "overlook"}}{{printf "` + phrase + `\n"}}{{end}}` - var repeatedIncl string - for i := 0; i < times; i++ { - repeatedIncl += `{{include "overlook" . }}` + var repeatedIncl strings.Builder + for range times { + repeatedIncl.WriteString(`{{include "overlook" . }}`) } d := &chart.Chart{ Metadata: &chart.Metadata{Name: "overlook"}, - Templates: []*chart.File{ - {Name: "templates/quote", Data: []byte(repeatedIncl)}, - {Name: "templates/_function", Data: []byte(printFunc)}, + Templates: []*common.File{ + {Name: "templates/quote", ModTime: modTime, Data: []byte(repeatedIncl.String())}, + {Name: "templates/_function", ModTime: modTime, Data: []byte(printFunc)}, }, } - out, err := Render(d, v) + out, err := Render(context.Background(), d, v) if err != nil { t.Fatal(err) } var expect string - for i := 0; i < times; i++ { + for range times { expect += phrase + "\n" } if got := out["overlook/templates/quote"]; got != expect { @@ -1050,30 +1070,31 @@ func TestRenderRecursionLimit(t *testing.T) { } func TestRenderLoadTemplateForTplFromFile(t *testing.T) { + modTime := time.Now() c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplLoadFromFile"}, - Templates: []*chart.File{ - {Name: "templates/base", Data: []byte(`{{ tpl (.Files.Get .Values.filename) . }}`)}, - {Name: "templates/_function", Data: []byte(`{{define "test-function"}}test-function{{end}}`)}, + Templates: []*common.File{ + {Name: "templates/base", ModTime: modTime, Data: []byte(`{{ tpl (.Files.Get .Values.filename) . }}`)}, + {Name: "templates/_function", ModTime: modTime, Data: []byte(`{{define "test-function"}}test-function{{end}}`)}, }, - Files: []*chart.File{ - {Name: "test", Data: []byte(`{{ tpl (.Files.Get .Values.filename2) .}}`)}, - {Name: "test2", Data: []byte(`{{include "test-function" .}}{{define "nested-define"}}nested-define-content{{end}} {{include "nested-define" .}}`)}, + Files: []*common.File{ + {Name: "test", ModTime: modTime, Data: []byte(`{{ tpl (.Files.Get .Values.filename2) .}}`)}, + {Name: "test2", ModTime: modTime, Data: []byte(`{{include "test-function" .}}{{define "nested-define"}}nested-define-content{{end}} {{include "nested-define" .}}`)}, }, } - v := chartutil.Values{ - "Values": chartutil.Values{ + v := common.Values{ + "Values": common.Values{ "filename": "test", "filename2": "test2", }, "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "TestRelease", }, } - out, err := Render(c, v) + out, err := Render(context.Background(), c, v) if err != nil { t.Fatal(err) } @@ -1085,22 +1106,23 @@ func TestRenderLoadTemplateForTplFromFile(t *testing.T) { } func TestRenderTplEmpty(t *testing.T) { + modTime := time.Now() c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplEmpty"}, - Templates: []*chart.File{ - {Name: "templates/empty-string", Data: []byte(`{{tpl "" .}}`)}, - {Name: "templates/empty-action", Data: []byte(`{{tpl "{{ \"\"}}" .}}`)}, - {Name: "templates/only-defines", Data: []byte(`{{tpl "{{define \"not-invoked\"}}not-rendered{{end}}" .}}`)}, + Templates: []*common.File{ + {Name: "templates/empty-string", ModTime: modTime, Data: []byte(`{{tpl "" .}}`)}, + {Name: "templates/empty-action", ModTime: modTime, Data: []byte(`{{tpl "{{ \"\"}}" .}}`)}, + {Name: "templates/only-defines", ModTime: modTime, Data: []byte(`{{tpl "{{define \"not-invoked\"}}not-rendered{{end}}" .}}`)}, }, } - v := chartutil.Values{ + v := common.Values{ "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "TestRelease", }, } - out, err := Render(c, v) + out, err := Render(context.Background(), c, v) if err != nil { t.Fatal(err) } @@ -1118,21 +1140,22 @@ func TestRenderTplEmpty(t *testing.T) { } func TestRenderTplTemplateNames(t *testing.T) { + modTime := time.Now() // .Template.BasePath and .Name make it through c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplTemplateNames"}, - Templates: []*chart.File{ - {Name: "templates/default-basepath", Data: []byte(`{{tpl "{{ .Template.BasePath }}" .}}`)}, - {Name: "templates/default-name", Data: []byte(`{{tpl "{{ .Template.Name }}" .}}`)}, - {Name: "templates/modified-basepath", Data: []byte(`{{tpl "{{ .Template.BasePath }}" .Values.dot}}`)}, - {Name: "templates/modified-name", Data: []byte(`{{tpl "{{ .Template.Name }}" .Values.dot}}`)}, - {Name: "templates/modified-field", Data: []byte(`{{tpl "{{ .Template.Field }}" .Values.dot}}`)}, - }, - } - v := chartutil.Values{ - "Values": chartutil.Values{ - "dot": chartutil.Values{ - "Template": chartutil.Values{ + Templates: []*common.File{ + {Name: "templates/default-basepath", ModTime: modTime, Data: []byte(`{{tpl "{{ .Template.BasePath }}" .}}`)}, + {Name: "templates/default-name", ModTime: modTime, Data: []byte(`{{tpl "{{ .Template.Name }}" .}}`)}, + {Name: "templates/modified-basepath", ModTime: modTime, Data: []byte(`{{tpl "{{ .Template.BasePath }}" .Values.dot}}`)}, + {Name: "templates/modified-name", ModTime: modTime, Data: []byte(`{{tpl "{{ .Template.Name }}" .Values.dot}}`)}, + {Name: "templates/modified-field", ModTime: modTime, Data: []byte(`{{tpl "{{ .Template.Field }}" .Values.dot}}`)}, + }, + } + v := common.Values{ + "Values": common.Values{ + "dot": common.Values{ + "Template": common.Values{ "BasePath": "path/to/template", "Name": "name-of-template", "Field": "extra-field", @@ -1140,12 +1163,12 @@ func TestRenderTplTemplateNames(t *testing.T) { }, }, "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "TestRelease", }, } - out, err := Render(c, v) + out, err := Render(context.Background(), c, v) if err != nil { t.Fatal(err) } @@ -1165,12 +1188,13 @@ func TestRenderTplTemplateNames(t *testing.T) { } func TestRenderTplRedefines(t *testing.T) { + modTime := time.Now() // Redefining a template inside 'tpl' does not affect the outer definition c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplRedefines"}, - Templates: []*chart.File{ - {Name: "templates/_partials", Data: []byte(`{{define "partial"}}original-in-partial{{end}}`)}, - {Name: "templates/partial", Data: []byte( + Templates: []*common.File{ + {Name: "templates/_partials", ModTime: modTime, Data: []byte(`{{define "partial"}}original-in-partial{{end}}`)}, + {Name: "templates/partial", ModTime: modTime, Data: []byte( `before: {{include "partial" .}}\n{{tpl .Values.partialText .}}\nafter: {{include "partial" .}}`, )}, {Name: "templates/manifest", Data: []byte( @@ -1190,8 +1214,8 @@ func TestRenderTplRedefines(t *testing.T) { )}, }, } - v := chartutil.Values{ - "Values": chartutil.Values{ + v := common.Values{ + "Values": common.Values{ "partialText": `{{define "partial"}}redefined-in-tpl{{end}}tpl: {{include "partial" .}}`, "manifestText": `{{define "manifest"}}redefined-in-tpl{{end}}tpl: {{include "manifest" .}}`, "manifestOnlyText": `tpl: {{include "manifest-only" .}}`, @@ -1203,12 +1227,12 @@ func TestRenderTplRedefines(t *testing.T) { "innerText": `{{define "nested"}}redefined-in-inner-tpl{{end}}inner-tpl: {{include "nested" .}} {{include "nested-outer" . }}`, }, "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "TestRelease", }, } - out, err := Render(c, v) + out, err := Render(context.Background(), c, v) if err != nil { t.Fatal(err) } @@ -1234,21 +1258,21 @@ func TestRenderTplMissingKey(t *testing.T) { // Rendering a missing key results in empty/zero output. c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplMissingKey"}, - Templates: []*chart.File{ - {Name: "templates/manifest", Data: []byte( + Templates: []*common.File{ + {Name: "templates/manifest", ModTime: time.Now(), Data: []byte( `missingValue: {{tpl "{{.Values.noSuchKey}}" .}}`, )}, }, } - v := chartutil.Values{ - "Values": chartutil.Values{}, + v := common.Values{ + "Values": common.Values{}, "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "TestRelease", }, } - out, err := Render(c, v) + out, err := Render(context.Background(), c, v) if err != nil { t.Fatal(err) } @@ -1267,16 +1291,16 @@ func TestRenderTplMissingKeyString(t *testing.T) { // Rendering a missing key results in error c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplMissingKeyStrict"}, - Templates: []*chart.File{ - {Name: "templates/manifest", Data: []byte( + Templates: []*common.File{ + {Name: "templates/manifest", ModTime: time.Now(), Data: []byte( `missingValue: {{tpl "{{.Values.noSuchKey}}" .}}`, )}, }, } - v := chartutil.Values{ - "Values": chartutil.Values{}, + v := common.Values{ + "Values": common.Values{}, "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "TestRelease", }, } @@ -1284,19 +1308,198 @@ func TestRenderTplMissingKeyString(t *testing.T) { e := new(Engine) e.Strict = true - out, err := e.Render(c, v) + out, err := e.Render(context.Background(), c, v) if err == nil { t.Errorf("Expected error, got %v", out) return } - switch err.(type) { - case (template.ExecError): - errTxt := fmt.Sprint(err) - if !strings.Contains(errTxt, "noSuchKey") { - t.Errorf("Expected error to contain 'noSuchKey', got %s", errTxt) - } - default: - // Some unexpected error. + errTxt := fmt.Sprint(err) + if !strings.Contains(errTxt, "noSuchKey") { + t.Errorf("Expected error to contain 'noSuchKey', got %s", errTxt) + } + +} + +func TestNestedHelpersProducesMultilineStacktrace(t *testing.T) { + modTime := time.Now() + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: "NestedHelperFunctions"}, + Templates: []*common.File{ + {Name: "templates/svc.yaml", ModTime: modTime, Data: []byte( + `name: {{ include "nested_helper.name" . }}`, + )}, + {Name: "templates/_helpers_1.tpl", ModTime: modTime, Data: []byte( + `{{- define "nested_helper.name" -}}{{- include "common.names.get_name" . -}}{{- end -}}`, + )}, + {Name: "charts/common/templates/_helpers_2.tpl", ModTime: modTime, Data: []byte( + `{{- define "common.names.get_name" -}}{{- .Values.nonexistant.key | trunc 63 | trimSuffix "-" -}}{{- end -}}`, + )}, + }, + } + + expectedErrorMessage := `NestedHelperFunctions/templates/svc.yaml:1:9 + executing "NestedHelperFunctions/templates/svc.yaml" at : + error calling include: +NestedHelperFunctions/templates/_helpers_1.tpl:1:39 + executing "nested_helper.name" at : + error calling include: +NestedHelperFunctions/charts/common/templates/_helpers_2.tpl:1:49 + executing "common.names.get_name" at <.Values.nonexistant.key>: + nil pointer evaluating interface {}.key` + + v := common.Values{} + + val, _ := util.CoalesceValues(c, v) + vals := map[string]interface{}{ + "Values": val.AsMap(), + } + _, err := Render(context.Background(), c, vals) + + assert.NotNil(t, err) + assert.Equal(t, expectedErrorMessage, err.Error()) +} + +func TestMultilineNoTemplateAssociatedError(t *testing.T) { + modTime := time.Now() + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: "multiline"}, + Templates: []*common.File{ + {Name: "templates/svc.yaml", ModTime: modTime, Data: []byte( + `name: {{ include "nested_helper.name" . }}`, + )}, + {Name: "templates/test.yaml", ModTime: modTime, Data: []byte( + `{{ toYaml .Values }}`, + )}, + {Name: "charts/common/templates/_helpers_2.tpl", ModTime: modTime, Data: []byte( + `{{ toYaml .Values }}`, + )}, + }, + } + + expectedErrorMessage := `multiline/templates/svc.yaml:1:9 + executing "multiline/templates/svc.yaml" at : + error calling include: +template: no template "nested_helper.name" associated with template "gotpl"` + + v := common.Values{} + + val, _ := util.CoalesceValues(c, v) + vals := map[string]interface{}{ + "Values": val.AsMap(), + } + _, err := Render(context.Background(), c, vals) + + assert.NotNil(t, err) + assert.Equal(t, expectedErrorMessage, err.Error()) +} + +func TestRenderCustomTemplateFuncs(t *testing.T) { + modTime := time.Now() + + // Create a chart with two templates that use custom functions + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: "CustomFunc"}, + Templates: []*common.File{ + { + Name: "templates/manifest", + ModTime: modTime, + Data: []byte(`{{exclaim .Values.message}}`), + }, + { + Name: "templates/override", + ModTime: modTime, + Data: []byte(`{{ upper .Values.message }}`), + }, + }, + } + v := common.Values{ + "Values": common.Values{ + "message": "hello", + }, + "Chart": c.Metadata, + "Release": common.Values{ + "Name": "TestRelease", + }, + } + + // Define a custom template function "exclaim" that appends "!!!" to a string and override "upper" function + customFuncs := template.FuncMap{ + "exclaim": func(input string) string { + return input + "!!!" + }, + "upper": func(s string) string { + return "custom:" + s + }, + } + + // Create an engine instance and set the CustomTemplateFuncs. + e := new(Engine) + e.CustomTemplateFuncs = customFuncs + + // Render the chart. + out, err := e.Render(context.Background(), c, v) + if err != nil { t.Fatal(err) } + + // Expected output should be "hello!!!". + expected := "hello!!!" + key := "CustomFunc/templates/manifest" + if rendered, ok := out[key]; !ok || rendered != expected { + t.Errorf("Expected %q, got %q", expected, rendered) + } + + // Verify that the rendered template used the custom "upper" function. + expected = "custom:hello" + key = "CustomFunc/templates/override" + if rendered, ok := out[key]; !ok || rendered != expected { + t.Errorf("Expected %q, got %q", expected, rendered) + } +} + +func TestTraceableError_SimpleForm(t *testing.T) { + testStrings := []string{ + "function_not_found/templates/secret.yaml: error calling include", + } + for _, errString := range testStrings { + trace, done := parseTemplateSimpleErrorString(errString) + if !done { + t.Errorf("Expected parse to pass but did not") + } + if trace.message != "error calling include" { + t.Errorf("Expected %q, got %q", errString, trace.message) + } + } +} +func TestTraceableError_ExecutingForm(t *testing.T) { + testStrings := [][]string{ + {"function_not_found/templates/secret.yaml:6:11: executing \"function_not_found/templates/secret.yaml\" at : ", "function_not_found/templates/secret.yaml:6:11"}, + {"divide_by_zero/templates/secret.yaml:6:11: executing \"divide_by_zero/templates/secret.yaml\" at : ", "divide_by_zero/templates/secret.yaml:6:11"}, + } + for _, errTuple := range testStrings { + errString := errTuple[0] + expectedLocation := errTuple[1] + trace, done := parseTemplateExecutingAtErrorType(errString) + if !done { + t.Errorf("Expected parse to pass but did not") + } + if trace.location != expectedLocation { + t.Errorf("Expected %q, got %q", expectedLocation, trace.location) + } + } +} + +func TestTraceableError_NoTemplateForm(t *testing.T) { + testStrings := []string{ + "no template \"common.names.get_name\" associated with template \"gotpl\"", + } + for _, errString := range testStrings { + trace, done := parseTemplateNoTemplateError(errString, errString) + if !done { + t.Errorf("Expected parse to pass but did not") + } + if trace.message != errString { + t.Errorf("Expected %q, got %q", errString, trace.message) + } + } } diff --git a/pkg/helm/pkg/engine/files.go b/pkg/helm/pkg/engine/files.go index 8c78951b..02a2b87d 100644 --- a/pkg/helm/pkg/engine/files.go +++ b/pkg/helm/pkg/engine/files.go @@ -23,7 +23,7 @@ import ( "github.com/gobwas/glob" - "github.com/werf/nelm/pkg/helm/pkg/chart" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" ) // files is a map of files in a chart that can be accessed from a template. @@ -31,7 +31,7 @@ type files map[string][]byte // NewFiles creates a new files from chart files. // Given an []*chart.File (the format for files in a chart.Chart), extract a map of files. -func newFiles(from []*chart.File) files { +func newFiles(from []*common.File) files { files := make(map[string][]byte) for _, f := range from { files[f.Name] = f.Data @@ -64,7 +64,7 @@ func (f files) Get(name string) string { } // Glob takes a glob pattern and returns another files object only containing -// matched files. +// matched files. // // This is designed to be called from a template. // diff --git a/pkg/helm/pkg/engine/funcs.go b/pkg/helm/pkg/engine/funcs.go index 8f05a3a1..a97f8f10 100644 --- a/pkg/helm/pkg/engine/funcs.go +++ b/pkg/helm/pkg/engine/funcs.go @@ -19,12 +19,14 @@ package engine import ( "bytes" "encoding/json" + "maps" "strings" "text/template" "github.com/BurntSushi/toml" "github.com/Masterminds/sprig/v3" "sigs.k8s.io/yaml" + goYaml "sigs.k8s.io/yaml/goyaml.v3" ) // funcMap returns a mapping of all of the functions that Engine has. @@ -48,10 +50,14 @@ func funcMap() template.FuncMap { // Add some extra functionality extra := template.FuncMap{ "toToml": toTOML, + "fromToml": fromTOML, "toYaml": toYAML, + "mustToYaml": mustToYAML, + "toYamlPretty": toYAMLPretty, "fromYaml": fromYAML, "fromYamlArray": fromYAMLArray, "toJson": toJSON, + "mustToJson": mustToJSON, "fromJson": fromJSON, "fromJsonArray": fromJSONArray, @@ -68,9 +74,7 @@ func funcMap() template.FuncMap { }, } - for k, v := range extra { - f[k] = v - } + maps.Copy(f, extra) return f } @@ -88,6 +92,32 @@ func toYAML(v interface{}) string { return strings.TrimSuffix(string(data), "\n") } +// mustToYAML takes an interface, marshals it to yaml, and returns a string. +// It will panic if there is an error. +// +// This is designed to be called from a template when need to ensure that the +// output YAML is valid. +func mustToYAML(v interface{}) string { + data, err := yaml.Marshal(v) + if err != nil { + panic(err) + } + return strings.TrimSuffix(string(data), "\n") +} + +func toYAMLPretty(v interface{}) string { + var data bytes.Buffer + encoder := goYaml.NewEncoder(&data) + encoder.SetIndent(2) + err := encoder.Encode(v) + + if err != nil { + // Swallow errors inside of a template. + return "" + } + return strings.TrimSuffix(data.String(), "\n") +} + // fromYAML converts a YAML document into a map[string]interface{}. // // This is not a general-purpose YAML parser, and will not parse all valid @@ -132,6 +162,21 @@ func toTOML(v interface{}) string { return b.String() } +// fromTOML converts a TOML document into a map[string]interface{}. +// +// This is not a general-purpose TOML parser, and will not parse all valid +// TOML documents. Additionally, because its intended use is within templates +// it tolerates errors. It will insert the returned error message string into +// m["Error"] in the returned map. +func fromTOML(str string) map[string]interface{} { + m := make(map[string]interface{}) + + if err := toml.Unmarshal([]byte(str), &m); err != nil { + m["Error"] = err.Error() + } + return m +} + // toJSON takes an interface, marshals it to json, and returns a string. It will // always return a string, even on marshal error (empty string). // @@ -145,6 +190,19 @@ func toJSON(v interface{}) string { return string(data) } +// mustToJSON takes an interface, marshals it to json, and returns a string. +// It will panic if there is an error. +// +// This is designed to be called from a template when need to ensure that the +// output JSON is valid. +func mustToJSON(v interface{}) string { + data, err := json.Marshal(v) + if err != nil { + panic(err) + } + return string(data) +} + // fromJSON converts a JSON document into a map[string]interface{}. // // This is not a general-purpose JSON parser, and will not parse all valid diff --git a/pkg/helm/pkg/engine/funcs_test.go b/pkg/helm/pkg/engine/funcs_test.go index 29bc121b..71a72e2e 100644 --- a/pkg/helm/pkg/engine/funcs_test.go +++ b/pkg/helm/pkg/engine/funcs_test.go @@ -33,10 +33,38 @@ func TestFuncs(t *testing.T) { tpl: `{{ toYaml . }}`, expect: `foo: bar`, vars: map[string]interface{}{"foo": "bar"}, + }, { + tpl: `{{ toYamlPretty . }}`, + expect: "baz:\n - 1\n - 2\n - 3", + vars: map[string]interface{}{"baz": []int{1, 2, 3}}, }, { tpl: `{{ toToml . }}`, expect: "foo = \"bar\"\n", vars: map[string]interface{}{"foo": "bar"}, + }, { + tpl: `{{ fromToml . }}`, + expect: "map[hello:world]", + vars: `hello = "world"`, + }, { + tpl: `{{ fromToml . }}`, + expect: "map[table:map[keyInTable:valueInTable subtable:map[keyInSubtable:valueInSubTable]]]", + vars: ` +[table] +keyInTable = "valueInTable" +[table.subtable] +keyInSubtable = "valueInSubTable"`, + }, { + tpl: `{{ fromToml . }}`, + expect: "map[tableArray:[map[keyInElement0:valueInElement0] map[keyInElement1:valueInElement1]]]", + vars: ` +[[tableArray]] +keyInElement0 = "valueInElement0" +[[tableArray]] +keyInElement1 = "valueInElement1"`, + }, { + tpl: `{{ fromToml . }}`, + expect: "map[Error:toml: line 1: unexpected EOF; expected key separator '=']", + vars: "one", }, { tpl: `{{ toJson . }}`, expect: `{"foo":"bar"}`, @@ -107,6 +135,43 @@ func TestFuncs(t *testing.T) { assert.NoError(t, err) assert.Equal(t, tt.expect, b.String(), tt.tpl) } + + loopMap := map[string]interface{}{ + "foo": "bar", + } + loopMap["loop"] = []interface{}{loopMap} + + mustFuncsTests := []struct { + tpl string + expect interface{} + vars interface{} + }{{ + tpl: `{{ mustToYaml . }}`, + vars: loopMap, + }, { + tpl: `{{ mustToJson . }}`, + vars: loopMap, + }, { + tpl: `{{ toYaml . }}`, + expect: "", // should return empty string and swallow error + vars: loopMap, + }, { + tpl: `{{ toJson . }}`, + expect: "", // should return empty string and swallow error + vars: loopMap, + }, + } + + for _, tt := range mustFuncsTests { + var b strings.Builder + err := template.Must(template.New("test").Funcs(funcMap()).Parse(tt.tpl)).Execute(&b, tt.vars) + if tt.expect != nil { + assert.NoError(t, err) + assert.Equal(t, tt.expect, b.String(), tt.tpl) + } else { + assert.Error(t, err) + } + } } // This test to check a function provided by sprig is due to a change in a diff --git a/pkg/helm/pkg/engine/lookup_func.go b/pkg/helm/pkg/engine/lookup_func.go index 86a7d698..c6ad8d25 100644 --- a/pkg/helm/pkg/engine/lookup_func.go +++ b/pkg/helm/pkg/engine/lookup_func.go @@ -18,10 +18,10 @@ package engine import ( "context" - "log" + "fmt" + "log/slog" "strings" - "github.com/pkg/errors" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" @@ -35,10 +35,7 @@ type lookupFunc = func(apiversion string, resource string, namespace string, nam // NewLookupFunction returns a function for looking up objects in the cluster. // // If the resource does not exist, no error is raised. -// -// This function is considered deprecated, and will be renamed in Helm 4. It will no -// longer be a public function. -func NewLookupFunction(config *rest.Config) lookupFunc { +func NewLookupFunction(config *rest.Config) lookupFunc { //nolint:revive return newLookupFunction(clientProviderFromConfig{config: config}) } @@ -101,8 +98,12 @@ func getDynamicClientOnKind(apiversion string, kind string, config *rest.Config) gvk := schema.FromAPIVersionAndKind(apiversion, kind) apiRes, err := getAPIResourceForGVK(gvk, config) if err != nil { - log.Printf("[ERROR] unable to get apiresource from unstructured: %s , error %s", gvk.String(), err) - return nil, false, errors.Wrapf(err, "unable to get apiresource from unstructured: %s", gvk.String()) + slog.Error( + "unable to get apiresource", + slog.String("groupVersionKind", gvk.String()), + slog.Any("error", err), + ) + return nil, false, fmt.Errorf("unable to get apiresource from unstructured: %s: %w", gvk.String(), err) } gvr := schema.GroupVersionResource{ Group: apiRes.Group, @@ -111,7 +112,7 @@ func getDynamicClientOnKind(apiversion string, kind string, config *rest.Config) } intf, err := dynamic.NewForConfig(config) if err != nil { - log.Printf("[ERROR] unable to get dynamic client %s", err) + slog.Error("unable to get dynamic client", slog.Any("error", err)) return nil, false, err } res := intf.Resource(gvr) @@ -122,16 +123,20 @@ func getAPIResourceForGVK(gvk schema.GroupVersionKind, config *rest.Config) (met res := metav1.APIResource{} discoveryClient, err := discovery.NewDiscoveryClientForConfig(config) if err != nil { - log.Printf("[ERROR] unable to create discovery client %s", err) + slog.Error("unable to create discovery client", slog.Any("error", err)) return res, err } resList, err := discoveryClient.ServerResourcesForGroupVersion(gvk.GroupVersion().String()) if err != nil { - log.Printf("[ERROR] unable to retrieve resource list for: %s , error: %s", gvk.GroupVersion().String(), err) + slog.Error( + "unable to retrieve resource list", + slog.String("GroupVersion", gvk.GroupVersion().String()), + slog.Any("error", err), + ) return res, err } for _, resource := range resList.APIResources { - // if a resource contains a "/" it's referencing a subresource. we don't support suberesource for now. + // if a resource contains a "/" it's referencing a subresource. we don't support subresource for now. if resource.Kind == gvk.Kind && !strings.Contains(resource.Name, "/") { res = resource res.Group = gvk.Group diff --git a/pkg/helm/pkg/gates/gates_test.go b/pkg/helm/pkg/gates/gates_test.go index 6bdd17ed..4d77199e 100644 --- a/pkg/helm/pkg/gates/gates_test.go +++ b/pkg/helm/pkg/gates/gates_test.go @@ -23,14 +23,13 @@ import ( const name string = "HELM_EXPERIMENTAL_FEATURE" func TestIsEnabled(t *testing.T) { - os.Unsetenv(name) g := Gate(name) if g.IsEnabled() { t.Errorf("feature gate shows as available, but the environment variable %s was not set", name) } - os.Setenv(name, "1") + t.Setenv(name, "1") if !g.IsEnabled() { t.Errorf("feature gate shows as disabled, but the environment variable %s was set", name) diff --git a/pkg/helm/pkg/getter/exports.go b/pkg/helm/pkg/getter/exports.go deleted file mode 100644 index 09d96ccd..00000000 --- a/pkg/helm/pkg/getter/exports.go +++ /dev/null @@ -1,6 +0,0 @@ -package getter - -var ( - HttpProvider = httpProvider - OCIProvider = ociProvider -) diff --git a/pkg/helm/pkg/getter/getter.go b/pkg/helm/pkg/getter/getter.go index cb005042..f67fd406 100644 --- a/pkg/helm/pkg/getter/getter.go +++ b/pkg/helm/pkg/getter/getter.go @@ -18,19 +18,20 @@ package getter import ( "bytes" + "fmt" "net/http" + "slices" "time" - "github.com/pkg/errors" - "github.com/werf/nelm/pkg/helm/pkg/cli" "github.com/werf/nelm/pkg/helm/pkg/registry" ) -// options are generic parameters to be provided to the getter during instantiation. +// getterOptions are generic parameters to be provided to the getter during instantiation. // // Getters may or may not ignore these parameters as they are passed in. -type options struct { +// TODO what is the difference between this and schema.GetterOptionsV1? +type getterOptions struct { url string certFile string keyFile string @@ -38,6 +39,7 @@ type options struct { unTar bool insecureSkipVerifyTLS bool plainHTTP bool + acceptHeader string username string password string passCredentialsAll bool @@ -46,51 +48,59 @@ type options struct { registryClient *registry.Client timeout time.Duration transport *http.Transport + artifactType string } // Option allows specifying various settings configurable by the user for overriding the defaults // used when performing Get operations with the Getter. -type Option func(*options) +type Option func(*getterOptions) // WithURL informs the getter the server name that will be used when fetching objects. Used in conjunction with // WithTLSClientConfig to set the TLSClientConfig's server name. func WithURL(url string) Option { - return func(opts *options) { + return func(opts *getterOptions) { opts.url = url } } +// WithAcceptHeader sets the request's Accept header as some REST APIs serve multiple content types +func WithAcceptHeader(header string) Option { + return func(opts *getterOptions) { + opts.acceptHeader = header + } +} + // WithBasicAuth sets the request's Authorization header to use the provided credentials func WithBasicAuth(username, password string) Option { - return func(opts *options) { + return func(opts *getterOptions) { opts.username = username opts.password = password } } func WithPassCredentialsAll(pass bool) Option { - return func(opts *options) { + return func(opts *getterOptions) { opts.passCredentialsAll = pass } } // WithUserAgent sets the request's User-Agent header to use the provided agent name. func WithUserAgent(userAgent string) Option { - return func(opts *options) { + return func(opts *getterOptions) { opts.userAgent = userAgent } } // WithInsecureSkipVerifyTLS determines if a TLS Certificate will be checked func WithInsecureSkipVerifyTLS(insecureSkipVerifyTLS bool) Option { - return func(opts *options) { + return func(opts *getterOptions) { opts.insecureSkipVerifyTLS = insecureSkipVerifyTLS } } // WithTLSClientConfig sets the client auth with the provided credentials. func WithTLSClientConfig(certFile, keyFile, caFile string) Option { - return func(opts *options) { + return func(opts *getterOptions) { opts.certFile = certFile opts.keyFile = keyFile opts.caFile = caFile @@ -98,43 +108,50 @@ func WithTLSClientConfig(certFile, keyFile, caFile string) Option { } func WithPlainHTTP(plainHTTP bool) Option { - return func(opts *options) { + return func(opts *getterOptions) { opts.plainHTTP = plainHTTP } } // WithTimeout sets the timeout for requests func WithTimeout(timeout time.Duration) Option { - return func(opts *options) { + return func(opts *getterOptions) { opts.timeout = timeout } } func WithTagName(tagname string) Option { - return func(opts *options) { + return func(opts *getterOptions) { opts.version = tagname } } func WithRegistryClient(client *registry.Client) Option { - return func(opts *options) { + return func(opts *getterOptions) { opts.registryClient = client } } func WithUntar() Option { - return func(opts *options) { + return func(opts *getterOptions) { opts.unTar = true } } // WithTransport sets the http.Transport to allow overwriting the HTTPGetter default. func WithTransport(transport *http.Transport) Option { - return func(opts *options) { + return func(opts *getterOptions) { opts.transport = transport } } +// WithArtifactType sets the type of OCI artifact ("chart" or "plugin") +func WithArtifactType(artifactType string) Option { + return func(opts *getterOptions) { + opts.artifactType = artifactType + } +} + // Getter is an interface to support GET to the specified URL. type Getter interface { // Get file content by url string @@ -156,12 +173,7 @@ type Provider struct { // Provides returns true if the given scheme is supported by this Provider. func (p Provider) Provides(scheme string) bool { - for _, i := range p.Schemes { - if i == scheme { - return true - } - } - return false + return slices.Contains(p.Schemes, scheme) } // Providers is a collection of Provider objects. @@ -176,7 +188,7 @@ func (p Providers) ByScheme(scheme string) (Getter, error) { return pp.New() } } - return nil, errors.Errorf("scheme %q not supported", scheme) + return nil, fmt.Errorf("scheme %q not supported", scheme) } const ( @@ -188,23 +200,33 @@ const ( var defaultOptions = []Option{WithTimeout(time.Second * DefaultHTTPTimeout)} -var httpProvider = Provider{ - Schemes: []string{"http", "https"}, - New: func(options ...Option) (Getter, error) { - options = append(options, defaultOptions...) - return NewHTTPGetter(options...) - }, -} - -var ociProvider = Provider{ - Schemes: []string{registry.OCIScheme}, - New: NewOCIGetter, +func Getters(extraOpts ...Option) Providers { + return Providers{ + Provider{ + Schemes: []string{"http", "https"}, + New: func(options ...Option) (Getter, error) { + options = append(options, defaultOptions...) + options = append(options, extraOpts...) + return NewHTTPGetter(options...) + }, + }, + Provider{ + Schemes: []string{registry.OCIScheme}, + New: func(options ...Option) (Getter, error) { + options = append(options, defaultOptions...) + options = append(options, extraOpts...) + return NewOCIGetter(options...) + }, + }, + } } // All finds all of the registered getters as a list of Provider instances. // Currently, the built-in getters and the discovered plugins with downloader // notations are collected. -func All(settings *cli.EnvSettings) Providers { - result := Providers{httpProvider, ociProvider} +func All(settings *cli.EnvSettings, opts ...Option) Providers { + result := Getters(opts...) + pluginDownloaders, _ := collectGetterPlugins(settings) + result = append(result, pluginDownloaders...) return result } diff --git a/pkg/helm/pkg/getter/getter_test.go b/pkg/helm/pkg/getter/getter_test.go index 0ba28bc7..64d92c0f 100644 --- a/pkg/helm/pkg/getter/getter_test.go +++ b/pkg/helm/pkg/getter/getter_test.go @@ -17,6 +17,7 @@ package getter import ( "testing" + "time" "github.com/werf/nelm/pkg/helm/pkg/cli" ) @@ -52,6 +53,23 @@ func TestProviders(t *testing.T) { } } +func TestProvidersWithTimeout(t *testing.T) { + want := time.Hour + getters := Getters(WithTimeout(want)) + getter, err := getters.ByScheme("http") + if err != nil { + t.Error(err) + } + client, err := getter.(*HTTPGetter).httpClient() + if err != nil { + t.Error(err) + } + got := client.Timeout + if got != want { + t.Errorf("Expected %q, got %q", want, got) + } +} + func TestAll(t *testing.T) { env := cli.New() env.PluginsDirectory = pluginDir diff --git a/pkg/helm/pkg/getter/httpgetter.go b/pkg/helm/pkg/getter/httpgetter.go index 77c5ec1c..059fbfc9 100644 --- a/pkg/helm/pkg/getter/httpgetter.go +++ b/pkg/helm/pkg/getter/httpgetter.go @@ -18,21 +18,19 @@ package getter import ( "bytes" "crypto/tls" + "fmt" "io" "net/http" "net/url" "sync" - "github.com/pkg/errors" - - "github.com/werf/nelm/pkg/helm/internal/tlsutil" - "github.com/werf/nelm/pkg/helm/internal/urlutil" - "github.com/werf/nelm/pkg/helm/internal/version" + "github.com/werf/nelm/pkg/helm/intern/tlsutil" + "github.com/werf/nelm/pkg/helm/intern/version" ) // HTTPGetter is the default HTTP(/S) backend handler type HTTPGetter struct { - opts options + opts getterOptions transport *http.Transport once sync.Once } @@ -53,6 +51,10 @@ func (g *HTTPGetter) get(href string) (*bytes.Buffer, error) { return nil, err } + if g.opts.acceptHeader != "" { + req.Header.Set("Accept", g.opts.acceptHeader) + } + req.Header.Set("User-Agent", version.GetUserAgent()) if g.opts.userAgent != "" { req.Header.Set("User-Agent", g.opts.userAgent) @@ -62,11 +64,11 @@ func (g *HTTPGetter) get(href string) (*bytes.Buffer, error) { // with the basic auth is the one being fetched. u1, err := url.Parse(g.opts.url) if err != nil { - return nil, errors.Wrap(err, "Unable to parse getter URL") + return nil, fmt.Errorf("unable to parse getter URL: %w", err) } u2, err := url.Parse(href) if err != nil { - return nil, errors.Wrap(err, "Unable to parse URL getting from") + return nil, fmt.Errorf("unable to parse URL getting from: %w", err) } // Host on URL (returned from url.Parse) contains the port if present. @@ -89,7 +91,7 @@ func (g *HTTPGetter) get(href string) (*bytes.Buffer, error) { } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, errors.Errorf("failed to fetch %s : %s", href, resp.Status) + return nil, fmt.Errorf("failed to fetch %s : %s", href, resp.Status) } buf := bytes.NewBuffer(nil) @@ -120,20 +122,21 @@ func (g *HTTPGetter) httpClient() (*http.Client, error) { g.transport = &http.Transport{ DisableCompression: true, Proxy: http.ProxyFromEnvironment, + // Being nil would cause the tls.Config default to be used + // "NewTLSConfig" modifies an empty TLS config, not the default one + TLSClientConfig: &tls.Config{}, } }) if (g.opts.certFile != "" && g.opts.keyFile != "") || g.opts.caFile != "" || g.opts.insecureSkipVerifyTLS { - tlsConf, err := tlsutil.NewClientTLS(g.opts.certFile, g.opts.keyFile, g.opts.caFile, g.opts.insecureSkipVerifyTLS) - if err != nil { - return nil, errors.Wrap(err, "can't create TLS config for client") - } - - sni, err := urlutil.ExtractHostname(g.opts.url) + tlsConf, err := tlsutil.NewTLSConfig( + tlsutil.WithInsecureSkipVerify(g.opts.insecureSkipVerifyTLS), + tlsutil.WithCertKeyPairFiles(g.opts.certFile, g.opts.keyFile), + tlsutil.WithCAFile(g.opts.caFile), + ) if err != nil { - return nil, err + return nil, fmt.Errorf("can't create TLS config for client: %w", err) } - tlsConf.ServerName = sni g.transport.TLSClientConfig = tlsConf } diff --git a/pkg/helm/pkg/getter/httpgetter_test.go b/pkg/helm/pkg/getter/httpgetter_test.go index daa41a34..cac1c165 100644 --- a/pkg/helm/pkg/getter/httpgetter_test.go +++ b/pkg/helm/pkg/getter/httpgetter_test.go @@ -28,10 +28,8 @@ import ( "testing" "time" - "github.com/pkg/errors" - - "github.com/werf/nelm/pkg/helm/internal/tlsutil" - "github.com/werf/nelm/pkg/helm/internal/version" + "github.com/werf/nelm/pkg/helm/intern/tlsutil" + "github.com/werf/nelm/pkg/helm/intern/version" "github.com/werf/nelm/pkg/helm/pkg/cli" ) @@ -52,7 +50,7 @@ func TestHTTPGetter(t *testing.T) { timeout := time.Second * 5 transport := &http.Transport{} - // Test with options + // Test with getterOptions g, err = NewHTTPGetter( WithBasicAuth("I", "Am"), WithPassCredentialsAll(false), @@ -280,17 +278,44 @@ func TestDownload(t *testing.T) { if got.String() != expect { t.Errorf("Expected %q, got %q", expect, got.String()) } + + // test server with varied Accept Header + const expectedAcceptHeader = "application/gzip,application/octet-stream" + acceptHeaderSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Accept") != expectedAcceptHeader { + t.Errorf("Expected '%s', got '%s'", expectedAcceptHeader, r.Header.Get("Accept")) + } + fmt.Fprint(w, expect) + })) + + defer acceptHeaderSrv.Close() + + u, _ = url.ParseRequestURI(acceptHeaderSrv.URL) + httpgetter, err = NewHTTPGetter( + WithAcceptHeader(expectedAcceptHeader), + ) + if err != nil { + t.Fatal(err) + } + _, err = httpgetter.Get(u.String()) + if err != nil { + t.Fatal(err) + } } func TestDownloadTLS(t *testing.T) { cd := "../../testdata" ca, pub, priv := filepath.Join(cd, "rootca.crt"), filepath.Join(cd, "crt.pem"), filepath.Join(cd, "key.pem") - insecureSkipTLSverify := false + insecureSkipTLSVerify := false - tlsSrv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) - tlsConf, err := tlsutil.NewClientTLS(pub, priv, ca, insecureSkipTLSverify) + tlsSrv := httptest.NewUnstartedServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) + tlsConf, err := tlsutil.NewTLSConfig( + tlsutil.WithInsecureSkipVerify(insecureSkipTLSVerify), + tlsutil.WithCertKeyPairFiles(pub, priv), + tlsutil.WithCAFile(ca), + ) if err != nil { - t.Fatal(errors.Wrap(err, "can't create TLS config for client")) + t.Fatal(fmt.Errorf("can't create TLS config for client: %w", err)) } tlsConf.ServerName = "helm.sh" tlsSrv.TLS = tlsConf @@ -331,8 +356,133 @@ func TestDownloadTLS(t *testing.T) { } } +func TestDownloadTLSWithRedirect(t *testing.T) { + cd := "../../testdata" + srv2Resp := "hello" + insecureSkipTLSVerify := false + + // Server 2 that will actually fulfil the request. + ca, pub, priv := filepath.Join(cd, "rootca.crt"), filepath.Join(cd, "localhost-crt.pem"), filepath.Join(cd, "key.pem") + tlsConf, err := tlsutil.NewTLSConfig( + tlsutil.WithCAFile(ca), + tlsutil.WithCertKeyPairFiles(pub, priv), + tlsutil.WithInsecureSkipVerify(insecureSkipTLSVerify), + ) + + if err != nil { + t.Fatal(fmt.Errorf("can't create TLS config for client: %w", err)) + } + + tlsSrv2 := httptest.NewUnstartedServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + rw.Header().Set("Content-Type", "text/plain") + rw.Write([]byte(srv2Resp)) + })) + + tlsSrv2.TLS = tlsConf + tlsSrv2.StartTLS() + defer tlsSrv2.Close() + + // Server 1 responds with a redirect to Server 2. + ca, pub, priv = filepath.Join(cd, "rootca.crt"), filepath.Join(cd, "crt.pem"), filepath.Join(cd, "key.pem") + tlsConf, err = tlsutil.NewTLSConfig( + tlsutil.WithCAFile(ca), + tlsutil.WithCertKeyPairFiles(pub, priv), + tlsutil.WithInsecureSkipVerify(insecureSkipTLSVerify), + ) + + if err != nil { + t.Fatal(fmt.Errorf("can't create TLS config for client: %w", err)) + } + + tlsSrv1 := httptest.NewUnstartedServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + u, _ := url.ParseRequestURI(tlsSrv2.URL) + + // Make the request using the hostname 'localhost' (to which 'localhost-crt.pem' is issued) + // to verify that a successful TLS connection is made even if the client doesn't specify + // the hostname (SNI) in `tls.Config.ServerName`. By default the hostname is derived from the + // request URL for every request (including redirects). Setting `tls.Config.ServerName` on the + // client just overrides the remote endpoint's hostname. + // See https://github.com/golang/go/blob/3979fb9/src/net/http/transport.go#L1505-L1513. + u.Host = fmt.Sprintf("localhost:%s", u.Port()) + + http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect) + })) + + tlsSrv1.TLS = tlsConf + tlsSrv1.StartTLS() + defer tlsSrv1.Close() + + u, _ := url.ParseRequestURI(tlsSrv1.URL) + + t.Run("Test with TLS", func(t *testing.T) { + g, err := NewHTTPGetter( + WithURL(u.String()), + WithTLSClientConfig(pub, priv, ca), + ) + if err != nil { + t.Fatal(err) + } + + buf, err := g.Get(u.String()) + if err != nil { + t.Error(err) + } + + b, err := io.ReadAll(buf) + if err != nil { + t.Error(err) + } + + if string(b) != srv2Resp { + t.Errorf("expected response from Server2 to be '%s', instead got: %s", srv2Resp, string(b)) + } + }) + + t.Run("Test with TLS config being passed along in .Get (see #6635)", func(t *testing.T) { + g, err := NewHTTPGetter() + if err != nil { + t.Fatal(err) + } + + buf, err := g.Get(u.String(), WithURL(u.String()), WithTLSClientConfig(pub, priv, ca)) + if err != nil { + t.Error(err) + } + + b, err := io.ReadAll(buf) + if err != nil { + t.Error(err) + } + + if string(b) != srv2Resp { + t.Errorf("expected response from Server2 to be '%s', instead got: %s", srv2Resp, string(b)) + } + }) + + t.Run("Test with only the CA file (see also #6635)", func(t *testing.T) { + g, err := NewHTTPGetter() + if err != nil { + t.Fatal(err) + } + + buf, err := g.Get(u.String(), WithURL(u.String()), WithTLSClientConfig("", "", ca)) + if err != nil { + t.Error(err) + } + + b, err := io.ReadAll(buf) + if err != nil { + t.Error(err) + } + + if string(b) != srv2Resp { + t.Errorf("expected response from Server2 to be '%s', instead got: %s", srv2Resp, string(b)) + } + }) +} + func TestDownloadInsecureSkipTLSVerify(t *testing.T) { - ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + ts := httptest.NewTLSServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) defer ts.Close() u, _ := url.ParseRequestURI(ts.URL) @@ -364,7 +514,7 @@ func TestDownloadInsecureSkipTLSVerify(t *testing.T) { } func TestHTTPGetterTarDownload(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { f, _ := os.Open("testdata/empty-0.0.1.tgz") defer f.Close() @@ -423,12 +573,10 @@ func TestHttpClientInsecureSkipVerify(t *testing.T) { if len(transport.TLSClientConfig.Certificates) <= 0 { t.Fatal("transport.TLSClientConfig.Certificates is not present") } - if transport.TLSClientConfig.ServerName == "" { - t.Fatal("TLSClientConfig.ServerName is blank") - } } func verifyInsecureSkipVerify(t *testing.T, g *HTTPGetter, caseName string, expectedValue bool) *http.Transport { + t.Helper() returnVal, err := g.httpClient() if err != nil { diff --git a/pkg/helm/pkg/getter/ocigetter.go b/pkg/helm/pkg/getter/ocigetter.go index 3782f8e3..65bc6d26 100644 --- a/pkg/helm/pkg/getter/ocigetter.go +++ b/pkg/helm/pkg/getter/ocigetter.go @@ -17,21 +17,23 @@ package getter import ( "bytes" + "crypto/tls" "fmt" "net" "net/http" + "path" "strings" "sync" "time" - "github.com/werf/nelm/pkg/helm/internal/tlsutil" - "github.com/werf/nelm/pkg/helm/internal/urlutil" + "github.com/werf/nelm/pkg/helm/intern/tlsutil" + "github.com/werf/nelm/pkg/helm/intern/urlutil" "github.com/werf/nelm/pkg/helm/pkg/registry" ) // OCIGetter is the default HTTP(/S) backend handler type OCIGetter struct { - opts options + opts getterOptions transport *http.Transport once sync.Once } @@ -58,6 +60,15 @@ func (g *OCIGetter) get(href string) (*bytes.Buffer, error) { ref := strings.TrimPrefix(href, fmt.Sprintf("%s://", registry.OCIScheme)) + if version := g.opts.version; version != "" && !strings.Contains(path.Base(ref), ":") { + ref = fmt.Sprintf("%s:%s", ref, version) + } + // Check if this is a plugin request + if g.opts.artifactType == "plugin" { + return g.getPlugin(client, ref) + } + + // Default to chart behavior for backward compatibility var pullOpts []registry.PullOption requestingProv := strings.HasSuffix(ref, ".prov") if requestingProv { @@ -119,11 +130,19 @@ func (g *OCIGetter) newRegistryClient() (*registry.Client, error) { IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, + Proxy: http.ProxyFromEnvironment, + // Being nil would cause the tls.Config default to be used + // "NewTLSConfig" modifies an empty TLS config, not the default one + TLSClientConfig: &tls.Config{}, } }) if (g.opts.certFile != "" && g.opts.keyFile != "") || g.opts.caFile != "" || g.opts.insecureSkipVerifyTLS { - tlsConf, err := tlsutil.NewClientTLS(g.opts.certFile, g.opts.keyFile, g.opts.caFile, g.opts.insecureSkipVerifyTLS) + tlsConf, err := tlsutil.NewTLSConfig( + tlsutil.WithInsecureSkipVerify(g.opts.insecureSkipVerifyTLS), + tlsutil.WithCertKeyPairFiles(g.opts.certFile, g.opts.keyFile), + tlsutil.WithCAFile(g.opts.caFile), + ) if err != nil { return nil, fmt.Errorf("can't create TLS config for client: %w", err) } @@ -153,3 +172,42 @@ func (g *OCIGetter) newRegistryClient() (*registry.Client, error) { return client, nil } + +// getPlugin handles plugin-specific OCI pulls +func (g *OCIGetter) getPlugin(client *registry.Client, ref string) (*bytes.Buffer, error) { + // Check if this is a provenance file request + requestingProv := strings.HasSuffix(ref, ".prov") + if requestingProv { + ref = strings.TrimSuffix(ref, ".prov") + } + + // Extract plugin name from the reference + // e.g., "ghcr.io/user/plugin-name:v1.0.0" -> "plugin-name" + parts := strings.Split(ref, "/") + if len(parts) < 2 { + return nil, fmt.Errorf("invalid OCI reference: %s", ref) + } + lastPart := parts[len(parts)-1] + pluginName := lastPart + if idx := strings.LastIndex(lastPart, ":"); idx > 0 { + pluginName = lastPart[:idx] + } + if idx := strings.LastIndex(lastPart, "@"); idx > 0 { + pluginName = lastPart[:idx] + } + + var pullOpts []registry.PluginPullOption + if requestingProv { + pullOpts = append(pullOpts, registry.PullPluginOptWithProv(true)) + } + + result, err := client.PullPlugin(ref, pluginName, pullOpts...) + if err != nil { + return nil, err + } + + if requestingProv { + return bytes.NewBuffer(result.Prov.Data), nil + } + return bytes.NewBuffer(result.PluginData), nil +} diff --git a/pkg/helm/pkg/getter/ocigetter_test.go b/pkg/helm/pkg/getter/ocigetter_test.go index 983ace40..ec79ea55 100644 --- a/pkg/helm/pkg/getter/ocigetter_test.go +++ b/pkg/helm/pkg/getter/ocigetter_test.go @@ -42,7 +42,7 @@ func TestOCIGetter(t *testing.T) { insecureSkipVerifyTLS := false plainHTTP := false - // Test with options + // Test with getterOptions g, err = NewOCIGetter( WithBasicAuth("I", "Am"), WithTLSClientConfig(pub, priv, ca), diff --git a/pkg/helm/pkg/getter/plugingetter.go b/pkg/helm/pkg/getter/plugingetter.go new file mode 100644 index 00000000..4e8b723f --- /dev/null +++ b/pkg/helm/pkg/getter/plugingetter.go @@ -0,0 +1,26 @@ +/* +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 getter + +import ( + "github.com/werf/nelm/pkg/helm/pkg/cli" +) + +// collectGetterPlugins scans for getter plugins. +// This will load plugins according to the cli. +func collectGetterPlugins(_ *cli.EnvSettings) (Providers, error) { + return nil, nil +} diff --git a/pkg/helm/pkg/getter/testdata/plugins/testgetter/get.sh b/pkg/helm/pkg/getter/testdata/plugins/testgetter/get.sh deleted file mode 100755 index cdd99236..00000000 --- a/pkg/helm/pkg/getter/testdata/plugins/testgetter/get.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -echo ENVIRONMENT -env - -echo "" -echo ARGUMENTS -echo $@ diff --git a/pkg/helm/pkg/getter/testdata/plugins/testgetter/plugin.yaml b/pkg/helm/pkg/getter/testdata/plugins/testgetter/plugin.yaml index 7c63007a..ca11b95e 100644 --- a/pkg/helm/pkg/getter/testdata/plugins/testgetter/plugin.yaml +++ b/pkg/helm/pkg/getter/testdata/plugins/testgetter/plugin.yaml @@ -1,15 +1,13 @@ name: "testgetter" version: "0.1.0" -usage: "Fetch a package from a test:// source" -description: |- - Print the environment that the plugin was given, then exit. - - This registers the test:// protocol. - -command: "$HELM_PLUGIN_DIR/get.sh" -ignoreFlags: true -downloaders: - #- command: "$HELM_PLUGIN_DIR/get.sh" - - command: "echo" - protocols: - - "test" +type: getter/v1 +apiVersion: v1 +runtime: subprocess +config: + protocols: + - "test" +runtimeConfig: + protocolCommands: + - command: "echo" + protocols: + - "test" diff --git a/pkg/helm/pkg/getter/testdata/plugins/testgetter2/get.sh b/pkg/helm/pkg/getter/testdata/plugins/testgetter2/get.sh deleted file mode 100755 index cdd99236..00000000 --- a/pkg/helm/pkg/getter/testdata/plugins/testgetter2/get.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -echo ENVIRONMENT -env - -echo "" -echo ARGUMENTS -echo $@ diff --git a/pkg/helm/pkg/getter/testdata/plugins/testgetter2/plugin.yaml b/pkg/helm/pkg/getter/testdata/plugins/testgetter2/plugin.yaml index c761149c..1c944a7c 100644 --- a/pkg/helm/pkg/getter/testdata/plugins/testgetter2/plugin.yaml +++ b/pkg/helm/pkg/getter/testdata/plugins/testgetter2/plugin.yaml @@ -1,10 +1,13 @@ name: "testgetter2" version: "0.1.0" -usage: "Fetch a different package from a test2:// source" -description: "Handle test2 scheme" -command: "$HELM_PLUGIN_DIR/get.sh" -ignoreFlags: true -downloaders: - - command: "echo" - protocols: - - "test2" +type: getter/v1 +apiVersion: v1 +runtime: subprocess +config: + protocols: + - "test2" +runtimeConfig: + protocolCommands: + - command: "echo" + protocols: + - "test2" diff --git a/pkg/helm/pkg/getter/testdata/repository/repositories.yaml b/pkg/helm/pkg/getter/testdata/repository/repositories.yaml index 1068a471..14ae6a8e 100644 --- a/pkg/helm/pkg/getter/testdata/repository/repositories.yaml +++ b/pkg/helm/pkg/getter/testdata/repository/repositories.yaml @@ -1,15 +1,15 @@ apiVersion: v1 generated: 2017-04-28T12:34:38.551693035-06:00 repositories: - - caFile: "" - cache: repository/cache/stable-index.yaml - certFile: "" - keyFile: "" - name: stable - url: https://charts.helm.sh/stable - - caFile: "" - cache: repository/cache/local-index.yaml - certFile: "" - keyFile: "" - name: local - url: http://127.0.0.1:8879/charts +- caFile: "" + cache: repository/cache/stable-index.yaml + certFile: "" + keyFile: "" + name: stable + url: https://charts.helm.sh/stable +- caFile: "" + cache: repository/cache/local-index.yaml + certFile: "" + keyFile: "" + name: local + url: http://127.0.0.1:8879/charts diff --git a/pkg/helm/pkg/helmpath/home_unix_test.go b/pkg/helm/pkg/helmpath/home_unix_test.go index de8beabc..87748759 100644 --- a/pkg/helm/pkg/helmpath/home_unix_test.go +++ b/pkg/helm/pkg/helmpath/home_unix_test.go @@ -16,7 +16,6 @@ package helmpath import ( - "os" "runtime" "testing" @@ -24,9 +23,9 @@ import ( ) func TestHelmHome(t *testing.T) { - os.Setenv(xdg.CacheHomeEnvVar, "/cache") - os.Setenv(xdg.ConfigHomeEnvVar, "/config") - os.Setenv(xdg.DataHomeEnvVar, "/data") + t.Setenv(xdg.CacheHomeEnvVar, "/cache") + t.Setenv(xdg.ConfigHomeEnvVar, "/config") + t.Setenv(xdg.DataHomeEnvVar, "/data") isEq := func(t *testing.T, got, expected string) { t.Helper() if expected != got { @@ -40,7 +39,7 @@ func TestHelmHome(t *testing.T) { isEq(t, DataPath(), "/data/helm") // test to see if lazy-loading environment variables at runtime works - os.Setenv(xdg.CacheHomeEnvVar, "/cache2") + t.Setenv(xdg.CacheHomeEnvVar, "/cache2") isEq(t, CachePath(), "/cache2/helm") } diff --git a/pkg/helm/pkg/helmpath/lazypath.go b/pkg/helm/pkg/helmpath/lazypath.go index 09e58dc2..a33fc166 100644 --- a/pkg/helm/pkg/helmpath/lazypath.go +++ b/pkg/helm/pkg/helmpath/lazypath.go @@ -34,7 +34,7 @@ const ( DataHomeEnvVar = "HELM_DATA_HOME" ) -// lazypath is an lazy-loaded path buffer for the XDG base directory specification. +// lazypath is a lazy-loaded path buffer for the XDG base directory specification. type lazypath string func (l lazypath) path(helmEnvVar, xdgEnvVar string, defaultFn func() string, elem ...string) string { diff --git a/pkg/helm/pkg/helmpath/lazypath_darwin_test.go b/pkg/helm/pkg/helmpath/lazypath_darwin_test.go index ec49ed53..4de08f0d 100644 --- a/pkg/helm/pkg/helmpath/lazypath_darwin_test.go +++ b/pkg/helm/pkg/helmpath/lazypath_darwin_test.go @@ -40,7 +40,7 @@ func TestDataPath(t *testing.T) { t.Errorf("expected '%s', got '%s'", expected, lazy.dataPath(testFile)) } - os.Setenv(xdg.DataHomeEnvVar, "/tmp") + t.Setenv(xdg.DataHomeEnvVar, "/tmp") expected = filepath.Join("/tmp", appName, testFile) @@ -58,7 +58,7 @@ func TestConfigPath(t *testing.T) { t.Errorf("expected '%s', got '%s'", expected, lazy.configPath(testFile)) } - os.Setenv(xdg.ConfigHomeEnvVar, "/tmp") + t.Setenv(xdg.ConfigHomeEnvVar, "/tmp") expected = filepath.Join("/tmp", appName, testFile) @@ -76,7 +76,7 @@ func TestCachePath(t *testing.T) { t.Errorf("expected '%s', got '%s'", expected, lazy.cachePath(testFile)) } - os.Setenv(xdg.CacheHomeEnvVar, "/tmp") + t.Setenv(xdg.CacheHomeEnvVar, "/tmp") expected = filepath.Join("/tmp", appName, testFile) diff --git a/pkg/helm/pkg/helmpath/lazypath_unix_test.go b/pkg/helm/pkg/helmpath/lazypath_unix_test.go index 87bf9c31..1bb3c2ff 100644 --- a/pkg/helm/pkg/helmpath/lazypath_unix_test.go +++ b/pkg/helm/pkg/helmpath/lazypath_unix_test.go @@ -16,7 +16,6 @@ package helmpath import ( - "os" "path/filepath" "testing" @@ -32,15 +31,13 @@ const ( ) func TestDataPath(t *testing.T) { - os.Unsetenv(xdg.DataHomeEnvVar) - expected := filepath.Join(homedir.HomeDir(), ".local", "share", appName, testFile) if lazy.dataPath(testFile) != expected { t.Errorf("expected '%s', got '%s'", expected, lazy.dataPath(testFile)) } - os.Setenv(xdg.DataHomeEnvVar, "/tmp") + t.Setenv(xdg.DataHomeEnvVar, "/tmp") expected = filepath.Join("/tmp", appName, testFile) @@ -50,15 +47,13 @@ func TestDataPath(t *testing.T) { } func TestConfigPath(t *testing.T) { - os.Unsetenv(xdg.ConfigHomeEnvVar) - expected := filepath.Join(homedir.HomeDir(), ".config", appName, testFile) if lazy.configPath(testFile) != expected { t.Errorf("expected '%s', got '%s'", expected, lazy.configPath(testFile)) } - os.Setenv(xdg.ConfigHomeEnvVar, "/tmp") + t.Setenv(xdg.ConfigHomeEnvVar, "/tmp") expected = filepath.Join("/tmp", appName, testFile) @@ -68,15 +63,13 @@ func TestConfigPath(t *testing.T) { } func TestCachePath(t *testing.T) { - os.Unsetenv(xdg.CacheHomeEnvVar) - expected := filepath.Join(homedir.HomeDir(), ".cache", appName, testFile) if lazy.cachePath(testFile) != expected { t.Errorf("expected '%s', got '%s'", expected, lazy.cachePath(testFile)) } - os.Setenv(xdg.CacheHomeEnvVar, "/tmp") + t.Setenv(xdg.CacheHomeEnvVar, "/tmp") expected = filepath.Join("/tmp", appName, testFile) diff --git a/pkg/helm/pkg/ignore/doc.go b/pkg/helm/pkg/ignore/doc.go index 5245d410..f07764ea 100644 --- a/pkg/helm/pkg/ignore/doc.go +++ b/pkg/helm/pkg/ignore/doc.go @@ -26,7 +26,7 @@ The formatting rules are as follows: - Parsing is line-by-line - Empty lines are ignored - - Lines the begin with # (comments) will be ignored + - Lines that begin with # (comments) will be ignored - Leading and trailing spaces are always ignored - Inline comments are NOT supported ('foo* # Any foo' does not contain a comment) - There is no support for multi-line patterns @@ -65,4 +65,4 @@ Notable differences from .gitignore: - The evaluation of escape sequences has not been tested for compatibility - There is no support for '\!' as a special leading sequence. */ -package ignore // import "helm.sh/helm/v3/pkg/ignore" +package ignore // import "github.com/werf/nelm/pkg/helm/pkg/ignore" diff --git a/pkg/helm/pkg/ignore/rules.go b/pkg/helm/pkg/ignore/rules.go index a80923ba..a8160da2 100644 --- a/pkg/helm/pkg/ignore/rules.go +++ b/pkg/helm/pkg/ignore/rules.go @@ -19,13 +19,12 @@ package ignore import ( "bufio" "bytes" + "errors" "io" - "log" + "log/slog" "os" "path/filepath" "strings" - - "github.com/pkg/errors" ) // HelmIgnore default name of an ignorefile. @@ -102,7 +101,7 @@ func (r *Rules) Ignore(path string, fi os.FileInfo) bool { } for _, p := range r.patterns { if p.match == nil { - log.Printf("ignore: no matcher supplied for %q", p.raw) + slog.Info("this will be ignored no matcher supplied", "patterns", p.raw) return false } @@ -171,35 +170,39 @@ func (r *Rules) parseRule(rule string) error { rule = strings.TrimSuffix(rule, "/") } - if strings.HasPrefix(rule, "/") { + if after, ok := strings.CutPrefix(rule, "/"); ok { // Require path matches the root path. - p.match = func(n string, fi os.FileInfo) bool { - rule = strings.TrimPrefix(rule, "/") + p.match = func(n string, _ os.FileInfo) bool { + rule = after ok, err := filepath.Match(rule, n) if err != nil { - log.Printf("Failed to compile %q: %s", rule, err) + slog.Error("failed to compile", slog.String("rule", rule), slog.Any("error", err)) return false } return ok } } else if strings.Contains(rule, "/") { // require structural match. - p.match = func(n string, fi os.FileInfo) bool { + p.match = func(n string, _ os.FileInfo) bool { ok, err := filepath.Match(rule, n) if err != nil { - log.Printf("Failed to compile %q: %s", rule, err) + slog.Error( + "failed to compile", + slog.String("rule", rule), + slog.Any("error", err), + ) return false } return ok } } else { - p.match = func(n string, fi os.FileInfo) bool { + p.match = func(n string, _ os.FileInfo) bool { // When there is no slash in the pattern, we evaluate ONLY the // filename. n = filepath.Base(n) ok, err := filepath.Match(rule, n) if err != nil { - log.Printf("Failed to compile %q: %s", rule, err) + slog.Error("failed to compile", slog.String("rule", rule), slog.Any("error", err)) return false } return ok diff --git a/pkg/helm/pkg/kube/client.go b/pkg/helm/pkg/kube/client.go index 3b2017fb..0e07043c 100644 --- a/pkg/helm/pkg/kube/client.go +++ b/pkg/helm/pkg/kube/client.go @@ -14,49 +14,50 @@ See the License for the specific language governing permissions and limitations under the License. */ -package kube // import "helm.sh/helm/v3/pkg/kube" +package kube // import "github.com/werf/nelm/pkg/helm/pkg/kube" import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" + "log/slog" + "net/http" "os" "path/filepath" "reflect" "strings" "sync" - "time" - jsonpatch "github.com/evanphx/json-patch" - "github.com/pkg/errors" - "github.com/werf/nelm/pkg/helm/pkg/releaseutil" - batch "k8s.io/api/batch/v1" + jsonpatch "github.com/evanphx/json-patch/v5" v1 "k8s.io/api/core/v1" apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + + "github.com/werf/nelm/pkg/helm/intern/logging" - multierror "github.com/hashicorp/go-multierror" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1" - "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/labels" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/jsonmergepatch" + "k8s.io/apimachinery/pkg/util/mergepatch" + "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/strategicpatch" - "k8s.io/apimachinery/pkg/watch" "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/cli-runtime/pkg/resource" "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" - cachetools "k8s.io/client-go/tools/cache" - watchtools "k8s.io/client-go/tools/watch" + "k8s.io/client-go/util/csaupgrade" + "k8s.io/client-go/util/retry" cmdutil "k8s.io/kubectl/pkg/cmd/util" ) @@ -67,7 +68,7 @@ var metadataAccessor = meta.NewAccessor() // ManagedFieldsManager is the name of the manager of Kubernetes managedFields // first introduced in Kubernetes 1.18 -var ManagedFieldsManager string +var ManagedFieldsManager = "helm" // Client represents a client capable of communicating with the Kubernetes API. type Client struct { @@ -80,43 +81,160 @@ type Client struct { // needs. The smaller surface area of the interface means there is a lower // chance of it changing. Factory Factory - Log func(string, ...interface{}) // Namespace allows to bypass the kubeconfig file for the choice of the namespace Namespace string - kubeClient *kubernetes.Clientset + // WaitContext is an optional context to use for wait operations. + // If not set, a context will be created internally using the + // timeout provided to the wait functions. + // + // Deprecated: Use WithWaitContext wait option when getting a Waiter instead. + WaitContext context.Context + + Waiter + kubeClient kubernetes.Interface + + // Embed a LogHolder to provide logger functionality + logging.LogHolder +} + +var _ Interface = (*Client)(nil) + +// WaitStrategy represents the algorithm used to wait for Kubernetes +// resources to reach their desired state. +type WaitStrategy string + +const ( + // StatusWatcherStrategy: event-driven waits using kstatus (watches + aggregated readers). + // Default for --wait. More accurate and responsive; waits CRs and full reconciliation. + // Requires: reachable API server, list+watch RBAC on deployed resources, and a non-zero timeout. + StatusWatcherStrategy WaitStrategy = "watcher" + + // LegacyStrategy: Helm 3-style periodic polling until ready or timeout. + // Use when watches aren’t available/reliable, or for compatibility/simple CI. + // Requires only list RBAC for polled resources. + LegacyStrategy WaitStrategy = "legacy" + + // HookOnlyStrategy: wait only for hook Pods/Jobs to complete; does not wait for general chart resources. + HookOnlyStrategy WaitStrategy = "hookOnly" +) + +type FieldValidationDirective string + +const ( + FieldValidationDirectiveIgnore FieldValidationDirective = "Ignore" + FieldValidationDirectiveWarn FieldValidationDirective = "Warn" + FieldValidationDirectiveStrict FieldValidationDirective = "Strict" +) + +type CreateApplyFunc func(target *resource.Info) error +type UpdateApplyFunc func(original, target *resource.Info) error + +func init() { + // Add CRDs to the scheme. They are missing by default. + if err := apiextv1.AddToScheme(scheme.Scheme); err != nil { + // This should never happen. + panic(err) + } + if err := apiextv1beta1.AddToScheme(scheme.Scheme); err != nil { + panic(err) + } +} + +func (c *Client) newStatusWatcher(opts ...WaitOption) (*statusWaiter, error) { + var o waitOptions + for _, opt := range opts { + opt(&o) + } + cfg, err := c.Factory.ToRESTConfig() + if err != nil { + return nil, err + } + dynamicClient, err := c.Factory.DynamicClient() + if err != nil { + return nil, err + } + httpClient, err := rest.HTTPClientFor(cfg) + if err != nil { + return nil, err + } + restMapper, err := apiutil.NewDynamicRESTMapper(cfg, httpClient) + if err != nil { + return nil, err + } + waitContext := o.ctx + if waitContext == nil { + waitContext = c.WaitContext + } + sw := &statusWaiter{ + restMapper: restMapper, + client: dynamicClient, + ctx: waitContext, + watchUntilReadyCtx: o.watchUntilReadyCtx, + waitCtx: o.waitCtx, + waitWithJobsCtx: o.waitWithJobsCtx, + waitForDeleteCtx: o.waitForDeleteCtx, + readers: o.statusReaders, + } + sw.SetLogger(c.Logger().Handler()) + return sw, nil +} + +func (c *Client) GetWaiter(ws WaitStrategy) (Waiter, error) { + return c.GetWaiterWithOptions(ws) +} - ResourcesWaiter ResourcesWaiter - Extender ClientExtender +func (c *Client) GetWaiterWithOptions(strategy WaitStrategy, opts ...WaitOption) (Waiter, error) { + switch strategy { + case LegacyStrategy: + kc, err := c.Factory.KubernetesClientSet() + if err != nil { + return nil, err + } + return &legacyWaiter{kubeClient: kc, ctx: c.WaitContext}, nil + case StatusWatcherStrategy: + return c.newStatusWatcher(opts...) + case HookOnlyStrategy: + sw, err := c.newStatusWatcher(opts...) + if err != nil { + return nil, err + } + return &hookOnlyWaiter{sw: sw}, nil + case "": + return nil, errors.New("wait strategy not set. Choose one of: " + string(StatusWatcherStrategy) + ", " + string(HookOnlyStrategy) + ", " + string(LegacyStrategy)) + default: + return nil, errors.New("unknown wait strategy (s" + string(strategy) + "). Valid values are: " + string(StatusWatcherStrategy) + ", " + string(HookOnlyStrategy) + ", " + string(LegacyStrategy)) + } +} + +func (c *Client) SetWaiter(ws WaitStrategy) error { + return c.SetWaiterWithOptions(ws) } -var addToScheme sync.Once +func (c *Client) SetWaiterWithOptions(ws WaitStrategy, opts ...WaitOption) error { + var err error + c.Waiter, err = c.GetWaiterWithOptions(ws, opts...) + if err != nil { + return err + } + return nil +} // New creates a new Client. func New(getter genericclioptions.RESTClientGetter) *Client { if getter == nil { getter = genericclioptions.NewConfigFlags(true) } - // Add CRDs to the scheme. They are missing by default. - addToScheme.Do(func() { - if err := apiextv1.AddToScheme(scheme.Scheme); err != nil { - // This should never happen. - panic(err) - } - if err := apiextv1beta1.AddToScheme(scheme.Scheme); err != nil { - panic(err) - } - }) - return &Client{ - Factory: cmdutil.NewFactory(getter), - Log: nopLogger, + factory := cmdutil.NewFactory(getter) + c := &Client{ + Factory: factory, } + c.SetLogger(slog.Default().Handler()) + return c } -var nopLogger = func(_ string, _ ...interface{}) {} - // getKubeClient get or create a new KubernetesClientSet -func (c *Client) getKubeClient() (*kubernetes.Clientset, error) { +func (c *Client) getKubeClient() (kubernetes.Interface, error) { var err error if c.kubeClient == nil { c.kubeClient, err = c.Factory.KubernetesClientSet() @@ -129,37 +247,124 @@ func (c *Client) getKubeClient() (*kubernetes.Clientset, error) { func (c *Client) IsReachable() error { client, err := c.getKubeClient() if err == genericclioptions.ErrEmptyConfig { - // re-replace kubernetes ErrEmptyConfig error with a friendy error + // re-replace kubernetes ErrEmptyConfig error with a friendly error // moar workarounds for Kubernetes API breaking. - return errors.New("Kubernetes cluster unreachable") + return errors.New("kubernetes cluster unreachable") } if err != nil { - return errors.Wrap(err, "Kubernetes cluster unreachable") + return fmt.Errorf("kubernetes cluster unreachable: %w", err) } - if _, err := client.ServerVersion(); err != nil { - return errors.Wrap(err, "Kubernetes cluster unreachable") + if _, err := client.Discovery().ServerVersion(); err != nil { + return fmt.Errorf("kubernetes cluster unreachable: %w", err) } return nil } -// Create creates Kubernetes resources specified in the resource list. -func (c *Client) Create(resources ResourceList, opts CreateOptions) (*Result, error) { - if c.Extender != nil { - if err := perform(resources, c.Extender.BeforeCreateResource); err != nil { - return &Result{}, err +type clientCreateOptions struct { + serverSideApply bool + forceConflicts bool + dryRun bool + fieldValidationDirective FieldValidationDirective +} + +type ClientCreateOption func(*clientCreateOptions) error + +// ClientCreateOptionServerSideApply enables performing object apply server-side +// see: https://kubernetes.io/docs/reference/using-api/server-side-apply/ +// +// `forceConflicts` forces conflicts to be resolved (may be when serverSideApply enabled only) +// see: https://kubernetes.io/docs/reference/using-api/server-side-apply/#conflicts +func ClientCreateOptionServerSideApply(serverSideApply, forceConflicts bool) ClientCreateOption { + return func(o *clientCreateOptions) error { + if !serverSideApply && forceConflicts { + return fmt.Errorf("forceConflicts enabled when serverSideApply disabled") } + + o.serverSideApply = serverSideApply + o.forceConflicts = forceConflicts + + return nil } +} - c.Log("creating %d resource(s)", len(resources)) +// ClientCreateOptionDryRun requests the server to perform non-mutating operations only +func ClientCreateOptionDryRun(dryRun bool) ClientCreateOption { + return func(o *clientCreateOptions) error { + o.dryRun = dryRun - var fn func(*resource.Info) (performResourceStatus, error) - if opts.SkipIfAlreadyExists { - fn = createResourceSkipIfExists - } else { - fn = createResource + return nil } +} - return performWithResult(resources, fn) +// ClientCreateOptionFieldValidationDirective specifies how API operations validate object's schema +// - For client-side apply: this is ignored +// - For server-side apply: the directive is sent to the server to perform the validation +// +// Defaults to `FieldValidationDirectiveStrict` +func ClientCreateOptionFieldValidationDirective(fieldValidationDirective FieldValidationDirective) ClientCreateOption { + return func(o *clientCreateOptions) error { + o.fieldValidationDirective = fieldValidationDirective + + return nil + } +} + +func (c *Client) makeCreateApplyFunc(serverSideApply, forceConflicts, dryRun bool, fieldValidationDirective FieldValidationDirective) CreateApplyFunc { + if serverSideApply { + c.Logger().Debug( + "using server-side apply for resource creation", + slog.Bool("forceConflicts", forceConflicts), + slog.Bool("dryRun", dryRun), + slog.String("fieldValidationDirective", string(fieldValidationDirective))) + + return func(target *resource.Info) error { + err := patchResourceServerSide(target, dryRun, forceConflicts, fieldValidationDirective) + + logger := c.Logger().With( + slog.String("namespace", target.Namespace), + slog.String("name", target.Name), + slog.String("gvk", target.Mapping.GroupVersionKind.String())) + if err != nil { + logger.Debug("Error creating resource via patch", slog.Any("error", err)) + return err + } + + logger.Debug("Created resource via patch") + + return nil + } + } + + c.Logger().Debug("using client-side apply for resource creation") + return createResource +} + +// Create creates Kubernetes resources specified in the resource list. +func (c *Client) Create(resources ResourceList, options ...ClientCreateOption) (*Result, error) { + c.Logger().Debug("creating resource(s)", "resources", len(resources)) + + createOptions := clientCreateOptions{ + serverSideApply: true, // Default to server-side apply + fieldValidationDirective: FieldValidationDirectiveStrict, + } + + errs := make([]error, 0, len(options)) + for _, o := range options { + errs = append(errs, o(&createOptions)) + } + if err := errors.Join(errs...); err != nil { + return nil, fmt.Errorf("invalid client create option(s): %w", err) + } + + createApplyFunc := c.makeCreateApplyFunc( + createOptions.serverSideApply, + createOptions.forceConflicts, + createOptions.dryRun, + createOptions.fieldValidationDirective) + if err := perform(resources, createApplyFunc); err != nil { + return nil, err + } + return &Result{Created: resources}, nil } func transformRequests(req *rest.Request) { @@ -207,7 +412,7 @@ func (c *Client) Get(resources ResourceList, related bool) (map[string][]runtime objs, err = c.getSelectRelationPod(info, objs, isTable, &podSelectors) if err != nil { - c.Log("Warning: get the relation pod is failed, err:%s", err.Error()) + c.Logger().Warn("get the relation pod is failed", slog.Any("error", err)) } } } @@ -225,7 +430,7 @@ func (c *Client) getSelectRelationPod(info *resource.Info, objs map[string][]run if info == nil { return objs, nil } - c.Log("get relation pod of object: %s/%s/%s", info.Namespace, info.Mapping.GroupVersionKind.Kind, info.Name) + c.Logger().Debug("get relation pod of object", "namespace", info.Namespace, "name", info.Name, "kind", info.Mapping.GroupVersionKind.Kind) selector, ok, _ := getSelectorFromObject(info.Object) if !ok { return objs, nil @@ -297,45 +502,6 @@ func getResource(info *resource.Info) (runtime.Object, error) { return obj, nil } -// Wait waits up to the given timeout for the specified resources to be ready. -func (c *Client) Wait(resources ResourceList, timeout time.Duration) error { - cs, err := c.getKubeClient() - if err != nil { - return err - } - checker := NewReadyChecker(cs, c.Log, PausedAsReady(true)) - w := waiter{ - c: checker, - log: c.Log, - timeout: timeout, - } - return w.waitForResources(resources) -} - -// WaitWithJobs wait up to the given timeout for the specified resources to be ready, including jobs. -func (c *Client) WaitWithJobs(resources ResourceList, timeout time.Duration) error { - cs, err := c.getKubeClient() - if err != nil { - return err - } - checker := NewReadyChecker(cs, c.Log, PausedAsReady(true), CheckJobs(true)) - w := waiter{ - c: checker, - log: c.Log, - timeout: timeout, - } - return w.waitForResources(resources) -} - -// WaitForDelete wait up to the given timeout for the specified resources to be deleted. -func (c *Client) WaitForDelete(resources ResourceList, timeout time.Duration) error { - w := waiter{ - log: c.Log, - timeout: timeout, - } - return w.waitForDeletedResources(resources) -} - func (c *Client) namespace() string { if c.Namespace != "" { return c.Namespace @@ -346,113 +512,103 @@ func (c *Client) namespace() string { return v1.NamespaceDefault } -// newBuilder returns a new resource builder for structured api objects. -func (c *Client) newBuilder() *resource.Builder { - return c.Factory.NewBuilder(). - ContinueOnError(). - NamespaceParam(c.namespace()). - DefaultNamespace(). - Flatten() -} - -// Build validates for Kubernetes objects and returns unstructured infos. -func (c *Client) Build(reader io.Reader, validate bool) (ResourceList, error) { - validationDirective := metav1.FieldValidationIgnore +func determineFieldValidationDirective(validate bool) FieldValidationDirective { if validate { - validationDirective = metav1.FieldValidationStrict + return FieldValidationDirectiveStrict } - schema, err := c.Factory.Validator(validationDirective) + return FieldValidationDirectiveIgnore +} + +func buildResourceList(f Factory, namespace string, validationDirective FieldValidationDirective, reader io.Reader, transformRequest resource.RequestTransform) (ResourceList, error) { + + schema, err := f.Validator(string(validationDirective)) if err != nil { return nil, err } - result, err := c.newBuilder(). + + builder := f.NewBuilder(). + ContinueOnError(). + NamespaceParam(namespace). + DefaultNamespace(). + Flatten(). Unstructured(). Schema(schema). - Stream(reader, ""). - Do().Infos() + Stream(reader, "") + if transformRequest != nil { + builder.TransformRequests(transformRequest) + } + result, err := builder.Do().Infos() return result, scrubValidationError(err) } +// Build validates for Kubernetes objects and returns unstructured infos. +func (c *Client) Build(reader io.Reader, validate bool) (ResourceList, error) { + return buildResourceList( + c.Factory, + c.namespace(), + determineFieldValidationDirective(validate), + reader, + nil) +} + // BuildTable validates for Kubernetes objects and returns unstructured infos. // The returned kind is a Table. func (c *Client) BuildTable(reader io.Reader, validate bool) (ResourceList, error) { - validationDirective := metav1.FieldValidationIgnore - if validate { - validationDirective = metav1.FieldValidationStrict - } - - schema, err := c.Factory.Validator(validationDirective) - if err != nil { - return nil, err - } - result, err := c.newBuilder(). - Unstructured(). - Schema(schema). - Stream(reader, ""). - TransformRequests(transformRequests). - Do().Infos() - return result, scrubValidationError(err) + return buildResourceList( + c.Factory, + c.namespace(), + determineFieldValidationDirective(validate), + reader, + transformRequests) } -// Update takes the current list of objects and target list of objects and -// creates resources that don't already exist, updates resources that have been -// modified in the target configuration, and deletes resources from the current -// configuration that are not present in the target configuration. If an error -// occurs, a Result will still be returned with the error, containing all -// resource updates, creations, and deletions that were attempted. These can be -// used for cleanup or other logging purposes. -func (c *Client) Update(original, target ResourceList, force bool, opts UpdateOptions) (*Result, error) { - updateErrors := []string{} +func (c *Client) update(originals, targets ResourceList, createApplyFunc CreateApplyFunc, updateApplyFunc UpdateApplyFunc) (*Result, error) { + updateErrors := []error{} res := &Result{} - c.Log("checking %d resources for changes", len(target)) - err := target.Visit(func(info *resource.Info, err error) error { + c.Logger().Debug("checking resources for changes", "resources", len(targets)) + err := targets.Visit(func(target *resource.Info, err error) error { if err != nil { return err } - helper := resource.NewHelper(info.Client, info.Mapping).WithFieldManager(getManagedFieldsManager()) - if _, err := helper.Get(info.Namespace, info.Name); err != nil { + helper := resource.NewHelper(target.Client, target.Mapping).WithFieldManager(getManagedFieldsManager()) + if _, err := helper.Get(target.Namespace, target.Name); err != nil { if !apierrors.IsNotFound(err) { - return errors.Wrap(err, "could not get information about the resource") + return fmt.Errorf("could not get information about the resource: %w", err) } - if c.Extender != nil { - if err := c.Extender.BeforeCreateResource(info); err != nil { - return err - } - } + // Append the created resource to the results, even if something fails + res.Created = append(res.Created, target) + // Since the resource does not exist, create it. - if _, err := createResource(info); err != nil { - return errors.Wrap(err, "failed to create resource") + if err := createApplyFunc(target); err != nil { + return fmt.Errorf("failed to create resource: %w", err) } - res.Created = append(res.Created, info) - - kind := info.Mapping.GroupVersionKind.Kind - c.Log("Created a new %s called %q in %s\n", kind, info.Name, info.Namespace) + kind := target.Mapping.GroupVersionKind.Kind + c.Logger().Debug( + "created a new resource", + slog.String("namespace", target.Namespace), + slog.String("name", target.Name), + slog.String("kind", kind), + ) return nil } - originalInfo := original.Get(info) - if originalInfo == nil { - kind := info.Mapping.GroupVersionKind.Kind - return errors.Errorf("no %s with the name %q found", kind, info.Name) + original := originals.Get(target) + if original == nil { + kind := target.Mapping.GroupVersionKind.Kind + return fmt.Errorf("original object %s with the name %q not found", kind, target.Name) } - if c.Extender != nil { - if err := c.Extender.BeforeUpdateResource(info); err != nil { - return err - } + if err := updateApplyFunc(original, target); err != nil { + updateErrors = append(updateErrors, err) } - if err := updateResource(c, info, originalInfo.Object, force); err != nil { - c.Log("error updating the resource %q:\n\t %v", info.Name, err) - updateErrors = append(updateErrors, err.Error()) - } else { - res.Updated = append(res.Updated, info) - } + // Because we check for errors later, append the info regardless + res.Updated = append(res.Updated, target) return nil }) @@ -461,98 +617,282 @@ func (c *Client) Update(original, target ResourceList, force bool, opts UpdateOp case err != nil: return res, err case len(updateErrors) != 0: - return res, errors.Errorf(strings.Join(updateErrors, " && ")) + return res, joinErrors(updateErrors, " && ") } - for _, info := range original.Difference(target) { - c.Log("Deleting %s %q in namespace %s...", info.Mapping.GroupVersionKind.Kind, info.Name, info.Namespace) + for _, info := range originals.Difference(targets) { + c.Logger().Debug("deleting resource", "namespace", info.Namespace, "name", info.Name, "kind", info.Mapping.GroupVersionKind.Kind) if err := info.Get(); err != nil { - c.Log("Unable to get obj %q, err: %s", info.Name, err) + c.Logger().Debug( + "unable to get object", + slog.String("namespace", info.Namespace), + slog.String("name", info.Name), + slog.String("kind", info.Mapping.GroupVersionKind.Kind), + slog.Any("error", err), + ) continue } - - if err := releaseutil.CheckOwnership(info.Object, opts.ReleaseName, opts.ReleaseNamespace); err != nil { - c.Log("Skipping delete of %q due to unmatched ownership annotations: %s", info.Name, err) - continue - } - annotations, err := metadataAccessor.Annotations(info.Object) if err != nil { - c.Log("Unable to get annotations on %q, err: %s", info.Name, err) + c.Logger().Debug( + "unable to get annotations", + slog.String("namespace", info.Namespace), + slog.String("name", info.Name), + slog.String("kind", info.Mapping.GroupVersionKind.Kind), + slog.Any("error", err), + ) } if annotations != nil && annotations[ResourcePolicyAnno] == KeepPolicy { - c.Log("Skipping delete of %q due to annotation [%s=%s]", info.Name, ResourcePolicyAnno, KeepPolicy) + c.Logger().Debug("skipping delete due to annotation", "namespace", info.Namespace, "name", info.Name, "kind", info.Mapping.GroupVersionKind.Kind, "annotation", ResourcePolicyAnno, "value", KeepPolicy) continue } - - if c.Extender != nil { - if err := c.Extender.BeforeDeleteResource(info); err != nil { - return res, err - } - } - if err := deleteResource(info, metav1.DeletePropagationBackground); err != nil { - c.Log("Failed to delete %q, err: %s", info.ObjectName(), err) + c.Logger().Debug( + "failed to delete resource", + slog.String("namespace", info.Namespace), + slog.String("name", info.Name), + slog.String("kind", info.Mapping.GroupVersionKind.Kind), + slog.Any("error", err), + ) + if !apierrors.IsNotFound(err) { + updateErrors = append(updateErrors, fmt.Errorf("failed to delete resource %s: %w", info.Name, err)) + } continue } res.Deleted = append(res.Deleted, info) } + + if len(updateErrors) != 0 { + return res, joinErrors(updateErrors, " && ") + } return res, nil } -// Delete deletes Kubernetes resources specified in the resources list with -// background cascade deletion. It will attempt to delete all resources even -// if one or more fail and collect any errors. All successfully deleted items -// will be returned in the `Deleted` ResourceList that is part of the result. -func (c *Client) Delete(resources ResourceList, opts DeleteOptions) (*Result, []error) { - return rdelete(c, resources, metav1.DeletePropagationBackground, opts) +type clientUpdateOptions struct { + threeWayMergeForUnstructured bool + serverSideApply bool + forceReplace bool + forceConflicts bool + dryRun bool + fieldValidationDirective FieldValidationDirective + upgradeClientSideFieldManager bool } -// Delete deletes Kubernetes resources specified in the resources list with -// given deletion propagation policy. It will attempt to delete all resources even -// if one or more fail and collect any errors. All successfully deleted items -// will be returned in the `Deleted` ResourceList that is part of the result. -func (c *Client) DeleteWithPropagationPolicy(resources ResourceList, policy metav1.DeletionPropagation, opts DeleteOptions) (*Result, []error) { - return rdelete(c, resources, policy, opts) +type ClientUpdateOption func(*clientUpdateOptions) error + +// ClientUpdateOptionThreeWayMergeForUnstructured enables performing three-way merge for unstructured objects +// Must not be enabled when ClientUpdateOptionServerSideApply is enabled +func ClientUpdateOptionThreeWayMergeForUnstructured(threeWayMergeForUnstructured bool) ClientUpdateOption { + return func(o *clientUpdateOptions) error { + o.threeWayMergeForUnstructured = threeWayMergeForUnstructured + + return nil + } } -func rdelete(c *Client, resources ResourceList, propagation metav1.DeletionPropagation, opts DeleteOptions) (*Result, []error) { - var errs []error - res := &Result{} - mtx := sync.Mutex{} - err := perform(resources, func(info *resource.Info) error { - if opts.SkipIfInvalidOwnership { - if err := info.Get(); err != nil { - c.Log("Skipping delete of %q due to inability to get the object from cluster: %s", info.Name, err) +// ClientUpdateOptionServerSideApply enables performing object apply server-side (default) +// see: https://kubernetes.io/docs/reference/using-api/server-side-apply/ +// Must not be enabled when ClientUpdateOptionThreeWayMerge is enabled +// +// `forceConflicts` forces conflicts to be resolved (may be enabled when serverSideApply enabled only) +// see: https://kubernetes.io/docs/reference/using-api/server-side-apply/#conflicts +func ClientUpdateOptionServerSideApply(serverSideApply, forceConflicts bool) ClientUpdateOption { + return func(o *clientUpdateOptions) error { + if !serverSideApply && forceConflicts { + return fmt.Errorf("forceConflicts enabled when serverSideApply disabled") + } + + o.serverSideApply = serverSideApply + o.forceConflicts = forceConflicts + + return nil + } +} + +// ClientUpdateOptionForceReplace forces objects to be replaced rather than updated via patch +// Must not be enabled when ClientUpdateOptionForceConflicts is enabled +func ClientUpdateOptionForceReplace(forceReplace bool) ClientUpdateOption { + return func(o *clientUpdateOptions) error { + o.forceReplace = forceReplace + + return nil + } +} + +// ClientUpdateOptionDryRun requests the server to perform non-mutating operations only +func ClientUpdateOptionDryRun(dryRun bool) ClientUpdateOption { + return func(o *clientUpdateOptions) error { + o.dryRun = dryRun + + return nil + } +} + +// ClientUpdateOptionFieldValidationDirective specifies how API operations validate object's schema +// - For client-side apply: this is ignored +// - For server-side apply: the directive is sent to the server to perform the validation +// +// Defaults to `FieldValidationDirectiveStrict` +func ClientUpdateOptionFieldValidationDirective(fieldValidationDirective FieldValidationDirective) ClientUpdateOption { + return func(o *clientUpdateOptions) error { + o.fieldValidationDirective = fieldValidationDirective + + return nil + } +} + +// ClientUpdateOptionUpgradeClientSideFieldManager specifies that resources client-side field manager should be upgraded to server-side apply +// (before applying the object server-side) +// This is required when upgrading a chart from client-side to server-side apply, otherwise the client-side field management remains. Conflicting with server-side applied updates. +// +// Note: +// if this option is specified, but the object is not managed by client-side field manager, it will be a no-op. However, the cost of fetching the objects will be incurred. +// +// see: +// - https://github.com/kubernetes/kubernetes/pull/112905 +// - `UpgradeManagedFields` / https://github.com/kubernetes/kubernetes/blob/f47e9696d7237f1011d23c9b55f6947e60526179/staging/src/k8s.io/client-go/util/csaupgrade/upgrade.go#L81 +func ClientUpdateOptionUpgradeClientSideFieldManager(upgradeClientSideFieldManager bool) ClientUpdateOption { + return func(o *clientUpdateOptions) error { + o.upgradeClientSideFieldManager = upgradeClientSideFieldManager + + return nil + } +} + +// Update takes the current list of objects and target list of objects and +// creates resources that don't already exist, updates resources that have been +// modified in the target configuration, and deletes resources from the current +// configuration that are not present in the target configuration. If an error +// occurs, a Result will still be returned with the error, containing all +// resource updates, creations, and deletions that were attempted. These can be +// used for cleanup or other logging purposes. +// +// The default is to use server-side apply, equivalent to: `ClientUpdateOptionServerSideApply(true)` +func (c *Client) Update(originals, targets ResourceList, options ...ClientUpdateOption) (*Result, error) { + updateOptions := clientUpdateOptions{ + serverSideApply: true, // Default to server-side apply + fieldValidationDirective: FieldValidationDirectiveStrict, + } + + errs := make([]error, 0, len(options)) + for _, o := range options { + errs = append(errs, o(&updateOptions)) + } + if err := errors.Join(errs...); err != nil { + return &Result{}, fmt.Errorf("invalid client update option(s): %w", err) + } + + if updateOptions.threeWayMergeForUnstructured && updateOptions.serverSideApply { + return &Result{}, fmt.Errorf("invalid operation: cannot use three-way merge for unstructured and server-side apply together") + } + + if updateOptions.forceConflicts && updateOptions.forceReplace { + return &Result{}, fmt.Errorf("invalid operation: cannot use force conflicts and force replace together") + } + + if updateOptions.serverSideApply && updateOptions.forceReplace { + return &Result{}, fmt.Errorf("invalid operation: cannot use server-side apply and force replace together") + } + + createApplyFunc := c.makeCreateApplyFunc( + updateOptions.serverSideApply, + updateOptions.forceConflicts, + updateOptions.dryRun, + updateOptions.fieldValidationDirective) + + makeUpdateApplyFunc := func() UpdateApplyFunc { + if updateOptions.forceReplace { + c.Logger().Debug( + "using resource replace update strategy", + slog.String("fieldValidationDirective", string(updateOptions.fieldValidationDirective))) + return func(original, target *resource.Info) error { + if err := replaceResource(target, updateOptions.fieldValidationDirective); err != nil { + c.Logger().With( + slog.String("namespace", target.Namespace), + slog.String("name", target.Name), + slog.String("gvk", target.Mapping.GroupVersionKind.String()), + ).Debug( + "error replacing the resource", slog.Any("error", err), + ) + return err + } + + originalObject := original.Object + kind := target.Mapping.GroupVersionKind.Kind + c.Logger().Debug("replace succeeded", "name", original.Name, "initialKind", originalObject.GetObjectKind().GroupVersionKind().Kind, "kind", kind) + return nil } + } else if updateOptions.serverSideApply { + c.Logger().Debug( + "using server-side apply for resource update", + slog.Bool("forceConflicts", updateOptions.forceConflicts), + slog.Bool("dryRun", updateOptions.dryRun), + slog.String("fieldValidationDirective", string(updateOptions.fieldValidationDirective)), + slog.Bool("upgradeClientSideFieldManager", updateOptions.upgradeClientSideFieldManager)) + return func(original, target *resource.Info) error { + + logger := c.Logger().With( + slog.String("namespace", target.Namespace), + slog.String("name", target.Name), + slog.String("gvk", target.Mapping.GroupVersionKind.String())) + + if updateOptions.upgradeClientSideFieldManager { + patched, err := upgradeClientSideFieldManager(original, updateOptions.dryRun, updateOptions.fieldValidationDirective) + if err != nil { + c.Logger().Debug("Error patching resource to replace CSA field management", slog.Any("error", err)) + return err + } + + if patched { + logger.Debug("Upgraded object client-side field management with server-side apply field management") + } + } + + if err := patchResourceServerSide(target, updateOptions.dryRun, updateOptions.forceConflicts, updateOptions.fieldValidationDirective); err != nil { + logger.Debug("Error patching resource", slog.Any("error", err)) + return err + } + + logger.Debug("Patched resource") - if err := releaseutil.CheckOwnership(info.Object, opts.ReleaseName, opts.ReleaseNamespace); err != nil { - c.Log("Skipping delete of %q due to unmatched ownership annotations: %s", info.Name, err) return nil } } - if c.Extender != nil { - if err := c.Extender.BeforeDeleteResource(info); err != nil { - mtx.Lock() - defer mtx.Unlock() - // Collect the error and continue on - errs = append(errs, err) - return nil - } + c.Logger().Debug("using client-side apply for resource update", slog.Bool("threeWayMergeForUnstructured", updateOptions.threeWayMergeForUnstructured)) + return func(original, target *resource.Info) error { + return patchResourceClientSide(original.Object, target, updateOptions.threeWayMergeForUnstructured) } + } + + return c.update(originals, targets, createApplyFunc, makeUpdateApplyFunc()) +} - c.Log("Starting delete for %q %s", info.Name, info.Mapping.GroupVersionKind.Kind) - err := deleteResource(info, propagation) +// Delete deletes Kubernetes resources specified in the resources list with +// given deletion propagation policy. It will attempt to delete all resources even +// if one or more fail and collect any errors. All successfully deleted items +// will be returned in the `Deleted` ResourceList that is part of the result. +func (c *Client) Delete(resources ResourceList, policy metav1.DeletionPropagation) (*Result, []error) { + var errs []error + res := &Result{} + mtx := sync.Mutex{} + err := perform(resources, func(target *resource.Info) error { + c.Logger().Debug("starting delete resource", "namespace", target.Namespace, "name", target.Name, "kind", target.Mapping.GroupVersionKind.Kind) + err := deleteResource(target, policy) if err == nil || apierrors.IsNotFound(err) { if err != nil { - c.Log("Ignoring delete failure for %q %s: %v", info.Name, info.Mapping.GroupVersionKind, err) + c.Logger().Debug( + "ignoring delete failure", + slog.String("namespace", target.Namespace), + slog.String("name", target.Name), + slog.String("kind", target.Mapping.GroupVersionKind.Kind), + slog.Any("error", err)) } mtx.Lock() defer mtx.Unlock() - res.Deleted = append(res.Deleted, info) + res.Deleted = append(res.Deleted, target) return nil } mtx.Lock() @@ -568,51 +908,40 @@ func rdelete(c *Client, resources ResourceList, propagation metav1.DeletionPropa errs = append(errs, err) } if errs != nil { - return res, errs + return nil, errs } - - if opts.Wait { - var specs []*ResourcesWaiterDeleteResourceSpec - for _, resource := range res.Deleted { - specs = append(specs, &ResourcesWaiterDeleteResourceSpec{ - ResourceName: resource.Name, - Namespace: resource.Namespace, - GroupVersionResource: resource.Mapping.Resource, - }) - } - - if err := c.ResourcesWaiter.WaitUntilDeleted(context.Background(), specs, opts.WaitTimeout); err != nil { - return res, []error{fmt.Errorf("waiting until resources are deleted failed: %s", err)} - } - } - return res, nil } -func (c *Client) watchTimeout(t time.Duration) func(*resource.Info) error { - return func(info *resource.Info) error { - return c.watchUntilReady(t, info) +// https://github.com/kubernetes/kubectl/blob/197123726db24c61aa0f78d1f0ba6e91a2ec2f35/pkg/cmd/apply/apply.go#L439 +func isIncompatibleServerError(err error) bool { + // 415: Unsupported media type means we're talking to a server which doesn't + // support server-side apply. + if _, ok := err.(*apierrors.StatusError); !ok { + // Non-StatusError means the error isn't because the server is incompatible. + return false } + return err.(*apierrors.StatusError).Status().Code == http.StatusUnsupportedMediaType } -// WatchUntilReady watches the resources given and waits until it is ready. -// -// This method is mainly for hook implementations. It watches for a resource to -// hit a particular milestone. The milestone depends on the Kind. -// -// For most kinds, it checks to see if the resource is marked as Added or Modified -// by the Kubernetes event stream. For some kinds, it does more: -// -// - Jobs: A job is marked "Ready" when it has successfully completed. This is -// ascertained by watching the Status fields in a job's output. -// - Pods: A pod is marked "Ready" when it has successfully completed. This is -// ascertained by watching the status.phase field in a pod's output. -// -// Handling for other kinds will be added as necessary. -func (c *Client) WatchUntilReady(resources ResourceList, timeout time.Duration) error { - // For jobs, there's also the option to do poll c.Jobs(namespace).Get(): - // https://github.com/adamreese/kubernetes/blob/master/test/e2e/job.go#L291-L300 - return perform(resources, c.watchTimeout(timeout)) +// getManagedFieldsManager returns the manager string. If one was set it will be returned. +// Otherwise, one is calculated based on the name of the binary. +func getManagedFieldsManager() string { + + // When a manager is explicitly set use it + if ManagedFieldsManager != "" { + return ManagedFieldsManager + } + + // When no manager is set and no calling application can be found it is unknown + if len(os.Args[0]) == 0 { + return "unknown" + } + + // When there is an application that can be determined and no set manager + // use the base name. This is one of the ways Kubernetes libs handle figuring + // names out. + return filepath.Base(os.Args[0]) } func perform(infos ResourceList, fn func(*resource.Info) error) error { @@ -628,105 +957,89 @@ func perform(infos ResourceList, fn func(*resource.Info) error) error { for range infos { err := <-errs if err != nil { - result = multierror.Append(result, err) + result = errors.Join(result, err) } } return result } -// getManagedFieldsManager returns the manager string. If one was set it will be returned. -// Otherwise, one is calculated based on the name of the binary. -func getManagedFieldsManager() string { - - // When a manager is explicitly set use it - if ManagedFieldsManager != "" { - return ManagedFieldsManager - } - - // When no manager is set and no calling application can be found it is unknown - if len(os.Args[0]) == 0 { - return "unknown" - } - - // When there is an application that can be determined and no set manager - // use the base name. This is one of the ways Kubernetes libs handle figuring - // names out. - return filepath.Base(os.Args[0]) -} - func batchPerform(infos ResourceList, fn func(*resource.Info) error, errs chan<- error) { var kind string var wg sync.WaitGroup + defer wg.Wait() + for _, info := range infos { currentKind := info.Object.GetObjectKind().GroupVersionKind().Kind if kind != currentKind { wg.Wait() kind = currentKind } + wg.Add(1) - go func(i *resource.Info) { - errs <- fn(i) + go func(info *resource.Info) { + errs <- fn(info) wg.Done() }(info) } } -func createResource(info *resource.Info) (performResourceStatus, error) { - obj, err := resource.NewHelper(info.Client, info.Mapping).WithFieldManager(getManagedFieldsManager()).Create(info.Namespace, true, info.Object) - if err != nil { - return resourceStatusUnknown, err - } - - return resourceStatusCreated, info.Refresh(obj, true) -} +var createMutex sync.Mutex -func createResourceSkipIfExists(info *resource.Info) (performResourceStatus, error) { - _, err := resource.NewHelper(info.Client, info.Mapping).Get(info.Namespace, info.Name) - if apierrors.IsNotFound(err) { - return createResource(info) - } else if err != nil { - return resourceStatusUnknown, err - } +func createResource(info *resource.Info) error { + return retry.RetryOnConflict( + retry.DefaultRetry, + func() error { + createMutex.Lock() + defer createMutex.Unlock() + obj, err := resource.NewHelper(info.Client, info.Mapping).WithFieldManager(getManagedFieldsManager()).Create(info.Namespace, true, info.Object) + if err != nil { + return err + } - return resourceStatusUnknown, nil + return info.Refresh(obj, true) + }) } func deleteResource(info *resource.Info, policy metav1.DeletionPropagation) error { - opts := &metav1.DeleteOptions{PropagationPolicy: &policy} - _, err := resource.NewHelper(info.Client, info.Mapping).WithFieldManager(getManagedFieldsManager()).DeleteWithOptions(info.Namespace, info.Name, opts) - return err + return retry.RetryOnConflict( + retry.DefaultRetry, + func() error { + opts := &metav1.DeleteOptions{PropagationPolicy: &policy} + _, err := resource.NewHelper(info.Client, info.Mapping).WithFieldManager(getManagedFieldsManager()).DeleteWithOptions(info.Namespace, info.Name, opts) + return err + }) } -func createPatch(target *resource.Info, current runtime.Object) ([]byte, types.PatchType, error) { - oldData, err := json.Marshal(current) +func createPatch(original runtime.Object, target *resource.Info, threeWayMergeForUnstructured bool) ([]byte, types.PatchType, error) { + oldData, err := json.Marshal(original) if err != nil { - return nil, types.StrategicMergePatchType, errors.Wrap(err, "serializing current configuration") + return nil, types.StrategicMergePatchType, fmt.Errorf("serializing current configuration: %w", err) } newData, err := json.Marshal(target.Object) if err != nil { - return nil, types.StrategicMergePatchType, errors.Wrap(err, "serializing target configuration") + return nil, types.StrategicMergePatchType, fmt.Errorf("serializing target configuration: %w", err) } // Fetch the current object for the three way merge helper := resource.NewHelper(target.Client, target.Mapping).WithFieldManager(getManagedFieldsManager()) currentObj, err := helper.Get(target.Namespace, target.Name) if err != nil && !apierrors.IsNotFound(err) { - return nil, types.StrategicMergePatchType, errors.Wrapf(err, "unable to get data for current object %s/%s", target.Namespace, target.Name) + return nil, types.StrategicMergePatchType, fmt.Errorf("unable to get data for current object %s/%s: %w", target.Namespace, target.Name, err) } // Even if currentObj is nil (because it was not found), it will marshal just fine currentData, err := json.Marshal(currentObj) if err != nil { - return nil, types.StrategicMergePatchType, errors.Wrap(err, "serializing live configuration") + return nil, types.StrategicMergePatchType, fmt.Errorf("serializing live configuration: %w", err) } // Get a versioned object versionedObject := AsVersioned(target) - // Unstructured objects, such as CRDs, may not have an not registered error + // Unstructured objects, such as CRDs, may not have a not registered error // returned from ConvertToVersion. Anything that's unstructured should - // use the jsonpatch.CreateMergePatch. Strategic Merge Patch is not supported + // use generic JSON merge patch. Strategic Merge Patch is not supported // on objects like CRDs. _, isUnstructured := versionedObject.(runtime.Unstructured) @@ -734,6 +1047,19 @@ func createPatch(target *resource.Info, current runtime.Object) ([]byte, types.P _, isCRD := versionedObject.(*apiextv1beta1.CustomResourceDefinition) if isUnstructured || isCRD { + if threeWayMergeForUnstructured { + // from https://github.com/kubernetes/kubectl/blob/b83b2ec7d15f286720bccf7872b5c72372cb8e80/pkg/cmd/apply/patcher.go#L129 + preconditions := []mergepatch.PreconditionFunc{ + mergepatch.RequireKeyUnchanged("apiVersion"), + mergepatch.RequireKeyUnchanged("kind"), + mergepatch.RequireMetadataKeyUnchanged("name"), + } + patch, err := jsonmergepatch.CreateThreeWayJSONMergePatch(oldData, newData, currentData, preconditions...) + if err != nil && mergepatch.IsPreconditionFailed(err) { + err = fmt.Errorf("%w: at least one field was changed: apiVersion, kind or name", err) + } + return patch, types.MergePatchType, err + } // fall back to generic JSON merge patch patch, err := jsonpatch.CreateMergePatch(oldData, newData) return patch, types.MergePatchType, err @@ -741,156 +1067,196 @@ func createPatch(target *resource.Info, current runtime.Object) ([]byte, types.P patchMeta, err := strategicpatch.NewPatchMetaFromStruct(versionedObject) if err != nil { - return nil, types.StrategicMergePatchType, errors.Wrap(err, "unable to create patch metadata from object") + return nil, types.StrategicMergePatchType, fmt.Errorf("unable to create patch metadata from object: %w", err) } patch, err := strategicpatch.CreateThreeWayMergePatch(oldData, newData, currentData, patchMeta, true) return patch, types.StrategicMergePatchType, err } -func updateResource(c *Client, target *resource.Info, currentObj runtime.Object, force bool) error { - var ( - obj runtime.Object - helper = resource.NewHelper(target.Client, target.Mapping).WithFieldManager(getManagedFieldsManager()) - kind = target.Mapping.GroupVersionKind.Kind - ) +func replaceResource(target *resource.Info, fieldValidationDirective FieldValidationDirective) error { - // if --force is applied, attempt to replace the existing resource with the new object. - if force { - var err error - obj, err = helper.Replace(target.Namespace, target.Name, true, target.Object) - if err != nil { - return errors.Wrap(err, "failed to replace object") - } - c.Log("Replaced %q with kind %s for kind %s", target.Name, currentObj.GetObjectKind().GroupVersionKind().Kind, kind) - } else { - patch, patchType, err := createPatch(target, currentObj) - if err != nil { - return errors.Wrap(err, "failed to create patch") - } + helper := resource.NewHelper(target.Client, target.Mapping). + WithFieldValidation(string(fieldValidationDirective)). + WithFieldManager(getManagedFieldsManager()) - if patch == nil || string(patch) == "{}" { - c.Log("Looks like there are no changes for %s %q", kind, target.Name) - // This needs to happen to make sure that Helm has the latest info from the API - // Otherwise there will be no labels and other functions that use labels will panic - if err := target.Get(); err != nil { - return errors.Wrap(err, "failed to refresh resource information") - } - return nil - } - // send patch to server - c.Log("Patch %s %q in namespace %s", kind, target.Name, target.Namespace) - obj, err = helper.Patch(target.Namespace, target.Name, patchType, patch, nil) - if err != nil { - return errors.Wrapf(err, "cannot patch %q with kind %s", target.Name, kind) - } + obj, err := helper.Replace(target.Namespace, target.Name, true, target.Object) + if err != nil { + return fmt.Errorf("failed to replace object: %w", err) + } + + if err := target.Refresh(obj, true); err != nil { + return fmt.Errorf("failed to refresh object after replace: %w", err) } - target.Refresh(obj, true) return nil + } -func (c *Client) watchUntilReady(timeout time.Duration, info *resource.Info) error { - kind := info.Mapping.GroupVersionKind.Kind - switch kind { - case "Job", "Pod": - default: - return nil +func patchResourceClientSide(original runtime.Object, target *resource.Info, threeWayMergeForUnstructured bool) error { + + patch, patchType, err := createPatch(original, target, threeWayMergeForUnstructured) + if err != nil { + return fmt.Errorf("failed to create patch: %w", err) } - c.Log("Watching for changes to %s %s with timeout of %v", kind, info.Name, timeout) + kind := target.Mapping.GroupVersionKind.Kind + if patch == nil || string(patch) == "{}" { + slog.Debug("no changes detected", "kind", kind, "name", target.Name) + // This needs to happen to make sure that Helm has the latest info from the API + // Otherwise there will be no labels and other functions that use labels will panic + if err := target.Get(); err != nil { + return fmt.Errorf("failed to refresh resource information: %w", err) + } + return nil + } - // Use a selector on the name of the resource. This should be unique for the - // given version and kind - selector, err := fields.ParseSelector(fmt.Sprintf("metadata.name=%s", info.Name)) + // send patch to server + slog.Debug("patching resource", "kind", kind, "name", target.Name, "namespace", target.Namespace) + helper := resource.NewHelper(target.Client, target.Mapping).WithFieldManager(getManagedFieldsManager()) + obj, err := helper.Patch(target.Namespace, target.Name, patchType, patch, nil) if err != nil { - return err + return fmt.Errorf("cannot patch %q with kind %s: %w", target.Name, kind, err) } - lw := cachetools.NewListWatchFromClient(info.Client, info.Mapping.Resource.Resource, info.Namespace, selector) - - // What we watch for depends on the Kind. - // - For a Job, we watch for completion. - // - For all else, we watch until Ready. - // In the future, we might want to add some special logic for types - // like Ingress, Volume, etc. - - ctx, cancel := watchtools.ContextWithOptionalTimeout(context.Background(), timeout) - defer cancel() - _, err = watchtools.UntilWithSync(ctx, lw, &unstructured.Unstructured{}, nil, func(e watch.Event) (bool, error) { - // Make sure the incoming object is versioned as we use unstructured - // objects when we build manifests - obj := convertWithMapper(e.Object, info.Mapping) - switch e.Type { - case watch.Added, watch.Modified: - // For things like a secret or a config map, this is the best indicator - // we get. We care mostly about jobs, where what we want to see is - // the status go into a good state. For other types, like ReplicaSet - // we don't really do anything to support these as hooks. - c.Log("Add/Modify event for %s: %v", info.Name, e.Type) - switch kind { - case "Job": - return c.waitForJob(obj, info.Name) - case "Pod": - return c.waitForPodSuccess(obj, info.Name) + + target.Refresh(obj, true) + + return nil +} + +// upgradeClientSideFieldManager is simply a wrapper around csaupgrade.UpgradeManagedFields +// that upgrade CSA managed fields to SSA apply +// see: https://github.com/kubernetes/kubernetes/pull/112905 +func upgradeClientSideFieldManager(info *resource.Info, dryRun bool, fieldValidationDirective FieldValidationDirective) (bool, error) { + + fieldManagerName := getManagedFieldsManager() + + patched := false + err := retry.RetryOnConflict( + retry.DefaultRetry, + func() error { + + if err := info.Get(); err != nil { + return fmt.Errorf("failed to get object %s/%s %s: %w", info.Namespace, info.Name, info.Mapping.GroupVersionKind.String(), err) } - return true, nil - case watch.Deleted: - c.Log("Deleted event for %s", info.Name) - return true, nil - case watch.Error: - // Handle error and return with an error. - c.Log("Error event for %s", info.Name) - return true, errors.Errorf("failed to deploy %s", info.Name) - default: - return false, nil - } - }) - return err + + helper := resource.NewHelper( + info.Client, + info.Mapping). + DryRun(dryRun). + WithFieldManager(fieldManagerName). + WithFieldValidation(string(fieldValidationDirective)) + + patchData, err := csaupgrade.UpgradeManagedFieldsPatch( + info.Object, + sets.New(fieldManagerName), + fieldManagerName) + if err != nil { + return fmt.Errorf("failed to upgrade managed fields for object %s/%s %s: %w", info.Namespace, info.Name, info.Mapping.GroupVersionKind.String(), err) + } + + if len(patchData) == 0 { + return nil + } + + obj, err := helper.Patch( + info.Namespace, + info.Name, + types.JSONPatchType, + patchData, + nil) + + if err == nil { + patched = true + return info.Refresh(obj, true) + } + + if !apierrors.IsConflict(err) { + return fmt.Errorf("failed to patch object to upgrade CSA field manager %s/%s %s: %w", info.Namespace, info.Name, info.Mapping.GroupVersionKind.String(), err) + } + + return err + }) + + return patched, err } -// waitForJob is a helper that waits for a job to complete. -// -// This operates on an event returned from a watcher. -func (c *Client) waitForJob(obj runtime.Object, name string) (bool, error) { - o, ok := obj.(*batch.Job) - if !ok { - return true, errors.Errorf("expected %s to be a *batch.Job, got %T", name, obj) +// Patch reource using server-side apply +func patchResourceServerSide(target *resource.Info, dryRun bool, forceConflicts bool, fieldValidationDirective FieldValidationDirective) error { + helper := resource.NewHelper( + target.Client, + target.Mapping). + DryRun(dryRun). + WithFieldManager(getManagedFieldsManager()). + WithFieldValidation(string(fieldValidationDirective)) + + // Send the full object to be applied on the server side. + data, err := runtime.Encode(unstructured.UnstructuredJSONScheme, target.Object) + if err != nil { + return fmt.Errorf("failed to encode object %s/%s %s: %w", target.Namespace, target.Name, target.Mapping.GroupVersionKind.String(), err) + } + options := metav1.PatchOptions{ + Force: &forceConflicts, } + obj, err := helper.Patch( + target.Namespace, + target.Name, + types.ApplyPatchType, + data, + &options, + ) + if err != nil { + if isIncompatibleServerError(err) { + return fmt.Errorf("server-side apply not available on the server: %v", err) + } - for _, c := range o.Status.Conditions { - if c.Type == batch.JobComplete && c.Status == "True" { - return true, nil - } else if c.Type == batch.JobFailed && c.Status == "True" { - return true, errors.Errorf("job %s failed: %s", name, c.Reason) + if apierrors.IsConflict(err) { + return fmt.Errorf("conflict occurred while applying object %s/%s %s: %w", target.Namespace, target.Name, target.Mapping.GroupVersionKind.String(), err) } + + return err } - c.Log("%s: Jobs active: %d, jobs failed: %d, jobs succeeded: %d", name, o.Status.Active, o.Status.Failed, o.Status.Succeeded) - return false, nil + return target.Refresh(obj, true) } -// waitForPodSuccess is a helper that waits for a pod to complete. -// -// This operates on an event returned from a watcher. -func (c *Client) waitForPodSuccess(obj runtime.Object, name string) (bool, error) { - o, ok := obj.(*v1.Pod) - if !ok { - return true, errors.Errorf("expected %s to be a *v1.Pod, got %T", name, obj) +// GetPodList uses the kubernetes interface to get the list of pods filtered by listOptions +func (c *Client) GetPodList(namespace string, listOptions metav1.ListOptions) (*v1.PodList, error) { + podList, err := c.kubeClient.CoreV1().Pods(namespace).List(context.Background(), listOptions) + if err != nil { + return nil, fmt.Errorf("failed to get pod list with options: %+v with error: %v", listOptions, err) } + return podList, nil +} - switch o.Status.Phase { - case v1.PodSucceeded: - c.Log("Pod %s succeeded", o.Name) - return true, nil - case v1.PodFailed: - return true, errors.Errorf("pod %s failed", o.Name) - case v1.PodPending: - c.Log("Pod %s pending", o.Name) - case v1.PodRunning: - c.Log("Pod %s running", o.Name) +// OutputContainerLogsForPodList is a helper that outputs logs for a list of pods +func (c *Client) OutputContainerLogsForPodList(podList *v1.PodList, namespace string, writerFunc func(namespace, pod, container string) io.Writer) error { + for _, pod := range podList.Items { + for _, container := range pod.Spec.Containers { + options := &v1.PodLogOptions{ + Container: container.Name, + } + request := c.kubeClient.CoreV1().Pods(namespace).GetLogs(pod.Name, options) + err2 := copyRequestStreamToWriter(request, pod.Name, container.Name, writerFunc(namespace, pod.Name, container.Name)) + if err2 != nil { + return err2 + } + } } + return nil +} - return false, nil +func copyRequestStreamToWriter(request *rest.Request, podName, containerName string, writer io.Writer) error { + readCloser, err := request.Stream(context.Background()) + if err != nil { + return fmt.Errorf("failed to stream pod logs for pod: %s, container: %s", podName, containerName) + } + defer readCloser.Close() + _, err = io.Copy(writer, readCloser) + if err != nil { + return fmt.Errorf("failed to copy IO from logs for pod: %s, container: %s", podName, containerName) + } + return nil } // scrubValidationError removes kubectl info from the message. @@ -906,120 +1272,26 @@ func scrubValidationError(err error) error { return err } -// WaitAndGetCompletedPodPhase waits up to a timeout until a pod enters a completed phase -// and returns said phase (PodSucceeded or PodFailed qualify). -func (c *Client) WaitAndGetCompletedPodPhase(name string, timeout time.Duration) (v1.PodPhase, error) { - client, err := c.getKubeClient() - if err != nil { - return v1.PodUnknown, err - } - to := int64(timeout) - watcher, err := client.CoreV1().Pods(c.namespace()).Watch(context.Background(), metav1.ListOptions{ - FieldSelector: fmt.Sprintf("metadata.name=%s", name), - TimeoutSeconds: &to, - }) - if err != nil { - return v1.PodUnknown, err - } - - for event := range watcher.ResultChan() { - p, ok := event.Object.(*v1.Pod) - if !ok { - return v1.PodUnknown, fmt.Errorf("%s not a pod", name) - } - switch p.Status.Phase { - case v1.PodFailed: - return v1.PodFailed, nil - case v1.PodSucceeded: - return v1.PodSucceeded, nil - } - } - - return v1.PodUnknown, err +type joinedErrors struct { + errs []error + sep string } -type performResourceStatus int - -const ( - resourceStatusUnknown performResourceStatus = iota - resourceStatusCreated - resourceStatusUpdated - resourceStatusDeleted -) - -func performWithResult(infos ResourceList, fn func(*resource.Info) (performResourceStatus, error)) (*Result, error) { - if len(infos) == 0 { - return &Result{}, ErrNoObjectsVisited - } - - infosByGK := groupInfosByGK(infos) - - type performResult struct { - resource *resource.Info - status performResourceStatus - error error - } - - result := &Result{} - - for _, resList := range infosByGK { - performResultsCh := make(chan performResult, len(resList)) - for _, res := range resList { - resC := res - go func() { - status, err := fn(resC) - performResultsCh <- performResult{ - resource: resC, - status: status, - error: err, - } - }() - } - - var errs []error - for range resList { - perfRes := <-performResultsCh - - if perfRes.error != nil { - errs = append(errs, perfRes.error) - continue - } - - switch perfRes.status { - case resourceStatusUnknown: - case resourceStatusCreated: - result.Created = append(result.Created, perfRes.resource) - case resourceStatusUpdated: - result.Updated = append(result.Updated, perfRes.resource) - case resourceStatusDeleted: - result.Deleted = append(result.Deleted, perfRes.resource) - default: - panic("unexpected status") - } - } - - if len(errs) > 0 { - return result, errs[0] - } +func joinErrors(errs []error, sep string) error { + return &joinedErrors{ + errs: errs, + sep: sep, } - - return result, nil } -func groupInfosByGK(infos ResourceList) []ResourceList { - var infosByGK []ResourceList - var lastGK schema.GroupKind - for _, info := range infos { - currentGK := info.Object.GetObjectKind().GroupVersionKind().GroupKind() - if lastGK == currentGK { - infosByGK[len(infosByGK)-1].Append(info) - } else { - rl := ResourceList{} - rl.Append(info) - infosByGK = append(infosByGK, rl) - lastGK = currentGK - } +func (e *joinedErrors) Error() string { + errs := make([]string, 0, len(e.errs)) + for _, err := range e.errs { + errs = append(errs, err.Error()) } + return strings.Join(errs, e.sep) +} - return infosByGK +func (e *joinedErrors) Unwrap() []error { + return e.errs } diff --git a/pkg/helm/pkg/kube/client_extender.go b/pkg/helm/pkg/kube/client_extender.go deleted file mode 100644 index 1ee6edab..00000000 --- a/pkg/helm/pkg/kube/client_extender.go +++ /dev/null @@ -1,9 +0,0 @@ -package kube - -import "k8s.io/cli-runtime/pkg/resource" - -type ClientExtender interface { - BeforeCreateResource(info *resource.Info) error - BeforeUpdateResource(info *resource.Info) error - BeforeDeleteResource(info *resource.Info) error -} diff --git a/pkg/helm/pkg/kube/client_test.go b/pkg/helm/pkg/kube/client_test.go index a1f224d1..c44b0d7d 100644 --- a/pkg/helm/pkg/kube/client_test.go +++ b/pkg/helm/pkg/kube/client_test.go @@ -18,22 +18,49 @@ package kube import ( "bytes" + "context" + "errors" + "fmt" "io" "net/http" "strings" + "sync" "testing" + "time" + "github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine" + "github.com/fluxcd/cli-utils/pkg/kstatus/polling/event" + "github.com/fluxcd/cli-utils/pkg/kstatus/status" + "github.com/fluxcd/cli-utils/pkg/object" + "github.com/fluxcd/cli-utils/pkg/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + batchv1 "k8s.io/api/batch/v1" v1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + jsonserializer "k8s.io/apimachinery/pkg/runtime/serializer/json" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/cli-runtime/pkg/resource" + dynamicfake "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/kubernetes" + k8sfake "k8s.io/client-go/kubernetes/fake" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest/fake" cmdtesting "k8s.io/kubectl/pkg/cmd/testing" ) -var unstructuredSerializer = resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer -var codec = scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) +var ( + unstructuredSerializer = resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer + codec = scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...) +) func objBody(obj runtime.Object) io.ReadCloser { return io.NopCloser(bytes.NewReader([]byte(runtime.EncodeOrDie(codec, obj)))) @@ -90,108 +117,223 @@ func newResponse(code int, obj runtime.Object) (*http.Response, error) { return &http.Response{StatusCode: code, Header: header, Body: body}, nil } +func newResponseJSON(code int, json []byte) (*http.Response, error) { + header := http.Header{} + header.Set("Content-Type", runtime.ContentTypeJSON) + body := io.NopCloser(bytes.NewReader(json)) + return &http.Response{StatusCode: code, Header: header, Body: body}, nil +} + func newTestClient(t *testing.T) *Client { + t.Helper() testFactory := cmdtesting.NewTestFactory() t.Cleanup(testFactory.Cleanup) return &Client{ - Factory: testFactory.WithNamespace("default"), - Log: nopLogger, + Factory: testFactory.WithNamespace(v1.NamespaceDefault), } } -func TestUpdate(t *testing.T) { - listA := newPodList("starfish", "otter", "squid") - listB := newPodList("starfish", "otter", "dolphin") - listC := newPodList("starfish", "otter", "dolphin") - listB.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}} - listC.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}} +type RequestResponseAction struct { + Request http.Request + Response http.Response + Error error +} - var actions []string +type RoundTripperTestFunc func(previous []RequestResponseAction, req *http.Request) (*http.Response, error) - c := newTestClient(t) - c.Factory.(*cmdtesting.TestFactory).UnstructuredClient = &fake.RESTClient{ - NegotiatedSerializer: unstructuredSerializer, - Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { - p, m := req.URL.Path, req.Method - actions = append(actions, p+":"+m) - t.Logf("got request %s %s", p, m) - switch { - case p == "/namespaces/default/pods/starfish" && m == "GET": - return newResponse(200, &listA.Items[0]) - case p == "/namespaces/default/pods/otter" && m == "GET": - return newResponse(200, &listA.Items[1]) - case p == "/namespaces/default/pods/otter" && m == "PATCH": - data, err := io.ReadAll(req.Body) - if err != nil { - t.Fatalf("could not dump request: %s", err) - } - req.Body.Close() - expected := `{}` - if string(data) != expected { - t.Errorf("expected patch\n%s\ngot\n%s", expected, string(data)) - } - return newResponse(200, &listB.Items[0]) - case p == "/namespaces/default/pods/dolphin" && m == "GET": - return newResponse(404, notFoundBody()) - case p == "/namespaces/default/pods/starfish" && m == "PATCH": - data, err := io.ReadAll(req.Body) - if err != nil { - t.Fatalf("could not dump request: %s", err) - } - req.Body.Close() - expected := `{"spec":{"$setElementOrder/containers":[{"name":"app:v4"}],"containers":[{"$setElementOrder/ports":[{"containerPort":443}],"name":"app:v4","ports":[{"containerPort":443,"name":"https"},{"$patch":"delete","containerPort":80}]}]}}` - if string(data) != expected { - t.Errorf("expected patch\n%s\ngot\n%s", expected, string(data)) - } - return newResponse(200, &listB.Items[0]) - case p == "/namespaces/default/pods" && m == "POST": - return newResponse(200, &listB.Items[1]) - case p == "/namespaces/default/pods/squid" && m == "DELETE": - return newResponse(200, &listB.Items[1]) - case p == "/namespaces/default/pods/squid" && m == "GET": - return newResponse(200, &listB.Items[2]) - default: - t.Fatalf("unexpected request: %s %s", req.Method, req.URL.Path) - return nil, nil - } - }), +func NewRequestResponseLogClient(t *testing.T, cb RoundTripperTestFunc) RequestResponseLogClient { + t.Helper() + return RequestResponseLogClient{ + t: t, + cb: cb, } - first, err := c.Build(objBody(&listA), false) - if err != nil { - t.Fatal(err) +} + +// RequestResponseLogClient is a test client that logs requests and responses +// Satisfying http.RoundTripper interface, it can be used to mock HTTP requests in tests. +// Forwarding requests to a callback function (cb) that can be used to simulate server responses. +type RequestResponseLogClient struct { + t *testing.T + cb RoundTripperTestFunc + actionsLock sync.Mutex + Actions []RequestResponseAction +} + +func (r *RequestResponseLogClient) Do(req *http.Request) (*http.Response, error) { + t := r.t + t.Helper() + + readBodyBytes := func(body io.ReadCloser) []byte { + if body == nil { + return []byte{} + } + + defer body.Close() + bodyBytes, err := io.ReadAll(body) + require.NoError(t, err) + + return bodyBytes } - second, err := c.Build(objBody(&listB), false) - if err != nil { - t.Fatal(err) + + reqBytes := readBodyBytes(req.Body) + + t.Logf("Request: %s %s %s", req.Method, req.URL.String(), reqBytes) + if req.Body != nil { + req.Body = io.NopCloser(bytes.NewReader(reqBytes)) } - result, err := c.Update(first, second, false, UpdateOptions{}) - if err != nil { - t.Fatal(err) + resp, err := r.cb(r.Actions, req) + + respBytes := readBodyBytes(resp.Body) + t.Logf("Response: %d %s", resp.StatusCode, string(respBytes)) + if resp.Body != nil { + resp.Body = io.NopCloser(bytes.NewReader(respBytes)) } - if len(result.Created) != 1 { - t.Errorf("expected 1 resource created, got %d", len(result.Created)) + r.actionsLock.Lock() + defer r.actionsLock.Unlock() + r.Actions = append(r.Actions, RequestResponseAction{ + Request: *req, + Response: *resp, + Error: err, + }) + + return resp, err +} + +func TestCreate(t *testing.T) { + // Note: c.Create with the fake client can currently only test creation of a single pod/object in the same list. When testing + // with more than one pod, c.Create will run into a data race as it calls perform->batchPerform which performs creation + // in batches. The race is something in the fake client itself in `func (c *RESTClient) do(...)` + // when it stores the req: c.Req = req and cannot (?) be fixed easily. + + type testCase struct { + Name string + Pods v1.PodList + Callback func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error) + ServerSideApply bool + ExpectedActions []string + ExpectedErrorContains string + } + + testCases := map[string]testCase{ + "Create success (client-side apply)": { + Pods: newPodList("starfish"), + ServerSideApply: false, + Callback: func(t *testing.T, tc testCase, previous []RequestResponseAction, _ *http.Request) (*http.Response, error) { + t.Helper() + + if len(previous) < 2 { // simulate a conflict + return newResponseJSON(http.StatusConflict, resourceQuotaConflict) + } + + return newResponse(http.StatusOK, &tc.Pods.Items[0]) + }, + ExpectedActions: []string{ + "/namespaces/default/pods:POST", + "/namespaces/default/pods:POST", + "/namespaces/default/pods:POST", + }, + }, + "Create success (server-side apply)": { + Pods: newPodList("whale"), + ServerSideApply: true, + Callback: func(t *testing.T, tc testCase, _ []RequestResponseAction, _ *http.Request) (*http.Response, error) { + t.Helper() + + return newResponse(http.StatusOK, &tc.Pods.Items[0]) + }, + ExpectedActions: []string{ + "/namespaces/default/pods/whale:PATCH", + }, + }, + "Create fail: incompatible server (server-side apply)": { + Pods: newPodList("lobster"), + ServerSideApply: true, + Callback: func(t *testing.T, _ testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + return &http.Response{ + StatusCode: http.StatusUnsupportedMediaType, + Request: req, + }, nil + }, + ExpectedErrorContains: "server-side apply not available on the server:", + ExpectedActions: []string{ + "/namespaces/default/pods/lobster:PATCH", + }, + }, + "Create fail: quota (server-side apply)": { + Pods: newPodList("dolphin"), + ServerSideApply: true, + Callback: func(t *testing.T, _ testCase, _ []RequestResponseAction, _ *http.Request) (*http.Response, error) { + t.Helper() + + return newResponseJSON(http.StatusConflict, resourceQuotaConflict) + }, + ExpectedErrorContains: "Operation cannot be fulfilled on resourcequotas \"quota\": the object has been modified; " + + "please apply your changes to the latest version and try again", + ExpectedActions: []string{ + "/namespaces/default/pods/dolphin:PATCH", + }, + }, } - if len(result.Updated) != 2 { - t.Errorf("expected 2 resource updated, got %d", len(result.Updated)) + + c := newTestClient(t) + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + + client := NewRequestResponseLogClient(t, func(previous []RequestResponseAction, req *http.Request) (*http.Response, error) { + return tc.Callback(t, tc, previous, req) + }) + + c.Factory.(*cmdtesting.TestFactory).UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: unstructuredSerializer, + Client: fake.CreateHTTPClient(client.Do), + } + + list, err := c.Build(objBody(&tc.Pods), false) + require.NoError(t, err) + if err != nil { + t.Fatal(err) + } + + result, err := c.Create( + list, + ClientCreateOptionServerSideApply(tc.ServerSideApply, false)) + if tc.ExpectedErrorContains != "" { + require.ErrorContains(t, err, tc.ExpectedErrorContains) + } else { + require.NoError(t, err) + + // See note above about limitations in supporting more than a single object + assert.Len(t, result.Created, 1, "expected 1 object created, got %d", len(result.Created)) + } + + actions := []string{} + for _, action := range client.Actions { + path, method := action.Request.URL.Path, action.Request.Method + actions = append(actions, path+":"+method) + } + + assert.Equal(t, tc.ExpectedActions, actions) + + }) } - if len(result.Deleted) != 1 { - t.Errorf("expected 1 resource deleted, got %d", len(result.Deleted)) +} + +func TestUpdate(t *testing.T) { + type testCase struct { + OriginalPods v1.PodList + TargetPods v1.PodList + ThreeWayMergeForUnstructured bool + ServerSideApply bool + ExpectedActions []string + ExpectedError string } - // TODO: Find a way to test methods that use Client Set - // Test with a wait - // if err := c.Update("test", objBody(codec, &listB), objBody(codec, &listC), false, 300, true); err != nil { - // t.Fatal(err) - // } - // Test with a wait should fail - // TODO: A way to make this not based off of an extremely short timeout? - // if err := c.Update("test", objBody(codec, &listC), objBody(codec, &listA), false, 2, true); err != nil { - // t.Fatal(err) - // } - expectedActions := []string{ + expectedActionsClientSideApply := []string{ "/namespaces/default/pods/starfish:GET", "/namespaces/default/pods/starfish:GET", "/namespaces/default/pods/starfish:PATCH", @@ -199,17 +341,204 @@ func TestUpdate(t *testing.T) { "/namespaces/default/pods/otter:GET", "/namespaces/default/pods/otter:GET", "/namespaces/default/pods/dolphin:GET", - "/namespaces/default/pods:POST", + "/namespaces/default/pods:POST", // create dolphin + "/namespaces/default/pods:POST", // retry due to 409 + "/namespaces/default/pods:POST", // retry due to 409 "/namespaces/default/pods/squid:GET", "/namespaces/default/pods/squid:DELETE", + "/namespaces/default/pods/notfound:GET", + "/namespaces/default/pods/notfound:DELETE", } - if len(expectedActions) != len(actions) { - t.Fatalf("unexpected number of requests, expected %d, got %d", len(expectedActions), len(actions)) + + expectedActionsServerSideApply := []string{ + "/namespaces/default/pods/starfish:GET", + "/namespaces/default/pods/starfish:GET", + "/namespaces/default/pods/starfish:PATCH", + "/namespaces/default/pods/otter:GET", + "/namespaces/default/pods/otter:GET", + "/namespaces/default/pods/otter:PATCH", + "/namespaces/default/pods/dolphin:GET", + "/namespaces/default/pods/dolphin:PATCH", // create dolphin + "/namespaces/default/pods/squid:GET", + "/namespaces/default/pods/squid:DELETE", + "/namespaces/default/pods/notfound:GET", + "/namespaces/default/pods/notfound:DELETE", } - for k, v := range expectedActions { - if actions[k] != v { - t.Errorf("expected %s request got %s", v, actions[k]) - } + + testCases := map[string]testCase{ + "client-side apply": { + OriginalPods: newPodList("starfish", "otter", "squid", "notfound"), + TargetPods: func() v1.PodList { + listTarget := newPodList("starfish", "otter", "dolphin") + listTarget.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}} + + return listTarget + }(), + ThreeWayMergeForUnstructured: false, + ServerSideApply: false, + ExpectedActions: expectedActionsClientSideApply, + ExpectedError: "", + }, + "client-side apply (three-way merge for unstructured)": { + OriginalPods: newPodList("starfish", "otter", "squid", "notfound"), + TargetPods: func() v1.PodList { + listTarget := newPodList("starfish", "otter", "dolphin") + listTarget.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}} + + return listTarget + }(), + ThreeWayMergeForUnstructured: true, + ServerSideApply: false, + ExpectedActions: expectedActionsClientSideApply, + ExpectedError: "", + }, + "serverSideApply": { + OriginalPods: newPodList("starfish", "otter", "squid", "notfound"), + TargetPods: func() v1.PodList { + listTarget := newPodList("starfish", "otter", "dolphin") + listTarget.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}} + + return listTarget + }(), + ThreeWayMergeForUnstructured: false, + ServerSideApply: true, + ExpectedActions: expectedActionsServerSideApply, + ExpectedError: "", + }, + "serverSideApply with forbidden deletion": { + OriginalPods: newPodList("starfish", "otter", "squid", "notfound", "forbidden"), + TargetPods: func() v1.PodList { + listTarget := newPodList("starfish", "otter", "dolphin") + listTarget.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}} + + return listTarget + }(), + ThreeWayMergeForUnstructured: false, + ServerSideApply: true, + ExpectedActions: append(expectedActionsServerSideApply, + "/namespaces/default/pods/forbidden:GET", + "/namespaces/default/pods/forbidden:DELETE", + ), + ExpectedError: "failed to delete resource forbidden:", + }, + } + + c := newTestClient(t) + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + + listOriginal := tc.OriginalPods + listTarget := tc.TargetPods + + iterationCounter := 0 + cb := func(_ []RequestResponseAction, req *http.Request) (*http.Response, error) { + p, m := req.URL.Path, req.Method + + switch { + case p == "/namespaces/default/pods/starfish" && m == http.MethodGet: + return newResponse(http.StatusOK, &listOriginal.Items[0]) + case p == "/namespaces/default/pods/otter" && m == http.MethodGet: + return newResponse(http.StatusOK, &listOriginal.Items[1]) + case p == "/namespaces/default/pods/otter" && m == http.MethodPatch: + if !tc.ServerSideApply { + defer req.Body.Close() + data, err := io.ReadAll(req.Body) + require.NoError(t, err) + + assert.Equal(t, `{}`, string(data)) + } + + return newResponse(http.StatusOK, &listTarget.Items[0]) + case p == "/namespaces/default/pods/dolphin" && m == http.MethodGet: + return newResponse(http.StatusNotFound, notFoundBody()) + case p == "/namespaces/default/pods/starfish" && m == http.MethodPatch: + if !tc.ServerSideApply { + // Ensure client-side apply specifies correct patch + defer req.Body.Close() + data, err := io.ReadAll(req.Body) + require.NoError(t, err) + + expected := `{"spec":{"$setElementOrder/containers":[{"name":"app:v4"}],"containers":[{"$setElementOrder/ports":[{"containerPort":443}],"name":"app:v4","ports":[{"containerPort":443,"name":"https"},{"$patch":"delete","containerPort":80}]}]}}` + assert.Equal(t, expected, string(data)) + } + + return newResponse(http.StatusOK, &listTarget.Items[0]) + case p == "/namespaces/default/pods" && m == http.MethodPost: + if iterationCounter < 2 { + iterationCounter++ + return newResponseJSON(http.StatusConflict, resourceQuotaConflict) + } + + return newResponse(http.StatusOK, &listTarget.Items[1]) + case p == "/namespaces/default/pods/dolphin" && m == http.MethodPatch: + return newResponse(http.StatusOK, &listTarget.Items[1]) + case p == "/namespaces/default/pods/squid" && m == http.MethodDelete: + return newResponse(http.StatusOK, &listTarget.Items[1]) + case p == "/namespaces/default/pods/squid" && m == http.MethodGet: + return newResponse(http.StatusOK, &listTarget.Items[2]) + case p == "/namespaces/default/pods/notfound" && m == http.MethodGet: + // Resource exists in original but will simulate not found on delete + return newResponse(http.StatusOK, &listOriginal.Items[3]) + case p == "/namespaces/default/pods/notfound" && m == http.MethodDelete: + // Simulate a not found during deletion; should not cause update to fail + return newResponse(http.StatusNotFound, notFoundBody()) + case p == "/namespaces/default/pods/forbidden" && m == http.MethodGet: + return newResponse(http.StatusOK, &listOriginal.Items[4]) + case p == "/namespaces/default/pods/forbidden" && m == http.MethodDelete: + // Simulate RBAC forbidden that should cause update to fail + return newResponse(http.StatusForbidden, &metav1.Status{ + Status: metav1.StatusFailure, + Message: "pods \"forbidden\" is forbidden: User \"test-user\" cannot delete resource \"pods\" in API group \"\" in the namespace \"default\"", + Reason: metav1.StatusReasonForbidden, + Code: http.StatusForbidden, + }) + } + + t.FailNow() + return nil, nil + } + + client := NewRequestResponseLogClient(t, cb) + + c.Factory.(*cmdtesting.TestFactory).UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: unstructuredSerializer, + Client: fake.CreateHTTPClient(client.Do), + } + + first, err := c.Build(objBody(&listOriginal), false) + require.NoError(t, err) + + second, err := c.Build(objBody(&listTarget), false) + require.NoError(t, err) + + result, err := c.Update( + first, + second, + ClientUpdateOptionThreeWayMergeForUnstructured(tc.ThreeWayMergeForUnstructured), + ClientUpdateOptionForceReplace(false), + ClientUpdateOptionServerSideApply(tc.ServerSideApply, false), + ClientUpdateOptionUpgradeClientSideFieldManager(true)) + + if tc.ExpectedError != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.ExpectedError) + } else { + require.NoError(t, err) + } + + assert.Len(t, result.Created, 1, "expected 1 resource created, got %d", len(result.Created)) + assert.Len(t, result.Updated, 2, "expected 2 resource updated, got %d", len(result.Updated)) + assert.Len(t, result.Deleted, 1, "expected 1 resource deleted, got %d", len(result.Deleted)) + + actions := []string{} + for _, action := range client.Actions { + path, method := action.Request.URL.Path, action.Request.Method + actions = append(actions, path+":"+method) + } + + assert.Equal(t, tc.ExpectedActions, actions) + }) } } @@ -341,6 +670,219 @@ func TestPerform(t *testing.T) { } } +func TestWait(t *testing.T) { + podList := newPodList("starfish", "otter", "squid") + + var created *time.Time + + c := newTestClient(t) + c.Factory.(*cmdtesting.TestFactory).Client = &fake.RESTClient{ + NegotiatedSerializer: unstructuredSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + p, m := req.URL.Path, req.Method + t.Logf("got request %s %s", p, m) + switch { + case p == "/api/v1/namespaces/default/pods/starfish" && m == http.MethodGet: + pod := &podList.Items[0] + if created != nil && time.Since(*created) >= time.Second*5 { + pod.Status.Conditions = []v1.PodCondition{ + { + Type: v1.PodReady, + Status: v1.ConditionTrue, + }, + } + } + return newResponse(http.StatusOK, pod) + case p == "/api/v1/namespaces/default/pods/otter" && m == http.MethodGet: + pod := &podList.Items[1] + if created != nil && time.Since(*created) >= time.Second*5 { + pod.Status.Conditions = []v1.PodCondition{ + { + Type: v1.PodReady, + Status: v1.ConditionTrue, + }, + } + } + return newResponse(http.StatusOK, pod) + case p == "/api/v1/namespaces/default/pods/squid" && m == http.MethodGet: + pod := &podList.Items[2] + if created != nil && time.Since(*created) >= time.Second*5 { + pod.Status.Conditions = []v1.PodCondition{ + { + Type: v1.PodReady, + Status: v1.ConditionTrue, + }, + } + } + return newResponse(http.StatusOK, pod) + case p == "/namespaces/default/pods" && m == http.MethodPost: + resources, err := c.Build(req.Body, false) + if err != nil { + t.Fatal(err) + } + now := time.Now() + created = &now + return newResponse(http.StatusOK, resources[0].Object) + default: + t.Fatalf("unexpected request: %s %s", req.Method, req.URL.Path) + return nil, nil + } + }), + } + var err error + c.Waiter, err = c.GetWaiterWithOptions(LegacyStrategy) + if err != nil { + t.Fatal(err) + } + resources, err := c.Build(objBody(&podList), false) + if err != nil { + t.Fatal(err) + } + + result, err := c.Create( + resources, + ClientCreateOptionServerSideApply(false, false)) + + if err != nil { + t.Fatal(err) + } + if len(result.Created) != 3 { + t.Errorf("expected 3 resource created, got %d", len(result.Created)) + } + + if err := c.Wait(resources, time.Second*30); err != nil { + t.Errorf("expected wait without error, got %s", err) + } + + if time.Since(*created) < time.Second*5 { + t.Errorf("expected to wait at least 5 seconds before ready status was detected, but got %s", time.Since(*created)) + } +} + +func TestWaitJob(t *testing.T) { + job := newJob("starfish", 0, intToInt32(1), 0, 0) + + var created *time.Time + + c := newTestClient(t) + c.Factory.(*cmdtesting.TestFactory).Client = &fake.RESTClient{ + NegotiatedSerializer: unstructuredSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + p, m := req.URL.Path, req.Method + t.Logf("got request %s %s", p, m) + switch { + case p == "/apis/batch/v1/namespaces/default/jobs/starfish" && m == http.MethodGet: + if created != nil && time.Since(*created) >= time.Second*5 { + job.Status.Succeeded = 1 + } + return newResponse(http.StatusOK, job) + case p == "/namespaces/default/jobs" && m == http.MethodPost: + resources, err := c.Build(req.Body, false) + if err != nil { + t.Fatal(err) + } + now := time.Now() + created = &now + return newResponse(http.StatusOK, resources[0].Object) + default: + t.Fatalf("unexpected request: %s %s", req.Method, req.URL.Path) + return nil, nil + } + }), + } + var err error + c.Waiter, err = c.GetWaiterWithOptions(LegacyStrategy) + if err != nil { + t.Fatal(err) + } + resources, err := c.Build(objBody(job), false) + if err != nil { + t.Fatal(err) + } + result, err := c.Create( + resources, + ClientCreateOptionServerSideApply(false, false)) + + if err != nil { + t.Fatal(err) + } + if len(result.Created) != 1 { + t.Errorf("expected 1 resource created, got %d", len(result.Created)) + } + + if err := c.WaitWithJobs(resources, time.Second*30); err != nil { + t.Errorf("expected wait without error, got %s", err) + } + + if time.Since(*created) < time.Second*5 { + t.Errorf("expected to wait at least 5 seconds before ready status was detected, but got %s", time.Since(*created)) + } +} + +func TestWaitDelete(t *testing.T) { + pod := newPod("starfish") + + var deleted *time.Time + + c := newTestClient(t) + c.Factory.(*cmdtesting.TestFactory).Client = &fake.RESTClient{ + NegotiatedSerializer: unstructuredSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + p, m := req.URL.Path, req.Method + t.Logf("got request %s %s", p, m) + switch { + case p == "/namespaces/default/pods/starfish" && m == http.MethodGet: + if deleted != nil && time.Since(*deleted) >= time.Second*5 { + return newResponse(http.StatusNotFound, notFoundBody()) + } + return newResponse(http.StatusOK, &pod) + case p == "/namespaces/default/pods/starfish" && m == http.MethodDelete: + now := time.Now() + deleted = &now + return newResponse(http.StatusOK, &pod) + case p == "/namespaces/default/pods" && m == http.MethodPost: + resources, err := c.Build(req.Body, false) + if err != nil { + t.Fatal(err) + } + return newResponse(http.StatusOK, resources[0].Object) + default: + t.Fatalf("unexpected request: %s %s", req.Method, req.URL.Path) + return nil, nil + } + }), + } + var err error + c.Waiter, err = c.GetWaiterWithOptions(LegacyStrategy) + if err != nil { + t.Fatal(err) + } + resources, err := c.Build(objBody(&pod), false) + if err != nil { + t.Fatal(err) + } + result, err := c.Create( + resources, + ClientCreateOptionServerSideApply(false, false)) + if err != nil { + t.Fatal(err) + } + if len(result.Created) != 1 { + t.Errorf("expected 1 resource created, got %d", len(result.Created)) + } + if _, err := c.Delete(resources, metav1.DeletePropagationBackground); err != nil { + t.Fatal(err) + } + + if err := c.WaitForDelete(resources, time.Second*30); err != nil { + t.Errorf("expected wait without error, got %s", err) + } + + if time.Since(*deleted) < time.Second*5 { + t.Errorf("expected to wait at least 5 seconds before ready status was detected, but got %s", time.Since(*deleted)) + } +} + func TestReal(t *testing.T) { t.Skip("This is a live test, comment this line to run") c := New(nil) @@ -348,7 +890,7 @@ func TestReal(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := c.Create(resources, CreateOptions{}); err != nil { + if _, err := c.Create(resources); err != nil { t.Fatal(err) } @@ -358,7 +900,7 @@ func TestReal(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := c.Create(resources, CreateOptions{}); err != nil { + if _, err := c.Create(resources); err != nil { t.Fatal(err) } @@ -367,7 +909,7 @@ func TestReal(t *testing.T) { t.Fatal(err) } - if _, errs := c.Delete(resources, DeleteOptions{}); errs != nil { + if _, errs := c.Delete(resources, metav1.DeletePropagationBackground); errs != nil { t.Fatal(errs) } @@ -376,11 +918,42 @@ func TestReal(t *testing.T) { t.Fatal(err) } // ensures that delete does not fail if a resource is not found - if _, errs := c.Delete(resources, DeleteOptions{}); errs != nil { + if _, errs := c.Delete(resources, metav1.DeletePropagationBackground); errs != nil { t.Fatal(errs) } } +func TestGetPodList(t *testing.T) { + namespace := "some-namespace" + names := []string{"dave", "jimmy"} + var responsePodList v1.PodList + for _, name := range names { + responsePodList.Items = append(responsePodList.Items, newPodWithStatus(name, v1.PodStatus{}, namespace)) + } + + kubeClient := k8sfake.NewClientset(&responsePodList) + c := Client{Namespace: namespace, kubeClient: kubeClient} + + podList, err := c.GetPodList(namespace, metav1.ListOptions{}) + clientAssertions := assert.New(t) + clientAssertions.NoError(err) + clientAssertions.Equal(&responsePodList, podList) +} + +func TestOutputContainerLogsForPodList(t *testing.T) { + namespace := "some-namespace" + somePodList := newPodList("jimmy", "three", "structs") + + kubeClient := k8sfake.NewClientset(&somePodList) + c := Client{Namespace: namespace, kubeClient: kubeClient} + outBuffer := &bytes.Buffer{} + outBufferFunc := func(_, _, _ string) io.Writer { return outBuffer } + err := c.OutputContainerLogsForPodList(&somePodList, namespace, outBufferFunc) + clientAssertions := assert.New(t) + clientAssertions.NoError(err) + clientAssertions.Equal("fake logsfake logsfake logs", outBuffer.String()) +} + const testServiceManifest = ` kind: Service apiVersion: v1 @@ -451,11 +1024,11 @@ spec: apiVersion: v1 kind: Service metadata: - name: redis-slave + name: redis-replica labels: app: redis tier: backend - role: slave + role: replica spec: ports: # the port that this service should serve on @@ -463,24 +1036,24 @@ spec: selector: app: redis tier: backend - role: slave + role: replica --- apiVersion: extensions/v1beta1 kind: Deployment metadata: - name: redis-slave + name: redis-replica spec: replicas: 2 template: metadata: labels: app: redis - role: slave + role: replica tier: backend spec: containers: - - name: slave - image: gcr.io/google_samples/gb-redisslave:v1 + - name: replica + image: gcr.io/google_samples/gb-redisreplica:v1 resources: requests: cpu: 100m @@ -558,3 +1131,1194 @@ spec: ports: - containerPort: 80 ` + +var resourceQuotaConflict = []byte(` +{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Operation cannot be fulfilled on resourcequotas \"quota\": the object has been modified; please apply your changes to the latest version and try again","reason":"Conflict","details":{"name":"quota","kind":"resourcequotas"},"code":409}`) + +type createPatchTestCase struct { + name string + + // The target state. + target *unstructured.Unstructured + // The state as it exists in the release. + original *unstructured.Unstructured + // The actual state as it exists in the cluster. + actual *unstructured.Unstructured + + threeWayMergeForUnstructured bool + // The patch is supposed to transfer the current state to the target state, + // thereby preserving the actual state, wherever possible. + expectedPatch string + expectedPatchType types.PatchType +} + +func (c createPatchTestCase) run(t *testing.T) { + scheme := runtime.NewScheme() + v1.AddToScheme(scheme) + encoder := jsonserializer.NewSerializerWithOptions( + jsonserializer.DefaultMetaFactory, scheme, scheme, jsonserializer.SerializerOptions{ + Yaml: false, Pretty: false, Strict: true, + }, + ) + objBody := func(obj runtime.Object) io.ReadCloser { + return io.NopCloser(bytes.NewReader([]byte(runtime.EncodeOrDie(encoder, obj)))) + } + header := make(http.Header) + header.Set("Content-Type", runtime.ContentTypeJSON) + restClient := &fake.RESTClient{ + NegotiatedSerializer: unstructuredSerializer, + Resp: &http.Response{ + StatusCode: http.StatusOK, + Body: objBody(c.actual), + Header: header, + }, + } + + targetInfo := &resource.Info{ + Client: restClient, + Namespace: "default", + Name: "test-obj", + Object: c.target, + Mapping: &meta.RESTMapping{ + Resource: schema.GroupVersionResource{ + Group: "crd.com", + Version: "v1", + Resource: "datas", + }, + Scope: meta.RESTScopeNamespace, + }, + } + + patch, patchType, err := createPatch(c.original, targetInfo, c.threeWayMergeForUnstructured) + if err != nil { + t.Fatalf("Failed to create patch: %v", err) + } + + if c.expectedPatch != string(patch) { + t.Errorf("Unexpected patch.\nTarget:\n%s\nOriginal:\n%s\nActual:\n%s\n\nExpected:\n%s\nGot:\n%s", + c.target, + c.original, + c.actual, + c.expectedPatch, + string(patch), + ) + } + + if patchType != types.MergePatchType { + t.Errorf("Expected patch type %s, got %s", types.MergePatchType, patchType) + } +} + +func newTestCustomResourceData(metadata map[string]string, spec map[string]any) *unstructured.Unstructured { + if metadata == nil { + metadata = make(map[string]string) + } + if _, ok := metadata["name"]; !ok { + metadata["name"] = "test-obj" + } + if _, ok := metadata["namespace"]; !ok { + metadata["namespace"] = "default" + } + o := map[string]any{ + "apiVersion": "crd.com/v1", + "kind": "Data", + "metadata": metadata, + } + if len(spec) > 0 { + o["spec"] = spec + } + return &unstructured.Unstructured{ + Object: o, + } +} + +func TestCreatePatchCustomResourceMetadata(t *testing.T) { + target := newTestCustomResourceData(map[string]string{ + "meta.helm.sh/release-name": "foo-simple", + "meta.helm.sh/release-namespace": "default", + "objectset.rio.cattle.io/id": "default-foo-simple", + }, nil) + testCase := createPatchTestCase{ + name: "take ownership of resource", + target: target, + original: target, + actual: newTestCustomResourceData(nil, map[string]any{ + "color": "red", + }), + threeWayMergeForUnstructured: true, + expectedPatch: `{"metadata":{"meta.helm.sh/release-name":"foo-simple","meta.helm.sh/release-namespace":"default","objectset.rio.cattle.io/id":"default-foo-simple"}}`, + expectedPatchType: types.MergePatchType, + } + t.Run(testCase.name, testCase.run) + + // Previous behavior. + testCase.threeWayMergeForUnstructured = false + testCase.expectedPatch = `{}` + t.Run(testCase.name, testCase.run) +} + +func TestCreatePatchCustomResourceSpec(t *testing.T) { + target := newTestCustomResourceData(nil, map[string]any{ + "color": "red", + "size": "large", + }) + testCase := createPatchTestCase{ + name: "merge with spec of existing custom resource", + target: target, + original: target, + actual: newTestCustomResourceData(nil, map[string]any{ + "color": "red", + "weight": "heavy", + }), + threeWayMergeForUnstructured: true, + expectedPatch: `{"spec":{"size":"large"}}`, + expectedPatchType: types.MergePatchType, + } + t.Run(testCase.name, testCase.run) + + // Previous behavior. + testCase.threeWayMergeForUnstructured = false + testCase.expectedPatch = `{}` + t.Run(testCase.name, testCase.run) +} + +type errorFactory struct { + *cmdtesting.TestFactory + err error +} + +func (f *errorFactory) KubernetesClientSet() (*kubernetes.Clientset, error) { + return nil, f.err +} + +func newTestClientWithDiscoveryError(t *testing.T, err error) *Client { + t.Helper() + c := newTestClient(t) + c.Factory.(*cmdtesting.TestFactory).Client = &fake.RESTClient{ + NegotiatedSerializer: unstructuredSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + if req.URL.Path == "/version" { + return nil, err + } + resp, respErr := newResponse(http.StatusOK, &v1.Pod{}) + return resp, respErr + }), + } + return c +} + +func TestIsReachable(t *testing.T) { + const ( + expectedUnreachableMsg = "kubernetes cluster unreachable" + ) + tests := []struct { + name string + setupClient func(*testing.T) *Client + expectError bool + errorContains string + }{ + { + name: "successful reachability test", + setupClient: func(t *testing.T) *Client { + t.Helper() + client := newTestClient(t) + client.kubeClient = k8sfake.NewClientset() + return client + }, + expectError: false, + }, + { + name: "client creation error with ErrEmptyConfig", + setupClient: func(t *testing.T) *Client { + t.Helper() + client := newTestClient(t) + client.Factory = &errorFactory{err: genericclioptions.ErrEmptyConfig} + return client + }, + expectError: true, + errorContains: expectedUnreachableMsg, + }, + { + name: "client creation error with general error", + setupClient: func(t *testing.T) *Client { + t.Helper() + client := newTestClient(t) + client.Factory = &errorFactory{err: errors.New("connection refused")} + return client + }, + expectError: true, + errorContains: "kubernetes cluster unreachable: connection refused", + }, + { + name: "discovery error with cluster unreachable", + setupClient: func(t *testing.T) *Client { + t.Helper() + return newTestClientWithDiscoveryError(t, http.ErrServerClosed) + }, + expectError: true, + errorContains: expectedUnreachableMsg, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := tt.setupClient(t) + err := client.IsReachable() + + if tt.expectError { + if err == nil { + t.Error("expected error but got nil") + return + } + + if !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("expected error message to contain '%s', got: %v", tt.errorContains, err) + } + + } else { + if err != nil { + t.Errorf("expected no error but got: %v", err) + } + } + }) + } +} + +func TestIsIncompatibleServerError(t *testing.T) { + testCases := map[string]struct { + Err error + Want bool + }{ + "Unsupported media type": { + Err: &apierrors.StatusError{ErrStatus: metav1.Status{Code: http.StatusUnsupportedMediaType}}, + Want: true, + }, + "Not found error": { + Err: &apierrors.StatusError{ErrStatus: metav1.Status{Code: http.StatusNotFound}}, + Want: false, + }, + "Generic error": { + Err: fmt.Errorf("some generic error"), + Want: false, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + if got := isIncompatibleServerError(tc.Err); got != tc.Want { + t.Errorf("isIncompatibleServerError() = %v, want %v", got, tc.Want) + } + }) + } +} + +func TestReplaceResource(t *testing.T) { + type testCase struct { + Pods v1.PodList + Callback func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error) + ExpectedErrorContains string + } + + testCases := map[string]testCase{ + "normal": { + Pods: newPodList("whale"), + Callback: func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path) + switch len(previous) { + case 0: + assert.Equal(t, "GET", req.Method) + case 1: + assert.Equal(t, "PUT", req.Method) + } + + return newResponse(http.StatusOK, &tc.Pods.Items[0]) + }, + }, + "conflict": { + Pods: newPodList("whale"), + Callback: func(t *testing.T, _ testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + return &http.Response{ + StatusCode: http.StatusConflict, + Request: req, + }, nil + }, + ExpectedErrorContains: "failed to replace object: the server reported a conflict", + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + + testFactory := cmdtesting.NewTestFactory() + t.Cleanup(testFactory.Cleanup) + + client := NewRequestResponseLogClient(t, func(previous []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + return tc.Callback(t, tc, previous, req) + }) + + testFactory.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: unstructuredSerializer, + Client: fake.CreateHTTPClient(client.Do), + } + + resourceList, err := buildResourceList(testFactory, v1.NamespaceDefault, FieldValidationDirectiveStrict, objBody(&tc.Pods), nil) + require.NoError(t, err) + + require.Len(t, resourceList, 1) + info := resourceList[0] + + err = replaceResource(info, FieldValidationDirectiveStrict) + if tc.ExpectedErrorContains != "" { + require.ErrorContains(t, err, tc.ExpectedErrorContains) + } else { + require.NoError(t, err) + require.NotNil(t, info.Object) + } + }) + } +} + +func TestPatchResourceClientSide(t *testing.T) { + type testCase struct { + OriginalPods v1.PodList + TargetPods v1.PodList + ThreeWayMergeForUnstructured bool + Callback func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error) + ExpectedErrorContains string + } + + testCases := map[string]testCase{ + "normal": { + OriginalPods: newPodList("whale"), + TargetPods: func() v1.PodList { + pods := newPodList("whale") + pods.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}} + + return pods + }(), + ThreeWayMergeForUnstructured: false, + Callback: func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path) + switch len(previous) { + case 0: + assert.Equal(t, "GET", req.Method) + return newResponse(http.StatusOK, &tc.OriginalPods.Items[0]) + case 1: + assert.Equal(t, "PATCH", req.Method) + assert.Equal(t, "application/strategic-merge-patch+json", req.Header.Get("Content-Type")) + return newResponse(http.StatusOK, &tc.TargetPods.Items[0]) + } + + t.Fail() + return nil, nil + }, + }, + "three way merge for unstructured": { + OriginalPods: newPodList("whale"), + TargetPods: func() v1.PodList { + pods := newPodList("whale") + pods.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}} + + return pods + }(), + ThreeWayMergeForUnstructured: true, + Callback: func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path) + switch len(previous) { + case 0: + assert.Equal(t, "GET", req.Method) + return newResponse(http.StatusOK, &tc.OriginalPods.Items[0]) + case 1: + t.Logf("patcher: %+v", req.Header) + assert.Equal(t, "PATCH", req.Method) + assert.Equal(t, "application/strategic-merge-patch+json", req.Header.Get("Content-Type")) + return newResponse(http.StatusOK, &tc.TargetPods.Items[0]) + } + + t.Fail() + return nil, nil + }, + }, + "conflict": { + OriginalPods: newPodList("whale"), + TargetPods: func() v1.PodList { + pods := newPodList("whale") + pods.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}} + + return pods + }(), + Callback: func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path) + switch len(previous) { + case 0: + assert.Equal(t, "GET", req.Method) + return newResponse(http.StatusOK, &tc.OriginalPods.Items[0]) + case 1: + assert.Equal(t, "PATCH", req.Method) + return &http.Response{ + StatusCode: http.StatusConflict, + Request: req, + }, nil + } + + t.Fail() + return nil, nil + + }, + ExpectedErrorContains: "cannot patch \"whale\" with kind Pod: the server reported a conflict", + }, + "no patch": { + OriginalPods: newPodList("whale"), + TargetPods: newPodList("whale"), + Callback: func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path) + switch len(previous) { + case 0: + assert.Equal(t, "GET", req.Method) + return newResponse(http.StatusOK, &tc.OriginalPods.Items[0]) + case 1: + assert.Equal(t, "GET", req.Method) + return newResponse(http.StatusOK, &tc.TargetPods.Items[0]) + } + + t.Fail() + return nil, nil // newResponse(http.StatusOK, &tc.TargetPods.Items[0]) + + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + + testFactory := cmdtesting.NewTestFactory() + t.Cleanup(testFactory.Cleanup) + + client := NewRequestResponseLogClient(t, func(previous []RequestResponseAction, req *http.Request) (*http.Response, error) { + return tc.Callback(t, tc, previous, req) + }) + + testFactory.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: unstructuredSerializer, + Client: fake.CreateHTTPClient(client.Do), + } + + resourceListOriginal, err := buildResourceList(testFactory, v1.NamespaceDefault, FieldValidationDirectiveStrict, objBody(&tc.OriginalPods), nil) + require.NoError(t, err) + require.Len(t, resourceListOriginal, 1) + + resourceListTarget, err := buildResourceList(testFactory, v1.NamespaceDefault, FieldValidationDirectiveStrict, objBody(&tc.TargetPods), nil) + require.NoError(t, err) + require.Len(t, resourceListTarget, 1) + + original := resourceListOriginal[0] + target := resourceListTarget[0] + + err = patchResourceClientSide(original.Object, target, tc.ThreeWayMergeForUnstructured) + if tc.ExpectedErrorContains != "" { + require.ErrorContains(t, err, tc.ExpectedErrorContains) + } else { + require.NoError(t, err) + require.NotNil(t, target.Object) + } + }) + } +} + +func TestPatchResourceServerSide(t *testing.T) { + type testCase struct { + Pods v1.PodList + DryRun bool + ForceConflicts bool + FieldValidationDirective FieldValidationDirective + Callback func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error) + ExpectedErrorContains string + } + + testCases := map[string]testCase{ + "normal": { + Pods: newPodList("whale"), + DryRun: false, + ForceConflicts: false, + FieldValidationDirective: FieldValidationDirectiveStrict, + Callback: func(t *testing.T, tc testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + assert.Equal(t, "PATCH", req.Method) + assert.Equal(t, "application/apply-patch+yaml", req.Header.Get("Content-Type")) + assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path) + assert.Equal(t, "false", req.URL.Query().Get("force")) + assert.Equal(t, "Strict", req.URL.Query().Get("fieldValidation")) + + return newResponse(http.StatusOK, &tc.Pods.Items[0]) + }, + }, + "dry run": { + Pods: newPodList("whale"), + DryRun: true, + ForceConflicts: false, + FieldValidationDirective: FieldValidationDirectiveStrict, + Callback: func(t *testing.T, tc testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + assert.Equal(t, "PATCH", req.Method) + assert.Equal(t, "application/apply-patch+yaml", req.Header.Get("Content-Type")) + assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path) + assert.Equal(t, "All", req.URL.Query().Get("dryRun")) + assert.Equal(t, "false", req.URL.Query().Get("force")) + assert.Equal(t, "Strict", req.URL.Query().Get("fieldValidation")) + + return newResponse(http.StatusOK, &tc.Pods.Items[0]) + }, + }, + "force conflicts": { + Pods: newPodList("whale"), + DryRun: false, + ForceConflicts: true, + FieldValidationDirective: FieldValidationDirectiveStrict, + Callback: func(t *testing.T, tc testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + assert.Equal(t, "PATCH", req.Method) + assert.Equal(t, "application/apply-patch+yaml", req.Header.Get("Content-Type")) + assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path) + assert.Equal(t, "true", req.URL.Query().Get("force")) + assert.Equal(t, "Strict", req.URL.Query().Get("fieldValidation")) + + return newResponse(http.StatusOK, &tc.Pods.Items[0]) + }, + }, + "dry run + force conflicts": { + Pods: newPodList("whale"), + DryRun: true, + ForceConflicts: true, + FieldValidationDirective: FieldValidationDirectiveStrict, + Callback: func(t *testing.T, tc testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + assert.Equal(t, "PATCH", req.Method) + assert.Equal(t, "application/apply-patch+yaml", req.Header.Get("Content-Type")) + assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path) + assert.Equal(t, "All", req.URL.Query().Get("dryRun")) + assert.Equal(t, "true", req.URL.Query().Get("force")) + assert.Equal(t, "Strict", req.URL.Query().Get("fieldValidation")) + + return newResponse(http.StatusOK, &tc.Pods.Items[0]) + }, + }, + "field validation ignore": { + Pods: newPodList("whale"), + DryRun: false, + ForceConflicts: false, + FieldValidationDirective: FieldValidationDirectiveIgnore, + Callback: func(t *testing.T, tc testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + assert.Equal(t, "PATCH", req.Method) + assert.Equal(t, "application/apply-patch+yaml", req.Header.Get("Content-Type")) + assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path) + assert.Equal(t, "false", req.URL.Query().Get("force")) + assert.Equal(t, "Ignore", req.URL.Query().Get("fieldValidation")) + + return newResponse(http.StatusOK, &tc.Pods.Items[0]) + }, + }, + "incompatible server": { + Pods: newPodList("whale"), + DryRun: false, + ForceConflicts: false, + FieldValidationDirective: FieldValidationDirectiveStrict, + Callback: func(t *testing.T, _ testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + return &http.Response{ + StatusCode: http.StatusUnsupportedMediaType, + Request: req, + }, nil + }, + ExpectedErrorContains: "server-side apply not available on the server:", + }, + "conflict": { + Pods: newPodList("whale"), + DryRun: false, + ForceConflicts: false, + FieldValidationDirective: FieldValidationDirectiveStrict, + Callback: func(t *testing.T, _ testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + return &http.Response{ + StatusCode: http.StatusConflict, + Request: req, + }, nil + }, + ExpectedErrorContains: "the server reported a conflict", + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + + testFactory := cmdtesting.NewTestFactory() + t.Cleanup(testFactory.Cleanup) + + client := NewRequestResponseLogClient(t, func(previous []RequestResponseAction, req *http.Request) (*http.Response, error) { + return tc.Callback(t, tc, previous, req) + }) + + testFactory.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: unstructuredSerializer, + Client: fake.CreateHTTPClient(client.Do), + } + + resourceList, err := buildResourceList(testFactory, v1.NamespaceDefault, tc.FieldValidationDirective, objBody(&tc.Pods), nil) + require.NoError(t, err) + + require.Len(t, resourceList, 1) + info := resourceList[0] + + err = patchResourceServerSide(info, tc.DryRun, tc.ForceConflicts, tc.FieldValidationDirective) + if tc.ExpectedErrorContains != "" { + require.ErrorContains(t, err, tc.ExpectedErrorContains) + } else { + require.NoError(t, err) + require.NotNil(t, info.Object) + } + }) + } +} + +func TestDetermineFieldValidationDirective(t *testing.T) { + + assert.Equal(t, FieldValidationDirectiveIgnore, determineFieldValidationDirective(false)) + assert.Equal(t, FieldValidationDirectiveStrict, determineFieldValidationDirective(true)) +} + +func TestClientWaitContextCancellationLegacy(t *testing.T) { + podList := newPodList("starfish", "otter") + + ctx, cancel := context.WithCancel(t.Context()) + + c := newTestClient(t) + c.WaitContext = ctx + + requestCount := 0 + c.Factory.(*cmdtesting.TestFactory).Client = &fake.RESTClient{ + NegotiatedSerializer: unstructuredSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + requestCount++ + p, m := req.URL.Path, req.Method + t.Logf("got request %s %s", p, m) + + if requestCount == 2 { + cancel() + } + + switch { + case p == "/api/v1/namespaces/default/pods/starfish" && m == http.MethodGet: + pod := &podList.Items[0] + pod.Status.Conditions = []v1.PodCondition{ + { + Type: v1.PodReady, + Status: v1.ConditionFalse, + }, + } + return newResponse(http.StatusOK, pod) + case p == "/api/v1/namespaces/default/pods/otter" && m == http.MethodGet: + pod := &podList.Items[1] + pod.Status.Conditions = []v1.PodCondition{ + { + Type: v1.PodReady, + Status: v1.ConditionFalse, + }, + } + return newResponse(http.StatusOK, pod) + case p == "/namespaces/default/pods" && m == http.MethodPost: + resources, err := c.Build(req.Body, false) + if err != nil { + t.Fatal(err) + } + return newResponse(http.StatusOK, resources[0].Object) + default: + t.Logf("unexpected request: %s %s", req.Method, req.URL.Path) + return newResponse(http.StatusNotFound, notFoundBody()) + } + }), + } + + var err error + c.Waiter, err = c.GetWaiterWithOptions(LegacyStrategy) + require.NoError(t, err) + + resources, err := c.Build(objBody(&podList), false) + require.NoError(t, err) + + result, err := c.Create( + resources, + ClientCreateOptionServerSideApply(false, false)) + require.NoError(t, err) + assert.Len(t, result.Created, 2, "expected 2 resources created, got %d", len(result.Created)) + + err = c.Wait(resources, time.Second*30) + require.Error(t, err) + assert.Contains(t, err.Error(), "context canceled", "expected context canceled error, got: %v", err) +} + +func TestClientWaitWithJobsContextCancellationLegacy(t *testing.T) { + job := newJob("starfish", 0, intToInt32(1), 0, 0) + + ctx, cancel := context.WithCancel(t.Context()) + + c := newTestClient(t) + c.WaitContext = ctx + + requestCount := 0 + c.Factory.(*cmdtesting.TestFactory).Client = &fake.RESTClient{ + NegotiatedSerializer: unstructuredSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + requestCount++ + p, m := req.URL.Path, req.Method + t.Logf("got request %s %s", p, m) + + if requestCount == 2 { + cancel() + } + + switch { + case p == "/apis/batch/v1/namespaces/default/jobs/starfish" && m == http.MethodGet: + job.Status.Succeeded = 0 + return newResponse(http.StatusOK, job) + case p == "/namespaces/default/jobs" && m == http.MethodPost: + resources, err := c.Build(req.Body, false) + if err != nil { + t.Fatal(err) + } + return newResponse(http.StatusOK, resources[0].Object) + default: + t.Logf("unexpected request: %s %s", req.Method, req.URL.Path) + return newResponse(http.StatusNotFound, notFoundBody()) + } + }), + } + + var err error + c.Waiter, err = c.GetWaiterWithOptions(LegacyStrategy) + require.NoError(t, err) + + resources, err := c.Build(objBody(job), false) + require.NoError(t, err) + + result, err := c.Create( + resources, + ClientCreateOptionServerSideApply(false, false)) + require.NoError(t, err) + assert.Len(t, result.Created, 1, "expected 1 resource created, got %d", len(result.Created)) + + err = c.WaitWithJobs(resources, time.Second*30) + require.Error(t, err) + assert.Contains(t, err.Error(), "context canceled", "expected context canceled error, got: %v", err) +} + +func TestClientWaitForDeleteContextCancellationLegacy(t *testing.T) { + pod := newPod("starfish") + + ctx, cancel := context.WithCancel(t.Context()) + + c := newTestClient(t) + c.WaitContext = ctx + + deleted := false + requestCount := 0 + c.Factory.(*cmdtesting.TestFactory).Client = &fake.RESTClient{ + NegotiatedSerializer: unstructuredSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + requestCount++ + p, m := req.URL.Path, req.Method + t.Logf("got request %s %s", p, m) + + if requestCount == 3 { + cancel() + } + + switch { + case p == "/namespaces/default/pods/starfish" && m == http.MethodGet: + if deleted { + return newResponse(http.StatusOK, &pod) + } + return newResponse(http.StatusOK, &pod) + case p == "/namespaces/default/pods/starfish" && m == http.MethodDelete: + deleted = true + return newResponse(http.StatusOK, &pod) + case p == "/namespaces/default/pods" && m == http.MethodPost: + resources, err := c.Build(req.Body, false) + if err != nil { + t.Fatal(err) + } + return newResponse(http.StatusOK, resources[0].Object) + default: + t.Logf("unexpected request: %s %s", req.Method, req.URL.Path) + return newResponse(http.StatusNotFound, notFoundBody()) + } + }), + } + + var err error + c.Waiter, err = c.GetWaiterWithOptions(LegacyStrategy) + require.NoError(t, err) + + resources, err := c.Build(objBody(&pod), false) + require.NoError(t, err) + + result, err := c.Create( + resources, + ClientCreateOptionServerSideApply(false, false)) + require.NoError(t, err) + assert.Len(t, result.Created, 1, "expected 1 resource created, got %d", len(result.Created)) + + if _, err := c.Delete(resources, metav1.DeletePropagationBackground); err != nil { + t.Fatal(err) + } + + err = c.WaitForDelete(resources, time.Second*30) + require.Error(t, err) + assert.Contains(t, err.Error(), "context canceled", "expected context canceled error, got: %v", err) +} + +func TestClientWaitContextNilDoesNotPanic(t *testing.T) { + podList := newPodList("starfish") + + var created *time.Time + + c := newTestClient(t) + c.WaitContext = nil + + c.Factory.(*cmdtesting.TestFactory).Client = &fake.RESTClient{ + NegotiatedSerializer: unstructuredSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + p, m := req.URL.Path, req.Method + t.Logf("got request %s %s", p, m) + switch { + case p == "/api/v1/namespaces/default/pods/starfish" && m == http.MethodGet: + pod := &podList.Items[0] + if created != nil && time.Since(*created) >= time.Second*2 { + pod.Status.Conditions = []v1.PodCondition{ + { + Type: v1.PodReady, + Status: v1.ConditionTrue, + }, + } + } + return newResponse(http.StatusOK, pod) + case p == "/namespaces/default/pods" && m == http.MethodPost: + resources, err := c.Build(req.Body, false) + if err != nil { + t.Fatal(err) + } + now := time.Now() + created = &now + return newResponse(http.StatusOK, resources[0].Object) + default: + t.Fatalf("unexpected request: %s %s", req.Method, req.URL.Path) + return nil, nil + } + }), + } + + var err error + c.Waiter, err = c.GetWaiterWithOptions(LegacyStrategy) + require.NoError(t, err) + + resources, err := c.Build(objBody(&podList), false) + require.NoError(t, err) + + result, err := c.Create( + resources, + ClientCreateOptionServerSideApply(false, false)) + require.NoError(t, err) + assert.Len(t, result.Created, 1, "expected 1 resource created, got %d", len(result.Created)) + + err = c.Wait(resources, time.Second*30) + require.NoError(t, err) + + assert.GreaterOrEqual(t, time.Since(*created), time.Second*2, "expected to wait at least 2 seconds") +} + +func TestClientWaitContextPreCancelledLegacy(t *testing.T) { + podList := newPodList("starfish") + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + c := newTestClient(t) + c.WaitContext = ctx + + c.Factory.(*cmdtesting.TestFactory).Client = &fake.RESTClient{ + NegotiatedSerializer: unstructuredSerializer, + Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { + p, m := req.URL.Path, req.Method + t.Logf("got request %s %s", p, m) + switch { + case p == "/api/v1/namespaces/default/pods/starfish" && m == http.MethodGet: + pod := &podList.Items[0] + return newResponse(http.StatusOK, pod) + case p == "/namespaces/default/pods" && m == http.MethodPost: + resources, err := c.Build(req.Body, false) + if err != nil { + t.Fatal(err) + } + return newResponse(http.StatusOK, resources[0].Object) + default: + t.Fatalf("unexpected request: %s %s", req.Method, req.URL.Path) + return nil, nil + } + }), + } + + var err error + c.Waiter, err = c.GetWaiterWithOptions(LegacyStrategy) + require.NoError(t, err) + + resources, err := c.Build(objBody(&podList), false) + require.NoError(t, err) + + result, err := c.Create( + resources, + ClientCreateOptionServerSideApply(false, false)) + require.NoError(t, err) + assert.Len(t, result.Created, 1, "expected 1 resource created, got %d", len(result.Created)) + + err = c.Wait(resources, time.Second*30) + require.Error(t, err) + assert.Contains(t, err.Error(), "context canceled", "expected context canceled error, got: %v", err) +} + +func TestClientWaitContextCancellationStatusWatcher(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + + c := newTestClient(t) + c.WaitContext = ctx + + podManifest := ` +apiVersion: v1 +kind: Pod +metadata: + name: test-pod + namespace: default +` + var err error + c.Waiter, err = c.GetWaiterWithOptions(StatusWatcherStrategy) + require.NoError(t, err) + + resources, err := c.Build(strings.NewReader(podManifest), false) + require.NoError(t, err) + + cancel() + + err = c.Wait(resources, time.Second*30) + require.Error(t, err) + assert.Contains(t, err.Error(), "context canceled", "expected context canceled error, got: %v", err) +} + +func TestClientWaitWithJobsContextCancellationStatusWatcher(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + + c := newTestClient(t) + c.WaitContext = ctx + + jobManifest := ` +apiVersion: batch/v1 +kind: Job +metadata: + name: test-job + namespace: default +` + var err error + c.Waiter, err = c.GetWaiterWithOptions(StatusWatcherStrategy) + require.NoError(t, err) + + resources, err := c.Build(strings.NewReader(jobManifest), false) + require.NoError(t, err) + + cancel() + + err = c.WaitWithJobs(resources, time.Second*30) + require.Error(t, err) + assert.Contains(t, err.Error(), "context canceled", "expected context canceled error, got: %v", err) +} + +func TestClientWaitForDeleteContextCancellationStatusWatcher(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + + c := newTestClient(t) + c.WaitContext = ctx + + podManifest := ` +apiVersion: v1 +kind: Pod +metadata: + name: test-pod + namespace: default +status: + conditions: + - type: Ready + status: "True" + phase: Running +` + var err error + c.Waiter, err = c.GetWaiterWithOptions(StatusWatcherStrategy) + require.NoError(t, err) + + resources, err := c.Build(strings.NewReader(podManifest), false) + require.NoError(t, err) + + cancel() + + err = c.WaitForDelete(resources, time.Second*30) + require.Error(t, err) + assert.Contains(t, err.Error(), "context canceled", "expected context canceled error, got: %v", err) +} + +// testStatusReader is a custom status reader for testing that returns a configurable status. +type testStatusReader struct { + supportedGK schema.GroupKind + status status.Status +} + +func (r *testStatusReader) Supports(gk schema.GroupKind) bool { + return gk == r.supportedGK +} + +func (r *testStatusReader) ReadStatus(_ context.Context, _ engine.ClusterReader, id object.ObjMetadata) (*event.ResourceStatus, error) { + return &event.ResourceStatus{ + Identifier: id, + Status: r.status, + Message: "test status reader", + }, nil +} + +func (r *testStatusReader) ReadStatusForObject(_ context.Context, _ engine.ClusterReader, u *unstructured.Unstructured) (*event.ResourceStatus, error) { + id := object.ObjMetadata{ + Namespace: u.GetNamespace(), + Name: u.GetName(), + GroupKind: u.GroupVersionKind().GroupKind(), + } + return &event.ResourceStatus{ + Identifier: id, + Status: r.status, + Message: "test status reader", + }, nil +} + +func TestClientStatusReadersPassedToStatusWaiter(t *testing.T) { + // This test verifies that Client.StatusReaders is correctly passed through + // to the statusWaiter when using the StatusWatcherStrategy. + // We use a custom status reader that immediately returns CurrentStatus for pods, + // which allows a pod without Ready condition to pass the wait. + podManifest := ` +apiVersion: v1 +kind: Pod +metadata: + name: test-pod + namespace: default +` + + c := newTestClient(t) + statusReaders := []engine.StatusReader{ + &testStatusReader{ + supportedGK: v1.SchemeGroupVersion.WithKind("Pod").GroupKind(), + status: status.CurrentStatus, + }, + } + + // Create a fake dynamic client with the pod resource + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper(v1.SchemeGroupVersion.WithKind("Pod")) + + // Create the pod in the fake client + createManifest(t, podManifest, fakeMapper, fakeClient) + + // Set up the waiter with the fake client and custom status readers + c.Waiter = &statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + readers: statusReaders, + } + + resources, err := c.Build(strings.NewReader(podManifest), false) + require.NoError(t, err) + + // The pod has no Ready condition, but our custom reader returns CurrentStatus, + // so the wait should succeed immediately without timeout. + err = c.Wait(resources, time.Second*3) + require.NoError(t, err) +} + +func TestClientStatusReadersWithWaitWithJobs(t *testing.T) { + // This test verifies that Client.StatusReaders is correctly passed through + // to the statusWaiter when using WaitWithJobs. + jobManifest := ` +apiVersion: batch/v1 +kind: Job +metadata: + name: test-job + namespace: default +` + + c := newTestClient(t) + statusReaders := []engine.StatusReader{ + &testStatusReader{ + supportedGK: schema.GroupKind{Group: "batch", Kind: "Job"}, + status: status.CurrentStatus, + }, + } + + // Create a fake dynamic client with the job resource + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper(batchv1.SchemeGroupVersion.WithKind("Job")) + + // Create the job in the fake client + createManifest(t, jobManifest, fakeMapper, fakeClient) + + // Set up the waiter with the fake client and custom status readers + c.Waiter = &statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + readers: statusReaders, + } + + resources, err := c.Build(strings.NewReader(jobManifest), false) + require.NoError(t, err) + + // The job has no Complete condition, but our custom reader returns CurrentStatus, + // so the wait should succeed immediately without timeout. + err = c.WaitWithJobs(resources, time.Second*3) + require.NoError(t, err) +} + +func createManifest(t *testing.T, manifest string, + fakeMapper meta.RESTMapper, fakeClient *dynamicfake.FakeDynamicClient) { + t.Helper() + + m := make(map[string]any) + err := yaml.Unmarshal([]byte(manifest), &m) + require.NoError(t, err) + obj := &unstructured.Unstructured{Object: m} + gvk := obj.GroupVersionKind() + mapping, err := fakeMapper.RESTMapping(gvk.GroupKind(), gvk.Version) + require.NoError(t, err) + err = fakeClient.Tracker().Create(mapping.Resource, obj, obj.GetNamespace()) + require.NoError(t, err) +} diff --git a/pkg/helm/pkg/kube/config.go b/pkg/helm/pkg/kube/config.go deleted file mode 100644 index e00c9acb..00000000 --- a/pkg/helm/pkg/kube/config.go +++ /dev/null @@ -1,30 +0,0 @@ -/* -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 kube // import "helm.sh/helm/v3/pkg/kube" - -import "k8s.io/cli-runtime/pkg/genericclioptions" - -// GetConfig returns a Kubernetes client config. -// -// Deprecated -func GetConfig(kubeconfig, context, namespace string) *genericclioptions.ConfigFlags { - cf := genericclioptions.NewConfigFlags(true) - cf.Namespace = &namespace - cf.Context = &context - cf.KubeConfig = &kubeconfig - return cf -} diff --git a/pkg/helm/pkg/kube/converter.go b/pkg/helm/pkg/kube/converter.go index 3bf0e358..5b1b7e13 100644 --- a/pkg/helm/pkg/kube/converter.go +++ b/pkg/helm/pkg/kube/converter.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package kube // import "helm.sh/helm/v3/pkg/kube" +package kube // import "github.com/werf/nelm/pkg/helm/pkg/kube" import ( "sync" diff --git a/pkg/helm/pkg/kube/extensions.go b/pkg/helm/pkg/kube/extensions.go deleted file mode 100644 index 52e86007..00000000 --- a/pkg/helm/pkg/kube/extensions.go +++ /dev/null @@ -1,36 +0,0 @@ -package kube - -import ( - "context" - "fmt" - - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func IsNotFound(err error) bool { - return err != nil && apierrors.IsNotFound(err) -} - -func (c *Client) DeleteNamespace(ctx context.Context, namespace string, opts DeleteOptions) error { - cs, err := c.Factory.KubernetesClientSet() - if err != nil { - return err - } - - if err := cs.CoreV1().Namespaces().Delete(ctx, namespace, metav1.DeleteOptions{}); err != nil { - return err - } - - if opts.Wait { - specs := []*ResourcesWaiterDeleteResourceSpec{ - {ResourceName: namespace, Namespace: "", GroupVersionResource: corev1.SchemeGroupVersion.WithResource("namespaces")}, - } - if err := c.ResourcesWaiter.WaitUntilDeleted(context.Background(), specs, opts.WaitTimeout); err != nil { - return fmt.Errorf("waiting until namespace deleted failed: %s", err) - } - } - - return nil -} diff --git a/pkg/helm/pkg/kube/factory.go b/pkg/helm/pkg/kube/factory.go index f19d62dc..92ae93f7 100644 --- a/pkg/helm/pkg/kube/factory.go +++ b/pkg/helm/pkg/kube/factory.go @@ -14,12 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -package kube // import "helm.sh/helm/v3/pkg/kube" +package kube // import "github.com/werf/nelm/pkg/helm/pkg/kube" import ( "k8s.io/cli-runtime/pkg/resource" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" "k8s.io/kubectl/pkg/validation" ) @@ -33,6 +34,9 @@ import ( // Helm does not need are not impacted or exposed. This minimizes the impact of Kubernetes changes // being exposed. type Factory interface { + // ToRESTConfig returns restconfig + ToRESTConfig() (*rest.Config, error) + // ToRawKubeConfigLoader return kubeconfig loader as-is ToRawKubeConfigLoader() clientcmd.ClientConfig diff --git a/pkg/helm/pkg/kube/fake/extensions.go b/pkg/helm/pkg/kube/fake/extensions.go deleted file mode 100644 index 8f67b13c..00000000 --- a/pkg/helm/pkg/kube/fake/extensions.go +++ /dev/null @@ -1,11 +0,0 @@ -package fake - -import ( - "context" - - "github.com/werf/nelm/pkg/helm/pkg/kube" -) - -func (c *PrintingKubeClient) DeleteNamespace(ctx context.Context, namespace string, opts kube.DeleteOptions) error { - return nil -} diff --git a/pkg/helm/pkg/kube/fake/fake.go b/pkg/helm/pkg/kube/fake/failing_kube_client.go similarity index 51% rename from pkg/helm/pkg/kube/fake/fake.go rename to pkg/helm/pkg/kube/fake/failing_kube_client.go index 581d8bef..8440468f 100644 --- a/pkg/helm/pkg/kube/fake/fake.go +++ b/pkg/helm/pkg/kube/fake/failing_kube_client.go @@ -21,7 +21,6 @@ import ( "io" "time" - v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/cli-runtime/pkg/resource" @@ -34,27 +33,42 @@ import ( // delegates all its calls to `PrintingKubeClient` type FailingKubeClient struct { PrintingKubeClient - CreateError error - GetError error - WaitError error - DeleteError error - DeleteWithPropagationError error - WatchUntilReadyError error - UpdateError error - BuildError error - BuildTableError error - BuildDummy bool - BuildUnstructuredError error - WaitAndGetCompletedPodPhaseError error - WaitDuration time.Duration + CreateError error + GetError error + DeleteError error + UpdateError error + BuildError error + BuildTableError error + ConnectionError error + BuildDummy bool + DummyResources kube.ResourceList + BuildUnstructuredError error + WaitError error + WaitForDeleteError error + WatchUntilReadyError error + WaitDuration time.Duration + // RecordedWaitOptions stores the WaitOptions passed to GetWaiter for testing + RecordedWaitOptions []kube.WaitOption +} + +var _ kube.Interface = &FailingKubeClient{} + +// FailingKubeWaiter implements kube.Waiter for testing purposes. +// It also has additional errors you can set to fail different functions, otherwise it delegates all its calls to `PrintingKubeWaiter` +type FailingKubeWaiter struct { + *PrintingKubeWaiter + waitError error + waitForDeleteError error + watchUntilReadyError error + waitDuration time.Duration } // Create returns the configured error if set or prints -func (f *FailingKubeClient) Create(resources kube.ResourceList, opts kube.CreateOptions) (*kube.Result, error) { +func (f *FailingKubeClient) Create(resources kube.ResourceList, options ...kube.ClientCreateOption) (*kube.Result, error) { if f.CreateError != nil { return nil, f.CreateError } - return f.PrintingKubeClient.Create(resources, opts) + return f.PrintingKubeClient.Create(resources, options...) } // Get returns the configured error if set or prints @@ -66,52 +80,53 @@ func (f *FailingKubeClient) Get(resources kube.ResourceList, related bool) (map[ } // Waits the amount of time defined on f.WaitDuration, then returns the configured error if set or prints. -func (f *FailingKubeClient) Wait(resources kube.ResourceList, d time.Duration) error { - time.Sleep(f.WaitDuration) - if f.WaitError != nil { - return f.WaitError +func (f *FailingKubeWaiter) Wait(resources kube.ResourceList, d time.Duration) error { + time.Sleep(f.waitDuration) + if f.waitError != nil { + return f.waitError } - return f.PrintingKubeClient.Wait(resources, d) + return f.PrintingKubeWaiter.Wait(resources, d) } // WaitWithJobs returns the configured error if set or prints -func (f *FailingKubeClient) WaitWithJobs(resources kube.ResourceList, d time.Duration) error { - if f.WaitError != nil { - return f.WaitError +func (f *FailingKubeWaiter) WaitWithJobs(resources kube.ResourceList, d time.Duration) error { + if f.waitError != nil { + return f.waitError } - return f.PrintingKubeClient.WaitWithJobs(resources, d) + return f.PrintingKubeWaiter.WaitWithJobs(resources, d) } // WaitForDelete returns the configured error if set or prints -func (f *FailingKubeClient) WaitForDelete(resources kube.ResourceList, d time.Duration) error { - if f.WaitError != nil { - return f.WaitError +func (f *FailingKubeWaiter) WaitForDelete(resources kube.ResourceList, d time.Duration) error { + if f.waitForDeleteError != nil { + return f.waitForDeleteError } - return f.PrintingKubeClient.WaitForDelete(resources, d) + return f.PrintingKubeWaiter.WaitForDelete(resources, d) } // Delete returns the configured error if set or prints -func (f *FailingKubeClient) Delete(resources kube.ResourceList, opts kube.DeleteOptions) (*kube.Result, []error) { +func (f *FailingKubeClient) Delete(resources kube.ResourceList, deletionPropagation metav1.DeletionPropagation) (*kube.Result, []error) { if f.DeleteError != nil { return nil, []error{f.DeleteError} } - return f.PrintingKubeClient.Delete(resources, opts) + + return f.PrintingKubeClient.Delete(resources, deletionPropagation) } // WatchUntilReady returns the configured error if set or prints -func (f *FailingKubeClient) WatchUntilReady(resources kube.ResourceList, d time.Duration) error { - if f.WatchUntilReadyError != nil { - return f.WatchUntilReadyError +func (f *FailingKubeWaiter) WatchUntilReady(resources kube.ResourceList, d time.Duration) error { + if f.watchUntilReadyError != nil { + return f.watchUntilReadyError } - return f.PrintingKubeClient.WatchUntilReady(resources, d) + return f.PrintingKubeWaiter.WatchUntilReady(resources, d) } // Update returns the configured error if set or prints -func (f *FailingKubeClient) Update(r, modified kube.ResourceList, force bool, opts kube.UpdateOptions) (*kube.Result, error) { +func (f *FailingKubeClient) Update(r, modified kube.ResourceList, options ...kube.ClientUpdateOption) (*kube.Result, error) { if f.UpdateError != nil { return &kube.Result{}, f.UpdateError } - return f.PrintingKubeClient.Update(r, modified, force, opts) + return f.PrintingKubeClient.Update(r, modified, options...) } // Build returns the configured error if set or prints @@ -119,6 +134,9 @@ func (f *FailingKubeClient) Build(r io.Reader, _ bool) (kube.ResourceList, error if f.BuildError != nil { return []*resource.Info{}, f.BuildError } + if f.DummyResources != nil { + return f.DummyResources, nil + } if f.BuildDummy { return createDummyResourceList(), nil } @@ -130,23 +148,35 @@ func (f *FailingKubeClient) BuildTable(r io.Reader, _ bool) (kube.ResourceList, if f.BuildTableError != nil { return []*resource.Info{}, f.BuildTableError } + if f.BuildDummy { + return createDummyResourceList(), nil + } return f.PrintingKubeClient.BuildTable(r, false) } -// WaitAndGetCompletedPodPhase returns the configured error if set or prints -func (f *FailingKubeClient) WaitAndGetCompletedPodPhase(s string, d time.Duration) (v1.PodPhase, error) { - if f.WaitAndGetCompletedPodPhaseError != nil { - return v1.PodSucceeded, f.WaitAndGetCompletedPodPhaseError - } - return f.PrintingKubeClient.WaitAndGetCompletedPodPhase(s, d) +func (f *FailingKubeClient) GetWaiter(ws kube.WaitStrategy) (kube.Waiter, error) { + return f.GetWaiterWithOptions(ws) } -// DeleteWithPropagationPolicy returns the configured error if set or prints -func (f *FailingKubeClient) DeleteWithPropagationPolicy(resources kube.ResourceList, policy metav1.DeletionPropagation, opts kube.DeleteOptions) (*kube.Result, []error) { - if f.DeleteWithPropagationError != nil { - return nil, []error{f.DeleteWithPropagationError} +func (f *FailingKubeClient) GetWaiterWithOptions(ws kube.WaitStrategy, opts ...kube.WaitOption) (kube.Waiter, error) { + // Record the WaitOptions for testing + f.RecordedWaitOptions = append(f.RecordedWaitOptions, opts...) + waiter, _ := f.PrintingKubeClient.GetWaiterWithOptions(ws, opts...) + printingKubeWaiter, _ := waiter.(*PrintingKubeWaiter) + return &FailingKubeWaiter{ + PrintingKubeWaiter: printingKubeWaiter, + waitError: f.WaitError, + waitForDeleteError: f.WaitForDeleteError, + watchUntilReadyError: f.WatchUntilReadyError, + waitDuration: f.WaitDuration, + }, nil +} + +func (f *FailingKubeClient) IsReachable() error { + if f.ConnectionError != nil { + return f.ConnectionError } - return f.PrintingKubeClient.DeleteWithPropagationPolicy(resources, policy, opts) + return f.PrintingKubeClient.IsReachable() } func createDummyResourceList() kube.ResourceList { @@ -156,5 +186,4 @@ func createDummyResourceList() kube.ResourceList { var resourceList kube.ResourceList resourceList.Append(&resInfo) return resourceList - } diff --git a/pkg/helm/pkg/kube/fake/printer.go b/pkg/helm/pkg/kube/fake/printer.go index 298d3158..7e0c98b4 100644 --- a/pkg/helm/pkg/kube/fake/printer.go +++ b/pkg/helm/pkg/kube/fake/printer.go @@ -17,6 +17,7 @@ limitations under the License. package fake import ( + "fmt" "io" "strings" "time" @@ -32,16 +33,25 @@ import ( // PrintingKubeClient implements KubeClient, but simply prints the reader to // the given output. type PrintingKubeClient struct { - Out io.Writer + Out io.Writer + LogOutput io.Writer } +// PrintingKubeWaiter implements kube.Waiter, but simply prints the reader to the given output +type PrintingKubeWaiter struct { + Out io.Writer + LogOutput io.Writer +} + +var _ kube.Interface = &PrintingKubeClient{} + // IsReachable checks if the cluster is reachable func (p *PrintingKubeClient) IsReachable() error { return nil } // Create prints the values of what would be created with a real KubeClient. -func (p *PrintingKubeClient) Create(resources kube.ResourceList, _ kube.CreateOptions) (*kube.Result, error) { +func (p *PrintingKubeClient) Create(resources kube.ResourceList, _ ...kube.ClientCreateOption) (*kube.Result, error) { _, err := io.Copy(p.Out, bufferize(resources)) if err != nil { return nil, err @@ -57,17 +67,23 @@ func (p *PrintingKubeClient) Get(resources kube.ResourceList, _ bool) (map[strin return make(map[string][]runtime.Object), nil } -func (p *PrintingKubeClient) Wait(resources kube.ResourceList, _ time.Duration) error { +func (p *PrintingKubeWaiter) Wait(resources kube.ResourceList, _ time.Duration) error { _, err := io.Copy(p.Out, bufferize(resources)) return err } -func (p *PrintingKubeClient) WaitWithJobs(resources kube.ResourceList, _ time.Duration) error { +func (p *PrintingKubeWaiter) WaitWithJobs(resources kube.ResourceList, _ time.Duration) error { _, err := io.Copy(p.Out, bufferize(resources)) return err } -func (p *PrintingKubeClient) WaitForDelete(resources kube.ResourceList, _ time.Duration) error { +func (p *PrintingKubeWaiter) WaitForDelete(resources kube.ResourceList, _ time.Duration) error { + _, err := io.Copy(p.Out, bufferize(resources)) + return err +} + +// WatchUntilReady implements KubeClient WatchUntilReady. +func (p *PrintingKubeWaiter) WatchUntilReady(resources kube.ResourceList, _ time.Duration) error { _, err := io.Copy(p.Out, bufferize(resources)) return err } @@ -75,7 +91,7 @@ func (p *PrintingKubeClient) WaitForDelete(resources kube.ResourceList, _ time.D // Delete implements KubeClient delete. // // It only prints out the content to be deleted. -func (p *PrintingKubeClient) Delete(resources kube.ResourceList, _ kube.DeleteOptions) (*kube.Result, []error) { +func (p *PrintingKubeClient) Delete(resources kube.ResourceList, _ metav1.DeletionPropagation) (*kube.Result, []error) { _, err := io.Copy(p.Out, bufferize(resources)) if err != nil { return nil, []error{err} @@ -83,14 +99,8 @@ func (p *PrintingKubeClient) Delete(resources kube.ResourceList, _ kube.DeleteOp return &kube.Result{Deleted: resources}, nil } -// WatchUntilReady implements KubeClient WatchUntilReady. -func (p *PrintingKubeClient) WatchUntilReady(resources kube.ResourceList, _ time.Duration) error { - _, err := io.Copy(p.Out, bufferize(resources)) - return err -} - // Update implements KubeClient Update. -func (p *PrintingKubeClient) Update(_, modified kube.ResourceList, force bool, _ kube.UpdateOptions) (*kube.Result, error) { +func (p *PrintingKubeClient) Update(_, modified kube.ResourceList, _ ...kube.ClientUpdateOption) (*kube.Result, error) { _, err := io.Copy(p.Out, bufferize(modified)) if err != nil { return nil, err @@ -116,10 +126,21 @@ func (p *PrintingKubeClient) WaitAndGetCompletedPodPhase(_ string, _ time.Durati return v1.PodSucceeded, nil } +// GetPodList implements KubeClient GetPodList. +func (p *PrintingKubeClient) GetPodList(_ string, _ metav1.ListOptions) (*v1.PodList, error) { + return &v1.PodList{}, nil +} + +// OutputContainerLogsForPodList implements KubeClient OutputContainerLogsForPodList. +func (p *PrintingKubeClient) OutputContainerLogsForPodList(_ *v1.PodList, someNamespace string, _ func(namespace, pod, container string) io.Writer) error { + _, err := io.Copy(p.LogOutput, strings.NewReader(fmt.Sprintf("attempted to output logs for namespace: %s", someNamespace))) + return err +} + // DeleteWithPropagationPolicy implements KubeClient delete. // // It only prints out the content to be deleted. -func (p *PrintingKubeClient) DeleteWithPropagationPolicy(resources kube.ResourceList, _ metav1.DeletionPropagation, _ kube.DeleteOptions) (*kube.Result, []error) { +func (p *PrintingKubeClient) DeleteWithPropagationPolicy(resources kube.ResourceList, _ metav1.DeletionPropagation) (*kube.Result, []error) { _, err := io.Copy(p.Out, bufferize(resources)) if err != nil { return nil, []error{err} @@ -127,6 +148,14 @@ func (p *PrintingKubeClient) DeleteWithPropagationPolicy(resources kube.Resource return &kube.Result{Deleted: resources}, nil } +func (p *PrintingKubeClient) GetWaiter(ws kube.WaitStrategy) (kube.Waiter, error) { + return p.GetWaiterWithOptions(ws) +} + +func (p *PrintingKubeClient) GetWaiterWithOptions(_ kube.WaitStrategy, _ ...kube.WaitOption) (kube.Waiter, error) { + return &PrintingKubeWaiter{Out: p.Out, LogOutput: p.LogOutput}, nil +} + func bufferize(resources kube.ResourceList) io.Reader { var builder strings.Builder for _, info := range resources { diff --git a/pkg/helm/pkg/kube/interface.go b/pkg/helm/pkg/kube/interface.go index 74681b0a..63c78475 100644 --- a/pkg/helm/pkg/kube/interface.go +++ b/pkg/helm/pkg/kube/interface.go @@ -17,7 +17,6 @@ limitations under the License. package kube import ( - "context" "io" "time" @@ -30,33 +29,22 @@ import ( // // A KubernetesClient must be concurrency safe. type Interface interface { - // Create creates one or more resources. - Create(resources ResourceList, opts CreateOptions) (*Result, error) - - // Wait waits up to the given timeout for the specified resources to be ready. - Wait(resources ResourceList, timeout time.Duration) error + // Get details of deployed resources. + // The first argument is a list of resources to get. The second argument + // specifies if related pods should be fetched. For example, the pods being + // managed by a deployment. + Get(resources ResourceList, related bool) (map[string][]runtime.Object, error) - // WaitWithJobs wait up to the given timeout for the specified resources to be ready, including jobs. - WaitWithJobs(resources ResourceList, timeout time.Duration) error + // Create creates one or more resources. + Create(resources ResourceList, options ...ClientCreateOption) (*Result, error) - // Delete destroys one or more resources. - Delete(resources ResourceList, opts DeleteOptions) (*Result, []error) - DeleteNamespace(ctx context.Context, namespace string, opts DeleteOptions) error - - // WatchUntilReady watches the resources given and waits until it is ready. - // - // This method is mainly for hook implementations. It watches for a resource to - // hit a particular milestone. The milestone depends on the Kind. - // - // For Jobs, "ready" means the Job ran to completion (exited without error). - // For Pods, "ready" means the Pod phase is marked "succeeded". - // For all other kinds, it means the kind was created or modified without - // error. - WatchUntilReady(resources ResourceList, timeout time.Duration) error + // Delete destroys one or more resources using the specified deletion propagation policy. + // The 'policy' parameter determines how child resources are handled during deletion. + Delete(resources ResourceList, policy metav1.DeletionPropagation) (*Result, []error) // Update updates one or more resources or creates the resource // if it doesn't exist. - Update(original, target ResourceList, force bool, opts UpdateOptions) (*Result, error) + Update(original, target ResourceList, options ...ClientUpdateOption) (*Result, error) // Build creates a resource list from a Reader. // @@ -65,40 +53,17 @@ type Interface interface { // // Validates against OpenAPI schema if validate is true. Build(reader io.Reader, validate bool) (ResourceList, error) - - // WaitAndGetCompletedPodPhase waits up to a timeout until a pod enters a completed phase - // and returns said phase (PodSucceeded or PodFailed qualify). - WaitAndGetCompletedPodPhase(name string, timeout time.Duration) (v1.PodPhase, error) - // IsReachable checks whether the client is able to connect to the cluster. IsReachable() error -} -// InterfaceExt is introduced to avoid breaking backwards compatibility for Interface implementers. -// -// TODO Helm 4: Remove InterfaceExt and integrate its method(s) into the Interface. -type InterfaceExt interface { - // WaitForDelete wait up to the given timeout for the specified resources to be deleted. - WaitForDelete(resources ResourceList, timeout time.Duration) error -} + // GetWaiter gets the Kube.Waiter. + GetWaiter(ws WaitStrategy) (Waiter, error) -// InterfaceDeletionPropagation is introduced to avoid breaking backwards compatibility for Interface implementers. -// -// TODO Helm 4: Remove InterfaceDeletionPropagation and integrate its method(s) into the Interface. -type InterfaceDeletionPropagation interface { - // Delete destroys one or more resources. The deletion propagation is handled as per the given deletion propagation value. - DeleteWithPropagationPolicy(resources ResourceList, policy metav1.DeletionPropagation, opts DeleteOptions) (*Result, []error) -} + // GetPodList lists all pods that match the specified listOptions + GetPodList(namespace string, listOptions metav1.ListOptions) (*v1.PodList, error) -// InterfaceResources is introduced to avoid breaking backwards compatibility for Interface implementers. -// -// TODO Helm 4: Remove InterfaceResources and integrate its method(s) into the Interface. -type InterfaceResources interface { - // Get details of deployed resources. - // The first argument is a list of resources to get. The second argument - // specifies if related pods should be fetched. For example, the pods being - // managed by a deployment. - Get(resources ResourceList, related bool) (map[string][]runtime.Object, error) + // OutputContainerLogsForPodList outputs the logs for a pod list + OutputContainerLogsForPodList(podList *v1.PodList, namespace string, writerFunc func(namespace, pod, container string) io.Writer) error // BuildTable creates a resource list from a Reader. This differs from // Interface.Build() in that a table kind is returned. A table is useful @@ -112,25 +77,36 @@ type InterfaceResources interface { BuildTable(reader io.Reader, validate bool) (ResourceList, error) } -var _ Interface = (*Client)(nil) -var _ InterfaceExt = (*Client)(nil) -var _ InterfaceDeletionPropagation = (*Client)(nil) -var _ InterfaceResources = (*Client)(nil) +// Waiter defines methods related to waiting for resource states. +type Waiter interface { + // Wait waits up to the given timeout for the specified resources to be ready. + Wait(resources ResourceList, timeout time.Duration) error -type CreateOptions struct { - SkipIfAlreadyExists bool -} + // WaitWithJobs wait up to the given timeout for the specified resources to be ready, including jobs. + WaitWithJobs(resources ResourceList, timeout time.Duration) error -type UpdateOptions struct { - SkipDeleteIfInvalidOwnership bool - ReleaseName string // Required if SkipDeleteIfInvalidOwnership == true - ReleaseNamespace string // Required if SkipDeleteIfInvalidOwnership == true + // WaitForDelete wait up to the given timeout for the specified resources to be deleted. + WaitForDelete(resources ResourceList, timeout time.Duration) error + + // WatchUntilReady watches the resources given and waits until it is ready. + // + // This method is mainly for hook implementations. It watches for a resource to + // hit a particular milestone. The milestone depends on the Kind. + // + // For Jobs, "ready" means the Job ran to completion (exited without error). + // For Pods, "ready" means the Pod phase is marked "succeeded". + // For all other kinds, it means the kind was created or modified without + // error. + WatchUntilReady(resources ResourceList, timeout time.Duration) error } -type DeleteOptions struct { - Wait bool - WaitTimeout time.Duration - SkipIfInvalidOwnership bool - ReleaseName string // Required if SkipIfInvalidOwnership == true - ReleaseNamespace string // Required if SkipIfInvalidOwnership == true +// InterfaceWaitOptions defines an interface that extends Interface with +// methods that accept wait options. +// +// TODO Helm 5: Remove InterfaceWaitOptions and integrate its method(s) into the Interface. +type InterfaceWaitOptions interface { + // GetWaiter gets the Kube.Waiter with options. + GetWaiterWithOptions(ws WaitStrategy, opts ...WaitOption) (Waiter, error) } + +var _ InterfaceWaitOptions = (*Client)(nil) diff --git a/pkg/helm/pkg/kube/options.go b/pkg/helm/pkg/kube/options.go new file mode 100644 index 00000000..3326c284 --- /dev/null +++ b/pkg/helm/pkg/kube/options.go @@ -0,0 +1,82 @@ +/* +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 kube + +import ( + "context" + + "github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine" +) + +// WaitOption is a function that configures an option for waiting on resources. +type WaitOption func(*waitOptions) + +// WithWaitContext sets the context for waiting on resources. +// If unset, context.Background() will be used. +func WithWaitContext(ctx context.Context) WaitOption { + return func(wo *waitOptions) { + wo.ctx = ctx + } +} + +// WithWatchUntilReadyMethodContext sets the context specifically for the WatchUntilReady method. +// If unset, the context set by `WithWaitContext` will be used (falling back to `context.Background()`). +func WithWatchUntilReadyMethodContext(ctx context.Context) WaitOption { + return func(wo *waitOptions) { + wo.watchUntilReadyCtx = ctx + } +} + +// WithWaitMethodContext sets the context specifically for the Wait method. +// If unset, the context set by `WithWaitContext` will be used (falling back to `context.Background()`). +func WithWaitMethodContext(ctx context.Context) WaitOption { + return func(wo *waitOptions) { + wo.waitCtx = ctx + } +} + +// WithWaitWithJobsMethodContext sets the context specifically for the WaitWithJobs method. +// If unset, the context set by `WithWaitContext` will be used (falling back to `context.Background()`). +func WithWaitWithJobsMethodContext(ctx context.Context) WaitOption { + return func(wo *waitOptions) { + wo.waitWithJobsCtx = ctx + } +} + +// WithWaitForDeleteMethodContext sets the context specifically for the WaitForDelete method. +// If unset, the context set by `WithWaitContext` will be used (falling back to `context.Background()`). +func WithWaitForDeleteMethodContext(ctx context.Context) WaitOption { + return func(wo *waitOptions) { + wo.waitForDeleteCtx = ctx + } +} + +// WithKStatusReaders sets the status readers to be used while waiting on resources. +func WithKStatusReaders(readers ...engine.StatusReader) WaitOption { + return func(wo *waitOptions) { + wo.statusReaders = readers + } +} + +type waitOptions struct { + ctx context.Context + watchUntilReadyCtx context.Context + waitCtx context.Context + waitWithJobsCtx context.Context + waitForDeleteCtx context.Context + statusReaders []engine.StatusReader +} diff --git a/pkg/helm/pkg/kube/ready.go b/pkg/helm/pkg/kube/ready.go index ace57b29..734015b1 100644 --- a/pkg/helm/pkg/kube/ready.go +++ b/pkg/helm/pkg/kube/ready.go @@ -14,18 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -package kube // import "helm.sh/helm/v3/pkg/kube" +package kube // import "github.com/werf/nelm/pkg/helm/pkg/kube" import ( "context" "fmt" + "log/slog" appsv1 "k8s.io/api/apps/v1" - appsv1beta1 "k8s.io/api/apps/v1beta1" - appsv1beta2 "k8s.io/api/apps/v1beta2" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" - extensionsv1beta1 "k8s.io/api/extensions/v1beta1" apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -35,7 +33,7 @@ import ( "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/scheme" - deploymentutil "github.com/werf/nelm/pkg/helm/internal/third_party/k8s.io/kubernetes/deployment/util" + deploymentutil "github.com/werf/nelm/pkg/helm/intern/third_party/k8s.io/kubernetes/deployment/util" ) // ReadyCheckerOption is a function that configures a ReadyChecker. @@ -60,13 +58,9 @@ func CheckJobs(checkJobs bool) ReadyCheckerOption { // NewReadyChecker creates a new checker. Passed ReadyCheckerOptions can // be used to override defaults. -func NewReadyChecker(cl kubernetes.Interface, log func(string, ...interface{}), opts ...ReadyCheckerOption) ReadyChecker { +func NewReadyChecker(cl kubernetes.Interface, opts ...ReadyCheckerOption) ReadyChecker { c := ReadyChecker{ client: cl, - log: log, - } - if c.log == nil { - c.log = nopLogger } for _, opt := range opts { opt(&c) @@ -77,7 +71,6 @@ func NewReadyChecker(cl kubernetes.Interface, log func(string, ...interface{}), // ReadyChecker is a type that can check core Kubernetes types for readiness. type ReadyChecker struct { client kubernetes.Interface - log func(string, ...interface{}) checkJobs bool pausedAsReady bool } @@ -105,7 +98,7 @@ func (c *ReadyChecker) IsReady(ctx context.Context, v *resource.Info) (bool, err ready, err := c.jobReady(job) return ready, err } - case *appsv1.Deployment, *appsv1beta1.Deployment, *appsv1beta2.Deployment, *extensionsv1beta1.Deployment: + case *appsv1.Deployment: currentDeployment, err := c.client.AppsV1().Deployments(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{}) if err != nil { return false, err @@ -138,7 +131,7 @@ func (c *ReadyChecker) IsReady(ctx context.Context, v *resource.Info) (bool, err if !c.serviceReady(svc) { return false, nil } - case *extensionsv1beta1.DaemonSet, *appsv1.DaemonSet, *appsv1beta2.DaemonSet: + case *appsv1.DaemonSet: ds, err := c.client.AppsV1().DaemonSets(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{}) if err != nil { return false, err @@ -168,7 +161,7 @@ func (c *ReadyChecker) IsReady(ctx context.Context, v *resource.Info) (bool, err if !c.crdReady(*crd) { return false, nil } - case *appsv1.StatefulSet, *appsv1beta1.StatefulSet, *appsv1beta2.StatefulSet: + case *appsv1.StatefulSet: sts, err := c.client.AppsV1().StatefulSets(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{}) if err != nil { return false, err @@ -188,7 +181,7 @@ func (c *ReadyChecker) IsReady(ctx context.Context, v *resource.Info) (bool, err if !ready || err != nil { return false, err } - case *extensionsv1beta1.ReplicaSet, *appsv1beta2.ReplicaSet, *appsv1.ReplicaSet: + case *appsv1.ReplicaSet: rs, err := c.client.AppsV1().ReplicaSets(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{}) if err != nil { return false, err @@ -233,20 +226,21 @@ func (c *ReadyChecker) isPodReady(pod *corev1.Pod) bool { return true } } - c.log("Pod is not ready: %s/%s", pod.GetNamespace(), pod.GetName()) + slog.Debug("Pod is not ready", "namespace", pod.GetNamespace(), "name", pod.GetName()) return false } func (c *ReadyChecker) jobReady(job *batchv1.Job) (bool, error) { if job.Status.Failed > *job.Spec.BackoffLimit { - c.log("Job is failed: %s/%s", job.GetNamespace(), job.GetName()) + slog.Debug("Job is failed", "namespace", job.GetNamespace(), "name", job.GetName()) // If a job is failed, it can't recover, so throw an error return false, fmt.Errorf("job is failed: %s/%s", job.GetNamespace(), job.GetName()) } if job.Spec.Completions != nil && job.Status.Succeeded < *job.Spec.Completions { - c.log("Job is not completed: %s/%s", job.GetNamespace(), job.GetName()) + slog.Debug("Job is not completed", "namespace", job.GetNamespace(), "name", job.GetName()) return false, nil } + slog.Debug("Job is completed", "namespace", job.GetNamespace(), "name", job.GetName()) return true, nil } @@ -258,7 +252,7 @@ func (c *ReadyChecker) serviceReady(s *corev1.Service) bool { // Ensure that the service cluster IP is not empty if s.Spec.ClusterIP == "" { - c.log("Service does not have cluster IP address: %s/%s", s.GetNamespace(), s.GetName()) + slog.Debug("Service does not have cluster IP address", "namespace", s.GetNamespace(), "name", s.GetName()) return false } @@ -266,24 +260,25 @@ func (c *ReadyChecker) serviceReady(s *corev1.Service) bool { if s.Spec.Type == corev1.ServiceTypeLoadBalancer { // do not wait when at least 1 external IP is set if len(s.Spec.ExternalIPs) > 0 { - c.log("Service %s/%s has external IP addresses (%v), marking as ready", s.GetNamespace(), s.GetName(), s.Spec.ExternalIPs) + slog.Debug("Service has external IP addresses", "namespace", s.GetNamespace(), "name", s.GetName(), "externalIPs", s.Spec.ExternalIPs) return true } if s.Status.LoadBalancer.Ingress == nil { - c.log("Service does not have load balancer ingress IP address: %s/%s", s.GetNamespace(), s.GetName()) + slog.Debug("Service does not have load balancer ingress IP address", "namespace", s.GetNamespace(), "name", s.GetName()) return false } } - + slog.Debug("Service is ready", "namespace", s.GetNamespace(), "name", s.GetName(), "clusterIP", s.Spec.ClusterIP, "externalIPs", s.Spec.ExternalIPs) return true } func (c *ReadyChecker) volumeReady(v *corev1.PersistentVolumeClaim) bool { if v.Status.Phase != corev1.ClaimBound { - c.log("PersistentVolumeClaim is not bound: %s/%s", v.GetNamespace(), v.GetName()) + slog.Debug("PersistentVolumeClaim is not bound", "namespace", v.GetNamespace(), "name", v.GetName()) return false } + slog.Debug("PersistentVolumeClaim is bound", "namespace", v.GetNamespace(), "name", v.GetName(), "phase", v.Status.Phase) return true } @@ -293,23 +288,24 @@ func (c *ReadyChecker) deploymentReady(rs *appsv1.ReplicaSet, dep *appsv1.Deploy return false } // Verify the generation observed by the deployment controller matches the spec generation - if dep.Status.ObservedGeneration != dep.ObjectMeta.Generation { - c.log("Deployment is not ready: %s/%s. observedGeneration (%d) does not match spec generation (%d).", dep.Namespace, dep.Name, dep.Status.ObservedGeneration, dep.ObjectMeta.Generation) + if dep.Status.ObservedGeneration != dep.Generation { + slog.Debug("Deployment is not ready, observedGeneration does not match spec generation", "namespace", dep.GetNamespace(), "name", dep.GetName(), "actualGeneration", dep.Status.ObservedGeneration, "expectedGeneration", dep.Generation) return false } expectedReady := *dep.Spec.Replicas - deploymentutil.MaxUnavailable(*dep) - if !(rs.Status.ReadyReplicas >= expectedReady) { - c.log("Deployment is not ready: %s/%s. %d out of %d expected pods are ready", dep.Namespace, dep.Name, rs.Status.ReadyReplicas, expectedReady) + if rs.Status.ReadyReplicas < expectedReady { + slog.Debug("Deployment does not have enough pods ready", "namespace", dep.GetNamespace(), "name", dep.GetName(), "readyPods", rs.Status.ReadyReplicas, "totalPods", expectedReady) return false } + slog.Debug("Deployment is ready", "namespace", dep.GetNamespace(), "name", dep.GetName(), "readyPods", rs.Status.ReadyReplicas, "totalPods", expectedReady) return true } func (c *ReadyChecker) daemonSetReady(ds *appsv1.DaemonSet) bool { // Verify the generation observed by the daemonSet controller matches the spec generation - if ds.Status.ObservedGeneration != ds.ObjectMeta.Generation { - c.log("DaemonSet is not ready: %s/%s. observedGeneration (%d) does not match spec generation (%d).", ds.Namespace, ds.Name, ds.Status.ObservedGeneration, ds.ObjectMeta.Generation) + if ds.Status.ObservedGeneration != ds.Generation { + slog.Debug("DaemonSet is not ready, observedGeneration does not match spec generation", "namespace", ds.GetNamespace(), "name", ds.GetName(), "observedGeneration", ds.Status.ObservedGeneration, "expectedGeneration", ds.Generation) return false } @@ -320,7 +316,7 @@ func (c *ReadyChecker) daemonSetReady(ds *appsv1.DaemonSet) bool { // Make sure all the updated pods have been scheduled if ds.Status.UpdatedNumberScheduled != ds.Status.DesiredNumberScheduled { - c.log("DaemonSet is not ready: %s/%s. %d out of %d expected pods have been scheduled", ds.Namespace, ds.Name, ds.Status.UpdatedNumberScheduled, ds.Status.DesiredNumberScheduled) + slog.Debug("DaemonSet does not have enough Pods scheduled", "namespace", ds.GetNamespace(), "name", ds.GetName(), "scheduledPods", ds.Status.UpdatedNumberScheduled, "totalPods", ds.Status.DesiredNumberScheduled) return false } maxUnavailable, err := intstr.GetScaledValueFromIntOrPercent(ds.Spec.UpdateStrategy.RollingUpdate.MaxUnavailable, int(ds.Status.DesiredNumberScheduled), true) @@ -332,10 +328,11 @@ func (c *ReadyChecker) daemonSetReady(ds *appsv1.DaemonSet) bool { } expectedReady := int(ds.Status.DesiredNumberScheduled) - maxUnavailable - if !(int(ds.Status.NumberReady) >= expectedReady) { - c.log("DaemonSet is not ready: %s/%s. %d out of %d expected pods are ready", ds.Namespace, ds.Name, ds.Status.NumberReady, expectedReady) + if int(ds.Status.NumberReady) < expectedReady { + slog.Debug("DaemonSet does not have enough Pods ready", "namespace", ds.GetNamespace(), "name", ds.GetName(), "readyPods", ds.Status.NumberReady, "totalPods", expectedReady) return false } + slog.Debug("DaemonSet is ready", "namespace", ds.GetNamespace(), "name", ds.GetName(), "readyPods", ds.Status.NumberReady, "totalPods", expectedReady) return true } @@ -357,6 +354,8 @@ func (c *ReadyChecker) crdBetaReady(crd apiextv1beta1.CustomResourceDefinition) // continue. return true } + default: + // intentionally left empty } } return false @@ -377,6 +376,8 @@ func (c *ReadyChecker) crdReady(crd apiextv1.CustomResourceDefinition) bool { // continue. return true } + default: + // intentionally left empty } } return false @@ -384,14 +385,14 @@ func (c *ReadyChecker) crdReady(crd apiextv1.CustomResourceDefinition) bool { func (c *ReadyChecker) statefulSetReady(sts *appsv1.StatefulSet) bool { // Verify the generation observed by the statefulSet controller matches the spec generation - if sts.Status.ObservedGeneration != sts.ObjectMeta.Generation { - c.log("StatefulSet is not ready: %s/%s. observedGeneration (%d) does not match spec generation (%d).", sts.Namespace, sts.Name, sts.Status.ObservedGeneration, sts.ObjectMeta.Generation) + if sts.Status.ObservedGeneration != sts.Generation { + slog.Debug("StatefulSet is not ready, observedGeneration doest not match spec generation", "namespace", sts.GetNamespace(), "name", sts.GetName(), "actualGeneration", sts.Status.ObservedGeneration, "expectedGeneration", sts.Generation) return false } // If the update strategy is not a rolling update, there will be nothing to wait for if sts.Spec.UpdateStrategy.Type != appsv1.RollingUpdateStatefulSetStrategyType { - c.log("StatefulSet skipped ready check: %s/%s. updateStrategy is %v", sts.Namespace, sts.Name, sts.Spec.UpdateStrategy.Type) + slog.Debug("StatefulSet skipped ready check", "namespace", sts.GetNamespace(), "name", sts.GetName(), "updateStrategy", sts.Spec.UpdateStrategy.Type) return true } @@ -417,30 +418,29 @@ func (c *ReadyChecker) statefulSetReady(sts *appsv1.StatefulSet) bool { // Make sure all the updated pods have been scheduled if int(sts.Status.UpdatedReplicas) < expectedReplicas { - c.log("StatefulSet is not ready: %s/%s. %d out of %d expected pods have been scheduled", sts.Namespace, sts.Name, sts.Status.UpdatedReplicas, expectedReplicas) + slog.Debug("StatefulSet does not have enough Pods scheduled", "namespace", sts.GetNamespace(), "name", sts.GetName(), "readyPods", sts.Status.UpdatedReplicas, "totalPods", expectedReplicas) return false } if int(sts.Status.ReadyReplicas) != replicas { - c.log("StatefulSet is not ready: %s/%s. %d out of %d expected pods are ready", sts.Namespace, sts.Name, sts.Status.ReadyReplicas, replicas) + slog.Debug("StatefulSet does not have enough Pods ready", "namespace", sts.GetNamespace(), "name", sts.GetName(), "readyPods", sts.Status.ReadyReplicas, "totalPods", replicas) return false } // This check only makes sense when all partitions are being upgraded otherwise during a - // partioned rolling upgrade, this condition will never evaluate to true, leading to + // partitioned rolling upgrade, this condition will never evaluate to true, leading to // error. if partition == 0 && sts.Status.CurrentRevision != sts.Status.UpdateRevision { - c.log("StatefulSet is not ready: %s/%s. currentRevision %s does not yet match updateRevision %s", sts.Namespace, sts.Name, sts.Status.CurrentRevision, sts.Status.UpdateRevision) + slog.Debug("StatefulSet is not ready, currentRevision does not match updateRevision", "namespace", sts.GetNamespace(), "name", sts.GetName(), "currentRevision", sts.Status.CurrentRevision, "updateRevision", sts.Status.UpdateRevision) return false } - - c.log("StatefulSet is ready: %s/%s. %d out of %d expected pods are ready", sts.Namespace, sts.Name, sts.Status.ReadyReplicas, replicas) + slog.Debug("StatefulSet is ready", "namespace", sts.GetNamespace(), "name", sts.GetName(), "readyPods", sts.Status.ReadyReplicas, "totalPods", replicas) return true } func (c *ReadyChecker) replicationControllerReady(rc *corev1.ReplicationController) bool { // Verify the generation observed by the replicationController controller matches the spec generation - if rc.Status.ObservedGeneration != rc.ObjectMeta.Generation { - c.log("ReplicationController is not ready: %s/%s. observedGeneration (%d) does not match spec generation (%d).", rc.Namespace, rc.Name, rc.Status.ObservedGeneration, rc.ObjectMeta.Generation) + if rc.Status.ObservedGeneration != rc.Generation { + slog.Debug("ReplicationController is not ready, observedGeneration doest not match spec generation", "namespace", rc.GetNamespace(), "name", rc.GetName(), "actualGeneration", rc.Status.ObservedGeneration, "expectedGeneration", rc.Generation) return false } return true @@ -448,8 +448,8 @@ func (c *ReadyChecker) replicationControllerReady(rc *corev1.ReplicationControll func (c *ReadyChecker) replicaSetReady(rs *appsv1.ReplicaSet) bool { // Verify the generation observed by the replicaSet controller matches the spec generation - if rs.Status.ObservedGeneration != rs.ObjectMeta.Generation { - c.log("ReplicaSet is not ready: %s/%s. observedGeneration (%d) does not match spec generation (%d).", rs.Namespace, rs.Name, rs.Status.ObservedGeneration, rs.ObjectMeta.Generation) + if rs.Status.ObservedGeneration != rs.Generation { + slog.Debug("ReplicaSet is not ready, observedGeneration doest not match spec generation", "namespace", rs.GetNamespace(), "name", rs.GetName(), "actualGeneration", rs.Status.ObservedGeneration, "expectedGeneration", rs.Generation) return false } return true @@ -459,5 +459,8 @@ func getPods(ctx context.Context, client kubernetes.Interface, namespace, select list, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ LabelSelector: selector, }) - return list.Items, err + if err != nil { + return nil, fmt.Errorf("failed to list pods: %w", err) + } + return list.Items, nil } diff --git a/pkg/helm/pkg/kube/ready_test.go b/pkg/helm/pkg/kube/ready_test.go index 3b8c4b80..1fb0da5b 100644 --- a/pkg/helm/pkg/kube/ready_test.go +++ b/pkg/helm/pkg/kube/ready_test.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package kube // import "helm.sh/helm/v3/pkg/kube" +package kube // import "github.com/werf/nelm/pkg/helm/pkg/kube" import ( "context" @@ -22,14 +22,677 @@ import ( appsv1 "k8s.io/api/apps/v1" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/fake" ) const defaultNamespace = metav1.NamespaceDefault +func Test_ReadyChecker_IsReady_Pod(t *testing.T) { + type fields struct { + client kubernetes.Interface + checkJobs bool + pausedAsReady bool + } + type args struct { + ctx context.Context + resource *resource.Info + } + tests := []struct { + name string + fields fields + args args + pod *corev1.Pod + want bool + wantErr bool + }{ + { + name: "IsReady Pod", + fields: fields{ + client: fake.NewClientset(), + checkJobs: true, + pausedAsReady: false, + }, + args: args{ + ctx: t.Context(), + resource: &resource.Info{Object: &corev1.Pod{}, Name: "foo", Namespace: defaultNamespace}, + }, + pod: newPodWithCondition("foo", corev1.ConditionTrue), + want: true, + wantErr: false, + }, + { + name: "IsReady Pod returns error", + fields: fields{ + client: fake.NewClientset(), + checkJobs: true, + pausedAsReady: false, + }, + args: args{ + ctx: t.Context(), + resource: &resource.Info{Object: &corev1.Pod{}, Name: "foo", Namespace: defaultNamespace}, + }, + pod: newPodWithCondition("bar", corev1.ConditionTrue), + want: false, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &ReadyChecker{ + client: tt.fields.client, + checkJobs: tt.fields.checkJobs, + pausedAsReady: tt.fields.pausedAsReady, + } + if _, err := c.client.CoreV1().Pods(defaultNamespace).Create(t.Context(), tt.pod, metav1.CreateOptions{}); err != nil { + t.Errorf("Failed to create Pod error: %v", err) + return + } + got, err := c.IsReady(tt.args.ctx, tt.args.resource) + if (err != nil) != tt.wantErr { + t.Errorf("IsReady() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("IsReady() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_ReadyChecker_IsReady_Job(t *testing.T) { + type fields struct { + client kubernetes.Interface + checkJobs bool + pausedAsReady bool + } + type args struct { + ctx context.Context + resource *resource.Info + } + tests := []struct { + name string + fields fields + args args + job *batchv1.Job + want bool + wantErr bool + }{ + { + name: "IsReady Job error while getting job", + fields: fields{ + client: fake.NewClientset(), + checkJobs: true, + pausedAsReady: false, + }, + args: args{ + ctx: t.Context(), + resource: &resource.Info{Object: &batchv1.Job{}, Name: "foo", Namespace: defaultNamespace}, + }, + job: newJob("bar", 1, intToInt32(1), 1, 0), + want: false, + wantErr: true, + }, + { + name: "IsReady Job", + fields: fields{ + client: fake.NewClientset(), + checkJobs: true, + pausedAsReady: false, + }, + args: args{ + ctx: t.Context(), + resource: &resource.Info{Object: &batchv1.Job{}, Name: "foo", Namespace: defaultNamespace}, + }, + job: newJob("foo", 1, intToInt32(1), 1, 0), + want: true, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &ReadyChecker{ + client: tt.fields.client, + checkJobs: tt.fields.checkJobs, + pausedAsReady: tt.fields.pausedAsReady, + } + if _, err := c.client.BatchV1().Jobs(defaultNamespace).Create(t.Context(), tt.job, metav1.CreateOptions{}); err != nil { + t.Errorf("Failed to create Job error: %v", err) + return + } + got, err := c.IsReady(tt.args.ctx, tt.args.resource) + if (err != nil) != tt.wantErr { + t.Errorf("IsReady() error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Errorf("IsReady() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_ReadyChecker_IsReady_Deployment(t *testing.T) { + type fields struct { + client kubernetes.Interface + checkJobs bool + pausedAsReady bool + } + type args struct { + ctx context.Context + resource *resource.Info + } + tests := []struct { + name string + fields fields + args args + replicaSet *appsv1.ReplicaSet + deployment *appsv1.Deployment + want bool + wantErr bool + }{ + { + name: "IsReady Deployments error while getting current Deployment", + fields: fields{ + client: fake.NewClientset(), + checkJobs: true, + pausedAsReady: false, + }, + args: args{ + ctx: t.Context(), + resource: &resource.Info{Object: &appsv1.Deployment{}, Name: "foo", Namespace: defaultNamespace}, + }, + replicaSet: newReplicaSet("foo", 0, 0, true), + deployment: newDeployment("bar", 1, 1, 0, true), + want: false, + wantErr: true, + }, + { + name: "IsReady Deployments", //TODO fix this one + fields: fields{ + client: fake.NewClientset(), + checkJobs: true, + pausedAsReady: false, + }, + args: args{ + ctx: t.Context(), + resource: &resource.Info{Object: &appsv1.Deployment{}, Name: "foo", Namespace: defaultNamespace}, + }, + replicaSet: newReplicaSet("foo", 0, 0, true), + deployment: newDeployment("foo", 1, 1, 0, true), + want: false, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &ReadyChecker{ + client: tt.fields.client, + checkJobs: tt.fields.checkJobs, + pausedAsReady: tt.fields.pausedAsReady, + } + if _, err := c.client.AppsV1().Deployments(defaultNamespace).Create(t.Context(), tt.deployment, metav1.CreateOptions{}); err != nil { + t.Errorf("Failed to create Deployment error: %v", err) + return + } + if _, err := c.client.AppsV1().ReplicaSets(defaultNamespace).Create(t.Context(), tt.replicaSet, metav1.CreateOptions{}); err != nil { + t.Errorf("Failed to create ReplicaSet error: %v", err) + return + } + got, err := c.IsReady(tt.args.ctx, tt.args.resource) + if (err != nil) != tt.wantErr { + t.Errorf("IsReady() error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Errorf("IsReady() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_ReadyChecker_IsReady_PersistentVolumeClaim(t *testing.T) { + type fields struct { + client kubernetes.Interface + checkJobs bool + pausedAsReady bool + } + type args struct { + ctx context.Context + resource *resource.Info + } + tests := []struct { + name string + fields fields + args args + pvc *corev1.PersistentVolumeClaim + want bool + wantErr bool + }{ + { + name: "IsReady PersistentVolumeClaim", + fields: fields{ + client: fake.NewClientset(), + checkJobs: true, + pausedAsReady: false, + }, + args: args{ + ctx: t.Context(), + resource: &resource.Info{Object: &corev1.PersistentVolumeClaim{}, Name: "foo", Namespace: defaultNamespace}, + }, + pvc: newPersistentVolumeClaim("foo", corev1.ClaimPending), + want: false, + wantErr: false, + }, + { + name: "IsReady PersistentVolumeClaim with error", + fields: fields{ + client: fake.NewClientset(), + checkJobs: true, + pausedAsReady: false, + }, + args: args{ + ctx: t.Context(), + resource: &resource.Info{Object: &corev1.PersistentVolumeClaim{}, Name: "foo", Namespace: defaultNamespace}, + }, + pvc: newPersistentVolumeClaim("bar", corev1.ClaimPending), + want: false, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &ReadyChecker{ + client: tt.fields.client, + checkJobs: tt.fields.checkJobs, + pausedAsReady: tt.fields.pausedAsReady, + } + if _, err := c.client.CoreV1().PersistentVolumeClaims(defaultNamespace).Create(t.Context(), tt.pvc, metav1.CreateOptions{}); err != nil { + t.Errorf("Failed to create PersistentVolumeClaim error: %v", err) + return + } + got, err := c.IsReady(tt.args.ctx, tt.args.resource) + if (err != nil) != tt.wantErr { + t.Errorf("IsReady() error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Errorf("IsReady() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_ReadyChecker_IsReady_Service(t *testing.T) { + type fields struct { + client kubernetes.Interface + checkJobs bool + pausedAsReady bool + } + type args struct { + ctx context.Context + resource *resource.Info + } + tests := []struct { + name string + fields fields + args args + svc *corev1.Service + want bool + wantErr bool + }{ + { + name: "IsReady Service", + fields: fields{ + client: fake.NewClientset(), + checkJobs: true, + pausedAsReady: false, + }, + args: args{ + ctx: t.Context(), + resource: &resource.Info{Object: &corev1.Service{}, Name: "foo", Namespace: defaultNamespace}, + }, + svc: newService("foo", corev1.ServiceSpec{Type: corev1.ServiceTypeLoadBalancer, ClusterIP: ""}), + want: false, + wantErr: false, + }, + { + name: "IsReady Service with error", + fields: fields{ + client: fake.NewClientset(), + checkJobs: true, + pausedAsReady: false, + }, + args: args{ + ctx: t.Context(), + resource: &resource.Info{Object: &corev1.Service{}, Name: "foo", Namespace: defaultNamespace}, + }, + svc: newService("bar", corev1.ServiceSpec{Type: corev1.ServiceTypeExternalName, ClusterIP: ""}), + want: false, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &ReadyChecker{ + client: tt.fields.client, + checkJobs: tt.fields.checkJobs, + pausedAsReady: tt.fields.pausedAsReady, + } + if _, err := c.client.CoreV1().Services(defaultNamespace).Create(t.Context(), tt.svc, metav1.CreateOptions{}); err != nil { + t.Errorf("Failed to create Service error: %v", err) + return + } + got, err := c.IsReady(tt.args.ctx, tt.args.resource) + if (err != nil) != tt.wantErr { + t.Errorf("IsReady() error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Errorf("IsReady() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_ReadyChecker_IsReady_DaemonSet(t *testing.T) { + type fields struct { + client kubernetes.Interface + checkJobs bool + pausedAsReady bool + } + type args struct { + ctx context.Context + resource *resource.Info + } + tests := []struct { + name string + fields fields + args args + ds *appsv1.DaemonSet + want bool + wantErr bool + }{ + { + name: "IsReady DaemonSet", + fields: fields{ + client: fake.NewClientset(), + checkJobs: true, + pausedAsReady: false, + }, + args: args{ + ctx: t.Context(), + resource: &resource.Info{Object: &appsv1.DaemonSet{}, Name: "foo", Namespace: defaultNamespace}, + }, + ds: newDaemonSet("foo", 0, 0, 1, 0, true), + want: false, + wantErr: false, + }, + { + name: "IsReady DaemonSet with error", + fields: fields{ + client: fake.NewClientset(), + checkJobs: true, + pausedAsReady: false, + }, + args: args{ + ctx: t.Context(), + resource: &resource.Info{Object: &appsv1.DaemonSet{}, Name: "foo", Namespace: defaultNamespace}, + }, + ds: newDaemonSet("bar", 0, 1, 1, 1, true), + want: false, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &ReadyChecker{ + client: tt.fields.client, + checkJobs: tt.fields.checkJobs, + pausedAsReady: tt.fields.pausedAsReady, + } + if _, err := c.client.AppsV1().DaemonSets(defaultNamespace).Create(t.Context(), tt.ds, metav1.CreateOptions{}); err != nil { + t.Errorf("Failed to create DaemonSet error: %v", err) + return + } + got, err := c.IsReady(tt.args.ctx, tt.args.resource) + if (err != nil) != tt.wantErr { + t.Errorf("IsReady() error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Errorf("IsReady() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_ReadyChecker_IsReady_StatefulSet(t *testing.T) { + type fields struct { + client kubernetes.Interface + checkJobs bool + pausedAsReady bool + } + type args struct { + ctx context.Context + resource *resource.Info + } + tests := []struct { + name string + fields fields + args args + ss *appsv1.StatefulSet + want bool + wantErr bool + }{ + { + name: "IsReady StatefulSet", + fields: fields{ + client: fake.NewClientset(), + checkJobs: true, + pausedAsReady: false, + }, + args: args{ + ctx: t.Context(), + resource: &resource.Info{Object: &appsv1.StatefulSet{}, Name: "foo", Namespace: defaultNamespace}, + }, + ss: newStatefulSet("foo", 1, 0, 0, 1, true), + want: false, + wantErr: false, + }, + { + name: "IsReady StatefulSet with error", + fields: fields{ + client: fake.NewClientset(), + checkJobs: true, + pausedAsReady: false, + }, + args: args{ + ctx: t.Context(), + resource: &resource.Info{Object: &appsv1.StatefulSet{}, Name: "foo", Namespace: defaultNamespace}, + }, + ss: newStatefulSet("bar", 1, 0, 1, 1, true), + want: false, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &ReadyChecker{ + client: tt.fields.client, + checkJobs: tt.fields.checkJobs, + pausedAsReady: tt.fields.pausedAsReady, + } + if _, err := c.client.AppsV1().StatefulSets(defaultNamespace).Create(t.Context(), tt.ss, metav1.CreateOptions{}); err != nil { + t.Errorf("Failed to create StatefulSet error: %v", err) + return + } + got, err := c.IsReady(tt.args.ctx, tt.args.resource) + if (err != nil) != tt.wantErr { + t.Errorf("IsReady() error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Errorf("IsReady() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_ReadyChecker_IsReady_ReplicationController(t *testing.T) { + type fields struct { + client kubernetes.Interface + checkJobs bool + pausedAsReady bool + } + type args struct { + ctx context.Context + resource *resource.Info + } + tests := []struct { + name string + fields fields + args args + rc *corev1.ReplicationController + want bool + wantErr bool + }{ + { + name: "IsReady ReplicationController", + fields: fields{ + client: fake.NewClientset(), + checkJobs: true, + pausedAsReady: false, + }, + args: args{ + ctx: t.Context(), + resource: &resource.Info{Object: &corev1.ReplicationController{}, Name: "foo", Namespace: defaultNamespace}, + }, + rc: newReplicationController("foo", false), + want: false, + wantErr: false, + }, + { + name: "IsReady ReplicationController with error", + fields: fields{ + client: fake.NewClientset(), + checkJobs: true, + pausedAsReady: false, + }, + args: args{ + ctx: t.Context(), + resource: &resource.Info{Object: &corev1.ReplicationController{}, Name: "foo", Namespace: defaultNamespace}, + }, + rc: newReplicationController("bar", false), + want: false, + wantErr: true, + }, + { + name: "IsReady ReplicationController and pods not ready for object", + fields: fields{ + client: fake.NewClientset(), + checkJobs: true, + pausedAsReady: false, + }, + args: args{ + ctx: t.Context(), + resource: &resource.Info{Object: &corev1.ReplicationController{}, Name: "foo", Namespace: defaultNamespace}, + }, + rc: newReplicationController("foo", true), + want: true, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &ReadyChecker{ + client: tt.fields.client, + checkJobs: tt.fields.checkJobs, + pausedAsReady: tt.fields.pausedAsReady, + } + if _, err := c.client.CoreV1().ReplicationControllers(defaultNamespace).Create(t.Context(), tt.rc, metav1.CreateOptions{}); err != nil { + t.Errorf("Failed to create ReplicationController error: %v", err) + return + } + got, err := c.IsReady(tt.args.ctx, tt.args.resource) + if (err != nil) != tt.wantErr { + t.Errorf("IsReady() error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Errorf("IsReady() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_ReadyChecker_IsReady_ReplicaSet(t *testing.T) { + type fields struct { + client kubernetes.Interface + checkJobs bool + pausedAsReady bool + } + type args struct { + ctx context.Context + resource *resource.Info + } + tests := []struct { + name string + fields fields + args args + rs *appsv1.ReplicaSet + want bool + wantErr bool + }{ + { + name: "IsReady ReplicaSet", + fields: fields{ + client: fake.NewClientset(), + checkJobs: true, + pausedAsReady: false, + }, + args: args{ + ctx: t.Context(), + resource: &resource.Info{Object: &appsv1.ReplicaSet{}, Name: "foo", Namespace: defaultNamespace}, + }, + rs: newReplicaSet("foo", 1, 1, true), + want: false, + wantErr: true, + }, + { + name: "IsReady ReplicaSet not ready", + fields: fields{ + client: fake.NewClientset(), + checkJobs: true, + pausedAsReady: false, + }, + args: args{ + ctx: t.Context(), + resource: &resource.Info{Object: &appsv1.ReplicaSet{}, Name: "foo", Namespace: defaultNamespace}, + }, + rs: newReplicaSet("bar", 1, 1, false), + want: false, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &ReadyChecker{ + client: tt.fields.client, + checkJobs: tt.fields.checkJobs, + pausedAsReady: tt.fields.pausedAsReady, + } + // + got, err := c.IsReady(tt.args.ctx, tt.args.resource) + if (err != nil) != tt.wantErr { + t.Errorf("IsReady() error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Errorf("IsReady() = %v, want %v", got, tt.want) + } + }) + } +} + func Test_ReadyChecker_deploymentReady(t *testing.T) { type args struct { rs *appsv1.ReplicaSet @@ -91,7 +754,7 @@ func Test_ReadyChecker_deploymentReady(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - c := NewReadyChecker(fake.NewSimpleClientset(), nil) + c := NewReadyChecker(fake.NewClientset()) if got := c.deploymentReady(tt.args.rs, tt.args.dep); got != tt.want { t.Errorf("deploymentReady() = %v, want %v", got, tt.want) } @@ -125,7 +788,7 @@ func Test_ReadyChecker_replicaSetReady(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - c := NewReadyChecker(fake.NewSimpleClientset(), nil) + c := NewReadyChecker(fake.NewClientset()) if got := c.replicaSetReady(tt.args.rs); got != tt.want { t.Errorf("replicaSetReady() = %v, want %v", got, tt.want) } @@ -159,7 +822,7 @@ func Test_ReadyChecker_replicationControllerReady(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - c := NewReadyChecker(fake.NewSimpleClientset(), nil) + c := NewReadyChecker(fake.NewClientset()) if got := c.replicationControllerReady(tt.args.rc); got != tt.want { t.Errorf("replicationControllerReady() = %v, want %v", got, tt.want) } @@ -214,7 +877,7 @@ func Test_ReadyChecker_daemonSetReady(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - c := NewReadyChecker(fake.NewSimpleClientset(), nil) + c := NewReadyChecker(fake.NewClientset()) if got := c.daemonSetReady(tt.args.ds); got != tt.want { t.Errorf("daemonSetReady() = %v, want %v", got, tt.want) } @@ -290,7 +953,7 @@ func Test_ReadyChecker_statefulSetReady(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - c := NewReadyChecker(fake.NewSimpleClientset(), nil) + c := NewReadyChecker(fake.NewClientset()) if got := c.statefulSetReady(tt.args.sts); got != tt.want { t.Errorf("statefulSetReady() = %v, want %v", got, tt.want) } @@ -334,17 +997,29 @@ func Test_ReadyChecker_podsReadyForObject(t *testing.T) { want: false, wantErr: false, }, + { + name: "ReplicaSet not set", + args: args{ + namespace: defaultNamespace, + obj: nil, + }, + existPods: []corev1.Pod{ + *newPodWithCondition("foo", corev1.ConditionFalse), + }, + want: false, + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - c := NewReadyChecker(fake.NewSimpleClientset(), nil) + c := NewReadyChecker(fake.NewClientset()) for _, pod := range tt.existPods { - if _, err := c.client.CoreV1().Pods(defaultNamespace).Create(context.TODO(), &pod, metav1.CreateOptions{}); err != nil { + if _, err := c.client.CoreV1().Pods(defaultNamespace).Create(t.Context(), &pod, metav1.CreateOptions{}); err != nil { t.Errorf("Failed to create Pod error: %v", err) return } } - got, err := c.podsReadyForObject(context.TODO(), tt.args.namespace, tt.args.obj) + got, err := c.podsReadyForObject(t.Context(), tt.args.namespace, tt.args.obj) if (err != nil) != tt.wantErr { t.Errorf("podsReadyForObject() error = %v, wantErr %v", err, tt.wantErr) return @@ -416,7 +1091,7 @@ func Test_ReadyChecker_jobReady(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - c := NewReadyChecker(fake.NewSimpleClientset(), nil) + c := NewReadyChecker(fake.NewClientset()) got, err := c.jobReady(tt.args.job) if (err != nil) != tt.wantErr { t.Errorf("jobReady() error = %v, wantErr %v", err, tt.wantErr) @@ -455,7 +1130,7 @@ func Test_ReadyChecker_volumeReady(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - c := NewReadyChecker(fake.NewSimpleClientset(), nil) + c := NewReadyChecker(fake.NewClientset()) if got := c.volumeReady(tt.args.v); got != tt.want { t.Errorf("volumeReady() = %v, want %v", got, tt.want) } @@ -463,6 +1138,190 @@ func Test_ReadyChecker_volumeReady(t *testing.T) { } } +func Test_ReadyChecker_serviceReady(t *testing.T) { + type args struct { + service *corev1.Service + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "service type is of external name", + args: args{service: newService("foo", corev1.ServiceSpec{Type: corev1.ServiceTypeExternalName, ClusterIP: ""})}, + want: true, + }, + { + name: "service cluster ip is empty", + args: args{service: newService("foo", corev1.ServiceSpec{Type: corev1.ServiceTypeLoadBalancer, ClusterIP: ""})}, + want: false, + }, + { + name: "service has a cluster ip that is greater than 0", + args: args{service: newService("foo", corev1.ServiceSpec{Type: corev1.ServiceTypeLoadBalancer, ClusterIP: "bar", ExternalIPs: []string{"bar"}})}, + want: true, + }, + { + name: "service has a cluster ip that is less than 0 and ingress is nil", + args: args{service: newService("foo", corev1.ServiceSpec{Type: corev1.ServiceTypeLoadBalancer, ClusterIP: "bar"})}, + want: false, + }, + { + name: "service has a cluster ip that is less than 0 and ingress is nil", + args: args{service: newService("foo", corev1.ServiceSpec{Type: corev1.ServiceTypeClusterIP, ClusterIP: "bar"})}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := NewReadyChecker(fake.NewClientset()) + got := c.serviceReady(tt.args.service) + if got != tt.want { + t.Errorf("serviceReady() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_ReadyChecker_crdBetaReady(t *testing.T) { + type args struct { + crdBeta apiextv1beta1.CustomResourceDefinition + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "crdBeta type is Establish and Conditional is true", + args: args{crdBeta: newcrdBetaReady("foo", apiextv1beta1.CustomResourceDefinitionStatus{ + Conditions: []apiextv1beta1.CustomResourceDefinitionCondition{ + { + Type: apiextv1beta1.Established, + Status: apiextv1beta1.ConditionTrue, + }, + }, + })}, + want: true, + }, + { + name: "crdBeta type is Establish and Conditional is false", + args: args{crdBeta: newcrdBetaReady("foo", apiextv1beta1.CustomResourceDefinitionStatus{ + Conditions: []apiextv1beta1.CustomResourceDefinitionCondition{ + { + Type: apiextv1beta1.Established, + Status: apiextv1beta1.ConditionFalse, + }, + }, + })}, + want: false, + }, + { + name: "crdBeta type is NamesAccepted and Conditional is true", + args: args{crdBeta: newcrdBetaReady("foo", apiextv1beta1.CustomResourceDefinitionStatus{ + Conditions: []apiextv1beta1.CustomResourceDefinitionCondition{ + { + Type: apiextv1beta1.NamesAccepted, + Status: apiextv1beta1.ConditionTrue, + }, + }, + })}, + want: false, + }, + { + name: "crdBeta type is NamesAccepted and Conditional is false", + args: args{crdBeta: newcrdBetaReady("foo", apiextv1beta1.CustomResourceDefinitionStatus{ + Conditions: []apiextv1beta1.CustomResourceDefinitionCondition{ + { + Type: apiextv1beta1.NamesAccepted, + Status: apiextv1beta1.ConditionFalse, + }, + }, + })}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := NewReadyChecker(fake.NewClientset()) + got := c.crdBetaReady(tt.args.crdBeta) + if got != tt.want { + t.Errorf("crdBetaReady() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_ReadyChecker_crdReady(t *testing.T) { + type args struct { + crdBeta apiextv1.CustomResourceDefinition + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "crdBeta type is Establish and Conditional is true", + args: args{crdBeta: newcrdReady("foo", apiextv1.CustomResourceDefinitionStatus{ + Conditions: []apiextv1.CustomResourceDefinitionCondition{ + { + Type: apiextv1.Established, + Status: apiextv1.ConditionTrue, + }, + }, + })}, + want: true, + }, + { + name: "crdBeta type is Establish and Conditional is false", + args: args{crdBeta: newcrdReady("foo", apiextv1.CustomResourceDefinitionStatus{ + Conditions: []apiextv1.CustomResourceDefinitionCondition{ + { + Type: apiextv1.Established, + Status: apiextv1.ConditionFalse, + }, + }, + })}, + want: false, + }, + { + name: "crdBeta type is NamesAccepted and Conditional is true", + args: args{crdBeta: newcrdReady("foo", apiextv1.CustomResourceDefinitionStatus{ + Conditions: []apiextv1.CustomResourceDefinitionCondition{ + { + Type: apiextv1.NamesAccepted, + Status: apiextv1.ConditionTrue, + }, + }, + })}, + want: false, + }, + { + name: "crdBeta type is NamesAccepted and Conditional is false", + args: args{crdBeta: newcrdReady("foo", apiextv1.CustomResourceDefinitionStatus{ + Conditions: []apiextv1.CustomResourceDefinitionCondition{ + { + Type: apiextv1.NamesAccepted, + Status: apiextv1.ConditionFalse, + }, + }, + })}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := NewReadyChecker(fake.NewClientset()) + got := c.crdReady(tt.args.crdBeta) + if got != tt.want { + t.Errorf("crdBetaReady() = %v, want %v", got, tt.want) + } + }) + } +} + func newStatefulSetWithUpdateRevision(name string, replicas, partition, readyReplicas, updatedReplicas int, updateRevision string, generationInSync bool) *appsv1.StatefulSet { ss := newStatefulSet(name, replicas, partition, readyReplicas, updatedReplicas, generationInSync) ss.Status.UpdateRevision = updateRevision @@ -699,6 +1558,43 @@ func newJob(name string, backoffLimit int, completions *int32, succeeded int, fa } } +func newService(name string, serviceSpec corev1.ServiceSpec) *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: defaultNamespace, + }, + Spec: serviceSpec, + Status: corev1.ServiceStatus{ + LoadBalancer: corev1.LoadBalancerStatus{ + Ingress: nil, + }, + }, + } +} + +func newcrdBetaReady(name string, crdBetaStatus apiextv1beta1.CustomResourceDefinitionStatus) apiextv1beta1.CustomResourceDefinition { + return apiextv1beta1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: defaultNamespace, + }, + Spec: apiextv1beta1.CustomResourceDefinitionSpec{}, + Status: crdBetaStatus, + } +} + +func newcrdReady(name string, crdBetaStatus apiextv1.CustomResourceDefinitionStatus) apiextv1.CustomResourceDefinition { + return apiextv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: defaultNamespace, + }, + Spec: apiextv1.CustomResourceDefinitionSpec{}, + Status: crdBetaStatus, + } +} + func intToInt32(i int) *int32 { i32 := int32(i) return &i32 diff --git a/pkg/helm/pkg/kube/resource.go b/pkg/helm/pkg/kube/resource.go index 0f9ae03a..555e3679 100644 --- a/pkg/helm/pkg/kube/resource.go +++ b/pkg/helm/pkg/kube/resource.go @@ -14,15 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -package kube // import "helm.sh/helm/v3/pkg/kube" +package kube // import "github.com/werf/nelm/pkg/helm/pkg/kube" -import ( - "fmt" - - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/cli-runtime/pkg/resource" -) +import "k8s.io/cli-runtime/pkg/resource" // ResourceList provides convenience methods for comparing collections of Infos. type ResourceList []*resource.Info @@ -32,7 +26,7 @@ func (r *ResourceList) Append(val *resource.Info) { *r = append(*r, val) } -// Visit implements resource.Visitor. +// Visit implements resource.Visitor. The visitor stops if fn returns an error. func (r ResourceList) Visit(fn resource.VisitorFunc) error { for _, i := range r { if err := fn(i, nil); err != nil { @@ -44,7 +38,7 @@ func (r ResourceList) Visit(fn resource.VisitorFunc) error { // Filter returns a new Result with Infos that satisfy the predicate fn. func (r ResourceList) Filter(fn func(*resource.Info) bool) ResourceList { - result := ResourceList{} + var result ResourceList for _, i := range r { if fn(i) { result.Append(i) @@ -85,39 +79,14 @@ func (r ResourceList) Intersect(rs ResourceList) ResourceList { return r.Filter(rs.Contains) } -// isMatchingInfo returns true if infos match on Name and GroupVersionKind. +// isMatchingInfo returns true if infos match on Name, Namespace, Group and Kind. +// +// IMPORTANT: Version is intentionally excluded from the comparison. Resources +// served by the same CRD at different API versions (e.g. v2beta1 vs v2beta2) +// share the same underlying storage in the Kubernetes API server. Comparing +// the full GroupVersionKind causes Difference() to treat a version change as +// a resource removal + addition, which makes Helm delete the resource it just +// created during upgrades. See https://github.com/helm/helm/issues/31768 func isMatchingInfo(a, b *resource.Info) bool { - return a.Name == b.Name && a.Namespace == b.Namespace && a.Mapping.GroupVersionKind.Kind == b.Mapping.GroupVersionKind.Kind -} - -func (r ResourceList) ToYamlDocs() (string, error) { - var manifestsStr string - for _, res := range r { - var err error - unstructuredObj := unstructured.Unstructured{} - unstructuredObj.Object, err = runtime.DefaultUnstructuredConverter.ToUnstructured(res.Object) - if err != nil { - return "", fmt.Errorf("error converting object to unstructured type: %w", err) - } - - objByte, err := unstructuredObj.MarshalJSON() - if err != nil { - return "", fmt.Errorf("error marshaling object: %w", err) - } - - manifestsStr = fmt.Sprintf("%s\n---\n%s", manifestsStr, string(objByte)) - } - - return manifestsStr, nil -} - -func (r *ResourceList) Merge(rs ResourceList) { - *r = r.Difference(rs) - for _, res := range rs { - r.Append(res) - } -} - -func ResourceNameNamespaceKind(info *resource.Info) string { - return fmt.Sprint(info.Namespace, ":", info.Object.GetObjectKind().GroupVersionKind().Kind, "/", info.Name) + return a.Name == b.Name && a.Namespace == b.Namespace && a.Mapping.GroupVersionKind.GroupKind() == b.Mapping.GroupVersionKind.GroupKind() } diff --git a/pkg/helm/pkg/kube/resource_policy.go b/pkg/helm/pkg/kube/resource_policy.go index 46b8680d..9bc8bd0f 100644 --- a/pkg/helm/pkg/kube/resource_policy.go +++ b/pkg/helm/pkg/kube/resource_policy.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package kube // import "helm.sh/helm/v3/pkg/kube" +package kube // import "github.com/werf/nelm/pkg/helm/pkg/kube" // ResourcePolicyAnno is the annotation name for a resource policy const ResourcePolicyAnno = "helm.sh/resource-policy" diff --git a/pkg/helm/pkg/kube/resource_test.go b/pkg/helm/pkg/kube/resource_test.go index 3c906cec..a37e3ecf 100644 --- a/pkg/helm/pkg/kube/resource_test.go +++ b/pkg/helm/pkg/kube/resource_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package kube // import "helm.sh/helm/v3/pkg/kube" +package kube // import "github.com/werf/nelm/pkg/helm/pkg/kube" import ( "testing" @@ -59,3 +59,42 @@ func TestResourceList(t *testing.T) { t.Error("expected intersect to return bar") } } + +func TestIsMatchingInfo(t *testing.T) { + gvk := schema.GroupVersionKind{Group: "group1", Version: "version1", Kind: "pod"} + resourceInfo := resource.Info{Name: "name1", Namespace: "namespace1", Mapping: &meta.RESTMapping{GroupVersionKind: gvk}} + + gvkDiffGroup := schema.GroupVersionKind{Group: "diff", Version: "version1", Kind: "pod"} + resourceInfoDiffGroup := resource.Info{Name: "name1", Namespace: "namespace1", Mapping: &meta.RESTMapping{GroupVersionKind: gvkDiffGroup}} + if isMatchingInfo(&resourceInfo, &resourceInfoDiffGroup) { + t.Error("expected resources not equal") + } + + gvkDiffVersion := schema.GroupVersionKind{Group: "group1", Version: "diff", Kind: "pod"} + resourceInfoDiffVersion := resource.Info{Name: "name1", Namespace: "namespace1", Mapping: &meta.RESTMapping{GroupVersionKind: gvkDiffVersion}} + if !isMatchingInfo(&resourceInfo, &resourceInfoDiffVersion) { + t.Error("expected resources with different versions but same group and kind to be equal") + } + + gvkDiffKind := schema.GroupVersionKind{Group: "group1", Version: "version1", Kind: "deployment"} + resourceInfoDiffKind := resource.Info{Name: "name1", Namespace: "namespace1", Mapping: &meta.RESTMapping{GroupVersionKind: gvkDiffKind}} + if isMatchingInfo(&resourceInfo, &resourceInfoDiffKind) { + t.Error("expected resources not equal") + } + + resourceInfoDiffName := resource.Info{Name: "diff", Namespace: "namespace1", Mapping: &meta.RESTMapping{GroupVersionKind: gvk}} + if isMatchingInfo(&resourceInfo, &resourceInfoDiffName) { + t.Error("expected resources not equal") + } + + resourceInfoDiffNamespace := resource.Info{Name: "name1", Namespace: "diff", Mapping: &meta.RESTMapping{GroupVersionKind: gvk}} + if isMatchingInfo(&resourceInfo, &resourceInfoDiffNamespace) { + t.Error("expected resources not equal") + } + + gvkEqual := schema.GroupVersionKind{Group: "group1", Version: "version1", Kind: "pod"} + resourceInfoEqual := resource.Info{Name: "name1", Namespace: "namespace1", Mapping: &meta.RESTMapping{GroupVersionKind: gvkEqual}} + if !isMatchingInfo(&resourceInfo, &resourceInfoEqual) { + t.Error("expected resources to be equal") + } +} diff --git a/pkg/helm/pkg/kube/resources_waiter.go b/pkg/helm/pkg/kube/resources_waiter.go deleted file mode 100644 index 16929fd6..00000000 --- a/pkg/helm/pkg/kube/resources_waiter.go +++ /dev/null @@ -1,20 +0,0 @@ -package kube - -import ( - "context" - "time" - - "k8s.io/apimachinery/pkg/runtime/schema" -) - -type ResourcesWaiter interface { - Wait(ctx context.Context, resources ResourceList, timeout time.Duration) error - WatchUntilReady(ctx context.Context, resources ResourceList, timeout time.Duration) error - WaitUntilDeleted(ctx context.Context, specs []*ResourcesWaiterDeleteResourceSpec, timeout time.Duration) error -} - -type ResourcesWaiterDeleteResourceSpec struct { - ResourceName string - Namespace string - GroupVersionResource schema.GroupVersionResource -} diff --git a/pkg/helm/pkg/cli/roundtripper.go b/pkg/helm/pkg/kube/roundtripper.go similarity index 83% rename from pkg/helm/pkg/cli/roundtripper.go rename to pkg/helm/pkg/kube/roundtripper.go index 9cd4eacb..52cb5bad 100644 --- a/pkg/helm/pkg/cli/roundtripper.go +++ b/pkg/helm/pkg/kube/roundtripper.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package cli +package kube import ( "bytes" @@ -24,19 +24,19 @@ import ( "strings" ) -type retryingRoundTripper struct { - wrapped http.RoundTripper +type RetryingRoundTripper struct { + Wrapped http.RoundTripper } -func (rt *retryingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { +func (rt *RetryingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { return rt.roundTrip(req, 1, nil) } -func (rt *retryingRoundTripper) roundTrip(req *http.Request, retry int, prevResp *http.Response) (*http.Response, error) { +func (rt *RetryingRoundTripper) roundTrip(req *http.Request, retry int, prevResp *http.Response) (*http.Response, error) { if retry < 0 { return prevResp, nil } - resp, rtErr := rt.wrapped.RoundTrip(req) + resp, rtErr := rt.Wrapped.RoundTrip(req) if rtErr != nil { return resp, rtErr } @@ -49,7 +49,7 @@ func (rt *retryingRoundTripper) roundTrip(req *http.Request, retry int, prevResp b, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { - return resp, rtErr + return resp, err } var ke kubernetesError @@ -58,10 +58,10 @@ func (rt *retryingRoundTripper) roundTrip(req *http.Request, retry int, prevResp r.Seek(0, io.SeekStart) resp.Body = io.NopCloser(r) if err != nil { - return resp, rtErr + return resp, err } if ke.Code < 500 { - return resp, rtErr + return resp, nil } // Matches messages like "etcdserver: leader changed" if strings.HasSuffix(ke.Message, "etcdserver: leader changed") { @@ -71,7 +71,7 @@ func (rt *retryingRoundTripper) roundTrip(req *http.Request, retry int, prevResp if strings.HasSuffix(ke.Message, "raft proposal dropped") { return rt.roundTrip(req, retry-1, resp) } - return resp, rtErr + return resp, nil } type kubernetesError struct { diff --git a/pkg/helm/pkg/kube/roundtripper_test.go b/pkg/helm/pkg/kube/roundtripper_test.go new file mode 100644 index 00000000..96602c1f --- /dev/null +++ b/pkg/helm/pkg/kube/roundtripper_test.go @@ -0,0 +1,161 @@ +/* +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 kube + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +type fakeRoundTripper struct { + resp *http.Response + err error + calls int +} + +func (f *fakeRoundTripper) RoundTrip(_ *http.Request) (*http.Response, error) { + f.calls++ + return f.resp, f.err +} + +func newRespWithBody(statusCode int, contentType, body string) *http.Response { + return &http.Response{ + StatusCode: statusCode, + Header: http.Header{"Content-Type": []string{contentType}}, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func TestRetryingRoundTripper_RoundTrip(t *testing.T) { + marshalErr := func(code int, msg string) string { + b, _ := json.Marshal(kubernetesError{ + Code: code, + Message: msg, + }) + return string(b) + } + + tests := []struct { + name string + resp *http.Response + err error + expectedCalls int + expectedErr string + expectedCode int + }{ + { + name: "no retry, status < 500 returns response", + resp: newRespWithBody(200, "application/json", `{"message":"ok","code":200}`), + err: nil, + expectedCalls: 1, + expectedCode: 200, + }, + { + name: "error from wrapped RoundTripper propagates", + resp: nil, + err: errors.New("wrapped error"), + expectedCalls: 1, + expectedErr: "wrapped error", + }, + { + name: "no retry, content-type not application/json", + resp: newRespWithBody(500, "text/plain", "server error"), + err: nil, + expectedCalls: 1, + expectedCode: 500, + }, + { + name: "error reading body returns error", + resp: &http.Response{ + StatusCode: http.StatusInternalServerError, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: &errReader{}, + }, + err: nil, + expectedCalls: 1, + expectedErr: "read error", + }, + { + name: "error decoding JSON returns error", + resp: newRespWithBody(500, "application/json", `invalid-json`), + err: nil, + expectedCalls: 1, + expectedErr: "invalid character", + }, + { + name: "retry on etcdserver leader changed message", + resp: newRespWithBody(500, "application/json", marshalErr(500, "some error etcdserver: leader changed")), + err: nil, + expectedCalls: 2, + expectedCode: 500, + }, + { + name: "retry on raft proposal dropped message", + resp: newRespWithBody(500, "application/json", marshalErr(500, "rpc error: code = Unknown desc = raft proposal dropped")), + err: nil, + expectedCalls: 2, + expectedCode: 500, + }, + { + name: "no retry on other error message", + resp: newRespWithBody(500, "application/json", marshalErr(500, "other server error")), + err: nil, + expectedCalls: 1, + expectedCode: 500, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakeRT := &fakeRoundTripper{ + resp: tt.resp, + err: tt.err, + } + rt := RetryingRoundTripper{ + Wrapped: fakeRT, + } + req, _ := http.NewRequest(http.MethodGet, "http://example.com", nil) + resp, err := rt.RoundTrip(req) + + if tt.expectedErr != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + return + } + assert.NoError(t, err) + + assert.Equal(t, tt.expectedCode, resp.StatusCode) + assert.Equal(t, tt.expectedCalls, fakeRT.calls) + }) + } +} + +type errReader struct{} + +func (e *errReader) Read(_ []byte) (int, error) { + return 0, errors.New("read error") +} + +func (e *errReader) Close() error { + return nil +} diff --git a/pkg/helm/pkg/kube/statuswait.go b/pkg/helm/pkg/kube/statuswait.go new file mode 100644 index 00000000..4484c351 --- /dev/null +++ b/pkg/helm/pkg/kube/statuswait.go @@ -0,0 +1,292 @@ +/* +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 kube // import "github.com/werf/nelm/pkg/helm/pkg/kube" + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sort" + "time" + + "github.com/fluxcd/cli-utils/pkg/kstatus/polling/aggregator" + "github.com/fluxcd/cli-utils/pkg/kstatus/polling/collector" + "github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine" + "github.com/fluxcd/cli-utils/pkg/kstatus/polling/event" + "github.com/fluxcd/cli-utils/pkg/kstatus/polling/statusreaders" + "github.com/fluxcd/cli-utils/pkg/kstatus/status" + "github.com/fluxcd/cli-utils/pkg/kstatus/watcher" + "github.com/fluxcd/cli-utils/pkg/object" + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/dynamic" + watchtools "k8s.io/client-go/tools/watch" + + "github.com/werf/nelm/pkg/helm/intern/logging" + helmStatusReaders "github.com/werf/nelm/pkg/helm/intern/statusreaders" +) + +type statusWaiter struct { + client dynamic.Interface + restMapper meta.RESTMapper + ctx context.Context + watchUntilReadyCtx context.Context + waitCtx context.Context + waitWithJobsCtx context.Context + waitForDeleteCtx context.Context + readers []engine.StatusReader + logging.LogHolder +} + +// DefaultStatusWatcherTimeout is the timeout used by the status waiter when a +// zero timeout is provided. This prevents callers from accidentally passing a +// zero value (which would immediately cancel the context) and getting +// "context deadline exceeded" errors. SDK callers can rely on this default +// when they don't set a timeout. +var DefaultStatusWatcherTimeout = 30 * time.Second + +func alwaysReady(_ *unstructured.Unstructured) (*status.Result, error) { + return &status.Result{ + Status: status.CurrentStatus, + Message: "Resource is current", + }, nil +} + +func (w *statusWaiter) WatchUntilReady(resourceList ResourceList, timeout time.Duration) error { + if timeout == 0 { + timeout = DefaultStatusWatcherTimeout + } + ctx, cancel := w.contextWithTimeout(w.watchUntilReadyCtx, timeout) + defer cancel() + w.Logger().Debug("waiting for resources", "count", len(resourceList), "timeout", timeout) + sw := watcher.NewDefaultStatusWatcher(w.client, w.restMapper) + jobSR := helmStatusReaders.NewCustomJobStatusReader(w.restMapper) + podSR := helmStatusReaders.NewCustomPodStatusReader(w.restMapper) + // We don't want to wait on any other resources as watchUntilReady is only for Helm hooks. + // If custom readers are defined they can be used as Helm hooks support any resource. + // We put them in front since the DelegatingStatusReader uses the first reader that matches. + genericSR := statusreaders.NewGenericStatusReader(w.restMapper, alwaysReady) + + sr := &statusreaders.DelegatingStatusReader{ + StatusReaders: append(w.readers, jobSR, podSR, genericSR), + } + sw.StatusReader = sr + return w.wait(ctx, resourceList, sw) +} + +func (w *statusWaiter) Wait(resourceList ResourceList, timeout time.Duration) error { + if timeout == 0 { + timeout = DefaultStatusWatcherTimeout + } + ctx, cancel := w.contextWithTimeout(w.waitCtx, timeout) + defer cancel() + w.Logger().Debug("waiting for resources", "count", len(resourceList), "timeout", timeout) + sw := watcher.NewDefaultStatusWatcher(w.client, w.restMapper) + sw.StatusReader = statusreaders.NewStatusReader(w.restMapper, w.readers...) + return w.wait(ctx, resourceList, sw) +} + +func (w *statusWaiter) WaitWithJobs(resourceList ResourceList, timeout time.Duration) error { + if timeout == 0 { + timeout = DefaultStatusWatcherTimeout + } + ctx, cancel := w.contextWithTimeout(w.waitWithJobsCtx, timeout) + defer cancel() + w.Logger().Debug("waiting for resources", "count", len(resourceList), "timeout", timeout) + sw := watcher.NewDefaultStatusWatcher(w.client, w.restMapper) + newCustomJobStatusReader := helmStatusReaders.NewCustomJobStatusReader(w.restMapper) + readers := append([]engine.StatusReader(nil), w.readers...) + readers = append(readers, newCustomJobStatusReader) + customSR := statusreaders.NewStatusReader(w.restMapper, readers...) + sw.StatusReader = customSR + return w.wait(ctx, resourceList, sw) +} + +func (w *statusWaiter) WaitForDelete(resourceList ResourceList, timeout time.Duration) error { + if timeout == 0 { + timeout = DefaultStatusWatcherTimeout + } + ctx, cancel := w.contextWithTimeout(w.waitForDeleteCtx, timeout) + defer cancel() + w.Logger().Debug("waiting for resources to be deleted", "count", len(resourceList), "timeout", timeout) + sw := watcher.NewDefaultStatusWatcher(w.client, w.restMapper) + return w.waitForDelete(ctx, resourceList, sw) +} + +func (w *statusWaiter) waitForDelete(ctx context.Context, resourceList ResourceList, sw watcher.StatusWatcher) error { + cancelCtx, cancel := context.WithCancel(ctx) + defer cancel() + resources := []object.ObjMetadata{} + for _, resource := range resourceList { + obj, err := object.RuntimeToObjMeta(resource.Object) + if err != nil { + return err + } + resources = append(resources, obj) + } + eventCh := sw.Watch(cancelCtx, resources, watcher.Options{ + RESTScopeStrategy: watcher.RESTScopeNamespace, + }) + statusCollector := collector.NewResourceStatusCollector(resources) + done := statusCollector.ListenWithObserver(eventCh, statusObserver(cancel, status.NotFoundStatus, w.Logger())) + <-done + + if statusCollector.Error != nil { + return statusCollector.Error + } + + errs := []error{} + for _, id := range resources { + rs := statusCollector.ResourceStatuses[id] + if rs.Status == status.NotFoundStatus || rs.Status == status.UnknownStatus { + continue + } + errs = append(errs, fmt.Errorf("resource %s/%s/%s still exists. status: %s, message: %s", + rs.Identifier.GroupKind.Kind, rs.Identifier.Namespace, rs.Identifier.Name, rs.Status, rs.Message)) + } + if err := ctx.Err(); err != nil { + errs = append(errs, err) + } + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil +} + +func (w *statusWaiter) wait(ctx context.Context, resourceList ResourceList, sw watcher.StatusWatcher) error { + cancelCtx, cancel := context.WithCancel(ctx) + defer cancel() + resources := []object.ObjMetadata{} + for _, resource := range resourceList { + switch value := AsVersioned(resource).(type) { + case *appsv1.Deployment: + if value.Spec.Paused { + continue + } + } + obj, err := object.RuntimeToObjMeta(resource.Object) + if err != nil { + return err + } + resources = append(resources, obj) + } + + eventCh := sw.Watch(cancelCtx, resources, watcher.Options{ + RESTScopeStrategy: watcher.RESTScopeNamespace, + }) + statusCollector := collector.NewResourceStatusCollector(resources) + done := statusCollector.ListenWithObserver(eventCh, statusObserver(cancel, status.CurrentStatus, w.Logger())) + <-done + + if statusCollector.Error != nil { + return statusCollector.Error + } + + errs := []error{} + for _, id := range resources { + rs := statusCollector.ResourceStatuses[id] + if rs.Status == status.CurrentStatus { + continue + } + errs = append(errs, fmt.Errorf("resource %s/%s/%s not ready. status: %s, message: %s", + rs.Identifier.GroupKind.Kind, rs.Identifier.Namespace, rs.Identifier.Name, rs.Status, rs.Message)) + } + if err := ctx.Err(); err != nil { + errs = append(errs, err) + } + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil +} + +func (w *statusWaiter) contextWithTimeout(methodCtx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { + if methodCtx == nil { + methodCtx = w.ctx + } + return contextWithTimeout(methodCtx, timeout) +} + +func contextWithTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { + if ctx == nil { + ctx = context.Background() + } + return watchtools.ContextWithOptionalTimeout(ctx, timeout) +} + +func statusObserver(cancel context.CancelFunc, desired status.Status, logger *slog.Logger) collector.ObserverFunc { + return func(statusCollector *collector.ResourceStatusCollector, _ event.Event) { + var rss []*event.ResourceStatus + var nonDesiredResources []*event.ResourceStatus + for _, rs := range statusCollector.ResourceStatuses { + if rs == nil { + continue + } + // If a resource is already deleted before waiting has started, it will show as unknown. + // This check ensures we don't wait forever for a resource that is already deleted. + if rs.Status == status.UnknownStatus && desired == status.NotFoundStatus { + continue + } + // Failed is a terminal state. This check ensures we don't wait forever for a resource + // that has already failed, as intervention is required to resolve the failure. + if rs.Status == status.FailedStatus && desired == status.CurrentStatus { + continue + } + rss = append(rss, rs) + if rs.Status != desired { + nonDesiredResources = append(nonDesiredResources, rs) + } + } + + if aggregator.AggregateStatus(rss, desired) == desired { + logger.Debug("all resources achieved desired status", "desiredStatus", desired, "resourceCount", len(rss)) + cancel() + return + } + + if len(nonDesiredResources) > 0 { + // Log a single resource so the user knows what they're waiting for without an overwhelming amount of output + sort.Slice(nonDesiredResources, func(i, j int) bool { + return nonDesiredResources[i].Identifier.Name < nonDesiredResources[j].Identifier.Name + }) + first := nonDesiredResources[0] + logger.Debug("waiting for resource", "namespace", first.Identifier.Namespace, "name", first.Identifier.Name, "kind", first.Identifier.GroupKind.Kind, "expectedStatus", desired, "actualStatus", first.Status) + } + } +} + +type hookOnlyWaiter struct { + sw *statusWaiter +} + +func (w *hookOnlyWaiter) WatchUntilReady(resourceList ResourceList, timeout time.Duration) error { + return w.sw.WatchUntilReady(resourceList, timeout) +} + +func (w *hookOnlyWaiter) Wait(_ ResourceList, _ time.Duration) error { + return nil +} + +func (w *hookOnlyWaiter) WaitWithJobs(_ ResourceList, _ time.Duration) error { + return nil +} + +func (w *hookOnlyWaiter) WaitForDelete(_ ResourceList, _ time.Duration) error { + return nil +} diff --git a/pkg/helm/pkg/kube/statuswait_test.go b/pkg/helm/pkg/kube/statuswait_test.go new file mode 100644 index 00000000..85aaf879 --- /dev/null +++ b/pkg/helm/pkg/kube/statuswait_test.go @@ -0,0 +1,1820 @@ +/* +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 kube // import "github.com/werf/nelm/pkg/helm/pkg/kube" + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine" + "github.com/fluxcd/cli-utils/pkg/kstatus/polling/event" + "github.com/fluxcd/cli-utils/pkg/kstatus/status" + "github.com/fluxcd/cli-utils/pkg/object" + "github.com/fluxcd/cli-utils/pkg/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + v1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/apimachinery/pkg/watch" + dynamicfake "k8s.io/client-go/dynamic/fake" + clienttesting "k8s.io/client-go/testing" + "k8s.io/kubectl/pkg/scheme" +) + +var podCurrentManifest = ` +apiVersion: v1 +kind: Pod +metadata: + name: current-pod + namespace: ns +status: + conditions: + - type: Ready + status: "True" + phase: Running +` + +var podNoStatusManifest = ` +apiVersion: v1 +kind: Pod +metadata: + name: in-progress-pod + namespace: ns +` + +var jobNoStatusManifest = ` +apiVersion: batch/v1 +kind: Job +metadata: + name: test + namespace: qual + generation: 1 +` + +var jobReadyManifest = ` +apiVersion: batch/v1 +kind: Job +metadata: + name: ready-not-complete + namespace: default + generation: 1 +status: + startTime: 2025-02-06T16:34:20-05:00 + active: 1 + ready: 1 +` + +var jobCompleteManifest = ` +apiVersion: batch/v1 +kind: Job +metadata: + name: test + namespace: qual + generation: 1 +status: + succeeded: 1 + active: 0 + conditions: + - type: Complete + status: "True" +` + +var jobFailedManifest = ` +apiVersion: batch/v1 +kind: Job +metadata: + name: failed-job + namespace: default + generation: 1 +status: + failed: 1 + active: 0 + conditions: + - type: Failed + status: "True" + reason: BackoffLimitExceeded + message: "Job has reached the specified backoff limit" +` + +var podCompleteManifest = ` +apiVersion: v1 +kind: Pod +metadata: + name: good-pod + namespace: ns +status: + phase: Succeeded +` + +var pausedDeploymentManifest = ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: paused + namespace: ns-1 + generation: 1 +spec: + paused: true + replicas: 1 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.19.6 + ports: + - containerPort: 80 +` + +var notReadyDeploymentManifest = ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: not-ready + namespace: ns-1 + generation: 1 +spec: + replicas: 1 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.19.6 + ports: + - containerPort: 80 +` + +var podNamespace1Manifest = ` +apiVersion: v1 +kind: Pod +metadata: + name: pod-ns1 + namespace: namespace-1 +status: + conditions: + - type: Ready + status: "True" + phase: Running +` + +var podNamespace2Manifest = ` +apiVersion: v1 +kind: Pod +metadata: + name: pod-ns2 + namespace: namespace-2 +status: + conditions: + - type: Ready + status: "True" + phase: Running +` + +var podNamespace1NoStatusManifest = ` +apiVersion: v1 +kind: Pod +metadata: + name: pod-ns1 + namespace: namespace-1 +` + +var jobNamespace1CompleteManifest = ` +apiVersion: batch/v1 +kind: Job +metadata: + name: job-ns1 + namespace: namespace-1 + generation: 1 +status: + succeeded: 1 + active: 0 + conditions: + - type: Complete + status: "True" +` + +var podNamespace2SucceededManifest = ` +apiVersion: v1 +kind: Pod +metadata: + name: pod-ns2 + namespace: namespace-2 +status: + phase: Succeeded +` + +var clusterRoleManifest = ` +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: test-cluster-role +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] +` + +var namespaceManifest = ` +apiVersion: v1 +kind: Namespace +metadata: + name: test-namespace +` + +func getGVR(t *testing.T, mapper meta.RESTMapper, obj *unstructured.Unstructured) schema.GroupVersionResource { + t.Helper() + gvk := obj.GroupVersionKind() + mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version) + require.NoError(t, err) + return mapping.Resource +} + +func getRuntimeObjFromManifests(t *testing.T, manifests []string) []runtime.Object { + t.Helper() + objects := []runtime.Object{} + for _, manifest := range manifests { + m := make(map[string]interface{}) + err := yaml.Unmarshal([]byte(manifest), &m) + assert.NoError(t, err) + resource := &unstructured.Unstructured{Object: m} + objects = append(objects, resource) + } + return objects +} + +func getResourceListFromRuntimeObjs(t *testing.T, c *Client, objs []runtime.Object) ResourceList { + t.Helper() + resourceList := ResourceList{} + for _, obj := range objs { + list, err := c.Build(objBody(obj), false) + assert.NoError(t, err) + resourceList = append(resourceList, list...) + } + return resourceList +} + +func TestStatusWaitForDelete(t *testing.T) { + t.Parallel() + tests := []struct { + name string + manifestsToCreate []string + manifestsToDelete []string + expectErrs []string + }{ + { + name: "wait for pod to be deleted", + manifestsToCreate: []string{podCurrentManifest}, + manifestsToDelete: []string{podCurrentManifest}, + expectErrs: nil, + }, + { + name: "error when not all objects are deleted", + manifestsToCreate: []string{jobCompleteManifest, podCurrentManifest}, + manifestsToDelete: []string{jobCompleteManifest}, + expectErrs: []string{"resource Pod/ns/current-pod still exists. status: Current", "context deadline exceeded"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + timeout := time.Second + timeUntilPodDelete := time.Millisecond * 500 + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + v1.SchemeGroupVersion.WithKind("Pod"), + batchv1.SchemeGroupVersion.WithKind("Job"), + ) + statusWaiter := statusWaiter{ + restMapper: fakeMapper, + client: fakeClient, + } + statusWaiter.SetLogger(slog.Default().Handler()) + objsToCreate := getRuntimeObjFromManifests(t, tt.manifestsToCreate) + for _, objToCreate := range objsToCreate { + u := objToCreate.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + assert.NoError(t, err) + } + objsToDelete := getRuntimeObjFromManifests(t, tt.manifestsToDelete) + for _, objToDelete := range objsToDelete { + u := objToDelete.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + go func(gvr schema.GroupVersionResource, u *unstructured.Unstructured) { + time.Sleep(timeUntilPodDelete) + err := fakeClient.Tracker().Delete(gvr, u.GetNamespace(), u.GetName()) + assert.NoError(t, err) + }(gvr, u) + } + resourceList := getResourceListFromRuntimeObjs(t, c, objsToCreate) + err := statusWaiter.WaitForDelete(resourceList, timeout) + if tt.expectErrs != nil { + require.Error(t, err) + for _, expectedErrStr := range tt.expectErrs { + assert.Contains(t, err.Error(), expectedErrStr) + } + return + } + assert.NoError(t, err) + }) + } +} + +func TestStatusWaitForDeleteNonExistentObject(t *testing.T) { + t.Parallel() + c := newTestClient(t) + timeout := time.Second + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + v1.SchemeGroupVersion.WithKind("Pod"), + ) + statusWaiter := statusWaiter{ + restMapper: fakeMapper, + client: fakeClient, + } + statusWaiter.SetLogger(slog.Default().Handler()) + // Don't create the object to test that the wait for delete works when the object doesn't exist + objManifest := getRuntimeObjFromManifests(t, []string{podCurrentManifest}) + resourceList := getResourceListFromRuntimeObjs(t, c, objManifest) + err := statusWaiter.WaitForDelete(resourceList, timeout) + assert.NoError(t, err) +} + +func TestStatusWait(t *testing.T) { + t.Parallel() + tests := []struct { + name string + objManifests []string + expectErrStrs []string + waitForJobs bool + }{ + { + name: "Job is not complete", + objManifests: []string{jobNoStatusManifest}, + expectErrStrs: []string{"resource Job/qual/test not ready. status: InProgress", "context deadline exceeded"}, + waitForJobs: true, + }, + { + name: "Job is ready but not complete", + objManifests: []string{jobReadyManifest}, + expectErrStrs: nil, + waitForJobs: false, + }, + { + name: "Pod is ready", + objManifests: []string{podCurrentManifest}, + }, + { + name: "one of the pods never becomes ready", + objManifests: []string{podNoStatusManifest, podCurrentManifest}, + expectErrStrs: []string{"resource Pod/ns/in-progress-pod not ready. status: InProgress", "context deadline exceeded"}, + }, + { + name: "paused deployment passes", + objManifests: []string{pausedDeploymentManifest}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + v1.SchemeGroupVersion.WithKind("Pod"), + appsv1.SchemeGroupVersion.WithKind("Deployment"), + batchv1.SchemeGroupVersion.WithKind("Job"), + ) + statusWaiter := statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + } + statusWaiter.SetLogger(slog.Default().Handler()) + objs := getRuntimeObjFromManifests(t, tt.objManifests) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + assert.NoError(t, err) + } + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + err := statusWaiter.Wait(resourceList, time.Second*3) + if tt.expectErrStrs != nil { + require.Error(t, err) + for _, expectedErrStr := range tt.expectErrStrs { + assert.Contains(t, err.Error(), expectedErrStr) + } + return + } + assert.NoError(t, err) + }) + } +} + +func TestWaitForJobComplete(t *testing.T) { + t.Parallel() + tests := []struct { + name string + objManifests []string + expectErrStrs []string + }{ + { + name: "Job is complete", + objManifests: []string{jobCompleteManifest}, + }, + { + name: "Job is not ready", + objManifests: []string{jobNoStatusManifest}, + expectErrStrs: []string{"resource Job/qual/test not ready. status: InProgress", "context deadline exceeded"}, + }, + { + name: "Job is ready but not complete", + objManifests: []string{jobReadyManifest}, + expectErrStrs: []string{"resource Job/default/ready-not-complete not ready. status: InProgress", "context deadline exceeded"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + batchv1.SchemeGroupVersion.WithKind("Job"), + ) + statusWaiter := statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + } + statusWaiter.SetLogger(slog.Default().Handler()) + objs := getRuntimeObjFromManifests(t, tt.objManifests) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + assert.NoError(t, err) + } + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + err := statusWaiter.WaitWithJobs(resourceList, time.Second*3) + if tt.expectErrStrs != nil { + require.Error(t, err) + for _, expectedErrStr := range tt.expectErrStrs { + assert.Contains(t, err.Error(), expectedErrStr) + } + return + } + assert.NoError(t, err) + }) + } +} + +func TestWatchForReady(t *testing.T) { + t.Parallel() + tests := []struct { + name string + objManifests []string + expectErrStrs []string + }{ + { + name: "succeeds if pod and job are complete", + objManifests: []string{jobCompleteManifest, podCompleteManifest}, + }, + { + name: "succeeds when a resource that's not a pod or job is not ready", + objManifests: []string{notReadyDeploymentManifest}, + }, + { + name: "Fails if job is not complete", + objManifests: []string{jobReadyManifest}, + expectErrStrs: []string{"resource Job/default/ready-not-complete not ready. status: InProgress", "context deadline exceeded"}, + }, + { + name: "Fails if pod is not complete", + objManifests: []string{podCurrentManifest}, + expectErrStrs: []string{"resource Pod/ns/current-pod not ready. status: InProgress", "context deadline exceeded"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + v1.SchemeGroupVersion.WithKind("Pod"), + appsv1.SchemeGroupVersion.WithKind("Deployment"), + batchv1.SchemeGroupVersion.WithKind("Job"), + ) + statusWaiter := statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + } + statusWaiter.SetLogger(slog.Default().Handler()) + objs := getRuntimeObjFromManifests(t, tt.objManifests) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + assert.NoError(t, err) + } + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + err := statusWaiter.WatchUntilReady(resourceList, time.Second*3) + if tt.expectErrStrs != nil { + require.Error(t, err) + for _, expectedErrStr := range tt.expectErrStrs { + assert.Contains(t, err.Error(), expectedErrStr) + } + return + } + assert.NoError(t, err) + }) + } +} + +func TestStatusWaitMultipleNamespaces(t *testing.T) { + t.Parallel() + tests := []struct { + name string + objManifests []string + expectErrStrs []string + testFunc func(*statusWaiter, ResourceList, time.Duration) error + }{ + { + name: "pods in multiple namespaces", + objManifests: []string{podNamespace1Manifest, podNamespace2Manifest}, + testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.Wait(rl, timeout) + }, + }, + { + name: "hooks in multiple namespaces", + objManifests: []string{jobNamespace1CompleteManifest, podNamespace2SucceededManifest}, + testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.WatchUntilReady(rl, timeout) + }, + }, + { + name: "error when resource not ready in one namespace", + objManifests: []string{podNamespace1NoStatusManifest, podNamespace2Manifest}, + expectErrStrs: []string{"resource Pod/namespace-1/pod-ns1 not ready. status: InProgress", "context deadline exceeded"}, + testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.Wait(rl, timeout) + }, + }, + { + name: "delete resources in multiple namespaces", + objManifests: []string{podNamespace1Manifest, podNamespace2Manifest}, + testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.WaitForDelete(rl, timeout) + }, + }, + { + name: "cluster-scoped resources work correctly with unrestricted permissions", + objManifests: []string{podNamespace1Manifest, clusterRoleManifest}, + testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.Wait(rl, timeout) + }, + }, + { + name: "namespace-scoped and cluster-scoped resources work together", + objManifests: []string{podNamespace1Manifest, podNamespace2Manifest, clusterRoleManifest}, + testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.Wait(rl, timeout) + }, + }, + { + name: "delete cluster-scoped resources works correctly", + objManifests: []string{podNamespace1Manifest, namespaceManifest}, + testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.WaitForDelete(rl, timeout) + }, + }, + { + name: "watch cluster-scoped resources works correctly", + objManifests: []string{clusterRoleManifest}, + testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.WatchUntilReady(rl, timeout) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + v1.SchemeGroupVersion.WithKind("Pod"), + batchv1.SchemeGroupVersion.WithKind("Job"), + schema.GroupVersion{Group: "rbac.authorization.k8s.io", Version: "v1"}.WithKind("ClusterRole"), + v1.SchemeGroupVersion.WithKind("Namespace"), + ) + sw := statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + } + sw.SetLogger(slog.Default().Handler()) + objs := getRuntimeObjFromManifests(t, tt.objManifests) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + assert.NoError(t, err) + } + + if strings.Contains(tt.name, "delete") { + timeUntilDelete := time.Millisecond * 500 + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + go func(gvr schema.GroupVersionResource, u *unstructured.Unstructured) { + time.Sleep(timeUntilDelete) + err := fakeClient.Tracker().Delete(gvr, u.GetNamespace(), u.GetName()) + assert.NoError(t, err) + }(gvr, u) + } + } + + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + err := tt.testFunc(&sw, resourceList, time.Second*3) + if tt.expectErrStrs != nil { + require.Error(t, err) + for _, expectedErrStr := range tt.expectErrStrs { + assert.Contains(t, err.Error(), expectedErrStr) + } + return + } + assert.NoError(t, err) + }) + } +} + +// restrictedClientConfig holds the configuration for RBAC simulation on a fake dynamic client +type restrictedClientConfig struct { + allowedNamespaces map[string]bool + clusterScopedListAttempted bool +} + +// setupRestrictedClient configures a fake dynamic client to simulate RBAC restrictions +// by using PrependReactor and PrependWatchReactor to intercept list/watch operations. +func setupRestrictedClient(fakeClient *dynamicfake.FakeDynamicClient, allowedNamespaces []string) *restrictedClientConfig { + allowed := make(map[string]bool) + for _, ns := range allowedNamespaces { + allowed[ns] = true + } + config := &restrictedClientConfig{ + allowedNamespaces: allowed, + } + + // Intercept list operations + fakeClient.PrependReactor("list", "*", func(action clienttesting.Action) (bool, runtime.Object, error) { + listAction := action.(clienttesting.ListAction) + ns := listAction.GetNamespace() + if ns == "" { + // Cluster-scoped list + config.clusterScopedListAttempted = true + return true, nil, apierrors.NewForbidden( + action.GetResource().GroupResource(), + "", + fmt.Errorf("user does not have cluster-wide LIST permissions for cluster-scoped resources"), + ) + } + if !config.allowedNamespaces[ns] { + return true, nil, apierrors.NewForbidden( + action.GetResource().GroupResource(), + "", + fmt.Errorf("user does not have LIST permissions in namespace %q", ns), + ) + } + // Fall through to the default handler + return false, nil, nil + }) + + // Intercept watch operations + fakeClient.PrependWatchReactor("*", func(action clienttesting.Action) (bool, watch.Interface, error) { + watchAction := action.(clienttesting.WatchAction) + ns := watchAction.GetNamespace() + if ns == "" { + // Cluster-scoped watch + config.clusterScopedListAttempted = true + return true, nil, apierrors.NewForbidden( + action.GetResource().GroupResource(), + "", + fmt.Errorf("user does not have cluster-wide WATCH permissions for cluster-scoped resources"), + ) + } + if !config.allowedNamespaces[ns] { + return true, nil, apierrors.NewForbidden( + action.GetResource().GroupResource(), + "", + fmt.Errorf("user does not have WATCH permissions in namespace %q", ns), + ) + } + // Fall through to the default handler + return false, nil, nil + }) + + return config +} + +func TestStatusWaitRestrictedRBAC(t *testing.T) { + t.Parallel() + tests := []struct { + name string + objManifests []string + allowedNamespaces []string + expectErrs []error + testFunc func(*statusWaiter, ResourceList, time.Duration) error + }{ + { + name: "pods in multiple namespaces with namespace permissions", + objManifests: []string{podNamespace1Manifest, podNamespace2Manifest}, + allowedNamespaces: []string{"namespace-1", "namespace-2"}, + testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.Wait(rl, timeout) + }, + }, + { + name: "delete pods in multiple namespaces with namespace permissions", + objManifests: []string{podNamespace1Manifest, podNamespace2Manifest}, + allowedNamespaces: []string{"namespace-1", "namespace-2"}, + testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.WaitForDelete(rl, timeout) + }, + }, + { + name: "hooks in multiple namespaces with namespace permissions", + objManifests: []string{jobNamespace1CompleteManifest, podNamespace2SucceededManifest}, + allowedNamespaces: []string{"namespace-1", "namespace-2"}, + testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.WatchUntilReady(rl, timeout) + }, + }, + { + name: "error when cluster-scoped resource included", + objManifests: []string{podNamespace1Manifest, clusterRoleManifest}, + allowedNamespaces: []string{"namespace-1"}, + expectErrs: []error{fmt.Errorf("user does not have cluster-wide LIST permissions for cluster-scoped resources")}, + testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.Wait(rl, timeout) + }, + }, + { + name: "error when deleting cluster-scoped resource", + objManifests: []string{podNamespace1Manifest, namespaceManifest}, + allowedNamespaces: []string{"namespace-1"}, + expectErrs: []error{fmt.Errorf("user does not have cluster-wide LIST permissions for cluster-scoped resources")}, + testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.WaitForDelete(rl, timeout) + }, + }, + { + name: "error when accessing disallowed namespace", + objManifests: []string{podNamespace1Manifest, podNamespace2Manifest}, + allowedNamespaces: []string{"namespace-1"}, + expectErrs: []error{fmt.Errorf("user does not have LIST permissions in namespace %q", "namespace-2")}, + testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.Wait(rl, timeout) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + baseFakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + v1.SchemeGroupVersion.WithKind("Pod"), + batchv1.SchemeGroupVersion.WithKind("Job"), + schema.GroupVersion{Group: "rbac.authorization.k8s.io", Version: "v1"}.WithKind("ClusterRole"), + v1.SchemeGroupVersion.WithKind("Namespace"), + ) + restrictedConfig := setupRestrictedClient(baseFakeClient, tt.allowedNamespaces) + sw := statusWaiter{ + client: baseFakeClient, + restMapper: fakeMapper, + } + sw.SetLogger(slog.Default().Handler()) + objs := getRuntimeObjFromManifests(t, tt.objManifests) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := baseFakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + assert.NoError(t, err) + } + + if strings.Contains(tt.name, "delet") { + timeUntilDelete := time.Millisecond * 500 + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + go func(gvr schema.GroupVersionResource, u *unstructured.Unstructured) { + time.Sleep(timeUntilDelete) + err := baseFakeClient.Tracker().Delete(gvr, u.GetNamespace(), u.GetName()) + assert.NoError(t, err) + }(gvr, u) + } + } + + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + err := tt.testFunc(&sw, resourceList, time.Second*3) + if tt.expectErrs != nil { + require.Error(t, err) + for _, expectedErr := range tt.expectErrs { + assert.Contains(t, err.Error(), expectedErr.Error()) + } + return + } + assert.NoError(t, err) + assert.False(t, restrictedConfig.clusterScopedListAttempted) + }) + } +} + +func TestStatusWaitMixedResources(t *testing.T) { + t.Parallel() + tests := []struct { + name string + objManifests []string + allowedNamespaces []string + expectErrs []error + testFunc func(*statusWaiter, ResourceList, time.Duration) error + }{ + { + name: "wait succeeds with namespace-scoped resources only", + objManifests: []string{podNamespace1Manifest, podNamespace2Manifest}, + allowedNamespaces: []string{"namespace-1", "namespace-2"}, + testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.Wait(rl, timeout) + }, + }, + { + name: "wait fails when cluster-scoped resource included", + objManifests: []string{podNamespace1Manifest, clusterRoleManifest}, + allowedNamespaces: []string{"namespace-1"}, + expectErrs: []error{fmt.Errorf("user does not have cluster-wide LIST permissions for cluster-scoped resources")}, + testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.Wait(rl, timeout) + }, + }, + { + name: "waitForDelete fails when cluster-scoped resource included", + objManifests: []string{podNamespace1Manifest, clusterRoleManifest}, + allowedNamespaces: []string{"namespace-1"}, + expectErrs: []error{fmt.Errorf("user does not have cluster-wide LIST permissions for cluster-scoped resources")}, + testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.WaitForDelete(rl, timeout) + }, + }, + { + name: "wait fails when namespace resource included", + objManifests: []string{podNamespace1Manifest, namespaceManifest}, + allowedNamespaces: []string{"namespace-1"}, + expectErrs: []error{fmt.Errorf("user does not have cluster-wide LIST permissions for cluster-scoped resources")}, + testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.Wait(rl, timeout) + }, + }, + { + name: "error when accessing disallowed namespace", + objManifests: []string{podNamespace1Manifest, podNamespace2Manifest}, + allowedNamespaces: []string{"namespace-1"}, + expectErrs: []error{fmt.Errorf("user does not have LIST permissions in namespace %q", "namespace-2")}, + testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.Wait(rl, timeout) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + baseFakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + v1.SchemeGroupVersion.WithKind("Pod"), + batchv1.SchemeGroupVersion.WithKind("Job"), + schema.GroupVersion{Group: "rbac.authorization.k8s.io", Version: "v1"}.WithKind("ClusterRole"), + v1.SchemeGroupVersion.WithKind("Namespace"), + ) + restrictedConfig := setupRestrictedClient(baseFakeClient, tt.allowedNamespaces) + sw := statusWaiter{ + client: baseFakeClient, + restMapper: fakeMapper, + } + sw.SetLogger(slog.Default().Handler()) + objs := getRuntimeObjFromManifests(t, tt.objManifests) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := baseFakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + assert.NoError(t, err) + } + + if strings.Contains(tt.name, "delet") { + timeUntilDelete := time.Millisecond * 500 + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + go func(gvr schema.GroupVersionResource, u *unstructured.Unstructured) { + time.Sleep(timeUntilDelete) + err := baseFakeClient.Tracker().Delete(gvr, u.GetNamespace(), u.GetName()) + assert.NoError(t, err) + }(gvr, u) + } + } + + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + err := tt.testFunc(&sw, resourceList, time.Second*3) + if tt.expectErrs != nil { + require.Error(t, err) + for _, expectedErr := range tt.expectErrs { + assert.Contains(t, err.Error(), expectedErr.Error()) + } + return + } + assert.NoError(t, err) + assert.False(t, restrictedConfig.clusterScopedListAttempted) + }) + } +} + +// mockStatusReader is a custom status reader for testing that tracks when it's used +// and returns a configurable status for resources it supports. +type mockStatusReader struct { + supportedGK schema.GroupKind + status status.Status + callCount atomic.Int32 +} + +func (m *mockStatusReader) Supports(gk schema.GroupKind) bool { + return gk == m.supportedGK +} + +func (m *mockStatusReader) ReadStatus(_ context.Context, _ engine.ClusterReader, id object.ObjMetadata) (*event.ResourceStatus, error) { + m.callCount.Add(1) + return &event.ResourceStatus{ + Identifier: id, + Status: m.status, + Message: "mock status reader", + }, nil +} + +func (m *mockStatusReader) ReadStatusForObject(_ context.Context, _ engine.ClusterReader, u *unstructured.Unstructured) (*event.ResourceStatus, error) { + m.callCount.Add(1) + id := object.ObjMetadata{ + Namespace: u.GetNamespace(), + Name: u.GetName(), + GroupKind: u.GroupVersionKind().GroupKind(), + } + return &event.ResourceStatus{ + Identifier: id, + Status: m.status, + Message: "mock status reader", + }, nil +} + +func TestStatusWaitWithCustomReaders(t *testing.T) { + t.Parallel() + tests := []struct { + name string + objManifests []string + customReader *mockStatusReader + expectErrStrs []string + }{ + { + name: "custom reader makes pod immediately current", + objManifests: []string{podNoStatusManifest}, + customReader: &mockStatusReader{ + supportedGK: v1.SchemeGroupVersion.WithKind("Pod").GroupKind(), + status: status.CurrentStatus, + }, + }, + { + name: "custom reader returns in-progress status", + objManifests: []string{podCurrentManifest}, + customReader: &mockStatusReader{ + supportedGK: v1.SchemeGroupVersion.WithKind("Pod").GroupKind(), + status: status.InProgressStatus, + }, + expectErrStrs: []string{"resource Pod/ns/current-pod not ready. status: InProgress", "context deadline exceeded"}, + }, + { + name: "custom reader for different resource type is not used", + objManifests: []string{podCurrentManifest}, + customReader: &mockStatusReader{ + supportedGK: batchv1.SchemeGroupVersion.WithKind("Job").GroupKind(), + status: status.InProgressStatus, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + v1.SchemeGroupVersion.WithKind("Pod"), + batchv1.SchemeGroupVersion.WithKind("Job"), + ) + statusWaiter := statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + readers: []engine.StatusReader{tt.customReader}, + } + objs := getRuntimeObjFromManifests(t, tt.objManifests) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + assert.NoError(t, err) + } + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + err := statusWaiter.Wait(resourceList, time.Second*3) + if tt.expectErrStrs != nil { + require.Error(t, err) + for _, expectedErrStr := range tt.expectErrStrs { + assert.Contains(t, err.Error(), expectedErrStr) + } + return + } + assert.NoError(t, err) + }) + } +} + +func TestStatusWaitWithJobsAndCustomReaders(t *testing.T) { + t.Parallel() + tests := []struct { + name string + objManifests []string + customReader *mockStatusReader + expectErrs []error + }{ + { + name: "custom reader makes job immediately current", + objManifests: []string{jobNoStatusManifest}, + customReader: &mockStatusReader{ + supportedGK: batchv1.SchemeGroupVersion.WithKind("Job").GroupKind(), + status: status.CurrentStatus, + }, + expectErrs: nil, + }, + { + name: "custom reader for pod works with WaitWithJobs", + objManifests: []string{podNoStatusManifest}, + customReader: &mockStatusReader{ + supportedGK: v1.SchemeGroupVersion.WithKind("Pod").GroupKind(), + status: status.CurrentStatus, + }, + expectErrs: nil, + }, + { + name: "built-in job reader is still appended after custom readers", + objManifests: []string{jobCompleteManifest}, + customReader: &mockStatusReader{ + supportedGK: v1.SchemeGroupVersion.WithKind("Pod").GroupKind(), + status: status.CurrentStatus, + }, + expectErrs: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + v1.SchemeGroupVersion.WithKind("Pod"), + batchv1.SchemeGroupVersion.WithKind("Job"), + ) + statusWaiter := statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + readers: []engine.StatusReader{tt.customReader}, + } + objs := getRuntimeObjFromManifests(t, tt.objManifests) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + assert.NoError(t, err) + } + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + err := statusWaiter.WaitWithJobs(resourceList, time.Second*3) + if tt.expectErrs != nil { + assert.EqualError(t, err, errors.Join(tt.expectErrs...).Error()) + return + } + assert.NoError(t, err) + }) + } +} + +func TestStatusWaitWithFailedResources(t *testing.T) { + t.Parallel() + tests := []struct { + name string + objManifests []string + customReader *mockStatusReader + expectErrStrs []string + testFunc func(*statusWaiter, ResourceList, time.Duration) error + }{ + { + name: "Wait returns error when resource has failed", + objManifests: []string{podNoStatusManifest}, + customReader: &mockStatusReader{ + supportedGK: v1.SchemeGroupVersion.WithKind("Pod").GroupKind(), + status: status.FailedStatus, + }, + expectErrStrs: []string{"resource Pod/ns/in-progress-pod not ready. status: Failed, message: mock status reader"}, + testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.Wait(rl, timeout) + }, + }, + { + name: "WaitWithJobs returns error when job has failed", + objManifests: []string{jobFailedManifest}, + customReader: nil, // Use the built-in job status reader + expectErrStrs: []string{ + "resource Job/default/failed-job not ready. status: Failed", + }, + testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.WaitWithJobs(rl, timeout) + }, + }, + { + name: "Wait returns errors when multiple resources fail", + objManifests: []string{podNoStatusManifest, podCurrentManifest}, + customReader: &mockStatusReader{ + supportedGK: v1.SchemeGroupVersion.WithKind("Pod").GroupKind(), + status: status.FailedStatus, + }, + // The mock reader will make both pods return FailedStatus + expectErrStrs: []string{ + "resource Pod/ns/in-progress-pod not ready. status: Failed, message: mock status reader", + "resource Pod/ns/current-pod not ready. status: Failed, message: mock status reader", + }, + testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.Wait(rl, timeout) + }, + }, + { + name: "WatchUntilReady returns error when resource has failed", + objManifests: []string{podNoStatusManifest}, + customReader: &mockStatusReader{ + supportedGK: v1.SchemeGroupVersion.WithKind("Pod").GroupKind(), + status: status.FailedStatus, + }, + // WatchUntilReady also waits for CurrentStatus, so failed resources should return error + expectErrStrs: []string{"resource Pod/ns/in-progress-pod not ready. status: Failed, message: mock status reader"}, + testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.WatchUntilReady(rl, timeout) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + v1.SchemeGroupVersion.WithKind("Pod"), + batchv1.SchemeGroupVersion.WithKind("Job"), + ) + var readers []engine.StatusReader + if tt.customReader != nil { + readers = []engine.StatusReader{tt.customReader} + } + sw := statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + readers: readers, + } + objs := getRuntimeObjFromManifests(t, tt.objManifests) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + assert.NoError(t, err) + } + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + err := tt.testFunc(&sw, resourceList, time.Second*3) + if tt.expectErrStrs != nil { + require.Error(t, err) + for _, expectedErrStr := range tt.expectErrStrs { + assert.Contains(t, err.Error(), expectedErrStr) + } + return + } + assert.NoError(t, err) + }) + } +} + +func TestWaitOptionFunctions(t *testing.T) { + t.Parallel() + + t.Run("WithWatchUntilReadyMethodContext sets watchUntilReadyCtx", func(t *testing.T) { + t.Parallel() + type contextKey struct{} + ctx := context.WithValue(context.Background(), contextKey{}, "test") + opts := &waitOptions{} + WithWatchUntilReadyMethodContext(ctx)(opts) + assert.Equal(t, ctx, opts.watchUntilReadyCtx) + }) + + t.Run("WithWaitMethodContext sets waitCtx", func(t *testing.T) { + t.Parallel() + type contextKey struct{} + ctx := context.WithValue(context.Background(), contextKey{}, "test") + opts := &waitOptions{} + WithWaitMethodContext(ctx)(opts) + assert.Equal(t, ctx, opts.waitCtx) + }) + + t.Run("WithWaitWithJobsMethodContext sets waitWithJobsCtx", func(t *testing.T) { + t.Parallel() + type contextKey struct{} + ctx := context.WithValue(context.Background(), contextKey{}, "test") + opts := &waitOptions{} + WithWaitWithJobsMethodContext(ctx)(opts) + assert.Equal(t, ctx, opts.waitWithJobsCtx) + }) + + t.Run("WithWaitForDeleteMethodContext sets waitForDeleteCtx", func(t *testing.T) { + t.Parallel() + type contextKey struct{} + ctx := context.WithValue(context.Background(), contextKey{}, "test") + opts := &waitOptions{} + WithWaitForDeleteMethodContext(ctx)(opts) + assert.Equal(t, ctx, opts.waitForDeleteCtx) + }) +} + +func TestMethodSpecificContextCancellation(t *testing.T) { + t.Parallel() + + t.Run("WatchUntilReady uses method-specific context", func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + v1.SchemeGroupVersion.WithKind("Pod"), + ) + + // Create a cancelled method-specific context + methodCtx, methodCancel := context.WithCancel(context.Background()) + methodCancel() // Cancel immediately + + sw := statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + ctx: context.Background(), // General context is not cancelled + watchUntilReadyCtx: methodCtx, // Method context is cancelled + } + + objs := getRuntimeObjFromManifests(t, []string{podCompleteManifest}) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + require.NoError(t, err) + } + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + + err := sw.WatchUntilReady(resourceList, time.Second*3) + // Should fail due to cancelled method context + require.Error(t, err) + assert.Contains(t, err.Error(), "context canceled") + }) + + t.Run("Wait uses method-specific context", func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + v1.SchemeGroupVersion.WithKind("Pod"), + ) + + // Create a cancelled method-specific context + methodCtx, methodCancel := context.WithCancel(context.Background()) + methodCancel() // Cancel immediately + + sw := statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + ctx: context.Background(), // General context is not cancelled + waitCtx: methodCtx, // Method context is cancelled + } + + objs := getRuntimeObjFromManifests(t, []string{podCurrentManifest}) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + require.NoError(t, err) + } + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + + err := sw.Wait(resourceList, time.Second*3) + // Should fail due to cancelled method context + require.Error(t, err) + assert.Contains(t, err.Error(), "context canceled") + }) + + t.Run("WaitWithJobs uses method-specific context", func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + batchv1.SchemeGroupVersion.WithKind("Job"), + ) + + // Create a cancelled method-specific context + methodCtx, methodCancel := context.WithCancel(context.Background()) + methodCancel() // Cancel immediately + + sw := statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + ctx: context.Background(), // General context is not cancelled + waitWithJobsCtx: methodCtx, // Method context is cancelled + } + + objs := getRuntimeObjFromManifests(t, []string{jobCompleteManifest}) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + require.NoError(t, err) + } + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + + err := sw.WaitWithJobs(resourceList, time.Second*3) + // Should fail due to cancelled method context + require.Error(t, err) + assert.Contains(t, err.Error(), "context canceled") + }) + + t.Run("WaitForDelete uses method-specific context", func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + v1.SchemeGroupVersion.WithKind("Pod"), + ) + + // Create a cancelled method-specific context + methodCtx, methodCancel := context.WithCancel(context.Background()) + methodCancel() // Cancel immediately + + sw := statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + ctx: context.Background(), // General context is not cancelled + waitForDeleteCtx: methodCtx, // Method context is cancelled + } + + objs := getRuntimeObjFromManifests(t, []string{podCurrentManifest}) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + require.NoError(t, err) + } + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + + err := sw.WaitForDelete(resourceList, time.Second*3) + // Should fail due to cancelled method context + require.Error(t, err) + assert.Contains(t, err.Error(), "context canceled") + }) +} + +func TestMethodContextFallbackToGeneralContext(t *testing.T) { + t.Parallel() + + t.Run("WatchUntilReady falls back to general context when method context is nil", func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + v1.SchemeGroupVersion.WithKind("Pod"), + ) + + // Create a cancelled general context + generalCtx, generalCancel := context.WithCancel(context.Background()) + generalCancel() // Cancel immediately + + sw := statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + ctx: generalCtx, // General context is cancelled + watchUntilReadyCtx: nil, // Method context is nil, should fall back + } + + objs := getRuntimeObjFromManifests(t, []string{podCompleteManifest}) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + require.NoError(t, err) + } + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + + err := sw.WatchUntilReady(resourceList, time.Second*3) + // Should fail due to cancelled general context + require.Error(t, err) + assert.Contains(t, err.Error(), "context canceled") + }) + + t.Run("Wait falls back to general context when method context is nil", func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + v1.SchemeGroupVersion.WithKind("Pod"), + ) + + // Create a cancelled general context + generalCtx, generalCancel := context.WithCancel(context.Background()) + generalCancel() // Cancel immediately + + sw := statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + ctx: generalCtx, // General context is cancelled + waitCtx: nil, // Method context is nil, should fall back + } + + objs := getRuntimeObjFromManifests(t, []string{podCurrentManifest}) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + require.NoError(t, err) + } + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + + err := sw.Wait(resourceList, time.Second*3) + // Should fail due to cancelled general context + require.Error(t, err) + assert.Contains(t, err.Error(), "context canceled") + }) + + t.Run("WaitWithJobs falls back to general context when method context is nil", func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + batchv1.SchemeGroupVersion.WithKind("Job"), + ) + + // Create a cancelled general context + generalCtx, generalCancel := context.WithCancel(context.Background()) + generalCancel() // Cancel immediately + + sw := statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + ctx: generalCtx, // General context is cancelled + waitWithJobsCtx: nil, // Method context is nil, should fall back + } + + objs := getRuntimeObjFromManifests(t, []string{jobCompleteManifest}) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + require.NoError(t, err) + } + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + + err := sw.WaitWithJobs(resourceList, time.Second*3) + // Should fail due to cancelled general context + require.Error(t, err) + assert.Contains(t, err.Error(), "context canceled") + }) + + t.Run("WaitForDelete falls back to general context when method context is nil", func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + v1.SchemeGroupVersion.WithKind("Pod"), + ) + + // Create a cancelled general context + generalCtx, generalCancel := context.WithCancel(context.Background()) + generalCancel() // Cancel immediately + + sw := statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + ctx: generalCtx, // General context is cancelled + waitForDeleteCtx: nil, // Method context is nil, should fall back + } + + objs := getRuntimeObjFromManifests(t, []string{podCurrentManifest}) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + require.NoError(t, err) + } + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + + err := sw.WaitForDelete(resourceList, time.Second*3) + // Should fail due to cancelled general context + require.Error(t, err) + assert.Contains(t, err.Error(), "context canceled") + }) +} + +func TestMethodContextOverridesGeneralContext(t *testing.T) { + t.Parallel() + + t.Run("method-specific context overrides general context for WatchUntilReady", func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + v1.SchemeGroupVersion.WithKind("Pod"), + ) + + // General context is cancelled, but method context is not + generalCtx, generalCancel := context.WithCancel(context.Background()) + generalCancel() + + sw := statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + ctx: generalCtx, // Cancelled + watchUntilReadyCtx: context.Background(), // Not cancelled - should be used + } + + objs := getRuntimeObjFromManifests(t, []string{podCompleteManifest}) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + require.NoError(t, err) + } + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + + err := sw.WatchUntilReady(resourceList, time.Second*3) + // Should succeed because method context is used and it's not cancelled + assert.NoError(t, err) + }) + + t.Run("method-specific context overrides general context for Wait", func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + v1.SchemeGroupVersion.WithKind("Pod"), + ) + + // General context is cancelled, but method context is not + generalCtx, generalCancel := context.WithCancel(context.Background()) + generalCancel() + + sw := statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + ctx: generalCtx, // Cancelled + waitCtx: context.Background(), // Not cancelled - should be used + } + + objs := getRuntimeObjFromManifests(t, []string{podCurrentManifest}) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + require.NoError(t, err) + } + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + + err := sw.Wait(resourceList, time.Second*3) + // Should succeed because method context is used and it's not cancelled + assert.NoError(t, err) + }) + + t.Run("method-specific context overrides general context for WaitWithJobs", func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + batchv1.SchemeGroupVersion.WithKind("Job"), + ) + + // General context is cancelled, but method context is not + generalCtx, generalCancel := context.WithCancel(context.Background()) + generalCancel() + + sw := statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + ctx: generalCtx, // Cancelled + waitWithJobsCtx: context.Background(), // Not cancelled - should be used + } + + objs := getRuntimeObjFromManifests(t, []string{jobCompleteManifest}) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + require.NoError(t, err) + } + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + + err := sw.WaitWithJobs(resourceList, time.Second*3) + // Should succeed because method context is used and it's not cancelled + assert.NoError(t, err) + }) + + t.Run("method-specific context overrides general context for WaitForDelete", func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + timeout := time.Second + timeUntilPodDelete := time.Millisecond * 500 + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + v1.SchemeGroupVersion.WithKind("Pod"), + ) + + // General context is cancelled, but method context is not + generalCtx, generalCancel := context.WithCancel(context.Background()) + generalCancel() + + sw := statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + ctx: generalCtx, // Cancelled + waitForDeleteCtx: context.Background(), // Not cancelled - should be used + } + + objs := getRuntimeObjFromManifests(t, []string{podCurrentManifest}) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + require.NoError(t, err) + } + + // Schedule deletion + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + go func(gvr schema.GroupVersionResource, u *unstructured.Unstructured) { + time.Sleep(timeUntilPodDelete) + err := fakeClient.Tracker().Delete(gvr, u.GetNamespace(), u.GetName()) + assert.NoError(t, err) + }(gvr, u) + } + + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + err := sw.WaitForDelete(resourceList, timeout) + // Should succeed because method context is used and it's not cancelled + assert.NoError(t, err) + }) +} + +func TestWatchUntilReadyWithCustomReaders(t *testing.T) { + t.Parallel() + tests := []struct { + name string + objManifests []string + customReader *mockStatusReader + expectErrStrs []string + }{ + { + name: "custom reader makes job immediately current for hooks", + objManifests: []string{jobNoStatusManifest}, + customReader: &mockStatusReader{ + supportedGK: batchv1.SchemeGroupVersion.WithKind("Job").GroupKind(), + status: status.CurrentStatus, + }, + }, + { + name: "custom reader makes pod immediately current for hooks", + objManifests: []string{podCurrentManifest}, + customReader: &mockStatusReader{ + supportedGK: v1.SchemeGroupVersion.WithKind("Pod").GroupKind(), + status: status.CurrentStatus, + }, + }, + { + name: "custom reader takes precedence over built-in pod reader", + objManifests: []string{podCompleteManifest}, + customReader: &mockStatusReader{ + supportedGK: v1.SchemeGroupVersion.WithKind("Pod").GroupKind(), + status: status.InProgressStatus, + }, + expectErrStrs: []string{"resource Pod/ns/good-pod not ready. status: InProgress", "context deadline exceeded"}, + }, + { + name: "custom reader takes precedence over built-in job reader", + objManifests: []string{jobCompleteManifest}, + customReader: &mockStatusReader{ + supportedGK: batchv1.SchemeGroupVersion.WithKind("Job").GroupKind(), + status: status.InProgressStatus, + }, + expectErrStrs: []string{"resource Job/qual/test not ready. status: InProgress", "context deadline exceeded"}, + }, + { + name: "custom reader for different resource type does not affect pods", + objManifests: []string{podCompleteManifest}, + customReader: &mockStatusReader{ + supportedGK: batchv1.SchemeGroupVersion.WithKind("Job").GroupKind(), + status: status.InProgressStatus, + }, + }, + { + name: "built-in readers still work when custom reader does not match", + objManifests: []string{jobCompleteManifest}, + customReader: &mockStatusReader{ + supportedGK: v1.SchemeGroupVersion.WithKind("Pod").GroupKind(), + status: status.InProgressStatus, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + v1.SchemeGroupVersion.WithKind("Pod"), + batchv1.SchemeGroupVersion.WithKind("Job"), + ) + statusWaiter := statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + readers: []engine.StatusReader{tt.customReader}, + } + objs := getRuntimeObjFromManifests(t, tt.objManifests) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + assert.NoError(t, err) + } + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + err := statusWaiter.WatchUntilReady(resourceList, time.Second*3) + if tt.expectErrStrs != nil { + require.Error(t, err) + for _, expectedErrStr := range tt.expectErrStrs { + assert.Contains(t, err.Error(), expectedErrStr) + } + return + } + assert.NoError(t, err) + }) + } +} diff --git a/pkg/helm/pkg/kube/wait.go b/pkg/helm/pkg/kube/wait.go index ecdd3894..747291a9 100644 --- a/pkg/helm/pkg/kube/wait.go +++ b/pkg/helm/pkg/kube/wait.go @@ -14,14 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -package kube // import "helm.sh/helm/v3/pkg/kube" +package kube // import "github.com/werf/nelm/pkg/helm/pkg/kube" import ( "context" "fmt" + "log/slog" + "net/http" "time" - "github.com/pkg/errors" appsv1 "k8s.io/api/apps/v1" appsv1beta1 "k8s.io/api/apps/v1beta1" appsv1beta2 "k8s.io/api/apps/v1beta2" @@ -30,30 +31,66 @@ import ( extensionsv1beta1 "k8s.io/api/extensions/v1beta1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/kubernetes" + cachetools "k8s.io/client-go/tools/cache" + watchtools "k8s.io/client-go/tools/watch" "k8s.io/apimachinery/pkg/util/wait" ) -type waiter struct { - c ReadyChecker - timeout time.Duration - log func(string, ...interface{}) +// legacyWaiter is the legacy implementation of the Waiter interface. This logic was used by default in Helm 3 +// Helm 4 now uses the StatusWaiter implementation instead +type legacyWaiter struct { + c ReadyChecker + kubeClient *kubernetes.Clientset + ctx context.Context +} + +func (hw *legacyWaiter) Wait(resources ResourceList, timeout time.Duration) error { + hw.c = NewReadyChecker(hw.kubeClient, PausedAsReady(true)) + return hw.waitForResources(resources, timeout) +} + +func (hw *legacyWaiter) WaitWithJobs(resources ResourceList, timeout time.Duration) error { + hw.c = NewReadyChecker(hw.kubeClient, PausedAsReady(true), CheckJobs(true)) + return hw.waitForResources(resources, timeout) } // waitForResources polls to get the current status of all pods, PVCs, Services and // Jobs(optional) until all are ready or a timeout is reached -func (w *waiter) waitForResources(created ResourceList) error { - w.log("beginning wait for %d resources with timeout of %v", len(created), w.timeout) +func (hw *legacyWaiter) waitForResources(created ResourceList, timeout time.Duration) error { + slog.Debug("beginning wait for resources", "count", len(created), "timeout", timeout) - ctx, cancel := context.WithTimeout(context.Background(), w.timeout) + ctx, cancel := hw.contextWithTimeout(timeout) defer cancel() + numberOfErrors := make([]int, len(created)) + for i := range numberOfErrors { + numberOfErrors[i] = 0 + } + return wait.PollUntilContextCancel(ctx, 2*time.Second, true, func(ctx context.Context) (bool, error) { - for _, v := range created { - ready, err := w.c.IsReady(ctx, v) - if !ready || err != nil { + waitRetries := 30 + for i, v := range created { + ready, err := hw.c.IsReady(ctx, v) + + if waitRetries > 0 && hw.isRetryableError(err, v) { + numberOfErrors[i]++ + if numberOfErrors[i] > waitRetries { + slog.Debug("max number of retries reached", "resource", v.Name, "retries", numberOfErrors[i]) + return false, err + } + slog.Debug("retrying resource readiness", "resource", v.Name, "currentRetries", numberOfErrors[i]-1, "maxRetries", waitRetries) + return false, nil + } + numberOfErrors[i] = 0 + if !ready { return false, err } } @@ -61,14 +98,43 @@ func (w *waiter) waitForResources(created ResourceList) error { }) } -// waitForDeletedResources polls to check if all the resources are deleted or a timeout is reached -func (w *waiter) waitForDeletedResources(deleted ResourceList) error { - w.log("beginning wait for %d resources to be deleted with timeout of %v", len(deleted), w.timeout) +func (hw *legacyWaiter) isRetryableError(err error, resource *resource.Info) bool { + if err == nil { + return false + } + slog.Debug( + "error received when checking resource status", + slog.String("resource", resource.Name), + slog.Any("error", err), + ) + if ev, ok := err.(*apierrors.StatusError); ok { + statusCode := ev.Status().Code + retryable := hw.isRetryableHTTPStatusCode(statusCode) + slog.Debug( + "status code received", + slog.String("resource", resource.Name), + slog.Int("statusCode", int(statusCode)), + slog.Bool("retryable", retryable), + ) + return retryable + } + slog.Debug("retryable error assumed", "resource", resource.Name) + return true +} + +func (hw *legacyWaiter) isRetryableHTTPStatusCode(httpStatusCode int32) bool { + return httpStatusCode == 0 || httpStatusCode == http.StatusTooManyRequests || (httpStatusCode >= 500 && httpStatusCode != http.StatusNotImplemented) +} + +// WaitForDelete polls to check if all the resources are deleted or a timeout is reached +func (hw *legacyWaiter) WaitForDelete(deleted ResourceList, timeout time.Duration) error { + slog.Debug("beginning wait for resources to be deleted", "count", len(deleted), "timeout", timeout) - ctx, cancel := context.WithTimeout(context.Background(), w.timeout) + startTime := time.Now() + ctx, cancel := hw.contextWithTimeout(timeout) defer cancel() - return wait.PollUntilContextCancel(ctx, 2*time.Second, true, func(ctx context.Context) (bool, error) { + err := wait.PollUntilContextCancel(ctx, 2*time.Second, true, func(_ context.Context) (bool, error) { for _, v := range deleted { err := v.Get() if err == nil || !apierrors.IsNotFound(err) { @@ -77,6 +143,15 @@ func (w *waiter) waitForDeletedResources(deleted ResourceList) error { } return true, nil }) + + elapsed := time.Since(startTime).Round(time.Second) + if err != nil { + slog.Debug("wait for resources failed", slog.Duration("elapsed", elapsed), slog.Any("error", err)) + } else { + slog.Debug("wait for resources succeeded", slog.Duration("elapsed", elapsed)) + } + + return err } // SelectorsForObject returns the pod label selector for a given object @@ -115,7 +190,7 @@ func SelectorsForObject(object runtime.Object) (selector labels.Selector, err er case *batchv1.Job: selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector) case *corev1.Service: - if t.Spec.Selector == nil || len(t.Spec.Selector) == 0 { + if len(t.Spec.Selector) == 0 { return nil, fmt.Errorf("invalid service '%s': Service is defined without a selector", t.Name) } selector = labels.SelectorFromSet(t.Spec.Selector) @@ -124,5 +199,147 @@ func SelectorsForObject(object runtime.Object) (selector labels.Selector, err er return nil, fmt.Errorf("selector for %T not implemented", object) } - return selector, errors.Wrap(err, "invalid label selector") + if err != nil { + return selector, fmt.Errorf("invalid label selector: %w", err) + } + + return selector, nil +} + +func (hw *legacyWaiter) watchTimeout(t time.Duration) func(*resource.Info) error { + return func(info *resource.Info) error { + return hw.watchUntilReady(t, info) + } +} + +// WatchUntilReady watches the resources given and waits until it is ready. +// +// This method is mainly for hook implementations. It watches for a resource to +// hit a particular milestone. The milestone depends on the Kind. +// +// For most kinds, it checks to see if the resource is marked as Added or Modified +// by the Kubernetes event stream. For some kinds, it does more: +// +// - Jobs: A job is marked "Ready" when it has successfully completed. This is +// ascertained by watching the Status fields in a job's output. +// - Pods: A pod is marked "Ready" when it has successfully completed. This is +// ascertained by watching the status.phase field in a pod's output. +// +// Handling for other kinds will be added as necessary. +func (hw *legacyWaiter) WatchUntilReady(resources ResourceList, timeout time.Duration) error { + // For jobs, there's also the option to do poll c.Jobs(namespace).Get(): + // https://github.com/adamreese/kubernetes/blob/master/test/e2e/job.go#L291-L300 + return perform(resources, hw.watchTimeout(timeout)) +} + +func (hw *legacyWaiter) watchUntilReady(timeout time.Duration, info *resource.Info) error { + kind := info.Mapping.GroupVersionKind.Kind + switch kind { + case "Job", "Pod": + default: + return nil + } + + slog.Debug("watching for resource changes", "kind", kind, "resource", info.Name, "timeout", timeout) + + // Use a selector on the name of the resource. This should be unique for the + // given version and kind + selector, err := fields.ParseSelector(fmt.Sprintf("metadata.name=%s", info.Name)) + if err != nil { + return err + } + lw := cachetools.NewListWatchFromClient(info.Client, info.Mapping.Resource.Resource, info.Namespace, selector) + + // What we watch for depends on the Kind. + // - For a Job, we watch for completion. + // - For all else, we watch until Ready. + // In the future, we might want to add some special logic for types + // like Ingress, Volume, etc. + + ctx, cancel := hw.contextWithTimeout(timeout) + defer cancel() + _, err = watchtools.UntilWithSync(ctx, lw, &unstructured.Unstructured{}, nil, func(e watch.Event) (bool, error) { + // Make sure the incoming object is versioned as we use unstructured + // objects when we build manifests + obj := convertWithMapper(e.Object, info.Mapping) + switch e.Type { + case watch.Added, watch.Modified: + // For things like a secret or a config map, this is the best indicator + // we get. We care mostly about jobs, where what we want to see is + // the status go into a good state. For other types, like ReplicaSet + // we don't really do anything to support these as hooks. + slog.Debug("add/modify event received", "resource", info.Name, "eventType", e.Type) + + switch kind { + case "Job": + return hw.waitForJob(obj, info.Name) + case "Pod": + return hw.waitForPodSuccess(obj, info.Name) + } + return true, nil + case watch.Deleted: + slog.Debug("deleted event received", "resource", info.Name) + return true, nil + case watch.Error: + // Handle error and return with an error. + slog.Error("error event received", "resource", info.Name) + return true, fmt.Errorf("failed to deploy %s", info.Name) + default: + return false, nil + } + }) + return err +} + +// waitForJob is a helper that waits for a job to complete. +// +// This operates on an event returned from a watcher. +func (hw *legacyWaiter) waitForJob(obj runtime.Object, name string) (bool, error) { + o, ok := obj.(*batchv1.Job) + if !ok { + return true, fmt.Errorf("expected %s to be a *batch.Job, got %T", name, obj) + } + + for _, c := range o.Status.Conditions { + if c.Type == batchv1.JobComplete && c.Status == "True" { + return true, nil + } else if c.Type == batchv1.JobFailed && c.Status == "True" { + slog.Error("job failed", "job", name, "reason", c.Reason) + return true, fmt.Errorf("job %s failed: %s", name, c.Reason) + } + } + + slog.Debug("job status update", "job", name, "active", o.Status.Active, "failed", o.Status.Failed, "succeeded", o.Status.Succeeded) + return false, nil +} + +// waitForPodSuccess is a helper that waits for a pod to complete. +// +// This operates on an event returned from a watcher. +func (hw *legacyWaiter) waitForPodSuccess(obj runtime.Object, name string) (bool, error) { + o, ok := obj.(*corev1.Pod) + if !ok { + return true, fmt.Errorf("expected %s to be a *v1.Pod, got %T", name, obj) + } + + switch o.Status.Phase { + case corev1.PodSucceeded: + slog.Debug("pod succeeded", "pod", o.Name) + return true, nil + case corev1.PodFailed: + slog.Error("pod failed", "pod", o.Name) + return true, fmt.Errorf("pod %s failed", o.Name) + case corev1.PodPending: + slog.Debug("pod pending", "pod", o.Name) + case corev1.PodRunning: + slog.Debug("pod running", "pod", o.Name) + case corev1.PodUnknown: + slog.Debug("pod unknown", "pod", o.Name) + } + + return false, nil +} + +func (hw *legacyWaiter) contextWithTimeout(timeout time.Duration) (context.Context, context.CancelFunc) { + return contextWithTimeout(hw.ctx, timeout) } diff --git a/pkg/helm/pkg/kube/wait_test.go b/pkg/helm/pkg/kube/wait_test.go new file mode 100644 index 00000000..d96f2c48 --- /dev/null +++ b/pkg/helm/pkg/kube/wait_test.go @@ -0,0 +1,467 @@ +/* +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 kube + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + appsv1 "k8s.io/api/apps/v1" + appsv1beta1 "k8s.io/api/apps/v1beta1" + appsv1beta2 "k8s.io/api/apps/v1beta2" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + extensionsv1beta1 "k8s.io/api/extensions/v1beta1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/resource" +) + +func TestSelectorsForObject(t *testing.T) { + tests := []struct { + name string + object interface{} + expectError bool + errorContains string + expectedLabels map[string]string + }{ + { + name: "appsv1 ReplicaSet", + object: &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "test"}, + }, + }, + }, + expectError: false, + expectedLabels: map[string]string{"app": "test"}, + }, + { + name: "extensionsv1beta1 ReplicaSet", + object: &extensionsv1beta1.ReplicaSet{ + Spec: extensionsv1beta1.ReplicaSetSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "ext-rs"}}, + }, + }, + expectedLabels: map[string]string{"app": "ext-rs"}, + }, + { + name: "appsv1beta2 ReplicaSet", + object: &appsv1beta2.ReplicaSet{ + Spec: appsv1beta2.ReplicaSetSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "beta2-rs"}}, + }, + }, + expectedLabels: map[string]string{"app": "beta2-rs"}, + }, + { + name: "corev1 ReplicationController", + object: &corev1.ReplicationController{ + Spec: corev1.ReplicationControllerSpec{ + Selector: map[string]string{"rc": "test"}, + }, + }, + expectError: false, + expectedLabels: map[string]string{"rc": "test"}, + }, + { + name: "appsv1 StatefulSet", + object: &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "statefulset-v1"}}, + }, + }, + expectedLabels: map[string]string{"app": "statefulset-v1"}, + }, + { + name: "appsv1beta1 StatefulSet", + object: &appsv1beta1.StatefulSet{ + Spec: appsv1beta1.StatefulSetSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "statefulset-beta1"}}, + }, + }, + expectedLabels: map[string]string{"app": "statefulset-beta1"}, + }, + { + name: "appsv1beta2 StatefulSet", + object: &appsv1beta2.StatefulSet{ + Spec: appsv1beta2.StatefulSetSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "statefulset-beta2"}}, + }, + }, + expectedLabels: map[string]string{"app": "statefulset-beta2"}, + }, + { + name: "extensionsv1beta1 DaemonSet", + object: &extensionsv1beta1.DaemonSet{ + Spec: extensionsv1beta1.DaemonSetSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "daemonset-ext-beta1"}}, + }, + }, + expectedLabels: map[string]string{"app": "daemonset-ext-beta1"}, + }, + { + name: "appsv1 DaemonSet", + object: &appsv1.DaemonSet{ + Spec: appsv1.DaemonSetSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "daemonset-v1"}}, + }, + }, + expectedLabels: map[string]string{"app": "daemonset-v1"}, + }, + { + name: "appsv1beta2 DaemonSet", + object: &appsv1beta2.DaemonSet{ + Spec: appsv1beta2.DaemonSetSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "daemonset-beta2"}}, + }, + }, + expectedLabels: map[string]string{"app": "daemonset-beta2"}, + }, + { + name: "extensionsv1beta1 Deployment", + object: &extensionsv1beta1.Deployment{ + Spec: extensionsv1beta1.DeploymentSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "deployment-ext-beta1"}}, + }, + }, + expectedLabels: map[string]string{"app": "deployment-ext-beta1"}, + }, + { + name: "appsv1 Deployment", + object: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "deployment-v1"}}, + }, + }, + expectedLabels: map[string]string{"app": "deployment-v1"}, + }, + { + name: "appsv1beta1 Deployment", + object: &appsv1beta1.Deployment{ + Spec: appsv1beta1.DeploymentSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "deployment-beta1"}}, + }, + }, + expectedLabels: map[string]string{"app": "deployment-beta1"}, + }, + { + name: "appsv1beta2 Deployment", + object: &appsv1beta2.Deployment{ + Spec: appsv1beta2.DeploymentSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "deployment-beta2"}}, + }, + }, + expectedLabels: map[string]string{"app": "deployment-beta2"}, + }, + { + name: "batchv1 Job", + object: &batchv1.Job{ + Spec: batchv1.JobSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"job": "batch-job"}}, + }, + }, + expectedLabels: map[string]string{"job": "batch-job"}, + }, + { + name: "corev1 Service with selector", + object: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "svc"}, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"svc": "yes"}, + }, + }, + expectError: false, + expectedLabels: map[string]string{"svc": "yes"}, + }, + { + name: "corev1 Service without selector", + object: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "svc"}, + Spec: corev1.ServiceSpec{Selector: map[string]string{}}, + }, + expectError: true, + errorContains: "invalid service 'svc': Service is defined without a selector", + }, + { + name: "invalid label selector", + object: &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Selector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "foo", + Operator: "InvalidOperator", + Values: []string{"bar"}, + }, + }, + }, + }, + }, + expectError: true, + errorContains: "invalid label selector:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + selector, err := SelectorsForObject(tt.object.(runtime.Object)) + if tt.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errorContains) + } else { + assert.NoError(t, err) + expected := labels.Set(tt.expectedLabels) + assert.True(t, selector.Matches(expected), "expected selector to match") + } + }) + } +} + +func TestLegacyWaiter_waitForPodSuccess(t *testing.T) { + lw := &legacyWaiter{} + + tests := []struct { + name string + obj runtime.Object + wantDone bool + wantErr bool + errMessage string + }{ + { + name: "pod succeeded", + obj: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "pod1"}, + Status: corev1.PodStatus{Phase: corev1.PodSucceeded}, + }, + wantDone: true, + wantErr: false, + }, + { + name: "pod failed", + obj: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "pod2"}, + Status: corev1.PodStatus{Phase: corev1.PodFailed}, + }, + wantDone: true, + wantErr: true, + errMessage: "pod pod2 failed", + }, + { + name: "pod pending", + obj: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "pod3"}, + Status: corev1.PodStatus{Phase: corev1.PodPending}, + }, + wantDone: false, + wantErr: false, + }, + { + name: "pod running", + obj: &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "pod4"}, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + }, + wantDone: false, + wantErr: false, + }, + { + name: "wrong object type", + obj: &metav1.Status{}, + wantDone: true, + wantErr: true, + errMessage: "expected foo to be a *v1.Pod, got *v1.Status", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + done, err := lw.waitForPodSuccess(tt.obj, "foo") + if tt.wantErr { + if err == nil { + t.Errorf("expected error, got none") + } else if !strings.Contains(err.Error(), tt.errMessage) { + t.Errorf("expected error to contain %q, got %q", tt.errMessage, err.Error()) + } + } else if err != nil { + t.Errorf("unexpected error: %v", err) + } + if done != tt.wantDone { + t.Errorf("got done=%v, want %v", done, tt.wantDone) + } + }) + } +} + +func TestLegacyWaiter_waitForJob(t *testing.T) { + lw := &legacyWaiter{} + + tests := []struct { + name string + obj runtime.Object + wantDone bool + wantErr bool + errMessage string + }{ + { + name: "job complete", + obj: &batchv1.Job{ + Status: batchv1.JobStatus{ + Conditions: []batchv1.JobCondition{ + { + Type: batchv1.JobComplete, + Status: "True", + }, + }, + }, + }, + wantDone: true, + wantErr: false, + }, + { + name: "job failed", + obj: &batchv1.Job{ + Status: batchv1.JobStatus{ + Conditions: []batchv1.JobCondition{ + { + Type: batchv1.JobFailed, + Status: "True", + Reason: "FailedReason", + }, + }, + }, + }, + wantDone: true, + wantErr: true, + errMessage: "job test-job failed: FailedReason", + }, + { + name: "job in progress", + obj: &batchv1.Job{ + Status: batchv1.JobStatus{ + Active: 1, + Failed: 0, + Succeeded: 0, + Conditions: []batchv1.JobCondition{ + { + Type: batchv1.JobComplete, + Status: "False", + }, + { + Type: batchv1.JobFailed, + Status: "False", + }, + }, + }, + }, + wantDone: false, + wantErr: false, + }, + { + name: "wrong object type", + obj: &metav1.Status{}, + wantDone: true, + wantErr: true, + errMessage: "expected test-job to be a *batch.Job, got *v1.Status", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + done, err := lw.waitForJob(tt.obj, "test-job") + if tt.wantErr { + if err == nil { + t.Errorf("expected error, got none") + } else if !strings.Contains(err.Error(), tt.errMessage) { + t.Errorf("expected error to contain %q, got %q", tt.errMessage, err.Error()) + } + } else if err != nil { + t.Errorf("unexpected error: %v", err) + } + + if done != tt.wantDone { + t.Errorf("got done=%v, want %v", done, tt.wantDone) + } + }) + } +} + +func TestLegacyWaiter_isRetryableError(t *testing.T) { + lw := &legacyWaiter{} + + info := &resource.Info{ + Name: "test-resource", + } + + tests := []struct { + name string + err error + wantRetry bool + description string + }{ + { + name: "nil error", + err: nil, + wantRetry: false, + }, + { + name: "status error - 0 code", + err: &apierrors.StatusError{ErrStatus: metav1.Status{Code: 0}}, + wantRetry: true, + }, + { + name: "status error - 429 (TooManyRequests)", + err: &apierrors.StatusError{ErrStatus: metav1.Status{Code: http.StatusTooManyRequests}}, + wantRetry: true, + }, + { + name: "status error - 503", + err: &apierrors.StatusError{ErrStatus: metav1.Status{Code: http.StatusServiceUnavailable}}, + wantRetry: true, + }, + { + name: "status error - 501 (NotImplemented)", + err: &apierrors.StatusError{ErrStatus: metav1.Status{Code: http.StatusNotImplemented}}, + wantRetry: false, + }, + { + name: "status error - 400 (Bad Request)", + err: &apierrors.StatusError{ErrStatus: metav1.Status{Code: http.StatusBadRequest}}, + wantRetry: false, + }, + { + name: "non-status error", + err: fmt.Errorf("some generic error"), + wantRetry: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := lw.isRetryableError(tt.err, info) + if got != tt.wantRetry { + t.Errorf("isRetryableError() = %v, want %v", got, tt.wantRetry) + } + }) + } +} diff --git a/pkg/helm/pkg/postrender/exec.go b/pkg/helm/pkg/postrender/exec.go deleted file mode 100644 index 167e737d..00000000 --- a/pkg/helm/pkg/postrender/exec.go +++ /dev/null @@ -1,109 +0,0 @@ -/* -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 postrender - -import ( - "bytes" - "io" - "os/exec" - "path/filepath" - - "github.com/pkg/errors" -) - -type execRender struct { - binaryPath string - args []string -} - -// NewExec returns a PostRenderer implementation that calls the provided binary. -// It returns an error if the binary cannot be found. If the path does not -// contain any separators, it will search in $PATH, otherwise it will resolve -// any relative paths to a fully qualified path -func NewExec(binaryPath string, args ...string) (PostRenderer, error) { - fullPath, err := getFullPath(binaryPath) - if err != nil { - return nil, err - } - return &execRender{fullPath, args}, nil -} - -// Run the configured binary for the post render -func (p *execRender) Run(renderedManifests *bytes.Buffer) (*bytes.Buffer, error) { - cmd := exec.Command(p.binaryPath, p.args...) - stdin, err := cmd.StdinPipe() - if err != nil { - return nil, err - } - - var postRendered = &bytes.Buffer{} - var stderr = &bytes.Buffer{} - cmd.Stdout = postRendered - cmd.Stderr = stderr - - go func() { - defer stdin.Close() - io.Copy(stdin, renderedManifests) - }() - err = cmd.Run() - if err != nil { - return nil, errors.Wrapf(err, "error while running command %s. error output:\n%s", p.binaryPath, stderr.String()) - } - - return postRendered, nil -} - -// getFullPath returns the full filepath to the binary to execute. If the path -// does not contain any separators, it will search in $PATH, otherwise it will -// resolve any relative paths to a fully qualified path -func getFullPath(binaryPath string) (string, error) { - // NOTE(thomastaylor312): I am leaving this code commented out here. During - // the implementation of post-render, it was brought up that if we are - // relying on plugins, we should actually use the plugin system so it can - // properly handle multiple OSs. This will be a feature add in the future, - // so I left this code for reference. It can be deleted or reused once the - // feature is implemented - - // Manually check the plugin dir first - // if !strings.Contains(binaryPath, string(filepath.Separator)) { - // // First check the plugin dir - // pluginDir := helmpath.DataPath("plugins") // Default location - // // If location for plugins is explicitly set, check there - // if v, ok := os.LookupEnv("HELM_PLUGINS"); ok { - // pluginDir = v - // } - // // The plugins variable can actually contain multiple paths, so loop through those - // for _, p := range filepath.SplitList(pluginDir) { - // _, err := os.Stat(filepath.Join(p, binaryPath)) - // if err != nil && !os.IsNotExist(err) { - // return "", err - // } else if err == nil { - // binaryPath = filepath.Join(p, binaryPath) - // break - // } - // } - // } - - // Now check for the binary using the given path or check if it exists in - // the path and is executable - checkedPath, err := exec.LookPath(binaryPath) - if err != nil { - return "", errors.Wrapf(err, "unable to find binary at %s", binaryPath) - } - - return filepath.Abs(checkedPath) -} diff --git a/pkg/helm/pkg/postrender/exec_test.go b/pkg/helm/pkg/postrender/exec_test.go deleted file mode 100644 index 19a6ec6c..00000000 --- a/pkg/helm/pkg/postrender/exec_test.go +++ /dev/null @@ -1,182 +0,0 @@ -/* -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 postrender - -import ( - "bytes" - "os" - "path/filepath" - "runtime" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const testingScript = `#!/bin/sh -if [ $# -eq 0 ]; then -sed s/FOOTEST/BARTEST/g <&0 -else -sed s/FOOTEST/"$*"/g <&0 -fi -` - -func TestGetFullPath(t *testing.T) { - is := assert.New(t) - t.Run("full path resolves correctly", func(t *testing.T) { - testpath := setupTestingScript(t) - - fullPath, err := getFullPath(testpath) - is.NoError(err) - is.Equal(testpath, fullPath) - }) - - t.Run("relative path resolves correctly", func(t *testing.T) { - testpath := setupTestingScript(t) - - currentDir, err := os.Getwd() - require.NoError(t, err) - relative, err := filepath.Rel(currentDir, testpath) - require.NoError(t, err) - fullPath, err := getFullPath(relative) - is.NoError(err) - is.Equal(testpath, fullPath) - }) - - t.Run("binary in PATH resolves correctly", func(t *testing.T) { - testpath := setupTestingScript(t) - - realPath := os.Getenv("PATH") - os.Setenv("PATH", filepath.Dir(testpath)) - defer func() { - os.Setenv("PATH", realPath) - }() - - fullPath, err := getFullPath(filepath.Base(testpath)) - is.NoError(err) - is.Equal(testpath, fullPath) - }) - - // NOTE(thomastaylor312): See note in getFullPath for more details why this - // is here - - // t.Run("binary in plugin path resolves correctly", func(t *testing.T) { - // testpath, cleanup := setupTestingScript(t) - // defer cleanup() - - // realPath := os.Getenv("HELM_PLUGINS") - // os.Setenv("HELM_PLUGINS", filepath.Dir(testpath)) - // defer func() { - // os.Setenv("HELM_PLUGINS", realPath) - // }() - - // fullPath, err := getFullPath(filepath.Base(testpath)) - // is.NoError(err) - // is.Equal(testpath, fullPath) - // }) - - // t.Run("binary in multiple plugin paths resolves correctly", func(t *testing.T) { - // testpath, cleanup := setupTestingScript(t) - // defer cleanup() - - // realPath := os.Getenv("HELM_PLUGINS") - // os.Setenv("HELM_PLUGINS", filepath.Dir(testpath)+string(os.PathListSeparator)+"/another/dir") - // defer func() { - // os.Setenv("HELM_PLUGINS", realPath) - // }() - - // fullPath, err := getFullPath(filepath.Base(testpath)) - // is.NoError(err) - // is.Equal(testpath, fullPath) - // }) -} - -func TestExecRun(t *testing.T) { - if runtime.GOOS == "windows" { - // the actual Run test uses a basic sed example, so skip this test on windows - t.Skip("skipping on windows") - } - is := assert.New(t) - testpath := setupTestingScript(t) - - renderer, err := NewExec(testpath) - require.NoError(t, err) - - output, err := renderer.Run(bytes.NewBufferString("FOOTEST")) - is.NoError(err) - is.Contains(output.String(), "BARTEST") -} - -func TestNewExecWithOneArgsRun(t *testing.T) { - if runtime.GOOS == "windows" { - // the actual Run test uses a basic sed example, so skip this test on windows - t.Skip("skipping on windows") - } - is := assert.New(t) - testpath := setupTestingScript(t) - - renderer, err := NewExec(testpath, "ARG1") - require.NoError(t, err) - - output, err := renderer.Run(bytes.NewBufferString("FOOTEST")) - is.NoError(err) - is.Contains(output.String(), "ARG1") -} - -func TestNewExecWithTwoArgsRun(t *testing.T) { - if runtime.GOOS == "windows" { - // the actual Run test uses a basic sed example, so skip this test on windows - t.Skip("skipping on windows") - } - is := assert.New(t) - testpath := setupTestingScript(t) - - renderer, err := NewExec(testpath, "ARG1", "ARG2") - require.NoError(t, err) - - output, err := renderer.Run(bytes.NewBufferString("FOOTEST")) - is.NoError(err) - is.Contains(output.String(), "ARG1 ARG2") -} - -func setupTestingScript(t *testing.T) (filepath string) { - t.Helper() - - tempdir := t.TempDir() - - f, err := os.CreateTemp(tempdir, "post-render-test.sh") - if err != nil { - t.Fatalf("unable to create tempfile for testing: %s", err) - } - - _, err = f.WriteString(testingScript) - if err != nil { - t.Fatalf("unable to write tempfile for testing: %s", err) - } - - err = f.Chmod(0755) - if err != nil { - t.Fatalf("unable to make tempfile executable for testing: %s", err) - } - - err = f.Close() - if err != nil { - t.Fatalf("unable to close tempfile after writing: %s", err) - } - - return f.Name() -} diff --git a/pkg/helm/pkg/postrender/postrender.go b/pkg/helm/pkg/postrenderer/postrenderer.go similarity index 64% rename from pkg/helm/pkg/postrender/postrender.go rename to pkg/helm/pkg/postrenderer/postrenderer.go index 3af38429..3970eaf9 100644 --- a/pkg/helm/pkg/postrender/postrender.go +++ b/pkg/helm/pkg/postrenderer/postrenderer.go @@ -1,12 +1,9 @@ /* 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. @@ -14,16 +11,25 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Package postrender contains an interface that can be implemented for custom -// post-renderers and an exec implementation that can be used for arbitrary -// binaries and scripts -package postrender +package postrenderer -import "bytes" +import ( + "bytes" + "fmt" + "github.com/werf/nelm/pkg/helm/pkg/cli" +) + +// PostRenderer is an interface different plugin runtimes +// it may be also be used without the factory for custom post-renderers type PostRenderer interface { // Run expects a single buffer filled with Helm rendered manifests. It // expects the modified results to be returned on a separate buffer or an // error if there was an issue or failure while running the post render step Run(renderedManifests *bytes.Buffer) (modifiedManifests *bytes.Buffer, err error) } + +// NewPostRendererPlugin creates a PostRenderer that uses the plugin's Runtime +func NewPostRendererPlugin(_ *cli.EnvSettings, pluginName string, _ ...string) (PostRenderer, error) { + return nil, fmt.Errorf("plugins are not supported, cannot use post-renderer plugin %q", pluginName) +} diff --git a/pkg/helm/pkg/postrenderer/postrenderer_test.go b/pkg/helm/pkg/postrenderer/postrenderer_test.go new file mode 100644 index 00000000..47355f21 --- /dev/null +++ b/pkg/helm/pkg/postrenderer/postrenderer_test.go @@ -0,0 +1,81 @@ +/* +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 postrenderer + +import ( + "bytes" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/werf/nelm/pkg/helm/pkg/cli" +) + +func TestNewPostRenderPluginRunWithNoOutput(t *testing.T) { + if runtime.GOOS == "windows" { + // the actual Run test uses a basic sed example, so skip this test on windows + t.Skip("skipping on windows") + } + is := assert.New(t) + s := cli.New() + s.PluginsDirectory = "testdata/plugins" + name := "postrenderer-v1" + + renderer, err := NewPostRendererPlugin(s, name, "") + require.NoError(t, err) + + _, err = renderer.Run(bytes.NewBufferString("")) + is.Error(err) +} + +func TestNewPostRenderPluginWithOneArgsRun(t *testing.T) { + if runtime.GOOS == "windows" { + // the actual Run test uses a basic sed example, so skip this test on windows + t.Skip("skipping on windows") + } + is := assert.New(t) + s := cli.New() + s.PluginsDirectory = "testdata/plugins" + name := "postrenderer-v1" + + renderer, err := NewPostRendererPlugin(s, name, "ARG1") + require.NoError(t, err) + + output, err := renderer.Run(bytes.NewBufferString("FOOTEST")) + is.NoError(err) + is.Contains(output.String(), "ARG1") +} + +func TestNewPostRenderPluginWithTwoArgsRun(t *testing.T) { + if runtime.GOOS == "windows" { + // the actual Run test uses a basic sed example, so skip this test on windows + t.Skip("skipping on windows") + } + is := assert.New(t) + s := cli.New() + s.PluginsDirectory = "testdata/plugins" + name := "postrenderer-v1" + + renderer, err := NewPostRendererPlugin(s, name, "ARG1", "ARG2") + require.NoError(t, err) + + output, err := renderer.Run(bytes.NewBufferString("FOOTEST")) + is.NoError(err) + is.Contains(output.String(), "ARG1 ARG2") +} diff --git a/pkg/helm/pkg/postrenderer/testdata/plugins/postrenderer-v1/plugin.yaml b/pkg/helm/pkg/postrenderer/testdata/plugins/postrenderer-v1/plugin.yaml new file mode 100644 index 00000000..423a5191 --- /dev/null +++ b/pkg/helm/pkg/postrenderer/testdata/plugins/postrenderer-v1/plugin.yaml @@ -0,0 +1,8 @@ +name: "postrenderer-v1" +version: "1.2.3" +type: postrenderer/v1 +apiVersion: v1 +runtime: subprocess +runtimeConfig: + platformCommand: + - command: "${HELM_PLUGIN_DIR}/sed-test.sh" diff --git a/pkg/helm/pkg/postrenderer/testdata/plugins/postrenderer-v1/sed-test.sh b/pkg/helm/pkg/postrenderer/testdata/plugins/postrenderer-v1/sed-test.sh new file mode 100755 index 00000000..a016e398 --- /dev/null +++ b/pkg/helm/pkg/postrenderer/testdata/plugins/postrenderer-v1/sed-test.sh @@ -0,0 +1,6 @@ +#!/bin/sh +if [ $# -eq 0 ]; then + sed s/FOOTEST/BARTEST/g <&0 +else + sed s/FOOTEST/"$*"/g <&0 +fi diff --git a/pkg/helm/pkg/provenance/doc.go b/pkg/helm/pkg/provenance/doc.go index 0c7ae061..f1c19ae2 100644 --- a/pkg/helm/pkg/provenance/doc.go +++ b/pkg/helm/pkg/provenance/doc.go @@ -14,15 +14,15 @@ limitations under the License. */ /* -Package provenance provides tools for establishing the authenticity of a chart. +Package provenance provides tools for establishing the authenticity of packages. In Helm, provenance is established via several factors. The primary factor is the -cryptographic signature of a chart. Chart authors may sign charts, which in turn -provide the necessary metadata to ensure the integrity of the chart file, the -Chart.yaml, and the referenced Docker images. +cryptographic signature of a package. Package authors may sign packages, which in turn +provide the necessary metadata to ensure the integrity of the package file, the +metadata, and the referenced Docker images. A provenance file is clear-signed. This provides cryptographic verification that -a particular block of information (Chart.yaml, archive file, images) have not +a particular block of information (metadata, archive file, images) have not been tampered with or altered. To learn more, read the GnuPG documentation on clear signatures: https://www.gnupg.org/gph/en/manual/x135.html @@ -35,4 +35,4 @@ and using `gpg --verify`, `keybase pgp verify`, or similar: gpg: Signature made Mon Jul 25 17:23:44 2016 MDT using RSA key ID 1FC18762 gpg: Good signature from "Helm Testing (This key should only be used for testing. DO NOT TRUST.) " [ultimate] */ -package provenance // import "helm.sh/helm/v3/pkg/provenance" +package provenance // import "github.com/werf/nelm/pkg/helm/pkg/provenance" diff --git a/pkg/helm/pkg/provenance/sign.go b/pkg/helm/pkg/provenance/sign.go index c3382dbd..57af1ad4 100644 --- a/pkg/helm/pkg/provenance/sign.go +++ b/pkg/helm/pkg/provenance/sign.go @@ -19,20 +19,16 @@ import ( "bytes" "crypto" "encoding/hex" + "errors" + "fmt" "io" "os" - "path/filepath" "strings" - "github.com/pkg/errors" - "golang.org/x/crypto/openpgp" // nolint - "golang.org/x/crypto/openpgp/clearsign" // nolint - "golang.org/x/crypto/openpgp/packet" // nolint + "github.com/ProtonMail/go-crypto/openpgp" //nolint + "github.com/ProtonMail/go-crypto/openpgp/clearsign" //nolint + "github.com/ProtonMail/go-crypto/openpgp/packet" //nolint "sigs.k8s.io/yaml" - - hapi "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chart/loader" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" ) var defaultPGPConfig = packet.Config{ @@ -58,7 +54,7 @@ type SumCollection struct { // Verification contains information about a verification operation. type Verification struct { - // SignedBy contains the entity that signed a chart. + // SignedBy contains the entity that signed a package. SignedBy *openpgp.Entity // FileHash is the hash, prepended with the scheme, for the file that was verified. FileHash string @@ -68,11 +64,11 @@ type Verification struct { // Signatory signs things. // -// Signatories can be constructed from a PGP private key file using NewFromFiles +// Signatories can be constructed from a PGP private key file using NewFromFiles, // or they can be constructed manually by setting the Entity to a valid // PGP entity. // -// The same Signatory can be used to sign or validate multiple charts. +// The same Signatory can be used to sign or validate multiple packages. type Signatory struct { // The signatory for this instance of Helm. This is used for signing. Entity *openpgp.Entity @@ -144,7 +140,7 @@ func NewFromKeyring(keyringfile, id string) (*Signatory, error) { } } if vague { - return s, errors.Errorf("more than one key contain the id %q", id) + return s, fmt.Errorf("more than one key contain the id %q", id) } s.Entity = candidate @@ -197,28 +193,20 @@ func (s *Signatory) DecryptKey(fn PassphraseFetcher) error { return s.Entity.PrivateKey.Decrypt(p) } -// ClearSign signs a chart with the given key. -// -// This takes the path to a chart archive file and a key, and it returns a clear signature. +// ClearSign signs package data with the given key and pre-marshalled metadata. // -// The Signatory must have a valid Entity.PrivateKey for this to work. If it does -// not, an error will be returned. -func (s *Signatory) ClearSign(chartpath string, opts helmopts.HelmOptions) (string, error) { +// This is the core signing method that works with data in memory. +// The Signatory must have a valid Entity.PrivateKey for this to work. +func (s *Signatory) ClearSign(archiveData []byte, filename string, metadataBytes []byte) (string, error) { if s.Entity == nil { return "", errors.New("private key not found") } else if s.Entity.PrivateKey == nil { return "", errors.New("provided key is not a private key. Try providing a keyring with secret keys") } - if fi, err := os.Stat(chartpath); err != nil { - return "", err - } else if fi.IsDir() { - return "", errors.New("cannot sign a directory") - } - out := bytes.NewBuffer(nil) - b, err := messageBlock(chartpath, opts) + b, err := messageBlock(archiveData, filename, metadataBytes) if err != nil { return "", err } @@ -237,147 +225,125 @@ func (s *Signatory) ClearSign(chartpath string, opts helmopts.HelmOptions) (stri // In other words, if we call Close here, there's a risk that there's an attempt to use the // private key to sign garbage data (since we know that io.Copy failed, `w` won't contain // anything useful). - return "", errors.Wrap(err, "failed to write to clearsign encoder") + return "", fmt.Errorf("failed to write to clearsign encoder: %w", err) } err = w.Close() if err != nil { - return "", errors.Wrap(err, "failed to either sign or armor message block") + return "", fmt.Errorf("failed to either sign or armor message block: %w", err) } return out.String(), nil } -// Verify checks a signature and verifies that it is legit for a chart. -func (s *Signatory) Verify(chartpath, sigpath string) (*Verification, error) { +// Verify checks a signature and verifies that it is legit for package data. +// This is the core verification method that works with data in memory. +func (s *Signatory) Verify(archiveData, provData []byte, filename string) (*Verification, error) { ver := &Verification{} - for _, fname := range []string{chartpath, sigpath} { - if fi, err := os.Stat(fname); err != nil { - return ver, err - } else if fi.IsDir() { - return ver, errors.Errorf("%s cannot be a directory", fname) - } - } // First verify the signature - sig, err := s.decodeSignature(sigpath) - if err != nil { - return ver, errors.Wrap(err, "failed to decode signature") + block, _ := clearsign.Decode(provData) + if block == nil { + return ver, errors.New("signature block not found") } - by, err := s.verifySignature(sig) + by, err := s.verifySignature(block) if err != nil { return ver, err } ver.SignedBy = by - // Second, verify the hash of the tarball. - sum, err := DigestFile(chartpath) + // Second, verify the hash of the data. + sum, err := Digest(bytes.NewBuffer(archiveData)) if err != nil { return ver, err } - _, sums, err := parseMessageBlock(sig.Plaintext) + sums, err := parseMessageBlock(block.Plaintext) if err != nil { return ver, err } sum = "sha256:" + sum - basename := filepath.Base(chartpath) - if sha, ok := sums.Files[basename]; !ok { - return ver, errors.Errorf("provenance does not contain a SHA for a file named %q", basename) + if sha, ok := sums.Files[filename]; !ok { + return ver, fmt.Errorf("provenance does not contain a SHA for a file named %q", filename) } else if sha != sum { - return ver, errors.Errorf("sha256 sum does not match for %s: %q != %q", basename, sha, sum) + return ver, fmt.Errorf("sha256 sum does not match for %s: %q != %q", filename, sha, sum) } ver.FileHash = sum - ver.FileName = basename + ver.FileName = filename // TODO: when image signing is added, verify that here. return ver, nil } -func (s *Signatory) decodeSignature(filename string) (*clearsign.Block, error) { - data, err := os.ReadFile(filename) - if err != nil { - return nil, err - } - - block, _ := clearsign.Decode(data) - if block == nil { - // There was no sig in the file. - return nil, errors.New("signature block not found") - } - - return block, nil -} - // verifySignature verifies that the given block is validly signed, and returns the signer. func (s *Signatory) verifySignature(block *clearsign.Block) (*openpgp.Entity, error) { return openpgp.CheckDetachedSignature( s.KeyRing, - bytes.NewBuffer(block.Bytes), + bytes.NewReader(block.Bytes), block.ArmoredSignature.Body, + &defaultPGPConfig, ) } -func messageBlock(chartpath string, opts helmopts.HelmOptions) (*bytes.Buffer, error) { - var b *bytes.Buffer - // Checksum the archive - chash, err := DigestFile(chartpath) +// messageBlock creates a message block from archive data and pre-marshalled metadata +func messageBlock(archiveData []byte, filename string, metadataBytes []byte) (*bytes.Buffer, error) { + // Checksum the archive data + chash, err := Digest(bytes.NewBuffer(archiveData)) if err != nil { - return b, err + return nil, err } - base := filepath.Base(chartpath) sums := &SumCollection{ Files: map[string]string{ - base: "sha256:" + chash, + filename: "sha256:" + chash, }, } - // Load the archive into memory. - chart, err := loader.LoadFile(chartpath, opts) - if err != nil { - return b, err - } - - // Buffer a hash + checksums YAML file - data, err := yaml.Marshal(chart.Metadata) - if err != nil { - return b, err - } - + // Buffer the metadata + checksums YAML file // FIXME: YAML uses ---\n as a file start indicator, but this is not legal in a PGP // clearsign block. So we use ...\n, which is the YAML document end marker. // http://yaml.org/spec/1.2/spec.html#id2800168 - b = bytes.NewBuffer(data) + b := bytes.NewBuffer(metadataBytes) b.WriteString("\n...\n") - data, err = yaml.Marshal(sums) + data, err := yaml.Marshal(sums) if err != nil { - return b, err + return nil, err } b.Write(data) return b, nil } -// parseMessageBlock -func parseMessageBlock(data []byte) (*hapi.Metadata, *SumCollection, error) { - // This sucks. +// parseMessageBlock parses a message block and returns only checksums (metadata ignored like upstream) +func parseMessageBlock(data []byte) (*SumCollection, error) { + sc := &SumCollection{} + + // We ignore metadata, just like upstream - only need checksums for verification + if err := ParseMessageBlock(data, nil, sc); err != nil { + return sc, err + } + return sc, nil +} + +// ParseMessageBlock parses a message block containing metadata and checksums. +// +// This is the generic version that can work with any metadata type. +// The metadata parameter should be a pointer to a struct that can be unmarshaled from YAML. +func ParseMessageBlock(data []byte, metadata interface{}, sums *SumCollection) error { parts := bytes.Split(data, []byte("\n...\n")) if len(parts) < 2 { - return nil, nil, errors.New("message block must have at least two parts") + return errors.New("message block must have at least two parts") } - md := &hapi.Metadata{} - sc := &SumCollection{} - - if err := yaml.Unmarshal(parts[0], md); err != nil { - return md, sc, err + if metadata != nil { + if err := yaml.Unmarshal(parts[0], metadata); err != nil { + return err + } } - err := yaml.Unmarshal(parts[1], sc) - return md, sc, err + return yaml.Unmarshal(parts[1], sums) } // loadKey loads a GPG key found at a particular path. @@ -406,7 +372,7 @@ func loadKeyRing(ringpath string) (openpgp.EntityList, error) { // It takes the path to the archive file, and returns a string representation of // the SHA256 sum. // -// The intended use of this function is to generate a sum of a chart TGZ file. +// This function can be used to generate a sum of any package archive file. func DigestFile(filename string) (string, error) { f, err := os.Open(filename) if err != nil { diff --git a/pkg/helm/pkg/provenance/sign_test.go b/pkg/helm/pkg/provenance/sign_test.go index 17f727ea..2fbf3b88 100644 --- a/pkg/helm/pkg/provenance/sign_test.go +++ b/pkg/helm/pkg/provenance/sign_test.go @@ -16,6 +16,7 @@ limitations under the License. package provenance import ( + "context" "crypto" "fmt" "io" @@ -24,7 +25,13 @@ import ( "strings" "testing" - pgperrors "golang.org/x/crypto/openpgp/errors" //nolint + pgperrors "github.com/ProtonMail/go-crypto/openpgp/errors" //nolint + "github.com/ProtonMail/go-crypto/openpgp/packet" //nolint + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" + + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/loader" ) const ( @@ -34,7 +41,7 @@ const ( // phrase. Use `gpg --export-secret-keys helm-test` to export the secret. testKeyfile = "testdata/helm-test-key.secret" - // testPasswordKeyFile is a keyfile with a password. + // testPasswordKeyfile is a keyfile with a password. testPasswordKeyfile = "testdata/helm-password-key.secret" // testPubfile is the public key file. @@ -56,6 +63,9 @@ const ( // testTamperedSigBlock is a tampered copy of msgblock.yaml.asc testTamperedSigBlock = "testdata/msgblock.yaml.tampered" + // testMixedKeyring points to a keyring containing RSA and ed25519 keys. + testMixedKeyring = "testdata/helm-mixed-keyring.pub" + // testSumfile points to a SHA256 sum generated by an external tool. // We always want to validate against an external tool's representation to // verify that we haven't done something stupid. This file was generated @@ -75,8 +85,33 @@ files: hashtest-1.2.3.tgz: sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888 ` +// loadChartMetadataForSigning is a test helper that loads chart metadata and marshals it to YAML bytes +func loadChartMetadataForSigning(t *testing.T, chartPath string) []byte { + t.Helper() + + chart, err := loader.LoadFile(context.Background(), chartPath) + if err != nil { + t.Fatal(err) + } + + metadataBytes, err := yaml.Marshal(chart.Metadata) + if err != nil { + t.Fatal(err) + } + + return metadataBytes +} + func TestMessageBlock(t *testing.T) { - out, err := messageBlock(testChartfile) + metadataBytes := loadChartMetadataForSigning(t, testChartfile) + + // Read the chart file data + archiveData, err := os.ReadFile(testChartfile) + if err != nil { + t.Fatal(err) + } + + out, err := messageBlock(archiveData, filepath.Base(testChartfile), metadataBytes) if err != nil { t.Fatal(err) } @@ -88,14 +123,12 @@ func TestMessageBlock(t *testing.T) { } func TestParseMessageBlock(t *testing.T) { - md, sc, err := parseMessageBlock([]byte(testMessageBlock)) + sc, err := parseMessageBlock([]byte(testMessageBlock)) if err != nil { t.Fatal(err) } - if md.Name != "hashtest" { - t.Errorf("Expected name %q, got %q", "hashtest", md.Name) - } + // parseMessageBlock only returns checksums, not metadata (like upstream) if lsc := len(sc.Files); lsc != 1 { t.Errorf("Expected 1 file, got %d", lsc) @@ -196,7 +229,7 @@ func TestDecryptKey(t *testing.T) { } // We give this a simple callback that returns the password. - if err := k.DecryptKey(func(s string) ([]byte, error) { + if err := k.DecryptKey(func(_ string) ([]byte, error) { return []byte("secret"), nil }); err != nil { t.Fatal(err) @@ -208,7 +241,7 @@ func TestDecryptKey(t *testing.T) { t.Fatal(err) } // Now we give it a bogus password. - if err := k.DecryptKey(func(s string) ([]byte, error) { + if err := k.DecryptKey(func(_ string) ([]byte, error) { return []byte("secrets_and_lies"), nil }); err == nil { t.Fatal("Expected an error when giving a bogus passphrase") @@ -221,7 +254,15 @@ func TestClearSign(t *testing.T) { t.Fatal(err) } - sig, err := signer.ClearSign(testChartfile) + metadataBytes := loadChartMetadataForSigning(t, testChartfile) + + // Read the chart file data + archiveData, err := os.ReadFile(testChartfile) + if err != nil { + t.Fatal(err) + } + + sig, err := signer.ClearSign(archiveData, filepath.Base(testChartfile), metadataBytes) if err != nil { t.Fatal(err) } @@ -232,6 +273,56 @@ func TestClearSign(t *testing.T) { } } +func TestMixedKeyringRSASigningAndVerification(t *testing.T) { + signer, err := NewFromFiles(testKeyfile, testMixedKeyring) + require.NoError(t, err) + + require.NotEmpty(t, signer.KeyRing, "expected signer keyring to be loaded") + + hasEdDSA := false + for _, entity := range signer.KeyRing { + if entity.PrimaryKey != nil && entity.PrimaryKey.PubKeyAlgo == packet.PubKeyAlgoEdDSA { + hasEdDSA = true + break + } + + for _, subkey := range entity.Subkeys { + if subkey.PublicKey != nil && subkey.PublicKey.PubKeyAlgo == packet.PubKeyAlgoEdDSA { + hasEdDSA = true + break + } + } + + if hasEdDSA { + break + } + } + + assert.True(t, hasEdDSA, "expected %s to include an Ed25519 public key", testMixedKeyring) + + require.NotNil(t, signer.Entity, "expected signer entity to be loaded") + require.NotNil(t, signer.Entity.PrivateKey, "expected signer private key to be loaded") + assert.Equal(t, packet.PubKeyAlgoRSA, signer.Entity.PrivateKey.PubKeyAlgo, "expected RSA key") + + metadataBytes := loadChartMetadataForSigning(t, testChartfile) + + archiveData, err := os.ReadFile(testChartfile) + require.NoError(t, err) + + sig, err := signer.ClearSign(archiveData, filepath.Base(testChartfile), metadataBytes) + require.NoError(t, err, "failed to sign chart") + + verification, err := signer.Verify(archiveData, []byte(sig), filepath.Base(testChartfile)) + require.NoError(t, err, "failed to verify chart signature") + + require.NotNil(t, verification.SignedBy, "expected verification to include signer") + require.NotNil(t, verification.SignedBy.PrimaryKey, "expected verification to include signer primary key") + assert.Equal(t, packet.PubKeyAlgoRSA, verification.SignedBy.PrimaryKey.PubKeyAlgo, "expected verification to report RSA key") + + _, ok := verification.SignedBy.Identities[testKeyName] + assert.True(t, ok, "expected verification to be signed by %q", testKeyName) +} + // failSigner always fails to sign and returns an error type failSigner struct{} @@ -252,7 +343,15 @@ func TestClearSignError(t *testing.T) { // ensure that signing always fails signer.Entity.PrivateKey.PrivateKey = failSigner{} - sig, err := signer.ClearSign(testChartfile) + metadataBytes := loadChartMetadataForSigning(t, testChartfile) + + // Read the chart file data + archiveData, err := os.ReadFile(testChartfile) + if err != nil { + t.Fatal(err) + } + + sig, err := signer.ClearSign(archiveData, filepath.Base(testChartfile), metadataBytes) if err == nil { t.Fatal("didn't get an error from ClearSign but expected one") } @@ -262,54 +361,25 @@ func TestClearSignError(t *testing.T) { } } -func TestDecodeSignature(t *testing.T) { - // Unlike other tests, this does a round-trip test, ensuring that a signature - // generated by the library can also be verified by the library. - +func TestVerify(t *testing.T) { signer, err := NewFromFiles(testKeyfile, testPubfile) if err != nil { t.Fatal(err) } - sig, err := signer.ClearSign(testChartfile) + // Read the chart file data + archiveData, err := os.ReadFile(testChartfile) if err != nil { t.Fatal(err) } - f, err := os.CreateTemp("", "helm-test-sig-") - if err != nil { - t.Fatal(err) - } - - tname := f.Name() - defer func() { - os.Remove(tname) - }() - f.WriteString(sig) - f.Close() - - sig2, err := signer.decodeSignature(tname) + // Read the signature file data + sigData, err := os.ReadFile(testSigBlock) if err != nil { t.Fatal(err) } - by, err := signer.verifySignature(sig2) - if err != nil { - t.Fatal(err) - } - - if _, ok := by.Identities[testKeyName]; !ok { - t.Errorf("Expected identity %q", testKeyName) - } -} - -func TestVerify(t *testing.T) { - signer, err := NewFromFiles(testKeyfile, testPubfile) - if err != nil { - t.Fatal(err) - } - - if ver, err := signer.Verify(testChartfile, testSigBlock); err != nil { + if ver, err := signer.Verify(archiveData, sigData, filepath.Base(testChartfile)); err != nil { t.Errorf("Failed to pass verify. Err: %s", err) } else if len(ver.FileHash) == 0 { t.Error("Verification is missing hash.") @@ -319,7 +389,13 @@ func TestVerify(t *testing.T) { t.Errorf("FileName is unexpectedly %q", ver.FileName) } - if _, err = signer.Verify(testChartfile, testTamperedSigBlock); err == nil { + // Read the tampered signature file data + tamperedSigData, err := os.ReadFile(testTamperedSigBlock) + if err != nil { + t.Fatal(err) + } + + if _, err = signer.Verify(archiveData, tamperedSigData, filepath.Base(testChartfile)); err == nil { t.Errorf("Expected %s to fail.", testTamperedSigBlock) } diff --git a/pkg/helm/pkg/provenance/testdata/helm-mixed-keyring.pub b/pkg/helm/pkg/provenance/testdata/helm-mixed-keyring.pub new file mode 100644 index 0000000000000000000000000000000000000000..7985bd20f1b2390915c75aff4a24c61c77145fba GIT binary patch literal 1493 zcmYk6c{tQ*9Ke6SF~-c0NopvTab@Hxj3dP)MH9Xny(O5%q&qu zVL#4!o6%*gtn{TBmF0={cdAn0jK>Sr4{lEy7G9I0zFjMQNx$`r^J7Ab33WuKdUQH# zs>lCDLoyt1W*(qrBD=Zu^5ynSdAB|H!@L#Vv@G^Dk2(+^&NNg%GM8R8<<;u6IhD&4 zvfYzlpVR*{FAbFAOwQ);<%J1H1r7hlEN@lav+UD!~|ZVgkii$7#fvC#P|eW#RPq=RKplsW2~$R7=oSsd4ie} z<^=i69Mx}G{LVv-N;XIWb)cdk5|H7C>|7lp!N)I%fD7>PA3!4Dg8XO*9|%CCK!{ir zfJE$#({Im{?o9AXGJyk4YqP^s95Igm_1w%KBq}@TW}C7P8P}c39JZ^3y<}Lucj|~s zq+bH7kK5MjOvs>$JGp2V;a7VUi#>%G-NJfx6%Ph5Ki77{SZk$5swd~+sxWWMGe=bu zmKg&za2MiwpE@kUS*dPCk&diBck+4T0~TaK?)rJSf?7o}TRge1wINiRJa1~fmX1t} zxk+vgT;;FhPUze^Fd~pKWZhOzv7c(mRk564F~>XaG}vLNmJfa$AB$9JYF?CYFlpQ} zZa!ai(vG`gv!MiIOt=SE6l6UX@2srKjde%z{uKE^rOsS|bYD(cXX+DeIoR#=qS^5^Emos61*SEV-0-J;N@NXC$ zXY(r8wP<59GP|HAfznX%FC-zplO*(iV#aWAAaNp9M=yCG9eB^;^e zn7mZi6OcSSJosqYKZS>d$(!~bV$cYW&Wf_jCWoen>u5`Jd%Jpyh&Udu&05&feRK9$ zFZyXP)T_{!$x|GMu2QD@>u2(^(+`eJP%ir&J#?9zso zV)#Ks^A-PR3ha-jB^R$HkHpNNI*L+QaLt^n(eX;UB{iZ@S$sp=`&jKpfa&-24_-Vs ziJ}>!2|vv_YJ5aKx{;)BW7U)OVI(#=&a@~1uYd}L_3w2cz8PL6-HLm6Aw%mGA*}O> zM}3fO$?3a1hc3A?z|Me{LGOTMEC=OjLdnku$Iu967zhQqW+)I8z`GlL!d9?-Vh?F9 zBs8?3+y7e1YpK$Z#TtDI7Mq^vx!;7{_1}c3fx{is+x-W=sZ9DB`d8(|e5fcI37kU9 zKrP#7mI_Wnl!UYyk@a};KDK&4ksq?Nd`EBue|bUFuI3PkECjY|It03-Iz&p~o9W-X zn$WjEHt5U^W$cO!`t~&PZY)|P96ki0GIa68*0yXCKH?nj>gNd_*5Q!n+ V@cIdPI!rgbCMSw~vn_HR_y>WtmqP#m literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/provenance/testdata/msgblock.yaml b/pkg/helm/pkg/provenance/testdata/msgblock.yaml index 450a2709..c16293ff 100644 --- a/pkg/helm/pkg/provenance/testdata/msgblock.yaml +++ b/pkg/helm/pkg/provenance/testdata/msgblock.yaml @@ -3,6 +3,6 @@ description: Test chart versioning name: hashtest version: 1.2.3 ---- +... files: hashtest-1.2.3.tgz: sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888 diff --git a/pkg/helm/pkg/pusher/ocipusher.go b/pkg/helm/pkg/pusher/ocipusher.go index 03ebbf27..67074026 100644 --- a/pkg/helm/pkg/pusher/ocipusher.go +++ b/pkg/helm/pkg/pusher/ocipusher.go @@ -16,7 +16,10 @@ limitations under the License. package pusher import ( + "context" + "errors" "fmt" + "io/fs" "net" "net/http" "os" @@ -24,12 +27,9 @@ import ( "strings" "time" - "github.com/pkg/errors" - - "github.com/werf/nelm/pkg/helm/internal/tlsutil" - "github.com/werf/nelm/pkg/helm/pkg/chart/loader" + "github.com/werf/nelm/pkg/helm/intern/tlsutil" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/loader" "github.com/werf/nelm/pkg/helm/pkg/registry" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" ) // OCIPusher is the default OCI backend handler @@ -38,18 +38,18 @@ type OCIPusher struct { } // Push performs a Push from repo.Pusher. -func (pusher *OCIPusher) Push(chartRef, href string, opts helmopts.HelmOptions, options ...Option) error { +func (pusher *OCIPusher) Push(chartRef, href string, options ...Option) error { for _, opt := range options { opt(&pusher.opts) } - return pusher.push(chartRef, href, opts) + return pusher.push(chartRef, href) } -func (pusher *OCIPusher) push(chartRef, href string, opts helmopts.HelmOptions) error { +func (pusher *OCIPusher) push(chartRef, href string) error { stat, err := os.Stat(chartRef) if err != nil { - if os.IsNotExist(err) { - return errors.Errorf("%s: no such file", chartRef) + if errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("%s: no such file", chartRef) } return err } @@ -57,7 +57,7 @@ func (pusher *OCIPusher) push(chartRef, href string, opts helmopts.HelmOptions) return errors.New("cannot push directory, must provide chart archive (.tgz)") } - meta, err := loader.Load(chartRef, opts) + meta, err := loader.Load(context.Background(), chartRef) if err != nil { return err } @@ -90,7 +90,11 @@ func (pusher *OCIPusher) push(chartRef, href string, opts helmopts.HelmOptions) path.Join(strings.TrimPrefix(href, fmt.Sprintf("%s://", registry.OCIScheme)), meta.Metadata.Name), meta.Metadata.Version) - _, err = client.Push(chartBytes, ref, opts, pushOpts...) + // The time the chart was "created" is semantically the time the chart archive file was last written(modified) + chartArchiveFileCreatedTime := stat.ModTime() + pushOpts = append(pushOpts, registry.PushOptCreationTime(chartArchiveFileCreatedTime.Format(time.RFC3339))) + + _, err = client.Push(chartBytes, ref, pushOpts...) return err } @@ -106,10 +110,14 @@ func NewOCIPusher(ops ...Option) (Pusher, error) { } func (pusher *OCIPusher) newRegistryClient() (*registry.Client, error) { - if (pusher.opts.certFile != "" && pusher.opts.keyFile != "") || pusher.opts.caFile != "" || pusher.opts.insecureSkipTLSverify { - tlsConf, err := tlsutil.NewClientTLS(pusher.opts.certFile, pusher.opts.keyFile, pusher.opts.caFile, pusher.opts.insecureSkipTLSverify) + if (pusher.opts.certFile != "" && pusher.opts.keyFile != "") || pusher.opts.caFile != "" || pusher.opts.insecureSkipTLSVerify { + tlsConf, err := tlsutil.NewTLSConfig( + tlsutil.WithInsecureSkipVerify(pusher.opts.insecureSkipTLSVerify), + tlsutil.WithCertKeyPairFiles(pusher.opts.certFile, pusher.opts.keyFile), + tlsutil.WithCAFile(pusher.opts.caFile), + ) if err != nil { - return nil, errors.Wrap(err, "can't create TLS config for client") + return nil, fmt.Errorf("can't create TLS config for client: %w", err) } registryClient, err := registry.NewClient( diff --git a/pkg/helm/pkg/pusher/ocipusher_test.go b/pkg/helm/pkg/pusher/ocipusher_test.go index 1cd7a6f9..9151cb3d 100644 --- a/pkg/helm/pkg/pusher/ocipusher_test.go +++ b/pkg/helm/pkg/pusher/ocipusher_test.go @@ -1,3 +1,5 @@ +//go:build !windows + /* Copyright The Helm Authors. Licensed under the Apache License, Version 2.0 (the "License"); @@ -16,7 +18,10 @@ limitations under the License. package pusher import ( + "io" + "os" "path/filepath" + "strings" "testing" "github.com/werf/nelm/pkg/helm/pkg/registry" @@ -35,13 +40,13 @@ func TestNewOCIPusher(t *testing.T) { cd := "../../testdata" join := filepath.Join ca, pub, priv := join(cd, "rootca.crt"), join(cd, "crt.pem"), join(cd, "key.pem") - insecureSkipTLSverify := false + insecureSkipTLSVerify := false plainHTTP := false // Test with options p, err = NewOCIPusher( WithTLSClientConfig(pub, priv, ca), - WithInsecureSkipTLSVerify(insecureSkipTLSverify), + WithInsecureSkipTLSVerify(insecureSkipTLSVerify), WithPlainHTTP(plainHTTP), ) if err != nil { @@ -69,8 +74,8 @@ func TestNewOCIPusher(t *testing.T) { t.Errorf("Expected NewOCIPusher to have plainHTTP as %t, got %t", plainHTTP, op.opts.plainHTTP) } - if op.opts.insecureSkipTLSverify != insecureSkipTLSverify { - t.Errorf("Expected NewOCIPusher to have insecureSkipVerifyTLS as %t, got %t", insecureSkipTLSverify, op.opts.insecureSkipTLSverify) + if op.opts.insecureSkipTLSVerify != insecureSkipTLSVerify { + t.Errorf("Expected NewOCIPusher to have insecureSkipVerifyTLS as %t, got %t", insecureSkipTLSVerify, op.opts.insecureSkipTLSVerify) } // Test if setting registryClient is being passed to the ops @@ -94,3 +99,330 @@ func TestNewOCIPusher(t *testing.T) { t.Errorf("Expected NewOCIPusher to contain %p as RegistryClient, got %p", registryClient, op.opts.registryClient) } } + +func TestOCIPusher_Push_ErrorHandling(t *testing.T) { + tests := []struct { + name string + chartRef string + expectedError string + setupFunc func() string + }{ + { + name: "non-existent file", + chartRef: "/non/existent/file.tgz", + expectedError: "no such file", + }, + { + name: "directory instead of file", + expectedError: "cannot push directory, must provide chart archive (.tgz)", + setupFunc: func() string { + tempDir := t.TempDir() + return tempDir + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pusher, err := NewOCIPusher() + if err != nil { + t.Fatal(err) + } + + chartRef := tt.chartRef + if tt.setupFunc != nil { + chartRef = tt.setupFunc() + } + + err = pusher.Push(chartRef, "oci://localhost:5000/test") + if err == nil { + t.Fatal("Expected error but got none") + } + + if !strings.Contains(err.Error(), tt.expectedError) { + t.Errorf("Expected error containing %q, got %q", tt.expectedError, err.Error()) + } + }) + } +} + +func TestOCIPusher_newRegistryClient(t *testing.T) { + cd := "../../testdata" + join := filepath.Join + ca, pub, priv := join(cd, "rootca.crt"), join(cd, "crt.pem"), join(cd, "key.pem") + + tests := []struct { + name string + opts []Option + expectError bool + errorContains string + }{ + { + name: "plain HTTP", + opts: []Option{WithPlainHTTP(true)}, + }, + { + name: "with TLS client config", + opts: []Option{ + WithTLSClientConfig(pub, priv, ca), + }, + }, + { + name: "with insecure skip TLS verify", + opts: []Option{ + WithInsecureSkipTLSVerify(true), + }, + }, + { + name: "with cert and key only", + opts: []Option{ + WithTLSClientConfig(pub, priv, ""), + }, + }, + { + name: "with CA file only", + opts: []Option{ + WithTLSClientConfig("", "", ca), + }, + }, + { + name: "default client without options", + opts: []Option{}, + }, + { + name: "invalid cert file", + opts: []Option{ + WithTLSClientConfig("/non/existent/cert.pem", priv, ca), + }, + expectError: true, + errorContains: "can't create TLS config", + }, + { + name: "invalid key file", + opts: []Option{ + WithTLSClientConfig(pub, "/non/existent/key.pem", ca), + }, + expectError: true, + errorContains: "can't create TLS config", + }, + { + name: "invalid CA file", + opts: []Option{ + WithTLSClientConfig("", "", "/non/existent/ca.crt"), + }, + expectError: true, + errorContains: "can't create TLS config", + }, + { + name: "combined TLS options", + opts: []Option{ + WithTLSClientConfig(pub, priv, ca), + WithInsecureSkipTLSVerify(true), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pusher, err := NewOCIPusher(tt.opts...) + if err != nil { + t.Fatal(err) + } + + op, ok := pusher.(*OCIPusher) + if !ok { + t.Fatal("Expected *OCIPusher") + } + + client, err := op.newRegistryClient() + if tt.expectError { + if err == nil { + t.Fatal("Expected error but got none") + } + if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("Expected error containing %q, got %q", tt.errorContains, err.Error()) + } + } else { + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if client == nil { + t.Fatal("Expected non-nil registry client") + } + } + }) + } +} + +func TestOCIPusher_Push_ChartOperations(t *testing.T) { + // Path to test charts + chartPath := "../../pkg/cmd/testdata/testcharts/compressedchart-0.1.0.tgz" + chartWithProvPath := "../../pkg/cmd/testdata/testcharts/signtest-0.1.0.tgz" + + tests := []struct { + name string + chartRef string + href string + options []Option + setupFunc func(t *testing.T) (string, func()) + expectError bool + errorContains string + }{ + { + name: "invalid chart file", + chartRef: "../../pkg/action/testdata/charts/corrupted-compressed-chart.tgz", + href: "oci://localhost:5000/test", + expectError: true, + errorContains: "does not appear to be a gzipped archive", + }, + { + name: "chart read error", + setupFunc: func(t *testing.T) (string, func()) { + t.Helper() + // Create a valid chart file that we'll make unreadable + tempDir := t.TempDir() + tempChart := filepath.Join(tempDir, "temp-chart.tgz") + + // Copy a valid chart + src, err := os.Open(chartPath) + if err != nil { + t.Fatal(err) + } + defer src.Close() + + dst, err := os.Create(tempChart) + if err != nil { + t.Fatal(err) + } + + if _, err := io.Copy(dst, src); err != nil { + t.Fatal(err) + } + dst.Close() + + // Make the file unreadable + if err := os.Chmod(tempChart, 0000); err != nil { + t.Fatal(err) + } + + return tempChart, func() { + os.Chmod(tempChart, 0644) // Restore permissions for cleanup + } + }, + href: "oci://localhost:5000/test", + expectError: true, + errorContains: "permission denied", + }, + { + name: "push with provenance file - loading phase", + chartRef: chartWithProvPath, + href: "oci://registry.example.com/charts", + setupFunc: func(t *testing.T) (string, func()) { + t.Helper() + // Copy chart and create a .prov file for it + tempDir := t.TempDir() + tempChart := filepath.Join(tempDir, "signtest-0.1.0.tgz") + tempProv := filepath.Join(tempDir, "signtest-0.1.0.tgz.prov") + + // Copy chart file + src, err := os.Open(chartWithProvPath) + if err != nil { + t.Fatal(err) + } + defer src.Close() + + dst, err := os.Create(tempChart) + if err != nil { + t.Fatal(err) + } + + if _, err := io.Copy(dst, src); err != nil { + t.Fatal(err) + } + dst.Close() + + // Create provenance file + if err := os.WriteFile(tempProv, []byte("test provenance data"), 0644); err != nil { + t.Fatal(err) + } + + return tempChart, func() {} + }, + expectError: true, // Will fail at the registry push step + errorContains: "", // Error depends on registry client behavior + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + chartRef := tt.chartRef + var cleanup func() + + if tt.setupFunc != nil { + chartRef, cleanup = tt.setupFunc(t) + if cleanup != nil { + defer cleanup() + } + } + + // Skip test if chart file doesn't exist and we're not expecting an error + if _, err := os.Stat(chartRef); err != nil && !tt.expectError { + t.Skipf("Test chart %s not found, skipping test", chartRef) + } + + pusher, err := NewOCIPusher(tt.options...) + if err != nil { + t.Fatal(err) + } + + err = pusher.Push(chartRef, tt.href) + + if tt.expectError { + if err == nil { + t.Fatal("Expected error but got none") + } + if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("Expected error containing %q, got %q", tt.errorContains, err.Error()) + } + } else { + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + } + }) + } +} + +func TestOCIPusher_Push_MultipleOptions(t *testing.T) { + chartPath := "../../pkg/cmd/testdata/testcharts/compressedchart-0.1.0.tgz" + + // Skip test if chart file doesn't exist + if _, err := os.Stat(chartPath); err != nil { + t.Skipf("Test chart %s not found, skipping test", chartPath) + } + + pusher, err := NewOCIPusher() + if err != nil { + t.Fatal(err) + } + + // Test that multiple options are applied correctly + err = pusher.Push(chartPath, "oci://localhost:5000/test", + WithPlainHTTP(true), + WithInsecureSkipTLSVerify(true), + ) + + // We expect an error since we're not actually pushing to a registry + if err == nil { + t.Fatal("Expected error when pushing without a valid registry") + } + + // Verify options were applied + op := pusher.(*OCIPusher) + if !op.opts.plainHTTP { + t.Error("Expected plainHTTP option to be applied") + } + if !op.opts.insecureSkipTLSVerify { + t.Error("Expected insecureSkipTLSVerify option to be applied") + } +} diff --git a/pkg/helm/pkg/pusher/pusher.go b/pkg/helm/pkg/pusher/pusher.go index d623f5a1..168ee93d 100644 --- a/pkg/helm/pkg/pusher/pusher.go +++ b/pkg/helm/pkg/pusher/pusher.go @@ -17,11 +17,11 @@ limitations under the License. package pusher import ( - "github.com/pkg/errors" + "fmt" + "slices" "github.com/werf/nelm/pkg/helm/pkg/cli" "github.com/werf/nelm/pkg/helm/pkg/registry" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" ) // options are generic parameters to be provided to the pusher during instantiation. @@ -32,7 +32,7 @@ type options struct { certFile string keyFile string caFile string - insecureSkipTLSverify bool + insecureSkipTLSVerify bool plainHTTP bool } @@ -59,7 +59,7 @@ func WithTLSClientConfig(certFile, keyFile, caFile string) Option { // WithInsecureSkipTLSVerify determines if a TLS Certificate will be checked func WithInsecureSkipTLSVerify(insecureSkipTLSVerify bool) Option { return func(opts *options) { - opts.insecureSkipTLSverify = insecureSkipTLSVerify + opts.insecureSkipTLSVerify = insecureSkipTLSVerify } } @@ -72,7 +72,7 @@ func WithPlainHTTP(plainHTTP bool) Option { // Pusher is an interface to support upload to the specified URL. type Pusher interface { // Push file content by url string - Push(chartRef, url string, opts helmopts.HelmOptions, options ...Option) error + Push(chartRef, url string, options ...Option) error } // Constructor is the function for every pusher which creates a specific instance @@ -87,12 +87,7 @@ type Provider struct { // Provides returns true if the given scheme is supported by this Provider. func (p Provider) Provides(scheme string) bool { - for _, i := range p.Schemes { - if i == scheme { - return true - } - } - return false + return slices.Contains(p.Schemes, scheme) } // Providers is a collection of Provider objects. @@ -107,7 +102,7 @@ func (p Providers) ByScheme(scheme string) (Pusher, error) { return pp.New() } } - return nil, errors.Errorf("scheme %q not supported", scheme) + return nil, fmt.Errorf("scheme %q not supported", scheme) } var ociProvider = Provider{ diff --git a/pkg/helm/pkg/registry/chart.go b/pkg/helm/pkg/registry/chart.go new file mode 100644 index 00000000..54010a06 --- /dev/null +++ b/pkg/helm/pkg/registry/chart.go @@ -0,0 +1,125 @@ +/* +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 registry // import "github.com/werf/nelm/pkg/helm/pkg/registry" + +import ( + "bytes" + "context" + "strings" + "time" + + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/loader" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +var immutableOciAnnotations = []string{ + ocispec.AnnotationVersion, + ocispec.AnnotationTitle, +} + +// extractChartMeta is used to extract a chart metadata from a byte array +func extractChartMeta(chartData []byte) (*chart.Metadata, error) { + ch, err := loader.LoadArchive(context.Background(), bytes.NewReader(chartData)) + if err != nil { + return nil, err + } + return ch.Metadata, nil +} + +// generateOCIAnnotations will generate OCI annotations to include within the OCI manifest +func generateOCIAnnotations(meta *chart.Metadata, creationTime string) map[string]string { + + // Get annotations from Chart attributes + ociAnnotations := generateChartOCIAnnotations(meta, creationTime) + + // Copy Chart annotations +annotations: + for chartAnnotationKey, chartAnnotationValue := range meta.Annotations { + + // Avoid overriding key properties + for _, immutableOciKey := range immutableOciAnnotations { + if immutableOciKey == chartAnnotationKey { + continue annotations + } + } + + // Add chart annotation + ociAnnotations[chartAnnotationKey] = chartAnnotationValue + } + + return ociAnnotations +} + +// generateChartOCIAnnotations will generate OCI annotations from the provided chart +func generateChartOCIAnnotations(meta *chart.Metadata, creationTime string) map[string]string { + chartOCIAnnotations := map[string]string{} + + chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationDescription, meta.Description) + chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationTitle, meta.Name) + chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationVersion, meta.Version) + chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationURL, meta.Home) + + if len(creationTime) == 0 { + creationTime = time.Now().UTC().Format(time.RFC3339) + } + + chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationCreated, creationTime) + + if len(meta.Sources) > 0 { + chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationSource, meta.Sources[0]) + } + + if len(meta.Maintainers) > 0 { + var maintainerSb strings.Builder + + for maintainerIdx, maintainer := range meta.Maintainers { + + if len(maintainer.Name) > 0 { + maintainerSb.WriteString(maintainer.Name) + } + + if len(maintainer.Email) > 0 { + maintainerSb.WriteString(" (") + maintainerSb.WriteString(maintainer.Email) + maintainerSb.WriteString(")") + } + + if maintainerIdx < len(meta.Maintainers)-1 { + maintainerSb.WriteString(", ") + } + + } + + chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationAuthors, maintainerSb.String()) + + } + + return chartOCIAnnotations +} + +// addToMap takes an existing map and adds an item if the value is not empty +func addToMap(inputMap map[string]string, newKey string, newValue string) map[string]string { + + // Add item to map if its + if len(strings.TrimSpace(newValue)) > 0 { + inputMap[newKey] = newValue + } + + return inputMap +} diff --git a/pkg/helm/pkg/registry/util_test.go b/pkg/helm/pkg/registry/chart_test.go similarity index 77% rename from pkg/helm/pkg/registry/util_test.go rename to pkg/helm/pkg/registry/chart_test.go index 31f5aae9..14aa3810 100644 --- a/pkg/helm/pkg/registry/util_test.go +++ b/pkg/helm/pkg/registry/chart_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package registry // import "helm.sh/helm/v3/pkg/registry" +package registry // import "github.com/werf/nelm/pkg/helm/pkg/registry" import ( "reflect" @@ -23,12 +23,13 @@ import ( ocispec "github.com/opencontainers/image-spec/specs-go/v1" - "github.com/werf/nelm/pkg/helm/pkg/chart" - helmtime "github.com/werf/nelm/pkg/helm/pkg/time" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" ) func TestGenerateOCIChartAnnotations(t *testing.T) { + nowString := time.Now().Format(time.RFC3339) + tests := []struct { name string chart *chart.Metadata @@ -43,6 +44,7 @@ func TestGenerateOCIChartAnnotations(t *testing.T) { map[string]string{ "org.opencontainers.image.title": "oci", "org.opencontainers.image.version": "0.0.1", + "org.opencontainers.image.created": nowString, }, }, { @@ -56,6 +58,7 @@ func TestGenerateOCIChartAnnotations(t *testing.T) { map[string]string{ "org.opencontainers.image.title": "oci", "org.opencontainers.image.version": "0.0.1", + "org.opencontainers.image.created": nowString, "org.opencontainers.image.description": "OCI Helm Chart", "org.opencontainers.image.url": "https://helm.sh", }, @@ -76,6 +79,7 @@ func TestGenerateOCIChartAnnotations(t *testing.T) { map[string]string{ "org.opencontainers.image.title": "oci", "org.opencontainers.image.version": "0.0.1", + "org.opencontainers.image.created": nowString, "org.opencontainers.image.description": "OCI Helm Chart", "org.opencontainers.image.url": "https://helm.sh", "org.opencontainers.image.authors": "John Snow", @@ -95,6 +99,7 @@ func TestGenerateOCIChartAnnotations(t *testing.T) { map[string]string{ "org.opencontainers.image.title": "oci", "org.opencontainers.image.version": "0.0.1", + "org.opencontainers.image.created": nowString, "org.opencontainers.image.description": "OCI Helm Chart", "org.opencontainers.image.url": "https://helm.sh", "org.opencontainers.image.authors": "John Snow (john@winterfell.com)", @@ -115,6 +120,7 @@ func TestGenerateOCIChartAnnotations(t *testing.T) { map[string]string{ "org.opencontainers.image.title": "oci", "org.opencontainers.image.version": "0.0.1", + "org.opencontainers.image.created": nowString, "org.opencontainers.image.description": "OCI Helm Chart", "org.opencontainers.image.url": "https://helm.sh", "org.opencontainers.image.authors": "John Snow (john@winterfell.com), Jane Snow", @@ -133,6 +139,7 @@ func TestGenerateOCIChartAnnotations(t *testing.T) { map[string]string{ "org.opencontainers.image.title": "oci", "org.opencontainers.image.version": "0.0.1", + "org.opencontainers.image.created": nowString, "org.opencontainers.image.description": "OCI Helm Chart", "org.opencontainers.image.source": "https://github.com/helm/helm", }, @@ -141,7 +148,7 @@ func TestGenerateOCIChartAnnotations(t *testing.T) { for _, tt := range tests { - result := generateChartOCIAnnotations(tt.chart, true) + result := generateChartOCIAnnotations(tt.chart, nowString) if !reflect.DeepEqual(tt.expect, result) { t.Errorf("%s: expected map %v, got %v", tt.name, tt.expect, result) @@ -152,6 +159,8 @@ func TestGenerateOCIChartAnnotations(t *testing.T) { func TestGenerateOCIAnnotations(t *testing.T) { + nowString := time.Now().Format(time.RFC3339) + tests := []struct { name string chart *chart.Metadata @@ -166,6 +175,7 @@ func TestGenerateOCIAnnotations(t *testing.T) { map[string]string{ "org.opencontainers.image.title": "oci", "org.opencontainers.image.version": "0.0.1", + "org.opencontainers.image.created": nowString, }, }, { @@ -183,6 +193,7 @@ func TestGenerateOCIAnnotations(t *testing.T) { "org.opencontainers.image.title": "oci", "org.opencontainers.image.version": "0.0.1", "org.opencontainers.image.description": "OCI Helm Chart", + "org.opencontainers.image.created": nowString, "extrakey": "extravlue", "anotherkey": "anothervalue", }, @@ -203,6 +214,7 @@ func TestGenerateOCIAnnotations(t *testing.T) { "org.opencontainers.image.title": "oci", "org.opencontainers.image.version": "0.0.1", "org.opencontainers.image.description": "OCI Helm Chart", + "org.opencontainers.image.created": nowString, "extrakey": "extravlue", }, }, @@ -210,7 +222,7 @@ func TestGenerateOCIAnnotations(t *testing.T) { for _, tt := range tests { - result := generateOCIAnnotations(tt.chart, true) + result := generateOCIAnnotations(tt.chart, nowString) if !reflect.DeepEqual(tt.expect, result) { t.Errorf("%s: expected map %v, got %v", tt.name, tt.expect, result) @@ -220,12 +232,16 @@ func TestGenerateOCIAnnotations(t *testing.T) { } func TestGenerateOCICreatedAnnotations(t *testing.T) { - chart := &chart.Metadata{ + + nowTime := time.Now() + nowTimeString := nowTime.Format(time.RFC3339) + + testChart := &chart.Metadata{ Name: "oci", Version: "0.0.1", } - result := generateOCIAnnotations(chart, false) + result := generateOCIAnnotations(testChart, nowTimeString) // Check that created annotation exists if _, ok := result[ocispec.AnnotationCreated]; !ok { @@ -233,8 +249,26 @@ func TestGenerateOCICreatedAnnotations(t *testing.T) { } // Verify value of created artifact in RFC3339 format - if _, err := helmtime.Parse(time.RFC3339, result[ocispec.AnnotationCreated]); err != nil { + if _, err := time.Parse(time.RFC3339, result[ocispec.AnnotationCreated]); err != nil { + t.Errorf("%s annotation with value '%s' not in RFC3339 format", ocispec.AnnotationCreated, result[ocispec.AnnotationCreated]) + } + + // Verify default creation time set + result = generateOCIAnnotations(testChart, "") + + // Check that created annotation exists + if _, ok := result[ocispec.AnnotationCreated]; !ok { + t.Errorf("%s annotation not created", ocispec.AnnotationCreated) + } + + if createdTimeAnnotation, err := time.Parse(time.RFC3339, result[ocispec.AnnotationCreated]); err != nil { t.Errorf("%s annotation with value '%s' not in RFC3339 format", ocispec.AnnotationCreated, result[ocispec.AnnotationCreated]) + + // Verify creation annotation after time test began + if !nowTime.Before(createdTimeAnnotation) { + t.Errorf("%s annotation with value '%s' not configured properly. Annotation value is not after %s", ocispec.AnnotationCreated, result[ocispec.AnnotationCreated], nowTimeString) + } + } } diff --git a/pkg/helm/pkg/registry/client.go b/pkg/helm/pkg/registry/client.go index 5a9a282a..3295c862 100644 --- a/pkg/helm/pkg/registry/client.go +++ b/pkg/helm/pkg/registry/client.go @@ -14,34 +14,37 @@ See the License for the specific language governing permissions and limitations under the License. */ -package registry // import "helm.sh/helm/v3/pkg/registry" +package registry // import "github.com/werf/nelm/pkg/helm/pkg/registry" import ( "context" "crypto/tls" + "crypto/x509" "encoding/json" + "errors" "fmt" "io" + "log/slog" "net/http" + "net/url" + "os" "sort" "strings" "github.com/Masterminds/semver/v3" - "github.com/containerd/containerd/remotes" + "github.com/opencontainers/image-spec/specs-go" ocispec "github.com/opencontainers/image-spec/specs-go/v1" - "github.com/pkg/errors" - "oras.land/oras-go/pkg/auth" - dockerauth "oras.land/oras-go/pkg/auth/docker" - "oras.land/oras-go/pkg/content" - "oras.land/oras-go/pkg/oras" - "oras.land/oras-go/pkg/registry" - registryremote "oras.land/oras-go/pkg/registry/remote" - registryauth "oras.land/oras-go/pkg/registry/remote/auth" - - "github.com/werf/nelm/pkg/helm/internal/version" - "github.com/werf/nelm/pkg/helm/pkg/chart" + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content/memory" + "oras.land/oras-go/v2/registry" + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials" + "oras.land/oras-go/v2/registry/remote/retry" + + "github.com/werf/nelm/pkg/helm/intern/version" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" "github.com/werf/nelm/pkg/helm/pkg/helmpath" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" ) // See https://github.com/helm/helm/issues/10166 @@ -52,22 +55,32 @@ an underscore (_) in chart version tags when pushing to a registry and back to a plus (+) when pulling from a registry.` type ( + // RemoteClient shadows the ORAS remote.Client interface + // (hiding the ORAS type from Helm client visibility) + // https://pkg.go.dev/oras.land/oras-go/pkg/registry/remote#Client + RemoteClient interface { + Do(req *http.Request) (*http.Response, error) + } + // Client works with OCI-compliant registries Client struct { debug bool enableCache bool // path to repository config file e.g. ~/.docker/config.json credentialsFile string + username string + password string out io.Writer - authorizer auth.Client - registryAuthorizer *registryauth.Client - resolver func(ref registry.Reference) (remotes.Resolver, error) + authorizer *auth.Client + registryAuthorizer RemoteClient + credentialsStore credentials.Store httpClient *http.Client plainHTTP bool } // ClientOption allows specifying various settings configurable by the user for overriding the defaults // used when creating a new default client + // TODO(TerryHowe): ClientOption should return error in v5 ClientOption func(*Client) ) @@ -82,89 +95,57 @@ func NewClient(options ...ClientOption) (*Client, error) { if client.credentialsFile == "" { client.credentialsFile = helmpath.ConfigPath(CredentialsFileBasename) } - if client.authorizer == nil { - authClient, err := dockerauth.NewClientWithDockerFallback(client.credentialsFile) - if err != nil { - return nil, err + if client.httpClient == nil { + client.httpClient = &http.Client{ + Transport: NewTransport(client.debug), } - client.authorizer = authClient } - resolverFn := client.resolver // copy for avoiding recursive call - client.resolver = func(ref registry.Reference) (remotes.Resolver, error) { - if resolverFn != nil { - // validate if the resolverFn returns a valid resolver - if resolver, err := resolverFn(ref); resolver != nil && err == nil { - return resolver, nil - } - } - headers := http.Header{} - headers.Set("User-Agent", version.GetUserAgent()) - opts := []auth.ResolverOption{auth.WithResolverHeaders(headers)} - if client.httpClient != nil { - opts = append(opts, auth.WithResolverClient(client.httpClient)) - } - if client.plainHTTP { - opts = append(opts, auth.WithResolverPlainHTTP()) - - opts = append(opts, func(settings *auth.ResolverSettings) { - settings.Client.Transport = &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, - } - }) - } - - resolver, err := client.authorizer.ResolverWithOpts(opts...) - if err != nil { - return nil, err - } - return resolver, nil + storeOptions := credentials.StoreOptions{ + AllowPlaintextPut: true, + DetectDefaultNativeStore: true, } - - // allocate a cache if option is set - var cache registryauth.Cache - if client.enableCache { - cache = registryauth.DefaultCache + store, err := credentials.NewStore(client.credentialsFile, storeOptions) + if err != nil { + return nil, err + } + dockerStore, err := credentials.NewStoreFromDocker(storeOptions) + if err != nil { + // should only fail if user home directory can't be determined + client.credentialsStore = store + } else { + // use Helm credentials with fallback to Docker + client.credentialsStore = credentials.NewStoreWithFallbacks(store, dockerStore) } - if client.registryAuthorizer == nil { - client.registryAuthorizer = ®istryauth.Client{ + + if client.authorizer == nil { + authorizer := auth.Client{ Client: client.httpClient, - Header: http.Header{ - "User-Agent": {version.GetUserAgent()}, - }, - Cache: cache, - Credential: func(ctx context.Context, reg string) (registryauth.Credential, error) { - dockerClient, ok := client.authorizer.(*dockerauth.Client) - if !ok { - return registryauth.EmptyCredential, errors.New("unable to obtain docker client") - } - - username, password, err := dockerClient.Credential(reg) - if err != nil { - return registryauth.EmptyCredential, errors.New("unable to retrieve credentials") - } - - // A blank returned username and password value is a bearer token - if username == "" && password != "" { - return registryauth.Credential{ - RefreshToken: password, - }, nil - } - - return registryauth.Credential{ - Username: username, - Password: password, - }, nil - - }, } + authorizer.SetUserAgent(version.GetUserAgent()) + if client.username != "" && client.password != "" { + authorizer.Credential = func(_ context.Context, _ string) (auth.Credential, error) { + return auth.Credential{Username: client.username, Password: client.password}, nil + } + } else { + authorizer.Credential = credentials.Credential(client.credentialsStore) + } + + if client.enableCache { + authorizer.Cache = auth.NewCache() + } + client.authorizer = &authorizer } + return client, nil } +// Generic returns a GenericClient for low-level OCI operations +func (c *Client) Generic() *GenericClient { + return NewGenericClient(c) +} + // ClientOptDebug returns a function that sets the debug setting on client options set func ClientOptDebug(debug bool) ClientOption { return func(client *Client) { @@ -179,6 +160,14 @@ func ClientOptEnableCache(enableCache bool) ClientOption { } } +// ClientOptBasicAuth returns a function that sets the username and password setting on client options set +func ClientOptBasicAuth(username, password string) ClientOption { + return func(client *Client) { + client.username = username + client.password = password + } +} + // ClientOptWriter returns a function that sets the writer setting on client options set func ClientOptWriter(out io.Writer) ClientOption { return func(client *Client) { @@ -186,6 +175,26 @@ func ClientOptWriter(out io.Writer) ClientOption { } } +// ClientOptAuthorizer returns a function that sets the authorizer setting on a client options set. This +// can be used to override the default authorization mechanism. +// +// Depending on the use-case you may need to set both ClientOptAuthorizer and ClientOptRegistryAuthorizer. +func ClientOptAuthorizer(authorizer auth.Client) ClientOption { + return func(client *Client) { + client.authorizer = &authorizer + } +} + +// ClientOptRegistryAuthorizer returns a function that sets the registry authorizer setting on a client options set. This +// can be used to override the default authorization mechanism. +// +// Depending on the use-case you may need to set both ClientOptAuthorizer and ClientOptRegistryAuthorizer. +func ClientOptRegistryAuthorizer(registryAuthorizer RemoteClient) ClientOption { + return func(client *Client) { + client.registryAuthorizer = registryAuthorizer + } +} + // ClientOptCredentialsFile returns a function that sets the credentialsFile setting on a client options set func ClientOptCredentialsFile(credentialsFile string) ClientOption { return func(client *Client) { @@ -206,74 +215,168 @@ func ClientOptPlainHTTP() ClientOption { } } -// ClientOptResolver returns a function that sets the resolver setting on a client options set -func ClientOptResolver(resolver remotes.Resolver) ClientOption { - return func(client *Client) { - client.resolver = func(ref registry.Reference) (remotes.Resolver, error) { - return resolver, nil - } - } -} - type ( // LoginOption allows specifying various settings on login LoginOption func(*loginOperation) loginOperation struct { - username string - password string - insecure bool - certFile string - keyFile string - caFile string + host string + client *Client } ) +// warnIfHostHasPath checks if the host contains a repository path and logs a warning if it does. +// Returns true if the host contains a path component (i.e., contains a '/'). +func warnIfHostHasPath(host string) bool { + if strings.Contains(host, "/") { + registryHost := strings.Split(host, "/")[0] + slog.Warn("registry login currently only supports registry hostname, not a repository path", "host", host, "suggested", registryHost) + return true + } + return false +} + // Login logs into a registry func (c *Client) Login(host string, options ...LoginOption) error { - operation := &loginOperation{} for _, option := range options { - option(operation) + option(&loginOperation{host, c}) } - authorizerLoginOpts := []auth.LoginOption{ - auth.WithLoginContext(ctx(c.out, c.debug)), - auth.WithLoginHostname(host), - auth.WithLoginUsername(operation.username), - auth.WithLoginSecret(operation.password), - auth.WithLoginUserAgent(version.GetUserAgent()), - auth.WithLoginTLS(operation.certFile, operation.keyFile, operation.caFile), + + warnIfHostHasPath(host) + + reg, err := remote.NewRegistry(host) + if err != nil { + return err } - if operation.insecure { - authorizerLoginOpts = append(authorizerLoginOpts, auth.WithLoginInsecure()) + reg.PlainHTTP = c.plainHTTP + cred := auth.Credential{Username: c.username, Password: c.password} + c.authorizer.ForceAttemptOAuth2 = true + reg.Client = c.authorizer + + ctx := context.Background() + if err := reg.Ping(ctx); err != nil { + c.authorizer.ForceAttemptOAuth2 = false + if err := reg.Ping(ctx); err != nil { + return fmt.Errorf("authenticating to %q: %w", host, err) + } } - if err := c.authorizer.LoginWithOpts(authorizerLoginOpts...); err != nil { + // Always restore to false after probing, to avoid forcing POST to token endpoints like GHCR. + c.authorizer.ForceAttemptOAuth2 = false + + key := credentials.ServerAddressFromRegistry(host) + key = credentials.ServerAddressFromHostname(key) + if err := c.credentialsStore.Put(ctx, key, cred); err != nil { return err } - fmt.Fprintln(c.out, "Login Succeeded") + + _, _ = fmt.Fprintln(c.out, "Login Succeeded") return nil } // LoginOptBasicAuth returns a function that sets the username/password settings on login func LoginOptBasicAuth(username string, password string) LoginOption { - return func(operation *loginOperation) { - operation.username = username - operation.password = password + return func(o *loginOperation) { + o.client.username = username + o.client.password = password + o.client.authorizer.Credential = auth.StaticCredential(o.host, auth.Credential{Username: username, Password: password}) + } +} + +// LoginOptPlainText returns a function that allows plaintext (HTTP) login +func LoginOptPlainText(isPlainText bool) LoginOption { + return func(o *loginOperation) { + o.client.plainHTTP = isPlainText } } +func ensureTLSConfig(client *auth.Client, setConfig *tls.Config) (*tls.Config, error) { + var transport *http.Transport + + switch t := client.Client.Transport.(type) { + case *http.Transport: + transport = t + case *retry.Transport: + switch t := t.Base.(type) { + case *http.Transport: + transport = t + case *LoggingTransport: + switch t := t.RoundTripper.(type) { + case *http.Transport: + transport = t + } + } + } + + if transport == nil { + // we don't know how to access the http.Transport, most likely the + // auth.Client.Client was provided by API user + return nil, fmt.Errorf("unable to access TLS client configuration, the provided HTTP Transport is not supported, given: %T", client.Client.Transport) + } + + switch { + case setConfig != nil: + transport.TLSClientConfig = setConfig + case transport.TLSClientConfig == nil: + transport.TLSClientConfig = &tls.Config{} + } + + return transport.TLSClientConfig, nil +} + // LoginOptInsecure returns a function that sets the insecure setting on login func LoginOptInsecure(insecure bool) LoginOption { - return func(operation *loginOperation) { - operation.insecure = insecure + return func(o *loginOperation) { + tlsConfig, err := ensureTLSConfig(o.client.authorizer, nil) + + if err != nil { + panic(err) + } + + tlsConfig.InsecureSkipVerify = insecure } } // LoginOptTLSClientConfig returns a function that sets the TLS settings on login. func LoginOptTLSClientConfig(certFile, keyFile, caFile string) LoginOption { - return func(operation *loginOperation) { - operation.certFile = certFile - operation.keyFile = keyFile - operation.caFile = caFile + return func(o *loginOperation) { + if (certFile == "" || keyFile == "") && caFile == "" { + return + } + tlsConfig, err := ensureTLSConfig(o.client.authorizer, nil) + if err != nil { + panic(err) + } + + if certFile != "" && keyFile != "" { + authCert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + panic(err) + } + tlsConfig.Certificates = []tls.Certificate{authCert} + } + + if caFile != "" { + certPool := x509.NewCertPool() + ca, err := os.ReadFile(caFile) + if err != nil { + panic(err) + } + if !certPool.AppendCertsFromPEM(ca) { + panic(fmt.Errorf("unable to parse CA file: %q", caFile)) + } + tlsConfig.RootCAs = certPool + } + } +} + +// LoginOptTLSClientConfigFromConfig returns a function that sets the TLS settings on login +// receiving the configuration in memory rather than from files. +func LoginOptTLSClientConfigFromConfig(conf *tls.Config) LoginOption { + return func(o *loginOperation) { + _, err := ensureTLSConfig(o.client.authorizer, conf) + if err != nil { + panic(err) + } } } @@ -290,10 +393,11 @@ func (c *Client) Logout(host string, opts ...LogoutOption) error { for _, opt := range opts { opt(operation) } - if err := c.authorizer.Logout(ctx(c.out, c.debug), host); err != nil { + + if err := credentials.Logout(context.Background(), c.credentialsStore, host); err != nil { return err } - fmt.Fprintf(c.out, "Removing login credentials for %s\n", host) + _, _ = fmt.Fprintf(c.out, "Removing login credentials for %s\n", host) return nil } @@ -328,68 +432,31 @@ type ( } ) -// Pull downloads a chart from a registry -func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) { - parsedRef, err := parseReference(ref) - if err != nil { - return nil, err - } +// processChartPull handles chart-specific processing of a generic pull result +func (c *Client) processChartPull(genericResult *GenericPullResult, operation *pullOperation) (*PullResult, error) { + var err error - operation := &pullOperation{ - withChart: true, // By default, always download the chart layer - } - for _, option := range options { - option(operation) - } - if !operation.withChart && !operation.withProv { - return nil, errors.New( - "must specify at least one layer to pull (chart/prov)") - } - memoryStore := content.NewMemory() - allowedMediaTypes := []string{ - ConfigMediaType, - } + // Chart-specific validation minNumDescriptors := 1 // 1 for the config if operation.withChart { minNumDescriptors++ - allowedMediaTypes = append(allowedMediaTypes, ChartLayerMediaType, LegacyChartLayerMediaType) - } - if operation.withProv { - if !operation.ignoreMissingProv { - minNumDescriptors++ - } - allowedMediaTypes = append(allowedMediaTypes, ProvLayerMediaType) - } - - var descriptors, layers []ocispec.Descriptor - remotesResolver, err := c.resolver(parsedRef) - if err != nil { - return nil, err } - registryStore := content.Registry{Resolver: remotesResolver} - - manifest, err := oras.Copy(ctx(c.out, c.debug), registryStore, parsedRef.String(), memoryStore, "", - oras.WithPullEmptyNameAllowed(), - oras.WithAllowedMediaTypes(allowedMediaTypes), - oras.WithLayerDescriptors(func(l []ocispec.Descriptor) { - layers = l - })) - if err != nil { - return nil, err + if operation.withProv && !operation.ignoreMissingProv { + minNumDescriptors++ } - descriptors = append(descriptors, manifest) - descriptors = append(descriptors, layers...) - - numDescriptors := len(descriptors) + numDescriptors := len(genericResult.Descriptors) if numDescriptors < minNumDescriptors { return nil, fmt.Errorf("manifest does not contain minimum number of descriptors (%d), descriptors found: %d", minNumDescriptors, numDescriptors) } + + // Find chart-specific descriptors var configDescriptor *ocispec.Descriptor var chartDescriptor *ocispec.Descriptor var provDescriptor *ocispec.Descriptor - for _, descriptor := range descriptors { + + for _, descriptor := range genericResult.Descriptors { d := descriptor switch d.MediaType { case ConfigMediaType: @@ -400,9 +467,11 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) { provDescriptor = &d case LegacyChartLayerMediaType: chartDescriptor = &d - fmt.Fprintf(c.out, "Warning: chart media type %s is deprecated\n", LegacyChartLayerMediaType) + _, _ = fmt.Fprintf(c.out, "Warning: chart media type %s is deprecated\n", LegacyChartLayerMediaType) } } + + // Chart-specific validation if configDescriptor == nil { return nil, fmt.Errorf("could not load config with mediatype %s", ConfigMediaType) } @@ -410,6 +479,7 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) { return nil, fmt.Errorf("manifest does not contain a layer with mediatype %s", ChartLayerMediaType) } + var provMissing bool if operation.withProv && provDescriptor == nil { if operation.ignoreMissingProv { @@ -419,10 +489,12 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) { ProvLayerMediaType) } } + + // Build chart-specific result result := &PullResult{ Manifest: &DescriptorPullSummary{ - Digest: manifest.Digest.String(), - Size: manifest.Size, + Digest: genericResult.Manifest.Digest.String(), + Size: genericResult.Manifest.Size, }, Config: &DescriptorPullSummary{ Digest: configDescriptor.Digest.String(), @@ -430,69 +502,94 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) { }, Chart: &DescriptorPullSummaryWithMeta{}, Prov: &DescriptorPullSummary{}, - Ref: parsedRef.String(), + Ref: genericResult.Ref, } - var getManifestErr error - if _, manifestData, ok := memoryStore.Get(manifest); !ok { - getManifestErr = errors.Errorf("Unable to retrieve blob with digest %s", manifest.Digest) - } else { - result.Manifest.Data = manifestData - } - if getManifestErr != nil { - return nil, getManifestErr + + // Fetch data using generic client + genericClient := c.Generic() + + result.Manifest.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, genericResult.Manifest) + if err != nil { + return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", genericResult.Manifest.Digest, err) } - var getConfigDescriptorErr error - if _, configData, ok := memoryStore.Get(*configDescriptor); !ok { - getConfigDescriptorErr = errors.Errorf("Unable to retrieve blob with digest %s", configDescriptor.Digest) - } else { - result.Config.Data = configData - var meta *chart.Metadata - if err := json.Unmarshal(configData, &meta); err != nil { - return nil, err - } - result.Chart.Meta = meta + + result.Config.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *configDescriptor) + if err != nil { + return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", configDescriptor.Digest, err) } - if getConfigDescriptorErr != nil { - return nil, getConfigDescriptorErr + + if err := json.Unmarshal(result.Config.Data, &result.Chart.Meta); err != nil { + return nil, err } + if operation.withChart { - var getChartDescriptorErr error - if _, chartData, ok := memoryStore.Get(*chartDescriptor); !ok { - getChartDescriptorErr = errors.Errorf("Unable to retrieve blob with digest %s", chartDescriptor.Digest) - } else { - result.Chart.Data = chartData - result.Chart.Digest = chartDescriptor.Digest.String() - result.Chart.Size = chartDescriptor.Size - } - if getChartDescriptorErr != nil { - return nil, getChartDescriptorErr + result.Chart.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *chartDescriptor) + if err != nil { + return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", chartDescriptor.Digest, err) } + result.Chart.Digest = chartDescriptor.Digest.String() + result.Chart.Size = chartDescriptor.Size } + if operation.withProv && !provMissing { - var getProvDescriptorErr error - if _, provData, ok := memoryStore.Get(*provDescriptor); !ok { - getProvDescriptorErr = errors.Errorf("Unable to retrieve blob with digest %s", provDescriptor.Digest) - } else { - result.Prov.Data = provData - result.Prov.Digest = provDescriptor.Digest.String() - result.Prov.Size = provDescriptor.Size - } - if getProvDescriptorErr != nil { - return nil, getProvDescriptorErr + result.Prov.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *provDescriptor) + if err != nil { + return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", provDescriptor.Digest, err) } + result.Prov.Digest = provDescriptor.Digest.String() + result.Prov.Size = provDescriptor.Size } - fmt.Fprintf(c.out, "Pulled: %s\n", result.Ref) - fmt.Fprintf(c.out, "Digest: %s\n", result.Manifest.Digest) + _, _ = fmt.Fprintf(c.out, "Pulled: %s\n", result.Ref) + _, _ = fmt.Fprintf(c.out, "Digest: %s\n", result.Manifest.Digest) if strings.Contains(result.Ref, "_") { - fmt.Fprintf(c.out, "%s contains an underscore.\n", result.Ref) - fmt.Fprint(c.out, registryUnderscoreMessage+"\n") + _, _ = fmt.Fprintf(c.out, "%s contains an underscore.\n", result.Ref) + _, _ = fmt.Fprint(c.out, registryUnderscoreMessage+"\n") } return result, nil } +// Pull downloads a chart from a registry +func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) { + operation := &pullOperation{ + withChart: true, // By default, always download the chart layer + } + for _, option := range options { + option(operation) + } + if !operation.withChart && !operation.withProv { + return nil, errors.New( + "must specify at least one layer to pull (chart/prov)") + } + + // Build allowed media types for chart pull + allowedMediaTypes := []string{ + ocispec.MediaTypeImageIndex, + ocispec.MediaTypeImageManifest, + ConfigMediaType, + } + if operation.withChart { + allowedMediaTypes = append(allowedMediaTypes, ChartLayerMediaType, LegacyChartLayerMediaType) + } + if operation.withProv { + allowedMediaTypes = append(allowedMediaTypes, ProvLayerMediaType) + } + + // Use generic client for the pull operation + genericClient := c.Generic() + genericResult, err := genericClient.PullGeneric(ref, GenericPullOptions{ + AllowedMediaTypes: allowedMediaTypes, + }) + if err != nil { + return nil, err + } + + // Process the result with chart-specific logic + return c.processChartPull(genericResult, operation) +} + // PullOptWithChart returns a function that sets the withChart setting on pull func PullOptWithChart(withChart bool) PullOption { return func(operation *pullOperation) { @@ -538,15 +635,15 @@ type ( } pushOperation struct { - provData []byte - strictMode bool - test bool + provData []byte + strictMode bool + creationTime string } ) // Push uploads a chart to a registry. -func (c *Client) Push(data []byte, ref string, opts helmopts.HelmOptions, options ...PushOption) (*PushResult, error) { - parsedRef, err := parseReference(ref) +func (c *Client) Push(data []byte, ref string, options ...PushOption) (*PushResult, error) { + parsedRef, err := newReference(ref) if err != nil { return nil, err } @@ -557,7 +654,7 @@ func (c *Client) Push(data []byte, ref string, opts helmopts.HelmOptions, option for _, option := range options { option(operation) } - meta, err := extractChartMeta(data, opts) + meta, err := extractChartMeta(data) if err != nil { return nil, err } @@ -567,8 +664,11 @@ func (c *Client) Push(data []byte, ref string, opts helmopts.HelmOptions, option "strict mode enabled, ref basename and tag must match the chart name and version") } } - memoryStore := content.NewMemory() - chartDescriptor, err := memoryStore.Add("", ChartLayerMediaType, data) + + ctx := context.Background() + + memoryStore := memory.New() + chartDescriptor, err := oras.PushBytes(ctx, memoryStore, ChartLayerMediaType, data) if err != nil { return nil, err } @@ -578,43 +678,47 @@ func (c *Client) Push(data []byte, ref string, opts helmopts.HelmOptions, option return nil, err } - configDescriptor, err := memoryStore.Add("", ConfigMediaType, configData) + configDescriptor, err := oras.PushBytes(ctx, memoryStore, ConfigMediaType, configData) if err != nil { return nil, err } - descriptors := []ocispec.Descriptor{chartDescriptor} + layers := []ocispec.Descriptor{chartDescriptor} var provDescriptor ocispec.Descriptor if operation.provData != nil { - provDescriptor, err = memoryStore.Add("", ProvLayerMediaType, operation.provData) + provDescriptor, err = oras.PushBytes(ctx, memoryStore, ProvLayerMediaType, operation.provData) if err != nil { return nil, err } - descriptors = append(descriptors, provDescriptor) + layers = append(layers, provDescriptor) } - ociAnnotations := generateOCIAnnotations(meta, operation.test) + // sort layers for determinism, similar to how ORAS v1 does it + sort.Slice(layers, func(i, j int) bool { + return layers[i].Digest < layers[j].Digest + }) - manifestData, manifest, err := content.GenerateManifest(&configDescriptor, ociAnnotations, descriptors...) - if err != nil { - return nil, err - } + ociAnnotations := generateOCIAnnotations(meta, operation.creationTime) - if err := memoryStore.StoreManifest(parsedRef.String(), manifest, manifestData); err != nil { + manifestDescriptor, err := c.tagManifest(ctx, memoryStore, configDescriptor, + layers, ociAnnotations, parsedRef) + if err != nil { return nil, err } - remotesResolver, err := c.resolver(parsedRef) + repository, err := remote.NewRepository(parsedRef.String()) if err != nil { return nil, err } - registryStore := content.Registry{Resolver: remotesResolver} - _, err = oras.Copy(ctx(c.out, c.debug), memoryStore, parsedRef.String(), registryStore, "", - oras.WithNameValidation(nil)) + repository.PlainHTTP = c.plainHTTP + repository.Client = c.authorizer + + manifestDescriptor, err = oras.ExtendedCopy(ctx, memoryStore, parsedRef.String(), repository, parsedRef.String(), oras.DefaultExtendedCopyOptions) if err != nil { return nil, err } + chartSummary := &descriptorPushSummaryWithMeta{ Meta: meta, } @@ -622,8 +726,8 @@ func (c *Client) Push(data []byte, ref string, opts helmopts.HelmOptions, option chartSummary.Size = chartDescriptor.Size result := &PushResult{ Manifest: &descriptorPushSummary{ - Digest: manifest.Digest.String(), - Size: manifest.Size, + Digest: manifestDescriptor.Digest.String(), + Size: manifestDescriptor.Size, }, Config: &descriptorPushSummary{ Digest: configDescriptor.Digest.String(), @@ -639,11 +743,11 @@ func (c *Client) Push(data []byte, ref string, opts helmopts.HelmOptions, option Size: provDescriptor.Size, } } - fmt.Fprintf(c.out, "Pushed: %s\n", result.Ref) - fmt.Fprintf(c.out, "Digest: %s\n", result.Manifest.Digest) - if strings.Contains(parsedRef.Reference, "_") { - fmt.Fprintf(c.out, "%s contains an underscore.\n", result.Ref) - fmt.Fprint(c.out, registryUnderscoreMessage+"\n") + _, _ = fmt.Fprintf(c.out, "Pushed: %s\n", result.Ref) + _, _ = fmt.Fprintf(c.out, "Digest: %s\n", result.Manifest.Digest) + if strings.Contains(parsedRef.orasReference.Reference, "_") { + _, _ = fmt.Fprintf(c.out, "%s contains an underscore.\n", result.Ref) + _, _ = fmt.Fprint(c.out, registryUnderscoreMessage+"\n") } return result, err @@ -663,10 +767,10 @@ func PushOptStrictMode(strictMode bool) PushOption { } } -// PushOptTest returns a function that sets whether test setting on push -func PushOptTest(test bool) PushOption { +// PushOptCreationTime returns a function that sets the creation time +func PushOptCreationTime(creationTime string) PushOption { return func(operation *pushOperation) { - operation.test = test + operation.creationTime = creationTime } } @@ -677,27 +781,29 @@ func (c *Client) Tags(ref string) ([]string, error) { return nil, err } - repository := registryremote.Repository{ - Reference: parsedReference, - Client: c.registryAuthorizer, - PlainHTTP: c.plainHTTP, - } - - var registryTags []string - - registryTags, err = registry.Tags(ctx(c.out, c.debug), &repository) + ctx := context.Background() + repository, err := remote.NewRepository(parsedReference.String()) if err != nil { return nil, err } + repository.PlainHTTP = c.plainHTTP + repository.Client = c.authorizer var tagVersions []*semver.Version - for _, tag := range registryTags { - // Change underscore (_) back to plus (+) for Helm - // See https://github.com/helm/helm/issues/10166 - tagVersion, err := semver.StrictNewVersion(strings.ReplaceAll(tag, "_", "+")) - if err == nil { - tagVersions = append(tagVersions, tagVersion) + err = repository.Tags(ctx, "", func(tags []string) error { + for _, tag := range tags { + // Change underscore (_) back to plus (+) for Helm + // See https://github.com/helm/helm/issues/10166 + tagVersion, err := semver.StrictNewVersion(strings.ReplaceAll(tag, "_", "+")) + if err == nil { + tagVersions = append(tagVersions, tagVersion) + } } + + return nil + }) + if err != nil { + return nil, err } // Sort the collection @@ -712,3 +818,111 @@ func (c *Client) Tags(ref string) ([]string, error) { return tags, nil } + +// Resolve a reference to a descriptor. +func (c *Client) Resolve(ref string) (desc ocispec.Descriptor, err error) { + remoteRepository, err := remote.NewRepository(ref) + if err != nil { + return desc, err + } + remoteRepository.PlainHTTP = c.plainHTTP + remoteRepository.Client = c.authorizer + + parsedReference, err := newReference(ref) + if err != nil { + return desc, err + } + + ctx := context.Background() + parsedString := parsedReference.String() + return remoteRepository.Resolve(ctx, parsedString) +} + +// ValidateReference for path and version +func (c *Client) ValidateReference(ref, version string, u *url.URL) (string, *url.URL, error) { + var tag string + + registryReference, err := newReference(u.Host + u.Path) + if err != nil { + return "", nil, err + } + + if version == "" { + // Use OCI URI tag as default + version = registryReference.Tag + } else { + if registryReference.Tag != "" && registryReference.Tag != version { + return "", nil, fmt.Errorf("chart reference and version mismatch: %s is not %s", version, registryReference.Tag) + } + } + + if registryReference.Digest != "" { + if version == "" { + // Install by digest only + return "", u, nil + } + u.Path = fmt.Sprintf("%s@%s", registryReference.Repository, registryReference.Digest) + + // Validate the tag if it was specified + path := registryReference.Registry + "/" + registryReference.Repository + ":" + version + desc, err := c.Resolve(path) + if err != nil { + // The resource does not have to be tagged when digest is specified + return "", u, nil + } + if desc.Digest.String() != registryReference.Digest { + return "", nil, fmt.Errorf("chart reference digest mismatch: %s is not %s", desc.Digest.String(), registryReference.Digest) + } + return registryReference.Digest, u, nil + } + + // Evaluate whether an explicit version has been provided. Otherwise, determine version to use + _, errSemVer := semver.NewVersion(version) + if errSemVer == nil { + tag = version + } else { + // Retrieve list of repository tags + tags, err := c.Tags(strings.TrimPrefix(ref, fmt.Sprintf("%s://", OCIScheme))) + if err != nil { + return "", nil, err + } + if len(tags) == 0 { + return "", nil, fmt.Errorf("unable to locate any tags in provided repository: %s", ref) + } + + // Determine if version provided + // If empty, try to get the highest available tag + // If exact version, try to find it + // If semver constraint string, try to find a match + tag, err = GetTagMatchingVersionOrConstraint(tags, version) + if err != nil { + return "", nil, err + } + } + + u.Path = fmt.Sprintf("%s:%s", registryReference.Repository, tag) + // desc, err := c.Resolve(u.Path) + + return "", u, err +} + +// tagManifest prepares and tags a manifest in memory storage +func (c *Client) tagManifest(ctx context.Context, memoryStore *memory.Store, + configDescriptor ocispec.Descriptor, layers []ocispec.Descriptor, + ociAnnotations map[string]string, parsedRef reference) (ocispec.Descriptor, error) { + + manifest := ocispec.Manifest{ + Versioned: specs.Versioned{SchemaVersion: 2}, + Config: configDescriptor, + Layers: layers, + Annotations: ociAnnotations, + } + + manifestData, err := json.Marshal(manifest) + if err != nil { + return ocispec.Descriptor{}, err + } + + return oras.TagBytes(ctx, memoryStore, ocispec.MediaTypeImageManifest, + manifestData, parsedRef.String()) +} diff --git a/pkg/helm/pkg/registry/client_http_test.go b/pkg/helm/pkg/registry/client_http_test.go index 872d19fc..546d837d 100644 --- a/pkg/helm/pkg/registry/client_http_test.go +++ b/pkg/helm/pkg/registry/client_http_test.go @@ -17,41 +17,51 @@ limitations under the License. package registry import ( + "errors" "fmt" "os" "testing" - "github.com/containerd/containerd/errdefs" "github.com/stretchr/testify/suite" + "oras.land/oras-go/v2/content" ) type HTTPRegistryClientTestSuite struct { - TestSuite + TestRegistry } func (suite *HTTPRegistryClientTestSuite) SetupSuite() { // init test client - dockerRegistry := setup(&suite.TestSuite, false, false) - - // Start Docker registry - go dockerRegistry.ListenAndServe() + setup(&suite.TestRegistry, false, false) } func (suite *HTTPRegistryClientTestSuite) TearDownSuite() { - teardown(&suite.TestSuite) - os.RemoveAll(suite.WorkspaceDir) + teardown(&suite.TestRegistry) + _ = os.RemoveAll(suite.WorkspaceDir) +} + +func (suite *HTTPRegistryClientTestSuite) Test_0_Login() { + err := suite.RegistryClient.Login(suite.DockerRegistryHost, + LoginOptBasicAuth("badverybad", "ohsobad"), + LoginOptPlainText(true)) + suite.NotNil(err, "error logging into registry with bad credentials") + + err = suite.RegistryClient.Login(suite.DockerRegistryHost, + LoginOptBasicAuth(testUsername, testPassword), + LoginOptPlainText(true)) + suite.Nil(err, "no error logging into registry with good credentials") } func (suite *HTTPRegistryClientTestSuite) Test_1_Push() { - testPush(&suite.TestSuite) + testPush(&suite.TestRegistry) } func (suite *HTTPRegistryClientTestSuite) Test_2_Pull() { - testPull(&suite.TestSuite) + testPull(&suite.TestRegistry) } func (suite *HTTPRegistryClientTestSuite) Test_3_Tags() { - testTags(&suite.TestSuite) + testTags(&suite.TestRegistry) } func (suite *HTTPRegistryClientTestSuite) Test_4_ManInTheMiddle() { @@ -60,7 +70,14 @@ func (suite *HTTPRegistryClientTestSuite) Test_4_ManInTheMiddle() { // returns content that does not match the expected digest _, err := suite.RegistryClient.Pull(ref) suite.NotNil(err) - suite.True(errdefs.IsFailedPrecondition(err)) + suite.True(errors.Is(err, content.ErrMismatchedDigest)) +} + +func (suite *HTTPRegistryClientTestSuite) Test_5_ImageIndex() { + ref := fmt.Sprintf("%s/testrepo/image-index:0.1.0", suite.FakeRegistryHost) + + _, err := suite.RegistryClient.Pull(ref) + suite.Nil(err) } func TestHTTPRegistryClientTestSuite(t *testing.T) { diff --git a/pkg/helm/pkg/registry/client_insecure_tls_test.go b/pkg/helm/pkg/registry/client_insecure_tls_test.go index 5ba79b2e..2774f5e6 100644 --- a/pkg/helm/pkg/registry/client_insecure_tls_test.go +++ b/pkg/helm/pkg/registry/client_insecure_tls_test.go @@ -24,20 +24,17 @@ import ( ) type InsecureTLSRegistryClientTestSuite struct { - TestSuite + TestRegistry } func (suite *InsecureTLSRegistryClientTestSuite) SetupSuite() { // init test client - dockerRegistry := setup(&suite.TestSuite, true, true) - - // Start Docker registry - go dockerRegistry.ListenAndServe() + setup(&suite.TestRegistry, true, true) } func (suite *InsecureTLSRegistryClientTestSuite) TearDownSuite() { - teardown(&suite.TestSuite) - os.RemoveAll(suite.WorkspaceDir) + teardown(&suite.TestRegistry) + _ = os.RemoveAll(suite.WorkspaceDir) } func (suite *InsecureTLSRegistryClientTestSuite) Test_0_Login() { @@ -53,20 +50,23 @@ func (suite *InsecureTLSRegistryClientTestSuite) Test_0_Login() { } func (suite *InsecureTLSRegistryClientTestSuite) Test_1_Push() { - testPush(&suite.TestSuite) + testPush(&suite.TestRegistry) } func (suite *InsecureTLSRegistryClientTestSuite) Test_2_Pull() { - testPull(&suite.TestSuite) + testPull(&suite.TestRegistry) } func (suite *InsecureTLSRegistryClientTestSuite) Test_3_Tags() { - testTags(&suite.TestSuite) + testTags(&suite.TestRegistry) } func (suite *InsecureTLSRegistryClientTestSuite) Test_4_Logout() { err := suite.RegistryClient.Logout("this-host-aint-real:5000") - suite.NotNil(err, "error logging out of registry that has no entry") + if err != nil { + // credential backend for mac generates an error + suite.NotNil(err, "failed to delete the credential for this-host-aint-real:5000") + } err = suite.RegistryClient.Logout(suite.DockerRegistryHost) suite.Nil(err, "no error logging out of registry") diff --git a/pkg/helm/pkg/registry/client_test.go b/pkg/helm/pkg/registry/client_test.go new file mode 100644 index 00000000..98a8b2ea --- /dev/null +++ b/pkg/helm/pkg/registry/client_test.go @@ -0,0 +1,168 @@ +/* +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 registry + +import ( + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/require" + "oras.land/oras-go/v2/content/memory" +) + +// Inspired by oras test +// https://github.com/oras-project/oras-go/blob/05a2b09cbf2eab1df691411884dc4df741ec56ab/content_test.go#L1802 +func TestTagManifestTransformsReferences(t *testing.T) { + memStore := memory.New() + client := &Client{out: io.Discard} + ctx := t.Context() + + refWithPlus := "test-registry.io/charts/test:1.0.0+metadata" + expectedRef := "test-registry.io/charts/test:1.0.0_metadata" // + becomes _ + + configDesc := ocispec.Descriptor{MediaType: ConfigMediaType, Digest: "sha256:config", Size: 100} + layers := []ocispec.Descriptor{{MediaType: ChartLayerMediaType, Digest: "sha256:layer", Size: 200}} + + parsedRef, err := newReference(refWithPlus) + require.NoError(t, err) + + desc, err := client.tagManifest(ctx, memStore, configDesc, layers, nil, parsedRef) + require.NoError(t, err) + + transformedDesc, err := memStore.Resolve(ctx, expectedRef) + require.NoError(t, err, "Should find the reference with _ instead of +") + require.Equal(t, desc.Digest, transformedDesc.Digest) + + _, err = memStore.Resolve(ctx, refWithPlus) + require.Error(t, err, "Should NOT find the reference with the original +") +} + +// Verifies that Login always restores ForceAttemptOAuth2 to false on success. +func TestLogin_ResetsForceAttemptOAuth2_OnSuccess(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v2/" { + // Accept either HEAD or GET + w.WriteHeader(http.StatusOK) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + host := strings.TrimPrefix(srv.URL, "http://") + + credFile := filepath.Join(t.TempDir(), "config.json") + c, err := NewClient( + ClientOptWriter(io.Discard), + ClientOptCredentialsFile(credFile), + ) + if err != nil { + t.Fatalf("NewClient error: %v", err) + } + + if c.authorizer == nil || c.authorizer.ForceAttemptOAuth2 { + t.Fatalf("expected ForceAttemptOAuth2 default to be false") + } + + // Call Login with plain HTTP against our test server + if err := c.Login(host, LoginOptPlainText(true), LoginOptBasicAuth("u", "p")); err != nil { + t.Fatalf("Login error: %v", err) + } + + if c.authorizer.ForceAttemptOAuth2 { + t.Errorf("ForceAttemptOAuth2 should be false after successful Login") + } +} + +// Verifies that Login restores ForceAttemptOAuth2 to false even when ping fails. +func TestLogin_ResetsForceAttemptOAuth2_OnFailure(t *testing.T) { + t.Parallel() + + // Start and immediately close, so connections will fail + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) + host := strings.TrimPrefix(srv.URL, "http://") + srv.Close() + + credFile := filepath.Join(t.TempDir(), "config.json") + c, err := NewClient( + ClientOptWriter(io.Discard), + ClientOptCredentialsFile(credFile), + ) + if err != nil { + t.Fatalf("NewClient error: %v", err) + } + + // Invoke Login, expect an error but ForceAttemptOAuth2 must end false + _ = c.Login(host, LoginOptPlainText(true), LoginOptBasicAuth("u", "p")) + + if c.authorizer.ForceAttemptOAuth2 { + t.Errorf("ForceAttemptOAuth2 should be false after failed Login") + } +} + +// TestWarnIfHostHasPath verifies that warnIfHostHasPath correctly detects path components. +func TestWarnIfHostHasPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + host string + wantWarn bool + }{ + { + name: "domain only", + host: "ghcr.io", + wantWarn: false, + }, + { + name: "domain with port", + host: "localhost:8000", + wantWarn: false, + }, + { + name: "domain with repository path", + host: "ghcr.io/terryhowe", + wantWarn: true, + }, + { + name: "domain with nested path", + host: "ghcr.io/terryhowe/myrepo", + wantWarn: true, + }, + { + name: "localhost with port and path", + host: "localhost:8000/myrepo", + wantWarn: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := warnIfHostHasPath(tt.host) + if got != tt.wantWarn { + t.Errorf("warnIfHostHasPath(%q) = %v, want %v", tt.host, got, tt.wantWarn) + } + }) + } +} diff --git a/pkg/helm/pkg/registry/client_tls_test.go b/pkg/helm/pkg/registry/client_tls_test.go index 518cfced..ddeeb3b6 100644 --- a/pkg/helm/pkg/registry/client_tls_test.go +++ b/pkg/helm/pkg/registry/client_tls_test.go @@ -17,6 +17,8 @@ limitations under the License. package registry import ( + "crypto/tls" + "crypto/x509" "os" "testing" @@ -24,20 +26,17 @@ import ( ) type TLSRegistryClientTestSuite struct { - TestSuite + TestRegistry } func (suite *TLSRegistryClientTestSuite) SetupSuite() { // init test client - dockerRegistry := setup(&suite.TestSuite, true, false) - - // Start Docker registry - go dockerRegistry.ListenAndServe() + setup(&suite.TestRegistry, true, false) } func (suite *TLSRegistryClientTestSuite) TearDownSuite() { - teardown(&suite.TestSuite) - os.RemoveAll(suite.WorkspaceDir) + teardown(&suite.TestRegistry) + _ = os.RemoveAll(suite.WorkspaceDir) } func (suite *TLSRegistryClientTestSuite) Test_0_Login() { @@ -52,21 +51,48 @@ func (suite *TLSRegistryClientTestSuite) Test_0_Login() { suite.Nil(err, "no error logging into registry with good credentials") } +func (suite *TLSRegistryClientTestSuite) Test_1_Login() { + err := suite.RegistryClient.Login(suite.DockerRegistryHost, + LoginOptBasicAuth("badverybad", "ohsobad"), + LoginOptTLSClientConfigFromConfig(&tls.Config{})) + suite.NotNil(err, "error logging into registry with bad credentials") + + // Create a *tls.Config from tlsCert, tlsKey, and tlsCA. + cert, err := tls.LoadX509KeyPair(tlsCert, tlsKey) + suite.Nil(err, "error loading x509 key pair") + rootCAs := x509.NewCertPool() + caCert, err := os.ReadFile(tlsCA) + suite.Nil(err, "error reading CA certificate") + rootCAs.AppendCertsFromPEM(caCert) + conf := &tls.Config{ + Certificates: []tls.Certificate{cert}, + RootCAs: rootCAs, + } + + err = suite.RegistryClient.Login(suite.DockerRegistryHost, + LoginOptBasicAuth(testUsername, testPassword), + LoginOptTLSClientConfigFromConfig(conf)) + suite.Nil(err, "no error logging into registry with good credentials") +} + func (suite *TLSRegistryClientTestSuite) Test_1_Push() { - testPush(&suite.TestSuite) + testPush(&suite.TestRegistry) } func (suite *TLSRegistryClientTestSuite) Test_2_Pull() { - testPull(&suite.TestSuite) + testPull(&suite.TestRegistry) } func (suite *TLSRegistryClientTestSuite) Test_3_Tags() { - testTags(&suite.TestSuite) + testTags(&suite.TestRegistry) } func (suite *TLSRegistryClientTestSuite) Test_4_Logout() { err := suite.RegistryClient.Logout("this-host-aint-real:5000") - suite.NotNil(err, "error logging out of registry that has no entry") + if err != nil { + // credential backend for mac generates an error + suite.NotNil(err, "failed to delete the credential for this-host-aint-real:5000") + } err = suite.RegistryClient.Logout(suite.DockerRegistryHost) suite.Nil(err, "no error logging out of registry") diff --git a/pkg/helm/pkg/registry/constants.go b/pkg/helm/pkg/registry/constants.go index 570b6f0d..940119a5 100644 --- a/pkg/helm/pkg/registry/constants.go +++ b/pkg/helm/pkg/registry/constants.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package registry // import "helm.sh/helm/v3/pkg/registry" +package registry // import "github.com/werf/nelm/pkg/helm/pkg/registry" const ( // OCIScheme is the URL scheme for OCI-based requests diff --git a/pkg/helm/pkg/registry/generic.go b/pkg/helm/pkg/registry/generic.go new file mode 100644 index 00000000..b46133d9 --- /dev/null +++ b/pkg/helm/pkg/registry/generic.go @@ -0,0 +1,161 @@ +/* +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 registry + +import ( + "context" + "io" + "net/http" + "slices" + "sort" + "sync" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content" + "oras.land/oras-go/v2/content/memory" + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials" +) + +// GenericClient provides low-level OCI operations without artifact-specific assumptions +type GenericClient struct { + debug bool + enableCache bool + credentialsFile string + username string + password string + out io.Writer + authorizer *auth.Client + registryAuthorizer RemoteClient + credentialsStore credentials.Store + httpClient *http.Client + plainHTTP bool +} + +// GenericPullOptions configures a generic pull operation +type GenericPullOptions struct { + // MediaTypes to include in the pull (empty means all) + AllowedMediaTypes []string + // Skip descriptors with these media types + SkipMediaTypes []string + // Custom PreCopy function for filtering + PreCopy func(context.Context, ocispec.Descriptor) error +} + +// GenericPullResult contains the result of a generic pull operation +type GenericPullResult struct { + Manifest ocispec.Descriptor + Descriptors []ocispec.Descriptor + MemoryStore *memory.Store + Ref string +} + +// NewGenericClient creates a new generic OCI client from an existing Client +func NewGenericClient(client *Client) *GenericClient { + return &GenericClient{ + debug: client.debug, + enableCache: client.enableCache, + credentialsFile: client.credentialsFile, + username: client.username, + password: client.password, + out: client.out, + authorizer: client.authorizer, + registryAuthorizer: client.registryAuthorizer, + credentialsStore: client.credentialsStore, + httpClient: client.httpClient, + plainHTTP: client.plainHTTP, + } +} + +// PullGeneric performs a generic OCI pull without artifact-specific assumptions +func (c *GenericClient) PullGeneric(ref string, options GenericPullOptions) (*GenericPullResult, error) { + parsedRef, err := newReference(ref) + if err != nil { + return nil, err + } + + memoryStore := memory.New() + var descriptors []ocispec.Descriptor + + // Set up a repository with authentication and configuration + repository, err := remote.NewRepository(parsedRef.String()) + if err != nil { + return nil, err + } + repository.PlainHTTP = c.plainHTTP + repository.Client = c.authorizer + + ctx := context.Background() + + // Prepare allowed media types for filtering + var allowedMediaTypes []string + if len(options.AllowedMediaTypes) > 0 { + allowedMediaTypes = make([]string, len(options.AllowedMediaTypes)) + copy(allowedMediaTypes, options.AllowedMediaTypes) + sort.Strings(allowedMediaTypes) + } + + var mu sync.Mutex + manifest, err := oras.Copy(ctx, repository, parsedRef.String(), memoryStore, "", oras.CopyOptions{ + CopyGraphOptions: oras.CopyGraphOptions{ + PreCopy: func(ctx context.Context, desc ocispec.Descriptor) error { + // Apply a custom PreCopy function if provided + if options.PreCopy != nil { + if err := options.PreCopy(ctx, desc); err != nil { + return err + } + } + + mediaType := desc.MediaType + + // Skip media types if specified + if slices.Contains(options.SkipMediaTypes, mediaType) { + return oras.SkipNode + } + + // Filter by allowed media types if specified + if len(allowedMediaTypes) > 0 { + if i := sort.SearchStrings(allowedMediaTypes, mediaType); i >= len(allowedMediaTypes) || allowedMediaTypes[i] != mediaType { + return oras.SkipNode + } + } + + mu.Lock() + descriptors = append(descriptors, desc) + mu.Unlock() + return nil + }, + }, + }) + if err != nil { + return nil, err + } + + return &GenericPullResult{ + Manifest: manifest, + Descriptors: descriptors, + MemoryStore: memoryStore, + Ref: parsedRef.String(), + }, nil +} + +// GetDescriptorData retrieves the data for a specific descriptor +func (c *GenericClient) GetDescriptorData(store *memory.Store, desc ocispec.Descriptor) ([]byte, error) { + return content.FetchAll(context.Background(), store, desc) +} diff --git a/pkg/helm/pkg/registry/main_test.go b/pkg/helm/pkg/registry/main_test.go new file mode 100644 index 00000000..4f6e11e4 --- /dev/null +++ b/pkg/helm/pkg/registry/main_test.go @@ -0,0 +1,51 @@ +/* +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 registry + +import ( + "net" + "os" + "testing" + + "github.com/foxcpp/go-mockdns" +) + +func TestMain(m *testing.M) { + // A mock DNS server needed for TLS connection testing. + var srv *mockdns.Server + var err error + + srv, err = mockdns.NewServer(map[string]mockdns.Zone{ + "helm-test-registry.": { + A: []string{"127.0.0.1"}, + }, + }, false) + if err != nil { + panic(err) + } + + saveDialFunction := net.DefaultResolver.Dial + srv.PatchNet(net.DefaultResolver) + + // Run all tests in the package + code := m.Run() + + net.DefaultResolver.Dial = saveDialFunction + _ = srv.Close() + + os.Exit(code) +} diff --git a/pkg/helm/pkg/registry/plugin.go b/pkg/helm/pkg/registry/plugin.go new file mode 100644 index 00000000..e4b4afa2 --- /dev/null +++ b/pkg/helm/pkg/registry/plugin.go @@ -0,0 +1,212 @@ +/* +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 registry + +import ( + "encoding/json" + "fmt" + "strings" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +// Plugin-specific constants +const ( + // PluginArtifactType is the artifact type for Helm plugins + PluginArtifactType = "application/vnd.helm.plugin.v1+json" +) + +// PluginPullOptions configures a plugin pull operation +type PluginPullOptions struct { + // PluginName specifies the expected plugin name for layer validation + PluginName string +} + +// PluginPullResult contains the result of a plugin pull operation +type PluginPullResult struct { + Manifest ocispec.Descriptor + PluginData []byte + Prov struct { + Data []byte + } + Ref string + PluginName string +} + +// PullPlugin downloads a plugin from an OCI registry using artifact type +func (c *Client) PullPlugin(ref string, pluginName string, options ...PluginPullOption) (*PluginPullResult, error) { + operation := &pluginPullOperation{ + pluginName: pluginName, + } + for _, option := range options { + option(operation) + } + + // Use generic client for the pull operation with artifact type filtering + genericClient := c.Generic() + genericResult, err := genericClient.PullGeneric(ref, GenericPullOptions{ + // Allow manifests and all layer types - we'll validate artifact type after download + AllowedMediaTypes: []string{ + ocispec.MediaTypeImageManifest, + "application/vnd.oci.image.layer.v1.tar", + "application/vnd.oci.image.layer.v1.tar+gzip", + }, + }) + if err != nil { + return nil, err + } + + // Process the result with plugin-specific logic + return c.processPluginPull(genericResult, operation.pluginName) +} + +// processPluginPull handles plugin-specific processing of a generic pull result using artifact type +func (c *Client) processPluginPull(genericResult *GenericPullResult, pluginName string) (*PluginPullResult, error) { + // First validate that this is actually a plugin artifact + manifestData, err := c.Generic().GetDescriptorData(genericResult.MemoryStore, genericResult.Manifest) + if err != nil { + return nil, fmt.Errorf("unable to retrieve manifest: %w", err) + } + + // Parse the manifest to check artifact type + var manifest ocispec.Manifest + if err := json.Unmarshal(manifestData, &manifest); err != nil { + return nil, fmt.Errorf("unable to parse manifest: %w", err) + } + + // Validate artifact type (for OCI v1.1+ manifests) + if manifest.ArtifactType != "" && manifest.ArtifactType != PluginArtifactType { + return nil, fmt.Errorf("expected artifact type %s, got %s", PluginArtifactType, manifest.ArtifactType) + } + + // For backwards compatibility, also check config media type if no artifact type + if manifest.ArtifactType == "" && manifest.Config.MediaType != PluginArtifactType { + return nil, fmt.Errorf("expected config media type %s for legacy compatibility, got %s", PluginArtifactType, manifest.Config.MediaType) + } + + // Find the plugin tarball and optional provenance using NAME-VERSION.tgz format + var pluginDescriptor *ocispec.Descriptor + var provenanceDescriptor *ocispec.Descriptor + var foundProvenanceName string + + // Look for layers with the expected titles/annotations + for _, layer := range manifest.Layers { + d := layer + // Check for title annotation + if title, exists := d.Annotations[ocispec.AnnotationTitle]; exists { + // Check if this looks like a plugin tarball: {pluginName}-{version}.tgz + if pluginDescriptor == nil && strings.HasPrefix(title, pluginName+"-") && strings.HasSuffix(title, ".tgz") { + pluginDescriptor = &d + } + // Check if this looks like a plugin provenance: {pluginName}-{version}.tgz.prov + if provenanceDescriptor == nil && strings.HasPrefix(title, pluginName+"-") && strings.HasSuffix(title, ".tgz.prov") { + provenanceDescriptor = &d + foundProvenanceName = title + } + } + } + + // Plugin tarball is required + if pluginDescriptor == nil { + return nil, fmt.Errorf("required layer matching pattern %s-VERSION.tgz not found in manifest", pluginName) + } + + // Build plugin-specific result + result := &PluginPullResult{ + Manifest: genericResult.Manifest, + Ref: genericResult.Ref, + PluginName: pluginName, + } + + // Fetch plugin data using generic client + genericClient := c.Generic() + result.PluginData, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *pluginDescriptor) + if err != nil { + return nil, fmt.Errorf("unable to retrieve plugin data with digest %s: %w", pluginDescriptor.Digest, err) + } + + // Fetch provenance data if available + if provenanceDescriptor != nil { + result.Prov.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *provenanceDescriptor) + if err != nil { + return nil, fmt.Errorf("unable to retrieve provenance data with digest %s: %w", provenanceDescriptor.Digest, err) + } + } + + _, _ = fmt.Fprintf(c.out, "Pulled plugin: %s\n", result.Ref) + _, _ = fmt.Fprintf(c.out, "Digest: %s\n", result.Manifest.Digest) + if result.Prov.Data != nil { + _, _ = fmt.Fprintf(c.out, "Provenance: %s\n", foundProvenanceName) + } + + if strings.Contains(result.Ref, "_") { + _, _ = fmt.Fprintf(c.out, "%s contains an underscore.\n", result.Ref) + _, _ = fmt.Fprint(c.out, registryUnderscoreMessage+"\n") + } + + return result, nil +} + +// Plugin pull operation types and options +type ( + pluginPullOperation struct { + pluginName string + withProv bool + } + + // PluginPullOption allows customizing plugin pull operations + PluginPullOption func(*pluginPullOperation) +) + +// PluginPullOptWithPluginName sets the plugin name for validation +func PluginPullOptWithPluginName(name string) PluginPullOption { + return func(operation *pluginPullOperation) { + operation.pluginName = name + } +} + +// GetPluginName extracts the plugin name from an OCI reference using proper reference parsing +func GetPluginName(source string) (string, error) { + ref, err := newReference(source) + if err != nil { + return "", fmt.Errorf("invalid OCI reference: %w", err) + } + + // Extract plugin name from the repository path + // e.g., "ghcr.io/user/plugin-name:v1.0.0" -> Repository: "user/plugin-name" + repository := ref.Repository + if repository == "" { + return "", fmt.Errorf("invalid OCI reference: missing repository") + } + + // Get the last part of the repository path as the plugin name + parts := strings.Split(repository, "/") + pluginName := parts[len(parts)-1] + + if pluginName == "" { + return "", fmt.Errorf("invalid OCI reference: cannot determine plugin name from repository %s", repository) + } + + return pluginName, nil +} + +// PullPluginOptWithProv configures the pull to fetch provenance data +func PullPluginOptWithProv(withProv bool) PluginPullOption { + return func(operation *pluginPullOperation) { + operation.withProv = withProv + } +} diff --git a/pkg/helm/pkg/registry/plugin_test.go b/pkg/helm/pkg/registry/plugin_test.go new file mode 100644 index 00000000..f8525829 --- /dev/null +++ b/pkg/helm/pkg/registry/plugin_test.go @@ -0,0 +1,93 @@ +/* +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 registry + +import ( + "testing" +) + +func TestGetPluginName(t *testing.T) { + tests := []struct { + name string + source string + expected string + expectErr bool + }{ + { + name: "valid OCI reference with tag", + source: "oci://ghcr.io/user/plugin-name:v1.0.0", + expected: "plugin-name", + }, + { + name: "valid OCI reference with digest", + source: "oci://ghcr.io/user/plugin-name@sha256:1234567890abcdef", + expected: "plugin-name", + }, + { + name: "valid OCI reference without tag", + source: "oci://ghcr.io/user/plugin-name", + expected: "plugin-name", + }, + { + name: "valid OCI reference with multiple path segments", + source: "oci://registry.example.com/org/team/plugin-name:latest", + expected: "plugin-name", + }, + { + name: "valid OCI reference with plus signs in tag", + source: "oci://registry.example.com/user/plugin-name:v1.0.0+build.1", + expected: "plugin-name", + }, + { + name: "valid OCI reference - single path segment", + source: "oci://registry.example.com/plugin", + expected: "plugin", + }, + { + name: "invalid OCI reference - no repository", + source: "oci://registry.example.com", + expectErr: true, + }, + { + name: "invalid OCI reference - malformed", + source: "not-an-oci-reference", + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pluginName, err := GetPluginName(tt.source) + + if tt.expectErr { + if err == nil { + t.Errorf("expected error but got none") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if pluginName != tt.expected { + t.Errorf("expected plugin name %q, got %q", tt.expected, pluginName) + } + }) + } +} diff --git a/pkg/helm/pkg/registry/reference.go b/pkg/helm/pkg/registry/reference.go new file mode 100644 index 00000000..9a98cf5c --- /dev/null +++ b/pkg/helm/pkg/registry/reference.go @@ -0,0 +1,84 @@ +/* +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 registry + +import ( + "fmt" + "strings" + + "oras.land/oras-go/v2/registry" +) + +type reference struct { + orasReference registry.Reference + Registry string + Repository string + Tag string + Digest string +} + +// newReference will parse and validate the reference, and clean tags when +// applicable tags are only cleaned when plus (+) signs are present and are +// converted to underscores (_) before pushing +// See https://github.com/helm/helm/issues/10166 +func newReference(raw string) (result reference, err error) { + // Remove the oci:// prefix if it is there + raw = strings.TrimPrefix(raw, OCIScheme+"://") + + // The sole possible reference modification is replacing plus (+) signs + // present in tags with underscores (_). To do this properly, we first + // need to identify a tag, and then pass it on to the reference parser + // NOTE: Passing immediately to the reference parser will fail since (+) + // signs are an invalid tag character, and simply replacing all plus (+) + // occurrences could invalidate other portions of the URI + lastIndex := strings.LastIndex(raw, "@") + if lastIndex >= 0 { + result.Digest = raw[(lastIndex + 1):] + raw = raw[:lastIndex] + } + parts := strings.Split(raw, ":") + if len(parts) > 1 && !strings.Contains(parts[len(parts)-1], "/") { + tag := parts[len(parts)-1] + + if tag != "" { + // Replace any plus (+) signs with known underscore (_) conversion + newTag := strings.ReplaceAll(tag, "+", "_") + raw = strings.ReplaceAll(raw, tag, newTag) + } + } + + result.orasReference, err = registry.ParseReference(raw) + if err != nil { + return result, err + } + result.Registry = result.orasReference.Registry + result.Repository = result.orasReference.Repository + result.Tag = result.orasReference.Reference + return result, nil +} + +func (r *reference) String() string { + if r.Tag == "" { + return r.orasReference.String() + "@" + r.Digest + } + return r.orasReference.String() +} + +// IsOCI determines whether a URL is to be treated as an OCI URL +func IsOCI(url string) bool { + return strings.HasPrefix(url, fmt.Sprintf("%s://", OCIScheme)) +} diff --git a/pkg/helm/pkg/registry/reference_test.go b/pkg/helm/pkg/registry/reference_test.go new file mode 100644 index 00000000..b6872cc3 --- /dev/null +++ b/pkg/helm/pkg/registry/reference_test.go @@ -0,0 +1,100 @@ +/* +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 registry + +import "testing" + +func verify(t *testing.T, actual reference, registry, repository, tag, digest string) { + t.Helper() + if registry != actual.orasReference.Registry { + t.Errorf("Oras reference registry expected %v actual %v", registry, actual.Registry) + } + if repository != actual.orasReference.Repository { + t.Errorf("Oras reference repository expected %v actual %v", repository, actual.Repository) + } + if tag != actual.orasReference.Reference { + t.Errorf("Oras reference reference expected %v actual %v", tag, actual.Tag) + } + if registry != actual.Registry { + t.Errorf("Registry expected %v actual %v", registry, actual.Registry) + } + if repository != actual.Repository { + t.Errorf("Repository expected %v actual %v", repository, actual.Repository) + } + if tag != actual.Tag { + t.Errorf("Tag expected %v actual %v", tag, actual.Tag) + } + if digest != actual.Digest { + t.Errorf("Digest expected %v actual %v", digest, actual.Digest) + } + expectedString := registry + if repository != "" { + expectedString = expectedString + "/" + repository + } + if tag != "" { + expectedString = expectedString + ":" + tag + } else { + expectedString = expectedString + "@" + digest + } + if actual.String() != expectedString { + t.Errorf("String expected %s actual %s", expectedString, actual.String()) + } +} + +func TestNewReference(t *testing.T) { + actual, err := newReference("registry.example.com/repository:1.0@sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888") + if err != nil { + t.Errorf("Unexpected error %v", err) + } + verify(t, actual, "registry.example.com", "repository", "1.0", "sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888") + + actual, err = newReference("oci://registry.example.com/repository:1.0@sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888") + if err != nil { + t.Errorf("Unexpected error %v", err) + } + verify(t, actual, "registry.example.com", "repository", "1.0", "sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888") + + actual, err = newReference("a/b:1@c") + if err != nil { + t.Errorf("Unexpected error %v", err) + } + verify(t, actual, "a", "b", "1", "c") + + actual, err = newReference("a/b:@") + if err != nil { + t.Errorf("Unexpected error %v", err) + } + verify(t, actual, "a", "b", "", "") + + actual, err = newReference("registry.example.com/repository:1.0+001") + if err != nil { + t.Errorf("Unexpected error %v", err) + } + verify(t, actual, "registry.example.com", "repository", "1.0_001", "") + + actual, err = newReference("thing:1.0") + if err == nil { + t.Errorf("Expect error error %v", err) + } + verify(t, actual, "", "", "", "") + + actual, err = newReference("registry.example.com/the/repository@sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888") + if err != nil { + t.Errorf("Unexpected error %v", err) + } + verify(t, actual, "registry.example.com", "the/repository", "", "sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888") +} diff --git a/pkg/helm/pkg/registry/utils_test.go b/pkg/helm/pkg/registry/registry_test.go similarity index 56% rename from pkg/helm/pkg/registry/utils_test.go rename to pkg/helm/pkg/registry/registry_test.go index 624d94de..873c1f12 100644 --- a/pkg/helm/pkg/registry/utils_test.go +++ b/pkg/helm/pkg/registry/registry_test.go @@ -35,12 +35,12 @@ import ( "github.com/distribution/distribution/v3/registry" _ "github.com/distribution/distribution/v3/registry/auth/htpasswd" _ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory" - "github.com/foxcpp/go-mockdns" - "github.com/phayes/freeport" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "golang.org/x/crypto/bcrypt" - "github.com/werf/nelm/pkg/helm/internal/tlsutil" + "github.com/werf/nelm/pkg/helm/intern/tlsutil" ) const ( @@ -58,27 +58,26 @@ var ( testPassword = "mypass" ) -type TestSuite struct { +type TestRegistry struct { suite.Suite Out io.Writer + FakeRegistryHost string DockerRegistryHost string CompromisedRegistryHost string WorkspaceDir string RegistryClient *Client - - // A mock DNS server needed for TLS connection testing. - srv *mockdns.Server + dockerRegistry *registry.Registry } -func setup(suite *TestSuite, tlsEnabled, insecure bool) *registry.Registry { +func setup(suite *TestRegistry, tlsEnabled, insecure bool) { suite.WorkspaceDir = testWorkspaceDir - os.RemoveAll(suite.WorkspaceDir) - os.Mkdir(suite.WorkspaceDir, 0700) + err := os.RemoveAll(suite.WorkspaceDir) + require.NoError(suite.T(), err, "no error removing test workspace dir") + err = os.Mkdir(suite.WorkspaceDir, 0700) + require.NoError(suite.T(), err, "no error creating test workspace dir") + + var out bytes.Buffer - var ( - out bytes.Buffer - err error - ) suite.Out = &out credentialsFile := filepath.Join(suite.WorkspaceDir, CredentialsFileBasename) @@ -88,15 +87,20 @@ func setup(suite *TestSuite, tlsEnabled, insecure bool) *registry.Registry { ClientOptEnableCache(true), ClientOptWriter(suite.Out), ClientOptCredentialsFile(credentialsFile), - ClientOptResolver(nil), + ClientOptBasicAuth(testUsername, testPassword), } if tlsEnabled { var tlsConf *tls.Config if insecure { - tlsConf, err = tlsutil.NewClientTLS("", "", "", true) + tlsConf, err = tlsutil.NewTLSConfig( + tlsutil.WithInsecureSkipVerify(true), + ) } else { - tlsConf, err = tlsutil.NewClientTLS(tlsCert, tlsKey, tlsCA, false) + tlsConf, err = tlsutil.NewTLSConfig( + tlsutil.WithCertKeyPairFiles(tlsCert, tlsKey), + tlsutil.WithCAFile(tlsCA), + ) } httpClient := &http.Client{ Transport: &http.Transport{ @@ -116,37 +120,30 @@ func setup(suite *TestSuite, tlsEnabled, insecure bool) *registry.Registry { pwBytes, err := bcrypt.GenerateFromPassword([]byte(testPassword), bcrypt.DefaultCost) suite.Nil(err, "no error generating bcrypt password for test htpasswd file") htpasswdPath := filepath.Join(suite.WorkspaceDir, testHtpasswdFileBasename) - err = os.WriteFile(htpasswdPath, []byte(fmt.Sprintf("%s:%s\n", testUsername, string(pwBytes))), 0644) + err = os.WriteFile(htpasswdPath, fmt.Appendf(nil, "%s:%s\n", testUsername, string(pwBytes)), 0644) suite.Nil(err, "no error creating test htpasswd file") // Registry config config := &configuration.Configuration{} - port, err := freeport.GetFreePort() + ln, err := net.Listen("tcp", "127.0.0.1:0") suite.Nil(err, "no error finding free port for test registry") + defer func() { _ = ln.Close() }() // Change the registry host to another host which is not localhost. // This is required because Docker enforces HTTP if the registry // host is localhost/127.0.0.1. + port := ln.Addr().(*net.TCPAddr).Port suite.DockerRegistryHost = fmt.Sprintf("helm-test-registry:%d", port) - suite.srv, _ = mockdns.NewServer(map[string]mockdns.Zone{ - "helm-test-registry.": { - A: []string{"127.0.0.1"}, - }, - }, false) - suite.srv.PatchNet(net.DefaultResolver) - config.HTTP.Addr = fmt.Sprintf(":%d", port) + config.HTTP.Addr = ln.Addr().String() config.HTTP.DrainTimeout = time.Duration(10) * time.Second config.Storage = map[string]configuration.Parameters{"inmemory": map[string]interface{}{}} - // Basic auth is not possible if we are serving HTTP. - if tlsEnabled { - config.Auth = configuration.Auth{ - "htpasswd": configuration.Parameters{ - "realm": "localhost", - "path": htpasswdPath, - }, - } + config.Auth = configuration.Auth{ + "htpasswd": configuration.Parameters{ + "realm": "localhost", + "path": htpasswdPath, + }, } // config tls @@ -161,17 +158,19 @@ func setup(suite *TestSuite, tlsEnabled, insecure bool) *registry.Registry { config.HTTP.TLS.ClientCAs = []string{tlsCA} } } - dockerRegistry, err := registry.NewRegistry(context.Background(), config) + suite.dockerRegistry, err = registry.NewRegistry(context.Background(), config) suite.Nil(err, "no error creating test registry") + suite.FakeRegistryHost = initFakeRegistryTestServer() suite.CompromisedRegistryHost = initCompromisedRegistryTestServer() - return dockerRegistry + go func() { + _ = suite.dockerRegistry.ListenAndServe() + }() } -func teardown(suite *TestSuite) { - if suite.srv != nil { - mockdns.UnpatchNet(net.DefaultResolver) - suite.srv.Close() +func teardown(suite *TestRegistry) { + if suite.dockerRegistry != nil { + _ = suite.dockerRegistry.Shutdown(context.Background()) } } @@ -179,11 +178,9 @@ func initCompromisedRegistryTestServer() string { s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "manifests") { w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") - w.WriteHeader(200) + w.WriteHeader(http.StatusOK) - // layers[0] is the blob []byte("a") - w.Write([]byte( - fmt.Sprintf(`{ "schemaVersion": 2, "config": { + _, _ = fmt.Fprintf(w, `{ "schemaVersion": 2, "config": { "mediaType": "%s", "digest": "sha256:a705ee2789ab50a5ba20930f246dbd5cc01ff9712825bb98f57ee8414377f133", "size": 181 @@ -195,19 +192,19 @@ func initCompromisedRegistryTestServer() string { "size": 1 } ] -}`, ConfigMediaType, ChartLayerMediaType))) +}`, ConfigMediaType, ChartLayerMediaType) } else if r.URL.Path == "/v2/testrepo/supposedlysafechart/blobs/sha256:a705ee2789ab50a5ba20930f246dbd5cc01ff9712825bb98f57ee8414377f133" { w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - w.Write([]byte("{\"name\":\"mychart\",\"version\":\"0.1.0\",\"description\":\"A Helm chart for Kubernetes\\n" + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("{\"name\":\"mychart\",\"version\":\"0.1.0\",\"description\":\"A Helm chart for Kubernetes\\n" + "an 'application' or a 'library' chart.\",\"apiVersion\":\"v2\",\"appVersion\":\"1.16.0\",\"type\":" + "\"application\"}")) } else if r.URL.Path == "/v2/testrepo/supposedlysafechart/blobs/sha256:ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb" { w.Header().Set("Content-Type", ChartLayerMediaType) - w.WriteHeader(200) - w.Write([]byte("b")) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("b")) } else { - w.WriteHeader(500) + w.WriteHeader(http.StatusInternalServerError) } })) @@ -215,34 +212,204 @@ func initCompromisedRegistryTestServer() string { return fmt.Sprintf("localhost:%s", u.Port()) } -func testPush(suite *TestSuite) { +func initFakeRegistryTestServer() string { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/testrepo/image-index/manifests/0.1.0": + w.Header().Set("Content-Type", ocispec.MediaTypeImageIndex) + w.Write([]byte(`{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:2771e37a12b7bcb2902456ecf3f29bf9ee11ec348e66e8eb322d9780ad7fc2df", + "size": 1035, + "platform": { + "architecture": "amd64", + "os": "linux" + }, + "annotations": { + "com.docker.official-images.bashbrew.arch": "amd64", + "org.opencontainers.image.base.name": "scratch", + "org.opencontainers.image.created": "2025-08-13T22:16:57Z", + "org.opencontainers.image.revision": "6930d60e10e81283a57be3ee3a2b5ca328a40304", + "org.opencontainers.image.source": "https://github.com/docker-library/hello-world.git#6930d60e10e81283a57be3ee3a2b5ca328a40304:amd64/hello-world", + "org.opencontainers.image.url": "https://hub.docker.com/_/hello-world", + "org.opencontainers.image.version": "linux" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:6b75187531c5e9b6a85c8946d5d82e4ef3801e051fbff338f382f3edfa60e3d2", + "size": 566, + "platform": { + "architecture": "unknown", + "os": "unknown" + }, + "annotations": { + "com.docker.official-images.bashbrew.arch": "amd64", + "vnd.docker.reference.digest": "sha256:2771e37a12b7bcb2902456ecf3f29bf9ee11ec348e66e8eb322d9780ad7fc2df", + "vnd.docker.reference.type": "attestation-manifest" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:7fbdc47de56b45d092f8f419e8b6183adf0159d00e05574c01787231b54fe28f", + "size": 815 + } + ] +}`)) + + case "/v2/testrepo/image-index/manifests/sha256:2771e37a12b7bcb2902456ecf3f29bf9ee11ec348e66e8eb322d9780ad7fc2df": + w.Header().Set("Content-Type", ocispec.MediaTypeImageManifest) + w.Write([]byte(`{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:1b44b5a3e06a9aae883e7bf25e45c100be0bb81a0e01b32de604f3ac44711634", + "size": 547 + }, + "layers": [ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": "sha256:17eec7bbc9d79fa397ac95c7283ecd04d1fe6978516932a3db110c6206430809", + "size": 2380 + } + ], + "annotations": { + "com.docker.official-images.bashbrew.arch": "amd64", + "org.opencontainers.image.base.name": "scratch", + "org.opencontainers.image.created": "2025-08-08T19:05:17Z", + "org.opencontainers.image.revision": "6930d60e10e81283a57be3ee3a2b5ca328a40304", + "org.opencontainers.image.source": "https://github.com/docker-library/hello-world.git#6930d60e10e81283a57be3ee3a2b5ca328a40304:amd64/hello-world", + "org.opencontainers.image.url": "https://hub.docker.com/_/hello-world", + "org.opencontainers.image.version": "linux" + } +}`)) + + case "/v2/testrepo/image-index/manifests/sha256:6b75187531c5e9b6a85c8946d5d82e4ef3801e051fbff338f382f3edfa60e3d2": + w.Header().Set("Content-Type", ocispec.MediaTypeImageManifest) + w.Write([]byte(`{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:ec4b6233950725be4c816667d1eb2782ad59dc65b12f7ac53f1ffa0ad5b95b5b", + "size": 167 + }, + "layers": [ + { + "mediaType": "application/vnd.in-toto+json", + "digest": "sha256:ea52d2000f90ad63267302cba134025ee586b07a63c47aa9467471a395aee6c2", + "size": 4822, + "annotations": { + "in-toto.io/predicate-type": "https://slsa.dev/provenance/v0.2" + } + } + ] +}`)) + + case "/v2/testrepo/image-index/manifests/sha256:7fbdc47de56b45d092f8f419e8b6183adf0159d00e05574c01787231b54fe28f": + w.Header().Set("Content-Type", ocispec.MediaTypeImageManifest) + w.Write([]byte(`{ + "schemaVersion": 2, + "config": { + "mediaType": "application/vnd.cncf.helm.config.v1+json", + "digest": "sha256:24de43e4a9f5ed9427479f27dd7bab9d158227abe593302a6f54d1e13a903ac3", + "size": 112 + }, + "layers": [ + { + "mediaType": "application/vnd.cncf.helm.chart.provenance.v1.prov", + "digest": "sha256:b0a02b7412f78ae93324d48df8fcc316d8482e5ad7827b5b238657a29a22f256", + "size": 695 + }, + { + "mediaType": "application/vnd.cncf.helm.chart.content.v1.tar+gzip", + "digest": "sha256:e5ef611620fb97704d8751c16bab17fedb68883bfb0edc76f78a70e9173f9b55", + "size": 973 + } + ], + "annotations": { + "org.opencontainers.image.description": "A Helm chart for Kubernetes", + "org.opencontainers.image.title": "signtest", + "org.opencontainers.image.version": "0.1.0" + } +}`)) + + case "/v2/testrepo/image-index/blobs/sha256:24de43e4a9f5ed9427479f27dd7bab9d158227abe593302a6f54d1e13a903ac3": + w.Header().Set("Content-Type", ConfigMediaType) + w.Write([]byte(`{ + "name":"signtest", + "version":"0.1.0", + "description":"A Helm chart for Kubernetes", + "apiVersion":"v1" +}`)) + + case "/v2/testrepo/image-index/blobs/sha256:b0a02b7412f78ae93324d48df8fcc316d8482e5ad7827b5b238657a29a22f256": + data, err := os.ReadFile("../downloader/testdata/signtest-0.1.0.tgz.prov") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + + w.Header().Set("Content-Type", ProvLayerMediaType) + w.Write(data) + + case "/v2/testrepo/image-index/blobs/sha256:e5ef611620fb97704d8751c16bab17fedb68883bfb0edc76f78a70e9173f9b55": + data, err := os.ReadFile("../downloader/testdata/signtest-0.1.0.tgz") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + + w.Header().Set("Content-Type", ChartLayerMediaType) + w.Write(data) + + default: + w.WriteHeader(http.StatusNotFound) + } + })) + + u, _ := url.Parse(s.URL) + return fmt.Sprintf("localhost:%s", u.Port()) +} + +func testPush(suite *TestRegistry) { + + testingChartCreationTime := "1977-09-02T22:04:05Z" + // Bad bytes ref := fmt.Sprintf("%s/testrepo/testchart:1.2.3", suite.DockerRegistryHost) - _, err := suite.RegistryClient.Push([]byte("hello"), ref, PushOptTest(true)) + _, err := suite.RegistryClient.Push([]byte("hello"), ref, PushOptCreationTime(testingChartCreationTime)) suite.NotNil(err, "error pushing non-chart bytes") // Load a test chart - chartData, err := os.ReadFile("../repo/repotest/testdata/examplechart-0.1.0.tgz") + chartData, err := os.ReadFile("../repo/v1/repotest/testdata/examplechart-0.1.0.tgz") suite.Nil(err, "no error loading test chart") meta, err := extractChartMeta(chartData) suite.Nil(err, "no error extracting chart meta") // non-strict ref (chart name) ref = fmt.Sprintf("%s/testrepo/boop:%s", suite.DockerRegistryHost, meta.Version) - _, err = suite.RegistryClient.Push(chartData, ref, PushOptTest(true)) + _, err = suite.RegistryClient.Push(chartData, ref, PushOptCreationTime(testingChartCreationTime)) suite.NotNil(err, "error pushing non-strict ref (bad basename)") // non-strict ref (chart name), with strict mode disabled - _, err = suite.RegistryClient.Push(chartData, ref, PushOptStrictMode(false), PushOptTest(true)) + _, err = suite.RegistryClient.Push(chartData, ref, PushOptStrictMode(false), PushOptCreationTime(testingChartCreationTime)) suite.Nil(err, "no error pushing non-strict ref (bad basename), with strict mode disabled") // non-strict ref (chart version) ref = fmt.Sprintf("%s/testrepo/%s:latest", suite.DockerRegistryHost, meta.Name) - _, err = suite.RegistryClient.Push(chartData, ref, PushOptTest(true)) + _, err = suite.RegistryClient.Push(chartData, ref, PushOptCreationTime(testingChartCreationTime)) suite.NotNil(err, "error pushing non-strict ref (bad tag)") // non-strict ref (chart version), with strict mode disabled - _, err = suite.RegistryClient.Push(chartData, ref, PushOptStrictMode(false), PushOptTest(true)) + _, err = suite.RegistryClient.Push(chartData, ref, PushOptStrictMode(false), PushOptCreationTime(testingChartCreationTime)) suite.Nil(err, "no error pushing non-strict ref (bad tag), with strict mode disabled") // basic push, good ref @@ -251,7 +418,7 @@ func testPush(suite *TestSuite) { meta, err = extractChartMeta(chartData) suite.Nil(err, "no error extracting chart meta") ref = fmt.Sprintf("%s/testrepo/%s:%s", suite.DockerRegistryHost, meta.Name, meta.Version) - _, err = suite.RegistryClient.Push(chartData, ref, PushOptTest(true)) + _, err = suite.RegistryClient.Push(chartData, ref, PushOptCreationTime(testingChartCreationTime)) suite.Nil(err, "no error pushing good ref") _, err = suite.RegistryClient.Pull(ref) @@ -269,10 +436,10 @@ func testPush(suite *TestSuite) { // push with prov ref = fmt.Sprintf("%s/testrepo/%s:%s", suite.DockerRegistryHost, meta.Name, meta.Version) - result, err := suite.RegistryClient.Push(chartData, ref, PushOptProvData(provData), PushOptTest(true)) + result, err := suite.RegistryClient.Push(chartData, ref, PushOptProvData(provData), PushOptCreationTime(testingChartCreationTime)) suite.Nil(err, "no error pushing good ref with prov") - _, err = suite.RegistryClient.Pull(ref) + _, err = suite.RegistryClient.Pull(ref, PullOptWithProv(true)) suite.Nil(err, "no error pulling a simple chart") // Validate the output @@ -281,12 +448,12 @@ func testPush(suite *TestSuite) { suite.Equal(ref, result.Ref) suite.Equal(meta.Name, result.Chart.Meta.Name) suite.Equal(meta.Version, result.Chart.Meta.Version) - suite.Equal(int64(684), result.Manifest.Size) + suite.Equal(int64(742), result.Manifest.Size) suite.Equal(int64(99), result.Config.Size) suite.Equal(int64(973), result.Chart.Size) suite.Equal(int64(695), result.Prov.Size) suite.Equal( - "sha256:b57e8ffd938c43253f30afedb3c209136288e6b3af3b33473e95ea3b805888e6", + "sha256:fbbade96da6050f68f94f122881e3b80051a18f13ab5f4081868dd494538f5c2", result.Manifest.Digest) suite.Equal( "sha256:8d17cb6bf6ccd8c29aace9a658495cbd5e2e87fc267876e86117c7db681c9580", @@ -299,7 +466,7 @@ func testPush(suite *TestSuite) { result.Prov.Digest) } -func testPull(suite *TestSuite) { +func testPull(suite *TestRegistry) { // bad/missing ref ref := fmt.Sprintf("%s/testrepo/no-existy:1.2.3", suite.DockerRegistryHost) _, err := suite.RegistryClient.Pull(ref) @@ -346,7 +513,7 @@ func testPull(suite *TestSuite) { // full pull with chart and prov result, err := suite.RegistryClient.Pull(ref, PullOptWithProv(true)) - suite.Nil(err, "no error pulling a chart with prov") + suite.Require().Nil(err, "no error pulling a chart with prov") // Validate the output // Note: these digests/sizes etc may change if the test chart/prov files are modified, @@ -354,12 +521,12 @@ func testPull(suite *TestSuite) { suite.Equal(ref, result.Ref) suite.Equal(meta.Name, result.Chart.Meta.Name) suite.Equal(meta.Version, result.Chart.Meta.Version) - suite.Equal(int64(684), result.Manifest.Size) + suite.Equal(int64(742), result.Manifest.Size) suite.Equal(int64(99), result.Config.Size) suite.Equal(int64(973), result.Chart.Size) suite.Equal(int64(695), result.Prov.Size) suite.Equal( - "sha256:b57e8ffd938c43253f30afedb3c209136288e6b3af3b33473e95ea3b805888e6", + "sha256:fbbade96da6050f68f94f122881e3b80051a18f13ab5f4081868dd494538f5c2", result.Manifest.Digest) suite.Equal( "sha256:8d17cb6bf6ccd8c29aace9a658495cbd5e2e87fc267876e86117c7db681c9580", @@ -370,7 +537,7 @@ func testPull(suite *TestSuite) { suite.Equal( "sha256:b0a02b7412f78ae93324d48df8fcc316d8482e5ad7827b5b238657a29a22f256", result.Prov.Digest) - suite.Equal("{\"schemaVersion\":2,\"config\":{\"mediaType\":\"application/vnd.cncf.helm.config.v1+json\",\"digest\":\"sha256:8d17cb6bf6ccd8c29aace9a658495cbd5e2e87fc267876e86117c7db681c9580\",\"size\":99},\"layers\":[{\"mediaType\":\"application/vnd.cncf.helm.chart.provenance.v1.prov\",\"digest\":\"sha256:b0a02b7412f78ae93324d48df8fcc316d8482e5ad7827b5b238657a29a22f256\",\"size\":695},{\"mediaType\":\"application/vnd.cncf.helm.chart.content.v1.tar+gzip\",\"digest\":\"sha256:e5ef611620fb97704d8751c16bab17fedb68883bfb0edc76f78a70e9173f9b55\",\"size\":973}],\"annotations\":{\"org.opencontainers.image.description\":\"A Helm chart for Kubernetes\",\"org.opencontainers.image.title\":\"signtest\",\"org.opencontainers.image.version\":\"0.1.0\"}}", + suite.Equal("{\"schemaVersion\":2,\"config\":{\"mediaType\":\"application/vnd.cncf.helm.config.v1+json\",\"digest\":\"sha256:8d17cb6bf6ccd8c29aace9a658495cbd5e2e87fc267876e86117c7db681c9580\",\"size\":99},\"layers\":[{\"mediaType\":\"application/vnd.cncf.helm.chart.provenance.v1.prov\",\"digest\":\"sha256:b0a02b7412f78ae93324d48df8fcc316d8482e5ad7827b5b238657a29a22f256\",\"size\":695},{\"mediaType\":\"application/vnd.cncf.helm.chart.content.v1.tar+gzip\",\"digest\":\"sha256:e5ef611620fb97704d8751c16bab17fedb68883bfb0edc76f78a70e9173f9b55\",\"size\":973}],\"annotations\":{\"org.opencontainers.image.created\":\"1977-09-02T22:04:05Z\",\"org.opencontainers.image.description\":\"A Helm chart for Kubernetes\",\"org.opencontainers.image.title\":\"signtest\",\"org.opencontainers.image.version\":\"0.1.0\"}}", string(result.Manifest.Data)) suite.Equal("{\"name\":\"signtest\",\"version\":\"0.1.0\",\"description\":\"A Helm chart for Kubernetes\",\"apiVersion\":\"v1\"}", string(result.Config.Data)) @@ -378,7 +545,7 @@ func testPull(suite *TestSuite) { suite.Equal(provData, result.Prov.Data) } -func testTags(suite *TestSuite) { +func testTags(suite *TestRegistry) { // Load test chart (to build ref pushed in previous test) chartData, err := os.ReadFile("../downloader/testdata/local-subchart-0.1.0.tgz") suite.Nil(err, "no error loading test chart") diff --git a/pkg/helm/pkg/registry/tag.go b/pkg/helm/pkg/registry/tag.go new file mode 100644 index 00000000..39e94c99 --- /dev/null +++ b/pkg/helm/pkg/registry/tag.go @@ -0,0 +1,59 @@ +/* +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 registry // import "github.com/werf/nelm/pkg/helm/pkg/registry" + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" +) + +func GetTagMatchingVersionOrConstraint(tags []string, versionString string) (string, error) { + var constraint *semver.Constraints + if versionString == "" { + // If the string is empty, set a wildcard constraint + constraint, _ = semver.NewConstraint("*") + } else { + // when customer inputs a specific version, check whether there's an exact match first + for _, v := range tags { + if versionString == v { + return v, nil + } + } + + // Otherwise set constraint to the string given + var err error + constraint, err = semver.NewConstraint(versionString) + if err != nil { + return "", err + } + } + + // Otherwise try to find the first available version matching the string, + // in case it is a constraint + for _, v := range tags { + test, err := semver.NewVersion(v) + if err != nil { + continue + } + if constraint.Check(test) { + return v, nil + } + } + + return "", fmt.Errorf("could not locate a version matching provided version string %s", versionString) +} diff --git a/pkg/helm/pkg/registry/tag_test.go b/pkg/helm/pkg/registry/tag_test.go new file mode 100644 index 00000000..09f0f12e --- /dev/null +++ b/pkg/helm/pkg/registry/tag_test.go @@ -0,0 +1,122 @@ +/* +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 registry + +import ( + "strings" + "testing" +) + +func TestGetTagMatchingVersionOrConstraint_ExactMatch(t *testing.T) { + tags := []string{"1.0.0", "1.2.3", "2.0.0"} + got, err := GetTagMatchingVersionOrConstraint(tags, "1.2.3") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.2.3" { + t.Fatalf("expected exact match '1.2.3', got %q", got) + } +} + +func TestGetTagMatchingVersionOrConstraint_EmptyVersionWildcard(t *testing.T) { + // Includes a non-semver tag which should be skipped + tags := []string{"latest", "0.9.0", "1.0.0"} + got, err := GetTagMatchingVersionOrConstraint(tags, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should pick the first valid semver tag in order, which is 0.9.0 + if got != "0.9.0" { + t.Fatalf("expected '0.9.0', got %q", got) + } +} + +func TestGetTagMatchingVersionOrConstraint_ConstraintRange(t *testing.T) { + tags := []string{"0.5.0", "1.0.0", "1.1.0", "2.0.0"} + + // Caret range + got, err := GetTagMatchingVersionOrConstraint(tags, "^1.0.0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.0.0" { // first match in order + t.Fatalf("expected '1.0.0', got %q", got) + } + + // Compound range + got, err = GetTagMatchingVersionOrConstraint(tags, ">=1.0.0 <2.0.0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.0.0" { + t.Fatalf("expected '1.0.0', got %q", got) + } +} + +func TestGetTagMatchingVersionOrConstraint_InvalidConstraint(t *testing.T) { + tags := []string{"1.0.0"} + _, err := GetTagMatchingVersionOrConstraint(tags, ">a1") + if err == nil { + t.Fatalf("expected error for invalid constraint") + } +} + +func TestGetTagMatchingVersionOrConstraint_NoMatches(t *testing.T) { + tags := []string{"0.1.0", "0.2.0"} + _, err := GetTagMatchingVersionOrConstraint(tags, ">=1.0.0") + if err == nil { + t.Fatalf("expected error when no tags match") + } + if !strings.Contains(err.Error(), ">=1.0.0") { + t.Fatalf("expected error to contain version string, got: %v", err) + } +} + +func TestGetTagMatchingVersionOrConstraint_SkipsNonSemverTags(t *testing.T) { + tags := []string{"alpha", "1.0.0", "beta", "1.1.0"} + got, err := GetTagMatchingVersionOrConstraint(tags, ">=1.0.0 <2.0.0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.0.0" { + t.Fatalf("expected '1.0.0', got %q", got) + } +} + +func TestGetTagMatchingVersionOrConstraint_OrderMatters_FirstMatchReturned(t *testing.T) { + // Both 1.2.0 and 1.3.0 satisfy >=1.2.0 <2.0.0, but the function returns the first in input order + tags := []string{"1.3.0", "1.2.0"} + got, err := GetTagMatchingVersionOrConstraint(tags, ">=1.2.0 <2.0.0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.3.0" { + t.Fatalf("expected '1.3.0' (first satisfying tag), got %q", got) + } +} + +func TestGetTagMatchingVersionOrConstraint_ExactMatchHasPrecedence(t *testing.T) { + // Exact match should be returned even if another earlier tag would match the parsed constraint + tags := []string{"1.3.0", "1.2.3"} + got, err := GetTagMatchingVersionOrConstraint(tags, "1.2.3") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.2.3" { + t.Fatalf("expected exact match '1.2.3', got %q", got) + } +} diff --git a/pkg/helm/pkg/registry/testdata/tls/ca.crt b/pkg/helm/pkg/registry/testdata/tls/ca.crt index d5b845ac..8c46ff81 100644 --- a/pkg/helm/pkg/registry/testdata/tls/ca.crt +++ b/pkg/helm/pkg/registry/testdata/tls/ca.crt @@ -1,21 +1,21 @@ -----BEGIN CERTIFICATE----- -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 -----END CERTIFICATE----- diff --git a/pkg/helm/pkg/registry/testdata/tls/ca.key b/pkg/helm/pkg/registry/testdata/tls/ca.key new file mode 100644 index 00000000..f228b4d2 --- /dev/null +++ b/pkg/helm/pkg/registry/testdata/tls/ca.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCqOrCgXpMjeSjW +JYanmSG/2K4zk0HXeU3eMt5bkshlqHnEwJFD5tMZkJZUsGPiJr9AvAqYu2V9/gMK +UptvHgxmMkh9BZYCnXAGzhl+OogYcJA5l/YBuDvmgz8M3aRZr7xdIA9KtepnDlp7 +NRWXsgRHzJNMBkV4PpEVHbJTVdjHVYERCw0C1kcb6wjzshnmUmJJJVEQDRCCaYym +tIymR6kKrZzIw2FeyXxcccbvTsKILItEECYmRNevo1mc5/f8BEXxIzEPhDpoKSTq +5JjWHCQH1shkwWyg2neL7g0UJ8nyV0pqqScE0L1WUZ1BHnVJAmGmR61WXxA3xCFz +JHSc2enRAgMBAAECggEAJVX2A1Z64x7hzAYzAHNfqZo2qu0zVbUvVPrHNkJ9XX6U +Jokt0zy/NC44Kp79aU6iR+p2UIVZf0bFF/CCUt6+TXPd3j3pZu1s8rElekAQNXwK +xfcEZ+AmkypaG9JJB7q5j5tGf1Zi8PN++OLtt3W95pmB/PyrI/JlE8KNqCV+BEnq +jLheACmehK+G7Rtez128lPvWHAnUTuQQ0wql1z4Z9VB5UwCYD3AxDz34jd8lwZQ1 +RQLUQblN46zpzkBTAX7sTmi9/y0nHJ7rJukTKxDciZ0xPkhtiAKjh6R2wb1TO51Q +fyGT7iyvtxnqQf+VoNYZGiQ/L7DMppSEHUMm0gkZuQKBgQDoFmLz5J7spQgASjXi +OLt8lWQOovzNC7K/pjILhD86o58efbZs6NdBrdq8GbeBtowd8HW0nwrxPbk0YN8W +Fr8kl6hAHYd4UYpMWYNDmB7KIVTAoU/Fk+p5AjXIBwQcYm9H66tDAO/yC8G8EEzu +iPoBTBQGMss87LH0jsSCDO0oQwKBgQC7xLY58zrU/cdK+ZbKmNA158CibH6ksXHP +Z4gm+yMW0t7Jdd39L+CfyAEWF9BAagJUuiaxIq3ZiHu7rA6PJ2G8jqRcIHyFgMRk +sxKTd7F86AI/IEZy7k0l//E4AsXERVgafvRuuSwYsm+ns6cuVYjAYRaHHinZpQao +Y98SxuxeWwKBgGFE+KX1XHIb3JWahKjSVCmrxuqnfsJFM95Evla7T3C5ILg7wdg1 +Yfoh7jnFoXZY1rK5k+tmeMSQtO1x6C2uzN9+PELa3Wsc6ZSEM5KBz+2xOH8fXHqX +Or8KoRW7cwqears+12FWpDnSmZjDUCrs97LRetb6NNnM7exsZYmH92FXAoGBAJDZ +fm4UCfWXVK+s/TuLSUvcXYmvQr9QN+j1CF5x7C7GO6GUcMzJq3H3e4cMldWrMeMk +u4Z4pz6iADnV0GF00vv/2iFL2mOu41J/pjvm4R/nZxxFjLNKzG8dE3vO/7uadw3x +lCT6al8e/+2SNM0UpOsrupI/na9NlGZArSyyElPzAoGBAIVv0H798SZjUxpfLT8s ++DI1QFbenNeoEaeXdkYtGrSPUhfZQQ2F744QDsbMm6+4oFkD9yg2A3DvSbd9+WrP +eDKKA5MAeNiD3X6glEcQOE1x6iTZ0jEXArv1n/qCl1qaUDPDUr8meIlkuwRgwyyW +vKxiQdtK+ZLUNfU2R5xZwo+X +-----END PRIVATE KEY----- diff --git a/pkg/helm/pkg/registry/testdata/tls/client.crt b/pkg/helm/pkg/registry/testdata/tls/client.crt index 5b1daf27..f54f46c7 100644 --- a/pkg/helm/pkg/registry/testdata/tls/client.crt +++ b/pkg/helm/pkg/registry/testdata/tls/client.crt @@ -1,20 +1,21 @@ -----BEGIN CERTIFICATE----- -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 -----END CERTIFICATE----- diff --git a/pkg/helm/pkg/registry/testdata/tls/client.key b/pkg/helm/pkg/registry/testdata/tls/client.key index 2f6a8aa1..3e764500 100644 --- a/pkg/helm/pkg/registry/testdata/tls/client.key +++ b/pkg/helm/pkg/registry/testdata/tls/client.key @@ -1,28 +1,28 @@ -----BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDG5Ws4nJ9Q7vXC -Wp79p7yk1V/fP7pvtoJhGsoniXrpEow/P24/mUcP1AiYJNQt10RHjyJGvmfNpQqo -1Sw+fi1Nx1of0QStr48On5h5OjJkBE75yjEKrzK3U3FeuVsT+r0h71BSWu6G2GuC -v7kML6qiBkDyRxU86zb45up4II7pAakQLFE47bKxcfKPLZufAYLNlHP3QzjPSJ9d -x7L1EvNuYSxjKarpFtp5C93N6+BeHMgA2S0gu4HQPfAnZp5ZY/YJ8GuNo9s4G3N0 -OHKXhPVqouJSkMQtQktWl/+HrEKHnZDSy+mwZUa3I76ek96RnUtJUCdUZLomVZaG -ztEvR5o/AgMBAAECggEBAKTaovRZXPOIHMrqsb0sun8lHEG+YJkXfRlfSw9aNDXa -2cPSn163fN7xr+3rGLKmKkHlsVNRnlgk46Dsj698hbBh+6FDbc1IJhrIzWgthHbB -23PO0rc4X6Dz2JParlLxELJ/2ONp2yqJVxMYNhiTqaqB5HLr1/6WNwo220CWO92D -vLz3rBHO5Vw5b5Y6Kt6MN6ciIHB2k+obhh4GQRJjUhvmmKCzbk1/R1PFYNwhhMN0 -Av6BdwFgngvNzJ8KMxGia7WJSvDYUk0++RRZ1esiZqwWRVCFFkm4Hj+gKJq6Xnz0 -a2nSvlC9k4GJvD9yY9VcDTJY+WsNN3Ny29gIFUeU9IECgYEA4norD3XakMthgOQk -3NE3HSvpZ22xtVgN9uN0b/JXbg7CLlYzn3tabpbQM/4uI6VG3Mk5Pk83QfKnr4W1 -aYO3YTEQ9B4g0eu3t4zfQOibY2+/Jb7Yfv/fH+pjkI26zYDQn61gsFdV9uxF7Pgu -NGNVe/eY+RkxEWsTtb40jcrbCgsCgYEA4NLWAdlrGKWZP5nLvM1hVB8r4WS82c0e -Orfyv2NhiqfRasARC1lQCqwbmCjb0c/eQiW7lJ7iSECc/8xW3HrJBYpG/tCxi9+m -SWxZXzRXDL8bmuoVvYeA/hFZayef5qCc8eiTYGQp6N5ozQHLXuPbNu7n6YSwvoU4 -ANrVBDRXxR0CgYEAmwbfhPS6iVT+yFjjNthrrqdJXQhElgrRfEfUg3DTEj4+A7P0 -IF4y1/KaUIzUjofrSuTfL1zQSW9OA6M2PCTymTAaF9CrzKZbGuTuSaMwAtASe0b5 -MW37EQDD6MZrsZJUvIjU38DY0m6Hqx9zmV7JvFMPPqxU30R5uHWbyderOmMCgYA5 -P3afIe3TaNeNCmyGtwWBli5mRnCQRVrdONnnQjckR3db52xvp15qWUjthfnzgyrl -TRZm0c5s94cC29WCbwGhF4Tcfee35ktBhwV66KkB5efxmonOqSJ/j4tlbcGZyGwu -bTqZ4OeLFJc7HKncj8jSRCNpoxAec22/SfnUCEARQQKBgAnwaN6kmGqIW2EsNOwB -DXCvG4HI9np5xN5Wo2dz7wqGtrt0TVtJ/PNBL3iadDLyPHahwoEVceFrQwqxjPsV -AoSwVDTdX96PKM/v/2ysw1JLf7UMT59mpxFoYiXCPn5Do4D1/25UfMOsJSmFo1Ij -Hkw1bqG8QneuME16BnDQfY3b +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCytWjo0Lwd9+4J +o5DkY0EMgQ7EnMlgp3NYI8qXMD74ve/hRZaf42VFmrCGvhV3mFocFolCNrVLbQzT +2QE6qnNjMtbjBNg5tDxvagFLr1uQhHROwcN09iv/ANZsn7sf78jFxI1yhwJfroU3 +Kg5XsP8iBdoHztnSmJPsZ2cHROsPGyEE6p2htCcbSu0NFC5XbAjhbRfmuAvWcnuo +IHYnoXMFbleNXoW1QitxOwMpUxcGwCwemUOM91eo161wTXTCki8nrFgAMETnFAfk +uvDRGuQFknxlzk9oJR8zB3u3yaLpU3rbyYmigacZxSJzKrgUc8aTTuwOnlZqehUH +6V6qH8IpAgMBAAECggEAFv5M3oG25pM3GyHiu2QC41k6nXT/2xIIfvtx7lR8kbQc +iGtT90QCjHtcAaY07GObmngS1oRj/K2uBBbsd9AlEwsgR2rg6EHGsd4dhw+rtBM6 +xMRdAfBHlmKU9Dp0EOag+kMxIN56oXV6ue+NE17YYNgIZs9ISvarN7RRNwf4x4NS +wpeWBqt120B3p9mGS64vE6wFxpRKSpFcpIp+yUswI45x8mbvCBr4tNW0OQ7y+WwS +rPp7GayutEUB9etRWviw10D7pz3HrxfarrZJm65IH1Fw5Ye6ayteoWg4IY2s3qSS +gh4qMZNMPeE6G3UBmkMdUf27+Udt8bSrSoz2Z8OlVQKBgQDcMY6h0BTFJcioBLhV +qe0FmckVNzs5jtzdwXFSjQduUCZ74ag5hsW3jQ0KNvd1B/xOv/Df6rYJY3ww8cQ1 ++KRTzt5B4qZwC1swuzqHWjR/W5XBlX3hRbs+I3imveaQ9zNFpktDZhaG72AWLLpa +Y31ddrkG4a8rTZFSuOVCbyj7JQKBgQDPxN/2Ayt/x+n/A4LNDSUQiUSALIeBHCCo +UzNQojcQLyobBVCIu5E3gRqIbvyRde7MQMGhfpLuaW7wmW0hqkUtRDYb4Hy52YMg +PFkno11wdpoEN3McLJNH08q+2dFjUKzQWygelDvkQMkwiL2syu+rEoUIEOCWyW6V +mPEPmfcdtQKBgEbqgwhkTrwr7hMG6iNUxex+2f9GOYHRHBsjeQ7gMtt5XtuZEqfs +WvNBr0hx6YK8nqryMG69VgFyFAZjZxEG0k3Xm0dW6sm9LpJkSnZbO/skkPe24MLT +xXk+zVXOZVqc8ttksmqzj1/H6odZwm7oCfE3EmI//z2QDtS4jcW2rVktAoGABfdn +Xw80PpUlGRemt/C6scDfYLbmpUSDg5HwFU6zOhnAocoDSAnq36crdeOKCTtTwjXR +2ati2MnaT7p4MdFL70LYMvC9ZDDk3RYekU7VrhcZ0Skuew6kpBlm5xgmNS3p6InV +mxsypRlfLa+fksi5HTaI73RcnrfmHxGnSoVnXUkCgYAHggM+T7e11OB+aEQ0nFcL +nS58M7QgB3/Xd7jGrl9Fi5qogtHE80epiV/srWaACZV6ricCZoDikOZzH1rRL2AA +Wlmb4j9yKp4P4uN0tniU0JuFEIQgLklAsEb4BG6izHI0UpXZTKVXY0XymOBdNtaw +QakjUJVKk+LqapUGIR8xRw== -----END PRIVATE KEY----- diff --git a/pkg/helm/pkg/registry/testdata/tls/server.crt b/pkg/helm/pkg/registry/testdata/tls/server.crt index 5fae09bb..42585e77 100644 --- a/pkg/helm/pkg/registry/testdata/tls/server.crt +++ b/pkg/helm/pkg/registry/testdata/tls/server.crt @@ -1,20 +1,21 @@ -----BEGIN CERTIFICATE----- -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 -----END CERTIFICATE----- diff --git a/pkg/helm/pkg/registry/testdata/tls/server.key b/pkg/helm/pkg/registry/testdata/tls/server.key index da44121a..4f7bd54f 100644 --- a/pkg/helm/pkg/registry/testdata/tls/server.key +++ b/pkg/helm/pkg/registry/testdata/tls/server.key @@ -1,28 +1,28 @@ -----BEGIN PRIVATE KEY----- -MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDn2ODiaXza7K+u -D61d//RLxYe5rKXl8HeTWUg+06tO3koE2HrPlcdkOiCxDeZQSSjiBVS8ovQSKebW -LlzcLooUWPRE2JvRCk5m3G7nibHZHFrOSB7TKOhMzCX8/Wim9Ejqp9GuLwt2WpbB -T3AEhiYqoQzyM+PEpwI5pHRbJCYQnoptrJPOxU0dpDEb9/dogMKSne0WX4tpMkeM -Z1k3u28mxnXBeVDLqBdMkg9ccNNL+6HdenyOCqGWdA8ivjgeKSICn0Myc4EY7i7h -xmMKvThxCctL2SewEBAKONr0n0Odnm8pq0EcXec5UC0agEyjr0dxihfkXXRCDxg5 -rtDAhiOlAgMBAAECggEBAJ6kfFzwqYpz4lJMT+i+Nz+RzilyxaHtRSUCNrkmxVWW -LTfbmU1pw6IFVFFSnYHaTas60pyxNCkpmtZ7qvbOsZTyuVJSlWwYjUU9GHY+df+F -s2zrVIxQtYO3PVc7Xty+0xYd9xAlCMbXfciQvqmZ0Yvh36Xrc7MgRBmFOkkTFyjO -xaT70D5jwK0QKU8sMY+b9XvvaX59jbRmYAHL0wNcke/E7J4NKEAYfRI+x7kuFhP4 -yDbs9YE0u51cHYAGV4EujZhnv2AwvDnAWs0yHqIbVOIWI9+JRYKmPScr7b1bJfd/ -yy24GXvBu7Ss4TkfsJ/FdGXESr0Gj0ZIPIneDn/vrQECgYEA9jHu4FjTbRff+4tV -3zJJe88+yByjC6Hhj223JmRpCXQrXl2WLAYXl94p7M5NFdkD5QG7jsNUogLb73dV -ekUjuQl7IhJZYcRAXcnlkF+8pKt1duA0uRa22VtlR2wyn8oSnLV/9088Moh35sCP -MjWQDlZ/BW7YUPrOtB14eUCvMjECgYEA8RSpmXZVQdGnIIm6gC3rEhtfHQqAoBn0 -JRvnRXC/LKeVSgVF3ijeT9P/0JQuM9uxubV314nY+fhXsM5kkMZUoXMMSoxE+xPw -cgArpzwsleMn7BQ/UF3GLpdkUgNFI8bolZFbIa54F7YSFNto0NBp3mkceCJwoWmZ -BPIoo4zpV7UCgYEAviK2L8GqF5jWvPhRK300z0+xVu725ObywsijKB1oGYsEa26v -qfRSiFFl46M4WWUu4tBBv/IPDMhUf06UT0fSXPd7h0bQjPb6FvT0PFoT4MEiiNqD -HWbzdE5nm49uUYXIdgqed6tT/Fr07ttMPCStysT2eIWwvmnU9bnE7zALniECgYAr -HM7XqtnEU4HXx8macpu/OTXhM6ec+gc3O644NNl7WtzPx/GesSBQllEBM/6vN3Kp -C1LLMNOkoEzOSZqiaVVpKfHgwwTzAbXWLUGhPpmalGznQxevf5WZb2l5YSxUIZYm -aUAq3dCMLPs+z54G+b51D8cPlNkfhIrg34108hYooQKBgQDWMbc6wY6frvJCmesx -i7F/JHJweqcQdW649RCvtK8M/O062/3vvSNTxqEjPaJOGiD4Cn+D5pYchVujqlTM -8DK77N97NzQvpHm81lpKVIg5sObarvT3RnCSRpOumbX5SCBoBUs+nVC01/zZz79c -AJFLAeHI1RjhB0AFpRDCvZZk6w== +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDBO3EJf9PPiSb/ +GkdO7fZwJ6kOn2FMDAV/FC0tounV1KEdFqUwUvFYBlG9qTZx/N6/1W7wHB0mP9y0 +lHbdXY/nMggUB13/MkqJSJ1T9y97ON+Vibgfcs1WZpSPMayzmJn1S56Mlo7Iq2U9 +ioy0sIFYJ9DfT0OiviL6yKYf44AbL/XOqAi71h9U3FbUuQDeoG+Wkr69nF0QlXpZ +7Sx/VUKbJnHjLR38yVFt+wGqxL04/DTUzljCDzbNa6jaRRMtQHqR8t/PAut0ibqs +uvXb8fcBNQwgnbh9zzIBe3MVonKMPSXCCuJGS6wQ+FC3UsU5qgNfsi3/lR4UqwyE +Mty8I2wXAgMBAAECggEAAKk5/ytSlGCTicatCcZJbb0xy3ZpUcyuVCH28ABuEyiY +DugEU3PLll6Aw+JWG/Ieg1xKj3dSwWe+H785eazK3W9pYanCY4+1FSuMOW/pPkWs +IvA536ARhCmNRo27JoSJU+Wyh1tlTHOk2mukt/vs/vOb6x4NTPttIs7lUP42DC6O +e/gTvwD13Rrg9PC0aDpZzLqdmXyUoHQ4h8dfYytDE9rZ1gC2CNdd7NWvt2JUppRx +qWR5OQxm+QiZqrMDUFTZISB/bD7MX/Ubq5InAfwdznzyav4uWsxq72FuoFFGl9xh +l6WEdusyKay/eNZgXqrHyuJvmt1PUL+Azu8ZYD+C2QKBgQD/nogcrVKLzmmrnggG +lMAvF5tp3gMI7+wqALH/79Gelvj5CWzGBnS7BcuXFR5cbpLk1cW6mj16IPIRA2CR +xpGfYKtYt0j5hvIZTg3TpK3Pj/kqEv0AicdGP6SYduJYgaUwFKRzHSR+N3121v5X +MVXKb5q6pD1wb7cOc2FJAOySHQKBgQDBhR8bAg99EgvVNioSkot++kRffWxwZ9uS +k1jmhLl7djb1tND4yZGZmi8+bdw7qz7J5yEJHuJiMwOkDsBokpKykk36tjBx3UiV +Z46OiKbRkiwBLg6fio6BVwAuQpoQ+qMWwkjZFPzWiEhxTPo3ZyiJP8JlT8sG3rV4 +My3wvLagwwKBgFT3RRcDJaUC/2zkIpbNavQ8TJRsD2YxGbb8dC42cN7eH/Pnhhhs +nPBthLa7dlQTDRCzXf4gtr6ZpNyy2q6Z6l2nrEzY35DRojd3EnF/E6cinBe4KBC9 +u1dGYFetbJ8uuNG6is8YqMCrgTC3VeN1qqaXYj8XyLRO7fIHuBakD/6hAoGARDal +cUK3rPF4hE5UZDmNvFOBWFuAptqlFjSkKJVuQCu6Ub/LzXZXwVoM/yeAcvP47Phw +t6NQTycGSIT+o53O4e0aWZ5w0yIaHLflEy7uBn9MzZmrg+c2NjcxlBzb69I9PJ99 +SC/Ss9hUGMP2iyLssfxsjIOk4CYOt3Dq56nNgjsCgYBWOLVMCV10DpYKUY5LFq60 +CJppqPyBfGB+5LLYfOp8JSIh1ZwSL139A2oCynGjrIyyPksdkBUMcS/qLhT1vmzo +zdUZMwK8D/TjF037F/t34LUHweP/2pl90DUcNPHJJs/IhXji7Kpdnqf3LhSXmgNs +d7TshLFRKM1z2BlZPZ56cA== -----END PRIVATE KEY----- diff --git a/pkg/helm/pkg/registry/transport.go b/pkg/helm/pkg/registry/transport.go new file mode 100644 index 00000000..f039a815 --- /dev/null +++ b/pkg/helm/pkg/registry/transport.go @@ -0,0 +1,175 @@ +/* +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 registry + +import ( + "bytes" + "fmt" + "io" + "log/slog" + "mime" + "net/http" + "strings" + "sync/atomic" + + "oras.land/oras-go/v2/registry/remote/retry" +) + +var ( + // requestCount records the number of logged request-response pairs and will + // be used as the unique id for the next pair. + requestCount atomic.Uint64 + + // toScrub is a set of headers that should be scrubbed from the log. + toScrub = []string{ + "Authorization", + "Set-Cookie", + } +) + +// payloadSizeLimit limits the maximum size of the response body to be printed. +const payloadSizeLimit int64 = 16 * 1024 // 16 KiB + +// LoggingTransport is an http.RoundTripper that keeps track of the in-flight +// request and add hooks to report HTTP tracing events. +type LoggingTransport struct { + http.RoundTripper +} + +// NewTransport creates and returns a new instance of LoggingTransport +func NewTransport(debug bool) *retry.Transport { + type cloner[T any] interface { + Clone() T + } + + // try to copy (clone) the http.DefaultTransport so any mutations we + // perform on it (e.g. TLS config) are not reflected globally + // follow https://github.com/golang/go/issues/39299 for a more elegant + // solution in the future + transport := http.DefaultTransport + if t, ok := transport.(cloner[*http.Transport]); ok { + transport = t.Clone() + } else if t, ok := transport.(cloner[http.RoundTripper]); ok { + // this branch will not be used with go 1.20, it was added + // optimistically to try to clone if the http.DefaultTransport + // implementation changes, still the Clone method in that case + // might not return http.RoundTripper... + transport = t.Clone() + } + if debug { + transport = &LoggingTransport{RoundTripper: transport} + } + + return retry.NewTransport(transport) +} + +// RoundTrip calls base round trip while keeping track of the current request. +func (t *LoggingTransport) RoundTrip(req *http.Request) (resp *http.Response, err error) { + id := requestCount.Add(1) - 1 + + slog.Debug(req.Method, "id", id, "url", req.URL, "header", logHeader(req.Header)) + resp, err = t.RoundTripper.RoundTrip(req) + if err != nil { + slog.Debug("Response"[:len(req.Method)], "id", id, "error", err) + } else if resp != nil { + slog.Debug("Response"[:len(req.Method)], "id", id, "status", resp.Status, "header", logHeader(resp.Header), "body", logResponseBody(resp)) + } else { + slog.Debug("Response"[:len(req.Method)], "id", id, "response", "nil") + } + + return resp, err +} + +// logHeader prints out the provided header keys and values, with auth header scrubbed. +func logHeader(header http.Header) string { + if len(header) > 0 { + var headers []string + for k, v := range header { + for _, h := range toScrub { + if strings.EqualFold(k, h) { + v = []string{"*****"} + } + } + headers = append(headers, fmt.Sprintf(" %q: %q", k, strings.Join(v, ", "))) + } + return strings.Join(headers, "\n") + } + return " Empty header" +} + +// logResponseBody prints out the response body if it is printable and within size limit. +func logResponseBody(resp *http.Response) string { + if resp.Body == nil || resp.Body == http.NoBody { + return " No response body to print" + } + + // non-applicable body is not printed and remains untouched for subsequent processing + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + return " Response body without a content type is not printed" + } + if !isPrintableContentType(contentType) { + return fmt.Sprintf(" Response body of content type %q is not printed", contentType) + } + + buf := bytes.NewBuffer(nil) + body := resp.Body + // restore the body by concatenating the read body with the remaining body + resp.Body = struct { + io.Reader + io.Closer + }{ + Reader: io.MultiReader(buf, body), + Closer: body, + } + // read the body up to limit+1 to check if the body exceeds the limit + if _, err := io.CopyN(buf, body, payloadSizeLimit+1); err != nil && err != io.EOF { + return fmt.Sprintf(" Error reading response body: %v", err) + } + + readBody := buf.String() + if len(readBody) == 0 { + return " Response body is empty" + } + if containsCredentials(readBody) { + return " Response body redacted due to potential credentials" + } + if len(readBody) > int(payloadSizeLimit) { + return readBody[:payloadSizeLimit] + "\n...(truncated)" + } + return readBody +} + +// isPrintableContentType returns true if the contentType is printable. +func isPrintableContentType(contentType string) bool { + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + return false + } + + switch mediaType { + case "application/json", // JSON types + "text/plain", "text/html": // text types + return true + } + return strings.HasSuffix(mediaType, "+json") +} + +// containsCredentials returns true if the body contains potential credentials. +func containsCredentials(body string) bool { + return strings.Contains(body, `"token"`) || strings.Contains(body, `"access_token"`) +} diff --git a/pkg/helm/pkg/registry/transport_test.go b/pkg/helm/pkg/registry/transport_test.go new file mode 100644 index 00000000..b4990c52 --- /dev/null +++ b/pkg/helm/pkg/registry/transport_test.go @@ -0,0 +1,399 @@ +/* +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 registry + +import ( + "bytes" + "errors" + "io" + "net/http" + "testing" +) + +var errMockRead = errors.New("mock read error") + +type errorReader struct{} + +func (e *errorReader) Read(_ []byte) (n int, err error) { + return 0, errMockRead +} + +func Test_isPrintableContentType(t *testing.T) { + tests := []struct { + name string + contentType string + want bool + }{ + { + name: "Empty content type", + contentType: "", + want: false, + }, + { + name: "General JSON type", + contentType: "application/json", + want: true, + }, + { + name: "General JSON type with charset", + contentType: "application/json; charset=utf-8", + want: true, + }, + { + name: "Random type with application/json prefix", + contentType: "application/jsonwhatever", + want: false, + }, + { + name: "Manifest type in JSON", + contentType: "application/vnd.oci.image.manifest.v1+json", + want: true, + }, + { + name: "Manifest type in JSON with charset", + contentType: "application/vnd.oci.image.manifest.v1+json; charset=utf-8", + want: true, + }, + { + name: "Random content type in JSON", + contentType: "application/whatever+json", + want: true, + }, + { + name: "Plain text type", + contentType: "text/plain", + want: true, + }, + { + name: "Plain text type with charset", + contentType: "text/plain; charset=utf-8", + want: true, + }, + { + name: "Random type with text/plain prefix", + contentType: "text/plainnnnn", + want: false, + }, + { + name: "HTML type", + contentType: "text/html", + want: true, + }, + { + name: "Plain text type with charset", + contentType: "text/html; charset=utf-8", + want: true, + }, + { + name: "Random type with text/html prefix", + contentType: "text/htmlllll", + want: false, + }, + { + name: "Binary type", + contentType: "application/octet-stream", + want: false, + }, + { + name: "Unknown type", + contentType: "unknown/unknown", + want: false, + }, + { + name: "Invalid type", + contentType: "text/", + want: false, + }, + { + name: "Random string", + contentType: "random123!@#", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isPrintableContentType(tt.contentType); got != tt.want { + t.Errorf("isPrintableContentType() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_logResponseBody(t *testing.T) { + tests := []struct { + name string + resp *http.Response + want string + wantData []byte + }{ + { + name: "Nil body", + resp: &http.Response{ + Body: nil, + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, + want: " No response body to print", + }, + { + name: "No body", + wantData: nil, + resp: &http.Response{ + Body: http.NoBody, + ContentLength: 100, // in case of HEAD response, the content length is set but the body is empty + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, + want: " No response body to print", + }, + { + name: "Empty body", + wantData: []byte(""), + resp: &http.Response{ + Body: io.NopCloser(bytes.NewReader([]byte(""))), + ContentLength: 0, + Header: http.Header{"Content-Type": []string{"text/plain"}}, + }, + want: " Response body is empty", + }, + { + name: "Unknown content length", + wantData: []byte("whatever"), + resp: &http.Response{ + Body: io.NopCloser(bytes.NewReader([]byte("whatever"))), + ContentLength: -1, + Header: http.Header{"Content-Type": []string{"text/plain"}}, + }, + want: "whatever", + }, + { + name: "Missing content type header", + wantData: []byte("whatever"), + resp: &http.Response{ + Body: io.NopCloser(bytes.NewReader([]byte("whatever"))), + ContentLength: 8, + }, + want: " Response body without a content type is not printed", + }, + { + name: "Empty content type header", + wantData: []byte("whatever"), + resp: &http.Response{ + Body: io.NopCloser(bytes.NewReader([]byte("whatever"))), + ContentLength: 8, + Header: http.Header{"Content-Type": []string{""}}, + }, + want: " Response body without a content type is not printed", + }, + { + name: "Non-printable content type", + wantData: []byte("binary data"), + resp: &http.Response{ + Body: io.NopCloser(bytes.NewReader([]byte("binary data"))), + ContentLength: 11, + Header: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }, + want: " Response body of content type \"application/octet-stream\" is not printed", + }, + { + name: "Body at the limit", + wantData: bytes.Repeat([]byte("a"), int(payloadSizeLimit)), + resp: &http.Response{ + Body: io.NopCloser(bytes.NewReader(bytes.Repeat([]byte("a"), int(payloadSizeLimit)))), + ContentLength: payloadSizeLimit, + Header: http.Header{"Content-Type": []string{"text/plain"}}, + }, + want: string(bytes.Repeat([]byte("a"), int(payloadSizeLimit))), + }, + { + name: "Body larger than limit", + wantData: bytes.Repeat([]byte("a"), int(payloadSizeLimit+1)), + resp: &http.Response{ + Body: io.NopCloser(bytes.NewReader(bytes.Repeat([]byte("a"), int(payloadSizeLimit+1)))), // 1 byte larger than limit + ContentLength: payloadSizeLimit + 1, + Header: http.Header{"Content-Type": []string{"text/plain"}}, + }, + want: string(bytes.Repeat([]byte("a"), int(payloadSizeLimit))) + "\n...(truncated)", + }, + { + name: "Printable content type within limit", + wantData: []byte("data"), + resp: &http.Response{ + Body: io.NopCloser(bytes.NewReader([]byte("data"))), + ContentLength: 4, + Header: http.Header{"Content-Type": []string{"text/plain"}}, + }, + want: "data", + }, + { + name: "Actual body size is larger than content length", + wantData: []byte("data"), + resp: &http.Response{ + Body: io.NopCloser(bytes.NewReader([]byte("data"))), + ContentLength: 3, // mismatched content length + Header: http.Header{"Content-Type": []string{"text/plain"}}, + }, + want: "data", + }, + { + name: "Actual body size is larger than content length and exceeds limit", + wantData: bytes.Repeat([]byte("a"), int(payloadSizeLimit+1)), + resp: &http.Response{ + Body: io.NopCloser(bytes.NewReader(bytes.Repeat([]byte("a"), int(payloadSizeLimit+1)))), // 1 byte larger than limit + ContentLength: 1, // mismatched content length + Header: http.Header{"Content-Type": []string{"text/plain"}}, + }, + want: string(bytes.Repeat([]byte("a"), int(payloadSizeLimit))) + "\n...(truncated)", + }, + { + name: "Actual body size is smaller than content length", + wantData: []byte("data"), + resp: &http.Response{ + Body: io.NopCloser(bytes.NewReader([]byte("data"))), + ContentLength: 5, // mismatched content length + Header: http.Header{"Content-Type": []string{"text/plain"}}, + }, + want: "data", + }, + { + name: "Body contains token", + resp: &http.Response{ + Body: io.NopCloser(bytes.NewReader([]byte(`{"token":"12345"}`))), + ContentLength: 17, + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, + wantData: []byte(`{"token":"12345"}`), + want: " Response body redacted due to potential credentials", + }, + { + name: "Body contains access_token", + resp: &http.Response{ + Body: io.NopCloser(bytes.NewReader([]byte(`{"access_token":"12345"}`))), + ContentLength: 17, + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, + wantData: []byte(`{"access_token":"12345"}`), + want: " Response body redacted due to potential credentials", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := logResponseBody(tt.resp); got != tt.want { + t.Errorf("logResponseBody() = %v, want %v", got, tt.want) + } + // validate the response body + if tt.resp.Body != nil { + readBytes, err := io.ReadAll(tt.resp.Body) + if err != nil { + t.Errorf("failed to read body after logResponseBody(), err= %v", err) + } + if !bytes.Equal(readBytes, tt.wantData) { + t.Errorf("resp.Body after logResponseBody() = %v, want %v", readBytes, tt.wantData) + } + if closeErr := tt.resp.Body.Close(); closeErr != nil { + t.Errorf("failed to close body after logResponseBody(), err= %v", closeErr) + } + } + }) + } +} + +func Test_logResponseBody_error(t *testing.T) { + tests := []struct { + name string + resp *http.Response + want string + }{ + { + name: "Error reading body", + resp: &http.Response{ + Body: io.NopCloser(&errorReader{}), + ContentLength: 10, + Header: http.Header{"Content-Type": []string{"text/plain"}}, + }, + want: " Error reading response body: mock read error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := logResponseBody(tt.resp); got != tt.want { + t.Errorf("logResponseBody() = %v, want %v", got, tt.want) + } + if closeErr := tt.resp.Body.Close(); closeErr != nil { + t.Errorf("failed to close body after logResponseBody(), err= %v", closeErr) + } + }) + } +} + +func Test_containsCredentials(t *testing.T) { + tests := []struct { + name string + body string + want bool + }{ + { + name: "Contains token keyword", + body: `{"token": "12345"}`, + want: true, + }, + { + name: "Contains quoted token keyword", + body: `whatever "token" blah`, + want: true, + }, + { + name: "Contains unquoted token keyword", + body: `whatever token blah`, + want: false, + }, + { + name: "Contains access_token keyword", + body: `{"access_token": "12345"}`, + want: true, + }, + { + name: "Contains quoted access_token keyword", + body: `whatever "access_token" blah`, + want: true, + }, + { + name: "Contains unquoted access_token keyword", + body: `whatever access_token blah`, + want: false, + }, + { + name: "Does not contain credentials", + body: `{"key": "value"}`, + want: false, + }, + { + name: "Empty body", + body: ``, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := containsCredentials(tt.body); got != tt.want { + t.Errorf("containsCredentials() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/helm/pkg/registry/util.go b/pkg/helm/pkg/registry/util.go deleted file mode 100644 index 9508d18d..00000000 --- a/pkg/helm/pkg/registry/util.go +++ /dev/null @@ -1,248 +0,0 @@ -/* -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 registry // import "helm.sh/helm/v3/pkg/registry" - -import ( - "bytes" - "context" - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/Masterminds/semver/v3" - ocispec "github.com/opencontainers/image-spec/specs-go/v1" - "github.com/pkg/errors" - "github.com/sirupsen/logrus" - orascontext "oras.land/oras-go/pkg/context" - "oras.land/oras-go/pkg/registry" - - helmtime "github.com/werf/nelm/pkg/helm/pkg/time" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" - - "github.com/werf/nelm/pkg/helm/internal/tlsutil" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chart/loader" -) - -var immutableOciAnnotations = []string{ - ocispec.AnnotationVersion, - ocispec.AnnotationTitle, -} - -// IsOCI determines whether or not a URL is to be treated as an OCI URL -func IsOCI(url string) bool { - return strings.HasPrefix(url, fmt.Sprintf("%s://", OCIScheme)) -} - -// ContainsTag determines whether a tag is found in a provided list of tags -func ContainsTag(tags []string, tag string) bool { - for _, t := range tags { - if tag == t { - return true - } - } - return false -} - -func GetTagMatchingVersionOrConstraint(tags []string, versionString string) (string, error) { - var constraint *semver.Constraints - if versionString == "" { - // If string is empty, set wildcard constraint - constraint, _ = semver.NewConstraint("*") - } else { - // when customer input exact version, check whether have exact match - // one first - for _, v := range tags { - if versionString == v { - return v, nil - } - } - - // Otherwise set constraint to the string given - var err error - constraint, err = semver.NewConstraint(versionString) - if err != nil { - return "", err - } - } - - // Otherwise try to find the first available version matching the string, - // in case it is a constraint - for _, v := range tags { - test, err := semver.NewVersion(v) - if err != nil { - continue - } - if constraint.Check(test) { - return v, nil - } - } - - return "", errors.Errorf("Could not locate a version matching provided version string %s", versionString) -} - -// extractChartMeta is used to extract a chart metadata from a byte array -func extractChartMeta(chartData []byte, opts helmopts.HelmOptions) (*chart.Metadata, error) { - ch, err := loader.LoadArchive(bytes.NewReader(chartData), opts) - if err != nil { - return nil, err - } - return ch.Metadata, nil -} - -// ctx retrieves a fresh context. -// disable verbose logging coming from ORAS (unless debug is enabled) -func ctx(out io.Writer, debug bool) context.Context { - if !debug { - return orascontext.Background() - } - ctx := orascontext.WithLoggerFromWriter(context.Background(), out) - orascontext.GetLogger(ctx).Logger.SetLevel(logrus.DebugLevel) - return ctx -} - -// parseReference will parse and validate the reference, and clean tags when -// applicable tags are only cleaned when plus (+) signs are present, and are -// converted to underscores (_) before pushing -// See https://github.com/helm/helm/issues/10166 -func parseReference(raw string) (registry.Reference, error) { - // The sole possible reference modification is replacing plus (+) signs - // present in tags with underscores (_). To do this properly, we first - // need to identify a tag, and then pass it on to the reference parser - // NOTE: Passing immediately to the reference parser will fail since (+) - // signs are an invalid tag character, and simply replacing all plus (+) - // occurrences could invalidate other portions of the URI - parts := strings.Split(raw, ":") - if len(parts) > 1 && !strings.Contains(parts[len(parts)-1], "/") { - tag := parts[len(parts)-1] - - if tag != "" { - // Replace any plus (+) signs with known underscore (_) conversion - newTag := strings.ReplaceAll(tag, "+", "_") - raw = strings.ReplaceAll(raw, tag, newTag) - } - } - - return registry.ParseReference(raw) -} - -// NewRegistryClientWithTLS is a helper function to create a new registry client with TLS enabled. -func NewRegistryClientWithTLS(out io.Writer, certFile, keyFile, caFile string, insecureSkipTLSverify bool, registryConfig string, debug bool) (*Client, error) { - tlsConf, err := tlsutil.NewClientTLS(certFile, keyFile, caFile, insecureSkipTLSverify) - if err != nil { - return nil, fmt.Errorf("can't create TLS config for client: %s", err) - } - // Create a new registry client - registryClient, err := NewClient( - ClientOptDebug(debug), - ClientOptEnableCache(true), - ClientOptWriter(out), - ClientOptCredentialsFile(registryConfig), - ClientOptHTTPClient(&http.Client{ - Transport: &http.Transport{ - TLSClientConfig: tlsConf, - }, - }), - ) - if err != nil { - return nil, err - } - return registryClient, nil -} - -// generateOCIAnnotations will generate OCI annotations to include within the OCI manifest -func generateOCIAnnotations(meta *chart.Metadata, test bool) map[string]string { - - // Get annotations from Chart attributes - ociAnnotations := generateChartOCIAnnotations(meta, test) - - // Copy Chart annotations -annotations: - for chartAnnotationKey, chartAnnotationValue := range meta.Annotations { - - // Avoid overriding key properties - for _, immutableOciKey := range immutableOciAnnotations { - if immutableOciKey == chartAnnotationKey { - continue annotations - } - } - - // Add chart annotation - ociAnnotations[chartAnnotationKey] = chartAnnotationValue - } - - return ociAnnotations -} - -// getChartOCIAnnotations will generate OCI annotations from the provided chart -func generateChartOCIAnnotations(meta *chart.Metadata, test bool) map[string]string { - chartOCIAnnotations := map[string]string{} - - chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationDescription, meta.Description) - chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationTitle, meta.Name) - chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationVersion, meta.Version) - chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationURL, meta.Home) - - if !test { - chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationCreated, helmtime.Now().UTC().Format(time.RFC3339)) - } - - if len(meta.Sources) > 0 { - chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationSource, meta.Sources[0]) - } - - if meta.Maintainers != nil && len(meta.Maintainers) > 0 { - var maintainerSb strings.Builder - - for maintainerIdx, maintainer := range meta.Maintainers { - - if len(maintainer.Name) > 0 { - maintainerSb.WriteString(maintainer.Name) - } - - if len(maintainer.Email) > 0 { - maintainerSb.WriteString(" (") - maintainerSb.WriteString(maintainer.Email) - maintainerSb.WriteString(")") - } - - if maintainerIdx < len(meta.Maintainers)-1 { - maintainerSb.WriteString(", ") - } - - } - - chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationAuthors, maintainerSb.String()) - - } - - return chartOCIAnnotations -} - -// addToMap takes an existing map and adds an item if the value is not empty -func addToMap(inputMap map[string]string, newKey string, newValue string) map[string]string { - - // Add item to map if its - if len(strings.TrimSpace(newValue)) > 0 { - inputMap[newKey] = newValue - } - - return inputMap - -} diff --git a/pkg/helm/pkg/release/common.go b/pkg/helm/pkg/release/common.go new file mode 100644 index 00000000..f5840c1d --- /dev/null +++ b/pkg/helm/pkg/release/common.go @@ -0,0 +1,116 @@ +/* +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 release + +import ( + "errors" + "fmt" + "time" + + "github.com/werf/nelm/pkg/helm/pkg/chart" + v1release "github.com/werf/nelm/pkg/helm/pkg/release/v1" +) + +var NewAccessor func(rel Releaser) (Accessor, error) = newDefaultAccessor //nolint:revive + +var NewHookAccessor func(rel Hook) (HookAccessor, error) = newDefaultHookAccessor //nolint:revive + +func newDefaultAccessor(rel Releaser) (Accessor, error) { + switch v := rel.(type) { + case v1release.Release: + return &v1Accessor{&v}, nil + case *v1release.Release: + return &v1Accessor{v}, nil + default: + return nil, fmt.Errorf("unsupported release type: %T", rel) + } +} + +func newDefaultHookAccessor(hook Hook) (HookAccessor, error) { + switch h := hook.(type) { + case v1release.Hook: + return &v1HookAccessor{&h}, nil + case *v1release.Hook: + return &v1HookAccessor{h}, nil + default: + return nil, errors.New("unsupported release hook type") + } +} + +type v1Accessor struct { + rel *v1release.Release +} + +func (a *v1Accessor) Name() string { + return a.rel.Name +} + +func (a *v1Accessor) Namespace() string { + return a.rel.Namespace +} + +func (a *v1Accessor) Version() int { + return a.rel.Version +} + +func (a *v1Accessor) Hooks() []Hook { + var hooks = make([]Hook, len(a.rel.Hooks)) + for i, h := range a.rel.Hooks { + hooks[i] = h + } + return hooks +} + +func (a *v1Accessor) Manifest() string { + return a.rel.Manifest +} + +func (a *v1Accessor) Notes() string { + return a.rel.Info.Notes +} + +func (a *v1Accessor) Labels() map[string]string { + return a.rel.Labels +} + +func (a *v1Accessor) Chart() chart.Charter { + return a.rel.Chart +} + +func (a *v1Accessor) Status() string { + return a.rel.Info.Status.String() +} + +func (a *v1Accessor) ApplyMethod() string { + return a.rel.ApplyMethod +} + +func (a *v1Accessor) DeployedAt() time.Time { + return a.rel.Info.LastDeployed +} + +type v1HookAccessor struct { + hook *v1release.Hook +} + +func (a *v1HookAccessor) Path() string { + return a.hook.Path +} + +func (a *v1HookAccessor) Manifest() string { + return a.hook.Manifest +} diff --git a/pkg/helm/pkg/release/status.go b/pkg/helm/pkg/release/common/status.go similarity index 90% rename from pkg/helm/pkg/release/status.go rename to pkg/helm/pkg/release/common/status.go index 8b4c2068..fd501030 100644 --- a/pkg/helm/pkg/release/status.go +++ b/pkg/helm/pkg/release/common/status.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package release +package common // Status is the status of a release type Status string @@ -31,13 +31,13 @@ const ( StatusSuperseded Status = "superseded" // StatusFailed indicates that the release was not successfully deployed. StatusFailed Status = "failed" - // StatusUninstalling indicates that a uninstall operation is underway. + // StatusUninstalling indicates that an uninstall operation is underway. StatusUninstalling Status = "uninstalling" // StatusPendingInstall indicates that an install operation is underway. StatusPendingInstall Status = "pending-install" // StatusPendingUpgrade indicates that an upgrade operation is underway. StatusPendingUpgrade Status = "pending-upgrade" - // StatusPendingRollback indicates that an rollback operation is underway. + // StatusPendingRollback indicates that a rollback operation is underway. StatusPendingRollback Status = "pending-rollback" ) @@ -47,7 +47,3 @@ func (x Status) String() string { return string(x) } func (x Status) IsPending() bool { return x == StatusPendingInstall || x == StatusPendingUpgrade || x == StatusPendingRollback } - -const ( - StatusSkipped Status = "skipped" -) diff --git a/pkg/helm/pkg/release/common_test.go b/pkg/helm/pkg/release/common_test.go new file mode 100644 index 00000000..cdfbadb7 --- /dev/null +++ b/pkg/helm/pkg/release/common_test.go @@ -0,0 +1,65 @@ +/* +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 release + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/werf/nelm/pkg/helm/pkg/release/common" + rspb "github.com/werf/nelm/pkg/helm/pkg/release/v1" +) + +func TestNewDefaultAccessor(t *testing.T) { + // Testing the default implementation rather than NewAccessor which can be + // overridden by developers. + is := assert.New(t) + + // Create release + info := &rspb.Info{Status: common.StatusDeployed, LastDeployed: time.Now().Add(1000)} + labels := make(map[string]string) + labels["foo"] = "bar" + rel := &rspb.Release{ + Name: "happy-cats", + Version: 2, + Info: info, + Labels: labels, + Namespace: "default", + ApplyMethod: "csa", + } + + // newDefaultAccessor should not be called directly Instead, NewAccessor should be + // called and it will call NewDefaultAccessor. NewAccessor can be changed to a + // non-default accessor by a user so the test calls the default implementation. + // The accessor provides a means to access data on resources that are different types + // but have the same interface. Instead of properties, methods are used to access + // information. Structs with properties are useful in Go when it comes to marshalling + // and unmarshalling data (e.g. coming and going from JSON or YAML). But, structs + // can't be used with interfaces. The accessors enable access to the underlying data + // in a manner that works with Go interfaces. + accessor, err := newDefaultAccessor(rel) + is.NoError(err) + + // Verify information + is.Equal(rel.Name, accessor.Name()) + is.Equal(rel.Namespace, accessor.Namespace()) + is.Equal(rel.Version, accessor.Version()) + is.Equal(rel.ApplyMethod, accessor.ApplyMethod()) + is.Equal(rel.Labels, accessor.Labels()) +} diff --git a/pkg/helm/pkg/release/info.go b/pkg/helm/pkg/release/info.go deleted file mode 100644 index ec5400ae..00000000 --- a/pkg/helm/pkg/release/info.go +++ /dev/null @@ -1,44 +0,0 @@ -/* -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 release - -import ( - "k8s.io/apimachinery/pkg/runtime" - - "github.com/werf/nelm/pkg/helm/pkg/time" -) - -// Info describes release information. -type Info struct { - // FirstDeployed is when the release was first deployed. - FirstDeployed time.Time `json:"first_deployed,omitempty"` - // LastDeployed is when the release was last deployed. - LastDeployed time.Time `json:"last_deployed,omitempty"` - // Deleted tracks when this object was deleted. - Deleted time.Time `json:"deleted"` - // Description is human-friendly "log entry" about this release. - Description string `json:"description,omitempty"` - // Status is the current state of the release - Status Status `json:"status,omitempty"` - // Contains the rendered templates/NOTES.txt if available - Notes string `json:"notes,omitempty"` - // Contains the deployed resources information - Resources map[string][]runtime.Object `json:"resources,omitempty"` - - LastPhase *Phase `json:"last_phase,omitempty"` - LastStage *int `json:"last_stage,omitempty"` - Annotations map[string]string `json:"annotations,omitempty"` -} diff --git a/pkg/helm/pkg/release/interfaces.go b/pkg/helm/pkg/release/interfaces.go new file mode 100644 index 00000000..abadf899 --- /dev/null +++ b/pkg/helm/pkg/release/interfaces.go @@ -0,0 +1,46 @@ +/* +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 release + +import ( + "time" + + "github.com/werf/nelm/pkg/helm/pkg/chart" +) + +type Releaser interface{} + +type Hook interface{} + +type Accessor interface { + Name() string + Namespace() string + Version() int + Hooks() []Hook + Manifest() string + Notes() string + Labels() map[string]string + Chart() chart.Charter + Status() string + ApplyMethod() string + DeployedAt() time.Time +} + +type HookAccessor interface { + Path() string + Manifest() string +} diff --git a/pkg/helm/pkg/release/phase.go b/pkg/helm/pkg/release/phase.go deleted file mode 100644 index 86b66439..00000000 --- a/pkg/helm/pkg/release/phase.go +++ /dev/null @@ -1,62 +0,0 @@ -package release - -import "fmt" - -type Phase string - -const ( - PhaseInit Phase = "init" - PhaseHooksPre Phase = "hooks-pre" - PhaseRollout Phase = "rollout" - PhaseUninstall Phase = "uninstall" - PhaseHooksPost Phase = "hooks-post" -) - -// May return empty string. -func PhaseFromHookEvent(hookEvent HookEvent) Phase { - var phase Phase - switch hookEvent { - case HookPreInstall, HookPreDelete, HookPreUpgrade, HookPreRollback: - phase = PhaseHooksPre - case HookPostInstall, HookPostDelete, HookPostUpgrade, HookPostRollback: - phase = PhaseHooksPost - case HookTest: - default: - panic(fmt.Sprintf("unexpected HookEvent: %s", hookEvent.String())) - } - - return phase -} - -func SetInitPhaseStageInfo(rel *Release) *Release { - lastPhase := PhaseInit - lastStage := 0 - rel.Info.LastPhase = &lastPhase - rel.Info.LastStage = &lastStage - - return rel -} - -func SetHookPhaseStageInfo(rel *Release, hookIndex int, hook HookEvent) *Release { - lastPhase := PhaseFromHookEvent(hook) - rel.Info.LastPhase = &lastPhase - rel.Info.LastStage = &hookIndex - - return rel -} - -func SetRolloutPhaseStageInfo(rel *Release, stageIndex int) *Release { - lastPhase := PhaseRollout - rel.Info.LastPhase = &lastPhase - rel.Info.LastStage = &stageIndex - - return rel -} - -func SetUninstallPhaseStageInfo(rel *Release) *Release { - lastPhase := PhaseUninstall - rel.Info.LastPhase = &lastPhase - rel.Info.LastStage = nil - - return rel -} diff --git a/pkg/helm/pkg/release/report.go b/pkg/helm/pkg/release/report.go deleted file mode 100644 index 7ee8bc3b..00000000 --- a/pkg/helm/pkg/release/report.go +++ /dev/null @@ -1,45 +0,0 @@ -package release - -import ( - "encoding/json" - "fmt" - - "github.com/werf/nelm/pkg/helm/pkg/time" -) - -func NewDeployReport() *DeployReport { - return &DeployReport{} -} - -type DeployReport struct { - Release string `json:"release,omitempty"` - Namespace string `json:"namespace,omitempty"` - Revision int `json:"revision,omitempty"` - Status Status `json:"status,omitempty"` - LastPhase *Phase `json:"last_phase,omitempty"` - LastStage *int `json:"last_stage,omitempty"` - FirstDeployedTime time.Time `json:"first_deployed,omitempty"` - LastDeployedTime time.Time `json:"last_deployed,omitempty"` -} - -func (r *DeployReport) FromRelease(release *Release) *DeployReport { - r.Release = release.Name - r.Namespace = release.Namespace - r.Revision = release.Version - r.Status = release.Info.Status - r.LastPhase = release.Info.LastPhase - r.LastStage = release.Info.LastStage - r.FirstDeployedTime = release.Info.FirstDeployed - r.LastDeployedTime = release.Info.LastDeployed - - return r -} - -func (r *DeployReport) ToJSONData() ([]byte, error) { - data, err := json.MarshalIndent(r, "", "\t") - if err != nil { - return nil, fmt.Errorf("error marshalling deploy report: %w", err) - } - - return data, nil -} diff --git a/pkg/helm/pkg/release/responses.go b/pkg/helm/pkg/release/responses.go index 7ee1fc2e..6e0a0eae 100644 --- a/pkg/helm/pkg/release/responses.go +++ b/pkg/helm/pkg/release/responses.go @@ -18,7 +18,7 @@ package release // UninstallReleaseResponse represents a successful response to an uninstall request. type UninstallReleaseResponse struct { // Release is the release that was marked deleted. - Release *Release `json:"release,omitempty"` + Release Releaser `json:"release,omitempty"` // Info is an uninstall message Info string `json:"info,omitempty"` } diff --git a/pkg/helm/pkg/release/hook.go b/pkg/helm/pkg/release/v1/hook.go similarity index 59% rename from pkg/helm/pkg/release/hook.go rename to pkg/helm/pkg/release/v1/hook.go index 4a6a725e..f0a370c1 100644 --- a/pkg/helm/pkg/release/hook.go +++ b/pkg/helm/pkg/release/v1/hook.go @@ -14,10 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -package release +package v1 import ( - "github.com/werf/nelm/pkg/helm/pkg/time" + "encoding/json" + "time" ) // HookEvent specifies the hook event @@ -50,6 +51,17 @@ const ( func (x HookDeletePolicy) String() string { return string(x) } +// HookOutputLogPolicy specifies the hook output log policy +type HookOutputLogPolicy string + +// Hook output log policy types +const ( + HookOutputOnSucceeded HookOutputLogPolicy = "hook-succeeded" + HookOutputOnFailed HookOutputLogPolicy = "hook-failed" +) + +func (x HookOutputLogPolicy) String() string { return string(x) } + // HookAnnotation is the label name for a hook const HookAnnotation = "helm.sh/hook" @@ -59,6 +71,9 @@ const HookWeightAnnotation = "helm.sh/hook-weight" // HookDeleteAnnotation is the label name for the delete policy for a hook const HookDeleteAnnotation = "helm.sh/hook-delete-policy" +// HookOutputLogAnnotation is the label name for the output log policy for a hook +const HookOutputLogAnnotation = "helm.sh/hook-output-log-policy" + // Hook defines a hook object. type Hook struct { Name string `json:"name,omitempty"` @@ -76,14 +91,16 @@ type Hook struct { Weight int `json:"weight,omitempty"` // DeletePolicies are the policies that indicate when to delete the hook DeletePolicies []HookDeletePolicy `json:"delete_policies,omitempty"` + // OutputLogPolicies defines whether we should copy hook logs back to main process + OutputLogPolicies []HookOutputLogPolicy `json:"output_log_policies,omitempty"` } // A HookExecution records the result for the last execution of a hook for a given release. type HookExecution struct { // StartedAt indicates the date/time this hook was started - StartedAt time.Time `json:"started_at,omitempty"` + StartedAt time.Time `json:"started_at,omitzero"` // CompletedAt indicates the date/time this hook was completed. - CompletedAt time.Time `json:"completed_at,omitempty"` + CompletedAt time.Time `json:"completed_at,omitzero"` // Phase indicates whether the hook completed successfully Phase HookPhase `json:"phase"` } @@ -105,9 +122,68 @@ const ( // String converts a hook phase to a printable string func (x HookPhase) String() string { return string(x) } -const ( - HookInstall HookEvent = "install" - HookUpgrade HookEvent = "upgrade" - HookRollback HookEvent = "rollback" - HookDelete HookEvent = "delete" -) +// hookExecutionJSON is used for custom JSON marshaling/unmarshaling +type hookExecutionJSON struct { + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Phase HookPhase `json:"phase"` +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +// It handles empty string time fields by treating them as zero values. +func (h *HookExecution) UnmarshalJSON(data []byte) error { + // First try to unmarshal into a map to handle empty string time fields + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + // Replace empty string time fields with nil + for _, field := range []string{"started_at", "completed_at"} { + if val, ok := raw[field]; ok { + if str, ok := val.(string); ok && str == "" { + raw[field] = nil + } + } + } + + // Re-marshal with cleaned data + cleaned, err := json.Marshal(raw) + if err != nil { + return err + } + + // Unmarshal into temporary struct with pointer time fields + var tmp hookExecutionJSON + if err := json.Unmarshal(cleaned, &tmp); err != nil { + return err + } + + // Copy values to HookExecution struct + if tmp.StartedAt != nil { + h.StartedAt = *tmp.StartedAt + } + if tmp.CompletedAt != nil { + h.CompletedAt = *tmp.CompletedAt + } + h.Phase = tmp.Phase + + return nil +} + +// MarshalJSON implements the json.Marshaler interface. +// It omits zero-value time fields from the JSON output. +func (h HookExecution) MarshalJSON() ([]byte, error) { + tmp := hookExecutionJSON{ + Phase: h.Phase, + } + + if !h.StartedAt.IsZero() { + tmp.StartedAt = &h.StartedAt + } + if !h.CompletedAt.IsZero() { + tmp.CompletedAt = &h.CompletedAt + } + + return json.Marshal(tmp) +} diff --git a/pkg/helm/pkg/release/v1/hook_test.go b/pkg/helm/pkg/release/v1/hook_test.go new file mode 100644 index 00000000..cea2568b --- /dev/null +++ b/pkg/helm/pkg/release/v1/hook_test.go @@ -0,0 +1,231 @@ +/* +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 v1 + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHookExecutionMarshalJSON(t *testing.T) { + started := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + completed := time.Date(2025, 10, 8, 12, 5, 0, 0, time.UTC) + + tests := []struct { + name string + exec HookExecution + expected string + }{ + { + name: "all fields populated", + exec: HookExecution{ + StartedAt: started, + CompletedAt: completed, + Phase: HookPhaseSucceeded, + }, + expected: `{"started_at":"2025-10-08T12:00:00Z","completed_at":"2025-10-08T12:05:00Z","phase":"Succeeded"}`, + }, + { + name: "only phase", + exec: HookExecution{ + Phase: HookPhaseRunning, + }, + expected: `{"phase":"Running"}`, + }, + { + name: "with started time only", + exec: HookExecution{ + StartedAt: started, + Phase: HookPhaseRunning, + }, + expected: `{"started_at":"2025-10-08T12:00:00Z","phase":"Running"}`, + }, + { + name: "failed phase", + exec: HookExecution{ + StartedAt: started, + CompletedAt: completed, + Phase: HookPhaseFailed, + }, + expected: `{"started_at":"2025-10-08T12:00:00Z","completed_at":"2025-10-08T12:05:00Z","phase":"Failed"}`, + }, + { + name: "unknown phase", + exec: HookExecution{ + Phase: HookPhaseUnknown, + }, + expected: `{"phase":"Unknown"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(&tt.exec) + require.NoError(t, err) + assert.JSONEq(t, tt.expected, string(data)) + }) + } +} + +func TestHookExecutionUnmarshalJSON(t *testing.T) { + started := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + completed := time.Date(2025, 10, 8, 12, 5, 0, 0, time.UTC) + + tests := []struct { + name string + input string + expected HookExecution + wantErr bool + }{ + { + name: "all fields populated", + input: `{"started_at":"2025-10-08T12:00:00Z","completed_at":"2025-10-08T12:05:00Z","phase":"Succeeded"}`, + expected: HookExecution{ + StartedAt: started, + CompletedAt: completed, + Phase: HookPhaseSucceeded, + }, + }, + { + name: "only phase", + input: `{"phase":"Running"}`, + expected: HookExecution{ + Phase: HookPhaseRunning, + }, + }, + { + name: "empty string time fields", + input: `{"started_at":"","completed_at":"","phase":"Succeeded"}`, + expected: HookExecution{ + Phase: HookPhaseSucceeded, + }, + }, + { + name: "missing time fields", + input: `{"phase":"Failed"}`, + expected: HookExecution{ + Phase: HookPhaseFailed, + }, + }, + { + name: "null time fields", + input: `{"started_at":null,"completed_at":null,"phase":"Unknown"}`, + expected: HookExecution{ + Phase: HookPhaseUnknown, + }, + }, + { + name: "mixed empty and valid time fields", + input: `{"started_at":"2025-10-08T12:00:00Z","completed_at":"","phase":"Running"}`, + expected: HookExecution{ + StartedAt: started, + Phase: HookPhaseRunning, + }, + }, + { + name: "with started time only", + input: `{"started_at":"2025-10-08T12:00:00Z","phase":"Running"}`, + expected: HookExecution{ + StartedAt: started, + Phase: HookPhaseRunning, + }, + }, + { + name: "failed phase with times", + input: `{"started_at":"2025-10-08T12:00:00Z","completed_at":"2025-10-08T12:05:00Z","phase":"Failed"}`, + expected: HookExecution{ + StartedAt: started, + CompletedAt: completed, + Phase: HookPhaseFailed, + }, + }, + { + name: "invalid time format", + input: `{"started_at":"invalid-time","phase":"Running"}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var exec HookExecution + err := json.Unmarshal([]byte(tt.input), &exec) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.expected.StartedAt.Unix(), exec.StartedAt.Unix()) + assert.Equal(t, tt.expected.CompletedAt.Unix(), exec.CompletedAt.Unix()) + assert.Equal(t, tt.expected.Phase, exec.Phase) + }) + } +} + +func TestHookExecutionRoundTrip(t *testing.T) { + started := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + completed := time.Date(2025, 10, 8, 12, 5, 0, 0, time.UTC) + + original := HookExecution{ + StartedAt: started, + CompletedAt: completed, + Phase: HookPhaseSucceeded, + } + + data, err := json.Marshal(&original) + require.NoError(t, err) + + var decoded HookExecution + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, original.StartedAt.Unix(), decoded.StartedAt.Unix()) + assert.Equal(t, original.CompletedAt.Unix(), decoded.CompletedAt.Unix()) + assert.Equal(t, original.Phase, decoded.Phase) +} + +func TestHookExecutionEmptyStringRoundTrip(t *testing.T) { + // This test specifically verifies that empty string time fields + // are handled correctly during parsing + input := `{"started_at":"","completed_at":"","phase":"Succeeded"}` + + var exec HookExecution + err := json.Unmarshal([]byte(input), &exec) + require.NoError(t, err) + + // Verify time fields are zero values + assert.True(t, exec.StartedAt.IsZero()) + assert.True(t, exec.CompletedAt.IsZero()) + assert.Equal(t, HookPhaseSucceeded, exec.Phase) + + // Marshal back and verify empty time fields are omitted + data, err := json.Marshal(&exec) + require.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(data, &result) + require.NoError(t, err) + + // Zero time values should be omitted + assert.NotContains(t, result, "started_at") + assert.NotContains(t, result, "completed_at") + assert.Equal(t, "Succeeded", result["phase"]) +} diff --git a/pkg/helm/pkg/release/v1/info.go b/pkg/helm/pkg/release/v1/info.go new file mode 100644 index 00000000..d1aaacbd --- /dev/null +++ b/pkg/helm/pkg/release/v1/info.go @@ -0,0 +1,126 @@ +/* +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 v1 + +import ( + "encoding/json" + "time" + + "github.com/werf/nelm/pkg/helm/pkg/release/common" + + "k8s.io/apimachinery/pkg/runtime" +) + +// Info describes release information. +type Info struct { + // FirstDeployed is when the release was first deployed. + FirstDeployed time.Time `json:"first_deployed,omitzero"` + // LastDeployed is when the release was last deployed. + LastDeployed time.Time `json:"last_deployed,omitzero"` + // Deleted tracks when this object was deleted. + Deleted time.Time `json:"deleted,omitzero"` + // Description is human-friendly "log entry" about this release. + Description string `json:"description,omitempty"` + // Status is the current state of the release + Status common.Status `json:"status,omitempty"` + // Contains the rendered templates/NOTES.txt if available + Notes string `json:"notes,omitempty"` + // Contains the deployed resources information + Resources map[string][]runtime.Object `json:"resources,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` +} + +// infoJSON is used for custom JSON marshaling/unmarshaling +type infoJSON struct { + FirstDeployed *time.Time `json:"first_deployed,omitempty"` + LastDeployed *time.Time `json:"last_deployed,omitempty"` + Deleted *time.Time `json:"deleted,omitempty"` + Description string `json:"description,omitempty"` + Status common.Status `json:"status,omitempty"` + Notes string `json:"notes,omitempty"` + Resources map[string][]runtime.Object `json:"resources,omitempty"` +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +// It handles empty string time fields by treating them as zero values. +func (i *Info) UnmarshalJSON(data []byte) error { + // First try to unmarshal into a map to handle empty string time fields + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + // Replace empty string time fields with nil + for _, field := range []string{"first_deployed", "last_deployed", "deleted"} { + if val, ok := raw[field]; ok { + if str, ok := val.(string); ok && str == "" { + raw[field] = nil + } + } + } + + // Re-marshal with cleaned data + cleaned, err := json.Marshal(raw) + if err != nil { + return err + } + + // Unmarshal into temporary struct with pointer time fields + var tmp infoJSON + if err := json.Unmarshal(cleaned, &tmp); err != nil { + return err + } + + // Copy values to Info struct + if tmp.FirstDeployed != nil { + i.FirstDeployed = *tmp.FirstDeployed + } + if tmp.LastDeployed != nil { + i.LastDeployed = *tmp.LastDeployed + } + if tmp.Deleted != nil { + i.Deleted = *tmp.Deleted + } + i.Description = tmp.Description + i.Status = tmp.Status + i.Notes = tmp.Notes + i.Resources = tmp.Resources + + return nil +} + +// MarshalJSON implements the json.Marshaler interface. +// It omits zero-value time fields from the JSON output. +func (i Info) MarshalJSON() ([]byte, error) { + tmp := infoJSON{ + Description: i.Description, + Status: i.Status, + Notes: i.Notes, + Resources: i.Resources, + } + + if !i.FirstDeployed.IsZero() { + tmp.FirstDeployed = &i.FirstDeployed + } + if !i.LastDeployed.IsZero() { + tmp.LastDeployed = &i.LastDeployed + } + if !i.Deleted.IsZero() { + tmp.Deleted = &i.Deleted + } + + return json.Marshal(tmp) +} diff --git a/pkg/helm/pkg/release/v1/info_test.go b/pkg/helm/pkg/release/v1/info_test.go new file mode 100644 index 00000000..4c3cca17 --- /dev/null +++ b/pkg/helm/pkg/release/v1/info_test.go @@ -0,0 +1,285 @@ +/* +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 v1 + +import ( + "encoding/json" + "testing" + "time" + + "github.com/werf/nelm/pkg/helm/pkg/release/common" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInfoMarshalJSON(t *testing.T) { + now := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + later := time.Date(2025, 10, 8, 13, 0, 0, 0, time.UTC) + deleted := time.Date(2025, 10, 8, 14, 0, 0, 0, time.UTC) + + tests := []struct { + name string + info Info + expected string + }{ + { + name: "all fields populated", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Deleted: deleted, + Description: "Test release", + Status: common.StatusDeployed, + Notes: "Test notes", + }, + expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","deleted":"2025-10-08T14:00:00Z","description":"Test release","status":"deployed","notes":"Test notes"}`, + }, + { + name: "only required fields", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusDeployed, + }, + expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"deployed"}`, + }, + { + name: "zero time values omitted", + info: Info{ + Description: "Test release", + Status: common.StatusDeployed, + }, + expected: `{"description":"Test release","status":"deployed"}`, + }, + { + name: "with pending status", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusPendingInstall, + Description: "Installing release", + }, + expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","description":"Installing release","status":"pending-install"}`, + }, + { + name: "uninstalled with deleted time", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Deleted: deleted, + Status: common.StatusUninstalled, + Description: "Uninstalled release", + }, + expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","deleted":"2025-10-08T14:00:00Z","description":"Uninstalled release","status":"uninstalled"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(&tt.info) + require.NoError(t, err) + assert.JSONEq(t, tt.expected, string(data)) + }) + } +} + +func TestInfoUnmarshalJSON(t *testing.T) { + now := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + later := time.Date(2025, 10, 8, 13, 0, 0, 0, time.UTC) + deleted := time.Date(2025, 10, 8, 14, 0, 0, 0, time.UTC) + + tests := []struct { + name string + input string + expected Info + wantErr bool + }{ + { + name: "all fields populated", + input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","deleted":"2025-10-08T14:00:00Z","description":"Test release","status":"deployed","notes":"Test notes"}`, + expected: Info{ + FirstDeployed: now, + LastDeployed: later, + Deleted: deleted, + Description: "Test release", + Status: common.StatusDeployed, + Notes: "Test notes", + }, + }, + { + name: "only required fields", + input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"deployed"}`, + expected: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusDeployed, + }, + }, + { + name: "empty string time fields", + input: `{"first_deployed":"","last_deployed":"","deleted":"","description":"Test release","status":"deployed"}`, + expected: Info{ + Description: "Test release", + Status: common.StatusDeployed, + }, + }, + { + name: "missing time fields", + input: `{"description":"Test release","status":"deployed"}`, + expected: Info{ + Description: "Test release", + Status: common.StatusDeployed, + }, + }, + { + name: "null time fields", + input: `{"first_deployed":null,"last_deployed":null,"deleted":null,"description":"Test release","status":"deployed"}`, + expected: Info{ + Description: "Test release", + Status: common.StatusDeployed, + }, + }, + { + name: "mixed empty and valid time fields", + input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"","deleted":"","status":"deployed"}`, + expected: Info{ + FirstDeployed: now, + Status: common.StatusDeployed, + }, + }, + { + name: "pending install status", + input: `{"first_deployed":"2025-10-08T12:00:00Z","status":"pending-install","description":"Installing"}`, + expected: Info{ + FirstDeployed: now, + Status: common.StatusPendingInstall, + Description: "Installing", + }, + }, + { + name: "uninstalled with deleted time", + input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","deleted":"2025-10-08T14:00:00Z","status":"uninstalled"}`, + expected: Info{ + FirstDeployed: now, + LastDeployed: later, + Deleted: deleted, + Status: common.StatusUninstalled, + }, + }, + { + name: "failed status", + input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"failed","description":"Deployment failed"}`, + expected: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusFailed, + Description: "Deployment failed", + }, + }, + { + name: "invalid time format", + input: `{"first_deployed":"invalid-time","status":"deployed"}`, + wantErr: true, + }, + { + name: "empty object", + input: `{}`, + expected: Info{ + Status: "", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var info Info + err := json.Unmarshal([]byte(tt.input), &info) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.expected.FirstDeployed.Unix(), info.FirstDeployed.Unix()) + assert.Equal(t, tt.expected.LastDeployed.Unix(), info.LastDeployed.Unix()) + assert.Equal(t, tt.expected.Deleted.Unix(), info.Deleted.Unix()) + assert.Equal(t, tt.expected.Description, info.Description) + assert.Equal(t, tt.expected.Status, info.Status) + assert.Equal(t, tt.expected.Notes, info.Notes) + assert.Equal(t, tt.expected.Resources, info.Resources) + }) + } +} + +func TestInfoRoundTrip(t *testing.T) { + now := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + later := time.Date(2025, 10, 8, 13, 0, 0, 0, time.UTC) + + original := Info{ + FirstDeployed: now, + LastDeployed: later, + Description: "Test release", + Status: common.StatusDeployed, + Notes: "Release notes", + } + + data, err := json.Marshal(&original) + require.NoError(t, err) + + var decoded Info + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, original.FirstDeployed.Unix(), decoded.FirstDeployed.Unix()) + assert.Equal(t, original.LastDeployed.Unix(), decoded.LastDeployed.Unix()) + assert.Equal(t, original.Deleted.Unix(), decoded.Deleted.Unix()) + assert.Equal(t, original.Description, decoded.Description) + assert.Equal(t, original.Status, decoded.Status) + assert.Equal(t, original.Notes, decoded.Notes) +} + +func TestInfoEmptyStringRoundTrip(t *testing.T) { + // This test specifically verifies that empty string time fields + // are handled correctly during parsing + input := `{"first_deployed":"","last_deployed":"","deleted":"","status":"deployed","description":"test"}` + + var info Info + err := json.Unmarshal([]byte(input), &info) + require.NoError(t, err) + + // Verify time fields are zero values + assert.True(t, info.FirstDeployed.IsZero()) + assert.True(t, info.LastDeployed.IsZero()) + assert.True(t, info.Deleted.IsZero()) + assert.Equal(t, common.StatusDeployed, info.Status) + assert.Equal(t, "test", info.Description) + + // Marshal back and verify empty time fields are omitted + data, err := json.Marshal(&info) + require.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(data, &result) + require.NoError(t, err) + + // Zero time values should be omitted due to omitzero tag + assert.NotContains(t, result, "first_deployed") + assert.NotContains(t, result, "last_deployed") + assert.NotContains(t, result, "deleted") + assert.Equal(t, "deployed", result["status"]) + assert.Equal(t, "test", result["description"]) +} diff --git a/pkg/helm/pkg/release/mock.go b/pkg/helm/pkg/release/v1/mock.go similarity index 72% rename from pkg/helm/pkg/release/mock.go rename to pkg/helm/pkg/release/v1/mock.go index 7f329be9..b5642784 100644 --- a/pkg/helm/pkg/release/mock.go +++ b/pkg/helm/pkg/release/v1/mock.go @@ -14,14 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -package release +package v1 import ( "fmt" "math/rand" + "time" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/time" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + rcommon "github.com/werf/nelm/pkg/helm/pkg/release/common" ) // MockHookTemplate is the hook template used for all mock release objects. @@ -44,8 +46,9 @@ type MockReleaseOptions struct { Name string Version int Chart *chart.Chart - Status Status + Status rcommon.Status Namespace string + Labels map[string]string } // Mock creates a mock release object based on options set by MockReleaseOptions. This function should typically not be used outside of testing. @@ -66,6 +69,10 @@ func Mock(opts *MockReleaseOptions) *Release { if namespace == "" { namespace = "default" } + var labels map[string]string + if len(opts.Labels) > 0 { + labels = opts.Labels + } ch := opts.Chart if opts.Chart == nil { @@ -74,14 +81,32 @@ func Mock(opts *MockReleaseOptions) *Release { Name: "foo", Version: "0.1.0-beta.1", AppVersion: "1.0", + Annotations: map[string]string{ + "category": "web-apps", + "supported": "true", + }, + Dependencies: []*chart.Dependency{ + { + Name: "cool-plugin", + Version: "1.0.0", + Repository: "https://coolplugin.io/charts", + Condition: "coolPlugin.enabled", + Enabled: true, + }, + { + Name: "crds", + Version: "2.7.1", + Condition: "crds.enabled", + }, + }, }, - Templates: []*chart.File{ - {Name: "templates/foo.tpl", Data: []byte(MockManifest)}, + Templates: []*common.File{ + {Name: "templates/foo.tpl", ModTime: time.Now(), Data: []byte(MockManifest)}, }, } } - scode := StatusDeployed + scode := rcommon.StatusDeployed if len(opts.Status) > 0 { scode = opts.Status } @@ -112,5 +137,6 @@ func Mock(opts *MockReleaseOptions) *Release { }, }, Manifest: MockManifest, + Labels: labels, } } diff --git a/pkg/helm/pkg/release/release.go b/pkg/helm/pkg/release/v1/release.go similarity index 64% rename from pkg/helm/pkg/release/release.go rename to pkg/helm/pkg/release/v1/release.go index 07bf45fa..fc027cbc 100644 --- a/pkg/helm/pkg/release/release.go +++ b/pkg/helm/pkg/release/v1/release.go @@ -13,14 +13,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -package release +package v1 import ( - "fmt" - - "github.com/werf/nelm/pkg/helm/pkg/chart" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/release/common" ) +type ApplyMethod string + +const ApplyMethodClientSideApply ApplyMethod = "csa" +const ApplyMethodServerSideApply ApplyMethod = "ssa" + // Release describes a deployment of a chart, together with the chart // and the variables used to deploy that chart. type Release struct { @@ -44,53 +48,14 @@ type Release struct { // Labels of the release. // Disabled encoding into Json cause labels are stored in storage driver metadata field. Labels map[string]string `json:"-"` - - UnstoredManifest string `json:"-"` + // ApplyMethod stores whether server-side or client-side apply was used for the release + // Unset (empty string) should be treated as the default of client-side apply + ApplyMethod string `json:"apply_method,omitempty"` // "ssa" | "csa" + UnstoredManifest string `json:"unstored_manifest,omitempty"` } // SetStatus is a helper for setting the status on a release. -func (r *Release) SetStatus(status Status, msg string) { +func (r *Release) SetStatus(status common.Status, msg string) { r.Info.Status = status r.Info.Description = msg } - -func (r *Release) IsStatusSucceeded() bool { - switch r.Info.Status { - case StatusDeployed, - StatusSuperseded, - StatusUninstalled: - return true - default: - return false - } -} - -func (r *Release) IsStatusFailed() bool { - switch r.Info.Status { - case StatusFailed, - StatusUnknown, - StatusPendingInstall, - StatusPendingUpgrade, - StatusPendingRollback, - StatusUninstalling: - return true - default: - return false - } -} - -func (r *Release) ID() string { - return ReleaseID(r.Namespace, r.Name, r.Version) -} - -func (r *Release) IDHuman() string { - return ReleaseIDHuman(r.Namespace, r.Name, r.Version) -} - -func ReleaseID(namespace, name string, revision int) string { - return fmt.Sprintf("%s:%s:%d", namespace, name, revision) -} - -func ReleaseIDHuman(namespace, name string, revision int) string { - return fmt.Sprintf("%s/%d (namespace=%s)", name, revision, namespace) -} diff --git a/pkg/helm/pkg/releaseutil/filter.go b/pkg/helm/pkg/release/v1/util/filter.go similarity index 88% rename from pkg/helm/pkg/releaseutil/filter.go rename to pkg/helm/pkg/release/v1/util/filter.go index 360d52b8..60d95031 100644 --- a/pkg/helm/pkg/releaseutil/filter.go +++ b/pkg/helm/pkg/release/v1/util/filter.go @@ -14,9 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -package releaseutil // import "helm.sh/helm/v3/pkg/releaseutil" +package util // import "github.com/werf/nelm/pkg/helm/pkg/release/v1/util" -import rspb "github.com/werf/nelm/pkg/helm/pkg/release" +import ( + "github.com/werf/nelm/pkg/helm/pkg/release/common" + rspb "github.com/werf/nelm/pkg/helm/pkg/release/v1" +) // FilterFunc returns true if the release object satisfies // the predicate of the underlying filter func. @@ -68,7 +71,7 @@ func All(filters ...FilterFunc) FilterFunc { } // StatusFilter filters a set of releases by status code. -func StatusFilter(status rspb.Status) FilterFunc { +func StatusFilter(status common.Status) FilterFunc { return FilterFunc(func(rls *rspb.Release) bool { if rls == nil { return true diff --git a/pkg/helm/pkg/releaseutil/filter_test.go b/pkg/helm/pkg/release/v1/util/filter_test.go similarity index 74% rename from pkg/helm/pkg/releaseutil/filter_test.go rename to pkg/helm/pkg/release/v1/util/filter_test.go index 4e287bfb..7f081e2e 100644 --- a/pkg/helm/pkg/releaseutil/filter_test.go +++ b/pkg/helm/pkg/release/v1/util/filter_test.go @@ -14,25 +14,26 @@ See the License for the specific language governing permissions and limitations under the License. */ -package releaseutil // import "helm.sh/helm/v3/pkg/releaseutil" +package util // import "github.com/werf/nelm/pkg/helm/pkg/release/v1/util" import ( "testing" - rspb "github.com/werf/nelm/pkg/helm/pkg/release" + "github.com/werf/nelm/pkg/helm/pkg/release/common" + rspb "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) func TestFilterAny(t *testing.T) { - ls := Any(StatusFilter(rspb.StatusUninstalled)).Filter(releases) + ls := Any(StatusFilter(common.StatusUninstalled)).Filter(releases) if len(ls) != 2 { t.Fatalf("expected 2 results, got '%d'", len(ls)) } r0, r1 := ls[0], ls[1] switch { - case r0.Info.Status != rspb.StatusUninstalled: + case r0.Info.Status != common.StatusUninstalled: t.Fatalf("expected UNINSTALLED result, got '%s'", r1.Info.Status.String()) - case r1.Info.Status != rspb.StatusUninstalled: + case r1.Info.Status != common.StatusUninstalled: t.Fatalf("expected UNINSTALLED result, got '%s'", r1.Info.Status.String()) } } @@ -40,7 +41,7 @@ func TestFilterAny(t *testing.T) { func TestFilterAll(t *testing.T) { fn := FilterFunc(func(rls *rspb.Release) bool { // true if not uninstalled and version < 4 - v0 := !StatusFilter(rspb.StatusUninstalled).Check(rls) + v0 := !StatusFilter(common.StatusUninstalled).Check(rls) v1 := rls.Version < 4 return v0 && v1 }) @@ -53,7 +54,7 @@ func TestFilterAll(t *testing.T) { switch r0 := ls[0]; { case r0.Version == 4: t.Fatal("got release with status revision 4") - case r0.Info.Status == rspb.StatusUninstalled: + case r0.Info.Status == common.StatusUninstalled: t.Fatal("got release with status UNINSTALLED") } } diff --git a/pkg/helm/pkg/releaseutil/kind_sorter.go b/pkg/helm/pkg/release/v1/util/kind_sorter.go similarity index 92% rename from pkg/helm/pkg/releaseutil/kind_sorter.go rename to pkg/helm/pkg/release/v1/util/kind_sorter.go index 2455c51a..019228ae 100644 --- a/pkg/helm/pkg/releaseutil/kind_sorter.go +++ b/pkg/helm/pkg/release/v1/util/kind_sorter.go @@ -14,12 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -package releaseutil +package util import ( "sort" - "github.com/werf/nelm/pkg/helm/pkg/release" + release "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) // KindSortOrder is an ordering of Kinds. @@ -65,12 +65,17 @@ var InstallOrder KindSortOrder = []string{ "IngressClass", "Ingress", "APIService", + "MutatingWebhookConfiguration", + "ValidatingWebhookConfiguration", } // UninstallOrder is the order in which manifests should be uninstalled (by Kind). // // Those occurring earlier in the list get uninstalled before those occurring later in the list. var UninstallOrder KindSortOrder = []string{ + // For uninstall, we remove validation before mutation to ensure webhooks don't block removal + "ValidatingWebhookConfiguration", + "MutatingWebhookConfiguration", "APIService", "Ingress", "IngressClass", diff --git a/pkg/helm/pkg/releaseutil/kind_sorter_test.go b/pkg/helm/pkg/release/v1/util/kind_sorter_test.go similarity index 95% rename from pkg/helm/pkg/releaseutil/kind_sorter_test.go rename to pkg/helm/pkg/release/v1/util/kind_sorter_test.go index c7dbc295..00eddfb7 100644 --- a/pkg/helm/pkg/releaseutil/kind_sorter_test.go +++ b/pkg/helm/pkg/release/v1/util/kind_sorter_test.go @@ -14,13 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -package releaseutil +package util import ( "bytes" "testing" - "github.com/werf/nelm/pkg/helm/pkg/release" + release "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) func TestKindSorter(t *testing.T) { @@ -173,6 +173,14 @@ func TestKindSorter(t *testing.T) { Name: "F", Head: &SimpleHead{Kind: "PriorityClass"}, }, + { + Name: "M", + Head: &SimpleHead{Kind: "MutatingWebhookConfiguration"}, + }, + { + Name: "V", + Head: &SimpleHead{Kind: "ValidatingWebhookConfiguration"}, + }, } for _, test := range []struct { @@ -180,8 +188,8 @@ func TestKindSorter(t *testing.T) { order KindSortOrder expected string }{ - {"install", InstallOrder, "FaAbcC3deEf1gh2iIjJkKlLmnopqrxstuUvw!"}, - {"uninstall", UninstallOrder, "wvUmutsxrqponLlKkJjIi2hg1fEed3CcbAaF!"}, + {"install", InstallOrder, "FaAbcC3deEf1gh2iIjJkKlLmnopqrxstuUvwMV!"}, + {"uninstall", UninstallOrder, "VMwvUmutsxrqponLlKkJjIi2hg1fEed3CcbAaF!"}, } { var buf bytes.Buffer t.Run(test.description, func(t *testing.T) { diff --git a/pkg/helm/pkg/releaseutil/manifest.go b/pkg/helm/pkg/release/v1/util/manifest.go similarity index 78% rename from pkg/helm/pkg/releaseutil/manifest.go rename to pkg/helm/pkg/release/v1/util/manifest.go index 3962a709..9a87949f 100644 --- a/pkg/helm/pkg/releaseutil/manifest.go +++ b/pkg/helm/pkg/release/v1/util/manifest.go @@ -14,16 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -package releaseutil +package util import ( "fmt" "regexp" - "sort" "strconv" "strings" - - "github.com/samber/lo" ) // SimpleHead defines what the structure of the head of a manifest file @@ -36,7 +33,7 @@ type SimpleHead struct { } `json:"metadata,omitempty"` } -var sep = regexp.MustCompile(`(?m)^---\s*`) +var sep = regexp.MustCompile("(?:^|\\s*\n)---\\s*") // SplitManifests takes a string of manifest and returns a map contains individual manifests func SplitManifests(bigFile string) map[string]string { @@ -51,27 +48,11 @@ func SplitManifests(bigFile string) map[string]string { docs := sep.Split(bigFileTmp, -1) var count int for _, d := range docs { - d = strings.TrimSpace(d) - if d == "" { continue } - var contentFound bool - for _, line := range strings.Split(d, "\n") { - trimmedLine := strings.TrimSpace(line) - if trimmedLine != "" && !strings.HasPrefix(trimmedLine, "#") { - contentFound = true - break - } - } - - if !contentFound { - continue - } - - d += "\n" - + d = strings.TrimSpace(d) res[fmt.Sprintf(tpl, count)] = d count = count + 1 } @@ -89,14 +70,3 @@ func (a BySplitManifestsOrder) Less(i, j int) bool { return anum < bnum } func (a BySplitManifestsOrder) Swap(i, j int) { a[i], a[j] = a[j], a[i] } - -func SplitManifestsToSlice(manifests string) []string { - splitManifests := SplitManifests(manifests) - - keys := lo.Keys(splitManifests) - sort.Strings(keys) - - return lo.Map(keys, func(k string, _ int) string { - return splitManifests[k] - }) -} diff --git a/pkg/helm/pkg/releaseutil/manifest_sorter.go b/pkg/helm/pkg/release/v1/util/manifest_sorter.go similarity index 82% rename from pkg/helm/pkg/releaseutil/manifest_sorter.go rename to pkg/helm/pkg/release/v1/util/manifest_sorter.go index 3a658094..d239428c 100644 --- a/pkg/helm/pkg/releaseutil/manifest_sorter.go +++ b/pkg/helm/pkg/release/v1/util/manifest_sorter.go @@ -14,20 +14,20 @@ See the License for the specific language governing permissions and limitations under the License. */ -package releaseutil +package util import ( "fmt" + "log/slog" "path" "sort" "strconv" "strings" - "github.com/pkg/errors" "sigs.k8s.io/yaml" - "github.com/werf/nelm/pkg/helm/pkg/chartutil" - "github.com/werf/nelm/pkg/helm/pkg/release" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + release "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) // Manifest represents a manifest file, which has a name and some content. @@ -41,7 +41,6 @@ type Manifest struct { type manifestFile struct { entries map[string]string path string - apis chartutil.VersionSet } // result is an intermediate structure used during sorting. @@ -75,7 +74,7 @@ var events = map[string]release.HookEvent{ // // Files that do not parse into the expected format are simply placed into a map and // returned. -func SortManifests(files map[string]string, apis chartutil.VersionSet, ordering KindSortOrder) ([]*release.Hook, []Manifest, error) { +func SortManifests(files map[string]string, _ common.VersionSet, ordering KindSortOrder) ([]*release.Hook, []Manifest, error) { result := &result{} var sortedFilePaths []string @@ -100,7 +99,6 @@ func SortManifests(files map[string]string, apis chartutil.VersionSet, ordering manifestFile := &manifestFile{ entries: SplitManifests(content), path: filePath, - apis: apis, } if err := manifestFile.sort(result); err != nil { @@ -130,6 +128,14 @@ func SortManifests(files map[string]string, apis chartutil.VersionSet, ordering // metadata: // annotations: // helm.sh/hook-delete-policy: hook-succeeded +// +// To determine the policy to output logs of the hook (for Pod and Job only), it looks for a YAML structure like this: +// +// kind: Pod +// apiVersion: v1 +// metadata: +// annotations: +// helm.sh/hook-output-log-policy: hook-succeeded,hook-failed func (file *manifestFile) sort(result *result) error { // Go through manifests in order found in file (function `SplitManifests` creates integer-sortable keys) var sortedEntryKeys []string @@ -143,7 +149,7 @@ func (file *manifestFile) sort(result *result) error { var entry SimpleHead if err := yaml.Unmarshal([]byte(m), &entry); err != nil { - return errors.Wrapf(err, "YAML parse error on %s", file.path) + return fmt.Errorf("YAML parse error on %s: %w", file.path, err) } if !hasAnyAnnotation(entry) { @@ -168,29 +174,41 @@ func (file *manifestFile) sort(result *result) error { hw := calculateHookWeight(entry) h := &release.Hook{ - Name: entry.Metadata.Name, - Kind: entry.Kind, - Path: file.path, - Manifest: m, - Events: []release.HookEvent{}, - Weight: hw, - DeletePolicies: []release.HookDeletePolicy{}, + Name: entry.Metadata.Name, + Kind: entry.Kind, + Path: file.path, + Manifest: m, + Events: []release.HookEvent{}, + Weight: hw, + DeletePolicies: []release.HookDeletePolicy{}, + OutputLogPolicies: []release.HookOutputLogPolicy{}, } - for _, hookType := range strings.Split(hookTypes, ",") { + isUnknownHook := false + for hookType := range strings.SplitSeq(hookTypes, ",") { hookType = strings.ToLower(strings.TrimSpace(hookType)) e, ok := events[hookType] if !ok { - continue + isUnknownHook = true + break } h.Events = append(h.Events, e) } + if isUnknownHook { + slog.Info("skipping unknown hooks", "hookTypes", hookTypes) + continue + } + result.hooks = append(result.hooks, h) operateAnnotationValues(entry, release.HookDeleteAnnotation, func(value string) { h.DeletePolicies = append(h.DeletePolicies, release.HookDeletePolicy(value)) }) + + operateAnnotationValues(entry, release.HookOutputLogAnnotation, func(value string) { + h.OutputLogPolicies = append(h.OutputLogPolicies, release.HookOutputLogPolicy(value)) + }) } return nil @@ -218,7 +236,7 @@ func calculateHookWeight(entry SimpleHead) int { // operateAnnotationValues finds the given annotation and runs the operate function with the value of that annotation func operateAnnotationValues(entry SimpleHead, annotation string, operate func(p string)) { if dps, ok := entry.Metadata.Annotations[annotation]; ok { - for _, dp := range strings.Split(dps, ",") { + for dp := range strings.SplitSeq(dps, ",") { dp = strings.ToLower(strings.TrimSpace(dp)) operate(dp) } diff --git a/pkg/helm/pkg/releaseutil/manifest_sorter_test.go b/pkg/helm/pkg/release/v1/util/manifest_sorter_test.go similarity index 96% rename from pkg/helm/pkg/releaseutil/manifest_sorter_test.go rename to pkg/helm/pkg/release/v1/util/manifest_sorter_test.go index 48db96ad..34cf18cb 100644 --- a/pkg/helm/pkg/releaseutil/manifest_sorter_test.go +++ b/pkg/helm/pkg/release/v1/util/manifest_sorter_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package releaseutil +package util import ( "reflect" @@ -22,8 +22,7 @@ import ( "sigs.k8s.io/yaml" - "github.com/werf/nelm/pkg/helm/pkg/chartutil" - "github.com/werf/nelm/pkg/helm/pkg/release" + release "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) func TestSortManifests(t *testing.T) { @@ -139,7 +138,7 @@ metadata: manifests[o.path] = o.manifest } - hs, generic, err := SortManifests(manifests, chartutil.VersionSet{"v1", "v1beta1"}, InstallOrder) + hs, generic, err := SortManifests(manifests, nil, InstallOrder) if err != nil { t.Fatalf("Unexpected error: %s", err) } diff --git a/pkg/helm/pkg/releaseutil/manifest_test.go b/pkg/helm/pkg/release/v1/util/manifest_test.go similarity index 95% rename from pkg/helm/pkg/releaseutil/manifest_test.go rename to pkg/helm/pkg/release/v1/util/manifest_test.go index 8664d20e..754ac136 100644 --- a/pkg/helm/pkg/releaseutil/manifest_test.go +++ b/pkg/helm/pkg/release/v1/util/manifest_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package releaseutil // import "helm.sh/helm/v3/pkg/releaseutil" +package util // import "helm.sh/helm/v4/pkg/release/v1/util" import ( "reflect" diff --git a/pkg/helm/pkg/releaseutil/sorter.go b/pkg/helm/pkg/release/v1/util/sorter.go similarity index 57% rename from pkg/helm/pkg/releaseutil/sorter.go rename to pkg/helm/pkg/release/v1/util/sorter.go index 0c2535fe..20533391 100644 --- a/pkg/helm/pkg/releaseutil/sorter.go +++ b/pkg/helm/pkg/release/v1/util/sorter.go @@ -14,43 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -package releaseutil // import "helm.sh/helm/v3/pkg/releaseutil" +package util // import "github.com/werf/nelm/pkg/helm/pkg/release/v1/util" import ( "sort" - rspb "github.com/werf/nelm/pkg/helm/pkg/release" + rspb "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) -type list []*rspb.Release - -func (s list) Len() int { return len(s) } -func (s list) Swap(i, j int) { s[i], s[j] = s[j], s[i] } - -// ByName sorts releases by name -type ByName struct{ list } - -// Less compares to releases -func (s ByName) Less(i, j int) bool { return s.list[i].Name < s.list[j].Name } - -// ByDate sorts releases by date -type ByDate struct{ list } - -// Less compares to releases -func (s ByDate) Less(i, j int) bool { - ti := s.list[i].Info.LastDeployed.Unix() - tj := s.list[j].Info.LastDeployed.Unix() - return ti < tj -} - -// ByRevision sorts releases by revision number -type ByRevision struct{ list } - -// Less compares to releases -func (s ByRevision) Less(i, j int) bool { - return s.list[i].Version < s.list[j].Version -} - // Reverse reverses the list of releases sorted by the sort func. func Reverse(list []*rspb.Release, sortFn func([]*rspb.Release)) { sortFn(list) @@ -62,17 +33,29 @@ func Reverse(list []*rspb.Release, sortFn func([]*rspb.Release)) { // SortByName returns the list of releases sorted // in lexicographical order. func SortByName(list []*rspb.Release) { - sort.Sort(ByName{list}) + sort.Slice(list, func(i, j int) bool { + return list[i].Name < list[j].Name + }) } // SortByDate returns the list of releases sorted by a // release's last deployed time (in seconds). func SortByDate(list []*rspb.Release) { - sort.Sort(ByDate{list}) + sort.Slice(list, func(i, j int) bool { + ti := list[i].Info.LastDeployed.Unix() + tj := list[j].Info.LastDeployed.Unix() + if ti != tj { + return ti < tj + } + // Use name as tie-breaker for stable sorting + return list[i].Name < list[j].Name + }) } // SortByRevision returns the list of releases sorted by a // release's revision number (release.Version). func SortByRevision(list []*rspb.Release) { - sort.Sort(ByRevision{list}) + sort.Slice(list, func(i, j int) bool { + return list[i].Version < list[j].Version + }) } diff --git a/pkg/helm/pkg/releaseutil/sorter_test.go b/pkg/helm/pkg/release/v1/util/sorter_test.go similarity index 80% rename from pkg/helm/pkg/releaseutil/sorter_test.go rename to pkg/helm/pkg/release/v1/util/sorter_test.go index ec03abcb..7e4301e1 100644 --- a/pkg/helm/pkg/releaseutil/sorter_test.go +++ b/pkg/helm/pkg/release/v1/util/sorter_test.go @@ -14,27 +14,27 @@ See the License for the specific language governing permissions and limitations under the License. */ -package releaseutil // import "helm.sh/helm/v3/pkg/releaseutil" +package util // import "github.com/werf/nelm/pkg/helm/pkg/release/v1/util" import ( "testing" "time" - rspb "github.com/werf/nelm/pkg/helm/pkg/release" - helmtime "github.com/werf/nelm/pkg/helm/pkg/time" + "github.com/werf/nelm/pkg/helm/pkg/release/common" + rspb "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) // note: this test data is shared with filter_test.go. var releases = []*rspb.Release{ - tsRelease("quiet-bear", 2, 2000, rspb.StatusSuperseded), - tsRelease("angry-bird", 4, 3000, rspb.StatusDeployed), - tsRelease("happy-cats", 1, 4000, rspb.StatusUninstalled), - tsRelease("vocal-dogs", 3, 6000, rspb.StatusUninstalled), + tsRelease("quiet-bear", 2, 2000, common.StatusSuperseded), + tsRelease("angry-bird", 4, 3000, common.StatusDeployed), + tsRelease("happy-cats", 1, 4000, common.StatusUninstalled), + tsRelease("vocal-dogs", 3, 6000, common.StatusUninstalled), } -func tsRelease(name string, vers int, dur time.Duration, status rspb.Status) *rspb.Release { - info := &rspb.Info{Status: status, LastDeployed: helmtime.Now().Add(dur)} +func tsRelease(name string, vers int, dur time.Duration, status common.Status) *rspb.Release { + info := &rspb.Info{Status: status, LastDeployed: time.Now().Add(dur)} return &rspb.Release{ Name: name, Version: vers, @@ -43,6 +43,7 @@ func tsRelease(name string, vers int, dur time.Duration, status rspb.Status) *rs } func check(t *testing.T, by string, fn func(int, int) bool) { + t.Helper() for i := len(releases) - 1; i > 0; i-- { if fn(i, i-1) { t.Errorf("release at positions '(%d,%d)' not sorted by %s", i-1, i, by) diff --git a/pkg/helm/pkg/releaseutil/validate.go b/pkg/helm/pkg/releaseutil/validate.go deleted file mode 100644 index d6053e7d..00000000 --- a/pkg/helm/pkg/releaseutil/validate.go +++ /dev/null @@ -1,152 +0,0 @@ -/* -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 releaseutil - -import ( - "fmt" - - "github.com/pkg/errors" - "k8s.io/apimachinery/pkg/api/meta" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/cli-runtime/pkg/resource" -) - -var accessor = meta.NewAccessor() - -const ( - appManagedByLabel = "app.kubernetes.io/managed-by" - appManagedByHelm = "Helm" - helmReleaseNameAnnotation = "meta.helm.sh/release-name" - helmReleaseNamespaceAnnotation = "meta.helm.sh/release-namespace" -) - -func CheckOwnership(obj runtime.Object, releaseName, releaseNamespace string) error { - lbls, err := accessor.Labels(obj) - if err != nil { - return err - } - annos, err := accessor.Annotations(obj) - if err != nil { - return err - } - - var errs []error - if err := requireValue(lbls, appManagedByLabel, appManagedByHelm); err != nil { - errs = append(errs, fmt.Errorf("label validation error: %s", err)) - } - if err := requireValue(annos, helmReleaseNameAnnotation, releaseName); err != nil { - errs = append(errs, fmt.Errorf("annotation validation error: %s", err)) - } - if err := requireValue(annos, helmReleaseNamespaceAnnotation, releaseNamespace); err != nil { - errs = append(errs, fmt.Errorf("annotation validation error: %s", err)) - } - - if len(errs) > 0 { - err := errors.New("invalid ownership metadata") - for _, e := range errs { - err = fmt.Errorf("%w; %s", err, e) - } - return err - } - - return nil -} - -func requireValue(meta map[string]string, k, v string) error { - actual, ok := meta[k] - if !ok { - return fmt.Errorf("missing key %q: must be set to %q", k, v) - } - if actual != v { - return fmt.Errorf("key %q must equal %q: current value is %q", k, v, actual) - } - return nil -} - -// SetMetadataVisitor adds release tracking metadata to all resources. If force is enabled, existing -// ownership metadata will be overwritten. Otherwise an error will be returned if any resource has an -// existing and conflicting value for the managed by label or Helm release/namespace annotations. -func SetMetadataVisitor(releaseName, releaseNamespace string, force bool) resource.VisitorFunc { - return func(info *resource.Info, err error) error { - if err != nil { - return err - } - - if !force { - if err := CheckOwnership(info.Object, releaseName, releaseNamespace); err != nil { - return fmt.Errorf("%s cannot be owned: %s", ResourceString(info), err) - } - } - - if err := mergeLabels(info.Object, map[string]string{ - appManagedByLabel: appManagedByHelm, - }); err != nil { - return fmt.Errorf( - "%s labels could not be updated: %s", - ResourceString(info), err, - ) - } - - if err := mergeAnnotations(info.Object, map[string]string{ - helmReleaseNameAnnotation: releaseName, - helmReleaseNamespaceAnnotation: releaseNamespace, - }); err != nil { - return fmt.Errorf( - "%s annotations could not be updated: %s", - ResourceString(info), err, - ) - } - - return nil - } -} - -func ResourceString(info *resource.Info) string { - _, k := info.Mapping.GroupVersionKind.ToAPIVersionAndKind() - return fmt.Sprintf( - "%s %q in namespace %q", - k, info.Name, info.Namespace, - ) -} - -func mergeLabels(obj runtime.Object, labels map[string]string) error { - current, err := accessor.Labels(obj) - if err != nil { - return err - } - return accessor.SetLabels(obj, mergeStrStrMaps(current, labels)) -} - -func mergeAnnotations(obj runtime.Object, annotations map[string]string) error { - current, err := accessor.Annotations(obj) - if err != nil { - return err - } - return accessor.SetAnnotations(obj, mergeStrStrMaps(current, annotations)) -} - -// merge two maps, always taking the value on the right -func mergeStrStrMaps(current, desired map[string]string) map[string]string { - result := make(map[string]string) - for k, v := range current { - result[k] = v - } - for k, desiredVal := range desired { - result[k] = desiredVal - } - return result -} diff --git a/pkg/helm/pkg/releaseutil/validate_test.go b/pkg/helm/pkg/releaseutil/validate_test.go deleted file mode 100644 index 49286d42..00000000 --- a/pkg/helm/pkg/releaseutil/validate_test.go +++ /dev/null @@ -1,89 +0,0 @@ -/* -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 releaseutil - -import ( - "testing" - - "github.com/stretchr/testify/assert" - appsv1 "k8s.io/api/apps/v1" - "k8s.io/apimachinery/pkg/api/meta" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/cli-runtime/pkg/resource" -) - -func newDeploymentResource(name, namespace string) *resource.Info { - return &resource.Info{ - Name: name, - Mapping: &meta.RESTMapping{ - Resource: schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployment"}, - GroupVersionKind: schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}, - }, - Object: &appsv1.Deployment{ - ObjectMeta: v1.ObjectMeta{ - Name: name, - Namespace: namespace, - }, - }, - } -} - -func TestCheckOwnership(t *testing.T) { - deployFoo := newDeploymentResource("foo", "ns-a") - - // Verify that a resource that lacks labels/annotations is not owned - err := CheckOwnership(deployFoo.Object, "rel-a", "ns-a") - assert.EqualError(t, err, `invalid ownership metadata; label validation error: missing key "app.kubernetes.io/managed-by": must be set to "Helm"; annotation validation error: missing key "meta.helm.sh/release-name": must be set to "rel-a"; annotation validation error: missing key "meta.helm.sh/release-namespace": must be set to "ns-a"`) - - // Set managed by label and verify annotation error message - _ = accessor.SetLabels(deployFoo.Object, map[string]string{ - appManagedByLabel: appManagedByHelm, - }) - err = CheckOwnership(deployFoo.Object, "rel-a", "ns-a") - assert.EqualError(t, err, `invalid ownership metadata; annotation validation error: missing key "meta.helm.sh/release-name": must be set to "rel-a"; annotation validation error: missing key "meta.helm.sh/release-namespace": must be set to "ns-a"`) - - // Set only the release name annotation and verify missing release namespace error message - _ = accessor.SetAnnotations(deployFoo.Object, map[string]string{ - helmReleaseNameAnnotation: "rel-a", - }) - err = CheckOwnership(deployFoo.Object, "rel-a", "ns-a") - assert.EqualError(t, err, `invalid ownership metadata; annotation validation error: missing key "meta.helm.sh/release-namespace": must be set to "ns-a"`) - - // Set both release name and namespace annotations and verify no ownership errors - _ = accessor.SetAnnotations(deployFoo.Object, map[string]string{ - helmReleaseNameAnnotation: "rel-a", - helmReleaseNamespaceAnnotation: "ns-a", - }) - err = CheckOwnership(deployFoo.Object, "rel-a", "ns-a") - assert.NoError(t, err) - - // Verify ownership error for wrong release name - err = CheckOwnership(deployFoo.Object, "rel-b", "ns-a") - assert.EqualError(t, err, `invalid ownership metadata; annotation validation error: key "meta.helm.sh/release-name" must equal "rel-b": current value is "rel-a"`) - - // Verify ownership error for wrong release namespace - err = CheckOwnership(deployFoo.Object, "rel-a", "ns-b") - assert.EqualError(t, err, `invalid ownership metadata; annotation validation error: key "meta.helm.sh/release-namespace" must equal "ns-b": current value is "ns-a"`) - - // Verify ownership error for wrong manager label - _ = accessor.SetLabels(deployFoo.Object, map[string]string{ - appManagedByLabel: "helm", - }) - err = CheckOwnership(deployFoo.Object, "rel-a", "ns-a") - assert.EqualError(t, err, `invalid ownership metadata; label validation error: key "app.kubernetes.io/managed-by" must equal "Helm": current value is "helm"`) -} diff --git a/pkg/helm/pkg/repo/chartrepo.go b/pkg/helm/pkg/repo/chartrepo.go deleted file mode 100644 index c88bb119..00000000 --- a/pkg/helm/pkg/repo/chartrepo.go +++ /dev/null @@ -1,318 +0,0 @@ -/* -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 repo // import "helm.sh/helm/v3/pkg/repo" - -import ( - "crypto/rand" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "log" - "net/url" - "os" - "path/filepath" - "strings" - - "github.com/pkg/errors" - "sigs.k8s.io/yaml" - - "github.com/werf/nelm/pkg/helm/pkg/chart/loader" - "github.com/werf/nelm/pkg/helm/pkg/getter" - "github.com/werf/nelm/pkg/helm/pkg/helmpath" - "github.com/werf/nelm/pkg/helm/pkg/provenance" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" -) - -// Entry represents a collection of parameters for chart repository -type Entry struct { - Name string `json:"name"` - URL string `json:"url"` - Username string `json:"username"` - Password string `json:"password"` - CertFile string `json:"certFile"` - KeyFile string `json:"keyFile"` - CAFile string `json:"caFile"` - InsecureSkipTLSverify bool `json:"insecure_skip_tls_verify"` - PassCredentialsAll bool `json:"pass_credentials_all"` -} - -// ChartRepository represents a chart repository -type ChartRepository struct { - Config *Entry - ChartPaths []string - IndexFile *IndexFile - Client getter.Getter - CachePath string -} - -// NewChartRepository constructs ChartRepository -func NewChartRepository(cfg *Entry, getters getter.Providers) (*ChartRepository, error) { - u, err := url.Parse(cfg.URL) - if err != nil { - return nil, errors.Errorf("invalid chart URL format: %s", cfg.URL) - } - - client, err := getters.ByScheme(u.Scheme) - if err != nil { - return nil, errors.Errorf("could not find protocol handler for: %s", u.Scheme) - } - - return &ChartRepository{ - Config: cfg, - IndexFile: NewIndexFile(), - Client: client, - CachePath: helmpath.CachePath("repository"), - }, nil -} - -// Load loads a directory of charts as if it were a repository. -// -// It requires the presence of an index.yaml file in the directory. -// -// Deprecated: remove in Helm 4. -func (r *ChartRepository) Load() error { - dirInfo, err := os.Stat(r.Config.Name) - if err != nil { - return err - } - if !dirInfo.IsDir() { - return errors.Errorf("%q is not a directory", r.Config.Name) - } - - // FIXME: Why are we recursively walking directories? - // FIXME: Why are we not reading the repositories.yaml to figure out - // what repos to use? - filepath.Walk(r.Config.Name, func(path string, f os.FileInfo, err error) error { - if !f.IsDir() { - if strings.Contains(f.Name(), "-index.yaml") { - i, err := LoadIndexFile(path) - if err != nil { - return err - } - r.IndexFile = i - } else if strings.HasSuffix(f.Name(), ".tgz") { - r.ChartPaths = append(r.ChartPaths, path) - } - } - return nil - }) - return nil -} - -// DownloadIndexFile fetches the index from a repository. -func (r *ChartRepository) DownloadIndexFile() (string, error) { - indexURL, err := ResolveReferenceURL(r.Config.URL, "index.yaml") - if err != nil { - return "", err - } - - resp, err := r.Client.Get(indexURL, - getter.WithURL(r.Config.URL), - getter.WithInsecureSkipVerifyTLS(r.Config.InsecureSkipTLSverify), - getter.WithTLSClientConfig(r.Config.CertFile, r.Config.KeyFile, r.Config.CAFile), - getter.WithBasicAuth(r.Config.Username, r.Config.Password), - getter.WithPassCredentialsAll(r.Config.PassCredentialsAll), - ) - if err != nil { - return "", err - } - - index, err := io.ReadAll(resp) - if err != nil { - return "", err - } - - indexFile, err := loadIndex(index, r.Config.URL) - if err != nil { - return "", err - } - - // Create the chart list file in the cache directory - var charts strings.Builder - for name := range indexFile.Entries { - fmt.Fprintln(&charts, name) - } - chartsFile := filepath.Join(r.CachePath, helmpath.CacheChartsFile(r.Config.Name)) - os.MkdirAll(filepath.Dir(chartsFile), 0755) - os.WriteFile(chartsFile, []byte(charts.String()), 0644) - - // Create the index file in the cache directory - fname := filepath.Join(r.CachePath, helmpath.CacheIndexFile(r.Config.Name)) - os.MkdirAll(filepath.Dir(fname), 0755) - return fname, os.WriteFile(fname, index, 0644) -} - -// Index generates an index for the chart repository and writes an index.yaml file. -func (r *ChartRepository) Index(opts helmopts.HelmOptions) error { - err := r.generateIndex(opts) - if err != nil { - return err - } - return r.saveIndexFile() -} - -func (r *ChartRepository) saveIndexFile() error { - index, err := yaml.Marshal(r.IndexFile) - if err != nil { - return err - } - return os.WriteFile(filepath.Join(r.Config.Name, indexPath), index, 0644) -} - -func (r *ChartRepository) generateIndex(opts helmopts.HelmOptions) error { - for _, path := range r.ChartPaths { - ch, err := loader.Load(path, opts) - if err != nil { - return err - } - - digest, err := provenance.DigestFile(path) - if err != nil { - return err - } - - if !r.IndexFile.Has(ch.Name(), ch.Metadata.Version) { - if err := r.IndexFile.MustAdd(ch.Metadata, path, r.Config.URL, digest); err != nil { - return errors.Wrapf(err, "failed adding to %s to index", path) - } - } - // TODO: If a chart exists, but has a different Digest, should we error? - } - r.IndexFile.SortEntries() - return nil -} - -// FindChartInRepoURL finds chart in chart repository pointed by repoURL -// without adding repo to repositories -func FindChartInRepoURL(repoURL, chartName, chartVersion, certFile, keyFile, caFile string, getters getter.Providers) (string, error) { - return FindChartInAuthRepoURL(repoURL, "", "", chartName, chartVersion, certFile, keyFile, caFile, getters) -} - -// FindChartInAuthRepoURL finds chart in chart repository pointed by repoURL -// without adding repo to repositories, like FindChartInRepoURL, -// but it also receives credentials for the chart repository. -func FindChartInAuthRepoURL(repoURL, username, password, chartName, chartVersion, certFile, keyFile, caFile string, getters getter.Providers) (string, error) { - return FindChartInAuthAndTLSRepoURL(repoURL, username, password, chartName, chartVersion, certFile, keyFile, caFile, false, getters) -} - -// FindChartInAuthAndTLSRepoURL finds chart in chart repository pointed by repoURL -// without adding repo to repositories, like FindChartInRepoURL, -// but it also receives credentials and TLS verify flag for the chart repository. -// TODO Helm 4, FindChartInAuthAndTLSRepoURL should be integrated into FindChartInAuthRepoURL. -func FindChartInAuthAndTLSRepoURL(repoURL, username, password, chartName, chartVersion, certFile, keyFile, caFile string, insecureSkipTLSverify bool, getters getter.Providers) (string, error) { - return FindChartInAuthAndTLSAndPassRepoURL(repoURL, username, password, chartName, chartVersion, certFile, keyFile, caFile, insecureSkipTLSverify, false, getters) -} - -// FindChartInAuthAndTLSAndPassRepoURL finds chart in chart repository pointed by repoURL -// without adding repo to repositories, like FindChartInRepoURL, -// but it also receives credentials, TLS verify flag, and if credentials should -// be passed on to other domains. -// TODO Helm 4, FindChartInAuthAndTLSAndPassRepoURL should be integrated into FindChartInAuthRepoURL. -func FindChartInAuthAndTLSAndPassRepoURL(repoURL, username, password, chartName, chartVersion, certFile, keyFile, caFile string, insecureSkipTLSverify, passCredentialsAll bool, getters getter.Providers) (string, error) { - - // Download and write the index file to a temporary location - buf := make([]byte, 20) - rand.Read(buf) - name := strings.ReplaceAll(base64.StdEncoding.EncodeToString(buf), "/", "-") - - c := Entry{ - URL: repoURL, - Username: username, - Password: password, - PassCredentialsAll: passCredentialsAll, - CertFile: certFile, - KeyFile: keyFile, - CAFile: caFile, - Name: name, - InsecureSkipTLSverify: insecureSkipTLSverify, - } - r, err := NewChartRepository(&c, getters) - if err != nil { - return "", err - } - idx, err := r.DownloadIndexFile() - if err != nil { - return "", errors.Wrapf(err, "looks like %q is not a valid chart repository or cannot be reached", repoURL) - } - defer func() { - os.RemoveAll(filepath.Join(r.CachePath, helmpath.CacheChartsFile(r.Config.Name))) - os.RemoveAll(filepath.Join(r.CachePath, helmpath.CacheIndexFile(r.Config.Name))) - }() - - // Read the index file for the repository to get chart information and return chart URL - repoIndex, err := LoadIndexFile(idx) - if err != nil { - return "", err - } - - errMsg := fmt.Sprintf("chart %q", chartName) - if chartVersion != "" { - errMsg = fmt.Sprintf("%s version %q", errMsg, chartVersion) - } - cv, err := repoIndex.Get(chartName, chartVersion) - if err != nil { - return "", errors.Errorf("%s not found in %s repository", errMsg, repoURL) - } - - if len(cv.URLs) == 0 { - return "", errors.Errorf("%s has no downloadable URLs", errMsg) - } - - chartURL := cv.URLs[0] - - absoluteChartURL, err := ResolveReferenceURL(repoURL, chartURL) - if err != nil { - return "", errors.Wrap(err, "failed to make chart URL absolute") - } - - return absoluteChartURL, nil -} - -// ResolveReferenceURL resolves refURL relative to baseURL. -// If refURL is absolute, it simply returns refURL. -func ResolveReferenceURL(baseURL, refURL string) (string, error) { - parsedRefURL, err := url.Parse(refURL) - if err != nil { - return "", errors.Wrapf(err, "failed to parse %s as URL", refURL) - } - - if parsedRefURL.IsAbs() { - return refURL, nil - } - - parsedBaseURL, err := url.Parse(baseURL) - if err != nil { - return "", errors.Wrapf(err, "failed to parse %s as URL", baseURL) - } - - // We need a trailing slash for ResolveReference to work, but make sure there isn't already one - parsedBaseURL.RawPath = strings.TrimSuffix(parsedBaseURL.RawPath, "/") + "/" - parsedBaseURL.Path = strings.TrimSuffix(parsedBaseURL.Path, "/") + "/" - - resolvedURL := parsedBaseURL.ResolveReference(parsedRefURL) - resolvedURL.RawQuery = parsedBaseURL.RawQuery - return resolvedURL.String(), nil -} - -func (e *Entry) String() string { - buf, err := json.Marshal(e) - if err != nil { - log.Panic(err) - } - return string(buf) -} diff --git a/pkg/helm/pkg/repo/repotest/server_test.go b/pkg/helm/pkg/repo/repotest/server_test.go deleted file mode 100644 index b04591a2..00000000 --- a/pkg/helm/pkg/repo/repotest/server_test.go +++ /dev/null @@ -1,116 +0,0 @@ -/* -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 repotest - -import ( - "io" - "net/http" - "path/filepath" - "testing" - - "sigs.k8s.io/yaml" - - "github.com/werf/nelm/pkg/helm/internal/test/ensure" - "github.com/werf/nelm/pkg/helm/pkg/repo" -) - -// Young'n, in these here parts, we test our tests. - -func TestServer(t *testing.T) { - ensure.HelmHome(t) - - rootDir := t.TempDir() - - srv := NewServer(rootDir) - defer srv.Stop() - - c, err := srv.CopyCharts("testdata/*.tgz") - if err != nil { - // Some versions of Go don't correctly fire defer on Fatal. - t.Fatal(err) - } - - if len(c) != 1 { - t.Errorf("Unexpected chart count: %d", len(c)) - } - - if filepath.Base(c[0]) != "examplechart-0.1.0.tgz" { - t.Errorf("Unexpected chart: %s", c[0]) - } - - res, err := http.Get(srv.URL() + "/examplechart-0.1.0.tgz") - res.Body.Close() - if err != nil { - t.Fatal(err) - } - - if res.ContentLength < 500 { - t.Errorf("Expected at least 500 bytes of data, got %d", res.ContentLength) - } - - res, err = http.Get(srv.URL() + "/index.yaml") - if err != nil { - t.Fatal(err) - } - - data, err := io.ReadAll(res.Body) - res.Body.Close() - if err != nil { - t.Fatal(err) - } - - m := repo.NewIndexFile() - if err := yaml.Unmarshal(data, m); err != nil { - t.Fatal(err) - } - - if l := len(m.Entries); l != 1 { - t.Fatalf("Expected 1 entry, got %d", l) - } - - expect := "examplechart" - if !m.Has(expect, "0.1.0") { - t.Errorf("missing %q", expect) - } - - res, err = http.Get(srv.URL() + "/index.yaml-nosuchthing") - res.Body.Close() - if err != nil { - t.Fatal(err) - } - if res.StatusCode != 404 { - t.Fatalf("Expected 404, got %d", res.StatusCode) - } -} - -func TestNewTempServer(t *testing.T) { - ensure.HelmHome(t) - - srv, err := NewTempServerWithCleanup(t, "testdata/examplechart-0.1.0.tgz") - if err != nil { - t.Fatal(err) - } - defer srv.Stop() - - res, err := http.Head(srv.URL() + "/examplechart-0.1.0.tgz") - res.Body.Close() - if err != nil { - t.Error(err) - } - if res.StatusCode != 200 { - t.Errorf("Expected 200, got %d", res.StatusCode) - } -} diff --git a/pkg/helm/pkg/repo/v1/chartrepo.go b/pkg/helm/pkg/repo/v1/chartrepo.go new file mode 100644 index 00000000..c1d384b0 --- /dev/null +++ b/pkg/helm/pkg/repo/v1/chartrepo.go @@ -0,0 +1,276 @@ +/* +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 repo // import "github.com/werf/nelm/pkg/helm/pkg/repo/v1" + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/werf/nelm/pkg/helm/intern/fileutil" + "github.com/werf/nelm/pkg/helm/pkg/getter" + "github.com/werf/nelm/pkg/helm/pkg/helmpath" +) + +// Entry represents a collection of parameters for chart repository +type Entry struct { + Name string `json:"name"` + URL string `json:"url"` + Username string `json:"username"` + Password string `json:"password"` + CertFile string `json:"certFile"` + KeyFile string `json:"keyFile"` + CAFile string `json:"caFile"` + InsecureSkipTLSVerify bool `json:"insecure_skip_tls_verify"` + PassCredentialsAll bool `json:"pass_credentials_all"` +} + +// ChartRepository represents a chart repository +type ChartRepository struct { + Config *Entry + IndexFile *IndexFile + Client getter.Getter + CachePath string +} + +// NewChartRepository constructs ChartRepository +func NewChartRepository(cfg *Entry, getters getter.Providers) (*ChartRepository, error) { + u, err := url.Parse(cfg.URL) + if err != nil { + return nil, fmt.Errorf("invalid chart URL format: %s", cfg.URL) + } + + client, err := getters.ByScheme(u.Scheme) + if err != nil { + return nil, fmt.Errorf("could not find protocol handler for: %s", u.Scheme) + } + + return &ChartRepository{ + Config: cfg, + IndexFile: NewIndexFile(), + Client: client, + CachePath: helmpath.CachePath("repository"), + }, nil +} + +// DownloadIndexFile fetches the index from a repository. +func (r *ChartRepository) DownloadIndexFile() (string, error) { + indexURL, err := ResolveReferenceURL(r.Config.URL, "index.yaml") + if err != nil { + return "", err + } + + resp, err := r.Client.Get(indexURL, + getter.WithURL(r.Config.URL), + getter.WithInsecureSkipVerifyTLS(r.Config.InsecureSkipTLSVerify), + getter.WithTLSClientConfig(r.Config.CertFile, r.Config.KeyFile, r.Config.CAFile), + getter.WithBasicAuth(r.Config.Username, r.Config.Password), + getter.WithPassCredentialsAll(r.Config.PassCredentialsAll), + ) + if err != nil { + return "", err + } + + index, err := io.ReadAll(resp) + if err != nil { + return "", err + } + + indexFile, err := loadIndex(index, r.Config.URL) + if err != nil { + return "", err + } + + // Create the chart list file in the cache directory + var charts strings.Builder + for name := range indexFile.Entries { + fmt.Fprintln(&charts, name) + } + chartsFile := filepath.Join(r.CachePath, helmpath.CacheChartsFile(r.Config.Name)) + os.MkdirAll(filepath.Dir(chartsFile), 0755) + + fileutil.AtomicWriteFile(chartsFile, bytes.NewReader([]byte(charts.String())), 0644) + + // Create the index file in the cache directory + fname := filepath.Join(r.CachePath, helmpath.CacheIndexFile(r.Config.Name)) + os.MkdirAll(filepath.Dir(fname), 0755) + return fname, fileutil.AtomicWriteFile(fname, bytes.NewReader(index), 0644) +} + +type findChartInRepoURLOptions struct { + Username string + Password string + PassCredentialsAll bool + InsecureSkipTLSVerify bool + CertFile string + KeyFile string + CAFile string + ChartVersion string +} + +type FindChartInRepoURLOption func(*findChartInRepoURLOptions) + +// WithChartVersion specifies the chart version to find +func WithChartVersion(chartVersion string) FindChartInRepoURLOption { + return func(options *findChartInRepoURLOptions) { + options.ChartVersion = chartVersion + } +} + +// WithUsernamePassword specifies the username/password credntials for the repository +func WithUsernamePassword(username, password string) FindChartInRepoURLOption { + return func(options *findChartInRepoURLOptions) { + options.Username = username + options.Password = password + } +} + +// WithPassCredentialsAll flags whether credentials should be passed on to other domains +func WithPassCredentialsAll(passCredentialsAll bool) FindChartInRepoURLOption { + return func(options *findChartInRepoURLOptions) { + options.PassCredentialsAll = passCredentialsAll + } +} + +// WithClientTLS species the cert, key, and CA files for client mTLS +func WithClientTLS(certFile, keyFile, caFile string) FindChartInRepoURLOption { + return func(options *findChartInRepoURLOptions) { + options.CertFile = certFile + options.KeyFile = keyFile + options.CAFile = caFile + } +} + +// WithInsecureSkipTLSVerify skips TLS verification for repository communication +func WithInsecureSkipTLSVerify(insecureSkipTLSVerify bool) FindChartInRepoURLOption { + return func(options *findChartInRepoURLOptions) { + options.InsecureSkipTLSVerify = insecureSkipTLSVerify + } +} + +// FindChartInRepoURL finds chart in chart repository pointed by repoURL +// without adding repo to repositories +func FindChartInRepoURL(repoURL string, chartName string, getters getter.Providers, options ...FindChartInRepoURLOption) (string, error) { + + opts := findChartInRepoURLOptions{} + for _, option := range options { + option(&opts) + } + + // Download and write the index file to a temporary location + buf := make([]byte, 20) + rand.Read(buf) + name := strings.ReplaceAll(base64.StdEncoding.EncodeToString(buf), "/", "-") + + c := Entry{ + URL: repoURL, + Username: opts.Username, + Password: opts.Password, + PassCredentialsAll: opts.PassCredentialsAll, + CertFile: opts.CertFile, + KeyFile: opts.KeyFile, + CAFile: opts.CAFile, + Name: name, + InsecureSkipTLSVerify: opts.InsecureSkipTLSVerify, + } + r, err := NewChartRepository(&c, getters) + if err != nil { + return "", err + } + idx, err := r.DownloadIndexFile() + if err != nil { + return "", fmt.Errorf("looks like %q is not a valid chart repository or cannot be reached: %w", repoURL, err) + } + defer func() { + os.RemoveAll(filepath.Join(r.CachePath, helmpath.CacheChartsFile(r.Config.Name))) + os.RemoveAll(filepath.Join(r.CachePath, helmpath.CacheIndexFile(r.Config.Name))) + }() + + // Read the index file for the repository to get chart information and return chart URL + repoIndex, err := LoadIndexFile(idx) + if err != nil { + return "", err + } + + errMsg := fmt.Sprintf("chart %q", chartName) + if opts.ChartVersion != "" { + errMsg = fmt.Sprintf("%s version %q", errMsg, opts.ChartVersion) + } + cv, err := repoIndex.Get(chartName, opts.ChartVersion) + if err != nil { + return "", ChartNotFoundError{ + Chart: errMsg, + RepoURL: repoURL, + } + } + + if len(cv.URLs) == 0 { + return "", fmt.Errorf("%s has no downloadable URLs", errMsg) + } + + chartURL := cv.URLs[0] + + absoluteChartURL, err := ResolveReferenceURL(repoURL, chartURL) + if err != nil { + return "", fmt.Errorf("failed to make chart URL absolute: %w", err) + } + + return absoluteChartURL, nil +} + +// ResolveReferenceURL resolves refURL relative to baseURL. +// If refURL is absolute, it simply returns refURL. +func ResolveReferenceURL(baseURL, refURL string) (string, error) { + parsedRefURL, err := url.Parse(refURL) + if err != nil { + return "", fmt.Errorf("failed to parse %s as URL: %w", refURL, err) + } + + if parsedRefURL.IsAbs() { + return refURL, nil + } + + parsedBaseURL, err := url.Parse(baseURL) + if err != nil { + return "", fmt.Errorf("failed to parse %s as URL: %w", baseURL, err) + } + + // We need a trailing slash for ResolveReference to work, but make sure there isn't already one + parsedBaseURL.RawPath = strings.TrimSuffix(parsedBaseURL.RawPath, "/") + "/" + parsedBaseURL.Path = strings.TrimSuffix(parsedBaseURL.Path, "/") + "/" + + resolvedURL := parsedBaseURL.ResolveReference(parsedRefURL) + resolvedURL.RawQuery = parsedBaseURL.RawQuery + return resolvedURL.String(), nil +} + +func (e *Entry) String() string { + buf, err := json.Marshal(e) + if err != nil { + slog.Error("failed to marshal entry", slog.Any("error", err)) + panic(err) + } + return string(buf) +} diff --git a/pkg/helm/pkg/repo/chartrepo_test.go b/pkg/helm/pkg/repo/v1/chartrepo_test.go similarity index 59% rename from pkg/helm/pkg/repo/chartrepo_test.go rename to pkg/helm/pkg/repo/v1/chartrepo_test.go index 615ad6c6..4b06bfa1 100644 --- a/pkg/helm/pkg/repo/chartrepo_test.go +++ b/pkg/helm/pkg/repo/v1/chartrepo_test.go @@ -18,99 +18,24 @@ package repo import ( "bytes" + "errors" "net/http" "net/http/httptest" "os" "path/filepath" - "reflect" "runtime" "strings" + "sync" "testing" "time" "sigs.k8s.io/yaml" - "github.com/werf/nelm/pkg/helm/pkg/chart" "github.com/werf/nelm/pkg/helm/pkg/cli" "github.com/werf/nelm/pkg/helm/pkg/getter" + "github.com/werf/nelm/pkg/helm/pkg/helmpath" ) -const ( - testRepository = "testdata/repository" - testURL = "http://example-charts.com" -) - -func TestLoadChartRepository(t *testing.T) { - r, err := NewChartRepository(&Entry{ - Name: testRepository, - URL: testURL, - }, getter.All(&cli.EnvSettings{})) - if err != nil { - t.Errorf("Problem creating chart repository from %s: %v", testRepository, err) - } - - if err := r.Load(); err != nil { - t.Errorf("Problem loading chart repository from %s: %v", testRepository, err) - } - - paths := []string{ - filepath.Join(testRepository, "frobnitz-1.2.3.tgz"), - filepath.Join(testRepository, "sprocket-1.1.0.tgz"), - filepath.Join(testRepository, "sprocket-1.2.0.tgz"), - filepath.Join(testRepository, "universe/zarthal-1.0.0.tgz"), - } - - if r.Config.Name != testRepository { - t.Errorf("Expected %s as Name but got %s", testRepository, r.Config.Name) - } - - if !reflect.DeepEqual(r.ChartPaths, paths) { - t.Errorf("Expected %#v but got %#v\n", paths, r.ChartPaths) - } - - if r.Config.URL != testURL { - t.Errorf("Expected url for chart repository to be %s but got %s", testURL, r.Config.URL) - } -} - -func TestIndex(t *testing.T) { - r, err := NewChartRepository(&Entry{ - Name: testRepository, - URL: testURL, - }, getter.All(&cli.EnvSettings{})) - if err != nil { - t.Errorf("Problem creating chart repository from %s: %v", testRepository, err) - } - - if err := r.Load(); err != nil { - t.Errorf("Problem loading chart repository from %s: %v", testRepository, err) - } - - err = r.Index() - if err != nil { - t.Errorf("Error performing index: %v\n", err) - } - - tempIndexPath := filepath.Join(testRepository, indexPath) - actual, err := LoadIndexFile(tempIndexPath) - defer os.Remove(tempIndexPath) // clean up - if err != nil { - t.Errorf("Error loading index file %v", err) - } - verifyIndex(t, actual) - - // Re-index and test again. - err = r.Index() - if err != nil { - t.Errorf("Error performing re-index: %s\n", err) - } - second, err := LoadIndexFile(tempIndexPath) - if err != nil { - t.Errorf("Error re-loading index file %v", err) - } - verifyIndex(t, second) -} - type CustomGetter struct { repoUrls []string } @@ -132,7 +57,7 @@ func TestIndexCustomSchemeDownload(t *testing.T) { repoName := "gcs-repo" repoURL := "gs://some-gcs-bucket" myCustomGetter := &CustomGetter{} - customGetterConstructor := func(options ...getter.Option) (getter.Getter, error) { + customGetterConstructor := func(_ ...getter.Option) (getter.Getter, error) { return myCustomGetter, nil } providers := getter.Providers{{ @@ -148,7 +73,7 @@ func TestIndexCustomSchemeDownload(t *testing.T) { } repo.CachePath = t.TempDir() - tempIndexFile, err := os.CreateTemp("", "test-repo") + tempIndexFile, err := os.CreateTemp(t.TempDir(), "test-repo") if err != nil { t.Fatalf("Failed to create temp index file: %v", err) } @@ -169,95 +94,58 @@ func TestIndexCustomSchemeDownload(t *testing.T) { } } -func verifyIndex(t *testing.T, actual *IndexFile) { - var empty time.Time - if actual.Generated.Equal(empty) { - t.Errorf("Generated should be greater than 0: %s", actual.Generated) +func TestConcurrencyDownloadIndex(t *testing.T) { + srv, err := startLocalServerForTests(nil) + if err != nil { + t.Fatal(err) } + defer srv.Close() - if actual.APIVersion != APIVersionV1 { - t.Error("Expected v1 API") - } + repo, err := NewChartRepository(&Entry{ + Name: "nginx", + URL: srv.URL, + }, getter.All(&cli.EnvSettings{})) - entries := actual.Entries - if numEntries := len(entries); numEntries != 3 { - t.Errorf("Expected 3 charts to be listed in index file but got %v", numEntries) + if err != nil { + t.Fatalf("Problem loading chart repository from %s: %v", srv.URL, err) } + repo.CachePath = t.TempDir() - expects := map[string]ChartVersions{ - "frobnitz": { - { - Metadata: &chart.Metadata{ - Name: "frobnitz", - Version: "1.2.3", - }, - }, - }, - "sprocket": { - { - Metadata: &chart.Metadata{ - Name: "sprocket", - Version: "1.2.0", - }, - }, - { - Metadata: &chart.Metadata{ - Name: "sprocket", - Version: "1.1.0", - }, - }, - }, - "zarthal": { - { - Metadata: &chart.Metadata{ - Name: "zarthal", - Version: "1.0.0", - }, - }, - }, + // initial download index + idx, err := repo.DownloadIndexFile() + if err != nil { + t.Fatalf("Failed to download index file to %s: %v", idx, err) } - for name, versions := range expects { - got, ok := entries[name] - if !ok { - t.Errorf("Could not find %q entry", name) - continue - } - if len(versions) != len(got) { - t.Errorf("Expected %d versions, got %d", len(versions), len(got)) - continue - } - for i, e := range versions { - g := got[i] - if e.Name != g.Name { - t.Errorf("Expected %q, got %q", e.Name, g.Name) - } - if e.Version != g.Version { - t.Errorf("Expected %q, got %q", e.Version, g.Version) - } - if len(g.Keywords) != 3 { - t.Error("Expected 3 keywords.") - } - if len(g.Maintainers) != 2 { - t.Error("Expected 2 maintainers.") - } - if g.Created.Equal(empty) { - t.Error("Expected created to be non-empty") - } - if g.Description == "" { - t.Error("Expected description to be non-empty") - } - if g.Home == "" { - t.Error("Expected home to be non-empty") - } - if g.Digest == "" { - t.Error("Expected digest to be non-empty") + indexFName := filepath.Join(repo.CachePath, helmpath.CacheIndexFile(repo.Config.Name)) + + var wg sync.WaitGroup + + // Simultaneously start multiple goroutines that: + // 1) download index.yaml via DownloadIndexFile (write operation), + // 2) read index.yaml via LoadIndexFile (read operation). + // This checks for race conditions and ensures correct behavior under concurrent read/write access. + for range 150 { + wg.Add(1) + + go func() { + defer wg.Done() + idx, err := repo.DownloadIndexFile() + if err != nil { + t.Errorf("Failed to download index file to %s: %v", idx, err) } - if len(g.URLs) != 1 { - t.Error("Expected exactly 1 URL") + }() + + wg.Add(1) + go func() { + defer wg.Done() + _, err := LoadIndexFile(indexFName) + if err != nil { + t.Errorf("Failed to load index file: %v", err) } - } + }() } + wg.Wait() } // startLocalServerForTests Start the local helm server @@ -267,7 +155,7 @@ func startLocalServerForTests(handler http.Handler) (*httptest.Server, error) { if err != nil { return nil, err } - handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Write(fileBytes) }) } @@ -282,7 +170,7 @@ func startLocalTLSServerForTests(handler http.Handler) (*httptest.Server, error) if err != nil { return nil, err } - handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Write(fileBytes) }) } @@ -297,7 +185,12 @@ func TestFindChartInAuthAndTLSAndPassRepoURL(t *testing.T) { } defer srv.Close() - chartURL, err := FindChartInAuthAndTLSAndPassRepoURL(srv.URL, "", "", "nginx", "", "", "", "", true, false, getter.All(&cli.EnvSettings{})) + chartURL, err := FindChartInRepoURL( + srv.URL, + "nginx", + getter.All(&cli.EnvSettings{}), + WithInsecureSkipTLSVerify(true), + ) if err != nil { t.Fatalf("%v", err) } @@ -305,8 +198,8 @@ func TestFindChartInAuthAndTLSAndPassRepoURL(t *testing.T) { t.Errorf("%s is not the valid URL", chartURL) } - // If the insecureSkipTLsverify is false, it will return an error that contains "x509: certificate signed by unknown authority". - _, err = FindChartInAuthAndTLSAndPassRepoURL(srv.URL, "", "", "nginx", "0.1.0", "", "", "", false, false, getter.All(&cli.EnvSettings{})) + // If the insecureSkipTLSVerify is false, it will return an error that contains "x509: certificate signed by unknown authority". + _, err = FindChartInRepoURL(srv.URL, "nginx", getter.All(&cli.EnvSettings{}), WithChartVersion("0.1.0")) // Go communicates with the platform and different platforms return different messages. Go itself tests darwin // differently for its message. On newer versions of Darwin the message includes the "Acme Co" portion while older // versions of Darwin do not. As there are people developing Helm using both old and new versions of Darwin we test @@ -327,7 +220,7 @@ func TestFindChartInRepoURL(t *testing.T) { } defer srv.Close() - chartURL, err := FindChartInRepoURL(srv.URL, "nginx", "", "", "", "", getter.All(&cli.EnvSettings{})) + chartURL, err := FindChartInRepoURL(srv.URL, "nginx", getter.All(&cli.EnvSettings{})) if err != nil { t.Fatalf("%v", err) } @@ -335,7 +228,7 @@ func TestFindChartInRepoURL(t *testing.T) { t.Errorf("%s is not the valid URL", chartURL) } - chartURL, err = FindChartInRepoURL(srv.URL, "nginx", "0.1.0", "", "", "", getter.All(&cli.EnvSettings{})) + chartURL, err = FindChartInRepoURL(srv.URL, "nginx", getter.All(&cli.EnvSettings{}), WithChartVersion("0.1.0")) if err != nil { t.Errorf("%s", err) } @@ -350,7 +243,7 @@ func TestErrorFindChartInRepoURL(t *testing.T) { RepositoryCache: t.TempDir(), }) - if _, err := FindChartInRepoURL("http://someserver/something", "nginx", "", "", "", "", g); err == nil { + if _, err := FindChartInRepoURL("http://someserver/something", "nginx", g); err == nil { t.Errorf("Expected error for bad chart URL, but did not get any errors") } else if !strings.Contains(err.Error(), `looks like "http://someserver/something" is not a valid chart repository or cannot be reached`) { t.Errorf("Expected error for bad chart URL, but got a different error (%v)", err) @@ -362,19 +255,22 @@ func TestErrorFindChartInRepoURL(t *testing.T) { } defer srv.Close() - if _, err = FindChartInRepoURL(srv.URL, "nginx1", "", "", "", "", g); err == nil { + if _, err = FindChartInRepoURL(srv.URL, "nginx1", g); err == nil { t.Errorf("Expected error for chart not found, but did not get any errors") } else if err.Error() != `chart "nginx1" not found in `+srv.URL+` repository` { t.Errorf("Expected error for chart not found, but got a different error (%v)", err) } + if !errors.Is(err, ChartNotFoundError{}) { + t.Errorf("error is not of correct error type structure") + } - if _, err = FindChartInRepoURL(srv.URL, "nginx1", "0.1.0", "", "", "", g); err == nil { + if _, err = FindChartInRepoURL(srv.URL, "nginx1", g, WithChartVersion("0.1.0")); err == nil { t.Errorf("Expected error for chart not found, but did not get any errors") } else if err.Error() != `chart "nginx1" version "0.1.0" not found in `+srv.URL+` repository` { t.Errorf("Expected error for chart not found, but got a different error (%v)", err) } - if _, err = FindChartInRepoURL(srv.URL, "chartWithNoURL", "", "", "", "", g); err == nil { + if _, err = FindChartInRepoURL(srv.URL, "chartWithNoURL", g); err == nil { t.Errorf("Expected error for no chart URLs available, but did not get any errors") } else if err.Error() != `chart "chartWithNoURL" has no downloadable URLs` { t.Errorf("Expected error for chart not found, but got a different error (%v)", err) @@ -385,11 +281,15 @@ func TestResolveReferenceURL(t *testing.T) { for _, tt := range []struct { baseURL, refURL, chartURL string }{ + {"http://localhost:8123/", "/nginx-0.2.0.tgz", "http://localhost:8123/nginx-0.2.0.tgz"}, {"http://localhost:8123/charts/", "nginx-0.2.0.tgz", "http://localhost:8123/charts/nginx-0.2.0.tgz"}, + {"http://localhost:8123/charts/", "/nginx-0.2.0.tgz", "http://localhost:8123/nginx-0.2.0.tgz"}, {"http://localhost:8123/charts-with-no-trailing-slash", "nginx-0.2.0.tgz", "http://localhost:8123/charts-with-no-trailing-slash/nginx-0.2.0.tgz"}, {"http://localhost:8123", "https://charts.helm.sh/stable/nginx-0.2.0.tgz", "https://charts.helm.sh/stable/nginx-0.2.0.tgz"}, {"http://localhost:8123/charts%2fwith%2fescaped%2fslash", "nginx-0.2.0.tgz", "http://localhost:8123/charts%2fwith%2fescaped%2fslash/nginx-0.2.0.tgz"}, + {"http://localhost:8123/charts%2fwith%2fescaped%2fslash", "/nginx-0.2.0.tgz", "http://localhost:8123/nginx-0.2.0.tgz"}, {"http://localhost:8123/charts?with=queryparameter", "nginx-0.2.0.tgz", "http://localhost:8123/charts/nginx-0.2.0.tgz?with=queryparameter"}, + {"http://localhost:8123/charts?with=queryparameter", "/nginx-0.2.0.tgz", "http://localhost:8123/nginx-0.2.0.tgz?with=queryparameter"}, } { chartURL, err := ResolveReferenceURL(tt.baseURL, tt.refURL) if err != nil { diff --git a/pkg/helm/pkg/repo/doc.go b/pkg/helm/pkg/repo/v1/doc.go similarity index 100% rename from pkg/helm/pkg/repo/doc.go rename to pkg/helm/pkg/repo/v1/doc.go diff --git a/pkg/helm/pkg/repo/v1/error.go b/pkg/helm/pkg/repo/v1/error.go new file mode 100644 index 00000000..16264ed2 --- /dev/null +++ b/pkg/helm/pkg/repo/v1/error.go @@ -0,0 +1,35 @@ +/* +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 repo + +import ( + "fmt" +) + +type ChartNotFoundError struct { + RepoURL string + Chart string +} + +func (e ChartNotFoundError) Error() string { + return fmt.Sprintf("%s not found in %s repository", e.Chart, e.RepoURL) +} + +func (e ChartNotFoundError) Is(err error) bool { + _, ok := err.(ChartNotFoundError) + return ok +} diff --git a/pkg/helm/pkg/repo/index.go b/pkg/helm/pkg/repo/v1/index.go similarity index 89% rename from pkg/helm/pkg/repo/index.go rename to pkg/helm/pkg/repo/v1/index.go index b69c9ef7..ce2d354b 100644 --- a/pkg/helm/pkg/repo/index.go +++ b/pkg/helm/pkg/repo/v1/index.go @@ -18,8 +18,11 @@ package repo import ( "bytes" + "context" "encoding/json" - "log" + "errors" + "fmt" + "log/slog" "os" "path" "path/filepath" @@ -28,19 +31,15 @@ import ( "time" "github.com/Masterminds/semver/v3" - "github.com/pkg/errors" "sigs.k8s.io/yaml" - "github.com/werf/nelm/pkg/helm/internal/fileutil" - "github.com/werf/nelm/pkg/helm/internal/urlutil" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chart/loader" + "github.com/werf/nelm/pkg/helm/intern/fileutil" + "github.com/werf/nelm/pkg/helm/intern/urlutil" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/loader" "github.com/werf/nelm/pkg/helm/pkg/provenance" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" ) -var indexPath = "index.yaml" - // APIVersionV1 is the v1 API version for index and repository files. const APIVersionV1 = "v1" @@ -111,7 +110,7 @@ func LoadIndexFile(path string) (*IndexFile, error) { } i, err := loadIndex(b, path) if err != nil { - return nil, errors.Wrapf(err, "error loading %s", path) + return nil, fmt.Errorf("error loading %s: %w", path, err) } return i, nil } @@ -127,7 +126,7 @@ func (i IndexFile) MustAdd(md *chart.Metadata, filename, baseURL, digest string) md.APIVersion = chart.APIVersionV1 } if err := md.Validate(); err != nil { - return errors.Wrapf(err, "validate failed for %s", filename) + return fmt.Errorf("validate failed for %s: %w", filename, err) } u := filename @@ -155,7 +154,7 @@ func (i IndexFile) MustAdd(md *chart.Metadata, filename, baseURL, digest string) // Deprecated: Use index.MustAdd instead. func (i IndexFile) Add(md *chart.Metadata, filename, baseURL, digest string) { if err := i.MustAdd(md, filename, baseURL, digest); err != nil { - log.Printf("skipping loading invalid entry for chart %q %q from %s: %s", md.Name, md.Version, filename, err) + slog.Error("skipping loading invalid entry for chart %q %q from %s: %s", md.Name, md.Version, filename, err) } } @@ -201,7 +200,7 @@ func (i IndexFile) Get(name, version string) (*ChartVersion, error) { } } - // when customer input exact version, check whether have exact match one first + // when customer inputs specific version, check whether there's an exact match first if len(version) != 0 { for _, ver := range vs { if version == ver.Version { @@ -217,10 +216,13 @@ func (i IndexFile) Get(name, version string) (*ChartVersion, error) { } if constraint.Check(test) { + if len(version) != 0 { + slog.Warn("unable to find exact version requested; falling back to closest available version", "chart", name, "requested", version, "selected", ver.Version) + } return ver, nil } } - return nil, errors.Errorf("no chart version found for %s-%s", name, version) + return nil, fmt.Errorf("no chart version found for %s-%s", name, version) } // WriteFile writes an index file to the given destination path. @@ -296,7 +298,7 @@ type ChartVersion struct { // It indexes only charts that have been packaged (*.tgz). // // The index returned will be in an unsorted state -func IndexDirectory(dir, baseURL string, opts helmopts.HelmOptions) (*IndexFile, error) { +func IndexDirectory(dir, baseURL string) (*IndexFile, error) { archives, err := filepath.Glob(filepath.Join(dir, "*.tgz")) if err != nil { return nil, err @@ -323,7 +325,7 @@ func IndexDirectory(dir, baseURL string, opts helmopts.HelmOptions) (*IndexFile, parentURL = path.Join(baseURL, parentDir) } - c, err := loader.Load(arch, opts) + c, err := loader.Load(context.Background(), arch) if err != nil { // Assume this is not a chart. continue @@ -333,7 +335,7 @@ func IndexDirectory(dir, baseURL string, opts helmopts.HelmOptions) (*IndexFile, return index, err } if err := index.MustAdd(c.Metadata, fname, parentURL, hash); err != nil { - return index, errors.Wrapf(err, "failed adding to %s to index", fname) + return index, fmt.Errorf("failed adding to %s to index: %w", fname, err) } } return index, nil @@ -357,7 +359,8 @@ func loadIndex(data []byte, source string) (*IndexFile, error) { for name, cvs := range i.Entries { for idx := len(cvs) - 1; idx >= 0; idx-- { if cvs[idx] == nil { - log.Printf("skipping loading invalid entry for chart %q from %s: empty entry", name, source) + slog.Warn(fmt.Sprintf("skipping loading invalid entry for chart %q from %s: empty entry", name, source)) + cvs = append(cvs[:idx], cvs[idx+1:]...) continue } // When metadata section missing, initialize with no data @@ -368,10 +371,12 @@ func loadIndex(data []byte, source string) (*IndexFile, error) { cvs[idx].APIVersion = chart.APIVersionV1 } if err := cvs[idx].Validate(); ignoreSkippableChartValidationError(err) != nil { - log.Printf("skipping loading invalid entry for chart %q %q from %s: %s", name, cvs[idx].Version, source, err) + slog.Warn(fmt.Sprintf("skipping loading invalid entry for chart %q %q from %s: %s", name, cvs[idx].Version, source, err)) cvs = append(cvs[:idx], cvs[idx+1:]...) } } + // adjust slice to only contain a set of valid versions + i.Entries[name] = cvs } i.SortEntries() if i.APIVersion == "" { @@ -398,7 +403,7 @@ func jsonOrYamlUnmarshal(b []byte, i interface{}) error { // the error isn't important for index loading // // In particular, charts may introduce validations that don't impact repository indexes -// And repository indexes may be generated by older/non-complient software, which doesn't +// And repository indexes may be generated by older/non-compliant software, which doesn't // conform to all validations. func ignoreSkippableChartValidationError(err error) error { verr, ok := err.(chart.ValidationError) diff --git a/pkg/helm/pkg/repo/index_test.go b/pkg/helm/pkg/repo/v1/index_test.go similarity index 89% rename from pkg/helm/pkg/repo/index_test.go rename to pkg/helm/pkg/repo/v1/index_test.go index 97f0bc4f..9dedb23b 100644 --- a/pkg/helm/pkg/repo/index_test.go +++ b/pkg/helm/pkg/repo/v1/index_test.go @@ -28,7 +28,7 @@ import ( "strings" "testing" - "github.com/werf/nelm/pkg/helm/pkg/chart" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" "github.com/werf/nelm/pkg/helm/pkg/cli" "github.com/werf/nelm/pkg/helm/pkg/getter" "github.com/werf/nelm/pkg/helm/pkg/helmpath" @@ -68,6 +68,7 @@ entries: grafana: - apiVersion: v2 name: grafana + - null foo: - bar: @@ -123,17 +124,17 @@ func TestIndexFile(t *testing.T) { } cv, err := i.Get("setter", "0.1.9") - if err == nil && !strings.Contains(cv.Metadata.Version, "0.1.9") { - t.Errorf("Unexpected version: %s", cv.Metadata.Version) + if err == nil && !strings.Contains(cv.Version, "0.1.9") { + t.Errorf("Unexpected version: %s", cv.Version) } cv, err = i.Get("setter", "0.1.9+alpha") - if err != nil || cv.Metadata.Version != "0.1.9+alpha" { + if err != nil || cv.Version != "0.1.9+alpha" { t.Errorf("Expected version: 0.1.9+alpha") } cv, err = i.Get("setter", "0.1.8") - if err != nil || cv.Metadata.Version != "0.1.8" { + if err != nil || cv.Version != "0.1.8" { t.Errorf("Expected version: 0.1.8") } } @@ -159,7 +160,6 @@ func TestLoadIndex(t *testing.T) { } for _, tc := range tests { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() i, err := LoadIndexFile(tc.Filename) @@ -352,6 +352,7 @@ func TestDownloadIndexFile(t *testing.T) { } func verifyLocalIndex(t *testing.T, i *IndexFile) { + t.Helper() numEntries := len(i.Entries) if numEntries != 3 { t.Errorf("Expected 3 entries in index file but got %d", numEntries) @@ -450,6 +451,7 @@ func verifyLocalIndex(t *testing.T, i *IndexFile) { } func verifyLocalChartsFile(t *testing.T, chartsContent []byte, indexContent *IndexFile) { + t.Helper() var expected, reald []string for chart := range indexContent.Entries { expected = append(expected, chart) @@ -644,3 +646,75 @@ func TestIgnoreSkippableChartValidationError(t *testing.T) { }) } } + +var indexWithDuplicatesInChartDeps = ` +apiVersion: v1 +entries: + nginx: + - urls: + - https://charts.helm.sh/stable/alpine-1.0.0.tgz + - http://storage2.googleapis.com/kubernetes-charts/alpine-1.0.0.tgz + name: alpine + description: string + home: https://github.com/something + digest: "sha256:1234567890abcdef" + - urls: + - https://charts.helm.sh/stable/nginx-0.2.0.tgz + name: nginx + description: string + version: 0.2.0 + home: https://github.com/something/else + digest: "sha256:1234567890abcdef" +` +var indexWithDuplicatesInLastChartDeps = ` +apiVersion: v1 +entries: + nginx: + - urls: + - https://charts.helm.sh/stable/nginx-0.2.0.tgz + name: nginx + description: string + version: 0.2.0 + home: https://github.com/something/else + digest: "sha256:1234567890abcdef" + - urls: + - https://charts.helm.sh/stable/alpine-1.0.0.tgz + - http://storage2.googleapis.com/kubernetes-charts/alpine-1.0.0.tgz + name: alpine + description: string + home: https://github.com/something + digest: "sha256:111" +` + +func TestLoadIndex_DuplicateChartDeps(t *testing.T) { + tests := []struct { + source string + data string + }{ + { + source: "indexWithDuplicatesInChartDeps", + data: indexWithDuplicatesInChartDeps, + }, + { + source: "indexWithDuplicatesInLastChartDeps", + data: indexWithDuplicatesInLastChartDeps, + }, + } + for _, tc := range tests { + t.Run(tc.source, func(t *testing.T) { + idx, err := loadIndex([]byte(tc.data), tc.source) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + cvs := idx.Entries["nginx"] + if cvs == nil { + t.Error("expected one chart version not to be filtered out") + } + for _, v := range cvs { + if v.Name == "alpine" { + t.Error("malformed version was not filtered out") + } + } + }) + } +} diff --git a/pkg/helm/pkg/repo/repo.go b/pkg/helm/pkg/repo/v1/repo.go similarity index 94% rename from pkg/helm/pkg/repo/repo.go rename to pkg/helm/pkg/repo/v1/repo.go index 834d554b..036c1bb2 100644 --- a/pkg/helm/pkg/repo/repo.go +++ b/pkg/helm/pkg/repo/v1/repo.go @@ -14,14 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -package repo // import "helm.sh/helm/v3/pkg/repo" +package repo // import "github.com/werf/nelm/pkg/helm/pkg/repo/v1" import ( + "fmt" "os" "path/filepath" "time" - "github.com/pkg/errors" "sigs.k8s.io/yaml" ) @@ -48,7 +48,7 @@ func LoadFile(path string) (*File, error) { r := new(File) b, err := os.ReadFile(path) if err != nil { - return r, errors.Wrapf(err, "couldn't load repositories file (%s)", path) + return r, fmt.Errorf("couldn't load repositories file (%s): %w", path, err) } err = yaml.Unmarshal(b, r) diff --git a/pkg/helm/pkg/repo/repo_test.go b/pkg/helm/pkg/repo/v1/repo_test.go similarity index 99% rename from pkg/helm/pkg/repo/repo_test.go rename to pkg/helm/pkg/repo/v1/repo_test.go index c2087ebb..bdaa61ed 100644 --- a/pkg/helm/pkg/repo/repo_test.go +++ b/pkg/helm/pkg/repo/v1/repo_test.go @@ -197,7 +197,7 @@ func TestWriteFile(t *testing.T) { }, ) - file, err := os.CreateTemp("", "helm-repo") + file, err := os.CreateTemp(t.TempDir(), "helm-repo") if err != nil { t.Errorf("failed to create test-file (%v)", err) } diff --git a/pkg/helm/pkg/repo/repotest/doc.go b/pkg/helm/pkg/repo/v1/repotest/doc.go similarity index 100% rename from pkg/helm/pkg/repo/repotest/doc.go rename to pkg/helm/pkg/repo/v1/repotest/doc.go diff --git a/pkg/helm/pkg/repo/repotest/server.go b/pkg/helm/pkg/repo/v1/repotest/server.go similarity index 66% rename from pkg/helm/pkg/repo/repotest/server.go rename to pkg/helm/pkg/repo/v1/repotest/server.go index c46e6b0a..f098158d 100644 --- a/pkg/helm/pkg/repo/repotest/server.go +++ b/pkg/helm/pkg/repo/v1/repotest/server.go @@ -17,7 +17,9 @@ package repotest import ( "context" + "crypto/tls" "fmt" + "net" "net/http" "net/http/httptest" "os" @@ -29,47 +31,112 @@ import ( "github.com/distribution/distribution/v3/registry" _ "github.com/distribution/distribution/v3/registry/auth/htpasswd" // used for docker test registry _ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory" // used for docker test registry - "github.com/phayes/freeport" "golang.org/x/crypto/bcrypt" "sigs.k8s.io/yaml" - "github.com/werf/nelm/pkg/helm/internal/tlsutil" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chart/loader" - "github.com/werf/nelm/pkg/helm/pkg/chartutil" + chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/helm/pkg/chart/v2/loader" + chartutil "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" ociRegistry "github.com/werf/nelm/pkg/helm/pkg/registry" - "github.com/werf/nelm/pkg/helm/pkg/repo" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1" ) -// NewTempServerWithCleanup creates a server inside of a temp dir. +func BasicAuthMiddleware(t *testing.T) http.HandlerFunc { + t.Helper() + return http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != "username" || password != "password" { + t.Errorf("Expected request to use basic auth and for username == 'username' and password == 'password', got '%v', '%s', '%s'", ok, username, password) + } + }) +} + +type ServerOption func(*testing.T, *Server) + +func WithTLSConfig(tlsConfig *tls.Config) ServerOption { + return func(_ *testing.T, server *Server) { + server.tlsConfig = tlsConfig + } +} + +func WithMiddleware(middleware http.HandlerFunc) ServerOption { + return func(_ *testing.T, server *Server) { + server.middleware = middleware + } +} + +func WithChartSourceGlob(glob string) ServerOption { + return func(_ *testing.T, server *Server) { + server.chartSourceGlob = glob + } +} + +// Server is an implementation of a repository server for testing. +type Server struct { + docroot string + srv *httptest.Server + middleware http.HandlerFunc + tlsConfig *tls.Config + chartSourceGlob string +} + +// NewTempServer creates a server inside of a temp dir. // // If the passed in string is not "", it will be treated as a shell glob, and files // will be copied from that path to the server's docroot. // -// The caller is responsible for stopping the server. +// The server is started automatically. The caller is responsible for stopping +// the server. +// // The temp dir will be removed by testing package automatically when test finished. -func NewTempServerWithCleanup(t *testing.T, glob string) (*Server, error) { - srv, err := NewTempServer(glob) +func NewTempServer(t *testing.T, options ...ServerOption) *Server { + t.Helper() + docrootTempDir := t.TempDir() + + srv := newServer(t, docrootTempDir, options...) + t.Cleanup(func() { os.RemoveAll(srv.docroot) }) - return srv, err + + if srv.chartSourceGlob != "" { + if _, err := srv.CopyCharts(srv.chartSourceGlob); err != nil { + t.Fatal(err) + } + } + + return srv } -// Set up a fake repo with basic auth enabled -func NewTempServerWithCleanupAndBasicAuth(t *testing.T, glob string) *Server { - srv, err := NewTempServerWithCleanup(t, glob) - srv.Stop() +// Create the server, but don't yet start it +func newServer(t *testing.T, docroot string, options ...ServerOption) *Server { + t.Helper() + absdocroot, err := filepath.Abs(docroot) if err != nil { t.Fatal(err) } - srv.WithMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - username, password, ok := r.BasicAuth() - if !ok || username != "username" || password != "password" { - t.Errorf("Expected request to use basic auth and for username == 'username' and password == 'password', got '%v', '%s', '%s'", ok, username, password) + + s := &Server{ + docroot: absdocroot, + } + + for _, option := range options { + option(t, s) + } + + s.srv = httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.middleware != nil { + s.middleware.ServeHTTP(w, r) } + http.FileServer(http.Dir(s.Root())).ServeHTTP(w, r) })) - srv.Start() - return srv + + s.start() + + // Add the testing repository as the only repo. Server must be started for the server's URL to be valid + if err := setTestingRepository(s.URL(), filepath.Join(s.docroot, "repositories.yaml")); err != nil { + t.Fatal(err) + } + + return s } type OCIServer struct { @@ -87,6 +154,10 @@ type OCIServerRunConfig struct { type OCIServerOpt func(config *OCIServerRunConfig) +type OCIServerRunResult struct { + PushedChart *ociRegistry.PushResult +} + func WithDependingChart(c *chart.Chart) OCIServerOpt { return func(config *OCIServerRunConfig) { config.DependingChart = c @@ -94,6 +165,7 @@ func WithDependingChart(c *chart.Chart) OCIServerOpt { } func NewOCIServer(t *testing.T, dir string) (*OCIServer, error) { + t.Helper() testHtpasswdFileBasename := "authtest.htpasswd" testUsername, testPassword := "username", "password" @@ -102,19 +174,21 @@ func NewOCIServer(t *testing.T, dir string) (*OCIServer, error) { t.Fatal("error generating bcrypt password for test htpasswd file") } htpasswdPath := filepath.Join(dir, testHtpasswdFileBasename) - err = os.WriteFile(htpasswdPath, []byte(fmt.Sprintf("%s:%s\n", testUsername, string(pwBytes))), 0644) + err = os.WriteFile(htpasswdPath, fmt.Appendf(nil, "%s:%s\n", testUsername, string(pwBytes)), 0o644) if err != nil { t.Fatalf("error creating test htpasswd file") } // Registry config config := &configuration.Configuration{} - port, err := freeport.GetFreePort() + ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("error finding free port for test registry") } + defer ln.Close() - config.HTTP.Addr = fmt.Sprintf(":%d", port) + port := ln.Addr().(*net.TCPAddr).Port + config.HTTP.Addr = ln.Addr().String() config.HTTP.DrainTimeout = time.Duration(10) * time.Second config.Storage = map[string]configuration.Parameters{"inmemory": map[string]interface{}{}} config.Auth = configuration.Auth{ @@ -126,7 +200,7 @@ func NewOCIServer(t *testing.T, dir string) (*OCIServer, error) { registryURL := fmt.Sprintf("localhost:%d", port) - r, err := registry.NewRegistry(context.Background(), config) + r, err := registry.NewRegistry(t.Context(), config) if err != nil { t.Fatal(err) } @@ -141,6 +215,12 @@ func NewOCIServer(t *testing.T, dir string) (*OCIServer, error) { } func (srv *OCIServer) Run(t *testing.T, opts ...OCIServerOpt) { + t.Helper() + _ = srv.RunWithReturn(t, opts...) +} + +func (srv *OCIServer) RunWithReturn(t *testing.T, opts ...OCIServerOpt) *OCIServerRunResult { + t.Helper() cfg := &OCIServerRunConfig{} for _, fn := range opts { fn(cfg) @@ -164,9 +244,10 @@ func (srv *OCIServer) Run(t *testing.T, opts ...OCIServerOpt) { err = registryClient.Login( srv.RegistryURL, ociRegistry.LoginOptBasicAuth(srv.TestUsername, srv.TestPassword), - ociRegistry.LoginOptInsecure(false)) + ociRegistry.LoginOptInsecure(true), + ociRegistry.LoginOptPlainText(true)) if err != nil { - t.Fatalf("error logging into registry with good credentials") + t.Fatalf("error logging into registry with good credentials: %v", err) } ref := fmt.Sprintf("%s/u/ocitestuser/oci-dependent-chart:0.1.0", srv.RegistryURL) @@ -177,7 +258,7 @@ func (srv *OCIServer) Run(t *testing.T, opts ...OCIServerOpt) { } // valid chart - ch, err := loader.LoadDir(filepath.Join(srv.Dir, "oci-dependent-chart")) + ch, err := loader.LoadDir(context.Background(), filepath.Join(srv.Dir, "oci-dependent-chart")) if err != nil { t.Fatal("error loading chart") } @@ -199,7 +280,7 @@ func (srv *OCIServer) Run(t *testing.T, opts ...OCIServerOpt) { t.Fatal("could not load chart into memory") } - result, err := registryClient.Push(contentBytes, ref, helmopts.HelmOptions{}) + result, err := registryClient.Push(contentBytes, ref) if err != nil { t.Fatalf("error pushing dependent chart: %s", err) } @@ -213,7 +294,9 @@ func (srv *OCIServer) Run(t *testing.T, opts ...OCIServerOpt) { srv.Client = registryClient c := cfg.DependingChart if c == nil { - return + return &OCIServerRunResult{ + PushedChart: result, + } } dependingRef := fmt.Sprintf("%s/u/ocitestuser/%s:%s", @@ -227,7 +310,7 @@ func (srv *OCIServer) Run(t *testing.T, opts ...OCIServerOpt) { t.Fatal("could not load chart into memory") } - result, err = registryClient.Push(contentBytes, dependingRef, helmopts.HelmOptions{}) + result, err = registryClient.Push(contentBytes, dependingRef) if err != nil { t.Fatalf("error pushing depending chart: %s", err) } @@ -237,69 +320,10 @@ func (srv *OCIServer) Run(t *testing.T, opts ...OCIServerOpt) { result.Manifest.Digest, result.Manifest.Size, result.Config.Digest, result.Config.Size, result.Chart.Digest, result.Chart.Size) -} - -// NewTempServer creates a server inside of a temp dir. -// -// If the passed in string is not "", it will be treated as a shell glob, and files -// will be copied from that path to the server's docroot. -// -// The caller is responsible for destroying the temp directory as well as stopping -// the server. -// -// Deprecated: use NewTempServerWithCleanup -func NewTempServer(glob string) (*Server, error) { - tdir, err := os.MkdirTemp("", "helm-repotest-") - if err != nil { - return nil, err - } - srv := NewServer(tdir) - if glob != "" { - if _, err := srv.CopyCharts(glob); err != nil { - srv.Stop() - return srv, err - } + return &OCIServerRunResult{ + PushedChart: result, } - - return srv, nil -} - -// NewServer creates a repository server for testing. -// -// docroot should be a temp dir managed by the caller. -// -// This will start the server, serving files off of the docroot. -// -// Use CopyCharts to move charts into the repository and then index them -// for service. -func NewServer(docroot string) *Server { - root, err := filepath.Abs(docroot) - if err != nil { - panic(err) - } - srv := &Server{ - docroot: root, - } - srv.Start() - // Add the testing repository as the only repo. - if err := setTestingRepository(srv.URL(), filepath.Join(root, "repositories.yaml")); err != nil { - panic(err) - } - return srv -} - -// Server is an implementation of a repository server for testing. -type Server struct { - docroot string - srv *httptest.Server - middleware http.HandlerFunc -} - -// WithMiddleware injects middleware in front of the server. This can be used to inject -// additional functionality like layering in an authentication frontend. -func (s *Server) WithMiddleware(middleware http.HandlerFunc) { - s.middleware = middleware } // Root gets the docroot for the server. @@ -321,7 +345,7 @@ func (s *Server) CopyCharts(origin string) ([]string, error) { if err != nil { return []string{}, err } - if err := os.WriteFile(newname, data, 0644); err != nil { + if err := os.WriteFile(newname, data, 0o644); err != nil { return []string{}, err } copied[i] = newname @@ -334,7 +358,7 @@ func (s *Server) CopyCharts(origin string) ([]string, error) { // CreateIndex will read docroot and generate an index.yaml file. func (s *Server) CreateIndex() error { // generate the index - index, err := repo.IndexDirectory(s.docroot, s.URL(), helmopts.HelmOptions{}) + index, err := repo.IndexDirectory(s.docroot, s.URL()) if err != nil { return err } @@ -345,49 +369,15 @@ func (s *Server) CreateIndex() error { } ifile := filepath.Join(s.docroot, "index.yaml") - return os.WriteFile(ifile, d, 0644) -} - -func (s *Server) Start() { - s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if s.middleware != nil { - s.middleware.ServeHTTP(w, r) - } - http.FileServer(http.Dir(s.docroot)).ServeHTTP(w, r) - })) + return os.WriteFile(ifile, d, 0o644) } -func (s *Server) StartTLS() { - cd := "../../testdata" - ca, pub, priv := filepath.Join(cd, "rootca.crt"), filepath.Join(cd, "crt.pem"), filepath.Join(cd, "key.pem") - insecure := false - - s.srv = httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if s.middleware != nil { - s.middleware.ServeHTTP(w, r) - } - http.FileServer(http.Dir(s.Root())).ServeHTTP(w, r) - })) - tlsConf, err := tlsutil.NewClientTLS(pub, priv, ca, insecure) - if err != nil { - panic(err) - } - tlsConf.ServerName = "helm.sh" - s.srv.TLS = tlsConf - s.srv.StartTLS() - - // Set up repositories config with ca file - repoConfig := filepath.Join(s.Root(), "repositories.yaml") - - r := repo.NewFile() - r.Add(&repo.Entry{ - Name: "test", - URL: s.URL(), - CAFile: filepath.Join("../../testdata", "rootca.crt"), - }) - - if err := r.WriteFile(repoConfig, 0600); err != nil { - panic(err) +func (s *Server) start() { + if s.tlsConfig != nil { + s.srv.TLS = s.tlsConfig + s.srv.StartTLS() + } else { + s.srv.Start() } } @@ -407,6 +397,10 @@ func (s *Server) URL() string { return s.srv.URL } +func (s *Server) Client() *http.Client { + return s.srv.Client() +} + // LinkIndices links the index created with CreateIndex and makes a symbolic link to the cache index. // // This makes it possible to simulate a local cache of a repository. @@ -418,10 +412,14 @@ func (s *Server) LinkIndices() error { // setTestingRepository sets up a testing repository.yaml with only the given URL. func setTestingRepository(url, fname string) error { + if url == "" { + panic("no url") + } + r := repo.NewFile() r.Add(&repo.Entry{ Name: "test", URL: url, }) - return r.WriteFile(fname, 0640) + return r.WriteFile(fname, 0o640) } diff --git a/pkg/helm/pkg/repo/v1/repotest/server_test.go b/pkg/helm/pkg/repo/v1/repotest/server_test.go new file mode 100644 index 00000000..1907b014 --- /dev/null +++ b/pkg/helm/pkg/repo/v1/repotest/server_test.go @@ -0,0 +1,222 @@ +/* +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 repotest + +import ( + "io" + "net/http" + "path/filepath" + "strings" + "testing" + + "sigs.k8s.io/yaml" + + "github.com/werf/nelm/pkg/helm/intern/test/ensure" + "github.com/werf/nelm/pkg/helm/pkg/repo/v1" +) + +// Young'n, in these here parts, we test our tests. + +func TestServer(t *testing.T) { + ensure.HelmHome(t) + + rootDir := t.TempDir() + + srv := newServer(t, rootDir) + defer srv.Stop() + + c, err := srv.CopyCharts("testdata/*.tgz") + if err != nil { + // Some versions of Go don't correctly fire defer on Fatal. + t.Fatal(err) + } + + if len(c) != 1 { + t.Errorf("Unexpected chart count: %d", len(c)) + } + + if filepath.Base(c[0]) != "examplechart-0.1.0.tgz" { + t.Errorf("Unexpected chart: %s", c[0]) + } + + res, err := http.Get(srv.URL() + "/examplechart-0.1.0.tgz") + res.Body.Close() + if err != nil { + t.Fatal(err) + } + + if res.ContentLength < 500 { + t.Errorf("Expected at least 500 bytes of data, got %d", res.ContentLength) + } + + res, err = http.Get(srv.URL() + "/index.yaml") + if err != nil { + t.Fatal(err) + } + + data, err := io.ReadAll(res.Body) + res.Body.Close() + if err != nil { + t.Fatal(err) + } + + m := repo.NewIndexFile() + if err := yaml.Unmarshal(data, m); err != nil { + t.Fatal(err) + } + + if l := len(m.Entries); l != 1 { + t.Fatalf("Expected 1 entry, got %d", l) + } + + expect := "examplechart" + if !m.Has(expect, "0.1.0") { + t.Errorf("missing %q", expect) + } + + res, err = http.Get(srv.URL() + "/index.yaml-nosuchthing") + res.Body.Close() + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusNotFound { + t.Fatalf("Expected 404, got %d", res.StatusCode) + } +} + +func TestNewTempServer(t *testing.T) { + ensure.HelmHome(t) + + type testCase struct { + options []ServerOption + } + + testCases := map[string]testCase{ + "plainhttp": { + options: []ServerOption{ + WithChartSourceGlob("testdata/examplechart-0.1.0.tgz"), + }, + }, + "tls": { + options: []ServerOption{ + WithChartSourceGlob("testdata/examplechart-0.1.0.tgz"), + WithTLSConfig(MakeTestTLSConfig(t, "../../../../testdata")), + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + srv := NewTempServer( + t, + tc.options..., + ) + defer srv.Stop() + + if srv.srv.URL == "" { + t.Fatal("unstarted server") + } + + client := srv.Client() + + { + res, err := client.Head(srv.URL() + "/repositories.yaml") + if err != nil { + t.Error(err) + } + + res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Errorf("Expected 200, got %d", res.StatusCode) + } + + } + + { + res, err := client.Head(srv.URL() + "/examplechart-0.1.0.tgz") + if err != nil { + t.Error(err) + } + res.Body.Close() + + if res.StatusCode != http.StatusOK { + t.Errorf("Expected 200, got %d", res.StatusCode) + } + } + + res, err := client.Get(srv.URL() + "/examplechart-0.1.0.tgz") + res.Body.Close() + if err != nil { + t.Fatal(err) + } + + if res.ContentLength < 500 { + t.Errorf("Expected at least 500 bytes of data, got %d", res.ContentLength) + } + + res, err = client.Get(srv.URL() + "/index.yaml") + if err != nil { + t.Fatal(err) + } + + data, err := io.ReadAll(res.Body) + res.Body.Close() + if err != nil { + t.Fatal(err) + } + + m := repo.NewIndexFile() + if err := yaml.Unmarshal(data, m); err != nil { + t.Fatal(err) + } + + if l := len(m.Entries); l != 1 { + t.Fatalf("Expected 1 entry, got %d", l) + } + + expect := "examplechart" + if !m.Has(expect, "0.1.0") { + t.Errorf("missing %q", expect) + } + + res, err = client.Get(srv.URL() + "/index.yaml-nosuchthing") + res.Body.Close() + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusNotFound { + t.Fatalf("Expected 404, got %d", res.StatusCode) + } + }) + } + +} + +func TestNewTempServer_TLS(t *testing.T) { + ensure.HelmHome(t) + + srv := NewTempServer( + t, + WithChartSourceGlob("testdata/examplechart-0.1.0.tgz"), + WithTLSConfig(MakeTestTLSConfig(t, "../../../../testdata")), + ) + defer srv.Stop() + + if !strings.HasPrefix(srv.URL(), "https://") { + t.Fatal("non-TLS server") + } +} diff --git a/pkg/helm/pkg/repo/repotest/testdata/examplechart-0.1.0.tgz b/pkg/helm/pkg/repo/v1/repotest/testdata/examplechart-0.1.0.tgz similarity index 100% rename from pkg/helm/pkg/repo/repotest/testdata/examplechart-0.1.0.tgz rename to pkg/helm/pkg/repo/v1/repotest/testdata/examplechart-0.1.0.tgz diff --git a/pkg/helm/pkg/repo/repotest/testdata/examplechart/.helmignore b/pkg/helm/pkg/repo/v1/repotest/testdata/examplechart/.helmignore similarity index 100% rename from pkg/helm/pkg/repo/repotest/testdata/examplechart/.helmignore rename to pkg/helm/pkg/repo/v1/repotest/testdata/examplechart/.helmignore diff --git a/pkg/helm/pkg/repo/repotest/testdata/examplechart/Chart.yaml b/pkg/helm/pkg/repo/v1/repotest/testdata/examplechart/Chart.yaml similarity index 100% rename from pkg/helm/pkg/repo/repotest/testdata/examplechart/Chart.yaml rename to pkg/helm/pkg/repo/v1/repotest/testdata/examplechart/Chart.yaml diff --git a/pkg/helm/pkg/repo/repotest/testdata/examplechart/values.yaml b/pkg/helm/pkg/repo/v1/repotest/testdata/examplechart/values.yaml similarity index 100% rename from pkg/helm/pkg/repo/repotest/testdata/examplechart/values.yaml rename to pkg/helm/pkg/repo/v1/repotest/testdata/examplechart/values.yaml diff --git a/pkg/helm/pkg/repo/v1/repotest/tlsconfig.go b/pkg/helm/pkg/repo/v1/repotest/tlsconfig.go new file mode 100644 index 00000000..30aa28bf --- /dev/null +++ b/pkg/helm/pkg/repo/v1/repotest/tlsconfig.go @@ -0,0 +1,44 @@ +/* +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 repotest + +import ( + "crypto/tls" + "path/filepath" + "testing" + + "github.com/werf/nelm/pkg/helm/intern/tlsutil" + + "github.com/stretchr/testify/require" +) + +func MakeTestTLSConfig(t *testing.T, path string) *tls.Config { + t.Helper() + ca, pub, priv := filepath.Join(path, "rootca.crt"), filepath.Join(path, "crt.pem"), filepath.Join(path, "key.pem") + + insecure := false + tlsConf, err := tlsutil.NewTLSConfig( + tlsutil.WithInsecureSkipVerify(insecure), + tlsutil.WithCertKeyPairFiles(pub, priv), + tlsutil.WithCAFile(ca), + ) + //require.Nil(t, err, err.Error()) + require.Nil(t, err) + + tlsConf.ServerName = "helm.sh" + + return tlsConf +} diff --git a/pkg/helm/pkg/repo/testdata/chartmuseum-index.yaml b/pkg/helm/pkg/repo/v1/testdata/chartmuseum-index.yaml similarity index 100% rename from pkg/helm/pkg/repo/testdata/chartmuseum-index.yaml rename to pkg/helm/pkg/repo/v1/testdata/chartmuseum-index.yaml diff --git a/pkg/helm/pkg/repo/testdata/local-index-annotations.yaml b/pkg/helm/pkg/repo/v1/testdata/local-index-annotations.yaml similarity index 100% rename from pkg/helm/pkg/repo/testdata/local-index-annotations.yaml rename to pkg/helm/pkg/repo/v1/testdata/local-index-annotations.yaml diff --git a/pkg/helm/pkg/repo/testdata/local-index-unordered.yaml b/pkg/helm/pkg/repo/v1/testdata/local-index-unordered.yaml similarity index 100% rename from pkg/helm/pkg/repo/testdata/local-index-unordered.yaml rename to pkg/helm/pkg/repo/v1/testdata/local-index-unordered.yaml diff --git a/pkg/helm/pkg/repo/testdata/local-index.json b/pkg/helm/pkg/repo/v1/testdata/local-index.json similarity index 100% rename from pkg/helm/pkg/repo/testdata/local-index.json rename to pkg/helm/pkg/repo/v1/testdata/local-index.json diff --git a/pkg/helm/pkg/repo/testdata/local-index.yaml b/pkg/helm/pkg/repo/v1/testdata/local-index.yaml similarity index 100% rename from pkg/helm/pkg/repo/testdata/local-index.yaml rename to pkg/helm/pkg/repo/v1/testdata/local-index.yaml diff --git a/pkg/helm/pkg/repo/testdata/old-repositories.yaml b/pkg/helm/pkg/repo/v1/testdata/old-repositories.yaml similarity index 100% rename from pkg/helm/pkg/repo/testdata/old-repositories.yaml rename to pkg/helm/pkg/repo/v1/testdata/old-repositories.yaml diff --git a/pkg/helm/pkg/repo/testdata/repositories.yaml b/pkg/helm/pkg/repo/v1/testdata/repositories.yaml similarity index 100% rename from pkg/helm/pkg/repo/testdata/repositories.yaml rename to pkg/helm/pkg/repo/v1/testdata/repositories.yaml diff --git a/pkg/helm/pkg/repo/v1/testdata/repository/frobnitz-1.2.3.tgz b/pkg/helm/pkg/repo/v1/testdata/repository/frobnitz-1.2.3.tgz new file mode 100644 index 0000000000000000000000000000000000000000..8731dce02cc9603e7813a07670a7d057129a8b20 GIT binary patch literal 3485 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 LkUR>*0C)fZJ*e(i literal 0 HcmV?d00001 diff --git a/pkg/helm/pkg/repo/testdata/repository/sprocket-1.1.0.tgz b/pkg/helm/pkg/repo/v1/testdata/repository/sprocket-1.1.0.tgz similarity index 100% rename from pkg/helm/pkg/repo/testdata/repository/sprocket-1.1.0.tgz rename to pkg/helm/pkg/repo/v1/testdata/repository/sprocket-1.1.0.tgz diff --git a/pkg/helm/pkg/repo/testdata/repository/sprocket-1.2.0.tgz b/pkg/helm/pkg/repo/v1/testdata/repository/sprocket-1.2.0.tgz similarity index 100% rename from pkg/helm/pkg/repo/testdata/repository/sprocket-1.2.0.tgz rename to pkg/helm/pkg/repo/v1/testdata/repository/sprocket-1.2.0.tgz diff --git a/pkg/helm/pkg/repo/testdata/repository/universe/zarthal-1.0.0.tgz b/pkg/helm/pkg/repo/v1/testdata/repository/universe/zarthal-1.0.0.tgz similarity index 100% rename from pkg/helm/pkg/repo/testdata/repository/universe/zarthal-1.0.0.tgz rename to pkg/helm/pkg/repo/v1/testdata/repository/universe/zarthal-1.0.0.tgz diff --git a/pkg/helm/pkg/repo/testdata/server/index.yaml b/pkg/helm/pkg/repo/v1/testdata/server/index.yaml similarity index 100% rename from pkg/helm/pkg/repo/testdata/server/index.yaml rename to pkg/helm/pkg/repo/v1/testdata/server/index.yaml diff --git a/pkg/helm/pkg/repo/testdata/server/test.txt b/pkg/helm/pkg/repo/v1/testdata/server/test.txt similarity index 100% rename from pkg/helm/pkg/repo/testdata/server/test.txt rename to pkg/helm/pkg/repo/v1/testdata/server/test.txt diff --git a/pkg/helm/pkg/storage/driver/cfgmaps.go b/pkg/helm/pkg/storage/driver/cfgmaps.go index 96629baf..e94eda64 100644 --- a/pkg/helm/pkg/storage/driver/cfgmaps.go +++ b/pkg/helm/pkg/storage/driver/cfgmaps.go @@ -14,15 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -package driver // import "helm.sh/helm/v3/pkg/storage/driver" +package driver // import "github.com/werf/nelm/pkg/helm/pkg/storage/driver" import ( "context" + "fmt" + "log/slog" "strconv" "strings" "time" - "github.com/pkg/errors" v1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -30,7 +31,9 @@ import ( "k8s.io/apimachinery/pkg/util/validation" corev1 "k8s.io/client-go/kubernetes/typed/core/v1" - rspb "github.com/werf/nelm/pkg/helm/pkg/release" + "github.com/werf/nelm/pkg/helm/intern/logging" + "github.com/werf/nelm/pkg/helm/pkg/release" + rspb "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) var _ Driver = (*ConfigMaps)(nil) @@ -42,16 +45,19 @@ const ConfigMapsDriverName = "ConfigMap" // ConfigMapsInterface. type ConfigMaps struct { impl corev1.ConfigMapInterface - Log func(string, ...interface{}) + + // Embed a LogHolder to provide logger functionality + logging.LogHolder } // NewConfigMaps initializes a new ConfigMaps wrapping an implementation of // the kubernetes ConfigMapsInterface. func NewConfigMaps(impl corev1.ConfigMapInterface) *ConfigMaps { - return &ConfigMaps{ + c := &ConfigMaps{ impl: impl, - Log: func(_ string, _ ...interface{}) {}, } + c.SetLogger(slog.Default().Handler()) + return c } // Name returns the name of the driver. @@ -61,7 +67,7 @@ func (cfgmaps *ConfigMaps) Name() string { // Get fetches the release named by key. The corresponding release is returned // or error if not found. -func (cfgmaps *ConfigMaps) Get(key string) (*rspb.Release, error) { +func (cfgmaps *ConfigMaps) Get(key string) (release.Releaser, error) { // fetch the configmap holding the release named by key obj, err := cfgmaps.impl.Get(context.Background(), key, metav1.GetOptions{}) if err != nil { @@ -69,16 +75,16 @@ func (cfgmaps *ConfigMaps) Get(key string) (*rspb.Release, error) { return nil, ErrReleaseNotFound } - cfgmaps.Log("get: failed to get %q: %s", key, err) + cfgmaps.Logger().Debug("failed to get release", slog.String("key", key), slog.Any("error", err)) return nil, err } // found the configmap, decode the base64 data string r, err := decodeRelease(obj.Data["release"]) if err != nil { - cfgmaps.Log("get: failed to decode data %q: %s", key, err) + cfgmaps.Logger().Debug("failed to decode data", slog.String("key", key), slog.Any("error", err)) return nil, err } - r.Labels = filterSystemLabels(obj.ObjectMeta.Labels) + r.Labels = filterSystemLabels(obj.Labels) // return the release object return r, nil } @@ -86,28 +92,28 @@ func (cfgmaps *ConfigMaps) Get(key string) (*rspb.Release, error) { // List fetches all releases and returns the list releases such // that filter(release) == true. An error is returned if the // configmap fails to retrieve the releases. -func (cfgmaps *ConfigMaps) List(filter func(*rspb.Release) bool) ([]*rspb.Release, error) { +func (cfgmaps *ConfigMaps) List(filter func(release.Releaser) bool) ([]release.Releaser, error) { lsel := kblabels.Set{"owner": "helm"}.AsSelector() opts := metav1.ListOptions{LabelSelector: lsel.String()} list, err := cfgmaps.impl.List(context.Background(), opts) if err != nil { - cfgmaps.Log("list: failed to list: %s", err) + cfgmaps.Logger().Debug("failed to list releases", slog.Any("error", err)) return nil, err } - var results []*rspb.Release + var results []release.Releaser // iterate over the configmaps object list // and decode each release for _, item := range list.Items { rls, err := decodeRelease(item.Data["release"]) if err != nil { - cfgmaps.Log("list: failed to decode release: %v: %s", item, err) + cfgmaps.Logger().Debug("failed to decode release", slog.Any("item", item), slog.Any("error", err)) continue } - rls.Labels = item.ObjectMeta.Labels + rls.Labels = item.Labels if filter(rls) { results = append(results, rls) @@ -118,11 +124,11 @@ func (cfgmaps *ConfigMaps) List(filter func(*rspb.Release) bool) ([]*rspb.Releas // Query fetches all releases that match the provided map of labels. // An error is returned if the configmap fails to retrieve the releases. -func (cfgmaps *ConfigMaps) Query(labels map[string]string) ([]*rspb.Release, error) { +func (cfgmaps *ConfigMaps) Query(labels map[string]string) ([]release.Releaser, error) { ls := kblabels.Set{} for k, v := range labels { if errs := validation.IsValidLabelValue(v); len(errs) != 0 { - return nil, errors.Errorf("invalid label value: %q: %s", v, strings.Join(errs, "; ")) + return nil, fmt.Errorf("invalid label value: %q: %s", v, strings.Join(errs, "; ")) } ls[k] = v } @@ -131,7 +137,7 @@ func (cfgmaps *ConfigMaps) Query(labels map[string]string) ([]*rspb.Release, err list, err := cfgmaps.impl.List(context.Background(), opts) if err != nil { - cfgmaps.Log("query: failed to query with labels: %s", err) + cfgmaps.Logger().Debug("failed to query with labels", slog.Any("error", err)) return nil, err } @@ -139,14 +145,14 @@ func (cfgmaps *ConfigMaps) Query(labels map[string]string) ([]*rspb.Release, err return nil, ErrReleaseNotFound } - var results []*rspb.Release + var results []release.Releaser for _, item := range list.Items { rls, err := decodeRelease(item.Data["release"]) if err != nil { - cfgmaps.Log("query: failed to decode release: %s", err) + cfgmaps.Logger().Debug("failed to decode release", slog.Any("error", err)) continue } - rls.Labels = item.ObjectMeta.Labels + rls.Labels = item.Labels results = append(results, rls) } return results, nil @@ -154,18 +160,28 @@ func (cfgmaps *ConfigMaps) Query(labels map[string]string) ([]*rspb.Release, err // Create creates a new ConfigMap holding the release. If the // ConfigMap already exists, ErrReleaseExists is returned. -func (cfgmaps *ConfigMaps) Create(key string, rls *rspb.Release) error { +func (cfgmaps *ConfigMaps) Create(key string, rls release.Releaser) error { // set labels for configmaps object meta data var lbs labels + rac, err := release.NewAccessor(rls) + if err != nil { + return err + } + lbs.init() - lbs.fromMap(rls.Labels) - lbs.set("createdAt", strconv.Itoa(int(time.Now().Unix()))) + lbs.fromMap(rac.Labels()) + lbs.set("createdAt", fmt.Sprintf("%v", time.Now().Unix())) + + rel, err := releaserToV1Release(rls) + if err != nil { + return err + } // create a new configmap to hold the release - obj, err := newConfigMapsObject(key, rls, lbs) + obj, err := newConfigMapsObject(key, rel, lbs) if err != nil { - cfgmaps.Log("create: failed to encode release %q: %s", rls.Name, err) + cfgmaps.Logger().Debug("failed to encode release", slog.String("name", rac.Name()), slog.Any("error", err)) return err } // push the configmap object out into the kubiverse @@ -174,7 +190,7 @@ func (cfgmaps *ConfigMaps) Create(key string, rls *rspb.Release) error { return ErrReleaseExists } - cfgmaps.Log("create: failed to create: %s", err) + cfgmaps.Logger().Debug("failed to create release", slog.Any("error", err)) return err } return nil @@ -182,31 +198,40 @@ func (cfgmaps *ConfigMaps) Create(key string, rls *rspb.Release) error { // Update updates the ConfigMap holding the release. If not found // the ConfigMap is created to hold the release. -func (cfgmaps *ConfigMaps) Update(key string, rls *rspb.Release) error { +func (cfgmaps *ConfigMaps) Update(key string, rel release.Releaser) error { // set labels for configmaps object meta data var lbs labels + rls, err := releaserToV1Release(rel) + if err != nil { + return err + } + lbs.init() lbs.fromMap(rls.Labels) - lbs.set("modifiedAt", strconv.Itoa(int(time.Now().Unix()))) + lbs.set("modifiedAt", fmt.Sprintf("%v", time.Now().Unix())) // create a new configmap object to hold the release obj, err := newConfigMapsObject(key, rls, lbs) if err != nil { - cfgmaps.Log("update: failed to encode release %q: %s", rls.Name, err) + cfgmaps.Logger().Debug( + "failed to encode release", + slog.String("name", rls.Name), + slog.Any("error", err), + ) return err } // push the configmap object out into the kubiverse _, err = cfgmaps.impl.Update(context.Background(), obj, metav1.UpdateOptions{}) if err != nil { - cfgmaps.Log("update: failed to update: %s", err) + cfgmaps.Logger().Debug("failed to update release", slog.Any("error", err)) return err } return nil } // Delete deletes the ConfigMap holding the release named by key. -func (cfgmaps *ConfigMaps) Delete(key string) (rls *rspb.Release, err error) { +func (cfgmaps *ConfigMaps) Delete(key string) (rls release.Releaser, err error) { // fetch the release to check existence if rls, err = cfgmaps.Get(key); err != nil { return nil, err diff --git a/pkg/helm/pkg/storage/driver/cfgmaps_test.go b/pkg/helm/pkg/storage/driver/cfgmaps_test.go index 5eae389d..4040d2d6 100644 --- a/pkg/helm/pkg/storage/driver/cfgmaps_test.go +++ b/pkg/helm/pkg/storage/driver/cfgmaps_test.go @@ -16,12 +16,15 @@ package driver import ( "encoding/base64" "encoding/json" + "errors" "reflect" "testing" v1 "k8s.io/api/core/v1" - rspb "github.com/werf/nelm/pkg/helm/pkg/release" + "github.com/werf/nelm/pkg/helm/pkg/release" + "github.com/werf/nelm/pkg/helm/pkg/release/common" + rspb "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) func TestConfigMapName(t *testing.T) { @@ -36,7 +39,7 @@ func TestConfigMapGet(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) cfgmaps := newTestFixtureCfgMaps(t, []*rspb.Release{rel}...) @@ -56,7 +59,7 @@ func TestUncompressedConfigMapGet(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) // Create a test fixture which contains an uncompressed release cfgmap, err := newConfigMapsObject(key, rel, nil) @@ -83,19 +86,35 @@ func TestUncompressedConfigMapGet(t *testing.T) { } } +func convertReleaserToV1(t *testing.T, rel release.Releaser) *rspb.Release { + t.Helper() + switch r := rel.(type) { + case rspb.Release: + return &r + case *rspb.Release: + return r + case nil: + return nil + } + + t.Fatalf("Unsupported release type: %T", rel) + return nil +} + func TestConfigMapList(t *testing.T) { cfgmaps := newTestFixtureCfgMaps(t, []*rspb.Release{ - releaseStub("key-1", 1, "default", rspb.StatusUninstalled), - releaseStub("key-2", 1, "default", rspb.StatusUninstalled), - releaseStub("key-3", 1, "default", rspb.StatusDeployed), - releaseStub("key-4", 1, "default", rspb.StatusDeployed), - releaseStub("key-5", 1, "default", rspb.StatusSuperseded), - releaseStub("key-6", 1, "default", rspb.StatusSuperseded), + releaseStub("key-1", 1, "default", common.StatusUninstalled), + releaseStub("key-2", 1, "default", common.StatusUninstalled), + releaseStub("key-3", 1, "default", common.StatusDeployed), + releaseStub("key-4", 1, "default", common.StatusDeployed), + releaseStub("key-5", 1, "default", common.StatusSuperseded), + releaseStub("key-6", 1, "default", common.StatusSuperseded), }...) // list all deleted releases - del, err := cfgmaps.List(func(rel *rspb.Release) bool { - return rel.Info.Status == rspb.StatusUninstalled + del, err := cfgmaps.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusUninstalled }) // check if err != nil { @@ -106,8 +125,9 @@ func TestConfigMapList(t *testing.T) { } // list all deployed releases - dpl, err := cfgmaps.List(func(rel *rspb.Release) bool { - return rel.Info.Status == rspb.StatusDeployed + dpl, err := cfgmaps.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusDeployed }) // check if err != nil { @@ -118,8 +138,9 @@ func TestConfigMapList(t *testing.T) { } // list all superseded releases - ssd, err := cfgmaps.List(func(rel *rspb.Release) bool { - return rel.Info.Status == rspb.StatusSuperseded + ssd, err := cfgmaps.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusSuperseded }) // check if err != nil { @@ -129,7 +150,7 @@ func TestConfigMapList(t *testing.T) { t.Errorf("Expected 2 superseded, got %d", len(ssd)) } // Check if release having both system and custom labels, this is needed to ensure that selector filtering would work. - rls := ssd[0] + rls := convertReleaserToV1(t, ssd[0]) _, ok := rls.Labels["name"] if !ok { t.Fatalf("Expected 'name' label in results, actual %v", rls.Labels) @@ -142,12 +163,12 @@ func TestConfigMapList(t *testing.T) { func TestConfigMapQuery(t *testing.T) { cfgmaps := newTestFixtureCfgMaps(t, []*rspb.Release{ - releaseStub("key-1", 1, "default", rspb.StatusUninstalled), - releaseStub("key-2", 1, "default", rspb.StatusUninstalled), - releaseStub("key-3", 1, "default", rspb.StatusDeployed), - releaseStub("key-4", 1, "default", rspb.StatusDeployed), - releaseStub("key-5", 1, "default", rspb.StatusSuperseded), - releaseStub("key-6", 1, "default", rspb.StatusSuperseded), + releaseStub("key-1", 1, "default", common.StatusUninstalled), + releaseStub("key-2", 1, "default", common.StatusUninstalled), + releaseStub("key-3", 1, "default", common.StatusDeployed), + releaseStub("key-4", 1, "default", common.StatusDeployed), + releaseStub("key-5", 1, "default", common.StatusSuperseded), + releaseStub("key-6", 1, "default", common.StatusSuperseded), }...) rls, err := cfgmaps.Query(map[string]string{"status": "deployed"}) @@ -171,7 +192,7 @@ func TestConfigMapCreate(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) // store the release in a configmap if err := cfgmaps.Create(key, rel); err != nil { @@ -195,12 +216,12 @@ func TestConfigMapUpdate(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) cfgmaps := newTestFixtureCfgMaps(t, []*rspb.Release{rel}...) // modify release status code - rel.Info.Status = rspb.StatusSuperseded + rel.Info.Status = common.StatusSuperseded // perform the update if err := cfgmaps.Update(key, rel); err != nil { @@ -208,10 +229,11 @@ func TestConfigMapUpdate(t *testing.T) { } // fetch the updated release - got, err := cfgmaps.Get(key) + goti, err := cfgmaps.Get(key) if err != nil { t.Fatalf("Failed to get release with key %q: %s", key, err) } + got := convertReleaserToV1(t, goti) // check release has actually been updated by comparing modified fields if rel.Info.Status != got.Info.Status { @@ -224,7 +246,7 @@ func TestConfigMapDelete(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) cfgmaps := newTestFixtureCfgMaps(t, []*rspb.Release{rel}...) @@ -242,10 +264,8 @@ func TestConfigMapDelete(t *testing.T) { if !reflect.DeepEqual(rel, rls) { t.Errorf("Expected {%v}, got {%v}", rel, rls) } - - // fetch the deleted release _, err = cfgmaps.Get(key) - if !reflect.DeepEqual(ErrReleaseNotFound, err) { + if !errors.Is(err, ErrReleaseNotFound) { t.Errorf("Expected {%v}, got {%v}", ErrReleaseNotFound, err) } } diff --git a/pkg/helm/pkg/storage/driver/driver.go b/pkg/helm/pkg/storage/driver/driver.go index ebf3415a..0dc8a0ac 100644 --- a/pkg/helm/pkg/storage/driver/driver.go +++ b/pkg/helm/pkg/storage/driver/driver.go @@ -14,14 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -package driver // import "helm.sh/helm/v3/pkg/storage/driver" +package driver // import "github.com/werf/nelm/pkg/helm/pkg/storage/driver" import ( + "errors" "fmt" - "github.com/pkg/errors" - - rspb "github.com/werf/nelm/pkg/helm/pkg/release" + "github.com/werf/nelm/pkg/helm/pkg/release" + rspb "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) var ( @@ -59,7 +59,7 @@ func NewErrNoDeployedReleases(releaseName string) error { // Create stores the release or returns ErrReleaseExists // if an identical release already exists. type Creator interface { - Create(key string, rls *rspb.Release) error + Create(key string, rls release.Releaser) error } // Updator is the interface that wraps the Update method. @@ -67,7 +67,7 @@ type Creator interface { // Update updates an existing release or returns // ErrReleaseNotFound if the release does not exist. type Updator interface { - Update(key string, rls *rspb.Release) error + Update(key string, rls release.Releaser) error } // Deletor is the interface that wraps the Delete method. @@ -75,7 +75,7 @@ type Updator interface { // Delete deletes the release named by key or returns // ErrReleaseNotFound if the release does not exist. type Deletor interface { - Delete(key string) (*rspb.Release, error) + Delete(key string) (release.Releaser, error) } // Queryor is the interface that wraps the Get and List methods. @@ -87,9 +87,9 @@ type Deletor interface { // // Query returns the set of all releases that match the provided label set. type Queryor interface { - Get(key string) (*rspb.Release, error) - List(filter func(*rspb.Release) bool) ([]*rspb.Release, error) - Query(labels map[string]string) ([]*rspb.Release, error) + Get(key string) (release.Releaser, error) + List(filter func(release.Releaser) bool) ([]release.Releaser, error) + Query(labels map[string]string) ([]release.Releaser, error) } // Driver is the interface composed of Creator, Updator, Deletor, and Queryor @@ -103,3 +103,18 @@ type Driver interface { Queryor Name() string } + +// releaserToV1Release is a helper function to convert a v1 release passed by interface +// into the type object. +func releaserToV1Release(rel release.Releaser) (*rspb.Release, error) { + switch r := rel.(type) { + case rspb.Release: + return &r, nil + case *rspb.Release: + return r, nil + case nil: + return nil, nil + default: + return nil, fmt.Errorf("unsupported release type: %T", rel) + } +} diff --git a/pkg/helm/pkg/storage/driver/labels_test.go b/pkg/helm/pkg/storage/driver/labels_test.go index bfd80911..dff6e905 100644 --- a/pkg/helm/pkg/storage/driver/labels_test.go +++ b/pkg/helm/pkg/storage/driver/labels_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package driver // import "helm.sh/helm/v3/pkg/storage/driver" +package driver // import "github.com/werf/nelm/pkg/helm/pkg/storage/driver" import ( "testing" diff --git a/pkg/helm/pkg/storage/driver/memory.go b/pkg/helm/pkg/storage/driver/memory.go index 3340ff61..586771e1 100644 --- a/pkg/helm/pkg/storage/driver/memory.go +++ b/pkg/helm/pkg/storage/driver/memory.go @@ -17,11 +17,13 @@ limitations under the License. package driver import ( + "log/slog" "strconv" "strings" "sync" - rspb "github.com/werf/nelm/pkg/helm/pkg/release" + "github.com/werf/nelm/pkg/helm/intern/logging" + "github.com/werf/nelm/pkg/helm/pkg/release" ) var _ Driver = (*Memory)(nil) @@ -42,11 +44,15 @@ type Memory struct { namespace string // A map of namespaces to releases cache map[string]memReleases + // Embed a LogHolder to provide logger functionality + logging.LogHolder } // NewMemory initializes a new memory driver. func NewMemory() *Memory { - return &Memory{cache: map[string]memReleases{}, namespace: "default"} + m := &Memory{cache: map[string]memReleases{}, namespace: "default"} + m.SetLogger(slog.Default().Handler()) + return m } // SetNamespace sets a specific namespace in which releases will be accessed. @@ -61,7 +67,7 @@ func (mem *Memory) Name() string { } // Get returns the release named by key or returns ErrReleaseNotFound. -func (mem *Memory) Get(key string) (*rspb.Release, error) { +func (mem *Memory) Get(key string) (release.Releaser, error) { defer unlock(mem.rlock()) keyWithoutPrefix := strings.TrimPrefix(key, "sh.helm.release.v1.") @@ -83,10 +89,10 @@ func (mem *Memory) Get(key string) (*rspb.Release, error) { } // List returns the list of all releases such that filter(release) == true -func (mem *Memory) List(filter func(*rspb.Release) bool) ([]*rspb.Release, error) { +func (mem *Memory) List(filter func(release.Releaser) bool) ([]release.Releaser, error) { defer unlock(mem.rlock()) - var ls []*rspb.Release + var ls []release.Releaser for namespace := range mem.cache { if mem.namespace != "" { // Should only list releases of this namespace @@ -109,7 +115,7 @@ func (mem *Memory) List(filter func(*rspb.Release) bool) ([]*rspb.Release, error } // Query returns the set of releases that match the provided set of labels -func (mem *Memory) Query(keyvals map[string]string) ([]*rspb.Release, error) { +func (mem *Memory) Query(keyvals map[string]string) ([]release.Releaser, error) { defer unlock(mem.rlock()) var lbs labels @@ -117,7 +123,7 @@ func (mem *Memory) Query(keyvals map[string]string) ([]*rspb.Release, error) { lbs.init() lbs.fromMap(keyvals) - var ls []*rspb.Release + var ls []release.Releaser for namespace := range mem.cache { if mem.namespace != "" { // Should only query releases of this namespace @@ -150,9 +156,13 @@ func (mem *Memory) Query(keyvals map[string]string) ([]*rspb.Release, error) { } // Create creates a new release or returns ErrReleaseExists. -func (mem *Memory) Create(key string, rls *rspb.Release) error { +func (mem *Memory) Create(key string, rel release.Releaser) error { defer unlock(mem.wlock()) + rls, err := releaserToV1Release(rel) + if err != nil { + return err + } // For backwards compatibility, we protect against an unset namespace namespace := rls.Namespace if namespace == "" { @@ -176,9 +186,14 @@ func (mem *Memory) Create(key string, rls *rspb.Release) error { } // Update updates a release or returns ErrReleaseNotFound. -func (mem *Memory) Update(key string, rls *rspb.Release) error { +func (mem *Memory) Update(key string, rel release.Releaser) error { defer unlock(mem.wlock()) + rls, err := releaserToV1Release(rel) + if err != nil { + return err + } + // For backwards compatibility, we protect against an unset namespace namespace := rls.Namespace if namespace == "" { @@ -196,7 +211,7 @@ func (mem *Memory) Update(key string, rls *rspb.Release) error { } // Delete deletes a release or returns ErrReleaseNotFound. -func (mem *Memory) Delete(key string) (*rspb.Release, error) { +func (mem *Memory) Delete(key string) (release.Releaser, error) { defer unlock(mem.wlock()) keyWithoutPrefix := strings.TrimPrefix(key, "sh.helm.release.v1.") diff --git a/pkg/helm/pkg/storage/driver/memory_test.go b/pkg/helm/pkg/storage/driver/memory_test.go index 6728b2c7..268e15a8 100644 --- a/pkg/helm/pkg/storage/driver/memory_test.go +++ b/pkg/helm/pkg/storage/driver/memory_test.go @@ -21,7 +21,11 @@ import ( "reflect" "testing" - rspb "github.com/werf/nelm/pkg/helm/pkg/release" + "github.com/stretchr/testify/assert" + + "github.com/werf/nelm/pkg/helm/pkg/release" + "github.com/werf/nelm/pkg/helm/pkg/release/common" + rspb "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) func TestMemoryName(t *testing.T) { @@ -38,22 +42,22 @@ func TestMemoryCreate(t *testing.T) { }{ { "create should succeed", - releaseStub("rls-c", 1, "default", rspb.StatusDeployed), + releaseStub("rls-c", 1, "default", common.StatusDeployed), false, }, { "create should fail (release already exists)", - releaseStub("rls-a", 1, "default", rspb.StatusDeployed), + releaseStub("rls-a", 1, "default", common.StatusDeployed), true, }, { "create in namespace should succeed", - releaseStub("rls-a", 1, "mynamespace", rspb.StatusDeployed), + releaseStub("rls-a", 1, "mynamespace", common.StatusDeployed), false, }, { "create in other namespace should fail (release already exists)", - releaseStub("rls-c", 1, "mynamespace", rspb.StatusDeployed), + releaseStub("rls-c", 1, "mynamespace", common.StatusDeployed), true, }, } @@ -104,8 +108,9 @@ func TestMemoryList(t *testing.T) { ts.SetNamespace("default") // list all deployed releases - dpl, err := ts.List(func(rel *rspb.Release) bool { - return rel.Info.Status == rspb.StatusDeployed + dpl, err := ts.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusDeployed }) // check if err != nil { @@ -116,8 +121,9 @@ func TestMemoryList(t *testing.T) { } // list all superseded releases - ssd, err := ts.List(func(rel *rspb.Release) bool { - return rel.Info.Status == rspb.StatusSuperseded + ssd, err := ts.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusSuperseded }) // check if err != nil { @@ -128,8 +134,9 @@ func TestMemoryList(t *testing.T) { } // list all deleted releases - del, err := ts.List(func(rel *rspb.Release) bool { - return rel.Info.Status == rspb.StatusUninstalled + del, err := ts.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusUninstalled }) // check if err != nil { @@ -185,25 +192,25 @@ func TestMemoryUpdate(t *testing.T) { { "update release status", "rls-a.v4", - releaseStub("rls-a", 4, "default", rspb.StatusSuperseded), + releaseStub("rls-a", 4, "default", common.StatusSuperseded), false, }, { "update release does not exist", "rls-c.v1", - releaseStub("rls-c", 1, "default", rspb.StatusUninstalled), + releaseStub("rls-c", 1, "default", common.StatusUninstalled), true, }, { "update release status in namespace", "rls-c.v4", - releaseStub("rls-c", 4, "mynamespace", rspb.StatusSuperseded), + releaseStub("rls-c", 4, "mynamespace", common.StatusSuperseded), false, }, { "update release in namespace does not exist", "rls-a.v1", - releaseStub("rls-a", 1, "mynamespace", rspb.StatusUninstalled), + releaseStub("rls-a", 1, "mynamespace", common.StatusUninstalled), true, }, } @@ -255,17 +262,23 @@ func TestMemoryDelete(t *testing.T) { startLen := len(start) for _, tt := range tests { ts.SetNamespace(tt.namespace) - if rel, err := ts.Delete(tt.key); err != nil { + + rel, err := ts.Delete(tt.key) + var rls *rspb.Release + if err == nil { + rls = convertReleaserToV1(t, rel) + } + if err != nil { if !tt.err { t.Fatalf("Failed %q to get '%s': %q\n", tt.desc, tt.key, err) } continue } else if tt.err { t.Fatalf("Did not get expected error for %q '%s'\n", tt.desc, tt.key) - } else if fmt.Sprintf("%s.v%d", rel.Name, rel.Version) != tt.key { - t.Fatalf("Asked for delete on %s, but deleted %d", tt.key, rel.Version) + } else if fmt.Sprintf("%s.v%d", rls.Name, rls.Version) != tt.key { + t.Fatalf("Asked for delete on %s, but deleted %d", tt.key, rls.Version) } - _, err := ts.Get(tt.key) + _, err = ts.Get(tt.key) if err == nil { t.Errorf("Expected an error when asking for a deleted key") } @@ -282,7 +295,9 @@ func TestMemoryDelete(t *testing.T) { if startLen-2 != endLen { t.Errorf("expected end to be %d instead of %d", startLen-2, endLen) for _, ee := range end { - t.Logf("Name: %s, Version: %d", ee.Name, ee.Version) + rac, err := release.NewAccessor(ee) + assert.NoError(t, err, "unable to get release accessor") + t.Logf("Name: %s, Version: %d", rac.Name(), rac.Version()) } } diff --git a/pkg/helm/pkg/storage/driver/mock_test.go b/pkg/helm/pkg/storage/driver/mock_test.go index 0061f021..e6290256 100644 --- a/pkg/helm/pkg/storage/driver/mock_test.go +++ b/pkg/helm/pkg/storage/driver/mock_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package driver // import "helm.sh/helm/v3/pkg/storage/driver" +package driver // import "github.com/werf/nelm/pkg/helm/pkg/storage/driver" import ( "context" @@ -31,10 +31,11 @@ import ( kblabels "k8s.io/apimachinery/pkg/labels" corev1 "k8s.io/client-go/kubernetes/typed/core/v1" - rspb "github.com/werf/nelm/pkg/helm/pkg/release" + "github.com/werf/nelm/pkg/helm/pkg/release/common" + rspb "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) -func releaseStub(name string, vers int, namespace string, status rspb.Status) *rspb.Release { +func releaseStub(name string, vers int, namespace string, status common.Status) *rspb.Release { return &rspb.Release{ Name: name, Version: vers, @@ -52,22 +53,23 @@ func testKey(name string, vers int) string { } func tsFixtureMemory(t *testing.T) *Memory { + t.Helper() hs := []*rspb.Release{ // rls-a - releaseStub("rls-a", 4, "default", rspb.StatusDeployed), - releaseStub("rls-a", 1, "default", rspb.StatusSuperseded), - releaseStub("rls-a", 3, "default", rspb.StatusSuperseded), - releaseStub("rls-a", 2, "default", rspb.StatusSuperseded), + releaseStub("rls-a", 4, "default", common.StatusDeployed), + releaseStub("rls-a", 1, "default", common.StatusSuperseded), + releaseStub("rls-a", 3, "default", common.StatusSuperseded), + releaseStub("rls-a", 2, "default", common.StatusSuperseded), // rls-b - releaseStub("rls-b", 4, "default", rspb.StatusDeployed), - releaseStub("rls-b", 1, "default", rspb.StatusSuperseded), - releaseStub("rls-b", 3, "default", rspb.StatusSuperseded), - releaseStub("rls-b", 2, "default", rspb.StatusSuperseded), + releaseStub("rls-b", 4, "default", common.StatusDeployed), + releaseStub("rls-b", 1, "default", common.StatusSuperseded), + releaseStub("rls-b", 3, "default", common.StatusSuperseded), + releaseStub("rls-b", 2, "default", common.StatusSuperseded), // rls-c in other namespace - releaseStub("rls-c", 4, "mynamespace", rspb.StatusDeployed), - releaseStub("rls-c", 1, "mynamespace", rspb.StatusSuperseded), - releaseStub("rls-c", 3, "mynamespace", rspb.StatusSuperseded), - releaseStub("rls-c", 2, "mynamespace", rspb.StatusSuperseded), + releaseStub("rls-c", 4, "mynamespace", common.StatusDeployed), + releaseStub("rls-c", 1, "mynamespace", common.StatusSuperseded), + releaseStub("rls-c", 3, "mynamespace", common.StatusSuperseded), + releaseStub("rls-c", 2, "mynamespace", common.StatusSuperseded), } mem := NewMemory() @@ -80,9 +82,10 @@ func tsFixtureMemory(t *testing.T) *Memory { return mem } -// newTestFixture initializes a MockConfigMapsInterface. +// newTestFixtureCfgMaps initializes a MockConfigMapsInterface. // ConfigMaps are created for each release provided. func newTestFixtureCfgMaps(t *testing.T, releases ...*rspb.Release) *ConfigMaps { + t.Helper() var mock MockConfigMapsInterface mock.Init(t, releases...) @@ -98,6 +101,7 @@ type MockConfigMapsInterface struct { // Init initializes the MockConfigMapsInterface with the set of releases. func (mock *MockConfigMapsInterface) Init(t *testing.T, releases ...*rspb.Release) { + t.Helper() mock.objects = map[string]*v1.ConfigMap{} for _, rls := range releases { @@ -120,7 +124,7 @@ func (mock *MockConfigMapsInterface) Get(_ context.Context, name string, _ metav return object, nil } -// List returns the a of ConfigMaps. +// List returns all ConfigMaps. func (mock *MockConfigMapsInterface) List(_ context.Context, opts metav1.ListOptions) (*v1.ConfigMapList, error) { var list v1.ConfigMapList @@ -130,7 +134,7 @@ func (mock *MockConfigMapsInterface) List(_ context.Context, opts metav1.ListOpt } for _, cfgmap := range mock.objects { - if labelSelector.Matches(kblabels.Set(cfgmap.ObjectMeta.Labels)) { + if labelSelector.Matches(kblabels.Set(cfgmap.Labels)) { list.Items = append(list.Items, *cfgmap) } } @@ -139,7 +143,7 @@ func (mock *MockConfigMapsInterface) List(_ context.Context, opts metav1.ListOpt // Create creates a new ConfigMap. func (mock *MockConfigMapsInterface) Create(_ context.Context, cfgmap *v1.ConfigMap, _ metav1.CreateOptions) (*v1.ConfigMap, error) { - name := cfgmap.ObjectMeta.Name + name := cfgmap.Name if object, ok := mock.objects[name]; ok { return object, apierrors.NewAlreadyExists(v1.Resource("tests"), name) } @@ -149,7 +153,7 @@ func (mock *MockConfigMapsInterface) Create(_ context.Context, cfgmap *v1.Config // Update updates a ConfigMap. func (mock *MockConfigMapsInterface) Update(_ context.Context, cfgmap *v1.ConfigMap, _ metav1.UpdateOptions) (*v1.ConfigMap, error) { - name := cfgmap.ObjectMeta.Name + name := cfgmap.Name if _, ok := mock.objects[name]; !ok { return nil, apierrors.NewNotFound(v1.Resource("tests"), name) } @@ -166,9 +170,10 @@ func (mock *MockConfigMapsInterface) Delete(_ context.Context, name string, _ me return nil } -// newTestFixture initializes a MockSecretsInterface. +// newTestFixtureSecrets initializes a MockSecretsInterface. // Secrets are created for each release provided. func newTestFixtureSecrets(t *testing.T, releases ...*rspb.Release) *Secrets { + t.Helper() var mock MockSecretsInterface mock.Init(t, releases...) @@ -184,6 +189,7 @@ type MockSecretsInterface struct { // Init initializes the MockSecretsInterface with the set of releases. func (mock *MockSecretsInterface) Init(t *testing.T, releases ...*rspb.Release) { + t.Helper() mock.objects = map[string]*v1.Secret{} for _, rls := range releases { @@ -206,7 +212,7 @@ func (mock *MockSecretsInterface) Get(_ context.Context, name string, _ metav1.G return object, nil } -// List returns the a of Secret. +// List returns all Secrets. func (mock *MockSecretsInterface) List(_ context.Context, opts metav1.ListOptions) (*v1.SecretList, error) { var list v1.SecretList @@ -216,7 +222,7 @@ func (mock *MockSecretsInterface) List(_ context.Context, opts metav1.ListOption } for _, secret := range mock.objects { - if labelSelector.Matches(kblabels.Set(secret.ObjectMeta.Labels)) { + if labelSelector.Matches(kblabels.Set(secret.Labels)) { list.Items = append(list.Items, *secret) } } @@ -225,7 +231,7 @@ func (mock *MockSecretsInterface) List(_ context.Context, opts metav1.ListOption // Create creates a new Secret. func (mock *MockSecretsInterface) Create(_ context.Context, secret *v1.Secret, _ metav1.CreateOptions) (*v1.Secret, error) { - name := secret.ObjectMeta.Name + name := secret.Name if object, ok := mock.objects[name]; ok { return object, apierrors.NewAlreadyExists(v1.Resource("tests"), name) } @@ -235,7 +241,7 @@ func (mock *MockSecretsInterface) Create(_ context.Context, secret *v1.Secret, _ // Update updates a Secret. func (mock *MockSecretsInterface) Update(_ context.Context, secret *v1.Secret, _ metav1.UpdateOptions) (*v1.Secret, error) { - name := secret.ObjectMeta.Name + name := secret.Name if _, ok := mock.objects[name]; !ok { return nil, apierrors.NewNotFound(v1.Resource("tests"), name) } @@ -254,6 +260,7 @@ func (mock *MockSecretsInterface) Delete(_ context.Context, name string, _ metav // newTestFixtureSQL mocks the SQL database (for testing purposes) func newTestFixtureSQL(t *testing.T, _ ...*rspb.Release) (*SQL, sqlmock.Sqlmock) { + t.Helper() sqlDB, mock, err := sqlmock.New() if err != nil { t.Fatalf("error when opening stub database connection: %v", err) @@ -262,7 +269,6 @@ func newTestFixtureSQL(t *testing.T, _ ...*rspb.Release) (*SQL, sqlmock.Sqlmock) sqlxDB := sqlx.NewDb(sqlDB, "sqlmock") return &SQL{ db: sqlxDB, - Log: func(a string, b ...interface{}) {}, namespace: "default", statementBuilder: sq.StatementBuilder.PlaceholderFormat(sq.Dollar), }, mock diff --git a/pkg/helm/pkg/storage/driver/records.go b/pkg/helm/pkg/storage/driver/records.go index 284667c4..00bf63a5 100644 --- a/pkg/helm/pkg/storage/driver/records.go +++ b/pkg/helm/pkg/storage/driver/records.go @@ -14,13 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -package driver // import "helm.sh/helm/v3/pkg/storage/driver" +package driver // import "github.com/werf/nelm/pkg/helm/pkg/storage/driver" import ( "sort" "strconv" - rspb "github.com/werf/nelm/pkg/helm/pkg/release" + rspb "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) // records holds a list of in-memory release records diff --git a/pkg/helm/pkg/storage/driver/records_test.go b/pkg/helm/pkg/storage/driver/records_test.go index 7bf0bb2f..61a6c7be 100644 --- a/pkg/helm/pkg/storage/driver/records_test.go +++ b/pkg/helm/pkg/storage/driver/records_test.go @@ -14,19 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -package driver // import "helm.sh/helm/v3/pkg/storage/driver" +package driver // import "github.com/werf/nelm/pkg/helm/pkg/storage/driver" import ( "reflect" "testing" - rspb "github.com/werf/nelm/pkg/helm/pkg/release" + "github.com/werf/nelm/pkg/helm/pkg/release/common" ) func TestRecordsAdd(t *testing.T) { rs := records([]*record{ - newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusSuperseded)), - newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", rspb.StatusDeployed)), + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", common.StatusDeployed)), }) var tests = []struct { @@ -39,13 +39,13 @@ func TestRecordsAdd(t *testing.T) { "add valid key", "rls-a.v3", false, - newRecord("rls-a.v3", releaseStub("rls-a", 3, "default", rspb.StatusSuperseded)), + newRecord("rls-a.v3", releaseStub("rls-a", 3, "default", common.StatusSuperseded)), }, { "add already existing key", "rls-a.v1", true, - newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusDeployed)), + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusDeployed)), }, } @@ -70,8 +70,8 @@ func TestRecordsRemove(t *testing.T) { } rs := records([]*record{ - newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusSuperseded)), - newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", rspb.StatusDeployed)), + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", common.StatusDeployed)), }) startLen := rs.Len() @@ -98,8 +98,8 @@ func TestRecordsRemove(t *testing.T) { func TestRecordsRemoveAt(t *testing.T) { rs := records([]*record{ - newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusSuperseded)), - newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", rspb.StatusDeployed)), + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", common.StatusDeployed)), }) if len(rs) != 2 { @@ -114,8 +114,8 @@ func TestRecordsRemoveAt(t *testing.T) { func TestRecordsGet(t *testing.T) { rs := records([]*record{ - newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusSuperseded)), - newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", rspb.StatusDeployed)), + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", common.StatusDeployed)), }) var tests = []struct { @@ -126,7 +126,7 @@ func TestRecordsGet(t *testing.T) { { "get valid key", "rls-a.v1", - newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusSuperseded)), + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusSuperseded)), }, { "get invalid key", @@ -145,8 +145,8 @@ func TestRecordsGet(t *testing.T) { func TestRecordsIndex(t *testing.T) { rs := records([]*record{ - newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusSuperseded)), - newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", rspb.StatusDeployed)), + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", common.StatusDeployed)), }) var tests = []struct { @@ -176,8 +176,8 @@ func TestRecordsIndex(t *testing.T) { func TestRecordsExists(t *testing.T) { rs := records([]*record{ - newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusSuperseded)), - newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", rspb.StatusDeployed)), + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", common.StatusDeployed)), }) var tests = []struct { @@ -207,8 +207,8 @@ func TestRecordsExists(t *testing.T) { func TestRecordsReplace(t *testing.T) { rs := records([]*record{ - newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusSuperseded)), - newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", rspb.StatusDeployed)), + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", common.StatusDeployed)), }) var tests = []struct { @@ -220,13 +220,13 @@ func TestRecordsReplace(t *testing.T) { { "replace with existing key", "rls-a.v2", - newRecord("rls-a.v3", releaseStub("rls-a", 3, "default", rspb.StatusSuperseded)), - newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", rspb.StatusDeployed)), + newRecord("rls-a.v3", releaseStub("rls-a", 3, "default", common.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", common.StatusDeployed)), }, { "replace with non existing key", "rls-a.v4", - newRecord("rls-a.v4", releaseStub("rls-a", 4, "default", rspb.StatusDeployed)), + newRecord("rls-a.v4", releaseStub("rls-a", 4, "default", common.StatusDeployed)), nil, }, } diff --git a/pkg/helm/pkg/storage/driver/secrets.go b/pkg/helm/pkg/storage/driver/secrets.go index e180a400..a33c0284 100644 --- a/pkg/helm/pkg/storage/driver/secrets.go +++ b/pkg/helm/pkg/storage/driver/secrets.go @@ -14,15 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -package driver // import "helm.sh/helm/v3/pkg/storage/driver" +package driver // import "github.com/werf/nelm/pkg/helm/pkg/storage/driver" import ( "context" + "fmt" + "log/slog" "strconv" "strings" "time" - "github.com/pkg/errors" v1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -30,7 +31,9 @@ import ( "k8s.io/apimachinery/pkg/util/validation" corev1 "k8s.io/client-go/kubernetes/typed/core/v1" - rspb "github.com/werf/nelm/pkg/helm/pkg/release" + "github.com/werf/nelm/pkg/helm/intern/logging" + "github.com/werf/nelm/pkg/helm/pkg/release" + rspb "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) var _ Driver = (*Secrets)(nil) @@ -42,16 +45,18 @@ const SecretsDriverName = "Secret" // SecretsInterface. type Secrets struct { impl corev1.SecretInterface - Log func(string, ...interface{}) + // Embed a LogHolder to provide logger functionality + logging.LogHolder } // NewSecrets initializes a new Secrets wrapping an implementation of // the kubernetes SecretsInterface. func NewSecrets(impl corev1.SecretInterface) *Secrets { - return &Secrets{ + s := &Secrets{ impl: impl, - Log: func(_ string, _ ...interface{}) {}, } + s.SetLogger(slog.Default().Handler()) + return s } // Name returns the name of the driver. @@ -61,45 +66,51 @@ func (secrets *Secrets) Name() string { // Get fetches the release named by key. The corresponding release is returned // or error if not found. -func (secrets *Secrets) Get(key string) (*rspb.Release, error) { +func (secrets *Secrets) Get(key string) (release.Releaser, error) { // fetch the secret holding the release named by key obj, err := secrets.impl.Get(context.Background(), key, metav1.GetOptions{}) if err != nil { if apierrors.IsNotFound(err) { return nil, ErrReleaseNotFound } - return nil, errors.Wrapf(err, "get: failed to get %q", key) + return nil, fmt.Errorf("get: failed to get %q: %w", key, err) } // found the secret, decode the base64 data string r, err := decodeRelease(string(obj.Data["release"])) - r.Labels = filterSystemLabels(obj.ObjectMeta.Labels) - return r, errors.Wrapf(err, "get: failed to decode data %q", key) + if err != nil { + return r, fmt.Errorf("get: failed to decode data %q: %w", key, err) + } + r.Labels = filterSystemLabels(obj.Labels) + return r, nil } // List fetches all releases and returns the list releases such // that filter(release) == true. An error is returned if the // secret fails to retrieve the releases. -func (secrets *Secrets) List(filter func(*rspb.Release) bool) ([]*rspb.Release, error) { +func (secrets *Secrets) List(filter func(release.Releaser) bool) ([]release.Releaser, error) { lsel := kblabels.Set{"owner": "helm"}.AsSelector() opts := metav1.ListOptions{LabelSelector: lsel.String()} list, err := secrets.impl.List(context.Background(), opts) if err != nil { - return nil, errors.Wrap(err, "list: failed to list") + return nil, fmt.Errorf("list: failed to list: %w", err) } - var results []*rspb.Release + var results []release.Releaser // iterate over the secrets object list // and decode each release for _, item := range list.Items { rls, err := decodeRelease(string(item.Data["release"])) if err != nil { - secrets.Log("list: failed to decode release: %v: %s", item, err) + secrets.Logger().Debug( + "list failed to decode release", slog.String("key", item.Name), + slog.Any("error", err), + ) continue } - rls.Labels = item.ObjectMeta.Labels + rls.Labels = item.Labels if filter(rls) { results = append(results, rls) @@ -110,11 +121,11 @@ func (secrets *Secrets) List(filter func(*rspb.Release) bool) ([]*rspb.Release, // Query fetches all releases that match the provided map of labels. // An error is returned if the secret fails to retrieve the releases. -func (secrets *Secrets) Query(labels map[string]string) ([]*rspb.Release, error) { +func (secrets *Secrets) Query(labels map[string]string) ([]release.Releaser, error) { ls := kblabels.Set{} for k, v := range labels { if errs := validation.IsValidLabelValue(v); len(errs) != 0 { - return nil, errors.Errorf("invalid label value: %q: %s", v, strings.Join(errs, "; ")) + return nil, fmt.Errorf("invalid label value: %q: %s", v, strings.Join(errs, "; ")) } ls[k] = v } @@ -123,21 +134,25 @@ func (secrets *Secrets) Query(labels map[string]string) ([]*rspb.Release, error) list, err := secrets.impl.List(context.Background(), opts) if err != nil { - return nil, errors.Wrap(err, "query: failed to query with labels") + return nil, fmt.Errorf("query: failed to query with labels: %w", err) } if len(list.Items) == 0 { return nil, ErrReleaseNotFound } - var results []*rspb.Release + var results []release.Releaser for _, item := range list.Items { rls, err := decodeRelease(string(item.Data["release"])) if err != nil { - secrets.Log("query: failed to decode release: %s", err) + secrets.Logger().Debug( + "failed to decode release", + slog.String("key", item.Name), + slog.Any("error", err), + ) continue } - rls.Labels = item.ObjectMeta.Labels + rls.Labels = item.Labels results = append(results, rls) } return results, nil @@ -145,18 +160,23 @@ func (secrets *Secrets) Query(labels map[string]string) ([]*rspb.Release, error) // Create creates a new Secret holding the release. If the // Secret already exists, ErrReleaseExists is returned. -func (secrets *Secrets) Create(key string, rls *rspb.Release) error { +func (secrets *Secrets) Create(key string, rel release.Releaser) error { // set labels for secrets object meta data var lbs labels + rls, err := releaserToV1Release(rel) + if err != nil { + return err + } + lbs.init() lbs.fromMap(rls.Labels) - lbs.set("createdAt", strconv.Itoa(int(time.Now().Unix()))) + lbs.set("createdAt", fmt.Sprintf("%v", time.Now().Unix())) // create a new secret to hold the release obj, err := newSecretsObject(key, rls, lbs) if err != nil { - return errors.Wrapf(err, "create: failed to encode release %q", rls.Name) + return fmt.Errorf("create: failed to encode release %q: %w", rls.Name, err) } // push the secret object out into the kubiverse if _, err := secrets.impl.Create(context.Background(), obj, metav1.CreateOptions{}); err != nil { @@ -164,40 +184,51 @@ func (secrets *Secrets) Create(key string, rls *rspb.Release) error { return ErrReleaseExists } - return errors.Wrap(err, "create: failed to create") + return fmt.Errorf("create: failed to create: %w", err) } return nil } // Update updates the Secret holding the release. If not found // the Secret is created to hold the release. -func (secrets *Secrets) Update(key string, rls *rspb.Release) error { +func (secrets *Secrets) Update(key string, rel release.Releaser) error { // set labels for secrets object meta data var lbs labels + rls, err := releaserToV1Release(rel) + if err != nil { + return err + } + lbs.init() lbs.fromMap(rls.Labels) - lbs.set("modifiedAt", strconv.Itoa(int(time.Now().Unix()))) + lbs.set("modifiedAt", fmt.Sprintf("%v", time.Now().Unix())) // create a new secret object to hold the release obj, err := newSecretsObject(key, rls, lbs) if err != nil { - return errors.Wrapf(err, "update: failed to encode release %q", rls.Name) + return fmt.Errorf("update: failed to encode release %q: %w", rls.Name, err) } // push the secret object out into the kubiverse _, err = secrets.impl.Update(context.Background(), obj, metav1.UpdateOptions{}) - return errors.Wrap(err, "update: failed to update") + if err != nil { + return fmt.Errorf("update: failed to update: %w", err) + } + return nil } // Delete deletes the Secret holding the release named by key. -func (secrets *Secrets) Delete(key string) (rls *rspb.Release, err error) { +func (secrets *Secrets) Delete(key string) (rls release.Releaser, err error) { // fetch the release to check existence if rls, err = secrets.Get(key); err != nil { return nil, err } // delete the release err = secrets.impl.Delete(context.Background(), key, metav1.DeleteOptions{}) - return rls, err + if err != nil { + return nil, err + } + return rls, nil } // newSecretsObject constructs a kubernetes Secret object diff --git a/pkg/helm/pkg/storage/driver/secrets_test.go b/pkg/helm/pkg/storage/driver/secrets_test.go index e694daaa..090c23f0 100644 --- a/pkg/helm/pkg/storage/driver/secrets_test.go +++ b/pkg/helm/pkg/storage/driver/secrets_test.go @@ -16,12 +16,15 @@ package driver import ( "encoding/base64" "encoding/json" + "errors" "reflect" "testing" v1 "k8s.io/api/core/v1" - rspb "github.com/werf/nelm/pkg/helm/pkg/release" + "github.com/werf/nelm/pkg/helm/pkg/release" + "github.com/werf/nelm/pkg/helm/pkg/release/common" + rspb "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) func TestSecretName(t *testing.T) { @@ -36,7 +39,7 @@ func TestSecretGet(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) secrets := newTestFixtureSecrets(t, []*rspb.Release{rel}...) @@ -56,7 +59,7 @@ func TestUNcompressedSecretGet(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) // Create a test fixture which contains an uncompressed release secret, err := newSecretsObject(key, rel, nil) @@ -85,17 +88,18 @@ func TestUNcompressedSecretGet(t *testing.T) { func TestSecretList(t *testing.T) { secrets := newTestFixtureSecrets(t, []*rspb.Release{ - releaseStub("key-1", 1, "default", rspb.StatusUninstalled), - releaseStub("key-2", 1, "default", rspb.StatusUninstalled), - releaseStub("key-3", 1, "default", rspb.StatusDeployed), - releaseStub("key-4", 1, "default", rspb.StatusDeployed), - releaseStub("key-5", 1, "default", rspb.StatusSuperseded), - releaseStub("key-6", 1, "default", rspb.StatusSuperseded), + releaseStub("key-1", 1, "default", common.StatusUninstalled), + releaseStub("key-2", 1, "default", common.StatusUninstalled), + releaseStub("key-3", 1, "default", common.StatusDeployed), + releaseStub("key-4", 1, "default", common.StatusDeployed), + releaseStub("key-5", 1, "default", common.StatusSuperseded), + releaseStub("key-6", 1, "default", common.StatusSuperseded), }...) // list all deleted releases - del, err := secrets.List(func(rel *rspb.Release) bool { - return rel.Info.Status == rspb.StatusUninstalled + del, err := secrets.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusUninstalled }) // check if err != nil { @@ -106,8 +110,9 @@ func TestSecretList(t *testing.T) { } // list all deployed releases - dpl, err := secrets.List(func(rel *rspb.Release) bool { - return rel.Info.Status == rspb.StatusDeployed + dpl, err := secrets.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusDeployed }) // check if err != nil { @@ -118,8 +123,9 @@ func TestSecretList(t *testing.T) { } // list all superseded releases - ssd, err := secrets.List(func(rel *rspb.Release) bool { - return rel.Info.Status == rspb.StatusSuperseded + ssd, err := secrets.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusSuperseded }) // check if err != nil { @@ -129,7 +135,7 @@ func TestSecretList(t *testing.T) { t.Errorf("Expected 2 superseded, got %d", len(ssd)) } // Check if release having both system and custom labels, this is needed to ensure that selector filtering would work. - rls := ssd[0] + rls := convertReleaserToV1(t, ssd[0]) _, ok := rls.Labels["name"] if !ok { t.Fatalf("Expected 'name' label in results, actual %v", rls.Labels) @@ -142,12 +148,12 @@ func TestSecretList(t *testing.T) { func TestSecretQuery(t *testing.T) { secrets := newTestFixtureSecrets(t, []*rspb.Release{ - releaseStub("key-1", 1, "default", rspb.StatusUninstalled), - releaseStub("key-2", 1, "default", rspb.StatusUninstalled), - releaseStub("key-3", 1, "default", rspb.StatusDeployed), - releaseStub("key-4", 1, "default", rspb.StatusDeployed), - releaseStub("key-5", 1, "default", rspb.StatusSuperseded), - releaseStub("key-6", 1, "default", rspb.StatusSuperseded), + releaseStub("key-1", 1, "default", common.StatusUninstalled), + releaseStub("key-2", 1, "default", common.StatusUninstalled), + releaseStub("key-3", 1, "default", common.StatusDeployed), + releaseStub("key-4", 1, "default", common.StatusDeployed), + releaseStub("key-5", 1, "default", common.StatusSuperseded), + releaseStub("key-6", 1, "default", common.StatusSuperseded), }...) rls, err := secrets.Query(map[string]string{"status": "deployed"}) @@ -171,7 +177,7 @@ func TestSecretCreate(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) // store the release in a secret if err := secrets.Create(key, rel); err != nil { @@ -195,12 +201,12 @@ func TestSecretUpdate(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) secrets := newTestFixtureSecrets(t, []*rspb.Release{rel}...) // modify release status code - rel.Info.Status = rspb.StatusSuperseded + rel.Info.Status = common.StatusSuperseded // perform the update if err := secrets.Update(key, rel); err != nil { @@ -208,10 +214,11 @@ func TestSecretUpdate(t *testing.T) { } // fetch the updated release - got, err := secrets.Get(key) + goti, err := secrets.Get(key) if err != nil { t.Fatalf("Failed to get release with key %q: %s", key, err) } + got := convertReleaserToV1(t, goti) // check release has actually been updated by comparing modified fields if rel.Info.Status != got.Info.Status { @@ -224,7 +231,7 @@ func TestSecretDelete(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) secrets := newTestFixtureSecrets(t, []*rspb.Release{rel}...) @@ -242,10 +249,8 @@ func TestSecretDelete(t *testing.T) { if !reflect.DeepEqual(rel, rls) { t.Errorf("Expected {%v}, got {%v}", rel, rls) } - - // fetch the deleted release _, err = secrets.Get(key) - if !reflect.DeepEqual(ErrReleaseNotFound, err) { + if !errors.Is(err, ErrReleaseNotFound) { t.Errorf("Expected {%v}, got {%v}", ErrReleaseNotFound, err) } } diff --git a/pkg/helm/pkg/storage/driver/sql.go b/pkg/helm/pkg/storage/driver/sql.go index 8a41cef9..5e73a756 100644 --- a/pkg/helm/pkg/storage/driver/sql.go +++ b/pkg/helm/pkg/storage/driver/sql.go @@ -14,10 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -package driver // import "helm.sh/helm/v3/pkg/storage/driver" +package driver // import "github.com/werf/nelm/pkg/helm/pkg/storage/driver" import ( "fmt" + "log/slog" + "maps" "sort" "strconv" "time" @@ -30,7 +32,9 @@ import ( // Import pq for postgres dialect _ "github.com/lib/pq" - rspb "github.com/werf/nelm/pkg/helm/pkg/release" + "github.com/werf/nelm/pkg/helm/intern/logging" + "github.com/werf/nelm/pkg/helm/pkg/release" + rspb "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) var _ Driver = (*SQL)(nil) @@ -72,8 +76,8 @@ const ( // Following limits based on k8s labels limits - https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set const ( - sqlCustomLabelsTableKeyMaxLenght = 253 + 1 + 63 - sqlCustomLabelsTableValueMaxLenght = 63 + sqlCustomLabelsTableKeyMaxLength = 253 + 1 + 63 + sqlCustomLabelsTableValueMaxLength = 63 ) const ( @@ -86,8 +90,8 @@ type SQL struct { db *sqlx.DB namespace string statementBuilder sq.StatementBuilderType - - Log func(string, ...interface{}) + // Embed a LogHolder to provide logger functionality + logging.LogHolder } // Name returns the name of the driver. @@ -98,9 +102,9 @@ func (s *SQL) Name() string { // Check if all migrations al func (s *SQL) checkAlreadyApplied(migrations []*migrate.Migration) bool { // make map (set) of ids for fast search - migrationsIds := make(map[string]struct{}) + migrationsIDs := make(map[string]struct{}) for _, migration := range migrations { - migrationsIds[migration.Id] = struct{}{} + migrationsIDs[migration.Id] = struct{}{} } // get list of applied migrations @@ -108,21 +112,21 @@ func (s *SQL) checkAlreadyApplied(migrations []*migrate.Migration) bool { records, err := migrate.GetMigrationRecords(s.db.DB, postgreSQLDialect) migrate.SetDisableCreateTable(false) if err != nil { - s.Log("checkAlreadyApplied: failed to get migration records: %v", err) + s.Logger().Debug("failed to get migration records", slog.Any("error", err)) return false } for _, record := range records { - if _, ok := migrationsIds[record.Id]; ok { - s.Log("checkAlreadyApplied: found previous migration (Id: %v) applied at %v", record.Id, record.AppliedAt) - delete(migrationsIds, record.Id) + if _, ok := migrationsIDs[record.Id]; ok { + s.Logger().Debug("found previous migration", "id", record.Id, "appliedAt", record.AppliedAt) + delete(migrationsIDs, record.Id) } } - // check if all migrations appliyed - if len(migrationsIds) != 0 { - for id := range migrationsIds { - s.Log("checkAlreadyApplied: find unapplied migration (id: %v)", id) + // check if all migrations applied + if len(migrationsIDs) != 0 { + for id := range migrationsIDs { + s.Logger().Debug("find unapplied migration", "id", id) } return false } @@ -156,9 +160,9 @@ func (s *SQL) ensureDBSetup() error { CREATE INDEX ON %s (%s); CREATE INDEX ON %s (%s); CREATE INDEX ON %s (%s); - + GRANT ALL ON %s TO PUBLIC; - + ALTER TABLE %s ENABLE ROW LEVEL SECURITY; `, sqlReleaseTableName, @@ -204,11 +208,11 @@ func (s *SQL) ensureDBSetup() error { CREATE TABLE %s ( %s VARCHAR(64), %s VARCHAR(67), - %s VARCHAR(%d), + %s VARCHAR(%d), %s VARCHAR(%d) ); CREATE INDEX ON %s (%s, %s); - + GRANT ALL ON %s TO PUBLIC; ALTER TABLE %s ENABLE ROW LEVEL SECURITY; `, @@ -216,9 +220,9 @@ func (s *SQL) ensureDBSetup() error { sqlCustomLabelsTableReleaseKeyColumn, sqlCustomLabelsTableReleaseNamespaceColumn, sqlCustomLabelsTableKeyColumn, - sqlCustomLabelsTableKeyMaxLenght, + sqlCustomLabelsTableKeyMaxLength, sqlCustomLabelsTableValueColumn, - sqlCustomLabelsTableValueMaxLenght, + sqlCustomLabelsTableValueMaxLength, sqlCustomLabelsTableName, sqlCustomLabelsTableReleaseKeyColumn, sqlCustomLabelsTableReleaseNamespaceColumn, @@ -276,7 +280,7 @@ type SQLReleaseCustomLabelWrapper struct { } // NewSQL initializes a new sql driver. -func NewSQL(connectionString string, logger func(string, ...interface{}), namespace string) (*SQL, error) { +func NewSQL(connectionString string, namespace string) (*SQL, error) { db, err := sqlx.Connect(postgreSQLDialect, connectionString) if err != nil { return nil, err @@ -284,7 +288,6 @@ func NewSQL(connectionString string, logger func(string, ...interface{}), namesp driver := &SQL{ db: db, - Log: logger, statementBuilder: sq.StatementBuilder.PlaceholderFormat(sq.Dollar), } @@ -293,12 +296,13 @@ func NewSQL(connectionString string, logger func(string, ...interface{}), namesp } driver.namespace = namespace + driver.SetLogger(slog.Default().Handler()) return driver, nil } // Get returns the release named by key. -func (s *SQL) Get(key string) (*rspb.Release, error) { +func (s *SQL) Get(key string) (release.Releaser, error) { var record SQLReleaseWrapper qb := s.statementBuilder. @@ -309,24 +313,29 @@ func (s *SQL) Get(key string) (*rspb.Release, error) { query, args, err := qb.ToSql() if err != nil { - s.Log("failed to build query: %v", err) + s.Logger().Debug("failed to build query", slog.Any("error", err)) return nil, err } // Get will return an error if the result is empty if err := s.db.Get(&record, query, args...); err != nil { - s.Log("got SQL error when getting release %s: %v", key, err) + s.Logger().Debug("got SQL error when getting release", slog.String("key", key), slog.Any("error", err)) return nil, ErrReleaseNotFound } release, err := decodeRelease(record.Body) if err != nil { - s.Log("get: failed to decode data %q: %v", key, err) + s.Logger().Debug("failed to decode data", slog.String("key", key), slog.Any("error", err)) return nil, err } if release.Labels, err = s.getReleaseCustomLabels(key, s.namespace); err != nil { - s.Log("failed to get release %s/%s custom labels: %v", s.namespace, key, err) + s.Logger().Debug( + "failed to get release custom labels", + slog.String("namespace", s.namespace), + slog.String("key", key), + slog.Any("error", err), + ) return nil, err } @@ -334,7 +343,7 @@ func (s *SQL) Get(key string) (*rspb.Release, error) { } // List returns the list of all releases such that filter(release) == true -func (s *SQL) List(filter func(*rspb.Release) bool) ([]*rspb.Release, error) { +func (s *SQL) List(filter func(release.Releaser) bool) ([]release.Releaser, error) { sb := s.statementBuilder. Select(sqlReleaseTableKeyColumn, sqlReleaseTableNamespaceColumn, sqlReleaseTableBodyColumn). From(sqlReleaseTableName). @@ -347,31 +356,34 @@ func (s *SQL) List(filter func(*rspb.Release) bool) ([]*rspb.Release, error) { query, args, err := sb.ToSql() if err != nil { - s.Log("failed to build query: %v", err) + s.Logger().Debug("failed to build query", slog.Any("error", err)) return nil, err } var records = []SQLReleaseWrapper{} if err := s.db.Select(&records, query, args...); err != nil { - s.Log("list: failed to list: %v", err) + s.Logger().Debug("failed to list", slog.Any("error", err)) return nil, err } - var releases []*rspb.Release + var releases []release.Releaser for _, record := range records { release, err := decodeRelease(record.Body) if err != nil { - s.Log("list: failed to decode release: %v: %v", record, err) + s.Logger().Debug("failed to decode release", slog.Any("record", record), slog.Any("error", err)) continue } if release.Labels, err = s.getReleaseCustomLabels(record.Key, record.Namespace); err != nil { - s.Log("failed to get release %s/%s custom labels: %v", record.Namespace, record.Key, err) + s.Logger().Debug( + "failed to get release custom labels", + slog.String("namespace", record.Namespace), + slog.String("key", record.Key), + slog.Any("error", err), + ) return nil, err } - for k, v := range getReleaseSystemLabels(release) { - release.Labels[k] = v - } + maps.Copy(release.Labels, getReleaseSystemLabels(release)) if filter(release) { releases = append(releases, release) @@ -382,7 +394,7 @@ func (s *SQL) List(filter func(*rspb.Release) bool) ([]*rspb.Release, error) { } // Query returns the set of releases that match the provided set of labels. -func (s *SQL) Query(labels map[string]string) ([]*rspb.Release, error) { +func (s *SQL) Query(labels map[string]string) ([]release.Releaser, error) { sb := s.statementBuilder. Select(sqlReleaseTableKeyColumn, sqlReleaseTableNamespaceColumn, sqlReleaseTableBodyColumn). From(sqlReleaseTableName) @@ -396,7 +408,7 @@ func (s *SQL) Query(labels map[string]string) ([]*rspb.Release, error) { if _, ok := labelMap[key]; ok { sb = sb.Where(sq.Eq{key: labels[key]}) } else { - s.Log("unknown label %s", key) + s.Logger().Debug("unknown label", "key", key) return nil, fmt.Errorf("unknown label %s", key) } } @@ -409,13 +421,13 @@ func (s *SQL) Query(labels map[string]string) ([]*rspb.Release, error) { // Build our query query, args, err := sb.ToSql() if err != nil { - s.Log("failed to build query: %v", err) + s.Logger().Debug("failed to build query", slog.Any("error", err)) return nil, err } var records = []SQLReleaseWrapper{} if err := s.db.Select(&records, query, args...); err != nil { - s.Log("list: failed to query with labels: %v", err) + s.Logger().Debug("failed to query with labels", slog.Any("error", err)) return nil, err } @@ -423,16 +435,21 @@ func (s *SQL) Query(labels map[string]string) ([]*rspb.Release, error) { return nil, ErrReleaseNotFound } - var releases []*rspb.Release + var releases []release.Releaser for _, record := range records { release, err := decodeRelease(record.Body) if err != nil { - s.Log("list: failed to decode release: %v: %v", record, err) + s.Logger().Debug("failed to decode release", slog.Any("record", record), slog.Any("error", err)) continue } if release.Labels, err = s.getReleaseCustomLabels(record.Key, record.Namespace); err != nil { - s.Log("failed to get release %s/%s custom labels: %v", record.Namespace, record.Key, err) + s.Logger().Debug( + "failed to get release custom labels", + slog.String("namespace", record.Namespace), + slog.String("key", record.Key), + slog.Any("error", err), + ) return nil, err } @@ -447,7 +464,12 @@ func (s *SQL) Query(labels map[string]string) ([]*rspb.Release, error) { } // Create creates a new release. -func (s *SQL) Create(key string, rls *rspb.Release) error { +func (s *SQL) Create(key string, rel release.Releaser) error { + rls, err := releaserToV1Release(rel) + if err != nil { + return err + } + namespace := rls.Namespace if namespace == "" { namespace = defaultNamespace @@ -456,13 +478,13 @@ func (s *SQL) Create(key string, rls *rspb.Release) error { body, err := encodeRelease(rls) if err != nil { - s.Log("failed to encode release: %v", err) + s.Logger().Debug("failed to encode release", slog.Any("error", err)) return err } transaction, err := s.db.Beginx() if err != nil { - s.Log("failed to start SQL transaction: %v", err) + s.Logger().Debug("failed to start SQL transaction", slog.Any("error", err)) return fmt.Errorf("error beginning transaction: %v", err) } @@ -491,7 +513,7 @@ func (s *SQL) Create(key string, rls *rspb.Release) error { int(time.Now().Unix()), ).ToSql() if err != nil { - s.Log("failed to build insert query: %v", err) + s.Logger().Debug("failed to build insert query", slog.Any("error", err)) return err } @@ -505,17 +527,17 @@ func (s *SQL) Create(key string, rls *rspb.Release) error { Where(sq.Eq{sqlReleaseTableNamespaceColumn: s.namespace}). ToSql() if buildErr != nil { - s.Log("failed to build select query: %v", buildErr) + s.Logger().Debug("failed to build select query", "error", buildErr) return err } var record SQLReleaseWrapper if err := transaction.Get(&record, selectQuery, args...); err == nil { - s.Log("release %s already exists", key) + s.Logger().Debug("release already exists", slog.String("key", key)) return ErrReleaseExists } - s.Log("failed to store release %s in SQL database: %v", key, err) + s.Logger().Debug("failed to store release in SQL database", slog.String("key", key), slog.Any("error", err)) return err } @@ -538,13 +560,13 @@ func (s *SQL) Create(key string, rls *rspb.Release) error { if err != nil { defer transaction.Rollback() - s.Log("failed to build insert query: %v", err) + s.Logger().Debug("failed to build insert query", slog.Any("error", err)) return err } if _, err := transaction.Exec(insertLabelsQuery, args...); err != nil { defer transaction.Rollback() - s.Log("failed to write Labels: %v", err) + s.Logger().Debug("failed to write Labels", slog.Any("error", err)) return err } } @@ -554,7 +576,11 @@ func (s *SQL) Create(key string, rls *rspb.Release) error { } // Update updates a release. -func (s *SQL) Update(key string, rls *rspb.Release) error { +func (s *SQL) Update(key string, rel release.Releaser) error { + rls, err := releaserToV1Release(rel) + if err != nil { + return err + } namespace := rls.Namespace if namespace == "" { namespace = defaultNamespace @@ -563,7 +589,7 @@ func (s *SQL) Update(key string, rls *rspb.Release) error { body, err := encodeRelease(rls) if err != nil { - s.Log("failed to encode release: %v", err) + s.Logger().Debug("failed to encode release", slog.Any("error", err)) return err } @@ -580,12 +606,12 @@ func (s *SQL) Update(key string, rls *rspb.Release) error { ToSql() if err != nil { - s.Log("failed to build update query: %v", err) + s.Logger().Debug("failed to build update query", slog.Any("error", err)) return err } if _, err := s.db.Exec(query, args...); err != nil { - s.Log("failed to update release %s in SQL database: %v", key, err) + s.Logger().Debug("failed to update release in SQL database", slog.String("key", key), slog.Any("error", err)) return err } @@ -593,10 +619,10 @@ func (s *SQL) Update(key string, rls *rspb.Release) error { } // Delete deletes a release or returns ErrReleaseNotFound. -func (s *SQL) Delete(key string) (*rspb.Release, error) { +func (s *SQL) Delete(key string) (release.Releaser, error) { transaction, err := s.db.Beginx() if err != nil { - s.Log("failed to start SQL transaction: %v", err) + s.Logger().Debug("failed to start SQL transaction", slog.Any("error", err)) return nil, fmt.Errorf("error beginning transaction: %v", err) } @@ -607,20 +633,20 @@ func (s *SQL) Delete(key string) (*rspb.Release, error) { Where(sq.Eq{sqlReleaseTableNamespaceColumn: s.namespace}). ToSql() if err != nil { - s.Log("failed to build select query: %v", err) + s.Logger().Debug("failed to build select query", slog.Any("error", err)) return nil, err } var record SQLReleaseWrapper err = transaction.Get(&record, selectQuery, args...) if err != nil { - s.Log("release %s not found: %v", key, err) + s.Logger().Debug("release not found", slog.String("key", key), slog.Any("error", err)) return nil, ErrReleaseNotFound } release, err := decodeRelease(record.Body) if err != nil { - s.Log("failed to decode release %s: %v", key, err) + s.Logger().Debug("failed to decode release", slog.String("key", key), slog.Any("error", err)) transaction.Rollback() return nil, err } @@ -632,18 +658,22 @@ func (s *SQL) Delete(key string) (*rspb.Release, error) { Where(sq.Eq{sqlReleaseTableNamespaceColumn: s.namespace}). ToSql() if err != nil { - s.Log("failed to build delete query: %v", err) + s.Logger().Debug("failed to build delete query", slog.Any("error", err)) return nil, err } _, err = transaction.Exec(deleteQuery, args...) if err != nil { - s.Log("failed perform delete query: %v", err) + s.Logger().Debug("failed perform delete query", slog.Any("error", err)) return release, err } if release.Labels, err = s.getReleaseCustomLabels(key, s.namespace); err != nil { - s.Log("failed to get release %s/%s custom labels: %v", s.namespace, key, err) + s.Logger().Debug( + "failed to get release custom labels", + slog.String("namespace", s.namespace), + slog.String("key", key), + slog.Any("error", err)) return nil, err } @@ -654,7 +684,7 @@ func (s *SQL) Delete(key string) (*rspb.Release, error) { ToSql() if err != nil { - s.Log("failed to build delete Labels query: %v", err) + s.Logger().Debug("failed to build delete Labels query", slog.Any("error", err)) return nil, err } _, err = transaction.Exec(deleteCustomLabelsQuery, args...) diff --git a/pkg/helm/pkg/storage/driver/sql_test.go b/pkg/helm/pkg/storage/driver/sql_test.go index a1c1f131..a7d38b77 100644 --- a/pkg/helm/pkg/storage/driver/sql_test.go +++ b/pkg/helm/pkg/storage/driver/sql_test.go @@ -14,6 +14,7 @@ limitations under the License. package driver import ( + "database/sql/driver" "fmt" "reflect" "regexp" @@ -23,9 +24,38 @@ import ( sqlmock "github.com/DATA-DOG/go-sqlmock" migrate "github.com/rubenv/sql-migrate" - rspb "github.com/werf/nelm/pkg/helm/pkg/release" + "github.com/werf/nelm/pkg/helm/pkg/release" + "github.com/werf/nelm/pkg/helm/pkg/release/common" + rspb "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) +const recentTimestampTolerance = time.Second + +func recentUnixTimestamp() sqlmock.Argument { + return recentUnixTimestampArgument{} +} + +type recentUnixTimestampArgument struct{} + +func (recentUnixTimestampArgument) Match(value driver.Value) bool { + var ts int64 + switch v := value.(type) { + case int: + ts = int64(v) + case int64: + ts = v + default: + return false + } + + diff := time.Since(time.Unix(ts, 0)) + if diff < 0 { + diff = -diff + } + + return diff <= recentTimestampTolerance +} + func TestSQLName(t *testing.T) { sqlDriver, _ := newTestFixtureSQL(t) if sqlDriver.Name() != SQLDriverName { @@ -38,7 +68,7 @@ func TestSQLGet(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) body, _ := encodeRelease(rel) @@ -81,16 +111,16 @@ func TestSQLGet(t *testing.T) { func TestSQLList(t *testing.T) { releases := []*rspb.Release{} - releases = append(releases, releaseStub("key-1", 1, "default", rspb.StatusUninstalled)) - releases = append(releases, releaseStub("key-2", 1, "default", rspb.StatusUninstalled)) - releases = append(releases, releaseStub("key-3", 1, "default", rspb.StatusDeployed)) - releases = append(releases, releaseStub("key-4", 1, "default", rspb.StatusDeployed)) - releases = append(releases, releaseStub("key-5", 1, "default", rspb.StatusSuperseded)) - releases = append(releases, releaseStub("key-6", 1, "default", rspb.StatusSuperseded)) + releases = append(releases, releaseStub("key-1", 1, "default", common.StatusUninstalled)) + releases = append(releases, releaseStub("key-2", 1, "default", common.StatusUninstalled)) + releases = append(releases, releaseStub("key-3", 1, "default", common.StatusDeployed)) + releases = append(releases, releaseStub("key-4", 1, "default", common.StatusDeployed)) + releases = append(releases, releaseStub("key-5", 1, "default", common.StatusSuperseded)) + releases = append(releases, releaseStub("key-6", 1, "default", common.StatusSuperseded)) sqlDriver, mock := newTestFixtureSQL(t) - for i := 0; i < 3; i++ { + for range 3 { query := fmt.Sprintf( "SELECT %s, %s, %s FROM %s WHERE %s = $1 AND %s = $2", sqlReleaseTableKeyColumn, @@ -119,8 +149,9 @@ func TestSQLList(t *testing.T) { } // list all deleted releases - del, err := sqlDriver.List(func(rel *rspb.Release) bool { - return rel.Info.Status == rspb.StatusUninstalled + del, err := sqlDriver.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusUninstalled }) // check if err != nil { @@ -131,8 +162,9 @@ func TestSQLList(t *testing.T) { } // list all deployed releases - dpl, err := sqlDriver.List(func(rel *rspb.Release) bool { - return rel.Info.Status == rspb.StatusDeployed + dpl, err := sqlDriver.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusDeployed }) // check if err != nil { @@ -143,8 +175,9 @@ func TestSQLList(t *testing.T) { } // list all superseded releases - ssd, err := sqlDriver.List(func(rel *rspb.Release) bool { - return rel.Info.Status == rspb.StatusSuperseded + ssd, err := sqlDriver.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusSuperseded }) // check if err != nil { @@ -159,7 +192,7 @@ func TestSQLList(t *testing.T) { } // Check if release having both system and custom labels, this is needed to ensure that selector filtering would work. - rls := ssd[0] + rls := convertReleaserToV1(t, ssd[0]) _, ok := rls.Labels["name"] if !ok { t.Fatalf("Expected 'name' label in results, actual %v", rls.Labels) @@ -175,7 +208,7 @@ func TestSqlCreate(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) sqlDriver, mock := newTestFixtureSQL(t) body, _ := encodeRelease(rel) @@ -197,7 +230,7 @@ func TestSqlCreate(t *testing.T) { mock.ExpectBegin() mock. ExpectExec(regexp.QuoteMeta(query)). - WithArgs(key, sqlReleaseDefaultType, body, rel.Name, rel.Namespace, int(rel.Version), rel.Info.Status.String(), sqlReleaseDefaultOwner, int(time.Now().Unix())). + WithArgs(key, sqlReleaseDefaultType, body, rel.Name, rel.Namespace, int(rel.Version), rel.Info.Status.String(), sqlReleaseDefaultOwner, recentUnixTimestamp()). WillReturnResult(sqlmock.NewResult(1, 1)) labelsQuery := fmt.Sprintf( @@ -232,7 +265,7 @@ func TestSqlCreateAlreadyExists(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) sqlDriver, mock := newTestFixtureSQL(t) body, _ := encodeRelease(rel) @@ -255,7 +288,7 @@ func TestSqlCreateAlreadyExists(t *testing.T) { mock.ExpectBegin() mock. ExpectExec(regexp.QuoteMeta(insertQuery)). - WithArgs(key, sqlReleaseDefaultType, body, rel.Name, rel.Namespace, int(rel.Version), rel.Info.Status.String(), sqlReleaseDefaultOwner, int(time.Now().Unix())). + WithArgs(key, sqlReleaseDefaultType, body, rel.Name, rel.Namespace, int(rel.Version), rel.Info.Status.String(), sqlReleaseDefaultOwner, recentUnixTimestamp()). WillReturnError(fmt.Errorf("dialect dependent SQL error")) selectQuery := fmt.Sprintf( @@ -293,7 +326,7 @@ func TestSqlUpdate(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) sqlDriver, mock := newTestFixtureSQL(t) body, _ := encodeRelease(rel) @@ -313,7 +346,7 @@ func TestSqlUpdate(t *testing.T) { mock. ExpectExec(regexp.QuoteMeta(query)). - WithArgs(body, rel.Name, int(rel.Version), rel.Info.Status.String(), sqlReleaseDefaultOwner, int(time.Now().Unix()), key, namespace). + WithArgs(body, rel.Name, int(rel.Version), rel.Info.Status.String(), sqlReleaseDefaultOwner, recentUnixTimestamp(), key, namespace). WillReturnResult(sqlmock.NewResult(0, 1)) if err := sqlDriver.Update(key, rel); err != nil { @@ -342,9 +375,9 @@ func TestSqlQuery(t *testing.T) { "owner": sqlReleaseDefaultOwner, } - supersededRelease := releaseStub("smug-pigeon", 1, "default", rspb.StatusSuperseded) + supersededRelease := releaseStub("smug-pigeon", 1, "default", common.StatusSuperseded) supersededReleaseBody, _ := encodeRelease(supersededRelease) - deployedRelease := releaseStub("smug-pigeon", 2, "default", rspb.StatusDeployed) + deployedRelease := releaseStub("smug-pigeon", 2, "default", common.StatusDeployed) deployedReleaseBody, _ := encodeRelease(deployedRelease) // Let's actually start our test @@ -454,7 +487,7 @@ func TestSqlDelete(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) body, _ := encodeRelease(rel) @@ -543,34 +576,34 @@ func mockGetReleaseCustomLabels(mock sqlmock.Sqlmock, key string, namespace stri eq.WillReturnRows(returnRows).RowsWillBeClosed() } -func TestSqlChechkAppliedMigrations(t *testing.T) { +func TestSqlCheckAppliedMigrations(t *testing.T) { cases := []struct { migrationsToApply []*migrate.Migration - appliedMigrationsIds []string + appliedMigrationsIDs []string expectedResult bool errorExplanation string }{ { migrationsToApply: []*migrate.Migration{{Id: "init1"}, {Id: "init2"}, {Id: "init3"}}, - appliedMigrationsIds: []string{"1", "2", "init1", "3", "init2", "4", "5"}, + appliedMigrationsIDs: []string{"1", "2", "init1", "3", "init2", "4", "5"}, expectedResult: false, errorExplanation: "Has found one migration id \"init3\" as applied, that was not applied", }, { migrationsToApply: []*migrate.Migration{{Id: "init1"}, {Id: "init2"}, {Id: "init3"}}, - appliedMigrationsIds: []string{"1", "2", "init1", "3", "init2", "4", "init3", "5"}, + appliedMigrationsIDs: []string{"1", "2", "init1", "3", "init2", "4", "init3", "5"}, expectedResult: true, errorExplanation: "Has not found one or more migration ids, that was applied", }, { migrationsToApply: []*migrate.Migration{{Id: "init"}}, - appliedMigrationsIds: []string{"1", "2", "3", "inits", "4", "tinit", "5"}, + appliedMigrationsIDs: []string{"1", "2", "3", "inits", "4", "tinit", "5"}, expectedResult: false, errorExplanation: "Has found single \"init\", that was not applied", }, { migrationsToApply: []*migrate.Migration{{Id: "init"}}, - appliedMigrationsIds: []string{"1", "2", "init", "3", "init2", "4", "init3", "5"}, + appliedMigrationsIDs: []string{"1", "2", "init", "3", "init2", "4", "init3", "5"}, expectedResult: true, errorExplanation: "Has not found single migration id \"init\", that was applied", }, @@ -578,7 +611,7 @@ func TestSqlChechkAppliedMigrations(t *testing.T) { for i, c := range cases { sqlDriver, mock := newTestFixtureSQL(t) rows := sqlmock.NewRows([]string{"id", "applied_at"}) - for _, id := range c.appliedMigrationsIds { + for _, id := range c.appliedMigrationsIDs { rows.AddRow(id, time.Time{}) } mock. diff --git a/pkg/helm/pkg/storage/driver/util.go b/pkg/helm/pkg/storage/driver/util.go index 73335dea..c8a7bd15 100644 --- a/pkg/helm/pkg/storage/driver/util.go +++ b/pkg/helm/pkg/storage/driver/util.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package driver // import "helm.sh/helm/v3/pkg/storage/driver" +package driver // import "github.com/werf/nelm/pkg/helm/pkg/storage/driver" import ( "bytes" @@ -22,8 +22,9 @@ import ( "encoding/base64" "encoding/json" "io" + "slices" - rspb "github.com/werf/nelm/pkg/helm/pkg/release" + rspb "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) var b64 = base64.StdEncoding @@ -88,12 +89,7 @@ func decodeRelease(data string) (*rspb.Release, error) { // Checks if label is system func isSystemLabel(key string) bool { - for _, v := range GetSystemLabels() { - if key == v { - return true - } - } - return false + return slices.Contains(GetSystemLabels(), key) } // Removes system labels from labels map diff --git a/pkg/helm/pkg/storage/storage.go b/pkg/helm/pkg/storage/storage.go index 8cfe09e0..cb1ecb25 100644 --- a/pkg/helm/pkg/storage/storage.go +++ b/pkg/helm/pkg/storage/storage.go @@ -14,16 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -package storage // import "helm.sh/helm/v3/pkg/storage" +package storage // import "github.com/werf/nelm/pkg/helm/pkg/storage" import ( + "errors" "fmt" + "log/slog" "strings" - "github.com/pkg/errors" - - rspb "github.com/werf/nelm/pkg/helm/pkg/release" - relutil "github.com/werf/nelm/pkg/helm/pkg/releaseutil" + "github.com/werf/nelm/pkg/helm/intern/logging" + "github.com/werf/nelm/pkg/helm/pkg/release" + "github.com/werf/nelm/pkg/helm/pkg/release/common" + rspb "github.com/werf/nelm/pkg/helm/pkg/release/v1" + relutil "github.com/werf/nelm/pkg/helm/pkg/release/v1/util" "github.com/werf/nelm/pkg/helm/pkg/storage/driver" ) @@ -43,76 +46,114 @@ type Storage struct { // ignored (meaning no limits are imposed). MaxHistory int - Log func(string, ...interface{}) + // Embed a LogHolder to provide logger functionality + logging.LogHolder } // Get retrieves the release from storage. An error is returned // if the storage driver failed to fetch the release, or the // release identified by the key, version pair does not exist. -func (s *Storage) Get(name string, version int) (*rspb.Release, error) { - s.Log("getting release %q", makeKey(name, version)) +func (s *Storage) Get(name string, version int) (release.Releaser, error) { + s.Logger().Debug("getting release", "key", makeKey(name, version)) return s.Driver.Get(makeKey(name, version)) } // Create creates a new storage entry holding the release. An // error is returned if the storage driver fails to store the // release, or a release with an identical key already exists. -func (s *Storage) Create(rls *rspb.Release) error { - s.Log("creating release %q", makeKey(rls.Name, rls.Version)) +func (s *Storage) Create(rls release.Releaser) error { + rac, err := release.NewAccessor(rls) + if err != nil { + return err + } + s.Logger().Debug("creating release", "key", makeKey(rac.Name(), rac.Version())) if s.MaxHistory > 0 { // Want to make space for one more release. - if err := s.removeLeastRecent(rls.Name, s.MaxHistory-1); err != nil && + if err := s.removeLeastRecent(rac.Name(), s.MaxHistory-1); err != nil && !errors.Is(err, driver.ErrReleaseNotFound) { return err } } - return s.Driver.Create(makeKey(rls.Name, rls.Version), rls) + return s.Driver.Create(makeKey(rac.Name(), rac.Version()), rls) } // Update updates the release in storage. An error is returned if the // storage backend fails to update the release or if the release // does not exist. -func (s *Storage) Update(rls *rspb.Release) error { - s.Log("updating release %q", makeKey(rls.Name, rls.Version)) - return s.Driver.Update(makeKey(rls.Name, rls.Version), rls) +func (s *Storage) Update(rls release.Releaser) error { + rac, err := release.NewAccessor(rls) + if err != nil { + return err + } + s.Logger().Debug("updating release", "key", makeKey(rac.Name(), rac.Version())) + return s.Driver.Update(makeKey(rac.Name(), rac.Version()), rls) } // Delete deletes the release from storage. An error is returned if // the storage backend fails to delete the release or if the release // does not exist. -func (s *Storage) Delete(name string, version int) (*rspb.Release, error) { - s.Log("deleting release %q", makeKey(name, version)) +func (s *Storage) Delete(name string, version int) (release.Releaser, error) { + s.Logger().Debug("deleting release", "key", makeKey(name, version)) return s.Driver.Delete(makeKey(name, version)) } // ListReleases returns all releases from storage. An error is returned if the // storage backend fails to retrieve the releases. -func (s *Storage) ListReleases() ([]*rspb.Release, error) { - s.Log("listing all releases in storage") - return s.Driver.List(func(_ *rspb.Release) bool { return true }) +func (s *Storage) ListReleases() ([]release.Releaser, error) { + s.Logger().Debug("listing all releases in storage") + return s.List(func(_ release.Releaser) bool { return true }) +} + +// releaserToV1Release is a helper function to convert a v1 release passed by interface +// into the type object. +func releaserToV1Release(rel release.Releaser) (*rspb.Release, error) { + switch r := rel.(type) { + case rspb.Release: + return &r, nil + case *rspb.Release: + return r, nil + case nil: + return nil, nil + default: + return nil, fmt.Errorf("unsupported release type: %T", rel) + } } // ListUninstalled returns all releases with Status == UNINSTALLED. An error is returned // if the storage backend fails to retrieve the releases. -func (s *Storage) ListUninstalled() ([]*rspb.Release, error) { - s.Log("listing uninstalled releases in storage") - return s.Driver.List(func(rls *rspb.Release) bool { - return relutil.StatusFilter(rspb.StatusUninstalled).Check(rls) +func (s *Storage) ListUninstalled() ([]release.Releaser, error) { + s.Logger().Debug("listing uninstalled releases in storage") + return s.List(func(rls release.Releaser) bool { + rel, err := releaserToV1Release(rls) + if err != nil { + // This will only happen if calling code does not pass the proper types. This is + // a problem with the application and not user data. + s.Logger().Error("unable to convert release to typed release", slog.Any("error", err)) + panic(fmt.Sprintf("unable to convert release to typed release: %s", err)) + } + return relutil.StatusFilter(common.StatusUninstalled).Check(rel) }) } // ListDeployed returns all releases with Status == DEPLOYED. An error is returned // if the storage backend fails to retrieve the releases. -func (s *Storage) ListDeployed() ([]*rspb.Release, error) { - s.Log("listing all deployed releases in storage") - return s.Driver.List(func(rls *rspb.Release) bool { - return relutil.StatusFilter(rspb.StatusDeployed).Check(rls) +func (s *Storage) ListDeployed() ([]release.Releaser, error) { + s.Logger().Debug("listing all deployed releases in storage") + return s.List(func(rls release.Releaser) bool { + rel, err := releaserToV1Release(rls) + if err != nil { + // This will only happen if calling code does not pass the proper types. This is + // a problem with the application and not user data. + s.Logger().Error("unable to convert release to typed release", slog.Any("error", err)) + panic(fmt.Sprintf("unable to convert release to typed release: %s", err)) + } + return relutil.StatusFilter(common.StatusDeployed).Check(rel) }) } // Deployed returns the last deployed release with the provided release name, or -// returns ErrReleaseNotFound if not found. -func (s *Storage) Deployed(name string) (*rspb.Release, error) { +// returns driver.NewErrNoDeployedReleases if not found. +func (s *Storage) Deployed(name string) (release.Releaser, error) { ls, err := s.DeployedAll(name) if err != nil { return nil, err @@ -122,19 +163,37 @@ func (s *Storage) Deployed(name string) (*rspb.Release, error) { return nil, driver.NewErrNoDeployedReleases(name) } + rls, err := releaseListToV1List(ls) + if err != nil { + return nil, err + } + // If executed concurrently, Helm's database gets corrupted // and multiple releases are DEPLOYED. Take the latest. - relutil.Reverse(ls, relutil.SortByRevision) + relutil.Reverse(rls, relutil.SortByRevision) - return ls[0], nil + return rls[0], nil +} + +func releaseListToV1List(ls []release.Releaser) ([]*rspb.Release, error) { + rls := make([]*rspb.Release, 0, len(ls)) + for _, val := range ls { + rel, err := releaserToV1Release(val) + if err != nil { + return nil, err + } + rls = append(rls, rel) + } + + return rls, nil } // DeployedAll returns all deployed releases with the provided name, or -// returns ErrReleaseNotFound if not found. -func (s *Storage) DeployedAll(name string) ([]*rspb.Release, error) { - s.Log("getting deployed releases from %q history", name) +// returns driver.NewErrNoDeployedReleases if not found. +func (s *Storage) DeployedAll(name string) ([]release.Releaser, error) { + s.Logger().Debug("getting deployed releases", "name", name) - ls, err := s.Driver.Query(map[string]string{ + ls, err := s.Query(map[string]string{ "name": name, "owner": "helm", "status": "deployed", @@ -149,11 +208,11 @@ func (s *Storage) DeployedAll(name string) ([]*rspb.Release, error) { } // History returns the revision history for the release with the provided name, or -// returns ErrReleaseNotFound if no such release name exists. -func (s *Storage) History(name string) ([]*rspb.Release, error) { - s.Log("getting release history for %q", name) +// returns driver.ErrReleaseNotFound if no such release name exists. +func (s *Storage) History(name string) ([]release.Releaser, error) { + s.Logger().Debug("getting release history", "name", name) - return s.Driver.Query(map[string]string{"name": name, "owner": "helm"}) + return s.Query(map[string]string{"name": name, "owner": "helm"}) } // removeLeastRecent removes items from history until the length number of releases @@ -161,34 +220,42 @@ func (s *Storage) History(name string) ([]*rspb.Release, error) { // // We allow max to be set explicitly so that calling functions can "make space" // for the new records they are going to write. -func (s *Storage) removeLeastRecent(name string, max int) error { - if max < 0 { +func (s *Storage) removeLeastRecent(name string, maximum int) error { + if maximum < 0 { return nil } h, err := s.History(name) if err != nil { return err } - if len(h) <= max { + if len(h) <= maximum { return nil } + rls, err := releaseListToV1List(h) + if err != nil { + return err + } // We want oldest to newest - relutil.SortByRevision(h) + relutil.SortByRevision(rls) lastDeployed, err := s.Deployed(name) if err != nil && !errors.Is(err, driver.ErrNoDeployedReleases) { return err } - var toDelete []*rspb.Release - for _, rel := range h { - // once we have enough releases to delete to reach the max, stop - if len(h)-len(toDelete) == max { + var toDelete []release.Releaser + for _, rel := range rls { + // once we have enough releases to delete to reach the maximum, stop + if len(rls)-len(toDelete) == maximum { break } if lastDeployed != nil { - if rel.Version != lastDeployed.Version { + ldac, err := release.NewAccessor(lastDeployed) + if err != nil { + return err + } + if rel.Version != ldac.Version() { toDelete = append(toDelete, rel) } } else { @@ -200,20 +267,25 @@ func (s *Storage) removeLeastRecent(name string, max int) error { // multiple invocations of this function will eventually delete them all. errs := []error{} for _, rel := range toDelete { - err = s.deleteReleaseVersion(name, rel.Version) + rac, err := release.NewAccessor(rel) + if err != nil { + errs = append(errs, err) + continue + } + err = s.deleteReleaseVersion(name, rac.Version()) if err != nil { errs = append(errs, err) } } - s.Log("Pruned %d record(s) from %s with %d error(s)", len(toDelete), name, len(errs)) + s.Logger().Debug("pruned records", "count", len(toDelete), "release", name, "errors", len(errs)) switch c := len(errs); c { case 0: return nil case 1: return errs[0] default: - return errors.Errorf("encountered %d deletion errors. First is: %s", c, errs[0]) + return fmt.Errorf("encountered %d deletion errors. First is: %w", c, errs[0]) } } @@ -221,25 +293,29 @@ func (s *Storage) deleteReleaseVersion(name string, version int) error { key := makeKey(name, version) _, err := s.Delete(name, version) if err != nil { - s.Log("error pruning %s from release history: %s", key, err) + s.Logger().Debug("error pruning release", slog.String("key", key), slog.Any("error", err)) return err } return nil } // Last fetches the last revision of the named release. -func (s *Storage) Last(name string) (*rspb.Release, error) { - s.Log("getting last revision of %q", name) +func (s *Storage) Last(name string) (release.Releaser, error) { + s.Logger().Debug("getting last revision", "name", name) h, err := s.History(name) if err != nil { return nil, err } if len(h) == 0 { - return nil, errors.Errorf("no revision for release %q", name) + return nil, fmt.Errorf("no revision for release %q", name) + } + rls, err := releaseListToV1List(h) + if err != nil { + return nil, err } - relutil.Reverse(h, relutil.SortByRevision) - return h[0], nil + relutil.Reverse(rls, relutil.SortByRevision) + return rls[0], nil } // makeKey concatenates the Kubernetes storage object type, a release name and version @@ -259,27 +335,16 @@ func Init(d driver.Driver) *Storage { if d == nil { d = driver.NewMemory() } - return &Storage{ + s := &Storage{ Driver: d, - Log: func(_ string, _ ...interface{}) {}, - } -} - -func (s *Storage) HistoryUntilRevision(name string, ignoreSinceRevision int) ([]*rspb.Release, error) { - history, err := s.History(name) - if err != nil { - return nil, fmt.Errorf("error getting release history: %w", err) } - relutil.SortByRevision(history) - - resultLength := len(history) - for i, release := range history { - if release.Version == ignoreSinceRevision { - resultLength = i - break - } + // Get logger from driver if it implements the LoggerSetterGetter interface + if ls, ok := d.(logging.LoggerSetterGetter); ok { + ls.SetLogger(s.Logger().Handler()) + } else { + // If the driver does not implement the LoggerSetterGetter interface, set the default logger + s.SetLogger(slog.Default().Handler()) } - - return history[:resultLength], nil + return s } diff --git a/pkg/helm/pkg/storage/storage_test.go b/pkg/helm/pkg/storage/storage_test.go index 86bc3bf7..f417b68b 100644 --- a/pkg/helm/pkg/storage/storage_test.go +++ b/pkg/helm/pkg/storage/storage_test.go @@ -14,16 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -package storage // import "helm.sh/helm/v3/pkg/storage" +package storage // import "github.com/werf/nelm/pkg/helm/pkg/storage" import ( + "errors" "fmt" "reflect" "testing" - "github.com/pkg/errors" + "github.com/stretchr/testify/assert" - rspb "github.com/werf/nelm/pkg/helm/pkg/release" + "github.com/werf/nelm/pkg/helm/pkg/release" + "github.com/werf/nelm/pkg/helm/pkg/release/common" + rspb "github.com/werf/nelm/pkg/helm/pkg/release/v1" "github.com/werf/nelm/pkg/helm/pkg/storage/driver" ) @@ -57,13 +60,13 @@ func TestStorageUpdate(t *testing.T) { rls := ReleaseTestData{ Name: "angry-beaver", Version: 1, - Status: rspb.StatusDeployed, + Status: common.StatusDeployed, }.ToRelease() assertErrNil(t.Fatal, storage.Create(rls), "StoreRelease") // modify the release - rls.Info.Status = rspb.StatusUninstalled + rls.Info.Status = common.StatusUninstalled assertErrNil(t.Fatal, storage.Update(rls), "UpdateRelease") // retrieve the updated release @@ -107,13 +110,16 @@ func TestStorageDelete(t *testing.T) { t.Errorf("unexpected error: %s", err) } + rhist, err := releaseListToV1List(hist) + assert.NoError(t, err) + // We have now deleted one of the two records. - if len(hist) != 1 { + if len(rhist) != 1 { t.Errorf("expected 1 record for deleted release version, got %d", len(hist)) } - if hist[0].Version != 2 { - t.Errorf("Expected version to be 2, got %d", hist[0].Version) + if rhist[0].Version != 2 { + t.Errorf("Expected version to be 2, got %d", rhist[0].Version) } } @@ -124,13 +130,13 @@ func TestStorageList(t *testing.T) { // setup storage with test releases setup := func() { // release records - rls0 := ReleaseTestData{Name: "happy-catdog", Status: rspb.StatusSuperseded}.ToRelease() - rls1 := ReleaseTestData{Name: "livid-human", Status: rspb.StatusSuperseded}.ToRelease() - rls2 := ReleaseTestData{Name: "relaxed-cat", Status: rspb.StatusSuperseded}.ToRelease() - rls3 := ReleaseTestData{Name: "hungry-hippo", Status: rspb.StatusDeployed}.ToRelease() - rls4 := ReleaseTestData{Name: "angry-beaver", Status: rspb.StatusDeployed}.ToRelease() - rls5 := ReleaseTestData{Name: "opulent-frog", Status: rspb.StatusUninstalled}.ToRelease() - rls6 := ReleaseTestData{Name: "happy-liger", Status: rspb.StatusUninstalled}.ToRelease() + rls0 := ReleaseTestData{Name: "happy-catdog", Status: common.StatusSuperseded}.ToRelease() + rls1 := ReleaseTestData{Name: "livid-human", Status: common.StatusSuperseded}.ToRelease() + rls2 := ReleaseTestData{Name: "relaxed-cat", Status: common.StatusSuperseded}.ToRelease() + rls3 := ReleaseTestData{Name: "hungry-hippo", Status: common.StatusDeployed}.ToRelease() + rls4 := ReleaseTestData{Name: "angry-beaver", Status: common.StatusDeployed}.ToRelease() + rls5 := ReleaseTestData{Name: "opulent-frog", Status: common.StatusUninstalled}.ToRelease() + rls6 := ReleaseTestData{Name: "happy-liger", Status: common.StatusUninstalled}.ToRelease() // create the release records in the storage assertErrNil(t.Fatal, storage.Create(rls0), "Storing release 'rls0'") @@ -145,7 +151,7 @@ func TestStorageList(t *testing.T) { var listTests = []struct { Description string NumExpected int - ListFunc func() ([]*rspb.Release, error) + ListFunc func() ([]release.Releaser, error) }{ {"ListDeployed", 2, storage.ListDeployed}, {"ListReleases", 7, storage.ListReleases}, @@ -176,10 +182,10 @@ func TestStorageDeployed(t *testing.T) { // setup storage with test releases setup := func() { // release records - rls0 := ReleaseTestData{Name: name, Version: 1, Status: rspb.StatusSuperseded}.ToRelease() - rls1 := ReleaseTestData{Name: name, Version: 2, Status: rspb.StatusSuperseded}.ToRelease() - rls2 := ReleaseTestData{Name: name, Version: 3, Status: rspb.StatusSuperseded}.ToRelease() - rls3 := ReleaseTestData{Name: name, Version: 4, Status: rspb.StatusDeployed}.ToRelease() + rls0 := ReleaseTestData{Name: name, Version: 1, Status: common.StatusSuperseded}.ToRelease() + rls1 := ReleaseTestData{Name: name, Version: 2, Status: common.StatusSuperseded}.ToRelease() + rls2 := ReleaseTestData{Name: name, Version: 3, Status: common.StatusSuperseded}.ToRelease() + rls3 := ReleaseTestData{Name: name, Version: 4, Status: common.StatusDeployed}.ToRelease() // create the release records in the storage assertErrNil(t.Fatal, storage.Create(rls0), "Storing release 'angry-bird' (v1)") @@ -195,15 +201,18 @@ func TestStorageDeployed(t *testing.T) { t.Fatalf("Failed to query for deployed release: %s\n", err) } + rel, err := releaserToV1Release(rls) + assert.NoError(t, err) + switch { case rls == nil: t.Fatalf("Release is nil") - case rls.Name != name: - t.Fatalf("Expected release name %q, actual %q\n", name, rls.Name) - case rls.Version != vers: - t.Fatalf("Expected release version %d, actual %d\n", vers, rls.Version) - case rls.Info.Status != rspb.StatusDeployed: - t.Fatalf("Expected release status 'DEPLOYED', actual %s\n", rls.Info.Status.String()) + case rel.Name != name: + t.Fatalf("Expected release name %q, actual %q\n", name, rel.Name) + case rel.Version != vers: + t.Fatalf("Expected release version %d, actual %d\n", vers, rel.Version) + case rel.Info.Status != common.StatusDeployed: + t.Fatalf("Expected release status 'DEPLOYED', actual %s\n", rel.Info.Status.String()) } } @@ -216,10 +225,10 @@ func TestStorageDeployedWithCorruption(t *testing.T) { // setup storage with test releases setup := func() { // release records (notice odd order and corruption) - rls0 := ReleaseTestData{Name: name, Version: 1, Status: rspb.StatusSuperseded}.ToRelease() - rls1 := ReleaseTestData{Name: name, Version: 4, Status: rspb.StatusDeployed}.ToRelease() - rls2 := ReleaseTestData{Name: name, Version: 3, Status: rspb.StatusSuperseded}.ToRelease() - rls3 := ReleaseTestData{Name: name, Version: 2, Status: rspb.StatusDeployed}.ToRelease() + rls0 := ReleaseTestData{Name: name, Version: 1, Status: common.StatusSuperseded}.ToRelease() + rls1 := ReleaseTestData{Name: name, Version: 4, Status: common.StatusDeployed}.ToRelease() + rls2 := ReleaseTestData{Name: name, Version: 3, Status: common.StatusSuperseded}.ToRelease() + rls3 := ReleaseTestData{Name: name, Version: 2, Status: common.StatusDeployed}.ToRelease() // create the release records in the storage assertErrNil(t.Fatal, storage.Create(rls0), "Storing release 'angry-bird' (v1)") @@ -235,15 +244,18 @@ func TestStorageDeployedWithCorruption(t *testing.T) { t.Fatalf("Failed to query for deployed release: %s\n", err) } + rel, err := releaserToV1Release(rls) + assert.NoError(t, err) + switch { case rls == nil: t.Fatalf("Release is nil") - case rls.Name != name: - t.Fatalf("Expected release name %q, actual %q\n", name, rls.Name) - case rls.Version != vers: - t.Fatalf("Expected release version %d, actual %d\n", vers, rls.Version) - case rls.Info.Status != rspb.StatusDeployed: - t.Fatalf("Expected release status 'DEPLOYED', actual %s\n", rls.Info.Status.String()) + case rel.Name != name: + t.Fatalf("Expected release name %q, actual %q\n", name, rel.Name) + case rel.Version != vers: + t.Fatalf("Expected release version %d, actual %d\n", vers, rel.Version) + case rel.Info.Status != common.StatusDeployed: + t.Fatalf("Expected release status 'DEPLOYED', actual %s\n", rel.Info.Status.String()) } } @@ -255,10 +267,10 @@ func TestStorageHistory(t *testing.T) { // setup storage with test releases setup := func() { // release records - rls0 := ReleaseTestData{Name: name, Version: 1, Status: rspb.StatusSuperseded}.ToRelease() - rls1 := ReleaseTestData{Name: name, Version: 2, Status: rspb.StatusSuperseded}.ToRelease() - rls2 := ReleaseTestData{Name: name, Version: 3, Status: rspb.StatusSuperseded}.ToRelease() - rls3 := ReleaseTestData{Name: name, Version: 4, Status: rspb.StatusDeployed}.ToRelease() + rls0 := ReleaseTestData{Name: name, Version: 1, Status: common.StatusSuperseded}.ToRelease() + rls1 := ReleaseTestData{Name: name, Version: 2, Status: common.StatusSuperseded}.ToRelease() + rls2 := ReleaseTestData{Name: name, Version: 3, Status: common.StatusSuperseded}.ToRelease() + rls3 := ReleaseTestData{Name: name, Version: 4, Status: common.StatusDeployed}.ToRelease() // create the release records in the storage assertErrNil(t.Fatal, storage.Create(rls0), "Storing release 'angry-bird' (v1)") @@ -287,22 +299,22 @@ type MaxHistoryMockDriver struct { func NewMaxHistoryMockDriver(d driver.Driver) *MaxHistoryMockDriver { return &MaxHistoryMockDriver{Driver: d} } -func (d *MaxHistoryMockDriver) Create(key string, rls *rspb.Release) error { +func (d *MaxHistoryMockDriver) Create(key string, rls release.Releaser) error { return d.Driver.Create(key, rls) } -func (d *MaxHistoryMockDriver) Update(key string, rls *rspb.Release) error { +func (d *MaxHistoryMockDriver) Update(key string, rls release.Releaser) error { return d.Driver.Update(key, rls) } -func (d *MaxHistoryMockDriver) Delete(_ string) (*rspb.Release, error) { +func (d *MaxHistoryMockDriver) Delete(_ string) (release.Releaser, error) { return nil, errMaxHistoryMockDriverSomethingHappened } -func (d *MaxHistoryMockDriver) Get(key string) (*rspb.Release, error) { +func (d *MaxHistoryMockDriver) Get(key string) (release.Releaser, error) { return d.Driver.Get(key) } -func (d *MaxHistoryMockDriver) List(filter func(*rspb.Release) bool) ([]*rspb.Release, error) { +func (d *MaxHistoryMockDriver) List(filter func(release.Releaser) bool) ([]release.Releaser, error) { return d.Driver.List(filter) } -func (d *MaxHistoryMockDriver) Query(labels map[string]string) ([]*rspb.Release, error) { +func (d *MaxHistoryMockDriver) Query(labels map[string]string) ([]release.Releaser, error) { return d.Driver.Query(labels) } func (d *MaxHistoryMockDriver) Name() string { @@ -310,9 +322,8 @@ func (d *MaxHistoryMockDriver) Name() string { } func TestMaxHistoryErrorHandling(t *testing.T) { - // func TestStorageRemoveLeastRecentWithError(t *testing.T) { + //func TestStorageRemoveLeastRecentWithError(t *testing.T) { storage := Init(NewMaxHistoryMockDriver(driver.NewMemory())) - storage.Log = t.Logf storage.MaxHistory = 1 @@ -321,14 +332,14 @@ func TestMaxHistoryErrorHandling(t *testing.T) { // setup storage with test releases setup := func() { // release records - rls1 := ReleaseTestData{Name: name, Version: 1, Status: rspb.StatusSuperseded}.ToRelease() + rls1 := ReleaseTestData{Name: name, Version: 1, Status: common.StatusSuperseded}.ToRelease() // create the release records in the storage assertErrNil(t.Fatal, storage.Driver.Create(makeKey(rls1.Name, rls1.Version), rls1), "Storing release 'angry-bird' (v1)") } setup() - rls2 := ReleaseTestData{Name: name, Version: 2, Status: rspb.StatusSuperseded}.ToRelease() + rls2 := ReleaseTestData{Name: name, Version: 2, Status: common.StatusSuperseded}.ToRelease() wantErr := errMaxHistoryMockDriverSomethingHappened gotErr := storage.Create(rls2) if !errors.Is(gotErr, wantErr) { @@ -338,7 +349,6 @@ func TestMaxHistoryErrorHandling(t *testing.T) { func TestStorageRemoveLeastRecent(t *testing.T) { storage := Init(driver.NewMemory()) - storage.Log = t.Logf // Make sure that specifying this at the outset doesn't cause any bugs. storage.MaxHistory = 10 @@ -348,10 +358,10 @@ func TestStorageRemoveLeastRecent(t *testing.T) { // setup storage with test releases setup := func() { // release records - rls0 := ReleaseTestData{Name: name, Version: 1, Status: rspb.StatusSuperseded}.ToRelease() - rls1 := ReleaseTestData{Name: name, Version: 2, Status: rspb.StatusSuperseded}.ToRelease() - rls2 := ReleaseTestData{Name: name, Version: 3, Status: rspb.StatusSuperseded}.ToRelease() - rls3 := ReleaseTestData{Name: name, Version: 4, Status: rspb.StatusDeployed}.ToRelease() + rls0 := ReleaseTestData{Name: name, Version: 1, Status: common.StatusSuperseded}.ToRelease() + rls1 := ReleaseTestData{Name: name, Version: 2, Status: common.StatusSuperseded}.ToRelease() + rls2 := ReleaseTestData{Name: name, Version: 3, Status: common.StatusSuperseded}.ToRelease() + rls3 := ReleaseTestData{Name: name, Version: 4, Status: common.StatusDeployed}.ToRelease() // create the release records in the storage assertErrNil(t.Fatal, storage.Create(rls0), "Storing release 'angry-bird' (v1)") @@ -370,22 +380,25 @@ func TestStorageRemoveLeastRecent(t *testing.T) { } storage.MaxHistory = 3 - rls5 := ReleaseTestData{Name: name, Version: 5, Status: rspb.StatusDeployed}.ToRelease() + rls5 := ReleaseTestData{Name: name, Version: 5, Status: common.StatusDeployed}.ToRelease() assertErrNil(t.Fatal, storage.Create(rls5), "Storing release 'angry-bird' (v5)") // On inserting the 5th record, we expect two records to be pruned from history. hist, err := storage.History(name) + assert.NoError(t, err) + rhist, err := releaseListToV1List(hist) + assert.NoError(t, err) if err != nil { t.Fatal(err) - } else if len(hist) != storage.MaxHistory { - for _, item := range hist { + } else if len(rhist) != storage.MaxHistory { + for _, item := range rhist { t.Logf("%s %v", item.Name, item.Version) } - t.Fatalf("expected %d items in history, got %d", storage.MaxHistory, len(hist)) + t.Fatalf("expected %d items in history, got %d", storage.MaxHistory, len(rhist)) } // We expect the existing records to be 3, 4, and 5. - for i, item := range hist { + for i, item := range rhist { v := item.Version if expect := i + 3; v != expect { t.Errorf("Expected release %d, got %d", expect, v) @@ -395,7 +408,6 @@ func TestStorageRemoveLeastRecent(t *testing.T) { func TestStorageDoNotDeleteDeployed(t *testing.T) { storage := Init(driver.NewMemory()) - storage.Log = t.Logf storage.MaxHistory = 3 const name = "angry-bird" @@ -403,10 +415,10 @@ func TestStorageDoNotDeleteDeployed(t *testing.T) { // setup storage with test releases setup := func() { // release records - rls0 := ReleaseTestData{Name: name, Version: 1, Status: rspb.StatusSuperseded}.ToRelease() - rls1 := ReleaseTestData{Name: name, Version: 2, Status: rspb.StatusDeployed}.ToRelease() - rls2 := ReleaseTestData{Name: name, Version: 3, Status: rspb.StatusFailed}.ToRelease() - rls3 := ReleaseTestData{Name: name, Version: 4, Status: rspb.StatusFailed}.ToRelease() + rls0 := ReleaseTestData{Name: name, Version: 1, Status: common.StatusSuperseded}.ToRelease() + rls1 := ReleaseTestData{Name: name, Version: 2, Status: common.StatusDeployed}.ToRelease() + rls2 := ReleaseTestData{Name: name, Version: 3, Status: common.StatusFailed}.ToRelease() + rls3 := ReleaseTestData{Name: name, Version: 4, Status: common.StatusFailed}.ToRelease() // create the release records in the storage assertErrNil(t.Fatal, storage.Create(rls0), "Storing release 'angry-bird' (v1)") @@ -416,7 +428,7 @@ func TestStorageDoNotDeleteDeployed(t *testing.T) { } setup() - rls5 := ReleaseTestData{Name: name, Version: 5, Status: rspb.StatusFailed}.ToRelease() + rls5 := ReleaseTestData{Name: name, Version: 5, Status: common.StatusFailed}.ToRelease() assertErrNil(t.Fatal, storage.Create(rls5), "Storing release 'angry-bird' (v5)") // On inserting the 5th record, we expect a total of 3 releases, but we expect version 2 @@ -425,10 +437,12 @@ func TestStorageDoNotDeleteDeployed(t *testing.T) { if err != nil { t.Fatal(err) } else if len(hist) != storage.MaxHistory { - for _, item := range hist { + rhist, err := releaseListToV1List(hist) + assert.NoError(t, err) + for _, item := range rhist { t.Logf("%s %v", item.Name, item.Version) } - t.Fatalf("expected %d items in history, got %d", storage.MaxHistory, len(hist)) + t.Fatalf("expected %d items in history, got %d", storage.MaxHistory, len(rhist)) } expectedVersions := map[int]bool{ @@ -437,7 +451,9 @@ func TestStorageDoNotDeleteDeployed(t *testing.T) { 5: true, } - for _, item := range hist { + rhist, err := releaseListToV1List(hist) + assert.NoError(t, err) + for _, item := range rhist { if !expectedVersions[item.Version] { t.Errorf("Release version %d, found when not expected", item.Version) } @@ -452,10 +468,10 @@ func TestStorageLast(t *testing.T) { // Set up storage with test releases. setup := func() { // release records - rls0 := ReleaseTestData{Name: name, Version: 1, Status: rspb.StatusSuperseded}.ToRelease() - rls1 := ReleaseTestData{Name: name, Version: 2, Status: rspb.StatusSuperseded}.ToRelease() - rls2 := ReleaseTestData{Name: name, Version: 3, Status: rspb.StatusSuperseded}.ToRelease() - rls3 := ReleaseTestData{Name: name, Version: 4, Status: rspb.StatusFailed}.ToRelease() + rls0 := ReleaseTestData{Name: name, Version: 1, Status: common.StatusSuperseded}.ToRelease() + rls1 := ReleaseTestData{Name: name, Version: 2, Status: common.StatusSuperseded}.ToRelease() + rls2 := ReleaseTestData{Name: name, Version: 3, Status: common.StatusSuperseded}.ToRelease() + rls3 := ReleaseTestData{Name: name, Version: 4, Status: common.StatusFailed}.ToRelease() // create the release records in the storage assertErrNil(t.Fatal, storage.Create(rls0), "Storing release 'angry-bird' (v1)") @@ -471,12 +487,15 @@ func TestStorageLast(t *testing.T) { t.Fatalf("Failed to query for release history (%q): %s\n", name, err) } - if h.Version != 4 { - t.Errorf("Expected revision 4, got %d", h.Version) + rel, err := releaserToV1Release(h) + assert.NoError(t, err) + + if rel.Version != 4 { + t.Errorf("Expected revision 4, got %d", rel.Version) } } -// TestUpgradeInitiallyFailedRelease tests a case when there are no deployed release yet, but history limit has been +// TestUpgradeInitiallyFailedReleaseWithHistoryLimit tests a case when there are no deployed release yet, but history limit has been // reached: the has-no-deployed-releases error should not occur in such case. func TestUpgradeInitiallyFailedReleaseWithHistoryLimit(t *testing.T) { storage := Init(driver.NewMemory()) @@ -487,10 +506,10 @@ func TestUpgradeInitiallyFailedReleaseWithHistoryLimit(t *testing.T) { // setup storage with test releases setup := func() { // release records - rls0 := ReleaseTestData{Name: name, Version: 1, Status: rspb.StatusFailed}.ToRelease() - rls1 := ReleaseTestData{Name: name, Version: 2, Status: rspb.StatusFailed}.ToRelease() - rls2 := ReleaseTestData{Name: name, Version: 3, Status: rspb.StatusFailed}.ToRelease() - rls3 := ReleaseTestData{Name: name, Version: 4, Status: rspb.StatusFailed}.ToRelease() + rls0 := ReleaseTestData{Name: name, Version: 1, Status: common.StatusFailed}.ToRelease() + rls1 := ReleaseTestData{Name: name, Version: 2, Status: common.StatusFailed}.ToRelease() + rls2 := ReleaseTestData{Name: name, Version: 3, Status: common.StatusFailed}.ToRelease() + rls3 := ReleaseTestData{Name: name, Version: 4, Status: common.StatusFailed}.ToRelease() // create the release records in the storage assertErrNil(t.Fatal, storage.Create(rls0), "Storing release 'angry-bird' (v1)") @@ -511,7 +530,7 @@ func TestUpgradeInitiallyFailedReleaseWithHistoryLimit(t *testing.T) { setup() - rls5 := ReleaseTestData{Name: name, Version: 5, Status: rspb.StatusFailed}.ToRelease() + rls5 := ReleaseTestData{Name: name, Version: 5, Status: common.StatusFailed}.ToRelease() err := storage.Create(rls5) if err != nil { t.Fatalf("Failed to create a new release version: %s", err) @@ -522,13 +541,15 @@ func TestUpgradeInitiallyFailedReleaseWithHistoryLimit(t *testing.T) { t.Fatalf("unexpected error: %s", err) } - for i, rel := range hist { + rhist, err := releaseListToV1List(hist) + assert.NoError(t, err) + for i, rel := range rhist { wantVersion := i + 2 if rel.Version != wantVersion { t.Fatalf("Expected history release %d version to equal %d, got %d", i+1, wantVersion, rel.Version) } - wantStatus := rspb.StatusFailed + wantStatus := common.StatusFailed if rel.Info.Status != wantStatus { t.Fatalf("Expected history release %d status to equal %q, got %q", i+1, wantStatus, rel.Info.Status) } @@ -540,7 +561,7 @@ type ReleaseTestData struct { Version int Manifest string Namespace string - Status rspb.Status + Status common.Status } func (test ReleaseTestData) ToRelease() *rspb.Release { diff --git a/pkg/helm/pkg/strvals/fuzz_test.go b/pkg/helm/pkg/strvals/fuzz_test.go new file mode 100644 index 00000000..68b43c8e --- /dev/null +++ b/pkg/helm/pkg/strvals/fuzz_test.go @@ -0,0 +1,26 @@ +/* +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 strvals + +import ( + "testing" +) + +func FuzzParse(f *testing.F) { + f.Fuzz(func(_ *testing.T, data string) { + _, _ = Parse(data) + }) +} diff --git a/pkg/helm/pkg/strvals/literal_parser.go b/pkg/helm/pkg/strvals/literal_parser.go index f7565581..d5d4c25b 100644 --- a/pkg/helm/pkg/strvals/literal_parser.go +++ b/pkg/helm/pkg/strvals/literal_parser.go @@ -17,11 +17,10 @@ package strvals import ( "bytes" + "errors" "fmt" "io" "strconv" - - "github.com/pkg/errors" ) // ParseLiteral parses a set line interpreting the value as a literal string. @@ -68,7 +67,7 @@ func (t *literalParser) parse() error { if err == nil { continue } - if err == io.EOF { + if errors.Is(err, io.EOF) { return nil } return err @@ -102,7 +101,7 @@ func (t *literalParser) key(data map[string]interface{}, nestedNameLevel int) (r if len(key) == 0 { return err } - return errors.Errorf("key %q has no value", string(key)) + return fmt.Errorf("key %q has no value", string(key)) case lastRune == '=': // found end of key: swallow the '=' and get the value @@ -129,7 +128,7 @@ func (t *literalParser) key(data map[string]interface{}, nestedNameLevel int) (r // recurse on sub-tree with remaining data err := t.key(inner, nestedNameLevel) if err == nil && len(inner) == 0 { - return errors.Errorf("key map %q has no value", string(key)) + return fmt.Errorf("key map %q has no value", string(key)) } if len(inner) != 0 { set(data, string(key), inner) @@ -140,7 +139,7 @@ func (t *literalParser) key(data map[string]interface{}, nestedNameLevel int) (r // We are in a list index context, so we need to set an index. i, err := t.keyIndex() if err != nil { - return errors.Wrap(err, "error parsing index") + return fmt.Errorf("error parsing index: %w", err) } kk := string(key) @@ -178,14 +177,14 @@ func (t *literalParser) listItem(list []interface{}, i, nestedNameLevel int) ([] switch key, lastRune, err := runesUntilLiteral(t.sc, stop); { case len(key) > 0: - return list, errors.Errorf("unexpected data at end of array index: %q", key) + return list, fmt.Errorf("unexpected data at end of array index: %q", key) case err != nil: return list, err case lastRune == '=': value, err := t.val() - if err != nil && err != io.EOF { + if err != nil && !errors.Is(err, io.EOF) { return list, err } return setIndex(list, i, string(value)) @@ -214,7 +213,7 @@ func (t *literalParser) listItem(list []interface{}, i, nestedNameLevel int) ([] // now we have a nested list. Read the index and handle. nextI, err := t.keyIndex() if err != nil { - return list, errors.Wrap(err, "error parsing index") + return list, fmt.Errorf("error parsing index: %w", err) } var crtList []interface{} if len(list) > i { @@ -233,7 +232,7 @@ func (t *literalParser) listItem(list []interface{}, i, nestedNameLevel int) ([] return setIndex(list, i, list2) default: - return nil, errors.Errorf("parse error: unexpected token %v", lastRune) + return nil, fmt.Errorf("parse error: unexpected token %v", lastRune) } } diff --git a/pkg/helm/pkg/strvals/literal_parser_test.go b/pkg/helm/pkg/strvals/literal_parser_test.go index 4e74423d..6a76458f 100644 --- a/pkg/helm/pkg/strvals/literal_parser_test.go +++ b/pkg/helm/pkg/strvals/literal_parser_test.go @@ -17,6 +17,7 @@ package strvals import ( "fmt" + "strings" "testing" "sigs.k8s.io/yaml" @@ -416,14 +417,14 @@ func TestParseLiteralInto(t *testing.T) { } func TestParseLiteralNestedLevels(t *testing.T) { - var keyMultipleNestedLevels string + var keyMultipleNestedLevels strings.Builder for i := 1; i <= MaxNestedNameLevel+2; i++ { tmpStr := fmt.Sprintf("name%d", i) if i <= MaxNestedNameLevel+1 { tmpStr = tmpStr + "." } - keyMultipleNestedLevels += tmpStr + keyMultipleNestedLevels.WriteString(tmpStr) } tests := []struct { @@ -439,7 +440,7 @@ func TestParseLiteralNestedLevels(t *testing.T) { "", }, { - str: keyMultipleNestedLevels + "=value", + str: keyMultipleNestedLevels.String() + "=value", err: true, errStr: fmt.Sprintf("value name nested level is greater than maximum supported nested level of %d", MaxNestedNameLevel), }, diff --git a/pkg/helm/pkg/strvals/parser.go b/pkg/helm/pkg/strvals/parser.go index 2828f20c..8eb761dc 100644 --- a/pkg/helm/pkg/strvals/parser.go +++ b/pkg/helm/pkg/strvals/parser.go @@ -18,13 +18,13 @@ package strvals import ( "bytes" "encoding/json" + "errors" "fmt" "io" "strconv" "strings" "unicode" - "github.com/pkg/errors" "sigs.k8s.io/yaml" ) @@ -161,7 +161,7 @@ func (t *parser) parse() error { if err == nil { continue } - if err == io.EOF { + if errors.Is(err, io.EOF) { return nil } return err @@ -189,14 +189,14 @@ func (t *parser) key(data map[string]interface{}, nestedNameLevel int) (reterr e if len(k) == 0 { return err } - return errors.Errorf("key %q has no value", string(k)) + return fmt.Errorf("key %q has no value", string(k)) //set(data, string(k), "") //return err case last == '[': // We are in a list index context, so we need to set an index. i, err := t.keyIndex() if err != nil { - return errors.Wrap(err, "error parsing index") + return fmt.Errorf("error parsing index: %w", err) } kk := string(k) // Find or create target list @@ -237,7 +237,7 @@ func (t *parser) key(data map[string]interface{}, nestedNameLevel int) (reterr e _, err = t.emptyVal() return err } - //End of key. Consume =, Get value. + // End of key. Consume =, Get value. // FIXME: Get value list first vl, e := t.valList() switch e { @@ -261,7 +261,7 @@ func (t *parser) key(data map[string]interface{}, nestedNameLevel int) (reterr e case last == ',': // No value given. Set the value to empty string. Return error. set(data, string(k), "") - return errors.Errorf("key %q has no value (cannot end with ,)", string(k)) + return fmt.Errorf("key %q has no value (cannot end with ,)", string(k)) case last == '.': // Check value name is within the maximum nested name level nestedNameLevel++ @@ -278,7 +278,7 @@ func (t *parser) key(data map[string]interface{}, nestedNameLevel int) (reterr e // Recurse e := t.key(inner, nestedNameLevel) if e == nil && len(inner) == 0 { - return errors.Errorf("key map %q has no value", string(k)) + return fmt.Errorf("key map %q has no value", string(k)) } if len(inner) != 0 { set(data, string(k), inner) @@ -332,6 +332,7 @@ func (t *parser) keyIndex() (int, error) { return strconv.Atoi(string(v)) } + func (t *parser) listItem(list []interface{}, i, nestedNameLevel int) ([]interface{}, error) { if i < 0 { return list, fmt.Errorf("negative %d index not allowed", i) @@ -339,7 +340,7 @@ func (t *parser) listItem(list []interface{}, i, nestedNameLevel int) ([]interfa stop := runeSet([]rune{'[', '.', '='}) switch k, last, err := runesUntil(t.sc, stop); { case len(k) > 0: - return list, errors.Errorf("unexpected data at end of array index: %q", k) + return list, fmt.Errorf("unexpected data at end of array index: %q", k) case err != nil: return list, err case last == '=': @@ -394,7 +395,7 @@ func (t *parser) listItem(list []interface{}, i, nestedNameLevel int) ([]interfa // now we have a nested list. Read the index and handle. nextI, err := t.keyIndex() if err != nil { - return list, errors.Wrap(err, "error parsing index") + return list, fmt.Errorf("error parsing index: %w", err) } var crtList []interface{} if len(list) > i { @@ -430,13 +431,13 @@ func (t *parser) listItem(list []interface{}, i, nestedNameLevel int) ([]interfa } return setIndex(list, i, inner) default: - return nil, errors.Errorf("parse error: unexpected token %v", last) + return nil, fmt.Errorf("parse error: unexpected token %v", last) } } // check for an empty value // read and consume optional spaces until comma or EOF (empty val) or any other char (not empty val) -// comma and spaces are consumed, while any other char is not cosumed +// comma and spaces are consumed, while any other char is not consumed func (t *parser) emptyVal() (bool, error) { for { r, _, e := t.sc.ReadRune() diff --git a/pkg/helm/pkg/strvals/parser_test.go b/pkg/helm/pkg/strvals/parser_test.go index 925aa97c..73403fc5 100644 --- a/pkg/helm/pkg/strvals/parser_test.go +++ b/pkg/helm/pkg/strvals/parser_test.go @@ -17,6 +17,7 @@ package strvals import ( "fmt" + "strings" "testing" "sigs.k8s.io/yaml" @@ -626,7 +627,7 @@ func TestParseJSON(t *testing.T) { }, err: false, }, - { // null assigment, and no value assigned (equivalent to null) + { // null assignment, and no value assigned (equivalent to null) input: "outer.inner1=,outer.inner3={\"aa\":\"1\",\"bb\":2,\"cc\":[1,2,3]},outer.inner3.cc[1]=null", got: map[string]interface{}{ "outer": map[string]interface{}{ @@ -757,13 +758,13 @@ func TestToYAML(t *testing.T) { } func TestParseSetNestedLevels(t *testing.T) { - var keyMultipleNestedLevels string + var keyMultipleNestedLevels strings.Builder for i := 1; i <= MaxNestedNameLevel+2; i++ { tmpStr := fmt.Sprintf("name%d", i) if i <= MaxNestedNameLevel+1 { tmpStr = tmpStr + "." } - keyMultipleNestedLevels += tmpStr + keyMultipleNestedLevels.WriteString(tmpStr) } tests := []struct { str string @@ -778,7 +779,7 @@ func TestParseSetNestedLevels(t *testing.T) { "", }, { - str: keyMultipleNestedLevels + "=value", + str: keyMultipleNestedLevels.String() + "=value", err: true, errStr: fmt.Sprintf("value name nested level is greater than maximum supported nested level of %d", MaxNestedNameLevel), diff --git a/pkg/helm/pkg/time/time.go b/pkg/helm/pkg/time/time.go deleted file mode 100644 index 44f3fedf..00000000 --- a/pkg/helm/pkg/time/time.go +++ /dev/null @@ -1,91 +0,0 @@ -/* -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 time contains a wrapper for time.Time in the standard library and -// associated methods. This package mainly exists to workaround an issue in Go -// where the serializer doesn't omit an empty value for time: -// https://github.com/golang/go/issues/11939. As such, this can be removed if a -// proposal is ever accepted for Go -package time - -import ( - "bytes" - "time" -) - -// emptyString contains an empty JSON string value to be used as output -var emptyString = `""` - -// Time is a convenience wrapper around stdlib time, but with different -// marshalling and unmarshaling for zero values -type Time struct { - time.Time -} - -// Now returns the current time. It is a convenience wrapper around time.Now() -func Now() Time { - return Time{time.Now()} -} - -func (t Time) MarshalJSON() ([]byte, error) { - if t.Time.IsZero() { - return []byte(emptyString), nil - } - - return t.Time.MarshalJSON() -} - -func (t *Time) UnmarshalJSON(b []byte) error { - if bytes.Equal(b, []byte("null")) { - return nil - } - // If it is empty, we don't have to set anything since time.Time is not a - // pointer and will be set to the zero value - if bytes.Equal([]byte(emptyString), b) { - return nil - } - - return t.Time.UnmarshalJSON(b) -} - -func Parse(layout, value string) (Time, error) { - t, err := time.Parse(layout, value) - return Time{Time: t}, err -} -func ParseInLocation(layout, value string, loc *time.Location) (Time, error) { - t, err := time.ParseInLocation(layout, value, loc) - return Time{Time: t}, err -} - -func Date(year int, month time.Month, day, hour, min, sec, nsec int, loc *time.Location) Time { - return Time{Time: time.Date(year, month, day, hour, min, sec, nsec, loc)} -} - -func Unix(sec int64, nsec int64) Time { return Time{Time: time.Unix(sec, nsec)} } - -func (t Time) Add(d time.Duration) Time { return Time{Time: t.Time.Add(d)} } -func (t Time) AddDate(years int, months int, days int) Time { - return Time{Time: t.Time.AddDate(years, months, days)} -} -func (t Time) After(u Time) bool { return t.Time.After(u.Time) } -func (t Time) Before(u Time) bool { return t.Time.Before(u.Time) } -func (t Time) Equal(u Time) bool { return t.Time.Equal(u.Time) } -func (t Time) In(loc *time.Location) Time { return Time{Time: t.Time.In(loc)} } -func (t Time) Local() Time { return Time{Time: t.Time.Local()} } -func (t Time) Round(d time.Duration) Time { return Time{Time: t.Time.Round(d)} } -func (t Time) Sub(u Time) time.Duration { return t.Time.Sub(u.Time) } -func (t Time) Truncate(d time.Duration) Time { return Time{Time: t.Time.Truncate(d)} } -func (t Time) UTC() Time { return Time{Time: t.Time.UTC()} } diff --git a/pkg/helm/pkg/time/time_test.go b/pkg/helm/pkg/time/time_test.go deleted file mode 100644 index 20f0f8e2..00000000 --- a/pkg/helm/pkg/time/time_test.go +++ /dev/null @@ -1,83 +0,0 @@ -/* -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 time - -import ( - "encoding/json" - "testing" - "time" -) - -var ( - testingTime, _ = Parse(time.RFC3339, "1977-09-02T22:04:05Z") - testingTimeString = `"1977-09-02T22:04:05Z"` -) - -func TestNonZeroValueMarshal(t *testing.T) { - res, err := json.Marshal(testingTime) - if err != nil { - t.Fatal(err) - } - if testingTimeString != string(res) { - t.Errorf("expected a marshaled value of %s, got %s", testingTimeString, res) - } -} - -func TestZeroValueMarshal(t *testing.T) { - res, err := json.Marshal(Time{}) - if err != nil { - t.Fatal(err) - } - if string(res) != emptyString { - t.Errorf("expected zero value to marshal to empty string, got %s", res) - } -} - -func TestNonZeroValueUnmarshal(t *testing.T) { - var myTime Time - err := json.Unmarshal([]byte(testingTimeString), &myTime) - if err != nil { - t.Fatal(err) - } - if !myTime.Equal(testingTime) { - t.Errorf("expected time to be equal to %v, got %v", testingTime, myTime) - } -} - -func TestEmptyStringUnmarshal(t *testing.T) { - var myTime Time - err := json.Unmarshal([]byte(emptyString), &myTime) - if err != nil { - t.Fatal(err) - } - if !myTime.IsZero() { - t.Errorf("expected time to be equal to zero value, got %v", myTime) - } -} - -func TestZeroValueUnmarshal(t *testing.T) { - // This test ensures that we can unmarshal any time value that was output - // with the current go default value of "0001-01-01T00:00:00Z" - var myTime Time - err := json.Unmarshal([]byte(`"0001-01-01T00:00:00Z"`), &myTime) - if err != nil { - t.Fatal(err) - } - if !myTime.IsZero() { - t.Errorf("expected time to be equal to zero value, got %v", myTime) - } -} diff --git a/pkg/helm/pkg/uploader/chart_uploader.go b/pkg/helm/pkg/uploader/chart_uploader.go index 81191c87..927d5981 100644 --- a/pkg/helm/pkg/uploader/chart_uploader.go +++ b/pkg/helm/pkg/uploader/chart_uploader.go @@ -20,11 +20,8 @@ import ( "io" "net/url" - "github.com/pkg/errors" - "github.com/werf/nelm/pkg/helm/pkg/pusher" "github.com/werf/nelm/pkg/helm/pkg/registry" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" ) // ChartUploader handles uploading a chart. @@ -40,10 +37,10 @@ type ChartUploader struct { } // UploadTo uploads a chart. Depending on the settings, it may also upload a provenance file. -func (c *ChartUploader) UploadTo(ref, remote string, opts helmopts.HelmOptions) error { +func (c *ChartUploader) UploadTo(ref, remote string) error { u, err := url.Parse(remote) if err != nil { - return errors.Errorf("invalid chart URL format: %s", remote) + return fmt.Errorf("invalid chart URL format: %s", remote) } if u.Scheme == "" { @@ -55,5 +52,5 @@ func (c *ChartUploader) UploadTo(ref, remote string, opts helmopts.HelmOptions) return err } - return p.Push(ref, u.String(), opts, c.Options...) + return p.Push(ref, u.String(), c.Options...) } diff --git a/pkg/helm/pkg/werf/file/buffered_file.go b/pkg/helm/pkg/werf/file/buffered_file.go deleted file mode 100644 index 94bfdd7c..00000000 --- a/pkg/helm/pkg/werf/file/buffered_file.go +++ /dev/null @@ -1,6 +0,0 @@ -package file - -type ChartExtenderBufferedFile struct { - Name string - Data []byte -} diff --git a/pkg/helm/pkg/werf/file/chart_file_reader.go b/pkg/helm/pkg/werf/file/chart_file_reader.go deleted file mode 100644 index 47a9b6ac..00000000 --- a/pkg/helm/pkg/werf/file/chart_file_reader.go +++ /dev/null @@ -1,15 +0,0 @@ -package file - -import ( - "context" -) - -type ChartFileReaderInterface interface { - LocateChart(ctx context.Context, name string) (string, error) - ReadChartFile(ctx context.Context, filePath string) ([]byte, error) - LoadChartDir(ctx context.Context, dir string) ([]*ChartExtenderBufferedFile, error) - ChartIsDir(relPath string) (bool, error) -} - -// TODO(werf): keep it global, but separate package? Make non-giterminism default implementation -var ChartFileReader ChartFileReaderInterface diff --git a/pkg/helm/pkg/werf/file/chart_file_writer.go b/pkg/helm/pkg/werf/file/chart_file_writer.go deleted file mode 100644 index f55ef157..00000000 --- a/pkg/helm/pkg/werf/file/chart_file_writer.go +++ /dev/null @@ -1,12 +0,0 @@ -package file - -import ( - "context" -) - -type ChartFileWriterInterface interface { - WriteChartFile(ctx context.Context, filePath string, data []byte) error - CreateChartDir(ctx context.Context, dir string) error -} - -var ChartFileWriter ChartFileWriterInterface diff --git a/pkg/helm/pkg/werf/helmopts/helmoptions.go b/pkg/helm/pkg/werf/helmopts/helmoptions.go deleted file mode 100644 index 25872266..00000000 --- a/pkg/helm/pkg/werf/helmopts/helmoptions.go +++ /dev/null @@ -1,38 +0,0 @@ -package helmopts - -type HelmOptions struct { - ChartLoadOpts ChartLoadOptions -} - -type ChartLoadOptions struct { - ChartAppVersion string - ChartType ChartType - DefaultChartAPIVersion string - DefaultChartName string - DefaultChartVersion string - DefaultSecretValuesDisable bool - DefaultValuesDisable bool - DepDownloader DepDownloader - ExtraValues map[string]interface{} - NoSecrets bool - SecretKeyIgnore bool - SecretValuesFiles []string - SecretWorkDir string - DefaultRootContext map[string]interface{} -} - -type ChartType string - -const ( - ChartTypeChart ChartType = "" - ChartTypeBundle ChartType = "bundle" - ChartTypeSubchart ChartType = "subchart" - ChartTypeChartStub ChartType = "chartstub" -) - -type DepDownloader interface { - Build(opts HelmOptions) error - Update(opts HelmOptions) error - UpdateRepositories() error - SetChartPath(path string) -} diff --git a/pkg/helm/pkg/werf/secrets/chart_secrets_loader.go b/pkg/helm/pkg/werf/secrets/chart_secrets_loader.go deleted file mode 100644 index ff28e48b..00000000 --- a/pkg/helm/pkg/werf/secrets/chart_secrets_loader.go +++ /dev/null @@ -1,63 +0,0 @@ -package secrets - -import ( - "fmt" - "path/filepath" - "strings" - "unicode" - - "github.com/werf/common-go/pkg/secret" - "github.com/werf/common-go/pkg/util" - "github.com/werf/nelm/pkg/helm/pkg/werf/file" -) - -const ( - DefaultSecretValuesFileName = "secret-values.yaml" - SecretDirName = "secret" -) - -func GetDefaultSecretValuesFile(loadedChartFiles []*file.ChartExtenderBufferedFile) *file.ChartExtenderBufferedFile { - for _, file := range loadedChartFiles { - if file.Name == DefaultSecretValuesFileName { - return file - } - } - - return nil -} - -func GetSecretDirFiles(loadedChartFiles []*file.ChartExtenderBufferedFile) []*file.ChartExtenderBufferedFile { - var res []*file.ChartExtenderBufferedFile - - for _, file := range loadedChartFiles { - if !util.IsSubpathOfBasePath(SecretDirName, file.Name) { - continue - } - res = append(res, file) - } - - return res -} - -func LoadChartSecretDirFilesData( - secretFiles []*file.ChartExtenderBufferedFile, - encoder *secret.YamlEncoder, -) (map[string]string, error) { - res := make(map[string]string) - - for _, file := range secretFiles { - if !util.IsSubpathOfBasePath(SecretDirName, file.Name) { - continue - } - - decodedData, err := encoder.Decrypt([]byte(strings.TrimRightFunc(string(file.Data), unicode.IsSpace))) - if err != nil { - return nil, fmt.Errorf("error decoding %s: %w", file.Name, err) - } - - relPath := util.GetRelativeToBaseFilepath(SecretDirName, file.Name) - res[filepath.ToSlash(relPath)] = string(decodedData) - } - - return res, nil -} diff --git a/pkg/helm/pkg/werf/secrets/runtimedata/interface.go b/pkg/helm/pkg/werf/secrets/runtimedata/interface.go deleted file mode 100644 index bfbaffc5..00000000 --- a/pkg/helm/pkg/werf/secrets/runtimedata/interface.go +++ /dev/null @@ -1,23 +0,0 @@ -package runtimedata - -import ( - "context" - - "github.com/werf/common-go/pkg/secrets_manager" - "github.com/werf/nelm/pkg/helm/pkg/werf/file" -) - -type RuntimeData interface { - DecodeAndLoadSecrets(ctx context.Context, loadedChartFiles []*file.ChartExtenderBufferedFile, secretsManager *secrets_manager.SecretsManager, opts DecodeAndLoadSecretsOptions) error - GetEncodedSecretValues(ctx context.Context, secretsManager *secrets_manager.SecretsManager, secretsWorkingDir string, noDecryptSecrets bool) (map[string]interface{}, error) - GetDecryptedSecretValues() map[string]interface{} - GetDecryptedSecretFilesData() map[string]string -} - -type DecodeAndLoadSecretsOptions struct { - CustomSecretValueFiles []string - LoadFromLocalFilesystem bool - NoDecryptSecrets bool - SecretsWorkingDir string - WithoutDefaultSecretValues bool -} diff --git a/pkg/helm/pkg/werf/secrets/secrets_runtime_data.go b/pkg/helm/pkg/werf/secrets/secrets_runtime_data.go deleted file mode 100644 index d1d14f18..00000000 --- a/pkg/helm/pkg/werf/secrets/secrets_runtime_data.go +++ /dev/null @@ -1,161 +0,0 @@ -package secrets - -import ( - "context" - "fmt" - "io/ioutil" - - "sigs.k8s.io/yaml" - - "github.com/werf/common-go/pkg/secret" - "github.com/werf/common-go/pkg/secrets_manager" - werffile "github.com/werf/nelm/pkg/helm/pkg/werf/file" - "github.com/werf/nelm/pkg/helm/pkg/werf/secrets/runtimedata" -) - -var _ runtimedata.RuntimeData = (*SecretsRuntimeData)(nil) - -var CoalesceTablesFunc func(dst, src map[string]interface{}) map[string]interface{} - -type SecretsRuntimeData struct { - decryptedSecretValues map[string]interface{} - decryptedSecretFilesData map[string]string -} - -func NewSecretsRuntimeData() *SecretsRuntimeData { - return &SecretsRuntimeData{ - decryptedSecretFilesData: make(map[string]string), - } -} - -func (secretsRuntimeData *SecretsRuntimeData) DecodeAndLoadSecrets( - ctx context.Context, - loadedChartFiles []*werffile.ChartExtenderBufferedFile, - secretsManager *secrets_manager.SecretsManager, - opts runtimedata.DecodeAndLoadSecretsOptions, -) error { - secretDirFiles := GetSecretDirFiles(loadedChartFiles) - - var loadedSecretValuesFiles []*werffile.ChartExtenderBufferedFile - - if !opts.WithoutDefaultSecretValues { - if defaultSecretValues := GetDefaultSecretValuesFile(loadedChartFiles); defaultSecretValues != nil { - loadedSecretValuesFiles = append(loadedSecretValuesFiles, defaultSecretValues) - } - } - - for _, customSecretValuesFileName := range opts.CustomSecretValueFiles { - file := &werffile.ChartExtenderBufferedFile{Name: customSecretValuesFileName} - - if opts.LoadFromLocalFilesystem { - data, err := ioutil.ReadFile(customSecretValuesFileName) - if err != nil { - return fmt.Errorf("unable to read custom secret values file %q from local filesystem: %w", customSecretValuesFileName, err) - } - file.Data = data - } else { - data, err := werffile.ChartFileReader.ReadChartFile(ctx, customSecretValuesFileName) - if err != nil { - return fmt.Errorf("unable to read custom secret values file %q: %w", customSecretValuesFileName, err) - } - file.Data = data - } - - loadedSecretValuesFiles = append(loadedSecretValuesFiles, file) - } - - var encoder *secret.YamlEncoder - if len(secretDirFiles)+len(loadedSecretValuesFiles) > 0 { - if enc, err := secretsManager.GetYamlEncoder(ctx, opts.SecretsWorkingDir, opts.NoDecryptSecrets); err != nil { - return fmt.Errorf("error getting secrets yaml encoder: %w", err) - } else { - encoder = enc - } - } - - if len(secretDirFiles) > 0 { - if data, err := LoadChartSecretDirFilesData(secretDirFiles, encoder); err != nil { - return fmt.Errorf("error loading secret files data: %w", err) - } else { - secretsRuntimeData.decryptedSecretFilesData = data - } - } - - if len(loadedSecretValuesFiles) > 0 { - if values, err := LoadChartSecretValueFiles(loadedSecretValuesFiles, encoder); err != nil { - return fmt.Errorf("error loading secret value files: %w", err) - } else { - secretsRuntimeData.decryptedSecretValues = values - } - } - - return nil -} - -func (secretsRuntimeData *SecretsRuntimeData) GetEncodedSecretValues( - ctx context.Context, - secretsManager *secrets_manager.SecretsManager, - secretsWorkingDir string, - noDecryptSecrets bool, -) (map[string]interface{}, error) { - if len(secretsRuntimeData.decryptedSecretValues) == 0 { - return nil, nil - } - - // FIXME: secrets encoder should receive interface{} raw data instead of []byte yaml data - - var encoder *secret.YamlEncoder - if enc, err := secretsManager.GetYamlEncoder(ctx, secretsWorkingDir, noDecryptSecrets); err != nil { - return nil, fmt.Errorf("error getting secrets yaml encoder: %w", err) - } else { - encoder = enc - } - - decryptedSecretsData, err := yaml.Marshal(secretsRuntimeData.decryptedSecretValues) - if err != nil { - return nil, fmt.Errorf("unable to marshal decrypted secrets yaml: %w", err) - } - - encryptedSecretsData, err := encoder.EncryptYamlData(decryptedSecretsData) - if err != nil { - return nil, fmt.Errorf("unable to encrypt secrets data: %w", err) - } - - var encryptedData map[string]interface{} - if err := yaml.Unmarshal(encryptedSecretsData, &encryptedData); err != nil { - return nil, fmt.Errorf("unable to unmarshal encrypted secrets data: %w", err) - } - - return encryptedData, nil -} - -func (secretsRuntimeData *SecretsRuntimeData) GetDecryptedSecretValues() map[string]interface{} { - return secretsRuntimeData.decryptedSecretValues -} - -func (secretsRuntimeData *SecretsRuntimeData) GetDecryptedSecretFilesData() map[string]string { - return secretsRuntimeData.decryptedSecretFilesData -} - -func LoadChartSecretValueFiles( - secretDirFiles []*werffile.ChartExtenderBufferedFile, - encoder *secret.YamlEncoder, -) (map[string]interface{}, error) { - var res map[string]interface{} - - for _, file := range secretDirFiles { - decodedData, err := encoder.DecryptYamlData(file.Data) - if err != nil { - return nil, fmt.Errorf("cannot decode file %q secret data: %w", file.Name, err) - } - - rawValues := map[string]interface{}{} - if err := yaml.Unmarshal(decodedData, &rawValues); err != nil { - return nil, fmt.Errorf("cannot unmarshal secret values file %s: %w", file.Name, err) - } - - res = CoalesceTablesFunc(rawValues, res) - } - - return res, nil -} diff --git a/pkg/helm/testdata/localhost-crt.pem b/pkg/helm/testdata/localhost-crt.pem new file mode 100644 index 00000000..70fa0a42 --- /dev/null +++ b/pkg/helm/testdata/localhost-crt.pem @@ -0,0 +1,73 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: + 7f:5e:fa:21:fa:ee:e4:6a:be:9b:c2:80:bf:ed:42:f3:2d:47:f5:d2 + Signature Algorithm: sha256WithRSAEncryption + Issuer: C=US, ST=CO, L=Boulder, O=Helm, CN=helm.sh + Validity + Not Before: Nov 6 21:59:18 2023 GMT + Not After : Nov 3 21:59:18 2033 GMT + Subject: C=CA, ST=ON, L=Kitchener, O=Helm, CN=localhost + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + RSA Public-Key: (2048 bit) + Modulus: + 00:c8:89:55:0d:0b:f1:da:e6:c0:70:7d:d3:27:cd: + b8:a8:81:8b:7c:a4:89:e5:d1:b1:78:01:1d:df:44: + 88:0b:fc:d6:81:35:3d:d1:3b:5e:8f:bb:93:b3:7e: + 28:db:ed:ff:a0:13:3a:70:a3:fe:94:6b:0b:fe:fb: + 63:00:b0:cb:dc:81:cd:80:dc:d0:2f:bf:b2:4f:9a: + 81:d4:22:dc:97:c8:8f:27:86:59:91:fa:92:05:75: + c4:cc:6b:f5:a9:6b:74:1e:f5:db:a9:f8:bf:8c:a2: + 25:fd:a0:cc:79:f4:25:57:74:a9:23:9b:e2:b7:22: + 7a:14:7a:3d:ea:f1:7e:32:6b:57:6c:2e:c6:4f:75: + 54:f9:6b:54:d2:ca:eb:54:1c:af:39:15:9b:d0:7c: + 0f:f8:55:51:04:ea:da:fa:7b:8b:63:0f:ac:39:b1: + f6:4b:8e:4e:f6:ea:e9:7b:e6:ba:5e:5a:8e:91:ef: + dc:b1:7d:52:3f:73:83:52:46:83:48:49:ff:f2:2d: + ca:54:f2:36:bb:49:cc:59:99:c0:9e:cf:8e:78:55: + 6c:ed:7d:7e:83:b8:59:2c:7d:f8:1a:81:f0:7d:f5: + 27:f2:db:ae:d4:31:54:38:fe:47:b2:ee:16:20:0f: + f1:db:2d:28:bf:6f:38:eb:11:bb:9a:d4:b2:5a:3a: + 4a:7f + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Subject Alternative Name: + DNS:localhost + Signature Algorithm: sha256WithRSAEncryption + 47:47:fe:29:ca:94:28:75:59:ba:ab:67:ab:c6:a6:0b:0a:f2: + 0f:26:d9:1d:35:db:68:a5:d8:f5:1f:d1:87:e7:a7:74:fd:c0: + 22:aa:c8:ec:6c:d3:ac:8a:0b:ed:59:3a:a0:12:77:7c:53:74: + fd:30:59:34:8f:a4:ef:5b:98:3f:ff:cf:89:87:ed:d3:7f:41: + 2f:b1:9a:12:71:bb:fe:3a:cf:77:16:32:bc:83:90:cc:52:2f: + 3b:f4:ae:db:b1:bb:f0:dd:30:d4:03:17:5e:47:b7:06:86:7a: + 16:b1:72:2f:80:5d:d4:c0:f9:6c:91:df:5a:c5:15:86:66:68: + c8:90:8e:f1:a2:bb:40:0f:ef:26:1b:02:c4:42:de:8c:69:ec: + ad:27:d0:bc:da:7c:76:33:86:de:b7:c4:04:64:e6:f6:dc:44: + 89:7b:b8:2f:c7:28:7a:4c:a6:01:ad:a5:17:64:3a:23:da:aa: + db:ce:3f:86:e9:92:dc:0d:c4:5a:b4:52:a8:8a:ee:3d:62:7d: + b1:c8:fa:ef:96:2b:ab:f1:e1:6d:6f:7d:1e:ce:bc:7a:d0:92: + 02:1b:c8:55:36:77:bf:d4:42:d3:fc:57:ca:b7:cc:95:be:ce: + f8:6e:b2:28:ca:4d:9a:00:7d:78:c8:56:04:2e:b3:ac:03:fa: + 05:d8:42:bd +-----BEGIN CERTIFICATE----- +MIIDRDCCAiygAwIBAgIUf176Ifru5Gq+m8KAv+1C8y1H9dIwDQYJKoZIhvcNAQEL +BQAwTTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNPMRAwDgYDVQQHDAdCb3VsZGVy +MQ0wCwYDVQQKDARIZWxtMRAwDgYDVQQDDAdoZWxtLnNoMB4XDTIzMTEwNjIxNTkx +OFoXDTMzMTEwMzIxNTkxOFowUTELMAkGA1UEBhMCQ0ExCzAJBgNVBAgMAk9OMRIw +EAYDVQQHDAlLaXRjaGVuZXIxDTALBgNVBAoMBEhlbG0xEjAQBgNVBAMMCWxvY2Fs +aG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMiJVQ0L8drmwHB9 +0yfNuKiBi3ykieXRsXgBHd9EiAv81oE1PdE7Xo+7k7N+KNvt/6ATOnCj/pRrC/77 +YwCwy9yBzYDc0C+/sk+agdQi3JfIjyeGWZH6kgV1xMxr9alrdB7126n4v4yiJf2g +zHn0JVd0qSOb4rciehR6PerxfjJrV2wuxk91VPlrVNLK61QcrzkVm9B8D/hVUQTq +2vp7i2MPrDmx9kuOTvbq6Xvmul5ajpHv3LF9Uj9zg1JGg0hJ//ItylTyNrtJzFmZ +wJ7PjnhVbO19foO4WSx9+BqB8H31J/LbrtQxVDj+R7LuFiAP8dstKL9vOOsRu5rU +slo6Sn8CAwEAAaMYMBYwFAYDVR0RBA0wC4IJbG9jYWxob3N0MA0GCSqGSIb3DQEB +CwUAA4IBAQBHR/4pypQodVm6q2erxqYLCvIPJtkdNdtopdj1H9GH56d0/cAiqsjs +bNOsigvtWTqgEnd8U3T9MFk0j6TvW5g//8+Jh+3Tf0EvsZoScbv+Os93FjK8g5DM +Ui879K7bsbvw3TDUAxdeR7cGhnoWsXIvgF3UwPlskd9axRWGZmjIkI7xortAD+8m +GwLEQt6MaeytJ9C82nx2M4bet8QEZOb23ESJe7gvxyh6TKYBraUXZDoj2qrbzj+G +6ZLcDcRatFKoiu49Yn2xyPrvliur8eFtb30ezrx60JICG8hVNne/1ELT/FfKt8yV +vs74brIoyk2aAH14yFYELrOsA/oF2EK9 +-----END CERTIFICATE----- diff --git a/pkg/helm/testdata/openssl.conf b/pkg/helm/testdata/openssl.conf index 9b27e445..be5ff04b 100644 --- a/pkg/helm/testdata/openssl.conf +++ b/pkg/helm/testdata/openssl.conf @@ -40,3 +40,7 @@ subjectAltName = @alternate_names [alternate_names] DNS.1 = helm.sh IP.1 = 127.0.0.1 + +# # Used to generate localhost-crt.pem +# [alternate_names] +# DNS.1 = localhost diff --git a/pkg/helm/testdata/releases.yaml b/pkg/helm/testdata/releases.yaml index 451af395..e960e815 100644 --- a/pkg/helm/testdata/releases.yaml +++ b/pkg/helm/testdata/releases.yaml @@ -40,3 +40,4 @@ name: dartagnan-chart version: 0.4.4 appversion: 4.4.4 + diff --git a/pkg/kube/config.go b/pkg/kube/config.go index 88737050..7eacc763 100644 --- a/pkg/kube/config.go +++ b/pkg/kube/config.go @@ -72,10 +72,15 @@ func NewKubeConfig(ctx context.Context, opts KubeConfigOptions) (*KubeConfig, er clientConfig = clientcmd.NewDefaultClientConfig(*config, overrides) } else { - loadingRules := &clientcmd.ClientConfigLoadingRules{ - Precedence: opts.KubeConfigPaths, - MigrationRules: clientcmd.NewDefaultClientConfigLoadingRules().MigrationRules, - DefaultClientConfig: &clientcmd.DefaultClientConfig, + var loadingRules *clientcmd.ClientConfigLoadingRules + if len(opts.KubeConfigPaths) > 0 { + loadingRules = &clientcmd.ClientConfigLoadingRules{ + Precedence: opts.KubeConfigPaths, + MigrationRules: clientcmd.NewDefaultClientConfigLoadingRules().MigrationRules, + DefaultClientConfig: &clientcmd.DefaultClientConfig, + } + } else { + loadingRules = clientcmd.NewDefaultClientConfigLoadingRules() } clientConfig = clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, overrides) diff --git a/pkg/legacy/secret/chart_secrets_loader.go b/pkg/legacy/secret/chart_secrets_loader.go new file mode 100644 index 00000000..f9f80cae --- /dev/null +++ b/pkg/legacy/secret/chart_secrets_loader.go @@ -0,0 +1,60 @@ +package secret + +import ( + "fmt" + "path/filepath" + "strings" + "unicode" + + "github.com/werf/common-go/pkg/secret" + "github.com/werf/common-go/pkg/util" + "github.com/werf/nelm/pkg/common" +) + +const ( + DefaultSecretValuesFileName = "secret-values.yaml" + SecretDirName = "secret" +) + +func GetDefaultSecretValuesFile(loadedChartFiles []*common.BufferedFile) *common.BufferedFile { + for _, f := range loadedChartFiles { + if f.Name == DefaultSecretValuesFileName { + return f + } + } + + return nil +} + +func GetSecretDirFiles(loadedChartFiles []*common.BufferedFile) []*common.BufferedFile { + var res []*common.BufferedFile + + for _, f := range loadedChartFiles { + if !util.IsSubpathOfBasePath(SecretDirName, f.Name) { + continue + } + res = append(res, f) + } + + return res +} + +func LoadChartSecretDirFilesData(secretFiles []*common.BufferedFile, encoder *secret.YamlEncoder) (map[string]string, error) { + res := make(map[string]string) + + for _, f := range secretFiles { + if !util.IsSubpathOfBasePath(SecretDirName, f.Name) { + continue + } + + decodedData, err := encoder.Decrypt([]byte(strings.TrimRightFunc(string(f.Data), unicode.IsSpace))) + if err != nil { + return nil, fmt.Errorf("decoding %s: %w", f.Name, err) + } + + relPath := util.GetRelativeToBaseFilepath(SecretDirName, f.Name) + res[filepath.ToSlash(relPath)] = string(decodedData) + } + + return res, nil +} diff --git a/pkg/legacy/secret/runtime_data.go b/pkg/legacy/secret/runtime_data.go new file mode 100644 index 00000000..d5062fac --- /dev/null +++ b/pkg/legacy/secret/runtime_data.go @@ -0,0 +1,151 @@ +package secret + +import ( + "context" + "fmt" + "os" + + "sigs.k8s.io/yaml" + + "github.com/werf/common-go/pkg/secret" + "github.com/werf/common-go/pkg/secrets_manager" + "github.com/werf/nelm/pkg/common" + chartcommon "github.com/werf/nelm/pkg/helm/pkg/chart/common" +) + +var _ RuntimeData = (*SecretsRuntimeData)(nil) + +type DecodeAndLoadSecretsOptions = chartcommon.DecodeAndLoadSecretsOptions + +type RuntimeData interface { + DecodeAndLoadSecrets(ctx context.Context, loadedChartFiles []*common.BufferedFile, secretsManager *secrets_manager.SecretsManager, opts DecodeAndLoadSecretsOptions) error + GetEncodedSecretValues(ctx context.Context, secretsManager *secrets_manager.SecretsManager, secretsWorkingDir string, noDecryptSecrets bool) (map[string]interface{}, error) + GetDecryptedSecretValues() map[string]interface{} + GetDecryptedSecretFilesData() map[string]string +} + +type SecretsRuntimeData struct { + decryptedSecretFilesData map[string]string + decryptedSecretValues map[string]interface{} +} + +func NewSecretsRuntimeData() *SecretsRuntimeData { + return &SecretsRuntimeData{ + decryptedSecretFilesData: make(map[string]string), + } +} + +func (s *SecretsRuntimeData) DecodeAndLoadSecrets(ctx context.Context, loadedChartFiles []*common.BufferedFile, secretsManager *secrets_manager.SecretsManager, opts DecodeAndLoadSecretsOptions) error { + secretDirFiles := GetSecretDirFiles(loadedChartFiles) + + var loadedSecretValuesFiles []*common.BufferedFile + + if !opts.WithoutDefaultSecretValues { + if defaultSecretValues := GetDefaultSecretValuesFile(loadedChartFiles); defaultSecretValues != nil { + loadedSecretValuesFiles = append(loadedSecretValuesFiles, defaultSecretValues) + } + } + + for _, customSecretValuesFileName := range opts.CustomSecretValueFiles { + f := &common.BufferedFile{Name: customSecretValuesFileName} + + if opts.LoadFromLocalFilesystem { + data, err := os.ReadFile(customSecretValuesFileName) + if err != nil { + return fmt.Errorf("read custom secret values file %q from local filesystem: %w", customSecretValuesFileName, err) + } + f.Data = data + } else { + data, err := common.ChartFileReader.ReadChartFile(ctx, customSecretValuesFileName) + if err != nil { + return fmt.Errorf("read custom secret values file %q: %w", customSecretValuesFileName, err) + } + f.Data = data + } + + loadedSecretValuesFiles = append(loadedSecretValuesFiles, f) + } + + var encoder *secret.YamlEncoder + if len(secretDirFiles)+len(loadedSecretValuesFiles) > 0 { + enc, err := secretsManager.GetYamlEncoder(ctx, opts.SecretsWorkingDir, opts.NoDecryptSecrets) + if err != nil { + return fmt.Errorf("get secrets yaml encoder: %w", err) + } + encoder = enc + } + + if len(secretDirFiles) > 0 { + data, err := LoadChartSecretDirFilesData(secretDirFiles, encoder) + if err != nil { + return fmt.Errorf("load secret files data: %w", err) + } + s.decryptedSecretFilesData = data + } + + if len(loadedSecretValuesFiles) > 0 { + values, err := LoadChartSecretValueFiles(loadedSecretValuesFiles, encoder) + if err != nil { + return fmt.Errorf("load secret value files: %w", err) + } + s.decryptedSecretValues = values + } + + return nil +} + +func (s *SecretsRuntimeData) GetDecryptedSecretFilesData() map[string]string { + return s.decryptedSecretFilesData +} + +func (s *SecretsRuntimeData) GetDecryptedSecretValues() map[string]interface{} { + return s.decryptedSecretValues +} + +func (s *SecretsRuntimeData) GetEncodedSecretValues(ctx context.Context, secretsManager *secrets_manager.SecretsManager, secretsWorkingDir string, noDecryptSecrets bool) (map[string]interface{}, error) { + if len(s.decryptedSecretValues) == 0 { + return nil, nil + } + + enc, err := secretsManager.GetYamlEncoder(ctx, secretsWorkingDir, noDecryptSecrets) + if err != nil { + return nil, fmt.Errorf("get secrets yaml encoder: %w", err) + } + + decryptedSecretsData, err := yaml.Marshal(s.decryptedSecretValues) + if err != nil { + return nil, fmt.Errorf("marshal decrypted secrets yaml: %w", err) + } + + encryptedSecretsData, err := enc.EncryptYamlData(decryptedSecretsData) + if err != nil { + return nil, fmt.Errorf("encrypt secrets data: %w", err) + } + + var encryptedData map[string]interface{} + if err := yaml.Unmarshal(encryptedSecretsData, &encryptedData); err != nil { + return nil, fmt.Errorf("unmarshal encrypted secrets data: %w", err) + } + + return encryptedData, nil +} + +func LoadChartSecretValueFiles(secretDirFiles []*common.BufferedFile, encoder *secret.YamlEncoder) (map[string]interface{}, error) { + var res map[string]interface{} + + for _, f := range secretDirFiles { + decodedData, err := encoder.DecryptYamlData(f.Data) + if err != nil { + return nil, fmt.Errorf("decode file %q secret data: %w", f.Name, err) + } + + rawValues := map[string]interface{}{} + if err := yaml.Unmarshal(decodedData, &rawValues); err != nil { + return nil, fmt.Errorf("unmarshal secret values file %s: %w", f.Name, err) + } + + res = common.LegacyCoalesceTablesFunc(rawValues, res) + } + + return res, nil +} diff --git a/pkg/helm/pkg/werf/secrets/gotmplfunctions/go_tmpl_functions.go b/pkg/legacy/secret/template_funcs.go similarity index 65% rename from pkg/helm/pkg/werf/secrets/gotmplfunctions/go_tmpl_functions.go rename to pkg/legacy/secret/template_funcs.go index 3e0b278a..de1d8653 100644 --- a/pkg/helm/pkg/werf/secrets/gotmplfunctions/go_tmpl_functions.go +++ b/pkg/legacy/secret/template_funcs.go @@ -1,15 +1,24 @@ -package gotmplfunctions +package secret import ( "fmt" "path" "strings" "text/template" - - "github.com/werf/nelm/pkg/helm/pkg/werf/secrets/runtimedata" ) -func SetupWerfSecretFile(secretsRuntimeData runtimedata.RuntimeData, funcMap template.FuncMap) { +type SecretFilesRuntimeData interface { + GetDecryptedSecretFilesData() map[string]string +} + +func FuncMap(secretsRuntimeData SecretFilesRuntimeData) template.FuncMap { + funcMap := template.FuncMap{} + SetupWerfSecretFile(secretsRuntimeData, funcMap) + + return funcMap +} + +func SetupWerfSecretFile(secretsRuntimeData SecretFilesRuntimeData, funcMap template.FuncMap) { funcMap["werf_secret_file"] = func(secretRelativePath string) (string, error) { if path.IsAbs(secretRelativePath) { return "", fmt.Errorf("expected relative secret file path, given path %v", secretRelativePath) diff --git a/pkg/log/common.go b/pkg/log/common.go new file mode 100644 index 00000000..ae9264f2 --- /dev/null +++ b/pkg/log/common.go @@ -0,0 +1,3 @@ +package log + +var Default Logger = NewLogboekLogger() diff --git a/pkg/plan/operation_config.go b/pkg/plan/operation_config.go index e655fa05..c94b59bd 100644 --- a/pkg/plan/operation_config.go +++ b/pkg/plan/operation_config.go @@ -1,13 +1,14 @@ package plan import ( + "fmt" "regexp" "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/werf/kubedog/pkg/dyntracker/statestore" - helmrelease "github.com/werf/nelm/pkg/helm/pkg/release" + helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" "github.com/werf/nelm/pkg/resource/spec" ) @@ -165,11 +166,11 @@ type OperationConfigCreateRelease struct { } func (c *OperationConfigCreateRelease) ID() string { - return c.Release.ID() + return releaseID(c.Release.Namespace, c.Release.Name, c.Release.Version) } func (c *OperationConfigCreateRelease) IDHuman() string { - return c.Release.IDHuman() + return releaseIDHuman(c.Release.Namespace, c.Release.Name, c.Release.Version) } type OperationConfigUpdateRelease struct { @@ -177,11 +178,11 @@ type OperationConfigUpdateRelease struct { } func (c *OperationConfigUpdateRelease) ID() string { - return c.Release.ID() + return releaseID(c.Release.Namespace, c.Release.Name, c.Release.Version) } func (c *OperationConfigUpdateRelease) IDHuman() string { - return c.Release.IDHuman() + return releaseIDHuman(c.Release.Namespace, c.Release.Name, c.Release.Version) } type OperationConfigDeleteRelease struct { @@ -191,9 +192,17 @@ type OperationConfigDeleteRelease struct { } func (c *OperationConfigDeleteRelease) ID() string { - return helmrelease.ReleaseID(c.ReleaseNamespace, c.ReleaseName, c.ReleaseRevision) + return releaseID(c.ReleaseNamespace, c.ReleaseName, c.ReleaseRevision) } func (c *OperationConfigDeleteRelease) IDHuman() string { - return helmrelease.ReleaseIDHuman(c.ReleaseNamespace, c.ReleaseName, c.ReleaseRevision) + return releaseIDHuman(c.ReleaseNamespace, c.ReleaseName, c.ReleaseRevision) +} + +func releaseID(namespace, name string, revision int) string { + return fmt.Sprintf("%s:%s:%d", namespace, name, revision) +} + +func releaseIDHuman(namespace, name string, revision int) string { + return fmt.Sprintf("%s/%d (namespace=%s)", name, revision, namespace) } diff --git a/pkg/plan/plan_artifact.go b/pkg/plan/plan_artifact.go index 367a8b6b..86352122 100644 --- a/pkg/plan/plan_artifact.go +++ b/pkg/plan/plan_artifact.go @@ -13,7 +13,7 @@ import ( "github.com/werf/common-go/pkg/secrets_manager" "github.com/werf/nelm/pkg/common" - "github.com/werf/nelm/pkg/helm/pkg/release" + helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" "github.com/werf/nelm/pkg/log" ) @@ -34,7 +34,7 @@ type PlanArtifactData struct { Options common.ReleaseInstallRuntimeOptions `json:"options"` Changes []*ResourceChange `json:"changes"` Plan *Plan `json:"plan"` - Release *release.Release `json:"release"` + Release *helmrelease.Release `json:"release"` InstallableResourceInfos []*InstallableResourceInfo `json:"installableResourceInfos"` ReleaseInfos []*ReleaseInfo `json:"releaseInfos"` } diff --git a/pkg/plan/plan_build.go b/pkg/plan/plan_build.go index 89e6749a..5773fefe 100644 --- a/pkg/plan/plan_build.go +++ b/pkg/plan/plan_build.go @@ -8,7 +8,8 @@ import ( "github.com/samber/lo" "github.com/werf/nelm/pkg/common" - helmrelease "github.com/werf/nelm/pkg/helm/pkg/release" + helmreleasecommon "github.com/werf/nelm/pkg/helm/pkg/release/common" + helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" "github.com/werf/nelm/pkg/resource" ) @@ -146,9 +147,13 @@ func addFailureReleaseOperations(failedPlan, plan *Plan, releaseInfos []*Release switch config := op.Config.(type) { case *OperationConfigCreateRelease: - return config.Release.ID() == info.Release.ID() + return config.Release.Namespace == info.Release.Namespace && + config.Release.Name == info.Release.Name && + config.Release.Version == info.Release.Version case *OperationConfigUpdateRelease: - return config.Release.ID() == info.Release.ID() + return config.Release.Namespace == info.Release.Namespace && + config.Release.Name == info.Release.Name && + config.Release.Version == info.Release.Version default: return false } @@ -168,15 +173,15 @@ func addReleaseOperations(plan *Plan, releaseInfos []*ReleaseInfo) error { for _, info := range releaseInfos { switch info.Must { case ReleaseTypeInstall: - if err := addPendingAndDeployedReleaseOps(plan, info, helmrelease.StatusPendingInstall); err != nil { + if err := addPendingAndDeployedReleaseOps(plan, info, helmreleasecommon.StatusPendingInstall); err != nil { return fmt.Errorf("add pending/deployed ops for release install: %w", err) } case ReleaseTypeUpgrade: - if err := addPendingAndDeployedReleaseOps(plan, info, helmrelease.StatusPendingUpgrade); err != nil { + if err := addPendingAndDeployedReleaseOps(plan, info, helmreleasecommon.StatusPendingUpgrade); err != nil { return fmt.Errorf("add pending/deployed ops for release upgrade: %w", err) } case ReleaseTypeRollback: - if err := addPendingAndDeployedReleaseOps(plan, info, helmrelease.StatusPendingRollback); err != nil { + if err := addPendingAndDeployedReleaseOps(plan, info, helmreleasecommon.StatusPendingRollback); err != nil { return fmt.Errorf("add pending/deployed ops for release rollback: %w", err) } case ReleaseTypeSupersede: @@ -311,7 +316,7 @@ func addFailedReleaseOps(plan *Plan, info *ReleaseInfo) error { failedRel = rel.(*helmrelease.Release) } - failedRel.Info.Status = helmrelease.StatusFailed + failedRel.Info.Status = helmreleasecommon.StatusFailed failedOp := &Operation{ Type: OperationTypeUpdateRelease, @@ -551,7 +556,7 @@ func addMainStages(plan *Plan) error { return nil } -func addPendingAndDeployedReleaseOps(plan *Plan, info *ReleaseInfo, pendingStatus helmrelease.Status) error { +func addPendingAndDeployedReleaseOps(plan *Plan, info *ReleaseInfo, pendingStatus helmreleasecommon.Status) error { var pendingRel *helmrelease.Release if rel, err := copystructure.Copy(info.Release); err != nil { return fmt.Errorf("deep copy release: %w", err) @@ -578,7 +583,7 @@ func addPendingAndDeployedReleaseOps(plan *Plan, info *ReleaseInfo, pendingStatu succeededRel = rel.(*helmrelease.Release) } - succeededRel.Info.Status = helmrelease.StatusDeployed + succeededRel.Info.Status = helmreleasecommon.StatusDeployed succeededOp := &Operation{ Type: OperationTypeUpdateRelease, @@ -601,7 +606,7 @@ func addSupersedeReleaseOps(plan *Plan, info *ReleaseInfo) error { supersededRel = rel.(*helmrelease.Release) } - supersededRel.Info.Status = helmrelease.StatusSuperseded + supersededRel.Info.Status = helmreleasecommon.StatusSuperseded supersedeOp := &Operation{ Type: OperationTypeUpdateRelease, @@ -624,7 +629,7 @@ func addUninstallReleaseOps(plan *Plan, info *ReleaseInfo) error { uninstallingRel = rel.(*helmrelease.Release) } - uninstallingRel.Info.Status = helmrelease.StatusUninstalling + uninstallingRel.Info.Status = helmreleasecommon.StatusUninstalling uninstallingOp := &Operation{ Type: OperationTypeUpdateRelease, diff --git a/pkg/plan/plan_build_test.go b/pkg/plan/plan_build_test.go index 6584a55d..fc8fe24d 100644 --- a/pkg/plan/plan_build_test.go +++ b/pkg/plan/plan_build_test.go @@ -12,7 +12,8 @@ import ( "github.com/stretchr/testify/suite" "github.com/werf/nelm/pkg/common" - helmrelease "github.com/werf/nelm/pkg/helm/pkg/release" + helmreleasestatus "github.com/werf/nelm/pkg/helm/pkg/release/common" + helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" "github.com/werf/nelm/pkg/plan" "github.com/werf/nelm/pkg/resource" "github.com/werf/nelm/pkg/resource/spec" @@ -229,7 +230,7 @@ func (s *BuildPlanSuite) TestBuildPlan() { s.Require().NoError(err) updatedRel := updatedRelRaw.(*helmrelease.Release) - updatedRel.Info.Status = helmrelease.StatusDeployed + updatedRel.Info.Status = helmreleasestatus.StatusDeployed updateReleaseOp := &plan.Operation{ Type: plan.OperationTypeUpdateRelease, @@ -1415,7 +1416,7 @@ func defaultRelease(releaseName, releaseNamespace string) *helmrelease.Release { Name: releaseName, Namespace: releaseNamespace, Info: &helmrelease.Info{ - Status: helmrelease.StatusPendingInstall, + Status: helmreleasestatus.StatusPendingInstall, }, Version: 1, } diff --git a/pkg/plan/release_info.go b/pkg/plan/release_info.go index 01a4ed96..27f35055 100644 --- a/pkg/plan/release_info.go +++ b/pkg/plan/release_info.go @@ -4,7 +4,8 @@ import ( "context" "github.com/werf/nelm/pkg/common" - helmrelease "github.com/werf/nelm/pkg/helm/pkg/release" + helmreleasecommon "github.com/werf/nelm/pkg/helm/pkg/release/common" + helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) const ( @@ -50,7 +51,7 @@ func BuildReleaseInfos(ctx context.Context, deployType common.DeployType, prevRe }) for _, rel := range prevReleases { - if rel.Info.Status == helmrelease.StatusDeployed { + if rel.Info.Status == helmreleasecommon.StatusDeployed { infos = append(infos, &ReleaseInfo{ Must: ReleaseTypeSupersede, Release: rel, @@ -65,7 +66,7 @@ func BuildReleaseInfos(ctx context.Context, deployType common.DeployType, prevRe }) for _, rel := range prevReleases { - if rel.Info.Status == helmrelease.StatusDeployed { + if rel.Info.Status == helmreleasecommon.StatusDeployed { infos = append(infos, &ReleaseInfo{ Must: ReleaseTypeSupersede, Release: rel, @@ -80,7 +81,7 @@ func BuildReleaseInfos(ctx context.Context, deployType common.DeployType, prevRe }) for _, rel := range prevReleases { - if rel.Info.Status == helmrelease.StatusDeployed { + if rel.Info.Status == helmreleasecommon.StatusDeployed { infos = append(infos, &ReleaseInfo{ Must: ReleaseTypeSupersede, Release: rel, diff --git a/pkg/release/history.go b/pkg/release/history.go index 39e587f0..67a7517b 100644 --- a/pkg/release/history.go +++ b/pkg/release/history.go @@ -2,16 +2,18 @@ package release import ( "context" + "errors" "fmt" "slices" "sync" + "time" "github.com/samber/lo" - helmrelease "github.com/werf/nelm/pkg/helm/pkg/release" - "github.com/werf/nelm/pkg/helm/pkg/releaseutil" + helmreleasecommon "github.com/werf/nelm/pkg/helm/pkg/release/common" + helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" + releaseutil "github.com/werf/nelm/pkg/helm/pkg/release/v1/util" "github.com/werf/nelm/pkg/helm/pkg/storage/driver" - helmtime "github.com/werf/nelm/pkg/helm/pkg/time" ) var _ Historier = (*History)(nil) @@ -47,7 +49,7 @@ func (h *History) CreateRelease(ctx context.Context, rel *helmrelease.Release) e h.updateLock.Lock() defer h.updateLock.Unlock() - rel.Info.FirstDeployed = helmtime.Now() + rel.Info.FirstDeployed = time.Now() rel.Info.LastDeployed = rel.Info.FirstDeployed if err := h.storage.Create(rel); err != nil { @@ -81,8 +83,8 @@ func (h *History) DeleteRelease(ctx context.Context, name string, revision int) func (h *History) FindAllDeployed() []*helmrelease.Release { _, lastUninstalledRelIndex, lastUninstalledRelFound := lo.FindLastIndexOf(h.releases, func(r *helmrelease.Release) bool { - return r.Info.Status == helmrelease.StatusUninstalled || - r.Info.Status == helmrelease.StatusUninstalling + return r.Info.Status == helmreleasecommon.StatusUninstalled || + r.Info.Status == helmreleasecommon.StatusUninstalling }) var relsSinceUninstalled []*helmrelease.Release @@ -97,8 +99,8 @@ func (h *History) FindAllDeployed() []*helmrelease.Release { } return lo.Filter(relsSinceUninstalled, func(r *helmrelease.Release, _ int) bool { - return r.Info.Status == helmrelease.StatusDeployed || - r.Info.Status == helmrelease.StatusSuperseded + return r.Info.Status == helmreleasecommon.StatusDeployed || + r.Info.Status == helmreleasecommon.StatusSuperseded }) } @@ -116,7 +118,7 @@ func (h *History) UpdateRelease(ctx context.Context, rel *helmrelease.Release) e h.updateLock.Lock() defer h.updateLock.Unlock() - rel.Info.FirstDeployed = helmtime.Now() + rel.Info.FirstDeployed = time.Now() rel.Info.LastDeployed = rel.Info.FirstDeployed if err := h.storage.Update(rel); err != nil { @@ -139,7 +141,7 @@ type HistoryOptions struct{} // Builds histories for multiple different releases. func BuildHistories(historyStorage ReleaseStorager, opts HistoryOptions) ([]*History, error) { rels, err := historyStorage.Query(map[string]string{"owner": "helm"}) - if err != nil && err != driver.ErrReleaseNotFound { + if err != nil && !errors.Is(err, driver.ErrReleaseNotFound) { return nil, fmt.Errorf("query releases: %w", err) } @@ -176,7 +178,7 @@ func BuildHistories(historyStorage ReleaseStorager, opts HistoryOptions) ([]*His // Builds history for a specific release. func BuildHistory(releaseName string, historyStorage ReleaseStorager, opts HistoryOptions) (*History, error) { rels, err := historyStorage.Query(map[string]string{"name": releaseName, "owner": "helm"}) - if err != nil && err != driver.ErrReleaseNotFound { + if err != nil && !errors.Is(err, driver.ErrReleaseNotFound) { return nil, fmt.Errorf("query releases for release %q: %w", releaseName, err) } diff --git a/pkg/release/release.go b/pkg/release/release.go index 17eb27cb..2bf6b568 100644 --- a/pkg/release/release.go +++ b/pkg/release/release.go @@ -15,11 +15,13 @@ import ( "sigs.k8s.io/yaml" "github.com/werf/nelm/pkg/common" - helmchart "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chartutil" - helmrelease "github.com/werf/nelm/pkg/helm/pkg/release" - "github.com/werf/nelm/pkg/helm/pkg/releaseutil" + helmchart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + chartv2util "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" + helmreleasecommon "github.com/werf/nelm/pkg/helm/pkg/release/common" + helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" + releaseutil "github.com/werf/nelm/pkg/helm/pkg/release/v1/util" "github.com/werf/nelm/pkg/resource/spec" + "github.com/werf/nelm/pkg/util" ) type ReleaseOptions struct { @@ -39,7 +41,7 @@ func IsReleaseUpToDate(oldRel, newRel *helmrelease.Release) (bool, error) { cmpopts.EquateEmpty(), } - if oldRel.Info.Status != helmrelease.StatusDeployed || + if oldRel.Info.Status != helmreleasecommon.StatusDeployed || oldRel.Info.Notes != newRel.Info.Notes || !cmp.Equal(oldRel.Config, newRel.Config, cmpOpts) { return false, nil @@ -77,7 +79,7 @@ func IsReleaseUpToDate(oldRel, newRel *helmrelease.Release) (bool, error) { return false, nil } - oldRelManifests := releaseutil.SplitManifestsToSlice(oldRel.Manifest) + oldRelManifests := util.SplitManifests(oldRel.Manifest) oldRegularResourcesHash := fnv.New32a() for _, manifest := range oldRelManifests { @@ -93,7 +95,7 @@ func IsReleaseUpToDate(oldRel, newRel *helmrelease.Release) (bool, error) { } } - newRelManifests := releaseutil.SplitManifestsToSlice(newRel.Manifest) + newRelManifests := util.SplitManifests(newRel.Manifest) newRegularResourcesHash := fnv.New32a() for _, manifest := range newRelManifests { @@ -118,21 +120,21 @@ func IsReleaseUpToDate(oldRel, newRel *helmrelease.Release) (bool, error) { // Construct Helm release. func NewRelease(name, namespace string, revision int, deployType common.DeployType, resources []*spec.ResourceSpec, chart *helmchart.Chart, releaseConfig map[string]interface{}, opts ReleaseOptions) (*helmrelease.Release, error) { - if err := chartutil.ValidateReleaseName(name); err != nil { + if err := chartv2util.ValidateReleaseName(name); err != nil { return nil, fmt.Errorf("release name %q is not valid: %w", name, err) } - var status helmrelease.Status + var status helmreleasecommon.Status switch deployType { case common.DeployTypeInitial, common.DeployTypeInstall: - status = helmrelease.StatusPendingInstall + status = helmreleasecommon.StatusPendingInstall case common.DeployTypeUpgrade: - status = helmrelease.StatusPendingUpgrade + status = helmreleasecommon.StatusPendingUpgrade case common.DeployTypeRollback: - status = helmrelease.StatusPendingRollback + status = helmreleasecommon.StatusPendingRollback case common.DeployTypeUninstall: - status = helmrelease.StatusUninstalling + status = helmreleasecommon.StatusUninstalling default: panic("unexpected deploy type") } @@ -203,7 +205,7 @@ func NewRelease(name, namespace string, revision int, deployType common.DeployTy // Constructs ResourceSpecs from a Release object. func ReleaseToResourceSpecs(rel *helmrelease.Release, releaseNamespace string, noCleanNullFields bool /* TODO(major): get rid */) ([]*spec.ResourceSpec, error) { var resources []*spec.ResourceSpec - for _, manifest := range releaseutil.SplitManifestsToSlice(rel.UnstoredManifest) { + for _, manifest := range util.SplitManifests(rel.UnstoredManifest) { if res, err := spec.NewResourceSpecFromManifest(manifest, releaseNamespace, spec.ResourceSpecOptions{ StoreAs: common.StoreAsNone, LegacyNoCleanNullFields: noCleanNullFields, @@ -214,7 +216,7 @@ func ReleaseToResourceSpecs(rel *helmrelease.Release, releaseNamespace string, n } } - for _, manifest := range releaseutil.SplitManifestsToSlice(rel.Manifest) { + for _, manifest := range util.SplitManifests(rel.Manifest) { if res, err := spec.NewResourceSpecFromManifest(manifest, releaseNamespace, spec.ResourceSpecOptions{ StoreAs: common.StoreAsRegular, LegacyNoCleanNullFields: noCleanNullFields, diff --git a/pkg/release/release_storage.go b/pkg/release/release_storage.go index 648871cb..9c2ca005 100644 --- a/pkg/release/release_storage.go +++ b/pkg/release/release_storage.go @@ -7,17 +7,14 @@ import ( "k8s.io/client-go/kubernetes" "github.com/werf/nelm/pkg/common" - helmaction "github.com/werf/nelm/pkg/helm/pkg/action" - helmrelease "github.com/werf/nelm/pkg/helm/pkg/release" + helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" helmstorage "github.com/werf/nelm/pkg/helm/pkg/storage" helmdriver "github.com/werf/nelm/pkg/helm/pkg/storage/driver" "github.com/werf/nelm/pkg/kube" - "github.com/werf/nelm/pkg/log" ) -var _ ReleaseStorager = (*helmstorage.Storage)(nil) +var _ ReleaseStorager = (*storageAdapter)(nil) -// Minimal interface for Helm storage drivers. type ReleaseStorager interface { Create(rls *helmrelease.Release) error Update(rls *helmrelease.Release) error @@ -25,51 +22,96 @@ type ReleaseStorager interface { Query(labels map[string]string) ([]*helmrelease.Release, error) } +type storageAdapter struct { + storage *helmstorage.Storage +} + +func (a *storageAdapter) Create(rls *helmrelease.Release) error { + if err := a.storage.Create(rls); err != nil { + return fmt.Errorf("create release: %w", err) + } + + return nil +} + +func (a *storageAdapter) Delete(name string, version int) (*helmrelease.Release, error) { + rel, err := a.storage.Delete(name, version) + if err != nil { + return nil, fmt.Errorf("delete release: %w", err) + } + + r, ok := rel.(*helmrelease.Release) + if !ok { + return nil, fmt.Errorf("unexpected release type: %T", rel) + } + + return r, nil +} + +func (a *storageAdapter) Query(labels map[string]string) ([]*helmrelease.Release, error) { + releasers, err := a.storage.Query(labels) + if err != nil { + return nil, fmt.Errorf("query releases: %w", err) + } + + result := make([]*helmrelease.Release, 0, len(releasers)) + for _, rel := range releasers { + r, ok := rel.(*helmrelease.Release) + if !ok { + return nil, fmt.Errorf("unexpected release type: %T", rel) + } + + result = append(result, r) + } + + return result, nil +} + +func (a *storageAdapter) Storage() *helmstorage.Storage { + return a.storage +} + +func (a *storageAdapter) Update(rls *helmrelease.Release) error { + if err := a.storage.Update(rls); err != nil { + return fmt.Errorf("update release: %w", err) + } + + return nil +} + type ReleaseStorageOptions struct { HistoryLimit int SQLConnection string } -// Constructs Helm release storage driver. -func NewReleaseStorage(ctx context.Context, namespace, storageDriver string, clientFactory kube.ClientFactorier, opts ReleaseStorageOptions) (*helmstorage.Storage, error) { +func NewReleaseStorage(ctx context.Context, namespace, storageDriver string, clientFactory kube.ClientFactorier, opts ReleaseStorageOptions) (ReleaseStorager, error) { var storage *helmstorage.Storage - lazyClient := helmaction.NewLazyClient(namespace, func() (*kubernetes.Clientset, error) { - return clientFactory.Static().(*kubernetes.Clientset), nil - }) - - logFn := func(format string, a ...interface{}) { - log.Default.Debug(ctx, format, a...) - } + clientset := clientFactory.Static().(*kubernetes.Clientset) switch storageDriver { case common.ReleaseStorageDriverSecret, common.ReleaseStorageDriverSecrets, common.ReleaseStorageDriverDefault: - driver := helmdriver.NewSecrets(helmaction.NewSecretClient(lazyClient)) - driver.Log = logFn - - storage = helmstorage.Init(driver) + d := helmdriver.NewSecrets(clientset.CoreV1().Secrets(namespace)) + storage = helmstorage.Init(d) case common.ReleaseStorageDriverConfigMap, common.ReleaseStorageDriverConfigMaps: - driver := helmdriver.NewConfigMaps(helmaction.NewConfigMapClient(lazyClient)) - driver.Log = logFn - - storage = helmstorage.Init(driver) + d := helmdriver.NewConfigMaps(clientset.CoreV1().ConfigMaps(namespace)) + storage = helmstorage.Init(d) case common.ReleaseStorageDriverMemory: - driver := helmdriver.NewMemory() - driver.SetNamespace(namespace) - - storage = helmstorage.Init(driver) + d := helmdriver.NewMemory() + d.SetNamespace(namespace) + storage = helmstorage.Init(d) case common.ReleaseStorageDriverSQL: - driver, err := helmdriver.NewSQL(opts.SQLConnection, logFn, namespace) + d, err := helmdriver.NewSQL(opts.SQLConnection, namespace) if err != nil { return nil, fmt.Errorf("construct sql driver: %w", err) } - storage = helmstorage.Init(driver) + storage = helmstorage.Init(d) default: panic(fmt.Sprintf("Unknown storage driver: %s", storageDriver)) } storage.MaxHistory = opts.HistoryLimit - return storage, nil + return &storageAdapter{storage: storage}, nil } diff --git a/pkg/resource/helpers_ai_test.go b/pkg/resource/helpers_ai_test.go index 3d56ee87..c0768660 100644 --- a/pkg/resource/helpers_ai_test.go +++ b/pkg/resource/helpers_ai_test.go @@ -150,6 +150,6 @@ func setupLocalSchemaDir(t *testing.T, schemas map[string]string) string { func setupTestEnvironment(t *testing.T) { t.Helper() - common.APIResourceValidationJSONSchemasCacheDir = t.TempDir() + t.Setenv("HELM_CACHE_HOME", t.TempDir()) featgate.FeatGateResourceValidation.Enable() } diff --git a/pkg/resource/kubeconform.go b/pkg/resource/kubeconform.go index 43515625..3d289e5c 100644 --- a/pkg/resource/kubeconform.go +++ b/pkg/resource/kubeconform.go @@ -24,6 +24,7 @@ import ( "sigs.k8s.io/yaml" "github.com/werf/nelm/pkg/common" + "github.com/werf/nelm/pkg/helm/pkg/helmpath" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/resource/spec" "github.com/werf/nelm/pkg/util" @@ -453,7 +454,7 @@ func createKubeConformCacheDir(subDir, source string) (string, error) { sourceDirName = u.Hostname() + "-" + sourceHash[:7] } - path := filepath.Join(common.APIResourceValidationJSONSchemasCacheDir, subDir, sourceDirName) + path := filepath.Join(helmpath.CachePath(common.CacheDirAPIResourceJSONSchemas), subDir, sourceDirName) if stat, err := os.Stat(path); os.IsNotExist(err) { if err := os.MkdirAll(path, 0o755); err != nil { diff --git a/pkg/resource/metadata.go b/pkg/resource/metadata.go index 4affb691..d60277ed 100644 --- a/pkg/resource/metadata.go +++ b/pkg/resource/metadata.go @@ -20,7 +20,7 @@ import ( "github.com/werf/kubedog/pkg/dyntracker/statestore" "github.com/werf/nelm/pkg/common" - helmrelease "github.com/werf/nelm/pkg/helm/pkg/release" + helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/resource/spec" "github.com/werf/nelm/pkg/util" @@ -256,13 +256,13 @@ func deployConditionsForAnnotation(meta *spec.ResourceMeta, annoPattern *regexp. result[common.InstallOnDelete] = append(result[common.InstallOnDelete], common.StagePostInstall) case string(helmrelease.HookTest), "test-success": result[common.InstallOnTest] = append(result[common.InstallOnTest], common.StageInstall) - case string(helmrelease.HookInstall): + case string(common.InstallOnInstall): result[common.InstallOnInstall] = append(result[common.InstallOnInstall], common.StageInstall) - case string(helmrelease.HookUpgrade): + case string(common.InstallOnUpgrade): result[common.InstallOnUpgrade] = append(result[common.InstallOnUpgrade], common.StageInstall) - case string(helmrelease.HookRollback): + case string(common.InstallOnRollback): result[common.InstallOnRollback] = append(result[common.InstallOnRollback], common.StageInstall) - case string(helmrelease.HookDelete): + case string(common.InstallOnDelete): result[common.InstallOnDelete] = append(result[common.InstallOnDelete], common.StageInstall) default: panic(fmt.Sprintf("unknown value %q for %s", value, key)) @@ -979,10 +979,10 @@ func validateDeployOn(meta *spec.ResourceMeta) error { string(helmrelease.HookPreDelete), string(helmrelease.HookPostDelete), string(helmrelease.HookTest), - string(helmrelease.HookInstall), - string(helmrelease.HookUpgrade), - string(helmrelease.HookRollback), - string(helmrelease.HookDelete), + string(common.InstallOnInstall), + string(common.InstallOnUpgrade), + string(common.InstallOnRollback), + string(common.InstallOnDelete), "test-success": default: return fmt.Errorf("value %q for annotation %q is not supported", value, key) diff --git a/pkg/ts/bundle.go b/pkg/ts/bundle.go index 94bb1898..263019a7 100644 --- a/pkg/ts/bundle.go +++ b/pkg/ts/bundle.go @@ -10,8 +10,7 @@ import ( esbuild "github.com/evanw/esbuild/pkg/api" "github.com/werf/nelm/pkg/common" - helmchart "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/werf/file" + chartcommon "github.com/werf/nelm/pkg/helm/pkg/chart/common" "github.com/werf/nelm/pkg/log" ) @@ -61,8 +60,8 @@ func BuildVendorBundleToDir(ctx context.Context, chartPath string) error { log.Default.Info(ctx, "Bundled %d npm packages: %s", len(packages), strings.Join(packages, ", ")) - if file.ChartFileWriter != nil { - if err := file.ChartFileWriter.WriteChartFile(ctx, common.ChartTSVendorBundleFile, []byte(vendorBundle)); err != nil { + if common.ChartFileWriter != nil { + if err := common.ChartFileWriter.WriteChartFile(ctx, common.ChartTSVendorBundleFile, []byte(vendorBundle)); err != nil { return fmt.Errorf("write vendor bundle: %w", err) } } else { @@ -84,14 +83,14 @@ func BuildVendorBundleToDir(ctx context.Context, chartPath string) error { // loadTSFilesForVendorBundle loads TypeScript files for vendor bundle building. // Returns empty map if the path should be skipped (doesn't exist, not a directory, or no ts/ dir). func loadTSFilesForVendorBundle(ctx context.Context, absChartPath string) (map[string][]byte, error) { - if file.ChartFileReader != nil { + if common.ChartFileReader != nil { return loadTSFilesFromGiterminism(ctx, absChartPath) } return loadTSFilesFromFilesystem(ctx, absChartPath) } -func resolveVendorBundle(ctx context.Context, files []*helmchart.File) (string, []string, error) { +func resolveVendorBundle(ctx context.Context, files []*chartcommon.File) (string, []string, error) { // Check if node_modules exists in files hasNodeModules := false for _, f := range files { @@ -236,7 +235,7 @@ func loadTSFilesFromDir(tsDir string) (map[string][]byte, error) { } func loadTSFilesFromGiterminism(ctx context.Context, absChartPath string) (map[string][]byte, error) { - isDir, err := file.ChartFileReader.ChartIsDir(absChartPath) + isDir, err := common.ChartFileReader.ChartIsDir(absChartPath) if err != nil { return nil, fmt.Errorf("check directory %s: %w", absChartPath, err) } @@ -247,7 +246,7 @@ func loadTSFilesFromGiterminism(ctx context.Context, absChartPath string) (map[s return map[string][]byte{}, nil } - chartFiles, err := file.ChartFileReader.LoadChartDir(ctx, absChartPath) + chartFiles, err := common.ChartFileReader.LoadChartDir(ctx, absChartPath) if err != nil { return nil, fmt.Errorf("load chart dir: %w", err) } diff --git a/pkg/ts/bundle_ai_test.go b/pkg/ts/bundle_ai_test.go index 5a0e2a45..cfcb8266 100644 --- a/pkg/ts/bundle_ai_test.go +++ b/pkg/ts/bundle_ai_test.go @@ -10,8 +10,8 @@ import ( "github.com/stretchr/testify/require" "github.com/werf/nelm/pkg/common" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chartutil" + chartcommon "github.com/werf/nelm/pkg/helm/pkg/chart/common" + helmchart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" "github.com/werf/nelm/pkg/ts" ) @@ -27,9 +27,9 @@ var __NELM_VENDOR_BUNDLE__ = (function() { return { __NELM_VENDOR__: __NELM_VENDOR__ }; })(); ` - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{ {Name: common.ChartTSVendorBundleFile, Data: []byte(vendorBundle)}, {Name: "ts/src/index.ts", Data: []byte(` const utils = require('@myorg/utils'); @@ -45,7 +45,7 @@ export function render(ctx: any) { `)}, }, } - values := chartutil.Values{"Release": map[string]any{"Name": "scoped-test"}} + values := chartcommon.Values{"Release": map[string]any{"Name": "scoped-test"}} result, err := ts.RenderFiles(context.Background(), ch, values) require.NoError(t, err) @@ -69,9 +69,9 @@ var __NELM_VENDOR_BUNDLE__ = (function() { return { __NELM_VENDOR__: __NELM_VENDOR__ }; })(); ` - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{ {Name: common.ChartTSVendorBundleFile, Data: []byte(vendorBundle)}, {Name: "ts/src/index.ts", Data: []byte(` const _ = require('lodash'); @@ -92,7 +92,7 @@ export function render(ctx: any) { `)}, }, } - values := chartutil.Values{"Values": map[string]any{"labels": map[string]any{"env": "prod"}}} + values := chartcommon.Values{"Values": map[string]any{"labels": map[string]any{"env": "prod"}}} result, err := ts.RenderFiles(context.Background(), ch, values) require.NoError(t, err) @@ -116,9 +116,9 @@ var __NELM_VENDOR_BUNDLE__ = (function() { return { __NELM_VENDOR__: __NELM_VENDOR__ }; })(); ` - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{ {Name: common.ChartTSVendorBundleFile, Data: []byte(vendorBundle)}, {Name: "ts/src/index.ts", Data: []byte(` const core = require('mylib/core'); @@ -136,7 +136,7 @@ export function render(ctx: any) { `)}, }, } - values := chartutil.Values{"Release": map[string]any{"Name": "subpath"}} + values := chartcommon.Values{"Release": map[string]any{"Name": "subpath"}} result, err := ts.RenderFiles(context.Background(), ch, values) require.NoError(t, err) @@ -144,9 +144,9 @@ export function render(ctx: any) { }) t.Run("missing package gives clear error", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{ {Name: "ts/src/index.ts", Data: []byte(` const missing = require('nonexistent-package'); export function render(ctx: any) { @@ -156,7 +156,7 @@ export function render(ctx: any) { }, } - _, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + _, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.Error(t, err) assert.Contains(t, err.Error(), "nonexistent-package") }) @@ -188,9 +188,9 @@ var __NELM_VENDOR_BUNDLE__ = (function() { return { __NELM_VENDOR__: __NELM_VENDOR__ }; })(); ` - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "2.0.0"}, - RuntimeFiles: []*chart.File{ + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "2.0.0"}, + RuntimeFiles: []*chartcommon.File{ {Name: common.ChartTSVendorBundleFile, Data: []byte(vendorBundle)}, {Name: "ts/src/index.ts", Data: []byte(` const k8s = require('k8s-helpers'); @@ -205,7 +205,7 @@ export function render(ctx: any) { `)}, }, } - values := chartutil.Values{ + values := chartcommon.Values{ "Release": map[string]any{"Name": "nested-vendor"}, } @@ -231,9 +231,9 @@ var __NELM_VENDOR_BUNDLE__ = (function() { return { __NELM_VENDOR__: __NELM_VENDOR__ }; })(); ` - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{ {Name: common.ChartTSVendorBundleFile, Data: []byte(vendorBundle)}, {Name: "ts/src/index.ts", Data: []byte(` const cjsModule = require('cjs-module'); @@ -255,7 +255,7 @@ export function render(ctx: any) { }, } - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + result, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.NoError(t, err) yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] assert.Contains(t, yaml, "configName: cjs-config") diff --git a/pkg/ts/files.go b/pkg/ts/files.go index 8aed2659..e26fd00c 100644 --- a/pkg/ts/files.go +++ b/pkg/ts/files.go @@ -4,11 +4,10 @@ import ( "strings" "github.com/werf/nelm/pkg/common" - helmchart "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/werf/file" + chartcommon "github.com/werf/nelm/pkg/helm/pkg/chart/common" ) -func extractSourceFiles(files []*helmchart.File) map[string][]byte { +func extractSourceFiles(files []*chartcommon.File) map[string][]byte { sourceFiles := make(map[string][]byte) for _, f := range files { if strings.HasPrefix(f.Name, common.ChartTSSourceDir+"src/") { @@ -19,7 +18,7 @@ func extractSourceFiles(files []*helmchart.File) map[string][]byte { return sourceFiles } -func filterTSFiles(files []*file.ChartExtenderBufferedFile) map[string][]byte { +func filterTSFiles(files []*common.BufferedFile) map[string][]byte { result := make(map[string][]byte) for _, f := range files { if strings.HasPrefix(f.Name, common.ChartTSSourceDir) { diff --git a/pkg/ts/render.go b/pkg/ts/render.go index 910579c7..01d8ac51 100644 --- a/pkg/ts/render.go +++ b/pkg/ts/render.go @@ -10,12 +10,12 @@ import ( "sigs.k8s.io/yaml" "github.com/werf/nelm/pkg/common" - helmchart "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chartutil" + chartcommon "github.com/werf/nelm/pkg/helm/pkg/chart/common" + helmchart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" "github.com/werf/nelm/pkg/log" ) -func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values) (map[string]string, error) { +func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues chartcommon.Values) (map[string]string, error) { allRendered := make(map[string]string) if err := renderChartRecursive(ctx, chart, renderedValues, chart.Name(), allRendered); err != nil { @@ -25,7 +25,7 @@ func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues cha return allRendered, nil } -func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values chartutil.Values, pathPrefix string, results map[string]string) error { +func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values chartcommon.Values, pathPrefix string, results map[string]string) error { log.Default.Debug(ctx, "Rendering TypeScript for chart %q (path prefix: %s)", chart.Name(), pathPrefix) rendered, err := renderFiles(ctx, chart, values) @@ -58,7 +58,7 @@ func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values ch return nil } -func renderFiles(ctx context.Context, chart *helmchart.Chart, renderedValues chartutil.Values) (map[string]string, error) { +func renderFiles(ctx context.Context, chart *helmchart.Chart, renderedValues chartcommon.Values) (map[string]string, error) { mergedFiles := slices.Concat(chart.RuntimeFiles, chart.RuntimeDepsFiles) vendorBundle, packages, err := resolveVendorBundle(ctx, mergedFiles) @@ -104,11 +104,11 @@ func renderFiles(ctx context.Context, chart *helmchart.Chart, renderedValues cha }, nil } -func buildRenderContext(renderedValues chartutil.Values, chart *helmchart.Chart) map[string]any { +func buildRenderContext(renderedValues chartcommon.Values, chart *helmchart.Chart) map[string]any { renderContext := renderedValues.AsMap() if valuesInterface, ok := renderContext["Values"]; ok { - if chartValues, ok := valuesInterface.(chartutil.Values); ok { + if chartValues, ok := valuesInterface.(chartcommon.Values); ok { renderContext["Values"] = chartValues.AsMap() } } @@ -139,8 +139,8 @@ func convertRenderResultToYAML(result any) (string, error) { return marshalManifests(manifests) } -func scopeValuesForSubchart(parentValues chartutil.Values, subchartName string, subchart *helmchart.Chart) chartutil.Values { - scoped := chartutil.Values{ +func scopeValuesForSubchart(parentValues chartcommon.Values, subchartName string, subchart *helmchart.Chart) chartcommon.Values { + scoped := chartcommon.Values{ "Chart": buildChartMetadata(subchart), "Values": map[string]any{}, } @@ -156,7 +156,7 @@ func scopeValuesForSubchart(parentValues chartutil.Values, subchartName string, switch v := parentVals.(type) { case map[string]any: valuesMap = v - case chartutil.Values: + case chartcommon.Values: valuesMap = v } diff --git a/pkg/ts/render_ai_test.go b/pkg/ts/render_ai_test.go index e090cf0b..82628360 100644 --- a/pkg/ts/render_ai_test.go +++ b/pkg/ts/render_ai_test.go @@ -11,8 +11,8 @@ import ( "github.com/stretchr/testify/require" "github.com/werf/nelm/pkg/common" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chartutil" + chartcommon "github.com/werf/nelm/pkg/helm/pkg/chart/common" + helmchart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" "github.com/werf/nelm/pkg/ts" ) @@ -22,9 +22,9 @@ import ( func TestAI_ContextCompleteness(t *testing.T) { t.Run("Release object fields", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { const r = ctx.Release; return { @@ -45,7 +45,7 @@ export function render(ctx: any) { } `)}}, } - values := chartutil.Values{ + values := chartcommon.Values{ "Release": map[string]any{ "Name": "myrelease", "Namespace": "mynamespace", @@ -67,8 +67,8 @@ export function render(ctx: any) { }) t.Run("Chart object fields", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{ + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{ Name: "mychart", Version: "1.2.3", AppVersion: "4.5.6", @@ -78,7 +78,7 @@ export function render(ctx: any) { Keywords: []string{"web", "app"}, Type: "application", }, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { const c = ctx.Chart; return { @@ -99,7 +99,7 @@ export function render(ctx: any) { `)}}, } - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + result, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.NoError(t, err) yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] assert.Contains(t, yaml, "name: mychart") @@ -109,9 +109,9 @@ export function render(ctx: any) { }) t.Run("Capabilities object", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { const caps = ctx.Capabilities || {}; const kube = caps.KubeVersion || {}; @@ -132,7 +132,7 @@ export function render(ctx: any) { } `)}}, } - values := chartutil.Values{ + values := chartcommon.Values{ "Capabilities": map[string]any{ "KubeVersion": map[string]any{ "Version": "v1.28.0", @@ -154,9 +154,9 @@ export function render(ctx: any) { }) t.Run("Values with complex nesting", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { const v = ctx.Values; return { @@ -182,7 +182,7 @@ export function render(ctx: any) { } `)}}, } - values := chartutil.Values{ + values := chartcommon.Values{ "Values": map[string]any{ "replicas": 3, "image": map[string]any{ @@ -207,13 +207,13 @@ export function render(ctx: any) { }) t.Run("Files object access", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - Files: []*chart.File{ + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + Files: []*chartcommon.File{ {Name: "config/app.properties", Data: []byte("key1=value1\nkey2=value2")}, {Name: "scripts/init.sh", Data: []byte("#!/bin/bash\necho hello")}, }, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { const files = ctx.Files || {}; const fileList = Object.keys(files); @@ -233,16 +233,16 @@ export function render(ctx: any) { `)}}, } - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + result, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.NoError(t, err) yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] assert.Contains(t, yaml, "fileCount:") }) t.Run("Template object (current template info)", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { const tpl = ctx.Template || {}; return { @@ -260,7 +260,7 @@ export function render(ctx: any) { `)}}, } - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{ + result, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{ "Template": map[string]any{ "Name": "test/templates/configmap.yaml", "BasePath": "test/templates", @@ -277,24 +277,24 @@ export function render(ctx: any) { func TestAI_ErrorMessages(t *testing.T) { t.Run("syntax error includes file name", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { return { manifests: [{ // missing closing } `)}}, } - _, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + _, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.Error(t, err) assert.Contains(t, err.Error(), "index.ts") }) t.Run("runtime error includes source location", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { const obj: any = null; return obj.property.nested; @@ -302,30 +302,30 @@ export function render(ctx: any) { `)}}, } - _, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + _, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.Error(t, err) assert.Contains(t, err.Error(), "index.ts") }) t.Run("missing render function has clear message", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function notRender(ctx: any) { return { manifests: [] }; } `)}}, } - _, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + _, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.Error(t, err) assert.Contains(t, err.Error(), "render") }) t.Run("type error in helper file includes correct file", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{ {Name: "ts/src/index.ts", Data: []byte(` import { broken } from './helpers'; export function render(ctx: any) { @@ -341,7 +341,7 @@ export function broken() { }, } - _, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + _, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.Error(t, err) errStr := err.Error() assert.True(t, strings.Contains(errStr, "helpers.ts") || strings.Contains(errStr, "index.ts"), @@ -349,37 +349,37 @@ export function broken() { }) t.Run("undefined variable error is descriptive", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { return { manifests: [{ name: undefinedVariable }] }; } `)}}, } - _, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + _, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.Error(t, err) assert.Contains(t, err.Error(), "undefinedVariable") }) t.Run("subchart error includes chart path", func(t *testing.T) { - subchart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "failing-sub", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + subchart := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "failing-sub", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { throw new Error("intentional failure"); } `)}}, } - root := &chart.Chart{ - Metadata: &chart.Metadata{Name: "root", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{}, + root := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "root", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{}, } root.SetDependencies(subchart) - values := chartutil.Values{ + values := chartcommon.Values{ "Values": map[string]any{"failing-sub": map[string]any{}}, "Release": map[string]any{"Name": "test", "Namespace": "default"}, "Capabilities": map[string]any{}, @@ -391,9 +391,9 @@ export function render(ctx: any) { }) t.Run("thrown Error object message is preserved", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { if (!ctx.Values.required) { throw new Error("required value is missing"); @@ -403,22 +403,22 @@ export function render(ctx: any) { `)}}, } - _, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{"Values": map[string]any{}}) + _, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{"Values": map[string]any{}}) require.Error(t, err) assert.Contains(t, err.Error(), "required value is missing") }) t.Run("thrown string is captured", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { throw "string error message"; } `)}}, } - _, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + _, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.Error(t, err) assert.Contains(t, err.Error(), "string error message") }) @@ -430,9 +430,9 @@ export function render(ctx: any) { func TestAI_ImportExportPatterns(t *testing.T) { t.Run("re-exports from barrel file", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{ {Name: "ts/src/index.ts", Data: []byte(` import { createDeployment, createService } from './resources'; export function render(ctx: any) { @@ -460,7 +460,7 @@ export function createService(name: string) { `)}, }, } - values := chartutil.Values{"Release": map[string]any{"Name": "barrel-test"}} + values := chartcommon.Values{"Release": map[string]any{"Name": "barrel-test"}} result, err := ts.RenderFiles(context.Background(), ch, values) require.NoError(t, err) @@ -470,9 +470,9 @@ export function createService(name: string) { }) t.Run("default export", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{ {Name: "ts/src/index.ts", Data: []byte(` import config from './config'; export function render(ctx: any) { @@ -495,7 +495,7 @@ export default { }, } - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + result, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.NoError(t, err) yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] assert.Contains(t, yaml, "key1: value1") @@ -503,9 +503,9 @@ export default { }) t.Run("mixed default and named exports", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{ {Name: "ts/src/index.ts", Data: []byte(` import Builder, { VERSION, helper } from './builder'; export function render(ctx: any) { @@ -530,7 +530,7 @@ export default class Builder { `)}, }, } - values := chartutil.Values{"Release": map[string]any{"Name": "mixed-test"}} + values := chartcommon.Values{"Release": map[string]any{"Name": "mixed-test"}} result, err := ts.RenderFiles(context.Background(), ch, values) require.NoError(t, err) @@ -541,9 +541,9 @@ export default class Builder { }) t.Run("import with alias", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{ {Name: "ts/src/index.ts", Data: []byte(` import { createConfigMap as cm, createSecret as sec } from './helpers'; export function render(ctx: any) { @@ -561,7 +561,7 @@ export function createSecret(name: string) { }, } - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + result, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.NoError(t, err) yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] assert.Contains(t, yaml, "kind: ConfigMap") @@ -571,9 +571,9 @@ export function createSecret(name: string) { }) t.Run("namespace import", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{ {Name: "ts/src/index.ts", Data: []byte(` import * as utils from './utils'; export function render(ctx: any) { @@ -595,7 +595,7 @@ export const defaultLabels = { managed: 'true', source: 'ts' }; `)}, }, } - values := chartutil.Values{"Release": map[string]any{"Name": "My_App"}} + values := chartcommon.Values{"Release": map[string]any{"Name": "My_App"}} result, err := ts.RenderFiles(context.Background(), ch, values) require.NoError(t, err) @@ -604,9 +604,9 @@ export const defaultLabels = { managed: 'true', source: 'ts' }; }) t.Run("circular imports between helpers", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{ {Name: "ts/src/index.ts", Data: []byte(` import { createA } from './a'; export function render(ctx: any) { @@ -627,16 +627,16 @@ export function formatB(s: string) { return 'B-' + formatA(s); } }, } - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + result, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.NoError(t, err) yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] assert.Contains(t, yaml, "name: B-A-test") }) t.Run("deep nested imports", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{ {Name: "ts/src/index.ts", Data: []byte(` import { render as doRender } from './lib/core/render'; export function render(ctx: any) { return doRender(ctx); } @@ -656,7 +656,7 @@ export function formatName(name: string) { return name + '-formatted'; } `)}, }, } - values := chartutil.Values{"Release": map[string]any{"Name": "deep"}} + values := chartcommon.Values{"Release": map[string]any{"Name": "deep"}} result, err := ts.RenderFiles(context.Background(), ch, values) require.NoError(t, err) @@ -667,15 +667,15 @@ export function formatName(name: string) { return name + '-formatted'; } func TestAI_RenderChartWithDependencies_ChartMetadata(t *testing.T) { t.Run("subchart receives correct Chart metadata", func(t *testing.T) { - subchart := &chart.Chart{ - Metadata: &chart.Metadata{ + subchart := &helmchart.Chart{ + Metadata: &helmchart.Metadata{ Name: "my-subchart", Version: "2.3.4", AppVersion: "1.2.3", Description: "A test subchart", Keywords: []string{"test", "subchart"}, }, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { return { manifests: [{ @@ -700,13 +700,13 @@ export function render(ctx: any) { `)}}, } - root := &chart.Chart{ - Metadata: &chart.Metadata{Name: "root", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{}, + root := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "root", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{}, } root.SetDependencies(subchart) - values := chartutil.Values{ + values := chartcommon.Values{ "Values": map[string]any{"my-subchart": map[string]any{}}, "Release": map[string]any{"Name": "meta-test", "Namespace": "default"}, "Capabilities": map[string]any{}, @@ -727,9 +727,9 @@ export function render(ctx: any) { func TestAI_RenderChartWithDependencies_ConditionalSubcharts(t *testing.T) { t.Run("subchart conditionally renders based on enabled flag", func(t *testing.T) { - subchart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "optional-sub", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + subchart := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "optional-sub", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { if (!ctx.Values.enabled) { return null; @@ -746,9 +746,9 @@ export function render(ctx: any) { `)}}, } - root := &chart.Chart{ - Metadata: &chart.Metadata{Name: "root", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + root := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "root", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { return { manifests: [{ @@ -764,7 +764,7 @@ export function render(ctx: any) { root.SetDependencies(subchart) // Test with subchart disabled - valuesDisabled := chartutil.Values{ + valuesDisabled := chartcommon.Values{ "Values": map[string]any{ "optional-sub": map[string]any{"enabled": false}, }, @@ -786,7 +786,7 @@ export function render(ctx: any) { } // Test with subchart enabled - valuesEnabled := chartutil.Values{ + valuesEnabled := chartcommon.Values{ "Values": map[string]any{ "optional-sub": map[string]any{"enabled": true}, }, @@ -801,10 +801,10 @@ export function render(ctx: any) { }) t.Run("multiple conditional subcharts with different states", func(t *testing.T) { - makeConditionalChart := func(name string) *chart.Chart { - return &chart.Chart{ - Metadata: &chart.Metadata{Name: name, Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + makeConditionalChart := func(name string) *helmchart.Chart { + return &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: name, Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { if (!ctx.Values.enabled) return null; return { @@ -824,13 +824,13 @@ export function render(ctx: any) { postgres := makeConditionalChart("postgres") mongodb := makeConditionalChart("mongodb") - root := &chart.Chart{ - Metadata: &chart.Metadata{Name: "app", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(ctx: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'app-cm' } }] }; }`)}}, + root := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "app", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(ctx: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'app-cm' } }] }; }`)}}, } root.SetDependencies(redis, postgres, mongodb) - values := chartutil.Values{ + values := chartcommon.Values{ "Values": map[string]any{ "redis": map[string]any{"enabled": true}, "postgres": map[string]any{"enabled": false}, @@ -864,29 +864,29 @@ export function render(ctx: any) { func TestAI_RenderChartWithDependencies_DeepNesting(t *testing.T) { t.Run("4 levels deep subchart hierarchy", func(t *testing.T) { - level4 := &chart.Chart{ - Metadata: &chart.Metadata{Name: "level4", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(ctx: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'level4-cm' }, data: { depth: '4', chart: ctx.Chart.Name } }] }; }`)}}, + level4 := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "level4", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(ctx: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'level4-cm' }, data: { depth: '4', chart: ctx.Chart.Name } }] }; }`)}}, } - level3 := &chart.Chart{ - Metadata: &chart.Metadata{Name: "level3", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(ctx: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'level3-cm' }, data: { depth: '3', chart: ctx.Chart.Name } }] }; }`)}}, + level3 := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "level3", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(ctx: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'level3-cm' }, data: { depth: '3', chart: ctx.Chart.Name } }] }; }`)}}, } level3.SetDependencies(level4) - level2 := &chart.Chart{ - Metadata: &chart.Metadata{Name: "level2", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(ctx: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'level2-cm' }, data: { depth: '2', chart: ctx.Chart.Name } }] }; }`)}}, + level2 := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "level2", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(ctx: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'level2-cm' }, data: { depth: '2', chart: ctx.Chart.Name } }] }; }`)}}, } level2.SetDependencies(level3) - root := &chart.Chart{ - Metadata: &chart.Metadata{Name: "root", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(ctx: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'root-cm' }, data: { depth: '1', chart: ctx.Chart.Name } }] }; }`)}}, + root := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "root", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(ctx: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'root-cm' }, data: { depth: '1', chart: ctx.Chart.Name } }] }; }`)}}, } root.SetDependencies(level2) - values := chartutil.Values{ + values := chartcommon.Values{ "Values": map[string]any{ "level2": map[string]any{ "level3": map[string]any{ @@ -914,10 +914,10 @@ func TestAI_RenderChartWithDependencies_DeepNesting(t *testing.T) { }) t.Run("5 levels with values propagated correctly", func(t *testing.T) { - makeChart := func(name string) *chart.Chart { - return &chart.Chart{ - Metadata: &chart.Metadata{Name: name, Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + makeChart := func(name string) *helmchart.Chart { + return &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: name, Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { return { manifests: [{ @@ -946,7 +946,7 @@ export function render(ctx: any) { root := makeChart("root") root.SetDependencies(l2) - values := chartutil.Values{ + values := chartcommon.Values{ "Values": map[string]any{ "message": "root-msg", "l2": map[string]any{ @@ -980,9 +980,9 @@ export function render(ctx: any) { func TestAI_RenderChartWithDependencies_GlobalValues(t *testing.T) { t.Run("global values available to all subcharts", func(t *testing.T) { - subchart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "sub", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + subchart := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "sub", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { const global = ctx.Values.global || {}; return { @@ -1000,9 +1000,9 @@ export function render(ctx: any) { `)}}, } - root := &chart.Chart{ - Metadata: &chart.Metadata{Name: "root", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + root := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "root", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { const global = ctx.Values.global || {}; return { @@ -1021,7 +1021,7 @@ export function render(ctx: any) { } root.SetDependencies(subchart) - values := chartutil.Values{ + values := chartcommon.Values{ "Values": map[string]any{ "global": map[string]any{ "imageRegistry": "my-registry.io", @@ -1049,10 +1049,10 @@ export function render(ctx: any) { }) t.Run("global values in nested subcharts", func(t *testing.T) { - makeGlobalAwareChart := func(name string) *chart.Chart { - return &chart.Chart{ - Metadata: &chart.Metadata{Name: name, Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + makeGlobalAwareChart := func(name string) *helmchart.Chart { + return &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: name, Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { const global = ctx.Values.global || {}; return { @@ -1079,7 +1079,7 @@ export function render(ctx: any) { globalVals := map[string]any{"environment": "production"} - values := chartutil.Values{ + values := chartcommon.Values{ "Values": map[string]any{ "global": globalVals, "middle": map[string]any{ @@ -1105,14 +1105,14 @@ export function render(ctx: any) { func TestAI_RenderChartWithDependencies_MixedTSAndNonTS(t *testing.T) { t.Run("TS root with non-TS subchart", func(t *testing.T) { - nonTSSubchart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "classic-sub", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{}, + nonTSSubchart := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "classic-sub", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{}, } - tsRoot := &chart.Chart{ - Metadata: &chart.Metadata{Name: "ts-root", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + tsRoot := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "ts-root", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { return { manifests: [{ @@ -1127,7 +1127,7 @@ export function render(ctx: any) { } tsRoot.SetDependencies(nonTSSubchart) - values := chartutil.Values{ + values := chartcommon.Values{ "Values": map[string]any{"classic-sub": map[string]any{}}, "Release": map[string]any{"Name": "mixed", "Namespace": "default"}, "Capabilities": map[string]any{}, @@ -1142,9 +1142,9 @@ export function render(ctx: any) { }) t.Run("non-TS root with TS subchart", func(t *testing.T) { - tsSubchart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "ts-sub", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + tsSubchart := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "ts-sub", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { return { manifests: [{ @@ -1158,13 +1158,13 @@ export function render(ctx: any) { `)}}, } - classicRoot := &chart.Chart{ - Metadata: &chart.Metadata{Name: "classic-root", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{}, + classicRoot := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "classic-root", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{}, } classicRoot.SetDependencies(tsSubchart) - values := chartutil.Values{ + values := chartcommon.Values{ "Values": map[string]any{"ts-sub": map[string]any{}}, "Release": map[string]any{"Name": "mixed", "Namespace": "default"}, "Capabilities": map[string]any{}, @@ -1179,30 +1179,30 @@ export function render(ctx: any) { }) t.Run("alternating TS and non-TS in deep hierarchy", func(t *testing.T) { - level4 := &chart.Chart{ - Metadata: &chart.Metadata{Name: "l4-ts", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(ctx: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'l4' }, data: { type: 'ts' } }] }; }`)}}, + level4 := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "l4-ts", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(ctx: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'l4' }, data: { type: 'ts' } }] }; }`)}}, } - level3 := &chart.Chart{ - Metadata: &chart.Metadata{Name: "l3-classic", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{}, + level3 := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "l3-classic", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{}, } level3.SetDependencies(level4) - level2 := &chart.Chart{ - Metadata: &chart.Metadata{Name: "l2-ts", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(ctx: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'l2' }, data: { type: 'ts' } }] }; }`)}}, + level2 := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "l2-ts", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(ctx: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'l2' }, data: { type: 'ts' } }] }; }`)}}, } level2.SetDependencies(level3) - root := &chart.Chart{ - Metadata: &chart.Metadata{Name: "root-classic", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{}, + root := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "root-classic", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{}, } root.SetDependencies(level2) - values := chartutil.Values{ + values := chartcommon.Values{ "Values": map[string]any{ "l2-ts": map[string]any{ "l3-classic": map[string]any{ @@ -1225,9 +1225,9 @@ export function render(ctx: any) { func TestAI_RenderChartWithDependencies_SiblingSubcharts(t *testing.T) { t.Run("multiple sibling subcharts at same level", func(t *testing.T) { - frontend := &chart.Chart{ - Metadata: &chart.Metadata{Name: "frontend", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + frontend := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "frontend", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { return { manifests: [{ @@ -1241,9 +1241,9 @@ export function render(ctx: any) { `)}}, } - backend := &chart.Chart{ - Metadata: &chart.Metadata{Name: "backend", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + backend := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "backend", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { return { manifests: [{ @@ -1257,9 +1257,9 @@ export function render(ctx: any) { `)}}, } - worker := &chart.Chart{ - Metadata: &chart.Metadata{Name: "worker", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + worker := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "worker", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { return { manifests: [{ @@ -1273,13 +1273,13 @@ export function render(ctx: any) { `)}}, } - root := &chart.Chart{ - Metadata: &chart.Metadata{Name: "myapp", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{}, + root := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "myapp", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{}, } root.SetDependencies(frontend, backend, worker) - values := chartutil.Values{ + values := chartcommon.Values{ "Values": map[string]any{ "frontend": map[string]any{"replicas": 3}, "backend": map[string]any{"replicas": 2}, @@ -1299,18 +1299,18 @@ export function render(ctx: any) { }) t.Run("sibling subcharts with independent errors do not affect each other", func(t *testing.T) { - goodChart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "good", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + goodChart := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "good", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'good-cm' } }] }; } `)}}, } - badChart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "bad", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + badChart := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "bad", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { const x: any = null; return x.boom; // This will throw @@ -1318,13 +1318,13 @@ export function render(ctx: any) { `)}}, } - root := &chart.Chart{ - Metadata: &chart.Metadata{Name: "root", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{}, + root := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "root", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{}, } root.SetDependencies(goodChart, badChart) - values := chartutil.Values{ + values := chartcommon.Values{ "Values": map[string]any{"good": map[string]any{}, "bad": map[string]any{}}, "Release": map[string]any{"Name": "test", "Namespace": "default"}, "Capabilities": map[string]any{}, @@ -1338,9 +1338,9 @@ export function render(ctx: any) { func TestAI_RenderChartWithDependencies_ValueOverrides(t *testing.T) { t.Run("parent overrides subchart default values", func(t *testing.T) { - subchart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "sub", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + subchart := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "sub", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { return { manifests: [{ @@ -1364,13 +1364,13 @@ export function render(ctx: any) { `)}}, } - root := &chart.Chart{ - Metadata: &chart.Metadata{Name: "root", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{}, + root := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "root", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{}, } root.SetDependencies(subchart) - values := chartutil.Values{ + values := chartcommon.Values{ "Values": map[string]any{ "sub": map[string]any{ "replicas": 5, @@ -1398,9 +1398,9 @@ export function render(ctx: any) { }) t.Run("nested value overrides through multiple levels", func(t *testing.T) { - leaf := &chart.Chart{ - Metadata: &chart.Metadata{Name: "leaf", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + leaf := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "leaf", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { return { manifests: [{ @@ -1418,19 +1418,19 @@ export function render(ctx: any) { `)}}, } - middle := &chart.Chart{ - Metadata: &chart.Metadata{Name: "middle", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{}, + middle := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "middle", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{}, } middle.SetDependencies(leaf) - root := &chart.Chart{ - Metadata: &chart.Metadata{Name: "root", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{}, + root := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "root", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{}, } root.SetDependencies(middle) - values := chartutil.Values{ + values := chartcommon.Values{ "Values": map[string]any{ "middle": map[string]any{ "leaf": map[string]any{ @@ -1462,9 +1462,9 @@ export function render(ctx: any) { func TestAI_TypeScriptFeatures(t *testing.T) { t.Run("generic functions compile and work", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` function identity(arg: T): T { return arg; } @@ -1480,7 +1480,7 @@ export function render(ctx: any) { } `)}}, } - values := chartutil.Values{"Release": map[string]any{"Name": "generic-test"}} + values := chartcommon.Values{"Release": map[string]any{"Name": "generic-test"}} result, err := ts.RenderFiles(context.Background(), ch, values) require.NoError(t, err) @@ -1488,9 +1488,9 @@ export function render(ctx: any) { }) t.Run("enums compile and work", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` enum ResourceType { ConfigMap = "ConfigMap", Secret = "Secret", @@ -1515,16 +1515,16 @@ export function render(ctx: any) { `)}}, } - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + result, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.NoError(t, err) assert.Contains(t, result[common.ChartTSSourceDir+common.ChartTSEntryPointTS], "kind: ConfigMap") assert.Contains(t, result[common.ChartTSSourceDir+common.ChartTSEntryPointTS], "protocol: TCP") }) t.Run("type unions and intersections", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` type StringOrNumber = string | number; type Named = { name: string }; type Versioned = { version: string }; @@ -1545,7 +1545,7 @@ export function render(ctx: any) { } `)}}, } - values := chartutil.Values{"Values": map[string]any{"replicas": 3}} + values := chartcommon.Values{"Values": map[string]any{"replicas": 3}} result, err := ts.RenderFiles(context.Background(), ch, values) require.NoError(t, err) @@ -1554,9 +1554,9 @@ export function render(ctx: any) { }) t.Run("optional chaining and nullish coalescing", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { const nested = ctx.Values?.deeply?.nested?.value ?? 'default'; const port = ctx.Values.port ?? 8080; @@ -1572,7 +1572,7 @@ export function render(ctx: any) { } `)}}, } - values := chartutil.Values{"Values": map[string]any{}} + values := chartcommon.Values{"Values": map[string]any{}} result, err := ts.RenderFiles(context.Background(), ch, values) require.NoError(t, err) @@ -1581,9 +1581,9 @@ export function render(ctx: any) { }) t.Run("class with methods", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` class ResourceBuilder { private name: string; @@ -1607,7 +1607,7 @@ export function render(ctx: any) { } `)}}, } - values := chartutil.Values{"Release": map[string]any{"Name": "class-test"}} + values := chartcommon.Values{"Release": map[string]any{"Name": "class-test"}} result, err := ts.RenderFiles(context.Background(), ch, values) require.NoError(t, err) @@ -1616,9 +1616,9 @@ export function render(ctx: any) { }) t.Run("spread operator", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { const baseLabels = { app: 'myapp', version: '1.0' }; const extraLabels = ctx.Values.extraLabels || {}; @@ -1635,7 +1635,7 @@ export function render(ctx: any) { } `)}}, } - values := chartutil.Values{"Values": map[string]any{"extraLabels": map[string]any{"env": "prod"}}} + values := chartcommon.Values{"Values": map[string]any{"extraLabels": map[string]any{"env": "prod"}}} result, err := ts.RenderFiles(context.Background(), ch, values) require.NoError(t, err) @@ -1645,9 +1645,9 @@ export function render(ctx: any) { }) t.Run("destructuring assignment", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { const { Name: releaseName, Namespace: namespace } = ctx.Release; const { replicas = 1, image = 'nginx:latest' } = ctx.Values; @@ -1664,7 +1664,7 @@ export function render(ctx: any) { } `)}}, } - values := chartutil.Values{ + values := chartcommon.Values{ "Release": map[string]any{"Name": "destruct-test", "Namespace": "myns"}, "Values": map[string]any{"replicas": 5}, } @@ -1680,9 +1680,9 @@ export function render(ctx: any) { }) t.Run("template literals", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "mychart", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "mychart", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { const { Name, Namespace } = ctx.Release; const fullName = ` + "`${Name}-${ctx.Chart.Name}`" + `; @@ -1699,7 +1699,7 @@ export function render(ctx: any) { } `)}}, } - values := chartutil.Values{ + values := chartcommon.Values{ "Release": map[string]any{"Name": "myrelease", "Namespace": "mynamespace"}, } @@ -1711,9 +1711,9 @@ export function render(ctx: any) { }) t.Run("async/await is not supported at top level", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export async function render(ctx: any) { await Promise.resolve(); return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'async-test' } }] }; @@ -1721,7 +1721,7 @@ export async function render(ctx: any) { `)}}, } - _, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + _, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) // Async render should either fail or return a promise (which won't be valid) // The exact behavior depends on implementation @@ -1737,9 +1737,9 @@ export async function render(ctx: any) { func TestAI_YAMLOutput(t *testing.T) { t.Run("special characters are escaped", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { return { manifests: [{ @@ -1763,7 +1763,7 @@ export function render(ctx: any) { `)}}, } - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + result, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.NoError(t, err) yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] assert.Contains(t, yaml, "colon:") @@ -1772,9 +1772,9 @@ export function render(ctx: any) { }) t.Run("multiline strings", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { return { manifests: [{ @@ -1791,7 +1791,7 @@ export function render(ctx: any) { `)}}, } - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + result, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.NoError(t, err) yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] assert.Contains(t, yaml, "script:") @@ -1799,9 +1799,9 @@ export function render(ctx: any) { }) t.Run("null and undefined values", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { return { manifests: [{ @@ -1821,7 +1821,7 @@ export function render(ctx: any) { `)}}, } - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + result, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.NoError(t, err) yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] assert.Contains(t, yaml, "emptyString:") @@ -1830,9 +1830,9 @@ export function render(ctx: any) { }) t.Run("empty objects and arrays", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { return { manifests: [{ @@ -1850,7 +1850,7 @@ export function render(ctx: any) { `)}}, } - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + result, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.NoError(t, err) yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] assert.Contains(t, yaml, "name: empties") @@ -1859,9 +1859,9 @@ export function render(ctx: any) { }) t.Run("numeric strings stay as strings", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { return { manifests: [{ @@ -1881,7 +1881,7 @@ export function render(ctx: any) { `)}}, } - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + result, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.NoError(t, err) yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] @@ -1891,9 +1891,9 @@ export function render(ctx: any) { }) t.Run("boolean-like strings", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { return { manifests: [{ @@ -1914,7 +1914,7 @@ export function render(ctx: any) { `)}}, } - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + result, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.NoError(t, err) yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] assert.Contains(t, yaml, "yesString:") @@ -1922,9 +1922,9 @@ export function render(ctx: any) { }) t.Run("deeply nested objects", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { return { manifests: [{ @@ -1960,7 +1960,7 @@ export function render(ctx: any) { `)}}, } - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + result, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.NoError(t, err) yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] assert.Contains(t, yaml, "cpu: 100m") @@ -1970,9 +1970,9 @@ export function render(ctx: any) { }) t.Run("array of primitives", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { return { manifests: [{ @@ -1989,7 +1989,7 @@ export function render(ctx: any) { `)}}, } - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + result, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.NoError(t, err) yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] assert.Contains(t, yaml, "host1.example.com") @@ -1997,9 +1997,9 @@ export function render(ctx: any) { }) t.Run("unicode characters", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(` + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(` export function render(ctx: any) { return { manifests: [{ @@ -2019,7 +2019,7 @@ export function render(ctx: any) { `)}}, } - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + result, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.NoError(t, err) yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] assert.Contains(t, yaml, "japanese:") diff --git a/pkg/ts/render_test.go b/pkg/ts/render_test.go index c9131f13..9200d6f8 100644 --- a/pkg/ts/render_test.go +++ b/pkg/ts/render_test.go @@ -8,8 +8,8 @@ import ( "github.com/stretchr/testify/require" "github.com/werf/nelm/pkg/common" - "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chartutil" + chartcommon "github.com/werf/nelm/pkg/helm/pkg/chart/common" + helmchart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" "github.com/werf/nelm/pkg/ts" ) @@ -26,7 +26,7 @@ func TestRenderChartWithDependencies(t *testing.T) { }`, ) - values := chartutil.Values{ + values := chartcommon.Values{ "Values": map[string]any{ "rootMessage": "Hello from root", "ts-subchart": map[string]any{"subMessage": "Hello from subchart"}, @@ -54,9 +54,9 @@ func TestRenderChartWithDependencies(t *testing.T) { }) t.Run("classic root with TS subchart only", func(t *testing.T) { - subchart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "ts-subchart", Version: "0.1.0"}, - RuntimeFiles: []*chart.File{ + subchart := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "ts-subchart", Version: "0.1.0"}, + RuntimeFiles: []*chartcommon.File{ {Name: "ts/src/index.ts", Data: []byte(` export function render(context: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'ts-subchart-only' }, @@ -65,13 +65,13 @@ export function render(context: any) { `)}, }, } - rootChart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "classic-root", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{}, // No TypeScript in root + rootChart := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "classic-root", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{}, // No TypeScript in root } rootChart.SetDependencies(subchart) - values := chartutil.Values{ + values := chartcommon.Values{ "Values": map[string]any{"ts-subchart": map[string]any{}}, "Release": map[string]any{"Name": "my-release", "Namespace": "default"}, "Capabilities": map[string]any{}, @@ -89,23 +89,23 @@ export function render(context: any) { }) t.Run("nested dependencies (3 levels)", func(t *testing.T) { - sub2 := &chart.Chart{ - Metadata: &chart.Metadata{Name: "sub2", Version: "0.1.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(c: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'sub2' }, data: { level: 'sub2' } }] }; }`)}}, + sub2 := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "sub2", Version: "0.1.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(c: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'sub2' }, data: { level: 'sub2' } }] }; }`)}}, } - sub1 := &chart.Chart{ - Metadata: &chart.Metadata{Name: "sub1", Version: "0.1.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(c: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'sub1' }, data: { level: 'sub1' } }] }; }`)}}, + sub1 := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "sub1", Version: "0.1.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(c: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'sub1' }, data: { level: 'sub1' } }] }; }`)}}, } sub1.SetDependencies(sub2) - rootChart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "nested-root", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(c: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'root' }, data: { level: 'root' } }] }; }`)}}, + rootChart := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "nested-root", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(`export function render(c: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'root' }, data: { level: 'root' } }] }; }`)}}, } rootChart.SetDependencies(sub1) - values := chartutil.Values{ + values := chartcommon.Values{ "Values": map[string]any{"sub1": map[string]any{"sub2": map[string]any{}}}, "Release": map[string]any{"Name": "test"}, "Capabilities": map[string]any{}, @@ -130,7 +130,7 @@ export function render(context: any) { `export function render(c: any) { const x: any = null; x.foo.bar; return { manifests: [] }; }`, ) - values := chartutil.Values{ + values := chartcommon.Values{ "Values": map[string]any{"ts-subchart": map[string]any{}}, "Release": map[string]any{"Name": "test"}, "Capabilities": map[string]any{}, @@ -145,7 +145,7 @@ export function render(context: any) { func TestRenderFiles(t *testing.T) { t.Run("no TypeScript source returns empty", func(t *testing.T) { ch := newTestChart(nil) - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + result, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.NoError(t, err) assert.Empty(t, result) }) @@ -166,7 +166,7 @@ export function render(context: any) { }; } `) - values := chartutil.Values{ + values := chartcommon.Values{ "Values": map[string]any{"replicas": 3}, "Release": map[string]any{"Name": "test-release", "Namespace": "default"}, } @@ -193,7 +193,7 @@ export function render(context: any) { }; } `) - values := chartutil.Values{"Release": map[string]any{"Name": "test"}} + values := chartcommon.Values{"Release": map[string]any{"Name": "test"}} result, err := ts.RenderFiles(context.Background(), ch, values) require.NoError(t, err) @@ -211,7 +211,7 @@ export function render(context: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'test' } }] }; } `) - values := chartutil.Values{"Values": map[string]any{"enabled": false}} + values := chartcommon.Values{"Values": map[string]any{"enabled": false}} result, err := ts.RenderFiles(context.Background(), ch, values) require.NoError(t, err) @@ -224,7 +224,7 @@ module.exports.render = function(context: any) { return { manifests: [{ apiVersion: 'v1', kind: 'ConfigMap', metadata: { name: 'module-exports-test' } }] }; }; `) - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + result, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.NoError(t, err) yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] @@ -239,7 +239,7 @@ module.exports = { } }; `) - result, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + result, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.NoError(t, err) yaml := result[common.ChartTSSourceDir+common.ChartTSEntryPointTS] @@ -260,7 +260,7 @@ export const render = (context: any) => { return { manifests: resources }; }; `) - values := chartutil.Values{"Release": map[string]any{"Name": "my-app"}} + values := chartcommon.Values{"Release": map[string]any{"Name": "my-app"}} result, err := ts.RenderFiles(context.Background(), ch, values) require.NoError(t, err) @@ -295,7 +295,7 @@ export function render(context: RenderContext) { return { manifests: [manifest] }; } `) - values := chartutil.Values{ + values := chartcommon.Values{ "Release": map[string]any{"Name": "typed-app", "Namespace": "production"}, "Values": map[string]any{"replicas": 5}, } @@ -328,7 +328,7 @@ export function createConfigMap(name: string) { } `, }) - values := chartutil.Values{"Release": map[string]any{"Name": "multi-file-app"}} + values := chartcommon.Values{"Release": map[string]any{"Name": "multi-file-app"}} result, err := ts.RenderFiles(context.Background(), ch, values) require.NoError(t, err) @@ -341,7 +341,7 @@ export function createConfigMap(name: string) { t.Run("error when render function missing", func(t *testing.T) { ch := newChartWithTS(`export function notRender(context: any) { return { manifests: [] }; }`) - _, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + _, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.Error(t, err) assert.Contains(t, err.Error(), "no 'render' function exported") }) @@ -354,7 +354,7 @@ export function render(context: any) { return { manifests: [] }; } `) - _, err := ts.RenderFiles(context.Background(), ch, chartutil.Values{}) + _, err := ts.RenderFiles(context.Background(), ch, chartcommon.Values{}) require.Error(t, err) assert.Contains(t, err.Error(), "index.ts") }) @@ -372,9 +372,9 @@ var __NELM_VENDOR_BUNDLE__ = (function() { return { __NELM_VENDOR__: __NELM_VENDOR__ }; })(); ` - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test-chart", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test-chart", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{ {Name: common.ChartTSVendorBundleFile, Data: []byte(vendorBundle)}, {Name: "ts/src/index.ts", Data: []byte(` const fakeLib = require('fake-lib'); @@ -384,7 +384,7 @@ export function render(context: any) { `)}, }, } - values := chartutil.Values{"Release": map[string]any{"Name": "vendor-test"}} + values := chartcommon.Values{"Release": map[string]any{"Name": "vendor-test"}} result, err := ts.RenderFiles(context.Background(), ch, values) require.NoError(t, err) @@ -394,9 +394,9 @@ export function render(context: any) { }) t.Run("node_modules in RuntimeDepsFiles", func(t *testing.T) { - ch := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test-chart", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{ + ch := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test-chart", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{ {Name: "ts/src/index.ts", Data: []byte(` import { helper } from 'fake-lib'; export function render(context: any) { @@ -404,7 +404,7 @@ export function render(context: any) { } `)}, }, - RuntimeDepsFiles: []*chart.File{ + RuntimeDepsFiles: []*chartcommon.File{ {Name: "ts/node_modules/fake-lib/package.json", Data: []byte(`{"name": "fake-lib", "version": "1.0.0", "main": "index.js"}`)}, {Name: "ts/node_modules/fake-lib/index.js", Data: []byte(` module.exports.helper = function(name) { @@ -413,7 +413,7 @@ module.exports.helper = function(name) { `)}, }, } - values := chartutil.Values{"Release": map[string]any{"Name": "npm-test"}} + values := chartcommon.Values{"Release": map[string]any{"Name": "npm-test"}} result, err := ts.RenderFiles(context.Background(), ch, values) require.NoError(t, err) @@ -425,12 +425,12 @@ module.exports.helper = function(name) { func TestScopeValuesForSubchart(t *testing.T) { t.Run("scopes values correctly", func(t *testing.T) { - subchart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "my-subchart", Version: "2.0.0", AppVersion: "1.5.0", Description: "Test subchart"}, - Files: []*chart.File{{Name: "README.md", Data: []byte("# Subchart")}}, + subchart := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "my-subchart", Version: "2.0.0", AppVersion: "1.5.0", Description: "Test subchart"}, + Files: []*chartcommon.File{{Name: "README.md", Data: []byte("# Subchart")}}, } - parentValues := chartutil.Values{ + parentValues := chartcommon.Values{ "Values": map[string]any{ "rootKey": "rootValue", "my-subchart": map[string]any{ @@ -468,8 +468,8 @@ func TestScopeValuesForSubchart(t *testing.T) { }) t.Run("missing subchart values returns empty map", func(t *testing.T) { - subchart := &chart.Chart{Metadata: &chart.Metadata{Name: "missing-values-subchart", Version: "1.0.0"}} - parentValues := chartutil.Values{ + subchart := &helmchart.Chart{Metadata: &helmchart.Metadata{Name: "missing-values-subchart", Version: "1.0.0"}} + parentValues := chartcommon.Values{ "Values": map[string]any{"other-subchart": map[string]any{"key": "value"}}, "Release": map[string]any{"Name": "test"}, } @@ -481,49 +481,49 @@ func TestScopeValuesForSubchart(t *testing.T) { }) } -func createChartWithTSFiles(files map[string]string) *chart.Chart { - var runtimeFiles []*chart.File +func createChartWithTSFiles(files map[string]string) *helmchart.Chart { + var runtimeFiles []*chartcommon.File for name, content := range files { - runtimeFiles = append(runtimeFiles, &chart.File{Name: "ts/" + name, Data: []byte(content)}) + runtimeFiles = append(runtimeFiles, &chartcommon.File{Name: "ts/" + name, Data: []byte(content)}) } - return &chart.Chart{ - Metadata: &chart.Metadata{Name: "test-chart", Version: "1.0.0"}, + return &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test-chart", Version: "1.0.0"}, RuntimeFiles: runtimeFiles, } } -func createTestChartWithSubchart(rootContent, subchartContent string) *chart.Chart { - subchart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "ts-subchart", Version: "0.1.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(subchartContent)}}, +func createTestChartWithSubchart(rootContent, subchartContent string) *helmchart.Chart { + subchart := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "ts-subchart", Version: "0.1.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(subchartContent)}}, } - rootChart := &chart.Chart{ - Metadata: &chart.Metadata{Name: "root-chart", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(rootContent)}}, + rootChart := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "root-chart", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(rootContent)}}, } rootChart.SetDependencies(subchart) return rootChart } -func newChartWithTS(sourceContent string) *chart.Chart { - return &chart.Chart{ - Metadata: &chart.Metadata{Name: "test-chart", Version: "1.0.0"}, - RuntimeFiles: []*chart.File{{Name: "ts/src/index.ts", Data: []byte(sourceContent)}}, +func newChartWithTS(sourceContent string) *helmchart.Chart { + return &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test-chart", Version: "1.0.0"}, + RuntimeFiles: []*chartcommon.File{{Name: "ts/src/index.ts", Data: []byte(sourceContent)}}, } } // Test helpers -func newTestChart(files map[string]string) *chart.Chart { - var runtimeFiles []*chart.File +func newTestChart(files map[string]string) *helmchart.Chart { + var runtimeFiles []*chartcommon.File for name, content := range files { - runtimeFiles = append(runtimeFiles, &chart.File{Name: name, Data: []byte(content)}) + runtimeFiles = append(runtimeFiles, &chartcommon.File{Name: name, Data: []byte(content)}) } - return &chart.Chart{ - Metadata: &chart.Metadata{Name: "test-chart", Version: "1.0.0"}, + return &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "test-chart", Version: "1.0.0"}, RuntimeFiles: runtimeFiles, } } diff --git a/pkg/util/manifest.go b/pkg/util/manifest.go new file mode 100644 index 00000000..748f3682 --- /dev/null +++ b/pkg/util/manifest.go @@ -0,0 +1,46 @@ +package util + +import ( + "regexp" + "strings" +) + +var yamlDocSeparator = regexp.MustCompile(`(?m)^---\s*`) + +// SplitManifests splits a multi-document YAML string into individual manifest +// strings, filtering out empty documents and documents containing only comments. +// Documents are returned in the order they appear in the input. +func SplitManifests(bigFile string) []string { + bigFileTmp := strings.TrimSpace(bigFile) + if bigFileTmp == "" { + return nil + } + + docs := yamlDocSeparator.Split(bigFileTmp, -1) + + var result []string + for _, d := range docs { + d = strings.TrimSpace(d) + if d == "" { + continue + } + + hasContent := false + for _, line := range strings.Split(d, "\n") { + trimmedLine := strings.TrimSpace(line) + if trimmedLine != "" && !strings.HasPrefix(trimmedLine, "#") { + hasContent = true + break + } + } + + if !hasContent { + continue + } + + d += "\n" + result = append(result, d) + } + + return result +} From 8348f9be24bc2310ca15e997e602a5c3847fb0a2 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Wed, 1 Apr 2026 20:27:15 +0300 Subject: [PATCH 03/85] refactor: remove unnecessary flags and featgates Signed-off-by: Dmitry Mordvinov --- cmd/nelm/chart_lint.go | 25 +--- cmd/nelm/chart_render.go | 32 +---- cmd/nelm/common_flags.go | 13 -- cmd/nelm/release_install.go | 32 +---- cmd/nelm/release_plan_install.go | 42 ++---- cmd/nelm/release_plan_show.go | 8 -- cmd/nelm/release_rollback.go | 8 -- docs/reference.md | 20 --- pkg/action/chart_render.go | 3 - pkg/action/release_plan_install.go | 19 +-- pkg/action/release_uninstall.go | 2 +- pkg/chart/chart_download.go | 3 +- pkg/chart/chart_render.go | 9 -- pkg/common/options.go | 6 - pkg/featgate/feat.go | 26 +--- pkg/plan/planned_changes.go | 6 - pkg/resource/helpers_ai_test.go | 2 - pkg/resource/sensitive.go | 31 +---- pkg/resource/sensitive_test.go | 210 +++++------------------------ pkg/resource/spec/resource_spec.go | 3 +- pkg/resource/validate.go | 3 +- 21 files changed, 77 insertions(+), 426 deletions(-) diff --git a/cmd/nelm/chart_lint.go b/cmd/nelm/chart_lint.go index 4ede744c..e727618d 100644 --- a/cmd/nelm/chart_lint.go +++ b/cmd/nelm/chart_lint.go @@ -10,7 +10,6 @@ import ( "github.com/werf/common-go/pkg/cli" "github.com/werf/nelm/pkg/action" "github.com/werf/nelm/pkg/common" - "github.com/werf/nelm/pkg/featgate" "github.com/werf/nelm/pkg/log" ) @@ -25,11 +24,7 @@ func newChartLintCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co cfg := &chartLintConfig{} use := "lint [options...]" - if featgate.FeatGateRemoteCharts.Enabled() || featgate.FeatGatePreviewV2.Enabled() { - use += " [chart-dir|chart-repo-name/chart-name|chart-archive|chart-archive-url]" - } else { - use += " [chart-dir]" - } + use += " [chart-dir|chart-repo-name/chart-name|chart-archive|chart-archive-url]" cmd := cli.NewSubCommand( ctx, @@ -48,11 +43,7 @@ func newChartLintCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co ctx = action.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultChartLintLogLevel), action.SetupLoggingOptions{ColorMode: cfg.LogColorMode}) if len(args) > 0 { - if featgate.FeatGateRemoteCharts.Enabled() || featgate.FeatGatePreviewV2.Enabled() { - cfg.Chart = args[0] - } else { - cfg.ChartDirPath = args[0] - } + cfg.Chart = args[0] } if err := action.ChartLint(ctx, cfg.ChartLintOptions); err != nil { @@ -114,13 +105,11 @@ func newChartLintCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co return fmt.Errorf("add flag: %w", err) } - if featgate.FeatGateRemoteCharts.Enabled() || featgate.FeatGatePreviewV2.Enabled() { - if err := cli.AddFlag(cmd, &cfg.ChartVersion, "chart-version", "", "Choose a remote chart version, otherwise the latest version is used", cli.AddFlagOptions{ - GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, - Group: mainFlagGroup, - }); err != nil { - return fmt.Errorf("add flag: %w", err) - } + if err := cli.AddFlag(cmd, &cfg.ChartVersion, "chart-version", "", "Choose a remote chart version, otherwise the latest version is used", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: mainFlagGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) } // TODO: restrict allowed values diff --git a/cmd/nelm/chart_render.go b/cmd/nelm/chart_render.go index 3e98a17b..61a8772b 100644 --- a/cmd/nelm/chart_render.go +++ b/cmd/nelm/chart_render.go @@ -10,7 +10,6 @@ import ( "github.com/werf/common-go/pkg/cli" "github.com/werf/nelm/pkg/action" "github.com/werf/nelm/pkg/common" - "github.com/werf/nelm/pkg/featgate" "github.com/werf/nelm/pkg/log" ) @@ -25,11 +24,7 @@ func newChartRenderCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[* cfg := &chartRenderConfig{} use := "render [options...]" - if featgate.FeatGateRemoteCharts.Enabled() || featgate.FeatGatePreviewV2.Enabled() { - use += " [chart-dir|chart-repo-name/chart-name|chart-archive|chart-archive-url]" - } else { - use += " [chart-dir]" - } + use += " [chart-dir|chart-repo-name/chart-name|chart-archive|chart-archive-url]" cmd := cli.NewSubCommand( ctx, @@ -51,11 +46,7 @@ func newChartRenderCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[* }) if len(args) > 0 { - if featgate.FeatGateRemoteCharts.Enabled() || featgate.FeatGatePreviewV2.Enabled() { - cfg.Chart = args[0] - } else { - cfg.ChartDirPath = args[0] - } + cfg.Chart = args[0] } if _, err := action.ChartRender(ctx, cfg.ChartRenderOptions); err != nil { @@ -113,13 +104,11 @@ func newChartRenderCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[* return fmt.Errorf("add flag: %w", err) } - if featgate.FeatGateRemoteCharts.Enabled() || featgate.FeatGatePreviewV2.Enabled() { - if err := cli.AddFlag(cmd, &cfg.ChartVersion, "chart-version", "", "Choose a remote chart version, otherwise the latest version is used", cli.AddFlagOptions{ - GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, - Group: mainFlagGroup, - }); err != nil { - return fmt.Errorf("add flag: %w", err) - } + if err := cli.AddFlag(cmd, &cfg.ChartVersion, "chart-version", "", "Choose a remote chart version, otherwise the latest version is used", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: mainFlagGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) } if err := cli.AddFlag(cmd, &cfg.ExtraAPIVersions, "extra-apiversions", nil, "Extra Kubernetes API versions passed to $.Capabilities.APIVersions", cli.AddFlagOptions{ @@ -150,13 +139,6 @@ func newChartRenderCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[* return fmt.Errorf("add flag: %w", err) } - if err := cli.AddFlag(cmd, &cfg.ForceAdoption, "force-adoption", false, "Always adopt resources, even if they belong to a different Helm release", cli.AddFlagOptions{ - GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, - Group: mainFlagGroup, - }); err != nil { - return fmt.Errorf("add flag: %w", err) - } - if err := cli.AddFlag(cmd, &cfg.LocalKubeVersion, "kube-version", common.DefaultLocalKubeVersion, "Kubernetes version stub for non-remote mode", cli.AddFlagOptions{ Group: mainFlagGroup, }); err != nil { diff --git a/cmd/nelm/common_flags.go b/cmd/nelm/common_flags.go index a8dbee95..8d5f00a8 100644 --- a/cmd/nelm/common_flags.go +++ b/cmd/nelm/common_flags.go @@ -7,7 +7,6 @@ import ( "github.com/werf/common-go/pkg/cli" "github.com/werf/nelm/pkg/common" - "github.com/werf/nelm/pkg/featgate" ) func AddChartRepoConnectionFlags(cmd *cobra.Command, cfg *common.ChartRepoConnectionOptions) error { @@ -297,10 +296,6 @@ func AddKubeConnectionFlags(cmd *cobra.Command, cfg *common.KubeConnectionOption } func AddResourceValidationFlags(cmd *cobra.Command, cfg *common.ResourceValidationOptions) error { - if !featgate.FeatGateResourceValidation.Enabled() { - return nil - } - if err := cli.AddFlag(cmd, &cfg.NoResourceValidation, "no-resource-validation", false, "Disable resource validation", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: resourceValidationGroup, @@ -458,14 +453,6 @@ func AddValuesFlags(cmd *cobra.Command, cfg *common.ValuesOptions) error { return fmt.Errorf("add flag: %w", err) } - if err := cli.AddFlag(cmd, &cfg.RuntimeSetJSON, "set-runtime-json", []string{}, "Set new keys in $.Runtime, where the key is the value path and the value is JSON. This is meant to be generated inside the program, so use --set-json instead, unless you know what you are doing", cli.AddFlagOptions{ - GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, - Group: valuesFlagGroup, - NoSplitOnCommas: true, - }); err != nil { - return fmt.Errorf("add flag: %w", err) - } - if err := cli.AddFlag(cmd, &cfg.ValuesFiles, "values", []string{}, "Additional values files", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: valuesFlagGroup, diff --git a/cmd/nelm/release_install.go b/cmd/nelm/release_install.go index 1cd0970f..19a0c04a 100644 --- a/cmd/nelm/release_install.go +++ b/cmd/nelm/release_install.go @@ -10,7 +10,6 @@ import ( "github.com/werf/common-go/pkg/cli" "github.com/werf/nelm/pkg/action" "github.com/werf/nelm/pkg/common" - "github.com/werf/nelm/pkg/featgate" "github.com/werf/nelm/pkg/log" ) @@ -27,11 +26,7 @@ func newReleaseInstallCommand(ctx context.Context, afterAllCommandsBuiltFuncs ma cfg := &releaseInstallConfig{} use := "install [options...] -n namespace -r release" - if featgate.FeatGateRemoteCharts.Enabled() || featgate.FeatGatePreviewV2.Enabled() { - use += " [chart-dir|chart-repo-name/chart-name|chart-archive|chart-archive-url]" - } else { - use += " [chart-dir]" - } + use += " [chart-dir|chart-repo-name/chart-name|chart-archive|chart-archive-url]" cmd := cli.NewSubCommand( ctx, @@ -50,11 +45,7 @@ func newReleaseInstallCommand(ctx context.Context, afterAllCommandsBuiltFuncs ma ctx = action.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultReleaseInstallLogLevel), action.SetupLoggingOptions{ColorMode: cfg.LogColorMode}) if len(args) > 0 { - if featgate.FeatGateRemoteCharts.Enabled() || featgate.FeatGatePreviewV2.Enabled() { - cfg.Chart = args[0] - } else { - cfg.ChartDirPath = args[0] - } + cfg.Chart = args[0] } if err := action.ReleaseInstall(ctx, cfg.ReleaseName, cfg.ReleaseNamespace, cfg.ReleaseInstallOptions); err != nil { @@ -126,13 +117,11 @@ func newReleaseInstallCommand(ctx context.Context, afterAllCommandsBuiltFuncs ma return fmt.Errorf("add flag: %w", err) } - if featgate.FeatGateRemoteCharts.Enabled() || featgate.FeatGatePreviewV2.Enabled() { - if err := cli.AddFlag(cmd, &cfg.ChartVersion, "chart-version", "", "Choose a remote chart version, otherwise the latest version is used", cli.AddFlagOptions{ - GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, - Group: mainFlagGroup, - }); err != nil { - return fmt.Errorf("add flag: %w", err) - } + if err := cli.AddFlag(cmd, &cfg.ChartVersion, "chart-version", "", "Choose a remote chart version, otherwise the latest version is used", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: mainFlagGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) } // TODO: restrict allowed values @@ -262,13 +251,6 @@ func newReleaseInstallCommand(ctx context.Context, afterAllCommandsBuiltFuncs ma return fmt.Errorf("add flag: %w", err) } - if err := cli.AddFlag(cmd, &cfg.RollbackGraphPath, "save-rollback-graph-to", "", "Save the Graphviz rollback graph to a file", cli.AddFlagOptions{ - Group: mainFlagGroup, - Type: cli.FlagTypeFile, - }); err != nil { - return fmt.Errorf("add flag: %w", err) - } - if err := cli.AddFlag(cmd, &cfg.ShowSubchartNotes, "show-subchart-notes", false, "Show NOTES.txt of subcharts after the release", cli.AddFlagOptions{ Group: mainFlagGroup, }); err != nil { diff --git a/cmd/nelm/release_plan_install.go b/cmd/nelm/release_plan_install.go index 8d1731ac..d7e09482 100644 --- a/cmd/nelm/release_plan_install.go +++ b/cmd/nelm/release_plan_install.go @@ -10,7 +10,6 @@ import ( "github.com/werf/common-go/pkg/cli" "github.com/werf/nelm/pkg/action" "github.com/werf/nelm/pkg/common" - "github.com/werf/nelm/pkg/featgate" "github.com/werf/nelm/pkg/log" ) @@ -27,11 +26,7 @@ func newReleasePlanInstallCommand(ctx context.Context, afterAllCommandsBuiltFunc cfg := &releasePlanInstallConfig{} use := "install [options...] -n namespace -r release" - if featgate.FeatGateRemoteCharts.Enabled() || featgate.FeatGatePreviewV2.Enabled() { - use += " [chart-dir|chart-repo-name/chart-name|chart-archive|chart-archive-url]" - } else { - use += " [chart-dir]" - } + use += " [chart-dir|chart-repo-name/chart-name|chart-archive|chart-archive-url]" cmd := cli.NewSubCommand( ctx, @@ -50,11 +45,7 @@ func newReleasePlanInstallCommand(ctx context.Context, afterAllCommandsBuiltFunc ctx = action.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultReleasePlanInstallLogLevel), action.SetupLoggingOptions{ColorMode: cfg.LogColorMode}) if len(args) > 0 { - if featgate.FeatGateRemoteCharts.Enabled() || featgate.FeatGatePreviewV2.Enabled() { - cfg.Chart = args[0] - } else { - cfg.ChartDirPath = args[0] - } + cfg.Chart = args[0] } if err := action.ReleasePlanInstall(ctx, cfg.ReleaseName, cfg.ReleaseNamespace, cfg.ReleasePlanInstallOptions); err != nil { @@ -116,13 +107,11 @@ func newReleasePlanInstallCommand(ctx context.Context, afterAllCommandsBuiltFunc return fmt.Errorf("add flag: %w", err) } - if featgate.FeatGateRemoteCharts.Enabled() || featgate.FeatGatePreviewV2.Enabled() { - if err := cli.AddFlag(cmd, &cfg.ChartVersion, "chart-version", "", "Choose a remote chart version, otherwise the latest version is used", cli.AddFlagOptions{ - GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, - Group: mainFlagGroup, - }); err != nil { - return fmt.Errorf("add flag: %w", err) - } + if err := cli.AddFlag(cmd, &cfg.ChartVersion, "chart-version", "", "Choose a remote chart version, otherwise the latest version is used", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: mainFlagGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) } // TODO: restrict allowed values @@ -140,14 +129,7 @@ func newReleasePlanInstallCommand(ctx context.Context, afterAllCommandsBuiltFunc return fmt.Errorf("add flag: %w", err) } - var desc string - if featgate.FeatGateMoreDetailedExitCodeForPlan.Enabled() || featgate.FeatGatePreviewV2.Enabled() { - desc = "Return exit code 0 if no changes, 1 if error, 2 if resource changes planned, 3 if no resource changes planned, but release still should be installed" - } else { - desc = "Return exit code 0 if no changes, 1 if error, 2 if any changes planned" - } - - if err := cli.AddFlag(cmd, &cfg.ErrorIfChangesPlanned, "exit-code", false, desc, cli.AddFlagOptions{ + if err := cli.AddFlag(cmd, &cfg.ErrorIfChangesPlanned, "exit-code", false, "Return exit code 0 if no changes, 1 if error, 2 if resource changes planned, 3 if no resource changes planned, but release still should be installed", cli.AddFlagOptions{ Group: mainFlagGroup, }); err != nil { return fmt.Errorf("add flag: %w", err) @@ -280,14 +262,6 @@ func newReleasePlanInstallCommand(ctx context.Context, afterAllCommandsBuiltFunc return fmt.Errorf("add flag: %w", err) } - // TODO(major): get rid? - if err := cli.AddFlag(cmd, &cfg.ShowVerboseDiffs, "show-verbose-diffs", true, "Show verbose diff lines", cli.AddFlagOptions{ - GetEnvVarRegexesFunc: cli.GetFlagLocalEnvVarRegexes, - Group: mainFlagGroup, - }); err != nil { - return fmt.Errorf("add flag: %w", err) - } - if err := cli.AddFlag(cmd, &cfg.PlanArtifactPath, "save-plan", "", "Save the gzip-compressed JSON install plan to the specified file", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagLocalEnvVarRegexes, Group: mainFlagGroup, diff --git a/cmd/nelm/release_plan_show.go b/cmd/nelm/release_plan_show.go index bf691208..2dff62e4 100644 --- a/cmd/nelm/release_plan_show.go +++ b/cmd/nelm/release_plan_show.go @@ -71,14 +71,6 @@ func newReleasePlanShowCommand(ctx context.Context, afterAllCommandsBuiltFuncs m return fmt.Errorf("add flag: %w", err) } - // TODO(v2): get rid? - if err := cli.AddFlag(cmd, &cfg.ShowVerboseDiffs, "show-verbose-diffs", true, "Show verbose diff lines", cli.AddFlagOptions{ - GetEnvVarRegexesFunc: cli.GetFlagLocalEnvVarRegexes, - Group: mainFlagGroup, - }); err != nil { - return fmt.Errorf("add flag: %w", err) - } - if err := cli.AddFlag(cmd, &cfg.SecretKey, "secret-key", "", "Secret key for decrypting the plan artifact", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: mainFlagGroup, diff --git a/cmd/nelm/release_rollback.go b/cmd/nelm/release_rollback.go index dbe9390d..10cd7f59 100644 --- a/cmd/nelm/release_rollback.go +++ b/cmd/nelm/release_rollback.go @@ -154,14 +154,6 @@ func newReleaseRollbackCommand(ctx context.Context, afterAllCommandsBuiltFuncs m return fmt.Errorf("add flag: %w", err) } - // TODO(major): remove this duplicated flag - if err := cli.AddFlag(cmd, &cfg.RollbackGraphPath, "save-rollback-graph-to", "", "Save the Graphviz rollback graph to a file", cli.AddFlagOptions{ - Group: mainFlagGroup, - Type: cli.FlagTypeFile, - }); err != nil { - return fmt.Errorf("add flag: %w", err) - } - if err := cli.AddFlag(cmd, &cfg.RollbackGraphPath, "save-graph-to", "", "Save the Graphviz rollback graph to a file", cli.AddFlagOptions{ Group: mainFlagGroup, Type: cli.FlagTypeFile, diff --git a/docs/reference.md b/docs/reference.md index 5a6e09d7..af60e4ee 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -183,10 +183,6 @@ nelm release install [options...] -n namespace -r release [chart-dir] Save the install report to a file\. Var: \$NELM\_RELEASE\_INSTALL\_SAVE\_REPORT\_TO -- `--save-rollback-graph-to` (default: `""`) - - Save the Graphviz rollback graph to a file\. Var: \$NELM\_RELEASE\_INSTALL\_SAVE\_ROLLBACK\_GRAPH\_TO - - `--show-subchart-notes` (default: `false`) Show NOTES\.txt of subcharts after the release\. Var: \$NELM\_RELEASE\_INSTALL\_SHOW\_SUBCHART\_NOTES @@ -564,10 +560,6 @@ nelm release rollback [options...] -n namespace -r release [revision] Save the rollback report to a file\. Var: \$NELM\_RELEASE\_ROLLBACK\_SAVE\_REPORT\_TO -- `--save-rollback-graph-to` (default: `""`) - - Save the Graphviz rollback graph to a file\. Var: \$NELM\_RELEASE\_ROLLBACK\_SAVE\_ROLLBACK\_GRAPH\_TO - - `--timeout` (default: `0s`) Fail if not finished in time\. Vars: \$NELM\_TIMEOUT, \$NELM\_RELEASE\_ROLLBACK\_TIMEOUT @@ -840,10 +832,6 @@ nelm release plan install [options...] -n namespace -r release [chart-dir] Show verbose CRD diff lines\. Var: \$NELM\_RELEASE\_PLAN\_INSTALL\_SHOW\_VERBOSE\_CRD\_DIFFS -- `--show-verbose-diffs` (default: `true`) - - Show verbose diff lines\. Var: \$NELM\_RELEASE\_PLAN\_INSTALL\_SHOW\_VERBOSE\_DIFFS - - `--templates-allow-dns` (default: `false`) Allow performing DNS requests in templating\. Vars: \$NELM\_TEMPLATES\_ALLOW\_DNS, \$NELM\_RELEASE\_PLAN\_INSTALL\_TEMPLATES\_ALLOW\_DNS @@ -1817,10 +1805,6 @@ nelm release plan show [options...] plan.json Show verbose CRD diff lines\. Var: \$NELM\_RELEASE\_PLAN\_SHOW\_SHOW\_VERBOSE\_CRD\_DIFFS -- `--show-verbose-diffs` (default: `true`) - - Show verbose diff lines\. Var: \$NELM\_RELEASE\_PLAN\_SHOW\_SHOW\_VERBOSE\_DIFFS - **Other options:** @@ -2190,10 +2174,6 @@ nelm chart render [options...] [chart-dir] Extra Kubernetes API versions passed to \$\.Capabilities\.APIVersions\. Vars: \$NELM\_EXTRA\_APIVERSIONS\_\*, \$NELM\_CHART\_RENDER\_EXTRA\_APIVERSIONS\_\* -- `--force-adoption` (default: `false`) - - Always adopt resources, even if they belong to a different Helm release\. Vars: \$NELM\_FORCE\_ADOPTION, \$NELM\_CHART\_RENDER\_FORCE\_ADOPTION - - `--kube-version` (default: `"1.20.0"`) Kubernetes version stub for non\-remote mode\. Var: \$NELM\_CHART\_RENDER\_KUBE\_VERSION diff --git a/pkg/action/chart_render.go b/pkg/action/chart_render.go index 658850aa..ebd4bebb 100644 --- a/pkg/action/chart_render.go +++ b/pkg/action/chart_render.go @@ -71,9 +71,6 @@ type ChartRenderOptions struct { // ExtraRuntimeAnnotations are additional annotations to add to resources at runtime. // TODO(major): remove or implement custom logic for this field. ExtraRuntimeAnnotations map[string]string - // ForceAdoption is currently unused in chart rendering. - // TODO(major): remove this useless field. - ForceAdoption bool // LegacyChartType specifies the chart type for legacy compatibility. // Used internally for backward compatibility with werf integration. LegacyChartType common.LegacyChartType diff --git a/pkg/action/release_plan_install.go b/pkg/action/release_plan_install.go index 023bee77..c043348a 100644 --- a/pkg/action/release_plan_install.go +++ b/pkg/action/release_plan_install.go @@ -15,7 +15,6 @@ import ( "github.com/werf/nelm/pkg/chart" "github.com/werf/nelm/pkg/common" - "github.com/werf/nelm/pkg/featgate" "github.com/werf/nelm/pkg/helm/pkg/registry" helmreleasestatus "github.com/werf/nelm/pkg/helm/pkg/release/common" "github.com/werf/nelm/pkg/kube" @@ -443,19 +442,13 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc } if opts.ErrorIfChangesPlanned { - if featgate.FeatGateMoreDetailedExitCodeForPlan.Enabled() || featgate.FeatGatePreviewV2.Enabled() { - if releaseIsUpToDate && installPlanIsUseless { - return nil - } else if installPlanIsUseless || len(changes) == 0 { - return ErrReleaseInstallPlanned - } else { - return ErrResourceChangesPlanned - } - } else { - if !releaseIsUpToDate || !installPlanIsUseless { - return ErrChangesPlanned - } + if releaseIsUpToDate && installPlanIsUseless { + return nil + } else if installPlanIsUseless || len(changes) == 0 { + return ErrReleaseInstallPlanned } + + return ErrResourceChangesPlanned } return nil diff --git a/pkg/action/release_uninstall.go b/pkg/action/release_uninstall.go index e3ecb026..a9f1d223 100644 --- a/pkg/action/release_uninstall.go +++ b/pkg/action/release_uninstall.go @@ -9,10 +9,10 @@ import ( "github.com/gookit/color" "github.com/samber/lo" - "github.com/werf/kubedog/pkg/dyntracker" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" + "github.com/werf/kubedog/pkg/dyntracker" "github.com/werf/kubedog/pkg/dyntracker/logstore" "github.com/werf/kubedog/pkg/dyntracker/statestore" kdutil "github.com/werf/kubedog/pkg/dyntracker/util" diff --git a/pkg/chart/chart_download.go b/pkg/chart/chart_download.go index 7d3b98ed..f62de0ee 100644 --- a/pkg/chart/chart_download.go +++ b/pkg/chart/chart_download.go @@ -8,7 +8,6 @@ import ( "os" "github.com/werf/nelm/pkg/common" - "github.com/werf/nelm/pkg/featgate" helmdownloader "github.com/werf/nelm/pkg/helm/pkg/downloader" helmgetter "github.com/werf/nelm/pkg/helm/pkg/getter" "github.com/werf/nelm/pkg/helm/pkg/helmpath" @@ -26,7 +25,7 @@ type chartDownloaderOptions struct { } func downloadChart(ctx context.Context, chartPath string, registryClient *helmregistry.Client, opts RenderChartOptions) (string, error) { - if (featgate.FeatGateRemoteCharts.Enabled() || featgate.FeatGatePreviewV2.Enabled()) && !isLocalChart(chartPath) { + if !isLocalChart(chartPath) { chartDownloader, chartRef, err := newChartDownloader(ctx, chartPath, registryClient, chartDownloaderOptions{ ChartRepoConnectionOptions: opts.ChartRepoConnectionOptions, ChartProvenanceKeyring: opts.ChartProvenanceKeyring, diff --git a/pkg/chart/chart_render.go b/pkg/chart/chart_render.go index c5b89473..bf2987b4 100644 --- a/pkg/chart/chart_render.go +++ b/pkg/chart/chart_render.go @@ -187,13 +187,6 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s return nil, fmt.Errorf("chart requires kubeVersion: %s which is incompatible with Kubernetes %s", chartKubeVersion, caps.KubeVersion.String()) } - runtime, err := buildContextFromJSONSets(opts.RuntimeSetJSON) - if err != nil { - return nil, fmt.Errorf("build runtime: %w", err) - } - - log.Default.TraceStruct(ctx, runtime, "Runtime:") - defaultRootContext, err := buildContextFromJSONSets(opts.RootSetJSON) if err != nil { return nil, fmt.Errorf("build default root context: %w", err) @@ -225,8 +218,6 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s return nil, fmt.Errorf("build rendered values for chart %q: %w", chartAccessor.Name(), err) } - renderedValues["Runtime"] = runtime - for k, v := range defaultRootContext { if _, exists := renderedValues[k]; !exists { renderedValues[k] = v diff --git a/pkg/common/options.go b/pkg/common/options.go index 82386e83..21b477bf 100644 --- a/pkg/common/options.go +++ b/pkg/common/options.go @@ -131,11 +131,6 @@ type ValuesOptions struct { // arbitrary things in the global root context ("$"). This is meant to be // generated programmatically. Do not use it unless you know what you are doing. RootSetJSON []string - // RuntimeSetJSON is a list of key-value pairs in "key=json" format to set in $.Runtime. - // This is meant to be generated programmatically. Users should prefer ValuesSetJSON. - // Example: ["runtime.env=dev", "runtime.timestamp=1234567890"] - // TODO(major): get rid of it - RuntimeSetJSON []string // ValuesFiles is a list of paths to additional values files to merge with chart values. // Files are merged in order, with later files overriding earlier ones. ValuesFiles []string @@ -291,7 +286,6 @@ type ResourceDiffOptions struct { ShowInsignificantDiffs bool ShowSensitiveDiffs bool ShowVerboseCRDDiffs bool - ShowVerboseDiffs bool } func (opts *ResourceDiffOptions) ApplyDefaults() { diff --git a/pkg/featgate/feat.go b/pkg/featgate/feat.go index 02cb1fec..822b8a90 100644 --- a/pkg/featgate/feat.go +++ b/pkg/featgate/feat.go @@ -12,35 +12,11 @@ import ( var ( FeatGateEnvVarsPrefix = caps.ToScreamingSnake(common.Brand) + "_FEAT_" // Contains all defined feature gates. - FeatGates = []*FeatGate{} - FeatGateRemoteCharts = NewFeatGate( - "remote-charts", - `Allow not only local, but also remote charts as an argument to cli commands. Also adds the "--chart-version" option`, - ) + FeatGates = []*FeatGate{} FeatGatePeriodicStackTraces = NewFeatGate( "periodic-stack-traces", `Print stack traces periodically to help with debugging deadlocks and other issues`, ) - FeatGateFieldSensitive = NewFeatGate( - "field-sensitive", - `Enable JSONPath-based selective sensitive field redaction`, - ) - FeatGatePreviewV2 = NewFeatGate( - "preview-v2", - `Activate all feature gates that will be enabled by default in Nelm v2`, - ) - FeatGateCleanNullFields = NewFeatGate( - "clean-null-fields", - `Enable cleaning of null fields from resource manifests for better Helm chart compatibility`, - ) - FeatGateMoreDetailedExitCodeForPlan = NewFeatGate( - "more-detailed-exit-code-for-plan", - `Make the "plan" command with the flag "--exit-code" return an exit code 3 instead of 2 when no resource changes, but still must install the release`, - ) - FeatGateResourceValidation = NewFeatGate( - "resource-validation", - "Validate chart resources against specific Kubernetes resources' schemas", - ) FeatGateTypescript = NewFeatGate( "typescript", `Enable TypeScript chart rendering from ts/ directory`, diff --git a/pkg/plan/planned_changes.go b/pkg/plan/planned_changes.go index bc9101e6..65172868 100644 --- a/pkg/plan/planned_changes.go +++ b/pkg/plan/planned_changes.go @@ -18,9 +18,7 @@ import ( const ( HiddenInsignificantChanges = "" - HiddenSensitiveChanges = "" HiddenVerboseCRDChanges = "" - HiddenVerboseChanges = "" ) type ResourceChange struct { @@ -44,10 +42,6 @@ func (c *ResourceChange) UDiff(opts common.ResourceDiffOptions) (string, error) !opts.ShowVerboseCRDDiffs && (c.Before == nil || c.After == nil) { uDiff = HiddenVerboseCRDChanges - } else if sensitiveInfo.FullySensitive() && !opts.ShowSensitiveDiffs { - uDiff = HiddenSensitiveChanges - } else if !opts.ShowVerboseDiffs && (c.Before == nil || c.After == nil) { - uDiff = HiddenVerboseChanges } else { var ( oldObjManifest string diff --git a/pkg/resource/helpers_ai_test.go b/pkg/resource/helpers_ai_test.go index c0768660..b16ffc35 100644 --- a/pkg/resource/helpers_ai_test.go +++ b/pkg/resource/helpers_ai_test.go @@ -16,7 +16,6 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "github.com/werf/nelm/pkg/common" - "github.com/werf/nelm/pkg/featgate" "github.com/werf/nelm/pkg/resource" "github.com/werf/nelm/pkg/resource/spec" ) @@ -151,5 +150,4 @@ func setupLocalSchemaDir(t *testing.T, schemas map[string]string) string { func setupTestEnvironment(t *testing.T) { t.Helper() t.Setenv("HELM_CACHE_HOME", t.TempDir()) - featgate.FeatGateResourceValidation.Enable() } diff --git a/pkg/resource/sensitive.go b/pkg/resource/sensitive.go index f742047a..a08e5b00 100644 --- a/pkg/resource/sensitive.go +++ b/pkg/resource/sensitive.go @@ -13,7 +13,6 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "github.com/werf/nelm/pkg/common" - "github.com/werf/nelm/pkg/featgate" "github.com/werf/nelm/pkg/resource/spec" ) @@ -24,10 +23,6 @@ type SensitiveInfo struct { SensitivePaths []string } -func (i *SensitiveInfo) FullySensitive() bool { - return i.IsSensitive && len(i.SensitivePaths) == 1 && i.SensitivePaths[0] == HideAll -} - func GetSensitiveInfo(groupKind schema.GroupKind, annotations map[string]string) SensitiveInfo { // Check for werf.io/sensitive-paths (comma-separated) if _, value, found := spec.FindAnnotationOrLabelByKeyPattern(annotations, common.AnnotationKeyPatternSensitivePaths); found { @@ -37,33 +32,18 @@ func GetSensitiveInfo(groupKind schema.GroupKind, annotations map[string]string) } } - useNewBehavior := featgate.FeatGateFieldSensitive.Enabled() || featgate.FeatGatePreviewV2.Enabled() - // Check for werf.io/sensitive annotation if _, value, found := spec.FindAnnotationOrLabelByKeyPattern(annotations, common.AnnotationKeyPatternSensitive); found { - sensitive := lo.Must(strconv.ParseBool(value)) - if sensitive { - if useNewBehavior { - // V2 behavior: only hide data.* and stringData.* - return SensitiveInfo{IsSensitive: true, SensitivePaths: []string{"data.*", "stringData.*"}} - } else { - // V1 behavior: hide everything - return SensitiveInfo{IsSensitive: true, SensitivePaths: []string{HideAll}} - } - } else { - return SensitiveInfo{IsSensitive: false, SensitivePaths: nil} + if lo.Must(strconv.ParseBool(value)) { + return SensitiveInfo{IsSensitive: true, SensitivePaths: []string{"data.*", "stringData.*"}} } + + return SensitiveInfo{IsSensitive: false, SensitivePaths: nil} } // Default behavior for Secrets if groupKind == (schema.GroupKind{Group: "", Kind: "Secret"}) { - if useNewBehavior { - // V2 behavior: only hide data.* and stringData.* - return SensitiveInfo{IsSensitive: true, SensitivePaths: []string{"data.*", "stringData.*"}} - } else { - // V1 behavior: hide everything - return SensitiveInfo{IsSensitive: true, SensitivePaths: []string{HideAll}} - } + return SensitiveInfo{IsSensitive: true, SensitivePaths: []string{"data.*", "stringData.*"}} } return SensitiveInfo{IsSensitive: false, SensitivePaths: nil} @@ -116,6 +96,7 @@ func RedactSensitiveData(unstruct *unstructured.Unstructured, sensitivePaths []s func redactSensitiveData(unstruct *unstructured.Unstructured, sensitivePaths []string) *unstructured.Unstructured { for _, pathExpr := range sensitivePaths { + // TODO(major): should we remove this? if pathExpr == HideAll { return &unstructured.Unstructured{Object: map[string]interface{}{ "apiVersion": unstruct.GetAPIVersion(), diff --git a/pkg/resource/sensitive_test.go b/pkg/resource/sensitive_test.go index 3305641e..a9374a57 100644 --- a/pkg/resource/sensitive_test.go +++ b/pkg/resource/sensitive_test.go @@ -1,114 +1,67 @@ package resource_test import ( - "os" - "strings" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" - "github.com/werf/nelm/pkg/featgate" "github.com/werf/nelm/pkg/resource" ) func TestGetSensitiveInfo(t *testing.T) { - // Save original env and restore after test - originalEnv := os.Getenv(featgate.FeatGateFieldSensitive.EnvVarName()) - defer func() { - if originalEnv != "" { - t.Setenv(featgate.FeatGateFieldSensitive.EnvVarName(), originalEnv) - } - }() - tests := []struct { - name string - enableFeature bool - groupKind schema.GroupKind - annotations map[string]string - expected resource.SensitiveInfo + name string + groupKind schema.GroupKind + annotations map[string]string + expected resource.SensitiveInfo }{ { - name: "regular resource not sensitive", - enableFeature: true, - groupKind: schema.GroupKind{Group: "apps", Kind: "Deployment"}, - annotations: map[string]string{}, - expected: resource.SensitiveInfo{IsSensitive: false, SensitivePaths: nil}, + name: "regular resource not sensitive", + groupKind: schema.GroupKind{Group: "apps", Kind: "Deployment"}, + annotations: map[string]string{}, + expected: resource.SensitiveInfo{IsSensitive: false, SensitivePaths: nil}, }, { - name: "secret resource automatically sensitive - legacy behavior", - enableFeature: false, - groupKind: schema.GroupKind{Group: "", Kind: "Secret"}, - annotations: map[string]string{}, - expected: resource.SensitiveInfo{IsSensitive: true, SensitivePaths: []string{"$$HIDE_ALL$$"}}, + name: "secret with sensitive annotation set to false", + groupKind: schema.GroupKind{Group: "", Kind: "Secret"}, + annotations: map[string]string{"werf.io/sensitive": "false"}, + expected: resource.SensitiveInfo{IsSensitive: false, SensitivePaths: nil}, }, { - name: "secret resource with annotation - legacy behavior", - enableFeature: false, - groupKind: schema.GroupKind{Group: "", Kind: "Secret"}, - annotations: map[string]string{ - "werf.io/sensitive": "false", - }, - expected: resource.SensitiveInfo{IsSensitive: false, SensitivePaths: nil}, + name: "secret resource automatically sensitive", + groupKind: schema.GroupKind{Group: "", Kind: "Secret"}, + annotations: map[string]string{}, + expected: resource.SensitiveInfo{IsSensitive: true, SensitivePaths: []string{"data.*", "stringData.*"}}, }, { - name: "secret with sensitive annotation set to false", - enableFeature: true, - groupKind: schema.GroupKind{Group: "", Kind: "Secret"}, - annotations: map[string]string{ - "werf.io/sensitive": "false", - }, - expected: resource.SensitiveInfo{IsSensitive: false, SensitivePaths: nil}, - }, - { - name: "secret resource automatically sensitive - new behavior", - enableFeature: true, - groupKind: schema.GroupKind{Group: "", Kind: "Secret"}, - annotations: map[string]string{}, - expected: resource.SensitiveInfo{IsSensitive: true, SensitivePaths: []string{"data.*", "stringData.*"}}, - }, - { - name: "resource with sensitive annotation set to true - legacy behavior", - enableFeature: false, - groupKind: schema.GroupKind{Group: "apps", Kind: "Deployment"}, - annotations: map[string]string{ - "werf.io/sensitive": "true", - }, - expected: resource.SensitiveInfo{IsSensitive: true, SensitivePaths: []string{resource.HideAll}}, - }, - { - name: "resource with sensitive annotation set to true - new behavior", - enableFeature: true, - groupKind: schema.GroupKind{Group: "apps", Kind: "Deployment"}, + name: "resource with sensitive annotation set to true", + groupKind: schema.GroupKind{Group: "apps", Kind: "Deployment"}, annotations: map[string]string{ "werf.io/sensitive": "true", }, expected: resource.SensitiveInfo{IsSensitive: true, SensitivePaths: []string{"data.*", "stringData.*"}}, }, { - name: "resource with comma-separated sensitive-paths annotation", - enableFeature: true, - groupKind: schema.GroupKind{Group: "apps", Kind: "Deployment"}, + name: "resource with comma-separated sensitive-paths annotation", + groupKind: schema.GroupKind{Group: "apps", Kind: "Deployment"}, annotations: map[string]string{ "werf.io/sensitive-paths": "spec.template.spec.containers.*.env.*.value,data.password", }, expected: resource.SensitiveInfo{IsSensitive: true, SensitivePaths: []string{"spec.template.spec.containers.*.env.*.value", "data.password"}}, }, { - name: "resource with escaped comma in sensitive-paths", - enableFeature: true, - groupKind: schema.GroupKind{Group: "apps", Kind: "Deployment"}, + name: "resource with escaped comma in sensitive-paths", + groupKind: schema.GroupKind{Group: "apps", Kind: "Deployment"}, annotations: map[string]string{ "werf.io/sensitive-paths": "data.field\\,with\\,commas,spec.other", }, expected: resource.SensitiveInfo{IsSensitive: true, SensitivePaths: []string{"data.field,with,commas", "spec.other"}}, }, { - name: "resource with both sensitive and sensitive-paths annotations - sensitive path precedence in v2", - enableFeature: true, - groupKind: schema.GroupKind{Group: "apps", Kind: "Deployment"}, + name: "resource with both sensitive and sensitive-paths annotations - sensitive path precedence in v2", + groupKind: schema.GroupKind{Group: "apps", Kind: "Deployment"}, annotations: map[string]string{ "werf.io/sensitive": "true", "werf.io/sensitive-paths": "data.password", @@ -116,32 +69,17 @@ func TestGetSensitiveInfo(t *testing.T) { expected: resource.SensitiveInfo{IsSensitive: true, SensitivePaths: []string{"data.password"}}, }, { - name: "resource with empty sensitive-paths annotation", - enableFeature: true, - groupKind: schema.GroupKind{Group: "apps", Kind: "Deployment"}, + name: "resource with empty sensitive-paths annotation", + groupKind: schema.GroupKind{Group: "apps", Kind: "Deployment"}, annotations: map[string]string{ "werf.io/sensitive-paths": "", }, expected: resource.SensitiveInfo{IsSensitive: false, SensitivePaths: nil}, }, - { - name: "resource with sensitive-paths annotation - feature flag disabled", - enableFeature: false, - groupKind: schema.GroupKind{Group: "v1", Kind: "ConfigMap"}, - annotations: map[string]string{ - "werf.io/sensitive-paths": "$.data[*]", - }, - expected: resource.SensitiveInfo{IsSensitive: true, SensitivePaths: []string{"$.data[*]"}}, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Set feature gate - if tt.enableFeature { - t.Setenv(featgate.FeatGateFieldSensitive.EnvVarName(), "true") - } - result := resource.GetSensitiveInfo(tt.groupKind, tt.annotations) assert.Equal(t, tt.expected, result, "behavior should match expected") @@ -206,16 +144,6 @@ func TestParseSensitivePaths(t *testing.T) { } func TestRedactAtJSONPath(t *testing.T) { - // Enable feature gate - originalEnv := os.Getenv(featgate.FeatGateFieldSensitive.EnvVarName()) - defer func() { - if originalEnv != "" { - t.Setenv(featgate.FeatGateFieldSensitive.EnvVarName(), originalEnv) - } - }() - - t.Setenv(featgate.FeatGateFieldSensitive.EnvVarName(), "true") - tests := []struct { name string input *unstructured.Unstructured @@ -585,61 +513,14 @@ func TestRedactAtJSONPath(t *testing.T) { } func TestRedactSensitiveData(t *testing.T) { - // Save original env and restore after test - originalEnv := os.Getenv(featgate.FeatGateFieldSensitive.EnvVarName()) - defer func() { - if originalEnv != "" { - t.Setenv(featgate.FeatGateFieldSensitive.EnvVarName(), originalEnv) - } - }() - tests := []struct { name string - enableFeature bool input *unstructured.Unstructured sensitivePaths []string checkFunc func(t *testing.T, result *unstructured.Unstructured) }{ { - name: "bug: sensitive-paths ignored when feature flag disabled", - enableFeature: false, - input: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]interface{}{ - "name": "test-config", - "namespace": "default", - }, - "data": map[string]interface{}{ - "key1": "sensitive-value-1", - "key2": "sensitive-value-2", - }, - }, - }, - sensitivePaths: []string{"data.*"}, - checkFunc: func(t *testing.T, result *unstructured.Unstructured) { - // The bug is that when feature flag is disabled, the entire data section - // is removed instead of redacting only the specified sensitive paths - data, found, err := unstructured.NestedMap(result.Object, "data") - require.NoError(t, err) - - if !found { - t.Errorf("data section was completely removed instead of being redacted") - } else { - // If data exists, it should be redacted - for key, value := range data { - valueStr, ok := value.(string) - if ok && !strings.Contains(valueStr, "sensitive") { - t.Errorf("Expected data.%s to be redacted but got: %s", key, valueStr) - } - } - } - }, - }, - { - name: "no sensitive paths", - enableFeature: true, + name: "no sensitive paths", input: &unstructured.Unstructured{ Object: map[string]interface{}{ "apiVersion": "v1", @@ -662,8 +543,7 @@ func TestRedactSensitiveData(t *testing.T) { }, }, { - name: "hide all with feature gate", - enableFeature: true, + name: "hide all", input: &unstructured.Unstructured{ Object: map[string]interface{}{ "apiVersion": "v1", @@ -689,8 +569,7 @@ func TestRedactSensitiveData(t *testing.T) { }, }, { - name: "redact data fields with wildcard - new behavior", - enableFeature: true, + name: "redact data fields with wildcard", input: &unstructured.Unstructured{ Object: map[string]interface{}{ "apiVersion": "v1", @@ -723,8 +602,7 @@ func TestRedactSensitiveData(t *testing.T) { }, }, { - name: "redact specific field", - enableFeature: true, + name: "redact specific field", input: &unstructured.Unstructured{ Object: map[string]interface{}{ "apiVersion": "v1", @@ -753,8 +631,7 @@ func TestRedactSensitiveData(t *testing.T) { }, }, { - name: "type change handling - string to slice", - enableFeature: true, + name: "type change handling - string to slice", input: &unstructured.Unstructured{ Object: map[string]interface{}{ "apiVersion": "apps/v1", @@ -797,11 +674,6 @@ func TestRedactSensitiveData(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Set feature gate - if tt.enableFeature { - t.Setenv(featgate.FeatGateFieldSensitive.EnvVarName(), "true") - } - result := resource.RedactSensitiveData(tt.input, tt.sensitivePaths) // Ensure original object is not modified @@ -813,16 +685,6 @@ func TestRedactSensitiveData(t *testing.T) { } func TestRedactSensitiveDataEdgeCases(t *testing.T) { - // Enable feature gate - originalEnv := os.Getenv(featgate.FeatGateFieldSensitive.EnvVarName()) - defer func() { - if originalEnv != "" { - t.Setenv(featgate.FeatGateFieldSensitive.EnvVarName(), originalEnv) - } - }() - - t.Setenv(featgate.FeatGateFieldSensitive.EnvVarName(), "true") - tests := []struct { name string input *unstructured.Unstructured @@ -867,16 +729,6 @@ func TestRedactSensitiveDataEdgeCases(t *testing.T) { } func TestSHA256HashingConsistency(t *testing.T) { - // Enable feature gate - originalEnv := os.Getenv(featgate.FeatGateFieldSensitive.EnvVarName()) - defer func() { - if originalEnv != "" { - t.Setenv(featgate.FeatGateFieldSensitive.EnvVarName(), originalEnv) - } - }() - - t.Setenv(featgate.FeatGateFieldSensitive.EnvVarName(), "true") - input1 := &unstructured.Unstructured{ Object: map[string]interface{}{ "data": map[string]interface{}{ diff --git a/pkg/resource/spec/resource_spec.go b/pkg/resource/spec/resource_spec.go index 92ec9ed6..1cae4a22 100644 --- a/pkg/resource/spec/resource_spec.go +++ b/pkg/resource/spec/resource_spec.go @@ -10,7 +10,6 @@ import ( "k8s.io/client-go/kubernetes/scheme" "github.com/werf/nelm/pkg/common" - "github.com/werf/nelm/pkg/featgate" ) // Contains all generic information about the resource, e.g. its name, namespace, GVK and its spec. @@ -25,7 +24,7 @@ type ResourceSpec struct { func NewResourceSpec(unstruct *unstructured.Unstructured, releaseNamespace string, opts ResourceSpecOptions) *ResourceSpec { unstruct = CleanUnstruct(unstruct, CleanUnstructOptions{ - CleanNullFields: (featgate.FeatGatePreviewV2.Enabled() || featgate.FeatGateCleanNullFields.Enabled()) && !opts.LegacyNoCleanNullFields, + CleanNullFields: !opts.LegacyNoCleanNullFields, }) if opts.StoreAs == "" { diff --git a/pkg/resource/validate.go b/pkg/resource/validate.go index caf35424..8d58afd2 100644 --- a/pkg/resource/validate.go +++ b/pkg/resource/validate.go @@ -11,7 +11,6 @@ import ( "k8s.io/client-go/kubernetes/scheme" "github.com/werf/nelm/pkg/common" - "github.com/werf/nelm/pkg/featgate" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/resource/spec" "github.com/werf/nelm/pkg/util" @@ -25,7 +24,7 @@ func ValidateLocal(ctx context.Context, releaseNamespace string, transformedReso return fmt.Errorf("validate for no duplicated resources: %w", err) } - if featgate.FeatGateResourceValidation.Enabled() && !opts.NoResourceValidation { + if !opts.NoResourceValidation { if err := validateResourceSchemas(ctx, releaseNamespace, transformedResources, opts); err != nil { return fmt.Errorf("validate resource schemas: %w", err) } From c0894306fd9c69b8dc5ea1ff51951e3c58bb880c Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Thu, 2 Apr 2026 17:16:18 +0300 Subject: [PATCH 04/85] refactor: remove unnecessary ChartDirPath Signed-off-by: Dmitry Mordvinov --- Taskfile.dist.yaml | 2 +- pkg/action/chart_lint.go | 6 +----- pkg/action/chart_render.go | 6 +----- pkg/action/release_install.go | 6 +----- pkg/action/release_plan_install.go | 7 +------ 5 files changed, 5 insertions(+), 22 deletions(-) diff --git a/Taskfile.dist.yaml b/Taskfile.dist.yaml index 580b9719..05fd4530 100644 --- a/Taskfile.dist.yaml +++ b/Taskfile.dist.yaml @@ -52,7 +52,7 @@ tasks: internal: true run: once cmds: - - golangci-lint run {{if eq .fix "true"}}--fix{{end}} --build-tags="{{.tags}}" {{.CLI_ARGS}} {{.paths | default "$(find pkg/ -mindepth 1 -maxdepth 1 -type d ! -name helm -printf './%p/... ' 2>/dev/null || find pkg/ -mindepth 1 -maxdepth 1 -type d ! -name helm | xargs -I{} echo './{}/...') ./cmd/..."}} + - golangci-lint run {{if eq .fix "true"}}--fix{{end}} --build-tags="{{.tags}}" {{.CLI_ARGS}} {{.paths | default "$(find pkg/ -mindepth 1 -maxdepth 1 -type d ! -name helm -printf './%p/... ') ./cmd/..."}} vars: paths: "{{.paths}}" diff --git a/pkg/action/chart_lint.go b/pkg/action/chart_lint.go index ad4e0eed..24c36202 100644 --- a/pkg/action/chart_lint.go +++ b/pkg/action/chart_lint.go @@ -37,8 +37,6 @@ type ChartLintOptions struct { // ChartAppVersion overrides the appVersion field in Chart.yaml. // Used to set application version metadata without modifying the chart file. ChartAppVersion string - // ChartDirPath is deprecated (TODO v2: remove). Use Chart instead. - ChartDirPath string // ChartProvenanceKeyring is the path to a keyring file containing public keys // used to verify chart provenance signatures. Used with signed charts for security. ChartProvenanceKeyring string @@ -387,9 +385,7 @@ func applyChartLintOptionsDefaults(opts ChartLintOptions, currentDir, homeDir st opts.ValuesOptions.ApplyDefaults() opts.SecretValuesOptions.ApplyDefaults(currentDir) - if opts.Chart == "" && opts.ChartDirPath != "" { - opts.Chart = opts.ChartDirPath - } else if opts.ChartDirPath == "" && opts.Chart == "" { + if opts.Chart == "" { opts.Chart = currentDir } diff --git a/pkg/action/chart_render.go b/pkg/action/chart_render.go index ebd4bebb..c7c92b9c 100644 --- a/pkg/action/chart_render.go +++ b/pkg/action/chart_render.go @@ -39,8 +39,6 @@ type ChartRenderOptions struct { // ChartAppVersion overrides the appVersion field in Chart.yaml. // Used to set application version metadata without modifying the chart file. ChartAppVersion string - // ChartDirPath is deprecated (TODO v2: remove). Use Chart instead. - ChartDirPath string // ChartProvenanceKeyring is the path to a keyring file containing public keys // used to verify chart provenance signatures. Used with signed charts for security. ChartProvenanceKeyring string @@ -386,9 +384,7 @@ func applyChartRenderOptionsDefaults(opts ChartRenderOptions, currentDir, homeDi opts.ValuesOptions.ApplyDefaults() opts.SecretValuesOptions.ApplyDefaults(currentDir) - if opts.Chart == "" && opts.ChartDirPath != "" { - opts.Chart = opts.ChartDirPath - } else if opts.ChartDirPath == "" && opts.Chart == "" { + if opts.Chart == "" { opts.Chart = currentDir } diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index 0c07f336..7824c23a 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -55,8 +55,6 @@ type ReleaseInstallOptions struct { // ChartAppVersion overrides the appVersion field in Chart.yaml. // Used to set application version metadata without modifying the chart file. ChartAppVersion string - // ChartDirPath is deprecated - ChartDirPath string // TODO(major): get rid // ChartProvenanceKeyring is the path to a keyring file containing public keys // used to verify chart provenance signatures. Used with signed charts for security. ChartProvenanceKeyring string @@ -692,9 +690,7 @@ func applyReleaseInstallOptionsDefaults(opts ReleaseInstallOptions, currentDir, opts.SecretValuesOptions.ApplyDefaults(currentDir) opts.TrackingOptions.ApplyDefaults() - if opts.Chart == "" && opts.ChartDirPath != "" { - opts.Chart = opts.ChartDirPath - } else if opts.ChartDirPath == "" && opts.Chart == "" { + if opts.Chart == "" { opts.Chart = currentDir } diff --git a/pkg/action/release_plan_install.go b/pkg/action/release_plan_install.go index c043348a..6201b3e6 100644 --- a/pkg/action/release_plan_install.go +++ b/pkg/action/release_plan_install.go @@ -49,9 +49,6 @@ type ReleasePlanInstallOptions struct { // ChartAppVersion overrides the appVersion field in Chart.yaml. // Used to set application version metadata without modifying the chart file. ChartAppVersion string - // ChartDirPath is deprecated - // TODO(major): get rid - ChartDirPath string // ChartProvenanceKeyring is the path to a keyring file containing public keys // used to verify chart provenance signatures. Used with signed charts for security. ChartProvenanceKeyring string @@ -508,9 +505,7 @@ func applyReleasePlanInstallOptionsDefaults(opts ReleasePlanInstallOptions, curr opts.ResourceDiffOptions.ApplyDefaults() opts.SecretValuesOptions.ApplyDefaults(currentDir) - if opts.Chart == "" && opts.ChartDirPath != "" { - opts.Chart = opts.ChartDirPath - } else if opts.ChartDirPath == "" && opts.Chart == "" { + if opts.Chart == "" { opts.Chart = currentDir } From 3ef6dc9bdf4b2a84ec09af46554d3379fa958da4 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Thu, 2 Apr 2026 19:40:31 +0300 Subject: [PATCH 05/85] refactor: update dependency handling to support multiple operations Signed-off-by: Dmitry Mordvinov --- pkg/plan/plan_build.go | 91 ++-- pkg/plan/plan_build_ai_test.go | 878 +++++++++++++++++++++++++++++++++ 2 files changed, 927 insertions(+), 42 deletions(-) create mode 100644 pkg/plan/plan_build_ai_test.go diff --git a/pkg/plan/plan_build.go b/pkg/plan/plan_build.go index 5773fefe..e414aab0 100644 --- a/pkg/plan/plan_build.go +++ b/pkg/plan/plan_build.go @@ -100,33 +100,23 @@ func connectInternalDeployDependencies(plan *Plan, instInfos []*InstallableResou } for _, dep := range internalDeps { - var ( - dependUponOp *Operation - dependUponOpFound bool - ) + var dependUponOps []*Operation switch dep.ResourceState { case common.ResourceStatePresent: - dependUponOp, dependUponOpFound = findDeployOpInStage(plan, instInfos, dep, info.Stage) + dependUponOps = findDeployOpInStage(plan, instInfos, dep, info.Stage) case common.ResourceStateReady: - dependUponOp, dependUponOpFound = findTrackReadinessOpInStage(plan, instInfos, dep, info.Stage) + dependUponOps = findTrackReadinessOpInStage(plan, instInfos, dep, info.Stage) case common.ResourceStateAbsent: - // TODO(major): all deploy/delete dependencies must depend upon all matched operations, not a single one - dependUponOps := findTrackAbsenceOpInStage(plan, delInfos, instInfos, dep, info.Stage) - if len(dependUponOps) > 0 { - dependUponOp = dependUponOps[0] - dependUponOpFound = true - } + dependUponOps = findTrackAbsenceOpInStage(plan, delInfos, instInfos, dep, info.Stage) default: panic("unexpected internal dependency resource state") } - if !dependUponOpFound { - continue - } - - if err := plan.Connect(dependUponOp.ID(), deployOp.ID()); err != nil { - return fmt.Errorf("depend %q from %q: %w", deployOp.ID(), dependUponOp.ID(), err) + for _, dependUponOp := range dependUponOps { + if err := plan.Connect(dependUponOp.ID(), deployOp.ID()); err != nil { + return fmt.Errorf("depend %q from %q: %w", deployOp.ID(), dependUponOp.ID(), err) + } } } } @@ -236,24 +226,32 @@ func connectInternalDeleteDependencies(plan *Plan, delInfos []*DeletableResource return nil } -func findDeployOpInStage(plan *Plan, instInfos []*InstallableResourceInfo, dep *resource.InternalDependency, sourceStage common.Stage) (*Operation, bool) { - var match *InstallableResourceInfo +func findDeployOpInStage(plan *Plan, instInfos []*InstallableResourceInfo, dep *resource.InternalDependency, sourceStage common.Stage) []*Operation { + matchByID := make(map[string]*InstallableResourceInfo) for _, candidate := range instInfos { + match, found := matchByID[candidate.ID()] if candidate.MustInstall == ResourceInstallTypeNone || candidate.Stage != sourceStage || !dep.Match(candidate.ResourceMeta) || - (match != nil && candidate.Iteration >= match.Iteration) { + (found && candidate.Iteration >= match.Iteration) { continue } - match = candidate + matchByID[candidate.ID()] = candidate } - if match == nil { - return nil, false + if len(matchByID) == 0 { + return nil + } + + var foundOps []*Operation + for _, match := range matchByID { + if op, found := getDeployOp(plan, match); found { + foundOps = append(foundOps, op) + } } - return getDeployOp(plan, match) + return foundOps } func addDeleteReleaseOps(plan *Plan, info *ReleaseInfo) { @@ -730,52 +728,61 @@ func findTrackAbsenceOpInStage(plan *Plan, delInfos []*DeletableResourceInfo, in return foundOps } - var match *InstallableResourceInfo + matchByID := make(map[string]*InstallableResourceInfo) for _, candidate := range instInfos { + match, found := matchByID[candidate.ID()] if !candidate.MustDeleteOnSuccessfulInstall || candidate.StageDeleteOnSuccessfulInstall != sourceStage || !dep.Match(candidate.ResourceMeta) || - (match != nil && candidate.Iteration >= match.Iteration) { + (found && candidate.Iteration >= match.Iteration) { continue } - match = candidate + matchByID[candidate.ID()] = candidate } - if match == nil { + if len(matchByID) == 0 { return nil } - opID := OperationID(OperationTypeTrackAbsence, OperationVersionTrackAbsence, OperationIteration(match.Iteration), match.ID()) + for _, match := range matchByID { + opID := OperationID(OperationTypeTrackAbsence, OperationVersionTrackAbsence, OperationIteration(match.Iteration), match.ID()) - op, found := plan.Operation(opID) - if !found { - return nil + if op, found := plan.Operation(opID); found { + foundOps = append(foundOps, op) + } } - return []*Operation{op} + return foundOps } -func findTrackReadinessOpInStage(plan *Plan, instInfos []*InstallableResourceInfo, dep *resource.InternalDependency, sourceStage common.Stage) (*Operation, bool) { - var match *InstallableResourceInfo +func findTrackReadinessOpInStage(plan *Plan, instInfos []*InstallableResourceInfo, dep *resource.InternalDependency, sourceStage common.Stage) []*Operation { + matchByID := make(map[string]*InstallableResourceInfo) for _, candidate := range instInfos { + match, found := matchByID[candidate.ID()] if !candidate.MustTrackReadiness || candidate.Stage != sourceStage || !dep.Match(candidate.ResourceMeta) || - (match != nil && candidate.Iteration >= match.Iteration) { + (found && candidate.Iteration >= match.Iteration) { continue } - match = candidate + matchByID[candidate.ID()] = candidate } - if match == nil { - return nil, false + if len(matchByID) == 0 { + return nil } - opID := OperationID(OperationTypeTrackReadiness, OperationVersionTrackReadiness, OperationIteration(match.Iteration), match.ID()) + var foundOps []*Operation + for _, match := range matchByID { + opID := OperationID(OperationTypeTrackReadiness, OperationVersionTrackReadiness, OperationIteration(match.Iteration), match.ID()) + if op, found := plan.Operation(opID); found { + foundOps = append(foundOps, op) + } + } - return plan.Operation(opID) + return foundOps } func getDeleteOp(plan *Plan, info *DeletableResourceInfo) (*Operation, bool) { diff --git a/pkg/plan/plan_build_ai_test.go b/pkg/plan/plan_build_ai_test.go new file mode 100644 index 00000000..48ee21a7 --- /dev/null +++ b/pkg/plan/plan_build_ai_test.go @@ -0,0 +1,878 @@ +//go:build ai_tests + +package plan_test + +import ( + "fmt" + "testing" + + "github.com/dominikbraun/graph" + "github.com/stretchr/testify/suite" + + "github.com/werf/nelm/pkg/common" + "github.com/werf/nelm/pkg/plan" + "github.com/werf/nelm/pkg/resource" + "github.com/werf/nelm/pkg/resource/spec" +) + +type BuildPlanAISuite struct { + BuildPlanSuite +} + +func (s *BuildPlanAISuite) TestAI_BuildPlanConnectsAllMatchingDependencies() { + testCases := []buildPlanTestCase{ + { + expect: func(installableInfos []*plan.InstallableResourceInfo, deletableInfos []*plan.DeletableResourceInfo, releaseInfos []*plan.ReleaseInfo) ([]*plan.Operation, map[string]map[string]graph.Edge[string]) { + createOp1 := &plan.Operation{ + Type: plan.OperationTypeCreate, + Version: plan.OperationVersionCreate, + Category: plan.OperationCategoryResource, + Config: &plan.OperationConfigCreate{ + ResourceSpec: installableInfos[0].LocalResource.ResourceSpec, + }, + } + + createOp2 := &plan.Operation{ + Type: plan.OperationTypeCreate, + Version: plan.OperationVersionCreate, + Category: plan.OperationCategoryResource, + Config: &plan.OperationConfigCreate{ + ResourceSpec: installableInfos[1].LocalResource.ResourceSpec, + }, + } + + createDependentOp := &plan.Operation{ + Type: plan.OperationTypeCreate, + Version: plan.OperationVersionCreate, + Category: plan.OperationCategoryResource, + Config: &plan.OperationConfigCreate{ + ResourceSpec: installableInfos[2].LocalResource.ResourceSpec, + }, + } + + mainStageStartOp := &plan.Operation{ + Type: plan.OperationTypeNoop, + Version: plan.OperationVersionNoop, + Category: plan.OperationCategoryMeta, + Config: &plan.OperationConfigNoop{ + OpID: fmt.Sprintf("%s/%s/%s", common.StagePrefix, common.StageInstall, common.StageStartSuffix), + }, + } + + mainStageEndOp := &plan.Operation{ + Type: plan.OperationTypeNoop, + Version: plan.OperationVersionNoop, + Category: plan.OperationCategoryMeta, + Config: &plan.OperationConfigNoop{ + OpID: fmt.Sprintf("%s/%s/%s", common.StagePrefix, common.StageInstall, common.StageEndSuffix), + }, + } + + ops := []*plan.Operation{ + mainStageStartOp, + createOp1, + createOp2, + createDependentOp, + mainStageEndOp, + } + + adjMap := map[string]map[string]graph.Edge[string]{ + mainStageStartOp.ID(): { + createOp1.ID(): {}, + createOp2.ID(): {}, + }, + createOp1.ID(): { + createDependentOp.ID(): {}, + }, + createOp2.ID(): { + createDependentOp.ID(): {}, + }, + createDependentOp.ID(): { + mainStageEndOp.ID(): {}, + }, + mainStageEndOp.ID(): {}, + } + + return ops, adjMap + }, + input: func() (installableInfos []*plan.InstallableResourceInfo, deletableInfos []*plan.DeletableResourceInfo, releaseInfos []*plan.ReleaseInfo, opts plan.BuildPlanOptions) { + res1 := defaultInstallableResource(s.releaseName, s.releaseNamespace) + res1.Name = "test-configmap-1" + res1.Unstruct.SetName("test-configmap-1") + res1.Weight = nil + + res2 := defaultInstallableResource(s.releaseName, s.releaseNamespace) + res2.Name = "test-configmap-2" + res2.Unstruct.SetName("test-configmap-2") + res2.Weight = nil + + dependentRes := defaultInstallableResource(s.releaseName, s.releaseNamespace) + dependentRes.Name = "dependent-secret" + dependentRes.Unstruct.SetName("dependent-secret") + dependentRes.Unstruct.SetKind("Secret") + dependentRes.GroupVersionKind.Kind = "Secret" + dependentRes.Weight = nil + dependentRes.AutoInternalDependencies = []*resource.InternalDependency{ + { + ResourceMatcher: &spec.ResourceMatcher{ + Kinds: []string{"ConfigMap"}, + }, + ResourceState: common.ResourceStatePresent, + }, + } + + info1 := defaultInstallableResourceInfo(res1) + info1.MustTrackReadiness = false + + info2 := defaultInstallableResourceInfo(res2) + info2.MustTrackReadiness = false + + dependentInfo := defaultInstallableResourceInfo(dependentRes) + dependentInfo.MustTrackReadiness = false + + return []*plan.InstallableResourceInfo{info1, info2, dependentInfo}, nil, nil, plan.BuildPlanOptions{} + }, + name: "connect deploy dependency to all matching create operations", + }, + { + expect: func(installableInfos []*plan.InstallableResourceInfo, deletableInfos []*plan.DeletableResourceInfo, releaseInfos []*plan.ReleaseInfo) ([]*plan.Operation, map[string]map[string]graph.Edge[string]) { + createOp1 := &plan.Operation{ + Type: plan.OperationTypeCreate, + Version: plan.OperationVersionCreate, + Category: plan.OperationCategoryResource, + Config: &plan.OperationConfigCreate{ + ResourceSpec: installableInfos[0].LocalResource.ResourceSpec, + }, + } + + createOp2 := &plan.Operation{ + Type: plan.OperationTypeCreate, + Version: plan.OperationVersionCreate, + Category: plan.OperationCategoryResource, + Config: &plan.OperationConfigCreate{ + ResourceSpec: installableInfos[1].LocalResource.ResourceSpec, + }, + } + + createDependentOp := &plan.Operation{ + Type: plan.OperationTypeCreate, + Version: plan.OperationVersionCreate, + Category: plan.OperationCategoryResource, + Config: &plan.OperationConfigCreate{ + ResourceSpec: installableInfos[2].LocalResource.ResourceSpec, + }, + } + + trackReadinessOp1 := &plan.Operation{ + Type: plan.OperationTypeTrackReadiness, + Version: plan.OperationVersionTrackReadiness, + Category: plan.OperationCategoryTrack, + Config: &plan.OperationConfigTrackReadiness{ + ResourceMeta: installableInfos[0].LocalResource.ResourceMeta, + FailMode: installableInfos[0].LocalResource.FailMode, + FailuresAllowed: installableInfos[0].LocalResource.FailuresAllowed, + IgnoreLogs: installableInfos[0].LocalResource.SkipLogs, + IgnoreLogsForContainers: installableInfos[0].LocalResource.SkipLogsForContainers, + IgnoreReadinessProbeFailsByContainerName: installableInfos[0].LocalResource.IgnoreReadinessProbeFailsForContainers, + NoActivityTimeout: installableInfos[0].LocalResource.NoActivityTimeout, + SaveEvents: installableInfos[0].LocalResource.ShowServiceMessages, + SaveLogsByRegex: installableInfos[0].LocalResource.LogRegex, + SaveLogsByRegexForContainers: installableInfos[0].LocalResource.LogRegexesForContainers, + SaveLogsOnlyForContainers: installableInfos[0].LocalResource.ShowLogsOnlyForContainers, + SaveLogsOnlyForNumberOfReplicas: installableInfos[0].LocalResource.ShowLogsOnlyForNumberOfReplicas, + }, + } + + trackReadinessOp2 := &plan.Operation{ + Type: plan.OperationTypeTrackReadiness, + Version: plan.OperationVersionTrackReadiness, + Category: plan.OperationCategoryTrack, + Config: &plan.OperationConfigTrackReadiness{ + ResourceMeta: installableInfos[1].LocalResource.ResourceMeta, + FailMode: installableInfos[1].LocalResource.FailMode, + FailuresAllowed: installableInfos[1].LocalResource.FailuresAllowed, + IgnoreLogs: installableInfos[1].LocalResource.SkipLogs, + IgnoreLogsForContainers: installableInfos[1].LocalResource.SkipLogsForContainers, + IgnoreReadinessProbeFailsByContainerName: installableInfos[1].LocalResource.IgnoreReadinessProbeFailsForContainers, + NoActivityTimeout: installableInfos[1].LocalResource.NoActivityTimeout, + SaveEvents: installableInfos[1].LocalResource.ShowServiceMessages, + SaveLogsByRegex: installableInfos[1].LocalResource.LogRegex, + SaveLogsByRegexForContainers: installableInfos[1].LocalResource.LogRegexesForContainers, + SaveLogsOnlyForContainers: installableInfos[1].LocalResource.ShowLogsOnlyForContainers, + SaveLogsOnlyForNumberOfReplicas: installableInfos[1].LocalResource.ShowLogsOnlyForNumberOfReplicas, + }, + } + + mainStageStartOp := &plan.Operation{ + Type: plan.OperationTypeNoop, + Version: plan.OperationVersionNoop, + Category: plan.OperationCategoryMeta, + Config: &plan.OperationConfigNoop{ + OpID: fmt.Sprintf("%s/%s/%s", common.StagePrefix, common.StageInstall, common.StageStartSuffix), + }, + } + + mainStageEndOp := &plan.Operation{ + Type: plan.OperationTypeNoop, + Version: plan.OperationVersionNoop, + Category: plan.OperationCategoryMeta, + Config: &plan.OperationConfigNoop{ + OpID: fmt.Sprintf("%s/%s/%s", common.StagePrefix, common.StageInstall, common.StageEndSuffix), + }, + } + + ops := []*plan.Operation{ + mainStageStartOp, + createOp1, + trackReadinessOp1, + createOp2, + trackReadinessOp2, + createDependentOp, + mainStageEndOp, + } + + adjMap := map[string]map[string]graph.Edge[string]{ + mainStageStartOp.ID(): { + createOp1.ID(): {}, + createOp2.ID(): {}, + }, + createOp1.ID(): { + trackReadinessOp1.ID(): {}, + }, + trackReadinessOp1.ID(): { + createDependentOp.ID(): {}, + }, + createOp2.ID(): { + trackReadinessOp2.ID(): {}, + }, + trackReadinessOp2.ID(): { + createDependentOp.ID(): {}, + }, + createDependentOp.ID(): { + mainStageEndOp.ID(): {}, + }, + mainStageEndOp.ID(): {}, + } + + return ops, adjMap + }, + input: func() (installableInfos []*plan.InstallableResourceInfo, deletableInfos []*plan.DeletableResourceInfo, releaseInfos []*plan.ReleaseInfo, opts plan.BuildPlanOptions) { + res1 := defaultInstallableResource(s.releaseName, s.releaseNamespace) + res1.Name = "test-configmap-1" + res1.Unstruct.SetName("test-configmap-1") + res1.Weight = nil + + res2 := defaultInstallableResource(s.releaseName, s.releaseNamespace) + res2.Name = "test-configmap-2" + res2.Unstruct.SetName("test-configmap-2") + res2.Weight = nil + + dependentRes := defaultInstallableResource(s.releaseName, s.releaseNamespace) + dependentRes.Name = "dependent-secret" + dependentRes.Unstruct.SetName("dependent-secret") + dependentRes.Unstruct.SetKind("Secret") + dependentRes.GroupVersionKind.Kind = "Secret" + dependentRes.Weight = nil + dependentRes.AutoInternalDependencies = []*resource.InternalDependency{ + { + ResourceMatcher: &spec.ResourceMatcher{ + Kinds: []string{"ConfigMap"}, + }, + ResourceState: common.ResourceStateReady, + }, + } + + info1 := defaultInstallableResourceInfo(res1) + info2 := defaultInstallableResourceInfo(res2) + dependentInfo := defaultInstallableResourceInfo(dependentRes) + dependentInfo.MustTrackReadiness = false + + return []*plan.InstallableResourceInfo{info1, info2, dependentInfo}, nil, nil, plan.BuildPlanOptions{} + }, + name: "connect deploy dependency to all matching track-readiness operations", + }, + { + expect: func(installableInfos []*plan.InstallableResourceInfo, deletableInfos []*plan.DeletableResourceInfo, releaseInfos []*plan.ReleaseInfo) ([]*plan.Operation, map[string]map[string]graph.Edge[string]) { + deleteOp1 := &plan.Operation{ + Type: plan.OperationTypeDelete, + Version: plan.OperationVersionDelete, + Category: plan.OperationCategoryResource, + Config: &plan.OperationConfigDelete{ + ResourceMeta: deletableInfos[0].LocalResource.ResourceMeta, + DeletePropagation: deletableInfos[0].LocalResource.DeletePropagation, + }, + } + + trackAbsenceOp1 := &plan.Operation{ + Type: plan.OperationTypeTrackAbsence, + Version: plan.OperationVersionTrackAbsence, + Category: plan.OperationCategoryTrack, + Config: &plan.OperationConfigTrackAbsence{ + ResourceMeta: deletableInfos[0].LocalResource.ResourceMeta, + }, + } + + deleteOp2 := &plan.Operation{ + Type: plan.OperationTypeDelete, + Version: plan.OperationVersionDelete, + Category: plan.OperationCategoryResource, + Config: &plan.OperationConfigDelete{ + ResourceMeta: deletableInfos[1].LocalResource.ResourceMeta, + DeletePropagation: deletableInfos[1].LocalResource.DeletePropagation, + }, + } + + trackAbsenceOp2 := &plan.Operation{ + Type: plan.OperationTypeTrackAbsence, + Version: plan.OperationVersionTrackAbsence, + Category: plan.OperationCategoryTrack, + Config: &plan.OperationConfigTrackAbsence{ + ResourceMeta: deletableInfos[1].LocalResource.ResourceMeta, + }, + } + + createDependentOp := &plan.Operation{ + Type: plan.OperationTypeCreate, + Version: plan.OperationVersionCreate, + Category: plan.OperationCategoryResource, + Config: &plan.OperationConfigCreate{ + ResourceSpec: installableInfos[0].LocalResource.ResourceSpec, + }, + } + + mainStageStartOp := &plan.Operation{ + Type: plan.OperationTypeNoop, + Version: plan.OperationVersionNoop, + Category: plan.OperationCategoryMeta, + Config: &plan.OperationConfigNoop{ + OpID: fmt.Sprintf("%s/%s/%s", common.StagePrefix, common.StageInstall, common.StageStartSuffix), + }, + } + + mainStageEndOp := &plan.Operation{ + Type: plan.OperationTypeNoop, + Version: plan.OperationVersionNoop, + Category: plan.OperationCategoryMeta, + Config: &plan.OperationConfigNoop{ + OpID: fmt.Sprintf("%s/%s/%s", common.StagePrefix, common.StageInstall, common.StageEndSuffix), + }, + } + + ops := []*plan.Operation{ + mainStageStartOp, + deleteOp1, + trackAbsenceOp1, + deleteOp2, + trackAbsenceOp2, + createDependentOp, + mainStageEndOp, + } + + adjMap := map[string]map[string]graph.Edge[string]{ + mainStageStartOp.ID(): { + deleteOp1.ID(): {}, + deleteOp2.ID(): {}, + }, + deleteOp1.ID(): { + trackAbsenceOp1.ID(): {}, + }, + trackAbsenceOp1.ID(): { + createDependentOp.ID(): {}, + }, + deleteOp2.ID(): { + trackAbsenceOp2.ID(): {}, + }, + trackAbsenceOp2.ID(): { + createDependentOp.ID(): {}, + }, + createDependentOp.ID(): { + mainStageEndOp.ID(): {}, + }, + mainStageEndOp.ID(): {}, + } + + return ops, adjMap + }, + input: func() (installableInfos []*plan.InstallableResourceInfo, deletableInfos []*plan.DeletableResourceInfo, releaseInfos []*plan.ReleaseInfo, opts plan.BuildPlanOptions) { + delRes1 := defaultDeletableResource(s.releaseName, s.releaseNamespace) + delRes1.Name = "test-configmap-1" + + delRes2 := defaultDeletableResource(s.releaseName, s.releaseNamespace) + delRes2.Name = "test-configmap-2" + + delInfo1 := defaultDeletableResourceInfo(delRes1, s.releaseName, s.releaseNamespace) + delInfo1.Stage = common.StageInstall + + delInfo2 := defaultDeletableResourceInfo(delRes2, s.releaseName, s.releaseNamespace) + delInfo2.Stage = common.StageInstall + + dependentRes := defaultInstallableResource(s.releaseName, s.releaseNamespace) + dependentRes.Name = "dependent-secret" + dependentRes.Unstruct.SetName("dependent-secret") + dependentRes.Unstruct.SetKind("Secret") + dependentRes.GroupVersionKind.Kind = "Secret" + dependentRes.Weight = nil + dependentRes.AutoInternalDependencies = []*resource.InternalDependency{ + { + ResourceMatcher: &spec.ResourceMatcher{ + Kinds: []string{"ConfigMap"}, + }, + ResourceState: common.ResourceStateAbsent, + }, + } + + dependentInfo := defaultInstallableResourceInfo(dependentRes) + dependentInfo.MustTrackReadiness = false + + return []*plan.InstallableResourceInfo{dependentInfo}, []*plan.DeletableResourceInfo{delInfo1, delInfo2}, nil, plan.BuildPlanOptions{} + }, + name: "connect deploy dependency to all matching track-absence operations", + }, + { + expect: func(installableInfos []*plan.InstallableResourceInfo, deletableInfos []*plan.DeletableResourceInfo, releaseInfos []*plan.ReleaseInfo) ([]*plan.Operation, map[string]map[string]graph.Edge[string]) { + deleteOp1 := &plan.Operation{ + Type: plan.OperationTypeDelete, + Version: plan.OperationVersionDelete, + Category: plan.OperationCategoryResource, + Config: &plan.OperationConfigDelete{ + ResourceMeta: deletableInfos[0].LocalResource.ResourceMeta, + DeletePropagation: deletableInfos[0].LocalResource.DeletePropagation, + }, + } + + trackAbsenceOp1 := &plan.Operation{ + Type: plan.OperationTypeTrackAbsence, + Version: plan.OperationVersionTrackAbsence, + Category: plan.OperationCategoryTrack, + Config: &plan.OperationConfigTrackAbsence{ + ResourceMeta: deletableInfos[0].LocalResource.ResourceMeta, + }, + } + + deleteOp2 := &plan.Operation{ + Type: plan.OperationTypeDelete, + Version: plan.OperationVersionDelete, + Category: plan.OperationCategoryResource, + Config: &plan.OperationConfigDelete{ + ResourceMeta: deletableInfos[1].LocalResource.ResourceMeta, + DeletePropagation: deletableInfos[1].LocalResource.DeletePropagation, + }, + } + + trackAbsenceOp2 := &plan.Operation{ + Type: plan.OperationTypeTrackAbsence, + Version: plan.OperationVersionTrackAbsence, + Category: plan.OperationCategoryTrack, + Config: &plan.OperationConfigTrackAbsence{ + ResourceMeta: deletableInfos[1].LocalResource.ResourceMeta, + }, + } + + deleteDependentOp := &plan.Operation{ + Type: plan.OperationTypeDelete, + Version: plan.OperationVersionDelete, + Category: plan.OperationCategoryResource, + Config: &plan.OperationConfigDelete{ + ResourceMeta: deletableInfos[2].LocalResource.ResourceMeta, + DeletePropagation: deletableInfos[2].LocalResource.DeletePropagation, + }, + } + + trackAbsenceDependentOp := &plan.Operation{ + Type: plan.OperationTypeTrackAbsence, + Version: plan.OperationVersionTrackAbsence, + Category: plan.OperationCategoryTrack, + Config: &plan.OperationConfigTrackAbsence{ + ResourceMeta: deletableInfos[2].LocalResource.ResourceMeta, + }, + } + + mainStageStartOp := &plan.Operation{ + Type: plan.OperationTypeNoop, + Version: plan.OperationVersionNoop, + Category: plan.OperationCategoryMeta, + Config: &plan.OperationConfigNoop{ + OpID: fmt.Sprintf("%s/%s/%s", common.StagePrefix, common.StageUninstall, common.StageStartSuffix), + }, + } + + mainStageEndOp := &plan.Operation{ + Type: plan.OperationTypeNoop, + Version: plan.OperationVersionNoop, + Category: plan.OperationCategoryMeta, + Config: &plan.OperationConfigNoop{ + OpID: fmt.Sprintf("%s/%s/%s", common.StagePrefix, common.StageUninstall, common.StageEndSuffix), + }, + } + + ops := []*plan.Operation{ + mainStageStartOp, + deleteOp1, + trackAbsenceOp1, + deleteOp2, + trackAbsenceOp2, + deleteDependentOp, + trackAbsenceDependentOp, + mainStageEndOp, + } + + adjMap := map[string]map[string]graph.Edge[string]{ + mainStageStartOp.ID(): { + deleteOp1.ID(): {}, + deleteOp2.ID(): {}, + }, + deleteOp1.ID(): { + trackAbsenceOp1.ID(): {}, + }, + trackAbsenceOp1.ID(): { + deleteDependentOp.ID(): {}, + }, + deleteOp2.ID(): { + trackAbsenceOp2.ID(): {}, + }, + trackAbsenceOp2.ID(): { + deleteDependentOp.ID(): {}, + }, + deleteDependentOp.ID(): { + trackAbsenceDependentOp.ID(): {}, + }, + trackAbsenceDependentOp.ID(): { + mainStageEndOp.ID(): {}, + }, + mainStageEndOp.ID(): {}, + } + + return ops, adjMap + }, + input: func() (installableInfos []*plan.InstallableResourceInfo, deletableInfos []*plan.DeletableResourceInfo, releaseInfos []*plan.ReleaseInfo, opts plan.BuildPlanOptions) { + delRes1 := defaultDeletableResource(s.releaseName, s.releaseNamespace) + delRes1.Name = "test-configmap-1" + + delRes2 := defaultDeletableResource(s.releaseName, s.releaseNamespace) + delRes2.Name = "test-configmap-2" + + dependentDelRes := defaultDeletableResource(s.releaseName, s.releaseNamespace) + dependentDelRes.Name = "dependent-secret" + dependentDelRes.GroupVersionKind.Kind = "Secret" + dependentDelRes.AutoInternalDependencies = []*resource.InternalDependency{ + { + ResourceMatcher: &spec.ResourceMatcher{ + Kinds: []string{"ConfigMap"}, + }, + ResourceState: common.ResourceStateAbsent, + }, + } + + delInfo1 := defaultDeletableResourceInfo(delRes1, s.releaseName, s.releaseNamespace) + delInfo2 := defaultDeletableResourceInfo(delRes2, s.releaseName, s.releaseNamespace) + dependentDelInfo := defaultDeletableResourceInfo(dependentDelRes, s.releaseName, s.releaseNamespace) + + return nil, []*plan.DeletableResourceInfo{delInfo1, delInfo2, dependentDelInfo}, nil, plan.BuildPlanOptions{} + }, + name: "connect delete dependency to all matching track-absence operations", + }, + } + + for _, tc := range testCases { + s.Run(tc.name, runBuildPlanTest(tc, &s.BuildPlanSuite)) + } +} + +func (s *BuildPlanAISuite) TestAI_BuildPlanConnectsOnlySpecificMatchingDependencies() { + testCases := []buildPlanTestCase{ + { + expect: func(installableInfos []*plan.InstallableResourceInfo, deletableInfos []*plan.DeletableResourceInfo, releaseInfos []*plan.ReleaseInfo) ([]*plan.Operation, map[string]map[string]graph.Edge[string]) { + createOp1 := &plan.Operation{ + Type: plan.OperationTypeCreate, + Version: plan.OperationVersionCreate, + Category: plan.OperationCategoryResource, + Config: &plan.OperationConfigCreate{ResourceSpec: installableInfos[0].LocalResource.ResourceSpec}, + } + createOp2 := &plan.Operation{ + Type: plan.OperationTypeCreate, + Version: plan.OperationVersionCreate, + Category: plan.OperationCategoryResource, + Config: &plan.OperationConfigCreate{ResourceSpec: installableInfos[1].LocalResource.ResourceSpec}, + } + createOp3 := &plan.Operation{ + Type: plan.OperationTypeCreate, + Version: plan.OperationVersionCreate, + Category: plan.OperationCategoryResource, + Config: &plan.OperationConfigCreate{ResourceSpec: installableInfos[2].LocalResource.ResourceSpec}, + } + createDependentOp := &plan.Operation{ + Type: plan.OperationTypeCreate, + Version: plan.OperationVersionCreate, + Category: plan.OperationCategoryResource, + Config: &plan.OperationConfigCreate{ResourceSpec: installableInfos[3].LocalResource.ResourceSpec}, + } + mainStageStartOp := &plan.Operation{ + Type: plan.OperationTypeNoop, + Version: plan.OperationVersionNoop, + Category: plan.OperationCategoryMeta, + Config: &plan.OperationConfigNoop{OpID: fmt.Sprintf("%s/%s/%s", common.StagePrefix, common.StageInstall, common.StageStartSuffix)}, + } + mainStageEndOp := &plan.Operation{ + Type: plan.OperationTypeNoop, + Version: plan.OperationVersionNoop, + Category: plan.OperationCategoryMeta, + Config: &plan.OperationConfigNoop{OpID: fmt.Sprintf("%s/%s/%s", common.StagePrefix, common.StageInstall, common.StageEndSuffix)}, + } + + ops := []*plan.Operation{mainStageStartOp, createOp1, createOp2, createOp3, createDependentOp, mainStageEndOp} + adjMap := map[string]map[string]graph.Edge[string]{ + mainStageStartOp.ID(): {createOp1.ID(): {}, createOp2.ID(): {}, createOp3.ID(): {}}, + createOp1.ID(): {createDependentOp.ID(): {}}, + createOp2.ID(): {createDependentOp.ID(): {}}, + createOp3.ID(): {mainStageEndOp.ID(): {}}, + createDependentOp.ID(): { + mainStageEndOp.ID(): {}, + }, + mainStageEndOp.ID(): {}, + } + + return ops, adjMap + }, + input: func() (installableInfos []*plan.InstallableResourceInfo, deletableInfos []*plan.DeletableResourceInfo, releaseInfos []*plan.ReleaseInfo, opts plan.BuildPlanOptions) { + res1 := defaultInstallableResource(s.releaseName, s.releaseNamespace) + res1.Name = "test-configmap-1" + res1.Unstruct.SetName("test-configmap-1") + res1.Weight = nil + + res2 := defaultInstallableResource(s.releaseName, s.releaseNamespace) + res2.Name = "test-configmap-2" + res2.Unstruct.SetName("test-configmap-2") + res2.Weight = nil + + res3 := defaultInstallableResource(s.releaseName, s.releaseNamespace) + res3.Name = "test-configmap-3" + res3.Unstruct.SetName("test-configmap-3") + res3.Weight = nil + + dependentRes := defaultInstallableResource(s.releaseName, s.releaseNamespace) + dependentRes.Name = "dependent-secret" + dependentRes.Unstruct.SetName("dependent-secret") + dependentRes.Unstruct.SetKind("Secret") + dependentRes.GroupVersionKind.Kind = "Secret" + dependentRes.Weight = nil + dependentRes.AutoInternalDependencies = []*resource.InternalDependency{{ + ResourceMatcher: &spec.ResourceMatcher{Names: []string{"test-configmap-1", "test-configmap-2"}, Kinds: []string{"ConfigMap"}}, + ResourceState: common.ResourceStatePresent, + }} + + info1 := defaultInstallableResourceInfo(res1) + info1.MustTrackReadiness = false + info2 := defaultInstallableResourceInfo(res2) + info2.MustTrackReadiness = false + info3 := defaultInstallableResourceInfo(res3) + info3.MustTrackReadiness = false + dependentInfo := defaultInstallableResourceInfo(dependentRes) + dependentInfo.MustTrackReadiness = false + + return []*plan.InstallableResourceInfo{info1, info2, info3, dependentInfo}, nil, nil, plan.BuildPlanOptions{} + }, + name: "connect deploy dependency only to specifically matched create operations", + }, + { + expect: func(installableInfos []*plan.InstallableResourceInfo, deletableInfos []*plan.DeletableResourceInfo, releaseInfos []*plan.ReleaseInfo) ([]*plan.Operation, map[string]map[string]graph.Edge[string]) { + createOp1 := &plan.Operation{Type: plan.OperationTypeCreate, Version: plan.OperationVersionCreate, Category: plan.OperationCategoryResource, Config: &plan.OperationConfigCreate{ResourceSpec: installableInfos[0].LocalResource.ResourceSpec}} + trackReadinessOp1 := &plan.Operation{Type: plan.OperationTypeTrackReadiness, Version: plan.OperationVersionTrackReadiness, Category: plan.OperationCategoryTrack, Config: &plan.OperationConfigTrackReadiness{ResourceMeta: installableInfos[0].LocalResource.ResourceMeta, FailMode: installableInfos[0].LocalResource.FailMode, FailuresAllowed: installableInfos[0].LocalResource.FailuresAllowed, IgnoreLogs: installableInfos[0].LocalResource.SkipLogs, IgnoreLogsForContainers: installableInfos[0].LocalResource.SkipLogsForContainers, IgnoreReadinessProbeFailsByContainerName: installableInfos[0].LocalResource.IgnoreReadinessProbeFailsForContainers, NoActivityTimeout: installableInfos[0].LocalResource.NoActivityTimeout, SaveEvents: installableInfos[0].LocalResource.ShowServiceMessages, SaveLogsByRegex: installableInfos[0].LocalResource.LogRegex, SaveLogsByRegexForContainers: installableInfos[0].LocalResource.LogRegexesForContainers, SaveLogsOnlyForContainers: installableInfos[0].LocalResource.ShowLogsOnlyForContainers, SaveLogsOnlyForNumberOfReplicas: installableInfos[0].LocalResource.ShowLogsOnlyForNumberOfReplicas}} + + createOp2 := &plan.Operation{Type: plan.OperationTypeCreate, Version: plan.OperationVersionCreate, Category: plan.OperationCategoryResource, Config: &plan.OperationConfigCreate{ResourceSpec: installableInfos[1].LocalResource.ResourceSpec}} + trackReadinessOp2 := &plan.Operation{Type: plan.OperationTypeTrackReadiness, Version: plan.OperationVersionTrackReadiness, Category: plan.OperationCategoryTrack, Config: &plan.OperationConfigTrackReadiness{ResourceMeta: installableInfos[1].LocalResource.ResourceMeta, FailMode: installableInfos[1].LocalResource.FailMode, FailuresAllowed: installableInfos[1].LocalResource.FailuresAllowed, IgnoreLogs: installableInfos[1].LocalResource.SkipLogs, IgnoreLogsForContainers: installableInfos[1].LocalResource.SkipLogsForContainers, IgnoreReadinessProbeFailsByContainerName: installableInfos[1].LocalResource.IgnoreReadinessProbeFailsForContainers, NoActivityTimeout: installableInfos[1].LocalResource.NoActivityTimeout, SaveEvents: installableInfos[1].LocalResource.ShowServiceMessages, SaveLogsByRegex: installableInfos[1].LocalResource.LogRegex, SaveLogsByRegexForContainers: installableInfos[1].LocalResource.LogRegexesForContainers, SaveLogsOnlyForContainers: installableInfos[1].LocalResource.ShowLogsOnlyForContainers, SaveLogsOnlyForNumberOfReplicas: installableInfos[1].LocalResource.ShowLogsOnlyForNumberOfReplicas}} + + createOp3 := &plan.Operation{Type: plan.OperationTypeCreate, Version: plan.OperationVersionCreate, Category: plan.OperationCategoryResource, Config: &plan.OperationConfigCreate{ResourceSpec: installableInfos[2].LocalResource.ResourceSpec}} + trackReadinessOp3 := &plan.Operation{Type: plan.OperationTypeTrackReadiness, Version: plan.OperationVersionTrackReadiness, Category: plan.OperationCategoryTrack, Config: &plan.OperationConfigTrackReadiness{ResourceMeta: installableInfos[2].LocalResource.ResourceMeta, FailMode: installableInfos[2].LocalResource.FailMode, FailuresAllowed: installableInfos[2].LocalResource.FailuresAllowed, IgnoreLogs: installableInfos[2].LocalResource.SkipLogs, IgnoreLogsForContainers: installableInfos[2].LocalResource.SkipLogsForContainers, IgnoreReadinessProbeFailsByContainerName: installableInfos[2].LocalResource.IgnoreReadinessProbeFailsForContainers, NoActivityTimeout: installableInfos[2].LocalResource.NoActivityTimeout, SaveEvents: installableInfos[2].LocalResource.ShowServiceMessages, SaveLogsByRegex: installableInfos[2].LocalResource.LogRegex, SaveLogsByRegexForContainers: installableInfos[2].LocalResource.LogRegexesForContainers, SaveLogsOnlyForContainers: installableInfos[2].LocalResource.ShowLogsOnlyForContainers, SaveLogsOnlyForNumberOfReplicas: installableInfos[2].LocalResource.ShowLogsOnlyForNumberOfReplicas}} + + createDependentOp := &plan.Operation{Type: plan.OperationTypeCreate, Version: plan.OperationVersionCreate, Category: plan.OperationCategoryResource, Config: &plan.OperationConfigCreate{ResourceSpec: installableInfos[3].LocalResource.ResourceSpec}} + + mainStageStartOp := &plan.Operation{Type: plan.OperationTypeNoop, Version: plan.OperationVersionNoop, Category: plan.OperationCategoryMeta, Config: &plan.OperationConfigNoop{OpID: fmt.Sprintf("%s/%s/%s", common.StagePrefix, common.StageInstall, common.StageStartSuffix)}} + mainStageEndOp := &plan.Operation{Type: plan.OperationTypeNoop, Version: plan.OperationVersionNoop, Category: plan.OperationCategoryMeta, Config: &plan.OperationConfigNoop{OpID: fmt.Sprintf("%s/%s/%s", common.StagePrefix, common.StageInstall, common.StageEndSuffix)}} + + ops := []*plan.Operation{mainStageStartOp, createOp1, trackReadinessOp1, createOp2, trackReadinessOp2, createOp3, trackReadinessOp3, createDependentOp, mainStageEndOp} + adjMap := map[string]map[string]graph.Edge[string]{ + mainStageStartOp.ID(): {createOp1.ID(): {}, createOp2.ID(): {}, createOp3.ID(): {}}, + createOp1.ID(): {trackReadinessOp1.ID(): {}}, + trackReadinessOp1.ID(): { + createDependentOp.ID(): {}, + }, + createOp2.ID(): {trackReadinessOp2.ID(): {}}, + trackReadinessOp2.ID(): { + createDependentOp.ID(): {}, + }, + createOp3.ID(): {trackReadinessOp3.ID(): {}}, + trackReadinessOp3.ID(): { + mainStageEndOp.ID(): {}, + }, + createDependentOp.ID(): {mainStageEndOp.ID(): {}}, + mainStageEndOp.ID(): {}, + } + + return ops, adjMap + }, + input: func() (installableInfos []*plan.InstallableResourceInfo, deletableInfos []*plan.DeletableResourceInfo, releaseInfos []*plan.ReleaseInfo, opts plan.BuildPlanOptions) { + res1 := defaultInstallableResource(s.releaseName, s.releaseNamespace) + res1.Name = "test-configmap-1" + res1.Unstruct.SetName("test-configmap-1") + res1.Weight = nil + res2 := defaultInstallableResource(s.releaseName, s.releaseNamespace) + res2.Name = "test-configmap-2" + res2.Unstruct.SetName("test-configmap-2") + res2.Weight = nil + res3 := defaultInstallableResource(s.releaseName, s.releaseNamespace) + res3.Name = "test-configmap-3" + res3.Unstruct.SetName("test-configmap-3") + res3.Weight = nil + + dependentRes := defaultInstallableResource(s.releaseName, s.releaseNamespace) + dependentRes.Name = "dependent-secret" + dependentRes.Unstruct.SetName("dependent-secret") + dependentRes.Unstruct.SetKind("Secret") + dependentRes.GroupVersionKind.Kind = "Secret" + dependentRes.Weight = nil + dependentRes.AutoInternalDependencies = []*resource.InternalDependency{{ + ResourceMatcher: &spec.ResourceMatcher{Names: []string{"test-configmap-1", "test-configmap-2"}, Kinds: []string{"ConfigMap"}}, + ResourceState: common.ResourceStateReady, + }} + + info1 := defaultInstallableResourceInfo(res1) + info2 := defaultInstallableResourceInfo(res2) + info3 := defaultInstallableResourceInfo(res3) + dependentInfo := defaultInstallableResourceInfo(dependentRes) + dependentInfo.MustTrackReadiness = false + + return []*plan.InstallableResourceInfo{info1, info2, info3, dependentInfo}, nil, nil, plan.BuildPlanOptions{} + }, + name: "connect deploy dependency only to specifically matched track-readiness operations", + }, + { + expect: func(installableInfos []*plan.InstallableResourceInfo, deletableInfos []*plan.DeletableResourceInfo, releaseInfos []*plan.ReleaseInfo) ([]*plan.Operation, map[string]map[string]graph.Edge[string]) { + deleteOp1 := &plan.Operation{Type: plan.OperationTypeDelete, Version: plan.OperationVersionDelete, Category: plan.OperationCategoryResource, Config: &plan.OperationConfigDelete{ResourceMeta: deletableInfos[0].LocalResource.ResourceMeta, DeletePropagation: deletableInfos[0].LocalResource.DeletePropagation}} + trackAbsenceOp1 := &plan.Operation{Type: plan.OperationTypeTrackAbsence, Version: plan.OperationVersionTrackAbsence, Category: plan.OperationCategoryTrack, Config: &plan.OperationConfigTrackAbsence{ResourceMeta: deletableInfos[0].LocalResource.ResourceMeta}} + deleteOp2 := &plan.Operation{Type: plan.OperationTypeDelete, Version: plan.OperationVersionDelete, Category: plan.OperationCategoryResource, Config: &plan.OperationConfigDelete{ResourceMeta: deletableInfos[1].LocalResource.ResourceMeta, DeletePropagation: deletableInfos[1].LocalResource.DeletePropagation}} + trackAbsenceOp2 := &plan.Operation{Type: plan.OperationTypeTrackAbsence, Version: plan.OperationVersionTrackAbsence, Category: plan.OperationCategoryTrack, Config: &plan.OperationConfigTrackAbsence{ResourceMeta: deletableInfos[1].LocalResource.ResourceMeta}} + deleteOp3 := &plan.Operation{Type: plan.OperationTypeDelete, Version: plan.OperationVersionDelete, Category: plan.OperationCategoryResource, Config: &plan.OperationConfigDelete{ResourceMeta: deletableInfos[2].LocalResource.ResourceMeta, DeletePropagation: deletableInfos[2].LocalResource.DeletePropagation}} + trackAbsenceOp3 := &plan.Operation{Type: plan.OperationTypeTrackAbsence, Version: plan.OperationVersionTrackAbsence, Category: plan.OperationCategoryTrack, Config: &plan.OperationConfigTrackAbsence{ResourceMeta: deletableInfos[2].LocalResource.ResourceMeta}} + createDependentOp := &plan.Operation{Type: plan.OperationTypeCreate, Version: plan.OperationVersionCreate, Category: plan.OperationCategoryResource, Config: &plan.OperationConfigCreate{ResourceSpec: installableInfos[0].LocalResource.ResourceSpec}} + + mainStageStartOp := &plan.Operation{Type: plan.OperationTypeNoop, Version: plan.OperationVersionNoop, Category: plan.OperationCategoryMeta, Config: &plan.OperationConfigNoop{OpID: fmt.Sprintf("%s/%s/%s", common.StagePrefix, common.StageInstall, common.StageStartSuffix)}} + mainStageEndOp := &plan.Operation{Type: plan.OperationTypeNoop, Version: plan.OperationVersionNoop, Category: plan.OperationCategoryMeta, Config: &plan.OperationConfigNoop{OpID: fmt.Sprintf("%s/%s/%s", common.StagePrefix, common.StageInstall, common.StageEndSuffix)}} + + ops := []*plan.Operation{mainStageStartOp, deleteOp1, trackAbsenceOp1, deleteOp2, trackAbsenceOp2, deleteOp3, trackAbsenceOp3, createDependentOp, mainStageEndOp} + adjMap := map[string]map[string]graph.Edge[string]{ + mainStageStartOp.ID(): {deleteOp1.ID(): {}, deleteOp2.ID(): {}, deleteOp3.ID(): {}}, + deleteOp1.ID(): {trackAbsenceOp1.ID(): {}}, + trackAbsenceOp1.ID(): {createDependentOp.ID(): {}}, + deleteOp2.ID(): {trackAbsenceOp2.ID(): {}}, + trackAbsenceOp2.ID(): {createDependentOp.ID(): {}}, + deleteOp3.ID(): {trackAbsenceOp3.ID(): {}}, + trackAbsenceOp3.ID(): {mainStageEndOp.ID(): {}}, + createDependentOp.ID(): { + mainStageEndOp.ID(): {}, + }, + mainStageEndOp.ID(): {}, + } + + return ops, adjMap + }, + input: func() (installableInfos []*plan.InstallableResourceInfo, deletableInfos []*plan.DeletableResourceInfo, releaseInfos []*plan.ReleaseInfo, opts plan.BuildPlanOptions) { + delRes1 := defaultDeletableResource(s.releaseName, s.releaseNamespace) + delRes1.Name = "test-configmap-1" + delInfo1 := defaultDeletableResourceInfo(delRes1, s.releaseName, s.releaseNamespace) + delInfo1.Stage = common.StageInstall + + delRes2 := defaultDeletableResource(s.releaseName, s.releaseNamespace) + delRes2.Name = "test-configmap-2" + delInfo2 := defaultDeletableResourceInfo(delRes2, s.releaseName, s.releaseNamespace) + delInfo2.Stage = common.StageInstall + + delRes3 := defaultDeletableResource(s.releaseName, s.releaseNamespace) + delRes3.Name = "test-configmap-3" + delInfo3 := defaultDeletableResourceInfo(delRes3, s.releaseName, s.releaseNamespace) + delInfo3.Stage = common.StageInstall + + dependentRes := defaultInstallableResource(s.releaseName, s.releaseNamespace) + dependentRes.Name = "dependent-secret" + dependentRes.Unstruct.SetName("dependent-secret") + dependentRes.Unstruct.SetKind("Secret") + dependentRes.GroupVersionKind.Kind = "Secret" + dependentRes.Weight = nil + dependentRes.AutoInternalDependencies = []*resource.InternalDependency{{ + ResourceMatcher: &spec.ResourceMatcher{Names: []string{"test-configmap-1", "test-configmap-2"}, Kinds: []string{"ConfigMap"}}, + ResourceState: common.ResourceStateAbsent, + }} + dependentInfo := defaultInstallableResourceInfo(dependentRes) + dependentInfo.MustTrackReadiness = false + + return []*plan.InstallableResourceInfo{dependentInfo}, []*plan.DeletableResourceInfo{delInfo1, delInfo2, delInfo3}, nil, plan.BuildPlanOptions{} + }, + name: "connect deploy dependency only to specifically matched track-absence operations", + }, + { + expect: func(installableInfos []*plan.InstallableResourceInfo, deletableInfos []*plan.DeletableResourceInfo, releaseInfos []*plan.ReleaseInfo) ([]*plan.Operation, map[string]map[string]graph.Edge[string]) { + deleteOp1 := &plan.Operation{Type: plan.OperationTypeDelete, Version: plan.OperationVersionDelete, Category: plan.OperationCategoryResource, Config: &plan.OperationConfigDelete{ResourceMeta: deletableInfos[0].LocalResource.ResourceMeta, DeletePropagation: deletableInfos[0].LocalResource.DeletePropagation}} + trackAbsenceOp1 := &plan.Operation{Type: plan.OperationTypeTrackAbsence, Version: plan.OperationVersionTrackAbsence, Category: plan.OperationCategoryTrack, Config: &plan.OperationConfigTrackAbsence{ResourceMeta: deletableInfos[0].LocalResource.ResourceMeta}} + deleteOp2 := &plan.Operation{Type: plan.OperationTypeDelete, Version: plan.OperationVersionDelete, Category: plan.OperationCategoryResource, Config: &plan.OperationConfigDelete{ResourceMeta: deletableInfos[1].LocalResource.ResourceMeta, DeletePropagation: deletableInfos[1].LocalResource.DeletePropagation}} + trackAbsenceOp2 := &plan.Operation{Type: plan.OperationTypeTrackAbsence, Version: plan.OperationVersionTrackAbsence, Category: plan.OperationCategoryTrack, Config: &plan.OperationConfigTrackAbsence{ResourceMeta: deletableInfos[1].LocalResource.ResourceMeta}} + deleteOp3 := &plan.Operation{Type: plan.OperationTypeDelete, Version: plan.OperationVersionDelete, Category: plan.OperationCategoryResource, Config: &plan.OperationConfigDelete{ResourceMeta: deletableInfos[2].LocalResource.ResourceMeta, DeletePropagation: deletableInfos[2].LocalResource.DeletePropagation}} + trackAbsenceOp3 := &plan.Operation{Type: plan.OperationTypeTrackAbsence, Version: plan.OperationVersionTrackAbsence, Category: plan.OperationCategoryTrack, Config: &plan.OperationConfigTrackAbsence{ResourceMeta: deletableInfos[2].LocalResource.ResourceMeta}} + deleteDependentOp := &plan.Operation{Type: plan.OperationTypeDelete, Version: plan.OperationVersionDelete, Category: plan.OperationCategoryResource, Config: &plan.OperationConfigDelete{ResourceMeta: deletableInfos[3].LocalResource.ResourceMeta, DeletePropagation: deletableInfos[3].LocalResource.DeletePropagation}} + trackAbsenceDependentOp := &plan.Operation{Type: plan.OperationTypeTrackAbsence, Version: plan.OperationVersionTrackAbsence, Category: plan.OperationCategoryTrack, Config: &plan.OperationConfigTrackAbsence{ResourceMeta: deletableInfos[3].LocalResource.ResourceMeta}} + + mainStageStartOp := &plan.Operation{Type: plan.OperationTypeNoop, Version: plan.OperationVersionNoop, Category: plan.OperationCategoryMeta, Config: &plan.OperationConfigNoop{OpID: fmt.Sprintf("%s/%s/%s", common.StagePrefix, common.StageUninstall, common.StageStartSuffix)}} + mainStageEndOp := &plan.Operation{Type: plan.OperationTypeNoop, Version: plan.OperationVersionNoop, Category: plan.OperationCategoryMeta, Config: &plan.OperationConfigNoop{OpID: fmt.Sprintf("%s/%s/%s", common.StagePrefix, common.StageUninstall, common.StageEndSuffix)}} + + ops := []*plan.Operation{mainStageStartOp, deleteOp1, trackAbsenceOp1, deleteOp2, trackAbsenceOp2, deleteOp3, trackAbsenceOp3, deleteDependentOp, trackAbsenceDependentOp, mainStageEndOp} + adjMap := map[string]map[string]graph.Edge[string]{ + mainStageStartOp.ID(): {deleteOp1.ID(): {}, deleteOp2.ID(): {}, deleteOp3.ID(): {}}, + deleteOp1.ID(): {trackAbsenceOp1.ID(): {}}, + trackAbsenceOp1.ID(): {deleteDependentOp.ID(): {}}, + deleteOp2.ID(): {trackAbsenceOp2.ID(): {}}, + trackAbsenceOp2.ID(): {deleteDependentOp.ID(): {}}, + deleteOp3.ID(): {trackAbsenceOp3.ID(): {}}, + trackAbsenceOp3.ID(): {mainStageEndOp.ID(): {}}, + deleteDependentOp.ID(): { + trackAbsenceDependentOp.ID(): {}, + }, + trackAbsenceDependentOp.ID(): { + mainStageEndOp.ID(): {}, + }, + mainStageEndOp.ID(): {}, + } + + return ops, adjMap + }, + input: func() (installableInfos []*plan.InstallableResourceInfo, deletableInfos []*plan.DeletableResourceInfo, releaseInfos []*plan.ReleaseInfo, opts plan.BuildPlanOptions) { + delRes1 := defaultDeletableResource(s.releaseName, s.releaseNamespace) + delRes1.Name = "test-configmap-1" + delRes2 := defaultDeletableResource(s.releaseName, s.releaseNamespace) + delRes2.Name = "test-configmap-2" + delRes3 := defaultDeletableResource(s.releaseName, s.releaseNamespace) + delRes3.Name = "test-configmap-3" + dependentDelRes := defaultDeletableResource(s.releaseName, s.releaseNamespace) + dependentDelRes.Name = "dependent-secret" + dependentDelRes.GroupVersionKind.Kind = "Secret" + dependentDelRes.AutoInternalDependencies = []*resource.InternalDependency{{ + ResourceMatcher: &spec.ResourceMatcher{Names: []string{"test-configmap-1", "test-configmap-2"}, Kinds: []string{"ConfigMap"}}, + ResourceState: common.ResourceStateAbsent, + }} + + delInfo1 := defaultDeletableResourceInfo(delRes1, s.releaseName, s.releaseNamespace) + delInfo2 := defaultDeletableResourceInfo(delRes2, s.releaseName, s.releaseNamespace) + delInfo3 := defaultDeletableResourceInfo(delRes3, s.releaseName, s.releaseNamespace) + dependentDelInfo := defaultDeletableResourceInfo(dependentDelRes, s.releaseName, s.releaseNamespace) + + return nil, []*plan.DeletableResourceInfo{delInfo1, delInfo2, delInfo3, dependentDelInfo}, nil, plan.BuildPlanOptions{} + }, + name: "connect delete dependency only to specifically matched track-absence operations", + }, + } + + for _, tc := range testCases { + s.Run(tc.name, runBuildPlanTest(tc, &s.BuildPlanSuite)) + } +} + +func TestAI_BuildPlanSuiteMultiMatchDependencies(t *testing.T) { + suite.Run(t, new(BuildPlanAISuite)) +} From be2f6f8e20d6856e50bd8a3dd48a4aa7be36420d Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Fri, 3 Apr 2026 11:03:23 +0300 Subject: [PATCH 06/85] refactor: remove deprecated dependency annotation `.dependency.werf.io` Signed-off-by: Dmitry Mordvinov --- pkg/common/common.go | 2 -- pkg/resource/metadata.go | 73 ---------------------------------------- pkg/resource/resource.go | 4 --- 3 files changed, 79 deletions(-) diff --git a/pkg/common/common.go b/pkg/common/common.go index 9899a206..8f3dfe3e 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -200,8 +200,6 @@ var ( AnnotationKeyHumanDeleteDependency = "werf.io/delete-dependency-" AnnotationKeyPatternDeleteDependency = regexp.MustCompile(`^werf.io/delete-dependency-(?P.+)$`) // TODO(major): get rid - AnnotationKeyHumanDependency = ".dependency.werf.io" - AnnotationKeyPatternDependency = regexp.MustCompile(`^(?P.+).dependency.werf.io$`) AnnotationKeyHumanExternalDependency = ".external-dependency.werf.io" AnnotationKeyPatternExternalDependency = regexp.MustCompile(`^(?P.+).external-dependency.werf.io$`) AnnotationKeyHumanLegacyExternalDependencyResource = ".external-dependency.werf.io/resource" diff --git a/pkg/resource/metadata.go b/pkg/resource/metadata.go index d60277ed..02c1bf7d 100644 --- a/pkg/resource/metadata.go +++ b/pkg/resource/metadata.go @@ -534,49 +534,6 @@ func manualInternalDeployDependencies(meta *spec.ResourceMeta) []*InternalDepend deps := map[string]*InternalDependency{} - if annotations, found := spec.FindAnnotationsOrLabelsByKeyPattern(meta.Annotations, common.AnnotationKeyPatternDependency); found { - for key, value := range annotations { - matches := common.AnnotationKeyPatternDependency.FindStringSubmatch(key) - idSubexpIndex := common.AnnotationKeyPatternDependency.SubexpIndex("id") - depID := matches[idSubexpIndex] - valParts := strings.Split(value, ":") - depAPIVersionParts := strings.SplitN(valParts[0], "/", 2) - - var gvk schema.GroupVersionKind - if len(depAPIVersionParts) == 1 { - gvk = schema.GroupVersionKind{ - Version: depAPIVersionParts[0], - Kind: valParts[1], - } - } else { - gvk = schema.GroupVersionKind{ - Group: depAPIVersionParts[0], - Version: depAPIVersionParts[1], - Kind: valParts[1], - } - } - - var depNamespace string - if len(valParts) == 4 { - depNamespace = valParts[2] - } - - depName := valParts[len(valParts)-1] - - dep := &InternalDependency{ - ResourceMatcher: &spec.ResourceMatcher{ - Names: []string{depName}, - Namespaces: []string{depNamespace}, - Groups: []string{gvk.Group}, - Versions: []string{gvk.Version}, - Kinds: []string{gvk.Kind}, - }, - ResourceState: common.ResourceStatePresent, - } - deps[depID] = dep - } - } - if annotations, found := spec.FindAnnotationsOrLabelsByKeyPattern(meta.Annotations, common.AnnotationKeyPatternDeployDependency); found { for key, value := range annotations { matches := common.AnnotationKeyPatternDeployDependency.FindStringSubmatch(key) @@ -1117,36 +1074,6 @@ func validateHook(meta *spec.ResourceMeta) error { return nil } -func validateInternalDependencies(meta *spec.ResourceMeta) error { - if annotations, found := spec.FindAnnotationsOrLabelsByKeyPattern(meta.Annotations, common.AnnotationKeyPatternDependency); found { - for key, value := range annotations { - keyMatches := common.AnnotationKeyPatternDependency.FindStringSubmatch(key) - if keyMatches == nil { - return fmt.Errorf("invalid key for annotation %q", key) - } - - idSubexpIndex := common.AnnotationKeyPatternDependency.SubexpIndex("id") - if idSubexpIndex == -1 { - return fmt.Errorf("invalid regexp pattern %q for annotation %q", common.AnnotationKeyPatternDependency.String(), key) - } - - if len(keyMatches) < idSubexpIndex+1 { - return fmt.Errorf("can't parse dependency id from annotation key %q", key) - } - - if value != "" { - valueElems := strings.Split(value, ":") - - if len(valueElems) != 3 && len(valueElems) != 4 { - return fmt.Errorf(`invalid format of value %q for annotation %q, should be: apiVersion:kind[:namespace]:name or empty`, value, key) - } - } - } - } - - return nil -} - func validateOwnership(meta *spec.ResourceMeta) error { if key, value, found := spec.FindAnnotationOrLabelByKeyPattern(meta.Annotations, common.AnnotationKeyPatternOwnership); found { if value == "" { diff --git a/pkg/resource/resource.go b/pkg/resource/resource.go index 5c474bbf..06d40955 100644 --- a/pkg/resource/resource.go +++ b/pkg/resource/resource.go @@ -83,10 +83,6 @@ func NewInstallableResource(res *spec.ResourceSpec, releaseNamespace string, cli return nil, fmt.Errorf("validate deploy dependencies: %w", err) } - if err := validateInternalDependencies(res.ResourceMeta); err != nil { - return nil, fmt.Errorf("validate internal dependencies: %w", err) - } - if err := validateExternalDependencies(res.ResourceMeta); err != nil { return nil, fmt.Errorf("validate external dependencies: %w", err) } From 3406519a2a5d03107ca8c5935629b97bb314ef37 Mon Sep 17 00:00:00 2001 From: Aleksei Igrychev Date: Mon, 6 Apr 2026 11:50:52 +0100 Subject: [PATCH 07/85] deps: replace andremueller/cobra fork with werf/3p-cobra rebased on v1.10.2 (#591) Move cobra fork from andremueller/cobra to werf/3p-cobra with EnableErrorOnUnknownSubcommand patches cherry-picked onto cobra v1.10.2. Also bumps pflag v1.0.5 -> v1.0.9. Signed-off-by: Aleksei Igrychev --- go.mod | 2 +- go.sum | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 449770ec..ee9647f9 100644 --- a/go.mod +++ b/go.mod @@ -233,4 +233,4 @@ require ( sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect ) -replace github.com/spf13/cobra => github.com/andremueller/cobra v0.0.0-20241025091859-0d550c15a8a4 // remove when merged: https://github.com/spf13/cobra/pull/2167 +replace github.com/spf13/cobra => github.com/werf/3p-cobra v0.0.0-20260403075225-552c82797324 // adds EnableErrorOnUnknownSubcommand, not yet in upstream diff --git a/go.sum b/go.sum index 05a491a8..786fd4f7 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,6 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/andremueller/cobra v0.0.0-20241025091859-0d550c15a8a4 h1:behIQV+NveRm5cqXAfvYDEA2AkjwE8rdJjyA7YT5XlM= -github.com/andremueller/cobra v0.0.0-20241025091859-0d550c15a8a4/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/avelino/slugify v0.0.0-20180501145920-855f152bd774 h1:HrMVYtly2IVqg9EBooHsakQ256ueojP7QuG32K71X/U= @@ -66,7 +64,7 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= @@ -378,7 +376,7 @@ github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0b github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -404,6 +402,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/wI2L/jsondiff v0.7.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ= github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM= +github.com/werf/3p-cobra v0.0.0-20260403075225-552c82797324 h1:aqEM5aboMpBfsILjaxxRKhGFv9rGtNcd5YzMUDyVX+U= +github.com/werf/3p-cobra v0.0.0-20260403075225-552c82797324/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/werf/common-go v0.0.0-20260212174520-adf7d95a1579 h1:LojMRgEoMNrUnfsbDG5GT4M5HXC9LAYH+5DWBY3p4uU= github.com/werf/common-go v0.0.0-20260212174520-adf7d95a1579/go.mod h1:MXS0JR9zut+oR9oEM8PEkdXXoEbKDILTmWopt0z1eZs= github.com/werf/kubedog v0.13.1-0.20260320165832-7d97aaf7aab9 h1:N+XKTPiXT5pf5lxThhaQQPARLUpZTlYJeMNoNtn+540= From 074d3505764f00bc8a4d0f863b88bf988a44da74 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Tue, 7 Apr 2026 10:32:17 +0300 Subject: [PATCH 08/85] feat: new release history Signed-off-by: Dmitry Mordvinov --- cmd/nelm/release_history.go | 122 +++++++++++-- pkg/action/release_history.go | 325 ++++++++++++++++++++++++++++++++++ pkg/action/release_list.go | 5 +- 3 files changed, 431 insertions(+), 21 deletions(-) create mode 100644 pkg/action/release_history.go diff --git a/cmd/nelm/release_history.go b/cmd/nelm/release_history.go index 8398144e..4587fc29 100644 --- a/cmd/nelm/release_history.go +++ b/cmd/nelm/release_history.go @@ -1,39 +1,125 @@ package main import ( + "cmp" "context" - "strings" + "fmt" - "github.com/samber/lo" "github.com/spf13/cobra" "github.com/werf/common-go/pkg/cli" "github.com/werf/nelm/pkg/action" - "github.com/werf/nelm/pkg/helm/pkg/chart/loader" - helmcmd "github.com/werf/nelm/pkg/helm/pkg/cmd" + "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/log" ) +type releaseHistoryConfig struct { + action.ReleaseHistoryOptions + + LogColorMode string + LogLevel string + ReleaseName string + ReleaseNamespace string +} + func newReleaseHistoryCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra.Command]func(cmd *cobra.Command) error) *cobra.Command { - cmd := lo.Must(lo.Find(helmRootCmd.Commands(), func(c *cobra.Command) bool { - return strings.HasPrefix(c.Use, "history") - })) + cfg := &releaseHistoryConfig{} + + cmd := cli.NewSubCommand( + ctx, + "history [options...] -n namespace -r release ", + "Show release history.", + "Show release history.", + 40, + releaseCmdGroup, + cli.SubCommandOptions{}, + func(cmd *cobra.Command, args []string) error { + ctx = action.SetupLogging(ctx, cmp.Or(log.Level(cfg.LogLevel), action.DefaultReleaseHistoryLogLevel), action.SetupLoggingOptions{ + ColorMode: cfg.LogColorMode, + LogIsParseable: true, + }) + + if _, err := action.ReleaseHistory(ctx, cfg.ReleaseName, cfg.ReleaseNamespace, cfg.ReleaseHistoryOptions); err != nil { + return fmt.Errorf("release history: %w", err) + } + + return nil + }, + ) + + afterAllCommandsBuiltFuncs[cmd] = func(cmd *cobra.Command) error { + if err := AddKubeConnectionFlags(cmd, &cfg.KubeConnectionOptions); err != nil { + return fmt.Errorf("add kube connection flags: %w", err) + } + + // TODO: restrict values + if err := cli.AddFlag(cmd, &cfg.OutputFormat, "output-format", action.DefaultReleaseHistoryOutputFormat, "Result output format", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: miscFlagGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } - cmd.LocalFlags().AddFlagSet(cmd.InheritedFlags()) - cmd.Short = "Show release history." - cmd.Aliases = []string{} - cli.SetSubCommandAnnotations(cmd, 30, releaseCmdGroup) + if err := cli.AddFlag(cmd, &cfg.ReleaseName, "release", "", "The release name. Must be unique within the release namespace", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: mainFlagGroup, + Required: true, + ShortName: "r", + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + + if err := cli.AddFlag(cmd, &cfg.Max, "max", 0, "Maximum number of revisions to show. 0 means no limit", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: mainFlagGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + + if err := cli.AddFlag(cmd, &cfg.ReleaseNamespace, "namespace", "", "The release namespace. Resources with no namespace will be deployed here", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: mainFlagGroup, + Required: true, + ShortName: "n", + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } - originalRunE := cmd.RunE - cmd.RunE = func(cmd *cobra.Command, args []string) error { - helmSettings := helmcmd.Settings + // TODO: restrict allowed values + if err := cli.AddFlag(cmd, &cfg.ReleaseStorageDriver, "release-storage", "", "How releases should be stored", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalEnvVarRegexes, + Group: miscFlagGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } - ctx = action.SetupLogging(ctx, lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), action.SetupLoggingOptions{}) + if err := cli.AddFlag(cmd, &cfg.ReleaseStorageSQLConnection, "release-storage-sql-connection", "", "SQL connection string for MySQL release storage driver", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalEnvVarRegexes, + Group: miscFlagGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } - loader.NoChartLockWarning = "" + if err := cli.AddFlag(cmd, &cfg.TempDirPath, "temp-dir", "", "The directory for temporary files. By default, create a new directory in the default system directory for temporary files", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalEnvVarRegexes, + Group: miscFlagGroup, + Type: cli.FlagTypeDir, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + + if err := cli.AddFlag(cmd, &cfg.LogColorMode, "color-mode", common.DefaultLogColorMode, "Color mode for logs. "+allowedLogColorModesHelp(), cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: miscFlagGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } - if err := originalRunE(cmd, args); err != nil { - return err + if err := cli.AddFlag(cmd, &cfg.LogLevel, "log-level", string(action.DefaultReleaseHistoryLogLevel), "Set log level. "+allowedLogLevelsHelp(), cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: miscFlagGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) } return nil diff --git a/pkg/action/release_history.go b/pkg/action/release_history.go new file mode 100644 index 00000000..8a864199 --- /dev/null +++ b/pkg/action/release_history.go @@ -0,0 +1,325 @@ +package action + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + "time" + + "github.com/goccy/go-yaml" + "github.com/gookit/color" + prtable "github.com/jedib0t/go-pretty/v6/table" + "github.com/jedib0t/go-pretty/v6/text" + + "github.com/werf/nelm/pkg/common" + "github.com/werf/nelm/pkg/helm/pkg/chart/loader" + helmreleasestatus "github.com/werf/nelm/pkg/helm/pkg/release/common" + "github.com/werf/nelm/pkg/kube" + "github.com/werf/nelm/pkg/log" + "github.com/werf/nelm/pkg/release" +) + +const ( + DefaultReleaseHistoryLogLevel = log.ErrorLevel + DefaultReleaseHistoryOutputFormat = common.OutputFormatTable +) + +type ReleaseHistoryOptions struct { + common.KubeConnectionOptions + + // Max limits the number of revisions returned. 0 means no limit. + Max int + // OutputFormat specifies the output format for the release history. + // Valid values: "table" (default), "yaml", "json". + // Defaults to DefaultReleaseHistoryOutputFormat (table) if not specified. + OutputFormat string + // OutputNoPrint, when true, suppresses printing the output and only returns the result data structure. + // Useful when calling this programmatically. + OutputNoPrint bool + // ReleaseStorageDriver specifies how release metadata is stored in Kubernetes. + // Valid values: "secret" (default), "configmap", "sql". + // Defaults to "secret" if not specified or set to "default". + ReleaseStorageDriver string + // ReleaseStorageSQLConnection is the SQL connection string when using SQL storage driver. + // Only used when ReleaseStorageDriver is "sql". + ReleaseStorageSQLConnection string + // TempDirPath is the directory for temporary files during the operation. + // A temporary directory is created automatically if not specified. + TempDirPath string +} + +type ReleaseHistoryResultV1 struct { + APIVersion string `json:"apiVersion"` + Releases []*ReleaseHistoryResultRelease `json:"releases"` +} + +type ReleaseHistoryResultRelease struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Revision int `json:"revision"` + Status helmreleasestatus.Status `json:"status"` + Updated *ReleaseHistoryResultUpdated `json:"updated"` + Annotations map[string]string `json:"annotations"` + Chart *ReleaseHistoryResultChart `json:"chart"` + Description string `json:"description"` +} + +type ReleaseHistoryResultUpdated struct { + Human string `json:"human"` + HumanTable string `json:"-" yaml:"-"` + Unix int `json:"unix"` +} + +type ReleaseHistoryResultChart struct { + Name string `json:"name"` + Version string `json:"version"` + AppVersion string `json:"appVersion"` +} + +// Lists Helm release history from the cluster. +func ReleaseHistory(ctx context.Context, releaseName, releaseNamespace string, opts ReleaseHistoryOptions) (*ReleaseHistoryResultV1, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("get home directory: %w", err) + } + + opts, err = applyReleaseHistoryOptionsDefaults(opts, homeDir) + if err != nil { + return nil, fmt.Errorf("build release history options: %w", err) + } + + kubeConfig, err := kube.NewKubeConfig(ctx, kube.KubeConfigOptions{ + KubeConnectionOptions: opts.KubeConnectionOptions, + KubeContextNamespace: releaseNamespace, // TODO: unset it everywhere + }) + if err != nil { + return nil, fmt.Errorf("construct kube config: %w", err) + } + + clientFactory, err := kube.NewClientFactory(ctx, kubeConfig) + if err != nil { + return nil, fmt.Errorf("construct kube client factory: %w", err) + } + + releaseStorage, err := release.NewReleaseStorage(ctx, releaseNamespace, opts.ReleaseStorageDriver, clientFactory, release.ReleaseStorageOptions{ + SQLConnection: opts.ReleaseStorageSQLConnection, + }) + if err != nil { + return nil, fmt.Errorf("construct release storage: %w", err) + } + + loader.NoChartLockWarning = "" + + log.Default.Info(ctx, "Build release history") + + history, err := release.BuildHistory(releaseName, releaseStorage, release.HistoryOptions{}) + if err != nil { + return nil, fmt.Errorf("build release history: %w", err) + } + + result := &ReleaseHistoryResultV1{ + APIVersion: "v1", + } + + releases := history.Releases() + if len(releases) == 0 { + return nil, &ReleaseNotFoundError{ + ReleaseName: releaseName, + ReleaseNamespace: releaseNamespace, + } + } + + for _, release := range releases { + result.Releases = append(result.Releases, &ReleaseHistoryResultRelease{ + Annotations: release.Info.Annotations, + Chart: &ReleaseHistoryResultChart{ + Name: release.Chart.Name(), + Version: release.Chart.Metadata.Version, + AppVersion: release.Chart.Metadata.AppVersion, + }, + Description: release.Info.Description, + Name: release.Name, + Namespace: release.Namespace, + Revision: release.Version, + Status: release.Info.Status, + Updated: &ReleaseHistoryResultUpdated{ + Human: release.Info.LastDeployed.String(), + HumanTable: release.Info.LastDeployed.Format(time.ANSIC), + Unix: int(release.Info.LastDeployed.Unix()), + }, + }) + } + + sort.SliceStable(result.Releases, func(i, j int) bool { + return result.Releases[i].Revision < result.Releases[j].Revision + }) + + if opts.Max > 0 && len(result.Releases) > opts.Max { + result.Releases = result.Releases[len(result.Releases)-opts.Max:] + } + + if opts.OutputNoPrint { + return result, nil + } + + var resultMessage string + + switch opts.OutputFormat { + case common.OutputFormatTable: + table := buildReleaseHistoryOutputTable(ctx, result) + resultMessage = table.Render() + "\n" + case common.OutputFormatJSON: + b, err := json.MarshalIndent(result, "", strings.Repeat(" ", 2)) + if err != nil { + return nil, fmt.Errorf("marshal result to json: %w", err) + } + + resultMessage = string(b) + "\n" + case common.OutputFormatYAML: + b, err := yaml.MarshalContext(ctx, result, yaml.UseLiteralStyleIfMultiline(true)) + if err != nil { + return nil, fmt.Errorf("marshal result to yaml: %w", err) + } + + resultMessage = string(b) + default: + return nil, fmt.Errorf("unknown output format %q", opts.OutputFormat) + } + + var colorLevel color.Level + if color.Enable { + colorLevel = color.TermColorLevel() + } + + if err := writeWithSyntaxHighlight(os.Stdout, resultMessage, opts.OutputFormat, colorLevel); err != nil { + return nil, fmt.Errorf("write result to output: %w", err) + } + + return result, nil +} + +func buildReleaseHistoryOutputTable(ctx context.Context, result *ReleaseHistoryResultV1) prtable.Writer { + table := prtable.NewWriter() + setReleaseHistoryOutputTableStyle(ctx, table) + + headerRow := prtable.Row{ + color.New(color.Bold).Sprintf("REVISION"), + color.New(color.Bold).Sprintf("UPDATED"), + color.New(color.Bold).Sprintf("STATUS"), + color.New(color.Bold).Sprintf("CHART"), + color.New(color.Bold).Sprintf("APP VERSION"), + color.New(color.Bold).Sprintf("DESCRIPTION"), + } + + table.AppendHeader(headerRow) + + for _, release := range result.Releases { + var statusColor color.Color + switch release.Status { + case helmreleasestatus.StatusDeployed, helmreleasestatus.StatusSuperseded: + statusColor = color.Green + case helmreleasestatus.StatusFailed: + statusColor = color.LightRed + default: + statusColor = color.LightYellow + } + + row := prtable.Row{ + release.Revision, + release.Updated.HumanTable, + color.New(statusColor).Sprint(release.Status), + color.New(color.Cyan).Sprintf("%s-%s", release.Chart.Name, release.Chart.Version), + release.Chart.AppVersion, + release.Description, + } + + table.AppendRow(row) + } + + return table +} + +func applyReleaseHistoryOptionsDefaults(opts ReleaseHistoryOptions, homeDir string) (ReleaseHistoryOptions, error) { + var err error + if opts.TempDirPath == "" { + opts.TempDirPath, err = os.MkdirTemp("", "") + if err != nil { + return ReleaseHistoryOptions{}, fmt.Errorf("create temp dir: %w", err) + } + } + + opts.KubeConnectionOptions.ApplyDefaults(homeDir) + + if opts.ReleaseStorageDriver == common.ReleaseStorageDriverDefault { + opts.ReleaseStorageDriver = common.ReleaseStorageDriverSecrets + } + + if opts.OutputFormat == "" { + opts.OutputFormat = DefaultReleaseHistoryOutputFormat + } + + return opts, nil +} + +func setReleaseHistoryOutputTableStyle(ctx context.Context, table prtable.Writer) { + style := prtable.StyleBoxDefault + style.PaddingLeft = "" + style.PaddingRight = " " + + columnConfigs := []prtable.ColumnConfig{ + { + Number: 1, + Align: text.AlignLeft, + }, + { + Number: 2, + Align: text.AlignLeft, + }, + { + Number: 3, + Align: text.AlignLeft, + }, + { + Number: 4, + Align: text.AlignLeft, + }, + { + Number: 5, + Align: text.AlignLeft, + }, + { + Number: 6, + Align: text.AlignLeft, + }, + } + + tableWidth := log.Default.BlockContentWidth(ctx) + if tableWidth < 20 { + tableWidth = 140 + } else if tableWidth > 200 { + tableWidth = 200 + } + + paddingsWidth := len(columnConfigs) * (len(style.PaddingLeft) + len(style.PaddingRight)) + + columnConfigs[0].WidthMax = 10 + columnConfigs[1].WidthMax = 25 + columnConfigs[2].WidthMax = 12 + columnConfigs[3].WidthMax = 24 + columnConfigs[4].WidthMax = 16 + columnConfigs[5].WidthMax = tableWidth - paddingsWidth - columnConfigs[0].WidthMax - columnConfigs[1].WidthMax - columnConfigs[2].WidthMax - columnConfigs[3].WidthMax - columnConfigs[4].WidthMax + + table.SetColumnConfigs(columnConfigs) + table.SetStyle(prtable.Style{ + Box: style, + Color: prtable.ColorOptionsDefault, + Format: prtable.FormatOptionsDefault, + HTML: prtable.DefaultHTMLOptions, + Options: prtable.OptionsNoBordersAndSeparators, + Title: prtable.TitleOptionsDefault, + }) + table.SuppressTrailingSpaces() +} diff --git a/pkg/action/release_list.go b/pkg/action/release_list.go index 45c93e3b..26ef9e82 100644 --- a/pkg/action/release_list.go +++ b/pkg/action/release_list.go @@ -7,7 +7,6 @@ import ( "os" "sort" "strings" - "time" "github.com/goccy/go-yaml" "github.com/gookit/color" @@ -139,8 +138,8 @@ func ReleaseList(ctx context.Context, opts ReleaseListOptions) (*ReleaseListResu AppVersion: lastRelease.Chart.Metadata.AppVersion, }, DeployedAt: &ReleaseListResultDeployedAt{ - Human: time.Time{}.String(), - Unix: int(time.Time{}.Unix()), + Human: lastRelease.Info.LastDeployed.String(), + Unix: int(lastRelease.Info.LastDeployed.Unix()), }, Name: lastRelease.Name, Namespace: lastRelease.Namespace, From 56c19f14be53e6918d9dbe53b1222f4efafebd17 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Tue, 7 Apr 2026 23:14:07 +0300 Subject: [PATCH 09/85] refactor: rename release history --max to --revisions-limit and change table date format Signed-off-by: Dmitry Mordvinov --- cmd/nelm/release_history.go | 2 +- pkg/action/release_history.go | 64 ++++++++++++++++------------------- 2 files changed, 30 insertions(+), 36 deletions(-) diff --git a/cmd/nelm/release_history.go b/cmd/nelm/release_history.go index 4587fc29..16414b98 100644 --- a/cmd/nelm/release_history.go +++ b/cmd/nelm/release_history.go @@ -69,7 +69,7 @@ func newReleaseHistoryCommand(ctx context.Context, afterAllCommandsBuiltFuncs ma return fmt.Errorf("add flag: %w", err) } - if err := cli.AddFlag(cmd, &cfg.Max, "max", 0, "Maximum number of revisions to show. 0 means no limit", cli.AddFlagOptions{ + if err := cli.AddFlag(cmd, &cfg.RevisionsLimit, "revisions-limit", 0, "Maximum number of revisions to show. 0 means no limit", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: mainFlagGroup, }); err != nil { diff --git a/pkg/action/release_history.go b/pkg/action/release_history.go index 8a864199..999951d7 100644 --- a/pkg/action/release_history.go +++ b/pkg/action/release_history.go @@ -30,8 +30,6 @@ const ( type ReleaseHistoryOptions struct { common.KubeConnectionOptions - // Max limits the number of revisions returned. 0 means no limit. - Max int // OutputFormat specifies the output format for the release history. // Valid values: "table" (default), "yaml", "json". // Defaults to DefaultReleaseHistoryOutputFormat (table) if not specified. @@ -46,6 +44,8 @@ type ReleaseHistoryOptions struct { // ReleaseStorageSQLConnection is the SQL connection string when using SQL storage driver. // Only used when ReleaseStorageDriver is "sql". ReleaseStorageSQLConnection string + // RevisionsLimit limits the number of revisions returned. 0 means no limit. + RevisionsLimit int // TempDirPath is the directory for temporary files during the operation. // A temporary directory is created automatically if not specified. TempDirPath string @@ -57,20 +57,18 @@ type ReleaseHistoryResultV1 struct { } type ReleaseHistoryResultRelease struct { - Name string `json:"name"` - Namespace string `json:"namespace"` - Revision int `json:"revision"` - Status helmreleasestatus.Status `json:"status"` - Updated *ReleaseHistoryResultUpdated `json:"updated"` - Annotations map[string]string `json:"annotations"` - Chart *ReleaseHistoryResultChart `json:"chart"` - Description string `json:"description"` + Name string `json:"name"` + Namespace string `json:"namespace"` + Revision int `json:"revision"` + Status helmreleasestatus.Status `json:"status"` + DeployedAt *ReleaseHistoryResultDeployedAt `json:"deployedAt"` + Annotations map[string]string `json:"annotations"` + Chart *ReleaseHistoryResultChart `json:"chart"` } -type ReleaseHistoryResultUpdated struct { - Human string `json:"human"` - HumanTable string `json:"-" yaml:"-"` - Unix int `json:"unix"` +type ReleaseHistoryResultDeployedAt struct { + Human string `json:"human"` + Unix int `json:"unix"` } type ReleaseHistoryResultChart struct { @@ -140,16 +138,14 @@ func ReleaseHistory(ctx context.Context, releaseName, releaseNamespace string, o Version: release.Chart.Metadata.Version, AppVersion: release.Chart.Metadata.AppVersion, }, - Description: release.Info.Description, - Name: release.Name, - Namespace: release.Namespace, - Revision: release.Version, - Status: release.Info.Status, - Updated: &ReleaseHistoryResultUpdated{ - Human: release.Info.LastDeployed.String(), - HumanTable: release.Info.LastDeployed.Format(time.ANSIC), - Unix: int(release.Info.LastDeployed.Unix()), + DeployedAt: &ReleaseHistoryResultDeployedAt{ + Human: release.Info.LastDeployed.String(), + Unix: int(release.Info.LastDeployed.Unix()), }, + Name: release.Name, + Namespace: release.Namespace, + Revision: release.Version, + Status: release.Info.Status, }) } @@ -157,8 +153,8 @@ func ReleaseHistory(ctx context.Context, releaseName, releaseNamespace string, o return result.Releases[i].Revision < result.Releases[j].Revision }) - if opts.Max > 0 && len(result.Releases) > opts.Max { - result.Releases = result.Releases[len(result.Releases)-opts.Max:] + if opts.RevisionsLimit > 0 && len(result.Releases) > opts.RevisionsLimit { + result.Releases = result.Releases[len(result.Releases)-opts.RevisionsLimit:] } if opts.OutputNoPrint { @@ -207,11 +203,11 @@ func buildReleaseHistoryOutputTable(ctx context.Context, result *ReleaseHistoryR headerRow := prtable.Row{ color.New(color.Bold).Sprintf("REVISION"), - color.New(color.Bold).Sprintf("UPDATED"), color.New(color.Bold).Sprintf("STATUS"), + color.New(color.Bold).Sprintf("DEPLOYED"), color.New(color.Bold).Sprintf("CHART"), + color.New(color.Bold).Sprintf("CHART VERSION"), color.New(color.Bold).Sprintf("APP VERSION"), - color.New(color.Bold).Sprintf("DESCRIPTION"), } table.AppendHeader(headerRow) @@ -229,11 +225,11 @@ func buildReleaseHistoryOutputTable(ctx context.Context, result *ReleaseHistoryR row := prtable.Row{ release.Revision, - release.Updated.HumanTable, color.New(statusColor).Sprint(release.Status), - color.New(color.Cyan).Sprintf("%s-%s", release.Chart.Name, release.Chart.Version), + time.Unix(int64(release.DeployedAt.Unix), 0).Format(time.RFC822), + release.Chart.Name, + release.Chart.Version, release.Chart.AppVersion, - release.Description, } table.AppendRow(row) @@ -303,14 +299,12 @@ func setReleaseHistoryOutputTableStyle(ctx context.Context, table prtable.Writer tableWidth = 200 } - paddingsWidth := len(columnConfigs) * (len(style.PaddingLeft) + len(style.PaddingRight)) - columnConfigs[0].WidthMax = 10 - columnConfigs[1].WidthMax = 25 - columnConfigs[2].WidthMax = 12 + columnConfigs[1].WidthMax = 12 + columnConfigs[2].WidthMax = 25 columnConfigs[3].WidthMax = 24 columnConfigs[4].WidthMax = 16 - columnConfigs[5].WidthMax = tableWidth - paddingsWidth - columnConfigs[0].WidthMax - columnConfigs[1].WidthMax - columnConfigs[2].WidthMax - columnConfigs[3].WidthMax - columnConfigs[4].WidthMax + columnConfigs[5].WidthMax = 16 table.SetColumnConfigs(columnConfigs) table.SetStyle(prtable.Style{ From 1a42481cfbb2251db7b36fc98f47296389c14874 Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:42:07 +0300 Subject: [PATCH 10/85] feat: expose plan artifact in actions api (#586) --- cmd/nelm/release_plan_install.go | 2 +- pkg/{plan => action}/plan_artifact.go | 11 ++- pkg/action/release_install.go | 19 ++-- pkg/action/release_plan_install.go | 132 ++++++++++++++------------ pkg/action/release_plan_show.go | 23 +++-- 5 files changed, 105 insertions(+), 82 deletions(-) rename pkg/{plan => action}/plan_artifact.go (94%) diff --git a/cmd/nelm/release_plan_install.go b/cmd/nelm/release_plan_install.go index d7e09482..440f3f5b 100644 --- a/cmd/nelm/release_plan_install.go +++ b/cmd/nelm/release_plan_install.go @@ -48,7 +48,7 @@ func newReleasePlanInstallCommand(ctx context.Context, afterAllCommandsBuiltFunc cfg.Chart = args[0] } - if err := action.ReleasePlanInstall(ctx, cfg.ReleaseName, cfg.ReleaseNamespace, cfg.ReleasePlanInstallOptions); err != nil { + if _, err := action.ReleasePlanInstall(ctx, cfg.ReleaseName, cfg.ReleaseNamespace, cfg.ReleasePlanInstallOptions); err != nil { return fmt.Errorf("release plan install: %w", err) } diff --git a/pkg/plan/plan_artifact.go b/pkg/action/plan_artifact.go similarity index 94% rename from pkg/plan/plan_artifact.go rename to pkg/action/plan_artifact.go index 86352122..9014582c 100644 --- a/pkg/plan/plan_artifact.go +++ b/pkg/action/plan_artifact.go @@ -1,4 +1,4 @@ -package plan +package action import ( "compress/gzip" @@ -10,6 +10,7 @@ import ( "time" "github.com/samber/lo" + "github.com/werf/nelm/pkg/plan" "github.com/werf/common-go/pkg/secrets_manager" "github.com/werf/nelm/pkg/common" @@ -32,11 +33,11 @@ type PlanArtifact struct { type PlanArtifactData struct { Options common.ReleaseInstallRuntimeOptions `json:"options"` - Changes []*ResourceChange `json:"changes"` - Plan *Plan `json:"plan"` + Changes []*plan.ResourceChange `json:"changes"` + Plan *plan.Plan `json:"plan"` Release *helmrelease.Release `json:"release"` - InstallableResourceInfos []*InstallableResourceInfo `json:"installableResourceInfos"` - ReleaseInfos []*ReleaseInfo `json:"releaseInfos"` + InstallableResourceInfos []*plan.InstallableResourceInfo `json:"installableResourceInfos"` + ReleaseInfos []*plan.ReleaseInfo `json:"releaseInfos"` } type PlanArtifactRelease struct { diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index 7824c23a..56e2f1a5 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -88,6 +88,8 @@ type ReleaseInstallOptions struct { // LegacyLogRegistryStreamOut is the output writer for Helm registry client logs. // Defaults to io.Discard if not set. Used for debugging registry operations. LegacyLogRegistryStreamOut io.Writer + // LegacyPlanArtifact provides plan artifact as a result of the release plan install action. + LegacyPlanArtifact *PlanArtifact // LegacyProgressReportCh, when non-nil, receives ProgressReport snapshots during deployment. // Must be a buffered channel with capacity >= 1. The caller owns the channel and is responsible // for its lifecycle. Intermediate reports may be dropped if the consumer is slow; the final @@ -165,8 +167,6 @@ func ReleaseInstall(ctx context.Context, releaseName, releaseNamespace string, o } func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, releaseName, releaseNamespace string, opts ReleaseInstallOptions) error { - usePlan := opts.PlanArtifactPath != "" - currentDir, err := os.Getwd() if err != nil { return fmt.Errorf("get current working directory: %w", err) @@ -186,20 +186,27 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re lo.Must0(os.Setenv("WERF_SECRET_KEY", opts.SecretKey)) } - var planArtifact *plan.PlanArtifact - if usePlan { + var planArtifact *PlanArtifact + + if opts.PlanArtifactPath != "" { log.Default.Info(ctx, "Using %s plan artifact", opts.PlanArtifactPath) log.Default.Debug(ctx, "Read plan artifact") - planArtifact, err = plan.ReadPlanArtifact(ctx, opts.PlanArtifactPath, opts.SecretKey, opts.SecretWorkDir) + planArtifact, err = ReadPlanArtifact(ctx, opts.PlanArtifactPath, opts.SecretKey, opts.SecretWorkDir) if err != nil { return fmt.Errorf("read plan artifact from %s: %w", opts.PlanArtifactPath, err) } + } else { + planArtifact = opts.LegacyPlanArtifact + } + + usePlan := planArtifact != nil + if usePlan { log.Default.Debug(ctx, "Validate plan artifact") - if err := plan.ValidatePlanArtifact(planArtifact, opts.PlanArtifactLifetime); err != nil { + if err := ValidatePlanArtifact(planArtifact, opts.PlanArtifactLifetime); err != nil { return fmt.Errorf("validate plan artifact: %w", err) } diff --git a/pkg/action/release_plan_install.go b/pkg/action/release_plan_install.go index 6201b3e6..0c4b7899 100644 --- a/pkg/action/release_plan_install.go +++ b/pkg/action/release_plan_install.go @@ -108,7 +108,7 @@ type ReleasePlanInstallOptions struct { } // Plans the next release installation without applying changes to the cluster. -func ReleasePlanInstall(ctx context.Context, releaseName, releaseNamespace string, opts ReleasePlanInstallOptions) error { +func ReleasePlanInstall(ctx context.Context, releaseName, releaseNamespace string, opts ReleasePlanInstallOptions) (*PlanArtifact, error) { ctx, ctxCancelFn := context.WithCancelCause(ctx) if opts.Timeout == 0 { @@ -118,35 +118,41 @@ func ReleasePlanInstall(ctx context.Context, releaseName, releaseNamespace strin ctx, _ = context.WithTimeoutCause(ctx, opts.Timeout, fmt.Errorf("context timed out: action timed out after %s", opts.Timeout.String())) defer ctxCancelFn(fmt.Errorf("context canceled: action finished")) - actionCh := make(chan error, 1) + type actionResult struct { + artifact *PlanArtifact // Replace with your actual type + err error + } + + actionCh := make(chan actionResult, 1) go func() { - actionCh <- releasePlanInstall(ctx, ctxCancelFn, releaseName, releaseNamespace, opts) + planArtifact, err := releasePlanInstall(ctx, ctxCancelFn, releaseName, releaseNamespace, opts) + actionCh <- actionResult{artifact: planArtifact, err: err} }() for { select { - case err := <-actionCh: - return err + case res := <-actionCh: + return res.artifact, res.err case <-ctx.Done(): - return context.Cause(ctx) + return nil, context.Cause(ctx) } } } -func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, releaseName, releaseNamespace string, opts ReleasePlanInstallOptions) error { +func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, releaseName, releaseNamespace string, opts ReleasePlanInstallOptions) (*PlanArtifact, error) { currentDir, err := os.Getwd() if err != nil { - return fmt.Errorf("get current working directory: %w", err) + return nil, fmt.Errorf("get current working directory: %w", err) } homeDir, err := os.UserHomeDir() if err != nil { - return fmt.Errorf("get home directory: %w", err) + return nil, fmt.Errorf("get home directory: %w", err) } opts, err = applyReleasePlanInstallOptionsDefaults(opts, currentDir, homeDir) if err != nil { - return fmt.Errorf("build release plan install options: %w", err) + return nil, fmt.Errorf("build release plan install options: %w", err) } if opts.SecretKey != "" { @@ -158,12 +164,12 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc KubeContextNamespace: releaseNamespace, // TODO: unset it everywhere }) if err != nil { - return fmt.Errorf("construct kube config: %w", err) + return nil, fmt.Errorf("construct kube config: %w", err) } clientFactory, err := kube.NewClientFactory(ctx, kubeConfig) if err != nil { - return fmt.Errorf("construct kube client factory: %w", err) + return nil, fmt.Errorf("construct kube client factory: %w", err) } helmRegistryClientOpts := []registry.ClientOption{ @@ -181,14 +187,14 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc helmRegistryClient, err := registry.NewClient(helmRegistryClientOpts...) if err != nil { - return fmt.Errorf("construct registry client: %w", err) + return nil, fmt.Errorf("construct registry client: %w", err) } releaseStorage, err := release.NewReleaseStorage(ctx, releaseNamespace, opts.ReleaseStorageDriver, clientFactory, release.ReleaseStorageOptions{ SQLConnection: opts.ReleaseStorageSQLConnection, }) if err != nil { - return fmt.Errorf("construct release storage: %w", err) + return nil, fmt.Errorf("construct release storage: %w", err) } helmOptions := common.HelmOptions{ @@ -213,7 +219,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc history, err := release.BuildHistory(releaseName, releaseStorage, release.HistoryOptions{}) if err != nil { - return fmt.Errorf("build release history: %w", err) + return nil, fmt.Errorf("build release history: %w", err) } releases := history.Releases() @@ -257,7 +263,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc TemplatesAllowDNS: opts.TemplatesAllowDNS, }) if err != nil { - return fmt.Errorf("render chart: %w", err) + return nil, fmt.Errorf("render chart: %w", err) } log.Default.Debug(ctx, "Build transformed resource specs") @@ -267,7 +273,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc spec.NewDropInvalidAnnotationsAndLabelsTransformer(), }) if err != nil { - return fmt.Errorf("build transformed resource specs: %w", err) + return nil, fmt.Errorf("build transformed resource specs: %w", err) } log.Default.Debug(ctx, "Build releasable resource specs") @@ -283,7 +289,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc releasableResSpecs, err := spec.BuildReleasableResourceSpecs(ctx, releaseNamespace, transformedResSpecs, patchers) if err != nil { - return fmt.Errorf("build releasable resource specs: %w", err) + return nil, fmt.Errorf("build releasable resource specs: %w", err) } newRelease, err := release.NewRelease(releaseName, releaseNamespace, newRevision, deployType, releasableResSpecs, renderChartResult.Chart, renderChartResult.ReleaseConfig, release.ReleaseOptions{ @@ -292,7 +298,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc Notes: renderChartResult.Notes, }) if err != nil { - return fmt.Errorf("construct new release: %w", err) + return nil, fmt.Errorf("construct new release: %w", err) } log.Default.Debug(ctx, "Convert previous release to resource specs") @@ -301,7 +307,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc if prevRelease != nil { prevRelResSpecs, err = release.ReleaseToResourceSpecs(prevRelease, releaseNamespace, false) if err != nil { - return fmt.Errorf("convert previous release to resource specs: %w", err) + return nil, fmt.Errorf("convert previous release to resource specs: %w", err) } } @@ -309,7 +315,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc newRelResSpecs, err := release.ReleaseToResourceSpecs(newRelease, releaseNamespace, false) if err != nil { - return fmt.Errorf("convert new release to resource specs: %w", err) + return nil, fmt.Errorf("convert new release to resource specs: %w", err) } log.Default.Debug(ctx, "Build resources") @@ -322,13 +328,13 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc DefaultDeletePropagation: metav1.DeletionPropagation(opts.DefaultDeletePropagation), }) if err != nil { - return fmt.Errorf("build resources: %w", err) + return nil, fmt.Errorf("build resources: %w", err) } log.Default.Debug(ctx, "Locally validate resources") if err := resource.ValidateLocal(ctx, releaseNamespace, instResources, opts.ResourceValidationOptions); err != nil { - return fmt.Errorf("locally validate resources: %w", err) + return nil, fmt.Errorf("locally validate resources: %w", err) } log.Default.Debug(ctx, "Build resource infos") @@ -339,7 +345,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc if lastDeployedOrLastRelease != nil { lastDeployedOrLastRelResSpecs, err = release.ReleaseToResourceSpecs(lastDeployedOrLastRelease, releaseNamespace, false) if err != nil { - return fmt.Errorf("convert last deployed or last release to resource specs: %w", err) + return nil, fmt.Errorf("convert last deployed or last release to resource specs: %w", err) } } @@ -349,20 +355,20 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc LastDeployedOrLastRelResourceSpecs: lastDeployedOrLastRelResSpecs, }) if err != nil { - return fmt.Errorf("build resource infos: %w", err) + return nil, fmt.Errorf("build resource infos: %w", err) } log.Default.Debug(ctx, "Remotely validate resources") if err := plan.ValidateRemote(releaseName, releaseNamespace, instResInfos, opts.ForceAdoption); err != nil { - return fmt.Errorf("remotely validate resources: %w", err) + return nil, fmt.Errorf("remotely validate resources: %w", err) } log.Default.Debug(ctx, "Build release infos") relInfos, err := plan.BuildReleaseInfos(ctx, deployType, releases, newRelease) if err != nil { - return fmt.Errorf("build release infos: %w", err) + return nil, fmt.Errorf("build release infos: %w", err) } log.Default.Debug(ctx, "Build install plan") @@ -373,18 +379,18 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc if err != nil { handleBuildPlanErr(ctx, installPlan, err, opts.InstallGraphPath, opts.TempDirPath, "release-install-graph.dot") - return fmt.Errorf("build install plan: %w", err) + return nil, fmt.Errorf("build install plan: %w", err) } if opts.InstallGraphPath != "" { if err := savePlanAsDot(installPlan, opts.InstallGraphPath); err != nil { - return fmt.Errorf("save release install graph: %w", err) + return nil, fmt.Errorf("save release install graph: %w", err) } } releaseIsUpToDate, err := release.IsReleaseUpToDate(prevRelease, newRelease) if err != nil { - return fmt.Errorf("check if release is up to date: %w", err) + return nil, fmt.Errorf("check if release is up to date: %w", err) } installPlanIsUseless := lo.NoneBy(installPlan.Operations(), func(op *plan.Operation) bool { @@ -400,7 +406,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc changes, err := plan.CalculatePlannedChanges(instResInfos, delResInfos) if err != nil { - return fmt.Errorf("calculate planned changes: %w", err) + return nil, fmt.Errorf("calculate planned changes: %w", err) } if releaseIsUpToDate && installPlanIsUseless { @@ -409,56 +415,56 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc log.Default.Info(ctx, color.Style{color.Bold, color.Yellow}.Render(fmt.Sprintf("No resource changes planned, but still must install release %q (namespace: %q)", releaseName, releaseNamespace))) } - if err := logPlannedChanges(ctx, releaseName, releaseNamespace, changes, opts.ResourceDiffOptions); err != nil { - return fmt.Errorf("log planned changes: %w", err) + planArtifact := &PlanArtifact{ + APIVersion: PlanArtifactSchemeVersion, + Data: &PlanArtifactData{ + Options: opts.ReleaseInstallRuntimeOptions, + Release: newRelease, + Plan: installPlan, + Changes: changes, + InstallableResourceInfos: instResInfos, + ReleaseInfos: relInfos, + }, + DeployType: deployType, + Release: PlanArtifactRelease{ + Name: releaseName, + Namespace: releaseNamespace, + Revision: newRelease.Version, + }, + Timestamp: time.Now().UTC(), } - if opts.PlanArtifactPath != "" { - planArtifact := &plan.PlanArtifact{ - APIVersion: plan.PlanArtifactSchemeVersion, - Data: &plan.PlanArtifactData{ - Options: opts.ReleaseInstallRuntimeOptions, - Release: newRelease, - Plan: installPlan, - Changes: changes, - InstallableResourceInfos: instResInfos, - ReleaseInfos: relInfos, - }, - DeployType: deployType, - Release: plan.PlanArtifactRelease{ - Name: releaseName, - Namespace: releaseNamespace, - Revision: newRelease.Version, - }, - Timestamp: time.Now().UTC(), - } + if err := logPlannedChanges(ctx, planArtifact, opts.ResourceDiffOptions); err != nil { + return nil, fmt.Errorf("log planned changes: %w", err) + } - if err := plan.WritePlanArtifact(ctx, planArtifact, opts.PlanArtifactPath, opts.SecretKey, opts.SecretWorkDir); err != nil { - return fmt.Errorf("save install plan to %q: %w", opts.PlanArtifactPath, err) + if opts.PlanArtifactPath != "" { + if err := WritePlanArtifact(ctx, planArtifact, opts.PlanArtifactPath, opts.SecretKey, opts.SecretWorkDir); err != nil { + return nil, fmt.Errorf("save install plan to %q: %w", opts.PlanArtifactPath, err) } } if opts.ErrorIfChangesPlanned { if releaseIsUpToDate && installPlanIsUseless { - return nil + return planArtifact, nil } else if installPlanIsUseless || len(changes) == 0 { - return ErrReleaseInstallPlanned + return planArtifact, ErrReleaseInstallPlanned } - return ErrResourceChangesPlanned + return planArtifact, ErrResourceChangesPlanned } - return nil + return planArtifact, nil } -func logPlannedChanges(ctx context.Context, releaseName, releaseNamespace string, changes []*plan.ResourceChange, opts common.ResourceDiffOptions) error { - if len(changes) == 0 { +func logPlannedChanges(ctx context.Context, planArtifact *PlanArtifact, opts common.ResourceDiffOptions) error { + if len(planArtifact.Data.Changes) == 0 { return nil } log.Default.Info(ctx, "") - for _, change := range changes { + for _, change := range planArtifact.Data.Changes { if err := log.Default.InfoBlockErr(ctx, log.BlockOptions{ BlockTitle: buildDiffHeader(change), }, func() error { @@ -479,10 +485,10 @@ func logPlannedChanges(ctx context.Context, releaseName, releaseNamespace string } } - log.Default.Info(ctx, color.Bold.Render("Planned changes summary")+" for release %q (namespace: %q):", releaseName, releaseNamespace) + log.Default.Info(ctx, color.Bold.Render("Planned changes summary")+" for release %q (namespace: %q):", planArtifact.Release.Name, planArtifact.Release.Namespace) for _, changeType := range []string{"create", "recreate", "update", "blind apply", "delete"} { - logSummaryLine(ctx, changes, changeType) + logSummaryLine(ctx, planArtifact.Data.Changes, changeType) } log.Default.Info(ctx, "") diff --git a/pkg/action/release_plan_show.go b/pkg/action/release_plan_show.go index 47ed3590..b68bf94f 100644 --- a/pkg/action/release_plan_show.go +++ b/pkg/action/release_plan_show.go @@ -9,7 +9,6 @@ import ( "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/log" - "github.com/werf/nelm/pkg/plan" ) const DefaultReleasePlanShowLogLevel = log.InfoLevel @@ -17,7 +16,9 @@ const DefaultReleasePlanShowLogLevel = log.InfoLevel type ReleasePlanShowOptions struct { common.ResourceDiffOptions - // PlanArtifactPath is the path to the plan artifact file to execute. + // LegacyPlanArtifact provides plan artifact to review changes. + LegacyPlanArtifact *PlanArtifact + // PlanArtifactPath is the path to the plan artifact file to review changes. PlanArtifactPath string // SecretKey is the encryption/decryption key for the plan artifact file. SecretKey string @@ -42,14 +43,22 @@ func ReleasePlanShow(ctx context.Context, opts ReleasePlanShowOptions) error { lo.Must0(os.Setenv("WERF_SECRET_KEY", opts.SecretKey)) } - log.Default.Debug(ctx, "Read plan artifact") + var planArtifact *PlanArtifact - planArtifact, err := plan.ReadPlanArtifact(ctx, opts.PlanArtifactPath, opts.SecretKey, opts.SecretWorkDir) - if err != nil { - return fmt.Errorf("read plan artifact from %s: %w", opts.PlanArtifactPath, err) + if opts.LegacyPlanArtifact != nil { + planArtifact = opts.LegacyPlanArtifact + } else { + var err error + + log.Default.Debug(ctx, "Read plan artifact") + + planArtifact, err = ReadPlanArtifact(ctx, opts.PlanArtifactPath, opts.SecretKey, opts.SecretWorkDir) + if err != nil { + return fmt.Errorf("read plan artifact from %s: %w", opts.PlanArtifactPath, err) + } } - if err := logPlannedChanges(ctx, planArtifact.Release.Name, planArtifact.Release.Namespace, planArtifact.Data.Changes, opts.ResourceDiffOptions); err != nil { + if err := logPlannedChanges(ctx, planArtifact, opts.ResourceDiffOptions); err != nil { return fmt.Errorf("log planned changes: %w", err) } From 2ee2d05ef533f1c11fe588feee8106be0d834119 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Thu, 9 Apr 2026 15:03:16 +0300 Subject: [PATCH 11/85] chore: remove renaming step `main -> helm` from helm sync task Signed-off-by: Dmitry Mordvinov --- Taskfile.dist.yaml | 5 ++--- pkg/action/release_history.go | 10 +++++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Taskfile.dist.yaml b/Taskfile.dist.yaml index 94cc3d02..5f4f8122 100644 --- a/Taskfile.dist.yaml +++ b/Taskfile.dist.yaml @@ -491,8 +491,7 @@ tasks: sg scan --rule "$intern_rule" -U "{{.forkSnapshotDir}}" rm -f "$intern_rule" - rm -f "{{.forkSnapshotDir}}/cmd/helm/helm.go" - find "{{.forkSnapshotDir}}/cmd/helm" -name "*.go" -exec perl -i -p -e 's/^package main$/package helm/' {} + + rm -rf "{{.forkSnapshotDir}}/cmd" git -C "{{.forkSnapshotDir}}" add -A git -C "{{.forkSnapshotDir}}" -c user.name="sync" -c user.email="sync@local" \ @@ -504,7 +503,7 @@ tasks: git fetch "{{.forkTempRemote}}" prepared git checkout -B "{{.forkSyncBranch}}" FETCH_HEAD --no-track - prev_split=$(git log --grep="git-subtree-dir: {{.forkPrefix}}" --format="%b" "${original_ref}" | grep -m1 "^git-subtree-split:" | cut -d' ' -f2) + prev_split=$(git log --grep="git-subtree-dir: {{.forkPrefix}}" --format="%b" "${original_ref}" | grep "^git-subtree-split:" | head -n1 | cut -d' ' -f2) if [ -z "$prev_split" ]; then echo "WARNING: no previous subtree split found for {{.forkPrefix}}, skipping file stripping" else diff --git a/pkg/action/release_history.go b/pkg/action/release_history.go index 999951d7..d46fb5b1 100644 --- a/pkg/action/release_history.go +++ b/pkg/action/release_history.go @@ -299,13 +299,17 @@ func setReleaseHistoryOutputTableStyle(ctx context.Context, table prtable.Writer tableWidth = 200 } + paddingsWidth := len(columnConfigs) * (len(style.PaddingLeft) + len(style.PaddingRight)) + columnConfigs[0].WidthMax = 10 - columnConfigs[1].WidthMax = 12 - columnConfigs[2].WidthMax = 25 - columnConfigs[3].WidthMax = 24 + columnConfigs[1].WidthMax = 16 + columnConfigs[2].WidthMax = 30 columnConfigs[4].WidthMax = 16 columnConfigs[5].WidthMax = 16 + fixedWidth := columnConfigs[0].WidthMax + columnConfigs[1].WidthMax + columnConfigs[2].WidthMax + columnConfigs[4].WidthMax + columnConfigs[5].WidthMax + columnConfigs[3].WidthMax = tableWidth - paddingsWidth - fixedWidth + table.SetColumnConfigs(columnConfigs) table.SetStyle(prtable.Style{ Box: style, From 1e1068f13d2401dca4ec7396d2fb17948e33e3b6 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Tue, 21 Apr 2026 17:25:11 +0300 Subject: [PATCH 12/85] fix: use v3 util for Chart.yaml load Signed-off-by: Dmitry Mordvinov --- pkg/action/chart_ts_init.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/action/chart_ts_init.go b/pkg/action/chart_ts_init.go index 531f177e..fb078573 100644 --- a/pkg/action/chart_ts_init.go +++ b/pkg/action/chart_ts_init.go @@ -8,7 +8,7 @@ import ( "path/filepath" "github.com/werf/nelm/pkg/featgate" - "github.com/werf/nelm/pkg/helm/pkg/chartutil" + "github.com/werf/nelm/pkg/helm/intern/chart/v3/util" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/ts" ) @@ -36,7 +36,7 @@ func ChartTSInit(ctx context.Context, opts ChartTSInitOptions) error { if opts.ChartName != "" { chartName = opts.ChartName } else { - meta, err := chartutil.LoadChartfile(filepath.Join(absPath, "Chart.yaml")) + meta, err := util.LoadChartfile(filepath.Join(absPath, "Chart.yaml")) if err != nil { return fmt.Errorf("load Chart.yaml: %w", err) } From 7a758a486e9daef4d1b33342916af0591dfd7b1d Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Wed, 22 Apr 2026 23:03:21 +0300 Subject: [PATCH 13/85] fix: clientFactory=nil breaks werf tests Signed-off-by: Dmitry Mordvinov --- pkg/release/release_storage.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/pkg/release/release_storage.go b/pkg/release/release_storage.go index 9c2ca005..3b1782b4 100644 --- a/pkg/release/release_storage.go +++ b/pkg/release/release_storage.go @@ -87,13 +87,21 @@ type ReleaseStorageOptions struct { func NewReleaseStorage(ctx context.Context, namespace, storageDriver string, clientFactory kube.ClientFactorier, opts ReleaseStorageOptions) (ReleaseStorager, error) { var storage *helmstorage.Storage - clientset := clientFactory.Static().(*kubernetes.Clientset) - switch storageDriver { case common.ReleaseStorageDriverSecret, common.ReleaseStorageDriverSecrets, common.ReleaseStorageDriverDefault: + if clientFactory == nil { + return nil, fmt.Errorf("kube client factory is required for %q storage driver", storageDriver) + } + + clientset := clientFactory.Static().(*kubernetes.Clientset) d := helmdriver.NewSecrets(clientset.CoreV1().Secrets(namespace)) storage = helmstorage.Init(d) case common.ReleaseStorageDriverConfigMap, common.ReleaseStorageDriverConfigMaps: + if clientFactory == nil { + return nil, fmt.Errorf("kube client factory is required for %q storage driver", storageDriver) + } + + clientset := clientFactory.Static().(*kubernetes.Clientset) d := helmdriver.NewConfigMaps(clientset.CoreV1().ConfigMaps(namespace)) storage = helmstorage.Init(d) case common.ReleaseStorageDriverMemory: From e9ec2eb21d2fdd8e7547c81d75a97c05e77b7206 Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:33:45 +0300 Subject: [PATCH 14/85] doc: add plan artifact feature description (#597) Signed-off-by: Ilya Drey --- README.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 190c68c0..56d39298 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ **Nelm** is a Helm 4 alternative. It is a Kubernetes deployment tool that manages Helm Charts and deploys them to Kubernetes. It is also the deployment engine of [werf](https://github.com/werf/werf). Nelm does everything that Helm does, but better, and even quite some on top of it. Nelm is based on an improved and partially rewritten Helm codebase, to introduce: -* `terraform plan`-like capabilities; +* `terraform plan`-like capabilities with three-stage deployment support; * improved CRD management; * out-of-the-box secrets management; * advanced resource ordering capabilities; @@ -35,7 +35,7 @@ Nelm is production-ready: as the werf deployment engine, it was battle-tested ac - [Advanced resource lifecycle capabilities](#advanced-resource-lifecycle-capabilities) - [Resource state tracking](#resource-state-tracking) - [Printing logs and events during deploy](#printing-logs-and-events-during-deploy) - - [Release planning](#release-planning) + - [Release planning and two-stage deployment support](#release-planning-and-two-stage-deployment-workflow-support) - [Encrypted values and encrypted files](#encrypted-values-and-encrypted-files) - [Improved CRD management](#improved-crd-management) - [Usage](#usage) @@ -244,12 +244,23 @@ Nelm has powerful resource tracking built from the ground up, much more advanced During the deployment, Nelm finds Pods of deploying resources and periodically prints their container logs. With annotation `werf.io/show-service-messages: "true"`, resource events are also printed. Can be configured with CLI flags and annotations. -### Release planning +### Release planning and two-stage deployment workflow support -`nelm release plan install` shows exactly what's going to happen in the cluster on the next release. It shows 100% accurate diffs between current and to-be resource versions, utilizing robust dry-run Server-Side Apply instead of client-side trickery. +`nelm release plan install` shows exactly what's going to happen in the cluster on the next release. It shows diffs between the current and to-be resource versions, utilizing robust dry-run Kubernetes Server-Side Apply capabilities. ![planning](resources/images/nelm-release-plan-install.png) +To ensure that these exact changes will be applied during the release install, you can utilize a two-stage deployment workflow: + +1. **Plan:** Generate, review, and save a plan artifact using the `--save-plan` flag: +``` +nelm release plan install --save-plan=plan.gz +``` +2. **Apply:** Perform the release install rapidly using the pre-generated reviewed plan: +``` +nelm release install --use-plan=plan.gz +``` + ### Encrypted values and encrypted files `nelm chart secret` commands manage encrypted values files such as `secret-values.yaml` or encrypted arbitrary files like `secret/mysecret.txt`. These files are decrypted in-memory during templating and can be used in templates as `.Values.my.secret.value` and `{{ werf_secret_file "mysecret.txt" }}`, respectively. From 8a3cba3610061aeab539efa134f5b5518dc10407 Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:41:19 +0300 Subject: [PATCH 15/85] chore: fix typo in the README.md (#598) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 56d39298..d739695f 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ **Nelm** is a Helm 4 alternative. It is a Kubernetes deployment tool that manages Helm Charts and deploys them to Kubernetes. It is also the deployment engine of [werf](https://github.com/werf/werf). Nelm does everything that Helm does, but better, and even quite some on top of it. Nelm is based on an improved and partially rewritten Helm codebase, to introduce: -* `terraform plan`-like capabilities with three-stage deployment support; +* `terraform plan`-like capabilities with two-stage deployment support; * improved CRD management; * out-of-the-box secrets management; * advanced resource ordering capabilities; From d1b62972befb7ca750ac84ec4f9f5760e862314f Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:20:38 +0300 Subject: [PATCH 16/85] feat: forbid resource recreation if helm.sh/resource-policy set --- pkg/plan/resource_info.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/plan/resource_info.go b/pkg/plan/resource_info.go index 4452b535..e8b1ec6e 100644 --- a/pkg/plan/resource_info.go +++ b/pkg/plan/resource_info.go @@ -216,6 +216,12 @@ func buildInstallableResourceInfo(ctx context.Context, localRes *resource.Instal return nil, fmt.Errorf("determine install type for resource %q: %w", localRes.IDHuman(), err) } + if installType == ResourceInstallTypeRecreate { + if _, found := localRes.Annotations[common.AnnotationKeyHumanResourcePolicy]; found { + return nil, fmt.Errorf("cannot recreate the resource %q because its deletion is prohibited", localRes.IDHuman()) + } + } + getMeta := spec.NewResourceMetaFromUnstructured(getObj, releaseNamespace, localRes.FilePath) mustDeleteOnSuccess := mustDeleteOnSuccessfulDeploy(localRes, getMeta, installType, releaseNamespace) trackReadiness := mustTrackReadiness(localRes, installType, true, prevRelFailed, mustDeleteOnSuccess) From 3ebc0150106a4160a028c034c537b5941be0ffe3 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Tue, 28 Apr 2026 18:59:33 +0300 Subject: [PATCH 17/85] chore: helm test cleanup and linter fixes (#609) Signed-off-by: Dmitry Mordvinov --- cmd/nelm/chart_pack.go | 2 +- cmd/nelm/generate_reference.go | 25 +- cmd/nelm/release.go | 3 +- pkg/helm/pkg/cmd/list_test.go | 617 ------------------ .../cmd/testdata/output/get-all-no-args.txt | 3 - .../cmd/testdata/output/get-hooks-no-args.txt | 3 - .../pkg/cmd/testdata/output/get-hooks.txt | 8 - .../testdata/output/get-manifest-no-args.txt | 3 - .../pkg/cmd/testdata/output/get-manifest.txt | 5 - .../cmd/testdata/output/get-metadata-args.txt | 3 - .../pkg/cmd/testdata/output/get-metadata.json | 1 - .../pkg/cmd/testdata/output/get-metadata.txt | 12 - .../pkg/cmd/testdata/output/get-metadata.yaml | 23 - .../cmd/testdata/output/get-notes-no-args.txt | 3 - .../pkg/cmd/testdata/output/get-notes.txt | 2 - .../testdata/output/get-release-template.txt | 1 - .../pkg/cmd/testdata/output/get-release.txt | 33 - .../cmd/testdata/output/get-values-all.txt | 2 - .../cmd/testdata/output/get-values-args.txt | 3 - .../pkg/cmd/testdata/output/get-values.txt | 2 - .../testdata/output/install-and-replace.txt | 7 - .../output/install-and-take-ownership.txt | 7 - .../output/install-chart-bad-type.txt | 1 - .../install-dry-run-with-secret-hidden.txt | 21 - .../output/install-dry-run-with-secret.txt | 26 - .../testdata/output/install-hide-secret.txt | 1 - .../cmd/testdata/output/install-lib-chart.txt | 1 - .../testdata/output/install-name-template.txt | 7 - .../cmd/testdata/output/install-no-args.txt | 3 - .../cmd/testdata/output/install-no-hooks.txt | 7 - .../install-with-multiple-values-files.txt | 7 - .../output/install-with-multiple-values.txt | 7 - .../testdata/output/install-with-timeout.txt | 7 - .../output/install-with-values-file.txt | 7 - .../testdata/output/install-with-values.txt | 7 - .../output/install-with-wait-for-jobs.txt | 7 - .../cmd/testdata/output/install-with-wait.txt | 7 - pkg/helm/pkg/cmd/testdata/output/install.txt | 7 - ...hart-with-bad-subcharts-with-subcharts.txt | 21 - .../output/lint-chart-with-bad-subcharts.txt | 7 - ...lint-chart-with-deprecated-api-old-k8s.txt | 4 - .../lint-chart-with-deprecated-api-strict.txt | 5 - .../output/lint-chart-with-deprecated-api.txt | 5 - .../testdata/output/lint-quiet-with-error.txt | 8 - .../output/lint-quiet-with-warning.txt | 4 - .../pkg/cmd/testdata/output/lint-quiet.txt | 0 .../output/list-all-date-reversed.txt | 9 - .../pkg/cmd/testdata/output/list-all-date.txt | 9 - .../pkg/cmd/testdata/output/list-all-max.txt | 2 - .../testdata/output/list-all-no-headers.txt | 8 - .../cmd/testdata/output/list-all-offset.txt | 8 - .../cmd/testdata/output/list-all-reverse.txt | 9 - .../testdata/output/list-all-short-json.txt | 1 - .../testdata/output/list-all-short-yaml.txt | 8 - .../cmd/testdata/output/list-all-short.txt | 8 - pkg/helm/pkg/cmd/testdata/output/list-all.txt | 9 - .../testdata/output/list-date-reversed.txt | 5 - .../pkg/cmd/testdata/output/list-date.txt | 5 - .../pkg/cmd/testdata/output/list-failed.txt | 2 - .../pkg/cmd/testdata/output/list-filter.txt | 5 - .../pkg/cmd/testdata/output/list-json.txt | 1 - pkg/helm/pkg/cmd/testdata/output/list-max.txt | 2 - .../cmd/testdata/output/list-namespace.txt | 2 - .../cmd/testdata/output/list-no-headers.txt | 4 - .../pkg/cmd/testdata/output/list-offset.txt | 4 - .../pkg/cmd/testdata/output/list-pending.txt | 2 - .../pkg/cmd/testdata/output/list-reverse.txt | 5 - .../cmd/testdata/output/list-short-json.txt | 1 - .../cmd/testdata/output/list-short-yaml.txt | 4 - .../pkg/cmd/testdata/output/list-short.txt | 4 - .../cmd/testdata/output/list-superseded.txt | 3 - .../cmd/testdata/output/list-time-format.txt | 2 - .../cmd/testdata/output/list-uninstalled.txt | 2 - .../cmd/testdata/output/list-uninstalling.txt | 2 - .../pkg/cmd/testdata/output/list-yaml.txt | 7 - pkg/helm/pkg/cmd/testdata/output/list.txt | 5 - .../cmd/testdata/output/plugin_args_comp.txt | 6 - .../testdata/output/plugin_args_flag_comp.txt | 6 - .../output/plugin_args_many_args_comp.txt | 6 - .../testdata/output/plugin_args_ns_comp.txt | 6 - .../output/plugin_echo_no_directive.txt | 6 - .../cmd/testdata/output/plugin_list_comp.txt | 7 - .../testdata/output/plugin_repeat_comp.txt | 6 - .../pkg/cmd/testdata/output/rollback-comp.txt | 4 - .../cmd/testdata/output/rollback-no-args.txt | 3 - .../testdata/output/rollback-no-revision.txt | 1 - .../output/rollback-non-existent-version.txt | 1 - .../cmd/testdata/output/rollback-timeout.txt | 1 - .../output/rollback-wait-for-jobs.txt | 1 - .../pkg/cmd/testdata/output/rollback-wait.txt | 1 - .../output/rollback-wrong-args-comp.txt | 3 - pkg/helm/pkg/cmd/testdata/output/rollback.txt | 1 - .../testdata/output/schema-negative-cli.txt | 4 - .../cmd/testdata/output/schema-negative.txt | 5 - pkg/helm/pkg/cmd/testdata/output/schema.txt | 7 - .../output/subchart-schema-cli-negative.txt | 4 - .../testdata/output/subchart-schema-cli.txt | 7 - .../output/subchart-schema-negative.txt | 6 - .../output/template-chart-bad-type.txt | 1 - ...te-chart-with-template-lib-archive-dep.txt | 61 -- .../template-chart-with-template-lib-dep.txt | 61 -- .../testdata/output/template-lib-chart.txt | 1 - .../output/template-name-template.txt | 114 ---- .../cmd/testdata/output/template-no-args.txt | 3 - .../pkg/cmd/testdata/output/template-set.txt | 114 ---- .../output/template-show-only-glob.txt | 24 - .../output/template-show-only-multiple.txt | 38 -- .../output/template-show-only-one.txt | 21 - .../testdata/output/template-skip-tests.txt | 85 --- .../output/template-subchart-cm-set-file.txt | 122 ---- .../output/template-subchart-cm-set.txt | 122 ---- .../testdata/output/template-subchart-cm.txt | 122 ---- .../testdata/output/template-values-files.txt | 114 ---- .../output/template-with-api-version.txt | 116 ---- .../testdata/output/template-with-crds.txt | 131 ---- .../template-with-invalid-yaml-debug.txt | 13 - .../output/template-with-invalid-yaml.txt | 3 - .../output/template-with-kube-version.txt | 114 ---- pkg/helm/pkg/cmd/testdata/output/template.txt | 114 ---- ...ninstall-keep-history-earlier-deployed.txt | 1 - .../output/uninstall-keep-history.txt | 1 - .../testdata/output/uninstall-multiple.txt | 2 - .../cmd/testdata/output/uninstall-no-args.txt | 3 - .../testdata/output/uninstall-no-hooks.txt | 1 - .../cmd/testdata/output/uninstall-timeout.txt | 1 - .../cmd/testdata/output/uninstall-wait.txt | 1 - .../pkg/cmd/testdata/output/uninstall.txt | 1 - .../output/upgrade-and-take-ownership.txt | 8 - .../upgrade-uninstalled-with-keep-history.txt | 8 - .../output/upgrade-with-bad-dependencies.txt | 1 - ...e-with-bad-or-missing-existing-release.txt | 1 - .../output/upgrade-with-dependency-update.txt | 10 - .../output/upgrade-with-install-timeout.txt | 8 - .../testdata/output/upgrade-with-install.txt | 8 - .../upgrade-with-missing-dependencies.txt | 1 - .../output/upgrade-with-pending-install.txt | 1 - .../output/upgrade-with-reset-values.txt | 8 - .../output/upgrade-with-reset-values2.txt | 8 - .../testdata/output/upgrade-with-timeout.txt | 8 - .../output/upgrade-with-wait-for-jobs.txt | 8 - .../cmd/testdata/output/upgrade-with-wait.txt | 8 - pkg/helm/pkg/cmd/testdata/output/upgrade.txt | 8 - pkg/helm/pkg/cmd/testdata/plugins.yaml | 3 - .../pkg/cmd/testdata/testplugin/plugin.yaml | 12 - pkg/helm/pkg/getter/getter_test.go | 31 - .../testdata/plugins/testgetter/plugin.yaml | 13 - .../testdata/plugins/testgetter2/plugin.yaml | 13 - .../pkg/postrenderer/postrenderer_test.go | 81 --- .../plugins/postrenderer-v1/plugin.yaml | 8 - .../plugins/postrenderer-v1/sed-test.sh | 6 - 150 files changed, 15 insertions(+), 2926 deletions(-) delete mode 100644 pkg/helm/pkg/cmd/list_test.go delete mode 100644 pkg/helm/pkg/cmd/testdata/output/get-all-no-args.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/get-hooks-no-args.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/get-hooks.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/get-manifest-no-args.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/get-manifest.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/get-metadata-args.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/get-metadata.json delete mode 100644 pkg/helm/pkg/cmd/testdata/output/get-metadata.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/get-metadata.yaml delete mode 100644 pkg/helm/pkg/cmd/testdata/output/get-notes-no-args.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/get-notes.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/get-release-template.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/get-release.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/get-values-all.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/get-values-args.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/get-values.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/install-and-replace.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/install-and-take-ownership.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/install-chart-bad-type.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/install-dry-run-with-secret-hidden.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/install-dry-run-with-secret.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/install-hide-secret.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/install-lib-chart.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/install-name-template.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/install-no-args.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/install-no-hooks.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/install-with-multiple-values-files.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/install-with-multiple-values.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/install-with-timeout.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/install-with-values-file.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/install-with-values.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/install-with-wait-for-jobs.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/install-with-wait.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/install.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/lint-chart-with-bad-subcharts-with-subcharts.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/lint-chart-with-bad-subcharts.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/lint-chart-with-deprecated-api-old-k8s.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/lint-chart-with-deprecated-api-strict.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/lint-chart-with-deprecated-api.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/lint-quiet-with-error.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/lint-quiet-with-warning.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/lint-quiet.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-all-date-reversed.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-all-date.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-all-max.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-all-no-headers.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-all-offset.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-all-reverse.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-all-short-json.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-all-short-yaml.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-all-short.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-all.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-date-reversed.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-date.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-failed.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-filter.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-json.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-max.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-namespace.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-no-headers.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-offset.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-pending.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-reverse.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-short-json.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-short-yaml.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-short.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-superseded.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-time-format.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-uninstalled.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-uninstalling.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list-yaml.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/list.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/plugin_args_comp.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/plugin_args_flag_comp.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/plugin_args_many_args_comp.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/plugin_args_ns_comp.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/plugin_echo_no_directive.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/plugin_list_comp.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/plugin_repeat_comp.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/rollback-comp.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/rollback-no-args.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/rollback-no-revision.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/rollback-non-existent-version.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/rollback-timeout.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/rollback-wait-for-jobs.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/rollback-wait.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/rollback-wrong-args-comp.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/rollback.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/schema-negative-cli.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/schema-negative.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/schema.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/subchart-schema-cli-negative.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/subchart-schema-cli.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/subchart-schema-negative.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/template-chart-bad-type.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/template-chart-with-template-lib-archive-dep.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/template-chart-with-template-lib-dep.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/template-lib-chart.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/template-name-template.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/template-no-args.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/template-set.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/template-show-only-glob.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/template-show-only-multiple.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/template-show-only-one.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/template-skip-tests.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/template-subchart-cm-set-file.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/template-subchart-cm-set.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/template-subchart-cm.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/template-values-files.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/template-with-api-version.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/template-with-crds.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/template-with-invalid-yaml-debug.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/template-with-invalid-yaml.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/template-with-kube-version.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/template.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/uninstall-keep-history-earlier-deployed.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/uninstall-keep-history.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/uninstall-multiple.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/uninstall-no-args.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/uninstall-no-hooks.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/uninstall-timeout.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/uninstall-wait.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/uninstall.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/upgrade-and-take-ownership.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/upgrade-uninstalled-with-keep-history.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/upgrade-with-bad-dependencies.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/upgrade-with-bad-or-missing-existing-release.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/upgrade-with-dependency-update.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/upgrade-with-install-timeout.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/upgrade-with-install.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/upgrade-with-missing-dependencies.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/upgrade-with-pending-install.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/upgrade-with-reset-values.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/upgrade-with-reset-values2.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/upgrade-with-timeout.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/upgrade-with-wait-for-jobs.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/upgrade-with-wait.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/output/upgrade.txt delete mode 100644 pkg/helm/pkg/cmd/testdata/plugins.yaml delete mode 100644 pkg/helm/pkg/cmd/testdata/testplugin/plugin.yaml delete mode 100644 pkg/helm/pkg/getter/testdata/plugins/testgetter/plugin.yaml delete mode 100644 pkg/helm/pkg/getter/testdata/plugins/testgetter2/plugin.yaml delete mode 100644 pkg/helm/pkg/postrenderer/postrenderer_test.go delete mode 100644 pkg/helm/pkg/postrenderer/testdata/plugins/postrenderer-v1/plugin.yaml delete mode 100755 pkg/helm/pkg/postrenderer/testdata/plugins/postrenderer-v1/sed-test.sh diff --git a/cmd/nelm/chart_pack.go b/cmd/nelm/chart_pack.go index 0e361a6a..a0feb3b2 100644 --- a/cmd/nelm/chart_pack.go +++ b/cmd/nelm/chart_pack.go @@ -34,7 +34,7 @@ func newChartPackCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co cmd.RunE = func(cmd *cobra.Command, args []string) error { helmSettings := helmcmd.Settings - // FIXME(major): should we do it like that everywhere, setting the context? + // TODO(major): should we do it like that everywhere, setting the context? ctx = action.SetupLogging(cmd.Context(), lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), action.SetupLoggingOptions{}) ctx = ts.NewContextWithTSOptions(ctx, opts) cmd.SetContext(ctx) diff --git a/cmd/nelm/generate_reference.go b/cmd/nelm/generate_reference.go index 9824b5f2..447690c2 100644 --- a/cmd/nelm/generate_reference.go +++ b/cmd/nelm/generate_reference.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" + "github.com/samber/lo" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -70,7 +71,7 @@ func generateReferenceDoc(rootCmd *cobra.Command) string { func renderCommandMarkdown(cmd *cobra.Command, commandPath string) string { var buf bytes.Buffer - buf.WriteString(fmt.Sprintf("### %s\n\n", commandPath)) + lo.Must(fmt.Fprintf(&buf, "### %s\n\n", commandPath)) if cmd.Long != "" { buf.WriteString(util.EscapeForMarkdownPreservingCodeSpans(cmd.Long) + "\n\n") @@ -79,16 +80,16 @@ func renderCommandMarkdown(cmd *cobra.Command, commandPath string) string { } buf.WriteString("**Usage:**\n\n") - buf.WriteString(fmt.Sprintf("```shell\n%s\n```\n\n", cmd.UseLine())) + lo.Must(fmt.Fprintf(&buf, "```shell\n%s\n```\n\n", cmd.UseLine())) if len(cmd.Aliases) > 0 { buf.WriteString("**Aliases:** ") - buf.WriteString(fmt.Sprintf("`%s`\n\n", cmd.NameAndAliases())) + lo.Must(fmt.Fprintf(&buf, "`%s`\n\n", cmd.NameAndAliases())) } if cmd.Example != "" { buf.WriteString("**Examples:**\n\n") - buf.WriteString(fmt.Sprintf("```shell\n%s\n```\n\n", cmd.Example)) + lo.Must(fmt.Fprintf(&buf, "```shell\n%s\n```\n\n", cmd.Example)) } if cmd.HasAvailableLocalFlags() { @@ -116,7 +117,7 @@ func renderFlagsMarkdown(fset *pflag.FlagSet) string { continue } - buf.WriteString(fmt.Sprintf("**%s**\n\n", group.Title)) + lo.Must(fmt.Fprintf(&buf, "**%s**\n\n", group.Title)) for _, flag := range groupedFlags[group] { if flag.Hidden { @@ -141,10 +142,10 @@ func renderCommandsOverview(groupsByPriority []cli.CommandGroup, groupedSubComma buf.WriteString("## Commands Overview\n\n") for _, group := range groupsByPriority { - buf.WriteString(fmt.Sprintf("### %s\n\n", strings.TrimSuffix(group.Title, ":"))) + lo.Must(fmt.Fprintf(&buf, "### %s\n\n", strings.TrimSuffix(group.Title, ":"))) for _, cmdInfo := range groupedSubCommandInfos[group] { - buf.WriteString(fmt.Sprintf("- [`%s %s`](#%s) — %s\n", strings.ToLower(common.Brand), cmdInfo.commandPath, commandPathToAnchor(cmdInfo.commandPath), util.EscapeForMarkdownPreservingCodeSpans(cmdInfo.short))) + lo.Must(fmt.Fprintf(&buf, "- [`%s %s`](#%s) — %s\n", strings.ToLower(common.Brand), cmdInfo.commandPath, commandPathToAnchor(cmdInfo.commandPath), util.EscapeForMarkdownPreservingCodeSpans(cmdInfo.short))) } buf.WriteString("\n") @@ -175,8 +176,8 @@ func renderFlagMarkdown(flag *pflag.Flag) string { defValue = flag.DefValue } - buf.WriteString(fmt.Sprintf("- %s (default: `%s`)\n\n", flagName, defValue)) - buf.WriteString(fmt.Sprintf(" %s\n\n", util.EscapeForMarkdownPreservingCodeSpans(flag.Usage))) + lo.Must(fmt.Fprintf(&buf, "- %s (default: `%s`)\n\n", flagName, defValue)) + lo.Must(fmt.Fprintf(&buf, " %s\n\n", util.EscapeForMarkdownPreservingCodeSpans(flag.Usage))) return buf.String() } @@ -231,9 +232,9 @@ func renderFeatGatesMarkdown() string { buf.WriteString("Feature gates are experimental features that can be enabled via environment variables.\n\n") for _, fg := range featgate.FeatGates { - buf.WriteString(fmt.Sprintf("### %s\n\n", fg.EnvVarName())) - buf.WriteString(fmt.Sprintf("**Default:** `%v`\n\n", fg.Default())) - buf.WriteString(fmt.Sprintf("%s\n\n", util.EscapeForMarkdownPreservingCodeSpans(fg.Help))) + lo.Must(fmt.Fprintf(&buf, "### %s\n\n", fg.EnvVarName())) + lo.Must(fmt.Fprintf(&buf, "**Default:** `%v`\n\n", fg.Default())) + lo.Must(fmt.Fprintf(&buf, "%s\n\n", util.EscapeForMarkdownPreservingCodeSpans(fg.Help))) } return buf.String() diff --git a/cmd/nelm/release.go b/cmd/nelm/release.go index 9a109712..74a5f58a 100644 --- a/cmd/nelm/release.go +++ b/cmd/nelm/release.go @@ -26,8 +26,7 @@ func newReleaseCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobr cmd.AddCommand(newReleaseGetCommand(ctx, afterAllCommandsBuiltFuncs)) cmd.AddCommand(newPlanCommand(ctx, afterAllCommandsBuiltFuncs)) - // FIXME(major): in v1 move all internal to pkg too to minimize diffs - // FIXME(major): in v1 don't fail on unknown env var, maybe warning (which can be disabled)? + // TODO(major): in v1 don't fail on unknown env var, maybe warning (which can be disabled)? return cmd } diff --git a/pkg/helm/pkg/cmd/list_test.go b/pkg/helm/pkg/cmd/list_test.go deleted file mode 100644 index ddcfc6e4..00000000 --- a/pkg/helm/pkg/cmd/list_test.go +++ /dev/null @@ -1,617 +0,0 @@ -/* -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 cmd - -import ( - "testing" - "time" - - chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" - "github.com/werf/nelm/pkg/helm/pkg/release/common" - release "github.com/werf/nelm/pkg/helm/pkg/release/v1" -) - -func TestListCmd(t *testing.T) { - defaultNamespace := "default" - - sampleTimeSeconds := int64(1452902400) - timestamp1 := time.Unix(sampleTimeSeconds+1, 0).UTC() - timestamp2 := time.Unix(sampleTimeSeconds+2, 0).UTC() - timestamp3 := time.Unix(sampleTimeSeconds+3, 0).UTC() - timestamp4 := time.Unix(sampleTimeSeconds+4, 0).UTC() - chartInfo := &chart.Chart{ - Metadata: &chart.Metadata{ - Name: "chickadee", - Version: "1.0.0", - AppVersion: "0.0.1", - }, - } - - releaseFixture := []*release.Release{ - { - Name: "starlord", - Version: 1, - Namespace: defaultNamespace, - Info: &release.Info{ - LastDeployed: timestamp1, - Status: common.StatusSuperseded, - }, - Chart: chartInfo, - }, - { - Name: "starlord", - Version: 2, - Namespace: defaultNamespace, - Info: &release.Info{ - LastDeployed: timestamp1, - Status: common.StatusDeployed, - }, - Chart: chartInfo, - }, - { - Name: "groot", - Version: 1, - Namespace: defaultNamespace, - Info: &release.Info{ - LastDeployed: timestamp1, - Status: common.StatusUninstalled, - }, - Chart: chartInfo, - }, - { - Name: "gamora", - Version: 1, - Namespace: defaultNamespace, - Info: &release.Info{ - LastDeployed: timestamp1, - Status: common.StatusSuperseded, - }, - Chart: chartInfo, - }, - { - Name: "rocket", - Version: 1, - Namespace: defaultNamespace, - Info: &release.Info{ - LastDeployed: timestamp2, - Status: common.StatusFailed, - }, - Chart: chartInfo, - }, - { - Name: "drax", - Version: 1, - Namespace: defaultNamespace, - Info: &release.Info{ - LastDeployed: timestamp1, - Status: common.StatusUninstalling, - }, - Chart: chartInfo, - }, - { - Name: "thanos", - Version: 1, - Namespace: defaultNamespace, - Info: &release.Info{ - LastDeployed: timestamp1, - Status: common.StatusPendingInstall, - }, - Chart: chartInfo, - }, - { - Name: "hummingbird", - Version: 1, - Namespace: defaultNamespace, - Info: &release.Info{ - LastDeployed: timestamp3, - Status: common.StatusDeployed, - }, - Chart: chartInfo, - }, - { - Name: "iguana", - Version: 2, - Namespace: defaultNamespace, - Info: &release.Info{ - LastDeployed: timestamp4, - Status: common.StatusDeployed, - }, - Chart: chartInfo, - }, - { - Name: "starlord", - Version: 2, - Namespace: "milano", - Info: &release.Info{ - LastDeployed: timestamp1, - Status: common.StatusDeployed, - }, - Chart: chartInfo, - }, - } - - tests := []cmdTestCase{{ - name: "list releases", - cmd: "list", - golden: "output/list-all.txt", - rels: releaseFixture, - }, { - name: "list without headers", - cmd: "list --no-headers", - golden: "output/list-all-no-headers.txt", - rels: releaseFixture, - }, { - name: "list releases sorted by release date", - cmd: "list --date", - golden: "output/list-all-date.txt", - rels: releaseFixture, - }, { - name: "list failed releases", - cmd: "list --failed", - golden: "output/list-failed.txt", - rels: releaseFixture, - }, { - name: "list filtered releases", - cmd: "list --filter='.*'", - golden: "output/list-all.txt", - rels: releaseFixture, - }, { - name: "list releases, limited to one release", - cmd: "list --max 1", - golden: "output/list-all-max.txt", - rels: releaseFixture, - }, { - name: "list releases, offset by one", - cmd: "list --offset 1", - golden: "output/list-all-offset.txt", - rels: releaseFixture, - }, { - name: "list pending releases", - cmd: "list --pending", - golden: "output/list-pending.txt", - rels: releaseFixture, - }, { - name: "list releases in reverse order", - cmd: "list --reverse", - golden: "output/list-all-reverse.txt", - rels: releaseFixture, - }, { - name: "list releases sorted by reversed release date", - cmd: "list --date --reverse", - golden: "output/list-all-date-reversed.txt", - rels: releaseFixture, - }, { - name: "list releases in short output format", - cmd: "list --short", - golden: "output/list-all-short.txt", - rels: releaseFixture, - }, { - name: "list releases in short output format", - cmd: "list --short --output yaml", - golden: "output/list-all-short-yaml.txt", - rels: releaseFixture, - }, { - name: "list releases in short output format", - cmd: "list --short --output json", - golden: "output/list-all-short-json.txt", - rels: releaseFixture, - }, { - name: "list deployed and failed releases only", - cmd: "list --deployed --failed", - golden: "output/list.txt", - rels: releaseFixture, - }, { - name: "list superseded releases", - cmd: "list --superseded", - golden: "output/list-superseded.txt", - rels: releaseFixture, - }, { - name: "list uninstalled releases", - cmd: "list --uninstalled", - golden: "output/list-uninstalled.txt", - rels: releaseFixture, - }, { - name: "list releases currently uninstalling", - cmd: "list --uninstalling", - golden: "output/list-uninstalling.txt", - rels: releaseFixture, - }, { - name: "list releases in another namespace", - cmd: "list -n milano", - golden: "output/list-namespace.txt", - rels: releaseFixture, - }} - runTestCmd(t, tests) -} - -func TestListOutputCompletion(t *testing.T) { - outputFlagCompletionTest(t, "list") -} - -func TestListFileCompletion(t *testing.T) { - checkFileCompletion(t, "list", false) -} - -func TestListOutputFormats(t *testing.T) { - defaultNamespace := "default" - timestamp := time.Unix(1452902400, 0).UTC() - chartInfo := &chart.Chart{ - Metadata: &chart.Metadata{ - Name: "test-chart", - Version: "1.0.0", - AppVersion: "0.0.1", - }, - } - - releaseFixture := []*release.Release{ - { - Name: "test-release", - Version: 1, - Namespace: defaultNamespace, - Info: &release.Info{ - LastDeployed: timestamp, - Status: common.StatusDeployed, - }, - Chart: chartInfo, - }, - } - - tests := []cmdTestCase{{ - name: "list releases in json format", - cmd: "list --output json", - golden: "output/list-json.txt", - rels: releaseFixture, - }, { - name: "list releases in yaml format", - cmd: "list --output yaml", - golden: "output/list-yaml.txt", - rels: releaseFixture, - }} - runTestCmd(t, tests) -} - -func TestReleaseListWriter(t *testing.T) { - timestamp := time.Unix(1452902400, 0).UTC() - chartInfo := &chart.Chart{ - Metadata: &chart.Metadata{ - Name: "test-chart", - Version: "1.0.0", - AppVersion: "0.0.1", - }, - } - - releases := []*release.Release{ - { - Name: "test-release", - Version: 1, - Namespace: "default", - Info: &release.Info{ - LastDeployed: timestamp, - Status: common.StatusDeployed, - }, - Chart: chartInfo, - }, - } - - tests := []struct { - name string - releases []*release.Release - timeFormat string - noHeaders bool - noColor bool - }{ - { - name: "empty releases list", - releases: []*release.Release{}, - timeFormat: "", - noHeaders: false, - noColor: false, - }, - { - name: "custom time format", - releases: releases, - timeFormat: "2006-01-02", - noHeaders: false, - noColor: false, - }, - { - name: "no headers", - releases: releases, - timeFormat: "", - noHeaders: true, - noColor: false, - }, - { - name: "no color", - releases: releases, - timeFormat: "", - noHeaders: false, - noColor: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - writer := newReleaseListWriter(tt.releases, tt.timeFormat, tt.noHeaders, tt.noColor) - - if writer == nil { - t.Error("Expected writer to be non-nil") - } else { - if len(writer.releases) != len(tt.releases) { - t.Errorf("Expected %d releases, got %d", len(tt.releases), len(writer.releases)) - } - } - }) - } -} - -func TestReleaseListWriterMethods(t *testing.T) { - timestamp := time.Unix(1452902400, 0).UTC() - zeroTimestamp := time.Time{} - chartInfo := &chart.Chart{ - Metadata: &chart.Metadata{ - Name: "test-chart", - Version: "1.0.0", - AppVersion: "0.0.1", - }, - } - - releases := []*release.Release{ - { - Name: "test-release", - Version: 1, - Namespace: "default", - Info: &release.Info{ - LastDeployed: timestamp, - Status: common.StatusDeployed, - }, - Chart: chartInfo, - }, - { - Name: "zero-time-release", - Version: 1, - Namespace: "default", - Info: &release.Info{ - LastDeployed: zeroTimestamp, - Status: common.StatusFailed, - }, - Chart: chartInfo, - }, - } - - tests := []struct { - name string - status common.Status - }{ - {"deployed", common.StatusDeployed}, - {"failed", common.StatusFailed}, - {"pending-install", common.StatusPendingInstall}, - {"pending-upgrade", common.StatusPendingUpgrade}, - {"pending-rollback", common.StatusPendingRollback}, - {"uninstalling", common.StatusUninstalling}, - {"uninstalled", common.StatusUninstalled}, - {"superseded", common.StatusSuperseded}, - {"unknown", common.StatusUnknown}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - testReleases := []*release.Release{ - { - Name: "test-release", - Version: 1, - Namespace: "default", - Info: &release.Info{ - LastDeployed: timestamp, - Status: tt.status, - }, - Chart: chartInfo, - }, - } - - writer := newReleaseListWriter(testReleases, "", false, false) - - var buf []byte - out := &bytesWriter{buf: &buf} - - err := writer.WriteJSON(out) - if err != nil { - t.Errorf("WriteJSON failed: %v", err) - } - - err = writer.WriteYAML(out) - if err != nil { - t.Errorf("WriteYAML failed: %v", err) - } - - err = writer.WriteTable(out) - if err != nil { - t.Errorf("WriteTable failed: %v", err) - } - }) - } - - writer := newReleaseListWriter(releases, "", false, false) - - var buf []byte - out := &bytesWriter{buf: &buf} - - err := writer.WriteJSON(out) - if err != nil { - t.Errorf("WriteJSON failed: %v", err) - } - - err = writer.WriteYAML(out) - if err != nil { - t.Errorf("WriteYAML failed: %v", err) - } - - err = writer.WriteTable(out) - if err != nil { - t.Errorf("WriteTable failed: %v", err) - } -} - -func TestFilterReleases(t *testing.T) { - releases := []*release.Release{ - {Name: "release1"}, - {Name: "release2"}, - {Name: "release3"}, - } - - tests := []struct { - name string - releases []*release.Release - ignoredReleaseNames []string - expectedCount int - }{ - { - name: "nil ignored list", - releases: releases, - ignoredReleaseNames: nil, - expectedCount: 3, - }, - { - name: "empty ignored list", - releases: releases, - ignoredReleaseNames: []string{}, - expectedCount: 3, - }, - { - name: "filter one release", - releases: releases, - ignoredReleaseNames: []string{"release1"}, - expectedCount: 2, - }, - { - name: "filter multiple releases", - releases: releases, - ignoredReleaseNames: []string{"release1", "release3"}, - expectedCount: 1, - }, - { - name: "filter non-existent release", - releases: releases, - ignoredReleaseNames: []string{"non-existent"}, - expectedCount: 3, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := filterReleases(tt.releases, tt.ignoredReleaseNames) - if len(result) != tt.expectedCount { - t.Errorf("Expected %d releases, got %d", tt.expectedCount, len(result)) - } - }) - } -} - -type bytesWriter struct { - buf *[]byte -} - -func (b *bytesWriter) Write(p []byte) (n int, err error) { - *b.buf = append(*b.buf, p...) - return len(p), nil -} - -func TestListCustomTimeFormat(t *testing.T) { - defaultNamespace := "default" - timestamp := time.Unix(1452902400, 0).UTC() - chartInfo := &chart.Chart{ - Metadata: &chart.Metadata{ - Name: "test-chart", - Version: "1.0.0", - AppVersion: "0.0.1", - }, - } - - releaseFixture := []*release.Release{ - { - Name: "test-release", - Version: 1, - Namespace: defaultNamespace, - Info: &release.Info{ - LastDeployed: timestamp, - Status: common.StatusDeployed, - }, - Chart: chartInfo, - }, - } - - tests := []cmdTestCase{{ - name: "list releases with custom time format", - cmd: "list --time-format '2006-01-02 15:04:05'", - golden: "output/list-time-format.txt", - rels: releaseFixture, - }} - runTestCmd(t, tests) -} - -func TestListStatusMapping(t *testing.T) { - defaultNamespace := "default" - timestamp := time.Unix(1452902400, 0).UTC() - chartInfo := &chart.Chart{ - Metadata: &chart.Metadata{ - Name: "test-chart", - Version: "1.0.0", - AppVersion: "0.0.1", - }, - } - - testCases := []struct { - name string - status common.Status - }{ - {"deployed", common.StatusDeployed}, - {"failed", common.StatusFailed}, - {"pending-install", common.StatusPendingInstall}, - {"pending-upgrade", common.StatusPendingUpgrade}, - {"pending-rollback", common.StatusPendingRollback}, - {"uninstalling", common.StatusUninstalling}, - {"uninstalled", common.StatusUninstalled}, - {"superseded", common.StatusSuperseded}, - {"unknown", common.StatusUnknown}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - releaseFixture := []*release.Release{ - { - Name: "test-release", - Version: 1, - Namespace: defaultNamespace, - Info: &release.Info{ - LastDeployed: timestamp, - Status: tc.status, - }, - Chart: chartInfo, - }, - } - - writer := newReleaseListWriter(releaseFixture, "", false, false) - if len(writer.releases) != 1 { - t.Errorf("Expected 1 release, got %d", len(writer.releases)) - } - - if writer.releases[0].Status != tc.status.String() { - t.Errorf("Expected status %s, got %s", tc.status.String(), writer.releases[0].Status) - } - }) - } -} diff --git a/pkg/helm/pkg/cmd/testdata/output/get-all-no-args.txt b/pkg/helm/pkg/cmd/testdata/output/get-all-no-args.txt deleted file mode 100644 index cc3fc2ad..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/get-all-no-args.txt +++ /dev/null @@ -1,3 +0,0 @@ -Error: "helm get all" requires 1 argument - -Usage: helm get all RELEASE_NAME [flags] diff --git a/pkg/helm/pkg/cmd/testdata/output/get-hooks-no-args.txt b/pkg/helm/pkg/cmd/testdata/output/get-hooks-no-args.txt deleted file mode 100644 index 2911fdb8..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/get-hooks-no-args.txt +++ /dev/null @@ -1,3 +0,0 @@ -Error: "helm get hooks" requires 1 argument - -Usage: helm get hooks RELEASE_NAME [flags] diff --git a/pkg/helm/pkg/cmd/testdata/output/get-hooks.txt b/pkg/helm/pkg/cmd/testdata/output/get-hooks.txt deleted file mode 100644 index 81e87b1f..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/get-hooks.txt +++ /dev/null @@ -1,8 +0,0 @@ ---- -# Source: pre-install-hook.yaml -apiVersion: v1 -kind: Job -metadata: - annotations: - "helm.sh/hook": pre-install - diff --git a/pkg/helm/pkg/cmd/testdata/output/get-manifest-no-args.txt b/pkg/helm/pkg/cmd/testdata/output/get-manifest-no-args.txt deleted file mode 100644 index df7aa5b0..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/get-manifest-no-args.txt +++ /dev/null @@ -1,3 +0,0 @@ -Error: "helm get manifest" requires 1 argument - -Usage: helm get manifest RELEASE_NAME [flags] diff --git a/pkg/helm/pkg/cmd/testdata/output/get-manifest.txt b/pkg/helm/pkg/cmd/testdata/output/get-manifest.txt deleted file mode 100644 index 88937e08..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/get-manifest.txt +++ /dev/null @@ -1,5 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: fixture - diff --git a/pkg/helm/pkg/cmd/testdata/output/get-metadata-args.txt b/pkg/helm/pkg/cmd/testdata/output/get-metadata-args.txt deleted file mode 100644 index acd3f4c1..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/get-metadata-args.txt +++ /dev/null @@ -1,3 +0,0 @@ -Error: "helm get metadata" requires 1 argument - -Usage: helm get metadata RELEASE_NAME [flags] diff --git a/pkg/helm/pkg/cmd/testdata/output/get-metadata.json b/pkg/helm/pkg/cmd/testdata/output/get-metadata.json deleted file mode 100644 index 9166f87a..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/get-metadata.json +++ /dev/null @@ -1 +0,0 @@ -{"name":"thomas-guide","chart":"foo","version":"0.1.0-beta.1","appVersion":"1.0","annotations":{"category":"web-apps","supported":"true"},"labels":{"key1":"value1"},"dependencies":[{"name":"cool-plugin","version":"1.0.0","repository":"https://coolplugin.io/charts","condition":"coolPlugin.enabled","enabled":true},{"name":"crds","version":"2.7.1","repository":"","condition":"crds.enabled"}],"namespace":"default","revision":1,"status":"deployed","deployedAt":"1977-09-02T22:04:05Z"} diff --git a/pkg/helm/pkg/cmd/testdata/output/get-metadata.txt b/pkg/helm/pkg/cmd/testdata/output/get-metadata.txt deleted file mode 100644 index b3cb73ee..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/get-metadata.txt +++ /dev/null @@ -1,12 +0,0 @@ -NAME: thomas-guide -CHART: foo -VERSION: 0.1.0-beta.1 -APP_VERSION: 1.0 -ANNOTATIONS: category=web-apps,supported=true -LABELS: key1=value1 -DEPENDENCIES: cool-plugin,crds -NAMESPACE: default -REVISION: 1 -STATUS: deployed -DEPLOYED_AT: 1977-09-02T22:04:05Z -APPLY_METHOD: client-side apply (defaulted) diff --git a/pkg/helm/pkg/cmd/testdata/output/get-metadata.yaml b/pkg/helm/pkg/cmd/testdata/output/get-metadata.yaml deleted file mode 100644 index 98f56783..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/get-metadata.yaml +++ /dev/null @@ -1,23 +0,0 @@ -annotations: - category: web-apps - supported: "true" -appVersion: "1.0" -chart: foo -dependencies: -- condition: coolPlugin.enabled - enabled: true - name: cool-plugin - repository: https://coolplugin.io/charts - version: 1.0.0 -- condition: crds.enabled - name: crds - repository: "" - version: 2.7.1 -deployedAt: "1977-09-02T22:04:05Z" -labels: - key1: value1 -name: thomas-guide -namespace: default -revision: 1 -status: deployed -version: 0.1.0-beta.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/get-notes-no-args.txt b/pkg/helm/pkg/cmd/testdata/output/get-notes-no-args.txt deleted file mode 100644 index 1a0c20ca..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/get-notes-no-args.txt +++ /dev/null @@ -1,3 +0,0 @@ -Error: "helm get notes" requires 1 argument - -Usage: helm get notes RELEASE_NAME [flags] diff --git a/pkg/helm/pkg/cmd/testdata/output/get-notes.txt b/pkg/helm/pkg/cmd/testdata/output/get-notes.txt deleted file mode 100644 index e710c780..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/get-notes.txt +++ /dev/null @@ -1,2 +0,0 @@ -NOTES: -Some mock release notes! diff --git a/pkg/helm/pkg/cmd/testdata/output/get-release-template.txt b/pkg/helm/pkg/cmd/testdata/output/get-release-template.txt deleted file mode 100644 index 02d44fb0..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/get-release-template.txt +++ /dev/null @@ -1 +0,0 @@ -0.1.0-beta.1 \ No newline at end of file diff --git a/pkg/helm/pkg/cmd/testdata/output/get-release.txt b/pkg/helm/pkg/cmd/testdata/output/get-release.txt deleted file mode 100644 index dbca662c..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/get-release.txt +++ /dev/null @@ -1,33 +0,0 @@ -NAME: thomas-guide -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 1 -CHART: foo -VERSION: 0.1.0-beta.1 -APP_VERSION: 1.0 -DESCRIPTION: Release mock -TEST SUITE: None -USER-SUPPLIED VALUES: -name: value - -COMPUTED VALUES: -name: value - -HOOKS: ---- -# Source: pre-install-hook.yaml -apiVersion: v1 -kind: Job -metadata: - annotations: - "helm.sh/hook": pre-install - -MANIFEST: -apiVersion: v1 -kind: Secret -metadata: - name: fixture - -NOTES: -Some mock release notes! diff --git a/pkg/helm/pkg/cmd/testdata/output/get-values-all.txt b/pkg/helm/pkg/cmd/testdata/output/get-values-all.txt deleted file mode 100644 index b7e9696b..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/get-values-all.txt +++ /dev/null @@ -1,2 +0,0 @@ -COMPUTED VALUES: -name: value diff --git a/pkg/helm/pkg/cmd/testdata/output/get-values-args.txt b/pkg/helm/pkg/cmd/testdata/output/get-values-args.txt deleted file mode 100644 index c8a65e7f..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/get-values-args.txt +++ /dev/null @@ -1,3 +0,0 @@ -Error: "helm get values" requires 1 argument - -Usage: helm get values RELEASE_NAME [flags] diff --git a/pkg/helm/pkg/cmd/testdata/output/get-values.txt b/pkg/helm/pkg/cmd/testdata/output/get-values.txt deleted file mode 100644 index b7d146b1..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/get-values.txt +++ /dev/null @@ -1,2 +0,0 @@ -USER-SUPPLIED VALUES: -name: value diff --git a/pkg/helm/pkg/cmd/testdata/output/install-and-replace.txt b/pkg/helm/pkg/cmd/testdata/output/install-and-replace.txt deleted file mode 100644 index fcf5cc0e..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/install-and-replace.txt +++ /dev/null @@ -1,7 +0,0 @@ -NAME: aeneas -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 1 -DESCRIPTION: Install complete -TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/output/install-and-take-ownership.txt b/pkg/helm/pkg/cmd/testdata/output/install-and-take-ownership.txt deleted file mode 100644 index 413329ae..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/install-and-take-ownership.txt +++ /dev/null @@ -1,7 +0,0 @@ -NAME: aeneas-take-ownership -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 1 -DESCRIPTION: Install complete -TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/output/install-chart-bad-type.txt b/pkg/helm/pkg/cmd/testdata/output/install-chart-bad-type.txt deleted file mode 100644 index c482a793..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/install-chart-bad-type.txt +++ /dev/null @@ -1 +0,0 @@ -Error: INSTALLATION FAILED: validation: chart.metadata.type must be application or library diff --git a/pkg/helm/pkg/cmd/testdata/output/install-dry-run-with-secret-hidden.txt b/pkg/helm/pkg/cmd/testdata/output/install-dry-run-with-secret-hidden.txt deleted file mode 100644 index eb770967..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/install-dry-run-with-secret-hidden.txt +++ /dev/null @@ -1,21 +0,0 @@ -NAME: secrets -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: pending-install -REVISION: 1 -DESCRIPTION: Dry run complete -TEST SUITE: None -HOOKS: -MANIFEST: ---- -# Source: chart-with-secret/templates/secret.yaml -# HIDDEN: The Secret output has been suppressed ---- -# Source: chart-with-secret/templates/configmap.yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: test-configmap -data: - foo: bar - diff --git a/pkg/helm/pkg/cmd/testdata/output/install-dry-run-with-secret.txt b/pkg/helm/pkg/cmd/testdata/output/install-dry-run-with-secret.txt deleted file mode 100644 index d22c1437..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/install-dry-run-with-secret.txt +++ /dev/null @@ -1,26 +0,0 @@ -NAME: secrets -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: pending-install -REVISION: 1 -DESCRIPTION: Dry run complete -TEST SUITE: None -HOOKS: -MANIFEST: ---- -# Source: chart-with-secret/templates/secret.yaml -apiVersion: v1 -kind: Secret -metadata: - name: test-secret -stringData: - foo: bar ---- -# Source: chart-with-secret/templates/configmap.yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: test-configmap -data: - foo: bar - diff --git a/pkg/helm/pkg/cmd/testdata/output/install-hide-secret.txt b/pkg/helm/pkg/cmd/testdata/output/install-hide-secret.txt deleted file mode 100644 index 165f14f7..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/install-hide-secret.txt +++ /dev/null @@ -1 +0,0 @@ -Error: INSTALLATION FAILED: hiding Kubernetes secrets requires a dry-run mode diff --git a/pkg/helm/pkg/cmd/testdata/output/install-lib-chart.txt b/pkg/helm/pkg/cmd/testdata/output/install-lib-chart.txt deleted file mode 100644 index c482a793..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/install-lib-chart.txt +++ /dev/null @@ -1 +0,0 @@ -Error: INSTALLATION FAILED: validation: chart.metadata.type must be application or library diff --git a/pkg/helm/pkg/cmd/testdata/output/install-name-template.txt b/pkg/helm/pkg/cmd/testdata/output/install-name-template.txt deleted file mode 100644 index bcc5f87b..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/install-name-template.txt +++ /dev/null @@ -1,7 +0,0 @@ -NAME: foobar -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 1 -DESCRIPTION: Install complete -TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/output/install-no-args.txt b/pkg/helm/pkg/cmd/testdata/output/install-no-args.txt deleted file mode 100644 index 47f010ab..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/install-no-args.txt +++ /dev/null @@ -1,3 +0,0 @@ -Error: "helm install" requires at least 1 argument - -Usage: helm install [NAME] [CHART] [flags] diff --git a/pkg/helm/pkg/cmd/testdata/output/install-no-hooks.txt b/pkg/helm/pkg/cmd/testdata/output/install-no-hooks.txt deleted file mode 100644 index fcf5cc0e..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/install-no-hooks.txt +++ /dev/null @@ -1,7 +0,0 @@ -NAME: aeneas -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 1 -DESCRIPTION: Install complete -TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/output/install-with-multiple-values-files.txt b/pkg/helm/pkg/cmd/testdata/output/install-with-multiple-values-files.txt deleted file mode 100644 index 1116cb90..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/install-with-multiple-values-files.txt +++ /dev/null @@ -1,7 +0,0 @@ -NAME: virgil -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 1 -DESCRIPTION: Install complete -TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/output/install-with-multiple-values.txt b/pkg/helm/pkg/cmd/testdata/output/install-with-multiple-values.txt deleted file mode 100644 index 1116cb90..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/install-with-multiple-values.txt +++ /dev/null @@ -1,7 +0,0 @@ -NAME: virgil -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 1 -DESCRIPTION: Install complete -TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/output/install-with-timeout.txt b/pkg/helm/pkg/cmd/testdata/output/install-with-timeout.txt deleted file mode 100644 index bcc5f87b..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/install-with-timeout.txt +++ /dev/null @@ -1,7 +0,0 @@ -NAME: foobar -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 1 -DESCRIPTION: Install complete -TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/output/install-with-values-file.txt b/pkg/helm/pkg/cmd/testdata/output/install-with-values-file.txt deleted file mode 100644 index 1116cb90..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/install-with-values-file.txt +++ /dev/null @@ -1,7 +0,0 @@ -NAME: virgil -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 1 -DESCRIPTION: Install complete -TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/output/install-with-values.txt b/pkg/helm/pkg/cmd/testdata/output/install-with-values.txt deleted file mode 100644 index 1116cb90..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/install-with-values.txt +++ /dev/null @@ -1,7 +0,0 @@ -NAME: virgil -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 1 -DESCRIPTION: Install complete -TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/output/install-with-wait-for-jobs.txt b/pkg/helm/pkg/cmd/testdata/output/install-with-wait-for-jobs.txt deleted file mode 100644 index c5676c61..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/install-with-wait-for-jobs.txt +++ /dev/null @@ -1,7 +0,0 @@ -NAME: apollo -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 1 -DESCRIPTION: Install complete -TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/output/install-with-wait.txt b/pkg/helm/pkg/cmd/testdata/output/install-with-wait.txt deleted file mode 100644 index c5676c61..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/install-with-wait.txt +++ /dev/null @@ -1,7 +0,0 @@ -NAME: apollo -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 1 -DESCRIPTION: Install complete -TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/output/install.txt b/pkg/helm/pkg/cmd/testdata/output/install.txt deleted file mode 100644 index fcf5cc0e..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/install.txt +++ /dev/null @@ -1,7 +0,0 @@ -NAME: aeneas -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 1 -DESCRIPTION: Install complete -TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/output/lint-chart-with-bad-subcharts-with-subcharts.txt b/pkg/helm/pkg/cmd/testdata/output/lint-chart-with-bad-subcharts-with-subcharts.txt deleted file mode 100644 index 67ed58ec..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/lint-chart-with-bad-subcharts-with-subcharts.txt +++ /dev/null @@ -1,21 +0,0 @@ -==> Linting testdata/testcharts/chart-with-bad-subcharts -[INFO] Chart.yaml: icon is recommended -[WARNING] templates/: directory does not exist -[ERROR] : unable to load chart - error unpacking subchart bad-subchart in chart-with-bad-subcharts: validation: chart.metadata.name is required - -==> Linting testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart -[ERROR] Chart.yaml: name is required -[ERROR] Chart.yaml: apiVersion is required. The value must be either "v1" or "v2" -[ERROR] Chart.yaml: version is required -[INFO] Chart.yaml: icon is recommended -[WARNING] Chart.yaml: version '' is not a valid SemVerV2 -[WARNING] templates/: directory does not exist -[ERROR] : unable to load chart - validation: chart.metadata.name is required - -==> Linting testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart -[INFO] Chart.yaml: icon is recommended -[WARNING] templates/: directory does not exist - -Error: 3 chart(s) linted, 2 chart(s) failed diff --git a/pkg/helm/pkg/cmd/testdata/output/lint-chart-with-bad-subcharts.txt b/pkg/helm/pkg/cmd/testdata/output/lint-chart-with-bad-subcharts.txt deleted file mode 100644 index 5a1c388b..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/lint-chart-with-bad-subcharts.txt +++ /dev/null @@ -1,7 +0,0 @@ -==> Linting testdata/testcharts/chart-with-bad-subcharts -[INFO] Chart.yaml: icon is recommended -[WARNING] templates/: directory does not exist -[ERROR] : unable to load chart - error unpacking subchart bad-subchart in chart-with-bad-subcharts: validation: chart.metadata.name is required - -Error: 1 chart(s) linted, 1 chart(s) failed diff --git a/pkg/helm/pkg/cmd/testdata/output/lint-chart-with-deprecated-api-old-k8s.txt b/pkg/helm/pkg/cmd/testdata/output/lint-chart-with-deprecated-api-old-k8s.txt deleted file mode 100644 index bd0d7000..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/lint-chart-with-deprecated-api-old-k8s.txt +++ /dev/null @@ -1,4 +0,0 @@ -==> Linting testdata/testcharts/chart-with-deprecated-api -[INFO] Chart.yaml: icon is recommended - -1 chart(s) linted, 0 chart(s) failed diff --git a/pkg/helm/pkg/cmd/testdata/output/lint-chart-with-deprecated-api-strict.txt b/pkg/helm/pkg/cmd/testdata/output/lint-chart-with-deprecated-api-strict.txt deleted file mode 100644 index a1ec4394..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/lint-chart-with-deprecated-api-strict.txt +++ /dev/null @@ -1,5 +0,0 @@ -==> Linting testdata/testcharts/chart-with-deprecated-api -[INFO] Chart.yaml: icon is recommended -[WARNING] templates/horizontalpodautoscaler.yaml: autoscaling/v2beta1 HorizontalPodAutoscaler is deprecated in v1.22+, unavailable in v1.25+; use autoscaling/v2 HorizontalPodAutoscaler - -Error: 1 chart(s) linted, 1 chart(s) failed diff --git a/pkg/helm/pkg/cmd/testdata/output/lint-chart-with-deprecated-api.txt b/pkg/helm/pkg/cmd/testdata/output/lint-chart-with-deprecated-api.txt deleted file mode 100644 index dac54620..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/lint-chart-with-deprecated-api.txt +++ /dev/null @@ -1,5 +0,0 @@ -==> Linting testdata/testcharts/chart-with-deprecated-api -[INFO] Chart.yaml: icon is recommended -[WARNING] templates/horizontalpodautoscaler.yaml: autoscaling/v2beta1 HorizontalPodAutoscaler is deprecated in v1.22+, unavailable in v1.25+; use autoscaling/v2 HorizontalPodAutoscaler - -1 chart(s) linted, 0 chart(s) failed diff --git a/pkg/helm/pkg/cmd/testdata/output/lint-quiet-with-error.txt b/pkg/helm/pkg/cmd/testdata/output/lint-quiet-with-error.txt deleted file mode 100644 index 0731a07d..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/lint-quiet-with-error.txt +++ /dev/null @@ -1,8 +0,0 @@ -==> Linting testdata/testcharts/chart-bad-requirements -[ERROR] Chart.yaml: unable to parse YAML - error converting YAML to JSON: yaml: line 6: did not find expected '-' indicator -[WARNING] templates/: directory does not exist -[ERROR] : unable to load chart - cannot load Chart.yaml: error converting YAML to JSON: yaml: line 6: did not find expected '-' indicator - -Error: 2 chart(s) linted, 1 chart(s) failed diff --git a/pkg/helm/pkg/cmd/testdata/output/lint-quiet-with-warning.txt b/pkg/helm/pkg/cmd/testdata/output/lint-quiet-with-warning.txt deleted file mode 100644 index ebf6c198..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/lint-quiet-with-warning.txt +++ /dev/null @@ -1,4 +0,0 @@ -==> Linting testdata/testcharts/chart-with-only-crds -[WARNING] templates/: directory does not exist - -1 chart(s) linted, 0 chart(s) failed diff --git a/pkg/helm/pkg/cmd/testdata/output/lint-quiet.txt b/pkg/helm/pkg/cmd/testdata/output/lint-quiet.txt deleted file mode 100644 index e69de29b..00000000 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-all-date-reversed.txt b/pkg/helm/pkg/cmd/testdata/output/list-all-date-reversed.txt deleted file mode 100644 index d185334a..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-all-date-reversed.txt +++ /dev/null @@ -1,9 +0,0 @@ -NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION -iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 -hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 -rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 -thanos default 1 2016-01-16 00:00:01 +0000 UTC pending-install chickadee-1.0.0 0.0.1 -starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 -groot default 1 2016-01-16 00:00:01 +0000 UTC uninstalled chickadee-1.0.0 0.0.1 -gamora default 1 2016-01-16 00:00:01 +0000 UTC superseded chickadee-1.0.0 0.0.1 -drax default 1 2016-01-16 00:00:01 +0000 UTC uninstalling chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-all-date.txt b/pkg/helm/pkg/cmd/testdata/output/list-all-date.txt deleted file mode 100644 index 5e5f9efe..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-all-date.txt +++ /dev/null @@ -1,9 +0,0 @@ -NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION -drax default 1 2016-01-16 00:00:01 +0000 UTC uninstalling chickadee-1.0.0 0.0.1 -gamora default 1 2016-01-16 00:00:01 +0000 UTC superseded chickadee-1.0.0 0.0.1 -groot default 1 2016-01-16 00:00:01 +0000 UTC uninstalled chickadee-1.0.0 0.0.1 -starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 -thanos default 1 2016-01-16 00:00:01 +0000 UTC pending-install chickadee-1.0.0 0.0.1 -rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 -hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 -iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-all-max.txt b/pkg/helm/pkg/cmd/testdata/output/list-all-max.txt deleted file mode 100644 index 92289639..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-all-max.txt +++ /dev/null @@ -1,2 +0,0 @@ -NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION -drax default 1 2016-01-16 00:00:01 +0000 UTC uninstalling chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-all-no-headers.txt b/pkg/helm/pkg/cmd/testdata/output/list-all-no-headers.txt deleted file mode 100644 index 33581d8c..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-all-no-headers.txt +++ /dev/null @@ -1,8 +0,0 @@ -drax default 1 2016-01-16 00:00:01 +0000 UTC uninstalling chickadee-1.0.0 0.0.1 -gamora default 1 2016-01-16 00:00:01 +0000 UTC superseded chickadee-1.0.0 0.0.1 -groot default 1 2016-01-16 00:00:01 +0000 UTC uninstalled chickadee-1.0.0 0.0.1 -hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 -iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 -rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 -starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 -thanos default 1 2016-01-16 00:00:01 +0000 UTC pending-install chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-all-offset.txt b/pkg/helm/pkg/cmd/testdata/output/list-all-offset.txt deleted file mode 100644 index e17fd7b0..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-all-offset.txt +++ /dev/null @@ -1,8 +0,0 @@ -NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION -gamora default 1 2016-01-16 00:00:01 +0000 UTC superseded chickadee-1.0.0 0.0.1 -groot default 1 2016-01-16 00:00:01 +0000 UTC uninstalled chickadee-1.0.0 0.0.1 -hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 -iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 -rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 -starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 -thanos default 1 2016-01-16 00:00:01 +0000 UTC pending-install chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-all-reverse.txt b/pkg/helm/pkg/cmd/testdata/output/list-all-reverse.txt deleted file mode 100644 index 31bb3de9..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-all-reverse.txt +++ /dev/null @@ -1,9 +0,0 @@ -NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION -thanos default 1 2016-01-16 00:00:01 +0000 UTC pending-install chickadee-1.0.0 0.0.1 -starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 -rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 -iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 -hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 -groot default 1 2016-01-16 00:00:01 +0000 UTC uninstalled chickadee-1.0.0 0.0.1 -gamora default 1 2016-01-16 00:00:01 +0000 UTC superseded chickadee-1.0.0 0.0.1 -drax default 1 2016-01-16 00:00:01 +0000 UTC uninstalling chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-all-short-json.txt b/pkg/helm/pkg/cmd/testdata/output/list-all-short-json.txt deleted file mode 100644 index 6dac52c4..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-all-short-json.txt +++ /dev/null @@ -1 +0,0 @@ -["drax","gamora","groot","hummingbird","iguana","rocket","starlord","thanos"] diff --git a/pkg/helm/pkg/cmd/testdata/output/list-all-short-yaml.txt b/pkg/helm/pkg/cmd/testdata/output/list-all-short-yaml.txt deleted file mode 100644 index 2ae0e88a..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-all-short-yaml.txt +++ /dev/null @@ -1,8 +0,0 @@ -- drax -- gamora -- groot -- hummingbird -- iguana -- rocket -- starlord -- thanos diff --git a/pkg/helm/pkg/cmd/testdata/output/list-all-short.txt b/pkg/helm/pkg/cmd/testdata/output/list-all-short.txt deleted file mode 100644 index 52871d8b..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-all-short.txt +++ /dev/null @@ -1,8 +0,0 @@ -drax -gamora -groot -hummingbird -iguana -rocket -starlord -thanos diff --git a/pkg/helm/pkg/cmd/testdata/output/list-all.txt b/pkg/helm/pkg/cmd/testdata/output/list-all.txt deleted file mode 100644 index ef6d44cd..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-all.txt +++ /dev/null @@ -1,9 +0,0 @@ -NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION -drax default 1 2016-01-16 00:00:01 +0000 UTC uninstalling chickadee-1.0.0 0.0.1 -gamora default 1 2016-01-16 00:00:01 +0000 UTC superseded chickadee-1.0.0 0.0.1 -groot default 1 2016-01-16 00:00:01 +0000 UTC uninstalled chickadee-1.0.0 0.0.1 -hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 -iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 -rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 -starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 -thanos default 1 2016-01-16 00:00:01 +0000 UTC pending-install chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-date-reversed.txt b/pkg/helm/pkg/cmd/testdata/output/list-date-reversed.txt deleted file mode 100644 index 8b4e71a3..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-date-reversed.txt +++ /dev/null @@ -1,5 +0,0 @@ -NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION -iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 -hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 -rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 -starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-date.txt b/pkg/helm/pkg/cmd/testdata/output/list-date.txt deleted file mode 100644 index 3d2b27ad..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-date.txt +++ /dev/null @@ -1,5 +0,0 @@ -NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION -starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 -rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 -hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 -iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-failed.txt b/pkg/helm/pkg/cmd/testdata/output/list-failed.txt deleted file mode 100644 index a8ec3e13..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-failed.txt +++ /dev/null @@ -1,2 +0,0 @@ -NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION -rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-filter.txt b/pkg/helm/pkg/cmd/testdata/output/list-filter.txt deleted file mode 100644 index 0a820922..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-filter.txt +++ /dev/null @@ -1,5 +0,0 @@ -NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION -hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 -iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 -rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 -starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-json.txt b/pkg/helm/pkg/cmd/testdata/output/list-json.txt deleted file mode 100644 index 89e4d9dc..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-json.txt +++ /dev/null @@ -1 +0,0 @@ -[{"name":"test-release","namespace":"default","revision":"1","updated":"2016-01-16 00:00:00 +0000 UTC","status":"deployed","chart":"test-chart-1.0.0","app_version":"0.0.1"}] diff --git a/pkg/helm/pkg/cmd/testdata/output/list-max.txt b/pkg/helm/pkg/cmd/testdata/output/list-max.txt deleted file mode 100644 index a909322b..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-max.txt +++ /dev/null @@ -1,2 +0,0 @@ -NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION -hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-namespace.txt b/pkg/helm/pkg/cmd/testdata/output/list-namespace.txt deleted file mode 100644 index 9382327d..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-namespace.txt +++ /dev/null @@ -1,2 +0,0 @@ -NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION -starlord milano 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-no-headers.txt b/pkg/helm/pkg/cmd/testdata/output/list-no-headers.txt deleted file mode 100644 index 9d11d0ca..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-no-headers.txt +++ /dev/null @@ -1,4 +0,0 @@ -hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 -iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 -rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 -starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-offset.txt b/pkg/helm/pkg/cmd/testdata/output/list-offset.txt deleted file mode 100644 index 36e963ca..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-offset.txt +++ /dev/null @@ -1,4 +0,0 @@ -NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION -iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 -rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 -starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-pending.txt b/pkg/helm/pkg/cmd/testdata/output/list-pending.txt deleted file mode 100644 index f3d7aa03..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-pending.txt +++ /dev/null @@ -1,2 +0,0 @@ -NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION -thanos default 1 2016-01-16 00:00:01 +0000 UTC pending-install chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-reverse.txt b/pkg/helm/pkg/cmd/testdata/output/list-reverse.txt deleted file mode 100644 index da178b2c..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-reverse.txt +++ /dev/null @@ -1,5 +0,0 @@ -NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION -starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 -rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 -iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 -hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-short-json.txt b/pkg/helm/pkg/cmd/testdata/output/list-short-json.txt deleted file mode 100644 index acbf1e44..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-short-json.txt +++ /dev/null @@ -1 +0,0 @@ -["hummingbird","iguana","rocket","starlord"] diff --git a/pkg/helm/pkg/cmd/testdata/output/list-short-yaml.txt b/pkg/helm/pkg/cmd/testdata/output/list-short-yaml.txt deleted file mode 100644 index 86fb3d67..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-short-yaml.txt +++ /dev/null @@ -1,4 +0,0 @@ -- hummingbird -- iguana -- rocket -- starlord diff --git a/pkg/helm/pkg/cmd/testdata/output/list-short.txt b/pkg/helm/pkg/cmd/testdata/output/list-short.txt deleted file mode 100644 index 0a63be99..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-short.txt +++ /dev/null @@ -1,4 +0,0 @@ -hummingbird -iguana -rocket -starlord diff --git a/pkg/helm/pkg/cmd/testdata/output/list-superseded.txt b/pkg/helm/pkg/cmd/testdata/output/list-superseded.txt deleted file mode 100644 index 50b43587..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-superseded.txt +++ /dev/null @@ -1,3 +0,0 @@ -NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION -gamora default 1 2016-01-16 00:00:01 +0000 UTC superseded chickadee-1.0.0 0.0.1 -starlord default 1 2016-01-16 00:00:01 +0000 UTC superseded chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-time-format.txt b/pkg/helm/pkg/cmd/testdata/output/list-time-format.txt deleted file mode 100644 index 4d493da7..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-time-format.txt +++ /dev/null @@ -1,2 +0,0 @@ -NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION -test-release default 1 2016-01-16 00:00:00 deployed test-chart-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-uninstalled.txt b/pkg/helm/pkg/cmd/testdata/output/list-uninstalled.txt deleted file mode 100644 index 430cf32f..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-uninstalled.txt +++ /dev/null @@ -1,2 +0,0 @@ -NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION -groot default 1 2016-01-16 00:00:01 +0000 UTC uninstalled chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-uninstalling.txt b/pkg/helm/pkg/cmd/testdata/output/list-uninstalling.txt deleted file mode 100644 index 92289639..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-uninstalling.txt +++ /dev/null @@ -1,2 +0,0 @@ -NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION -drax default 1 2016-01-16 00:00:01 +0000 UTC uninstalling chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/list-yaml.txt b/pkg/helm/pkg/cmd/testdata/output/list-yaml.txt deleted file mode 100644 index 9e1d41f3..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list-yaml.txt +++ /dev/null @@ -1,7 +0,0 @@ -- app_version: 0.0.1 - chart: test-chart-1.0.0 - name: test-release - namespace: default - revision: "1" - status: deployed - updated: 2016-01-16 00:00:00 +0000 UTC diff --git a/pkg/helm/pkg/cmd/testdata/output/list.txt b/pkg/helm/pkg/cmd/testdata/output/list.txt deleted file mode 100644 index 0a820922..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/list.txt +++ /dev/null @@ -1,5 +0,0 @@ -NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION -hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 -iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 -rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 -starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 diff --git a/pkg/helm/pkg/cmd/testdata/output/plugin_args_comp.txt b/pkg/helm/pkg/cmd/testdata/output/plugin_args_comp.txt deleted file mode 100644 index 4070cb1e..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/plugin_args_comp.txt +++ /dev/null @@ -1,6 +0,0 @@ -plugin.complete was called -Namespace: default -Num args received: 1 -Args received: -:4 -Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/pkg/helm/pkg/cmd/testdata/output/plugin_args_flag_comp.txt b/pkg/helm/pkg/cmd/testdata/output/plugin_args_flag_comp.txt deleted file mode 100644 index 87300fa9..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/plugin_args_flag_comp.txt +++ /dev/null @@ -1,6 +0,0 @@ -plugin.complete was called -Namespace: default -Num args received: 2 -Args received: --myflag -:4 -Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/pkg/helm/pkg/cmd/testdata/output/plugin_args_many_args_comp.txt b/pkg/helm/pkg/cmd/testdata/output/plugin_args_many_args_comp.txt deleted file mode 100644 index f3c386b6..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/plugin_args_many_args_comp.txt +++ /dev/null @@ -1,6 +0,0 @@ -plugin.complete was called -Namespace: mynamespace -Num args received: 2 -Args received: --myflag start -:2 -Completion ended with directive: ShellCompDirectiveNoSpace diff --git a/pkg/helm/pkg/cmd/testdata/output/plugin_args_ns_comp.txt b/pkg/helm/pkg/cmd/testdata/output/plugin_args_ns_comp.txt deleted file mode 100644 index 13bfcd3f..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/plugin_args_ns_comp.txt +++ /dev/null @@ -1,6 +0,0 @@ -plugin.complete was called -Namespace: mynamespace -Num args received: 1 -Args received: -:2 -Completion ended with directive: ShellCompDirectiveNoSpace diff --git a/pkg/helm/pkg/cmd/testdata/output/plugin_echo_no_directive.txt b/pkg/helm/pkg/cmd/testdata/output/plugin_echo_no_directive.txt deleted file mode 100644 index 99cc47c1..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/plugin_echo_no_directive.txt +++ /dev/null @@ -1,6 +0,0 @@ -echo plugin.complete was called -Namespace: mynamespace -Num args received: 1 -Args received: -:0 -Completion ended with directive: ShellCompDirectiveDefault diff --git a/pkg/helm/pkg/cmd/testdata/output/plugin_list_comp.txt b/pkg/helm/pkg/cmd/testdata/output/plugin_list_comp.txt deleted file mode 100644 index 1dff4355..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/plugin_list_comp.txt +++ /dev/null @@ -1,7 +0,0 @@ -args echo args -echo echo stuff -exitwith exitwith code -fullenv show env vars -shortenv env stuff -:4 -Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/pkg/helm/pkg/cmd/testdata/output/plugin_repeat_comp.txt b/pkg/helm/pkg/cmd/testdata/output/plugin_repeat_comp.txt deleted file mode 100644 index b46c1b7d..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/plugin_repeat_comp.txt +++ /dev/null @@ -1,6 +0,0 @@ -echo echo stuff -exitwith exitwith code -fullenv show env vars -shortenv env stuff -:4 -Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/pkg/helm/pkg/cmd/testdata/output/rollback-comp.txt b/pkg/helm/pkg/cmd/testdata/output/rollback-comp.txt deleted file mode 100644 index 2cfeed1f..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/rollback-comp.txt +++ /dev/null @@ -1,4 +0,0 @@ -carabins foo-0.1.0-beta.1 -> superseded -musketeers foo-0.1.0-beta.1 -> deployed -:4 -Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/pkg/helm/pkg/cmd/testdata/output/rollback-no-args.txt b/pkg/helm/pkg/cmd/testdata/output/rollback-no-args.txt deleted file mode 100644 index a1bc30b7..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/rollback-no-args.txt +++ /dev/null @@ -1,3 +0,0 @@ -Error: "helm rollback" requires at least 1 argument - -Usage: helm rollback [REVISION] [flags] diff --git a/pkg/helm/pkg/cmd/testdata/output/rollback-no-revision.txt b/pkg/helm/pkg/cmd/testdata/output/rollback-no-revision.txt deleted file mode 100644 index ae3c6f1c..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/rollback-no-revision.txt +++ /dev/null @@ -1 +0,0 @@ -Rollback was a success! Happy Helming! diff --git a/pkg/helm/pkg/cmd/testdata/output/rollback-non-existent-version.txt b/pkg/helm/pkg/cmd/testdata/output/rollback-non-existent-version.txt deleted file mode 100644 index 9c2e10e1..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/rollback-non-existent-version.txt +++ /dev/null @@ -1 +0,0 @@ -Error: release has no 3 version diff --git a/pkg/helm/pkg/cmd/testdata/output/rollback-timeout.txt b/pkg/helm/pkg/cmd/testdata/output/rollback-timeout.txt deleted file mode 100644 index ae3c6f1c..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/rollback-timeout.txt +++ /dev/null @@ -1 +0,0 @@ -Rollback was a success! Happy Helming! diff --git a/pkg/helm/pkg/cmd/testdata/output/rollback-wait-for-jobs.txt b/pkg/helm/pkg/cmd/testdata/output/rollback-wait-for-jobs.txt deleted file mode 100644 index ae3c6f1c..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/rollback-wait-for-jobs.txt +++ /dev/null @@ -1 +0,0 @@ -Rollback was a success! Happy Helming! diff --git a/pkg/helm/pkg/cmd/testdata/output/rollback-wait.txt b/pkg/helm/pkg/cmd/testdata/output/rollback-wait.txt deleted file mode 100644 index ae3c6f1c..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/rollback-wait.txt +++ /dev/null @@ -1 +0,0 @@ -Rollback was a success! Happy Helming! diff --git a/pkg/helm/pkg/cmd/testdata/output/rollback-wrong-args-comp.txt b/pkg/helm/pkg/cmd/testdata/output/rollback-wrong-args-comp.txt deleted file mode 100644 index 3c537283..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/rollback-wrong-args-comp.txt +++ /dev/null @@ -1,3 +0,0 @@ -_activeHelp_ This command does not take any more arguments (but may accept flags). -:4 -Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/pkg/helm/pkg/cmd/testdata/output/rollback.txt b/pkg/helm/pkg/cmd/testdata/output/rollback.txt deleted file mode 100644 index ae3c6f1c..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/rollback.txt +++ /dev/null @@ -1 +0,0 @@ -Rollback was a success! Happy Helming! diff --git a/pkg/helm/pkg/cmd/testdata/output/schema-negative-cli.txt b/pkg/helm/pkg/cmd/testdata/output/schema-negative-cli.txt deleted file mode 100644 index 12bcc510..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/schema-negative-cli.txt +++ /dev/null @@ -1,4 +0,0 @@ -Error: INSTALLATION FAILED: values don't meet the specifications of the schema(s) in the following chart(s): -empty: -- at '/age': minimum: got -5, want 0 - diff --git a/pkg/helm/pkg/cmd/testdata/output/schema-negative.txt b/pkg/helm/pkg/cmd/testdata/output/schema-negative.txt deleted file mode 100644 index daf13263..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/schema-negative.txt +++ /dev/null @@ -1,5 +0,0 @@ -Error: INSTALLATION FAILED: values don't meet the specifications of the schema(s) in the following chart(s): -empty: -- at '': missing property 'employmentInfo' -- at '/age': minimum: got -5, want 0 - diff --git a/pkg/helm/pkg/cmd/testdata/output/schema.txt b/pkg/helm/pkg/cmd/testdata/output/schema.txt deleted file mode 100644 index f5fe6376..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/schema.txt +++ /dev/null @@ -1,7 +0,0 @@ -NAME: schema -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 1 -DESCRIPTION: Install complete -TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/output/subchart-schema-cli-negative.txt b/pkg/helm/pkg/cmd/testdata/output/subchart-schema-cli-negative.txt deleted file mode 100644 index 179550f6..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/subchart-schema-cli-negative.txt +++ /dev/null @@ -1,4 +0,0 @@ -Error: INSTALLATION FAILED: values don't meet the specifications of the schema(s) in the following chart(s): -subchart-with-schema: -- at '/age': minimum: got -25, want 0 - diff --git a/pkg/helm/pkg/cmd/testdata/output/subchart-schema-cli.txt b/pkg/helm/pkg/cmd/testdata/output/subchart-schema-cli.txt deleted file mode 100644 index f5fe6376..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/subchart-schema-cli.txt +++ /dev/null @@ -1,7 +0,0 @@ -NAME: schema -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 1 -DESCRIPTION: Install complete -TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/output/subchart-schema-negative.txt b/pkg/helm/pkg/cmd/testdata/output/subchart-schema-negative.txt deleted file mode 100644 index 7522ef3e..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/subchart-schema-negative.txt +++ /dev/null @@ -1,6 +0,0 @@ -Error: INSTALLATION FAILED: values don't meet the specifications of the schema(s) in the following chart(s): -chart-without-schema: -- at '': missing property 'lastname' -subchart-with-schema: -- at '': missing property 'age' - diff --git a/pkg/helm/pkg/cmd/testdata/output/template-chart-bad-type.txt b/pkg/helm/pkg/cmd/testdata/output/template-chart-bad-type.txt deleted file mode 100644 index d8a3bf27..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/template-chart-bad-type.txt +++ /dev/null @@ -1 +0,0 @@ -Error: validation: chart.metadata.type must be application or library diff --git a/pkg/helm/pkg/cmd/testdata/output/template-chart-with-template-lib-archive-dep.txt b/pkg/helm/pkg/cmd/testdata/output/template-chart-with-template-lib-archive-dep.txt deleted file mode 100644 index c954b8e1..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/template-chart-with-template-lib-archive-dep.txt +++ /dev/null @@ -1,61 +0,0 @@ ---- -# Source: chart-with-template-lib-archive-dep/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - labels: - app: chart-with-template-lib-archive-dep - chart: chart-with-template-lib-archive-dep-0.1.0 - heritage: Helm - release: release-name - name: release-name-chart-with-template-lib-archive-dep -spec: - ports: - - name: http - port: 80 - targetPort: http - selector: - app: chart-with-template-lib-archive-dep - release: release-name - type: ClusterIP ---- -# Source: chart-with-template-lib-archive-dep/templates/deployment.yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: release-name-chart-with-template-lib-archive-dep - labels: - app: chart-with-template-lib-archive-dep - chart: chart-with-template-lib-archive-dep-0.1.0 - release: release-name - heritage: Helm -spec: - replicas: 1 - selector: - matchLabels: - app: chart-with-template-lib-archive-dep - release: release-name - template: - metadata: - labels: - app: chart-with-template-lib-archive-dep - release: release-name - spec: - containers: - - name: chart-with-template-lib-archive-dep - image: "nginx:stable" - imagePullPolicy: IfNotPresent - ports: - - name: http - containerPort: 80 - protocol: TCP - livenessProbe: - httpGet: - path: / - port: http - readinessProbe: - httpGet: - path: / - port: http - resources: - {} diff --git a/pkg/helm/pkg/cmd/testdata/output/template-chart-with-template-lib-dep.txt b/pkg/helm/pkg/cmd/testdata/output/template-chart-with-template-lib-dep.txt deleted file mode 100644 index 74a2a2df..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/template-chart-with-template-lib-dep.txt +++ /dev/null @@ -1,61 +0,0 @@ ---- -# Source: chart-with-template-lib-dep/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - labels: - app: chart-with-template-lib-dep - chart: chart-with-template-lib-dep-0.1.0 - heritage: Helm - release: release-name - name: release-name-chart-with-template-lib-dep -spec: - ports: - - name: http - port: 80 - targetPort: http - selector: - app: chart-with-template-lib-dep - release: release-name - type: ClusterIP ---- -# Source: chart-with-template-lib-dep/templates/deployment.yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: release-name-chart-with-template-lib-dep - labels: - app: chart-with-template-lib-dep - chart: chart-with-template-lib-dep-0.1.0 - release: release-name - heritage: Helm -spec: - replicas: 1 - selector: - matchLabels: - app: chart-with-template-lib-dep - release: release-name - template: - metadata: - labels: - app: chart-with-template-lib-dep - release: release-name - spec: - containers: - - name: chart-with-template-lib-dep - image: "nginx:stable" - imagePullPolicy: IfNotPresent - ports: - - name: http - containerPort: 80 - protocol: TCP - livenessProbe: - httpGet: - path: / - port: http - readinessProbe: - httpGet: - path: / - port: http - resources: - {} diff --git a/pkg/helm/pkg/cmd/testdata/output/template-lib-chart.txt b/pkg/helm/pkg/cmd/testdata/output/template-lib-chart.txt deleted file mode 100644 index d8a3bf27..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/template-lib-chart.txt +++ /dev/null @@ -1 +0,0 @@ -Error: validation: chart.metadata.type must be application or library diff --git a/pkg/helm/pkg/cmd/testdata/output/template-name-template.txt b/pkg/helm/pkg/cmd/testdata/output/template-name-template.txt deleted file mode 100644 index 9406048d..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/template-name-template.txt +++ /dev/null @@ -1,114 +0,0 @@ ---- -# Source: subchart/templates/subdir/serviceaccount.yaml -apiVersion: v1 -kind: ServiceAccount -metadata: - name: subchart-sa ---- -# Source: subchart/templates/subdir/role.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: subchart-role -rules: -- apiGroups: [""] - resources: ["pods"] - verbs: ["get","list","watch"] ---- -# Source: subchart/templates/subdir/rolebinding.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: subchart-binding -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: subchart-role -subjects: -- kind: ServiceAccount - name: subchart-sa - namespace: default ---- -# Source: subchart/charts/subcharta/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subcharta - labels: - helm.sh/chart: "subcharta-0.1.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: apache - selector: - app.kubernetes.io/name: subcharta ---- -# Source: subchart/charts/subchartb/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subchartb - labels: - helm.sh/chart: "subchartb-0.1.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: nginx - selector: - app.kubernetes.io/name: subchartb ---- -# Source: subchart/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subchart - labels: - helm.sh/chart: "subchart-0.1.0" - app.kubernetes.io/instance: "foobar-ywjj-baz" - kube-version/major: "1" - kube-version/minor: "20" - kube-version/version: "v1.20.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: nginx - selector: - app.kubernetes.io/name: subchart ---- -# Source: subchart/templates/tests/test-config.yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: "foobar-ywjj-baz-testconfig" - annotations: - "helm.sh/hook": test -data: - message: Hello World ---- -# Source: subchart/templates/tests/test-nothing.yaml -apiVersion: v1 -kind: Pod -metadata: - name: "foobar-ywjj-baz-test" - annotations: - "helm.sh/hook": test -spec: - containers: - - name: test - image: "alpine:latest" - envFrom: - - configMapRef: - name: "foobar-ywjj-baz-testconfig" - command: - - echo - - "$message" - restartPolicy: Never diff --git a/pkg/helm/pkg/cmd/testdata/output/template-no-args.txt b/pkg/helm/pkg/cmd/testdata/output/template-no-args.txt deleted file mode 100644 index f72f2b8c..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/template-no-args.txt +++ /dev/null @@ -1,3 +0,0 @@ -Error: "helm template" requires at least 1 argument - -Usage: helm template [NAME] [CHART] [flags] diff --git a/pkg/helm/pkg/cmd/testdata/output/template-set.txt b/pkg/helm/pkg/cmd/testdata/output/template-set.txt deleted file mode 100644 index 4040991c..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/template-set.txt +++ /dev/null @@ -1,114 +0,0 @@ ---- -# Source: subchart/templates/subdir/serviceaccount.yaml -apiVersion: v1 -kind: ServiceAccount -metadata: - name: subchart-sa ---- -# Source: subchart/templates/subdir/role.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: subchart-role -rules: -- apiGroups: [""] - resources: ["pods"] - verbs: ["get","list","watch"] ---- -# Source: subchart/templates/subdir/rolebinding.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: subchart-binding -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: subchart-role -subjects: -- kind: ServiceAccount - name: subchart-sa - namespace: default ---- -# Source: subchart/charts/subcharta/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subcharta - labels: - helm.sh/chart: "subcharta-0.1.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: apache - selector: - app.kubernetes.io/name: subcharta ---- -# Source: subchart/charts/subchartb/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subchartb - labels: - helm.sh/chart: "subchartb-0.1.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: nginx - selector: - app.kubernetes.io/name: subchartb ---- -# Source: subchart/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subchart - labels: - helm.sh/chart: "subchart-0.1.0" - app.kubernetes.io/instance: "release-name" - kube-version/major: "1" - kube-version/minor: "20" - kube-version/version: "v1.20.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: apache - selector: - app.kubernetes.io/name: subchart ---- -# Source: subchart/templates/tests/test-config.yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: "release-name-testconfig" - annotations: - "helm.sh/hook": test -data: - message: Hello World ---- -# Source: subchart/templates/tests/test-nothing.yaml -apiVersion: v1 -kind: Pod -metadata: - name: "release-name-test" - annotations: - "helm.sh/hook": test -spec: - containers: - - name: test - image: "alpine:latest" - envFrom: - - configMapRef: - name: "release-name-testconfig" - command: - - echo - - "$message" - restartPolicy: Never diff --git a/pkg/helm/pkg/cmd/testdata/output/template-show-only-glob.txt b/pkg/helm/pkg/cmd/testdata/output/template-show-only-glob.txt deleted file mode 100644 index b2d2b1c2..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/template-show-only-glob.txt +++ /dev/null @@ -1,24 +0,0 @@ ---- -# Source: subchart/templates/subdir/role.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: subchart-role -rules: -- apiGroups: [""] - resources: ["pods"] - verbs: ["get","list","watch"] ---- -# Source: subchart/templates/subdir/rolebinding.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: subchart-binding -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: subchart-role -subjects: -- kind: ServiceAccount - name: subchart-sa - namespace: default diff --git a/pkg/helm/pkg/cmd/testdata/output/template-show-only-multiple.txt b/pkg/helm/pkg/cmd/testdata/output/template-show-only-multiple.txt deleted file mode 100644 index 1aac3081..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/template-show-only-multiple.txt +++ /dev/null @@ -1,38 +0,0 @@ ---- -# Source: subchart/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subchart - labels: - helm.sh/chart: "subchart-0.1.0" - app.kubernetes.io/instance: "release-name" - kube-version/major: "1" - kube-version/minor: "20" - kube-version/version: "v1.20.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: nginx - selector: - app.kubernetes.io/name: subchart ---- -# Source: subchart/charts/subcharta/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subcharta - labels: - helm.sh/chart: "subcharta-0.1.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: apache - selector: - app.kubernetes.io/name: subcharta diff --git a/pkg/helm/pkg/cmd/testdata/output/template-show-only-one.txt b/pkg/helm/pkg/cmd/testdata/output/template-show-only-one.txt deleted file mode 100644 index 9cc34f51..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/template-show-only-one.txt +++ /dev/null @@ -1,21 +0,0 @@ ---- -# Source: subchart/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subchart - labels: - helm.sh/chart: "subchart-0.1.0" - app.kubernetes.io/instance: "release-name" - kube-version/major: "1" - kube-version/minor: "20" - kube-version/version: "v1.20.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: nginx - selector: - app.kubernetes.io/name: subchart diff --git a/pkg/helm/pkg/cmd/testdata/output/template-skip-tests.txt b/pkg/helm/pkg/cmd/testdata/output/template-skip-tests.txt deleted file mode 100644 index 5c907b56..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/template-skip-tests.txt +++ /dev/null @@ -1,85 +0,0 @@ ---- -# Source: subchart/templates/subdir/serviceaccount.yaml -apiVersion: v1 -kind: ServiceAccount -metadata: - name: subchart-sa ---- -# Source: subchart/templates/subdir/role.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: subchart-role -rules: -- apiGroups: [""] - resources: ["pods"] - verbs: ["get","list","watch"] ---- -# Source: subchart/templates/subdir/rolebinding.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: subchart-binding -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: subchart-role -subjects: -- kind: ServiceAccount - name: subchart-sa - namespace: default ---- -# Source: subchart/charts/subcharta/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subcharta - labels: - helm.sh/chart: "subcharta-0.1.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: apache - selector: - app.kubernetes.io/name: subcharta ---- -# Source: subchart/charts/subchartb/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subchartb - labels: - helm.sh/chart: "subchartb-0.1.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: nginx - selector: - app.kubernetes.io/name: subchartb ---- -# Source: subchart/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subchart - labels: - helm.sh/chart: "subchart-0.1.0" - app.kubernetes.io/instance: "release-name" - kube-version/major: "1" - kube-version/minor: "20" - kube-version/version: "v1.20.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: nginx - selector: - app.kubernetes.io/name: subchart diff --git a/pkg/helm/pkg/cmd/testdata/output/template-subchart-cm-set-file.txt b/pkg/helm/pkg/cmd/testdata/output/template-subchart-cm-set-file.txt deleted file mode 100644 index 56844e29..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/template-subchart-cm-set-file.txt +++ /dev/null @@ -1,122 +0,0 @@ ---- -# Source: subchart/templates/subdir/serviceaccount.yaml -apiVersion: v1 -kind: ServiceAccount -metadata: - name: subchart-sa ---- -# Source: subchart/templates/subdir/configmap.yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: subchart-cm -data: - value: qux ---- -# Source: subchart/templates/subdir/role.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: subchart-role -rules: -- apiGroups: [""] - resources: ["pods"] - verbs: ["get","list","watch"] ---- -# Source: subchart/templates/subdir/rolebinding.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: subchart-binding -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: subchart-role -subjects: -- kind: ServiceAccount - name: subchart-sa - namespace: default ---- -# Source: subchart/charts/subcharta/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subcharta - labels: - helm.sh/chart: "subcharta-0.1.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: apache - selector: - app.kubernetes.io/name: subcharta ---- -# Source: subchart/charts/subchartb/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subchartb - labels: - helm.sh/chart: "subchartb-0.1.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: nginx - selector: - app.kubernetes.io/name: subchartb ---- -# Source: subchart/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subchart - labels: - helm.sh/chart: "subchart-0.1.0" - app.kubernetes.io/instance: "release-name" - kube-version/major: "1" - kube-version/minor: "20" - kube-version/version: "v1.20.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: nginx - selector: - app.kubernetes.io/name: subchart ---- -# Source: subchart/templates/tests/test-config.yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: "release-name-testconfig" - annotations: - "helm.sh/hook": test -data: - message: Hello World ---- -# Source: subchart/templates/tests/test-nothing.yaml -apiVersion: v1 -kind: Pod -metadata: - name: "release-name-test" - annotations: - "helm.sh/hook": test -spec: - containers: - - name: test - image: "alpine:latest" - envFrom: - - configMapRef: - name: "release-name-testconfig" - command: - - echo - - "$message" - restartPolicy: Never diff --git a/pkg/helm/pkg/cmd/testdata/output/template-subchart-cm-set.txt b/pkg/helm/pkg/cmd/testdata/output/template-subchart-cm-set.txt deleted file mode 100644 index e52f7c23..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/template-subchart-cm-set.txt +++ /dev/null @@ -1,122 +0,0 @@ ---- -# Source: subchart/templates/subdir/serviceaccount.yaml -apiVersion: v1 -kind: ServiceAccount -metadata: - name: subchart-sa ---- -# Source: subchart/templates/subdir/configmap.yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: subchart-cm -data: - value: baz ---- -# Source: subchart/templates/subdir/role.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: subchart-role -rules: -- apiGroups: [""] - resources: ["pods"] - verbs: ["get","list","watch"] ---- -# Source: subchart/templates/subdir/rolebinding.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: subchart-binding -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: subchart-role -subjects: -- kind: ServiceAccount - name: subchart-sa - namespace: default ---- -# Source: subchart/charts/subcharta/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subcharta - labels: - helm.sh/chart: "subcharta-0.1.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: apache - selector: - app.kubernetes.io/name: subcharta ---- -# Source: subchart/charts/subchartb/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subchartb - labels: - helm.sh/chart: "subchartb-0.1.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: nginx - selector: - app.kubernetes.io/name: subchartb ---- -# Source: subchart/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subchart - labels: - helm.sh/chart: "subchart-0.1.0" - app.kubernetes.io/instance: "release-name" - kube-version/major: "1" - kube-version/minor: "20" - kube-version/version: "v1.20.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: nginx - selector: - app.kubernetes.io/name: subchart ---- -# Source: subchart/templates/tests/test-config.yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: "release-name-testconfig" - annotations: - "helm.sh/hook": test -data: - message: Hello World ---- -# Source: subchart/templates/tests/test-nothing.yaml -apiVersion: v1 -kind: Pod -metadata: - name: "release-name-test" - annotations: - "helm.sh/hook": test -spec: - containers: - - name: test - image: "alpine:latest" - envFrom: - - configMapRef: - name: "release-name-testconfig" - command: - - echo - - "$message" - restartPolicy: Never diff --git a/pkg/helm/pkg/cmd/testdata/output/template-subchart-cm.txt b/pkg/helm/pkg/cmd/testdata/output/template-subchart-cm.txt deleted file mode 100644 index 9cc9e229..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/template-subchart-cm.txt +++ /dev/null @@ -1,122 +0,0 @@ ---- -# Source: subchart/templates/subdir/serviceaccount.yaml -apiVersion: v1 -kind: ServiceAccount -metadata: - name: subchart-sa ---- -# Source: subchart/templates/subdir/configmap.yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: subchart-cm -data: - value: foo ---- -# Source: subchart/templates/subdir/role.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: subchart-role -rules: -- apiGroups: [""] - resources: ["pods"] - verbs: ["get","list","watch"] ---- -# Source: subchart/templates/subdir/rolebinding.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: subchart-binding -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: subchart-role -subjects: -- kind: ServiceAccount - name: subchart-sa - namespace: default ---- -# Source: subchart/charts/subcharta/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subcharta - labels: - helm.sh/chart: "subcharta-0.1.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: apache - selector: - app.kubernetes.io/name: subcharta ---- -# Source: subchart/charts/subchartb/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subchartb - labels: - helm.sh/chart: "subchartb-0.1.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: nginx - selector: - app.kubernetes.io/name: subchartb ---- -# Source: subchart/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subchart - labels: - helm.sh/chart: "subchart-0.1.0" - app.kubernetes.io/instance: "release-name" - kube-version/major: "1" - kube-version/minor: "20" - kube-version/version: "v1.20.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: nginx - selector: - app.kubernetes.io/name: subchart ---- -# Source: subchart/templates/tests/test-config.yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: "release-name-testconfig" - annotations: - "helm.sh/hook": test -data: - message: Hello World ---- -# Source: subchart/templates/tests/test-nothing.yaml -apiVersion: v1 -kind: Pod -metadata: - name: "release-name-test" - annotations: - "helm.sh/hook": test -spec: - containers: - - name: test - image: "alpine:latest" - envFrom: - - configMapRef: - name: "release-name-testconfig" - command: - - echo - - "$message" - restartPolicy: Never diff --git a/pkg/helm/pkg/cmd/testdata/output/template-values-files.txt b/pkg/helm/pkg/cmd/testdata/output/template-values-files.txt deleted file mode 100644 index 4040991c..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/template-values-files.txt +++ /dev/null @@ -1,114 +0,0 @@ ---- -# Source: subchart/templates/subdir/serviceaccount.yaml -apiVersion: v1 -kind: ServiceAccount -metadata: - name: subchart-sa ---- -# Source: subchart/templates/subdir/role.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: subchart-role -rules: -- apiGroups: [""] - resources: ["pods"] - verbs: ["get","list","watch"] ---- -# Source: subchart/templates/subdir/rolebinding.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: subchart-binding -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: subchart-role -subjects: -- kind: ServiceAccount - name: subchart-sa - namespace: default ---- -# Source: subchart/charts/subcharta/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subcharta - labels: - helm.sh/chart: "subcharta-0.1.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: apache - selector: - app.kubernetes.io/name: subcharta ---- -# Source: subchart/charts/subchartb/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subchartb - labels: - helm.sh/chart: "subchartb-0.1.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: nginx - selector: - app.kubernetes.io/name: subchartb ---- -# Source: subchart/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subchart - labels: - helm.sh/chart: "subchart-0.1.0" - app.kubernetes.io/instance: "release-name" - kube-version/major: "1" - kube-version/minor: "20" - kube-version/version: "v1.20.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: apache - selector: - app.kubernetes.io/name: subchart ---- -# Source: subchart/templates/tests/test-config.yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: "release-name-testconfig" - annotations: - "helm.sh/hook": test -data: - message: Hello World ---- -# Source: subchart/templates/tests/test-nothing.yaml -apiVersion: v1 -kind: Pod -metadata: - name: "release-name-test" - annotations: - "helm.sh/hook": test -spec: - containers: - - name: test - image: "alpine:latest" - envFrom: - - configMapRef: - name: "release-name-testconfig" - command: - - echo - - "$message" - restartPolicy: Never diff --git a/pkg/helm/pkg/cmd/testdata/output/template-with-api-version.txt b/pkg/helm/pkg/cmd/testdata/output/template-with-api-version.txt deleted file mode 100644 index 8b6074cd..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/template-with-api-version.txt +++ /dev/null @@ -1,116 +0,0 @@ ---- -# Source: subchart/templates/subdir/serviceaccount.yaml -apiVersion: v1 -kind: ServiceAccount -metadata: - name: subchart-sa ---- -# Source: subchart/templates/subdir/role.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: subchart-role -rules: -- apiGroups: [""] - resources: ["pods"] - verbs: ["get","list","watch"] ---- -# Source: subchart/templates/subdir/rolebinding.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: subchart-binding -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: subchart-role -subjects: -- kind: ServiceAccount - name: subchart-sa - namespace: default ---- -# Source: subchart/charts/subcharta/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subcharta - labels: - helm.sh/chart: "subcharta-0.1.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: apache - selector: - app.kubernetes.io/name: subcharta ---- -# Source: subchart/charts/subchartb/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subchartb - labels: - helm.sh/chart: "subchartb-0.1.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: nginx - selector: - app.kubernetes.io/name: subchartb ---- -# Source: subchart/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subchart - labels: - helm.sh/chart: "subchart-0.1.0" - app.kubernetes.io/instance: "release-name" - kube-version/major: "1" - kube-version/minor: "20" - kube-version/version: "v1.20.0" - kube-api-version/test: v1 - kube-api-version/test2: v2 -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: nginx - selector: - app.kubernetes.io/name: subchart ---- -# Source: subchart/templates/tests/test-config.yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: "release-name-testconfig" - annotations: - "helm.sh/hook": test -data: - message: Hello World ---- -# Source: subchart/templates/tests/test-nothing.yaml -apiVersion: v1 -kind: Pod -metadata: - name: "release-name-test" - annotations: - "helm.sh/hook": test -spec: - containers: - - name: test - image: "alpine:latest" - envFrom: - - configMapRef: - name: "release-name-testconfig" - command: - - echo - - "$message" - restartPolicy: Never diff --git a/pkg/helm/pkg/cmd/testdata/output/template-with-crds.txt b/pkg/helm/pkg/cmd/testdata/output/template-with-crds.txt deleted file mode 100644 index 256fc7c3..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/template-with-crds.txt +++ /dev/null @@ -1,131 +0,0 @@ ---- -# Source: subchart/crds/crdA.yaml -apiVersion: apiextensions.k8s.io/v1beta1 -kind: CustomResourceDefinition -metadata: - name: testcrds.testcrdgroups.example.com -spec: - group: testcrdgroups.example.com - version: v1alpha1 - names: - kind: TestCRD - listKind: TestCRDList - plural: testcrds - shortNames: - - tc - singular: authconfig - ---- -# Source: subchart/templates/subdir/serviceaccount.yaml -apiVersion: v1 -kind: ServiceAccount -metadata: - name: subchart-sa ---- -# Source: subchart/templates/subdir/role.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: subchart-role -rules: -- apiGroups: [""] - resources: ["pods"] - verbs: ["get","list","watch"] ---- -# Source: subchart/templates/subdir/rolebinding.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: subchart-binding -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: subchart-role -subjects: -- kind: ServiceAccount - name: subchart-sa - namespace: default ---- -# Source: subchart/charts/subcharta/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subcharta - labels: - helm.sh/chart: "subcharta-0.1.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: apache - selector: - app.kubernetes.io/name: subcharta ---- -# Source: subchart/charts/subchartb/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subchartb - labels: - helm.sh/chart: "subchartb-0.1.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: nginx - selector: - app.kubernetes.io/name: subchartb ---- -# Source: subchart/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subchart - labels: - helm.sh/chart: "subchart-0.1.0" - app.kubernetes.io/instance: "release-name" - kube-version/major: "1" - kube-version/minor: "20" - kube-version/version: "v1.20.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: nginx - selector: - app.kubernetes.io/name: subchart ---- -# Source: subchart/templates/tests/test-config.yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: "release-name-testconfig" - annotations: - "helm.sh/hook": test -data: - message: Hello World ---- -# Source: subchart/templates/tests/test-nothing.yaml -apiVersion: v1 -kind: Pod -metadata: - name: "release-name-test" - annotations: - "helm.sh/hook": test -spec: - containers: - - name: test - image: "alpine:latest" - envFrom: - - configMapRef: - name: "release-name-testconfig" - command: - - echo - - "$message" - restartPolicy: Never diff --git a/pkg/helm/pkg/cmd/testdata/output/template-with-invalid-yaml-debug.txt b/pkg/helm/pkg/cmd/testdata/output/template-with-invalid-yaml-debug.txt deleted file mode 100644 index 909c543d..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/template-with-invalid-yaml-debug.txt +++ /dev/null @@ -1,13 +0,0 @@ ---- -# Source: chart-with-template-with-invalid-yaml/templates/alpine-pod.yaml -apiVersion: v1 -kind: Pod -metadata: - name: "release-name-my-alpine" -spec: - containers: - - name: waiter - image: "alpine:3.9" - command: ["/bin/sleep","9000"] -invalid -Error: YAML parse error on chart-with-template-with-invalid-yaml/templates/alpine-pod.yaml: error converting YAML to JSON: yaml: line 11: could not find expected ':' diff --git a/pkg/helm/pkg/cmd/testdata/output/template-with-invalid-yaml.txt b/pkg/helm/pkg/cmd/testdata/output/template-with-invalid-yaml.txt deleted file mode 100644 index 687227b9..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/template-with-invalid-yaml.txt +++ /dev/null @@ -1,3 +0,0 @@ -Error: YAML parse error on chart-with-template-with-invalid-yaml/templates/alpine-pod.yaml: error converting YAML to JSON: yaml: line 11: could not find expected ':' - -Use --debug flag to render out invalid YAML diff --git a/pkg/helm/pkg/cmd/testdata/output/template-with-kube-version.txt b/pkg/helm/pkg/cmd/testdata/output/template-with-kube-version.txt deleted file mode 100644 index 9d326f32..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/template-with-kube-version.txt +++ /dev/null @@ -1,114 +0,0 @@ ---- -# Source: subchart/templates/subdir/serviceaccount.yaml -apiVersion: v1 -kind: ServiceAccount -metadata: - name: subchart-sa ---- -# Source: subchart/templates/subdir/role.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: subchart-role -rules: -- apiGroups: [""] - resources: ["pods"] - verbs: ["get","list","watch"] ---- -# Source: subchart/templates/subdir/rolebinding.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: subchart-binding -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: subchart-role -subjects: -- kind: ServiceAccount - name: subchart-sa - namespace: default ---- -# Source: subchart/charts/subcharta/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subcharta - labels: - helm.sh/chart: "subcharta-0.1.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: apache - selector: - app.kubernetes.io/name: subcharta ---- -# Source: subchart/charts/subchartb/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subchartb - labels: - helm.sh/chart: "subchartb-0.1.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: nginx - selector: - app.kubernetes.io/name: subchartb ---- -# Source: subchart/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subchart - labels: - helm.sh/chart: "subchart-0.1.0" - app.kubernetes.io/instance: "release-name" - kube-version/major: "1" - kube-version/minor: "16" - kube-version/version: "v1.16.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: nginx - selector: - app.kubernetes.io/name: subchart ---- -# Source: subchart/templates/tests/test-config.yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: "release-name-testconfig" - annotations: - "helm.sh/hook": test -data: - message: Hello World ---- -# Source: subchart/templates/tests/test-nothing.yaml -apiVersion: v1 -kind: Pod -metadata: - name: "release-name-test" - annotations: - "helm.sh/hook": test -spec: - containers: - - name: test - image: "alpine:latest" - envFrom: - - configMapRef: - name: "release-name-testconfig" - command: - - echo - - "$message" - restartPolicy: Never diff --git a/pkg/helm/pkg/cmd/testdata/output/template.txt b/pkg/helm/pkg/cmd/testdata/output/template.txt deleted file mode 100644 index 58c480b4..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/template.txt +++ /dev/null @@ -1,114 +0,0 @@ ---- -# Source: subchart/templates/subdir/serviceaccount.yaml -apiVersion: v1 -kind: ServiceAccount -metadata: - name: subchart-sa ---- -# Source: subchart/templates/subdir/role.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: subchart-role -rules: -- apiGroups: [""] - resources: ["pods"] - verbs: ["get","list","watch"] ---- -# Source: subchart/templates/subdir/rolebinding.yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: subchart-binding -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: subchart-role -subjects: -- kind: ServiceAccount - name: subchart-sa - namespace: default ---- -# Source: subchart/charts/subcharta/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subcharta - labels: - helm.sh/chart: "subcharta-0.1.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: apache - selector: - app.kubernetes.io/name: subcharta ---- -# Source: subchart/charts/subchartb/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subchartb - labels: - helm.sh/chart: "subchartb-0.1.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: nginx - selector: - app.kubernetes.io/name: subchartb ---- -# Source: subchart/templates/service.yaml -apiVersion: v1 -kind: Service -metadata: - name: subchart - labels: - helm.sh/chart: "subchart-0.1.0" - app.kubernetes.io/instance: "release-name" - kube-version/major: "1" - kube-version/minor: "20" - kube-version/version: "v1.20.0" -spec: - type: ClusterIP - ports: - - port: 80 - targetPort: 80 - protocol: TCP - name: nginx - selector: - app.kubernetes.io/name: subchart ---- -# Source: subchart/templates/tests/test-config.yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: "release-name-testconfig" - annotations: - "helm.sh/hook": test -data: - message: Hello World ---- -# Source: subchart/templates/tests/test-nothing.yaml -apiVersion: v1 -kind: Pod -metadata: - name: "release-name-test" - annotations: - "helm.sh/hook": test -spec: - containers: - - name: test - image: "alpine:latest" - envFrom: - - configMapRef: - name: "release-name-testconfig" - command: - - echo - - "$message" - restartPolicy: Never diff --git a/pkg/helm/pkg/cmd/testdata/output/uninstall-keep-history-earlier-deployed.txt b/pkg/helm/pkg/cmd/testdata/output/uninstall-keep-history-earlier-deployed.txt deleted file mode 100644 index f5454b88..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/uninstall-keep-history-earlier-deployed.txt +++ /dev/null @@ -1 +0,0 @@ -release "aeneas" uninstalled diff --git a/pkg/helm/pkg/cmd/testdata/output/uninstall-keep-history.txt b/pkg/helm/pkg/cmd/testdata/output/uninstall-keep-history.txt deleted file mode 100644 index f5454b88..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/uninstall-keep-history.txt +++ /dev/null @@ -1 +0,0 @@ -release "aeneas" uninstalled diff --git a/pkg/helm/pkg/cmd/testdata/output/uninstall-multiple.txt b/pkg/helm/pkg/cmd/testdata/output/uninstall-multiple.txt deleted file mode 100644 index ee1c67d2..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/uninstall-multiple.txt +++ /dev/null @@ -1,2 +0,0 @@ -release "aeneas" uninstalled -release "aeneas2" uninstalled diff --git a/pkg/helm/pkg/cmd/testdata/output/uninstall-no-args.txt b/pkg/helm/pkg/cmd/testdata/output/uninstall-no-args.txt deleted file mode 100644 index fc01a75b..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/uninstall-no-args.txt +++ /dev/null @@ -1,3 +0,0 @@ -Error: "helm uninstall" requires at least 1 argument - -Usage: helm uninstall RELEASE_NAME [...] [flags] diff --git a/pkg/helm/pkg/cmd/testdata/output/uninstall-no-hooks.txt b/pkg/helm/pkg/cmd/testdata/output/uninstall-no-hooks.txt deleted file mode 100644 index f5454b88..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/uninstall-no-hooks.txt +++ /dev/null @@ -1 +0,0 @@ -release "aeneas" uninstalled diff --git a/pkg/helm/pkg/cmd/testdata/output/uninstall-timeout.txt b/pkg/helm/pkg/cmd/testdata/output/uninstall-timeout.txt deleted file mode 100644 index f5454b88..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/uninstall-timeout.txt +++ /dev/null @@ -1 +0,0 @@ -release "aeneas" uninstalled diff --git a/pkg/helm/pkg/cmd/testdata/output/uninstall-wait.txt b/pkg/helm/pkg/cmd/testdata/output/uninstall-wait.txt deleted file mode 100644 index f5454b88..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/uninstall-wait.txt +++ /dev/null @@ -1 +0,0 @@ -release "aeneas" uninstalled diff --git a/pkg/helm/pkg/cmd/testdata/output/uninstall.txt b/pkg/helm/pkg/cmd/testdata/output/uninstall.txt deleted file mode 100644 index f5454b88..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/uninstall.txt +++ /dev/null @@ -1 +0,0 @@ -release "aeneas" uninstalled diff --git a/pkg/helm/pkg/cmd/testdata/output/upgrade-and-take-ownership.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-and-take-ownership.txt deleted file mode 100644 index 59267651..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/upgrade-and-take-ownership.txt +++ /dev/null @@ -1,8 +0,0 @@ -Release "funny-bunny" has been upgraded. Happy Helming! -NAME: funny-bunny -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 3 -DESCRIPTION: Upgrade complete -TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/output/upgrade-uninstalled-with-keep-history.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-uninstalled-with-keep-history.txt deleted file mode 100644 index d5c42d15..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/upgrade-uninstalled-with-keep-history.txt +++ /dev/null @@ -1,8 +0,0 @@ -Release "funny-bunny" does not exist. Installing it now. -NAME: funny-bunny -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 3 -DESCRIPTION: Install complete -TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/output/upgrade-with-bad-dependencies.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-bad-dependencies.txt deleted file mode 100644 index 6dddc734..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/upgrade-with-bad-dependencies.txt +++ /dev/null @@ -1 +0,0 @@ -Error: cannot load Chart.yaml: error converting YAML to JSON: yaml: line 6: did not find expected '-' indicator diff --git a/pkg/helm/pkg/cmd/testdata/output/upgrade-with-bad-or-missing-existing-release.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-bad-or-missing-existing-release.txt deleted file mode 100644 index 8f24574a..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/upgrade-with-bad-or-missing-existing-release.txt +++ /dev/null @@ -1 +0,0 @@ -Error: UPGRADE FAILED: "funny-bunny" has no deployed releases diff --git a/pkg/helm/pkg/cmd/testdata/output/upgrade-with-dependency-update.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-dependency-update.txt deleted file mode 100644 index d1517a68..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/upgrade-with-dependency-update.txt +++ /dev/null @@ -1,10 +0,0 @@ -Release "funny-bunny" has been upgraded. Happy Helming! -NAME: funny-bunny -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 3 -DESCRIPTION: Upgrade complete -TEST SUITE: None -NOTES: -PARENT NOTES diff --git a/pkg/helm/pkg/cmd/testdata/output/upgrade-with-install-timeout.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-install-timeout.txt deleted file mode 100644 index b159dc3b..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/upgrade-with-install-timeout.txt +++ /dev/null @@ -1,8 +0,0 @@ -Release "crazy-bunny" has been upgraded. Happy Helming! -NAME: crazy-bunny -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 2 -DESCRIPTION: Upgrade complete -TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/output/upgrade-with-install.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-install.txt deleted file mode 100644 index 7dc2fce6..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/upgrade-with-install.txt +++ /dev/null @@ -1,8 +0,0 @@ -Release "zany-bunny" has been upgraded. Happy Helming! -NAME: zany-bunny -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 2 -DESCRIPTION: Upgrade complete -TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/output/upgrade-with-missing-dependencies.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-missing-dependencies.txt deleted file mode 100644 index b2c154a8..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/upgrade-with-missing-dependencies.txt +++ /dev/null @@ -1 +0,0 @@ -Error: an error occurred while checking for chart dependencies. You may need to run `helm dependency build` to fetch missing dependencies: found in Chart.yaml, but missing in charts/ directory: reqsubchart2 diff --git a/pkg/helm/pkg/cmd/testdata/output/upgrade-with-pending-install.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-pending-install.txt deleted file mode 100644 index 57a8e787..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/upgrade-with-pending-install.txt +++ /dev/null @@ -1 +0,0 @@ -Error: UPGRADE FAILED: another operation (install/upgrade/rollback) is in progress diff --git a/pkg/helm/pkg/cmd/testdata/output/upgrade-with-reset-values.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-reset-values.txt deleted file mode 100644 index d02993a5..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/upgrade-with-reset-values.txt +++ /dev/null @@ -1,8 +0,0 @@ -Release "funny-bunny" has been upgraded. Happy Helming! -NAME: funny-bunny -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 5 -DESCRIPTION: Upgrade complete -TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/output/upgrade-with-reset-values2.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-reset-values2.txt deleted file mode 100644 index 7780c4fd..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/upgrade-with-reset-values2.txt +++ /dev/null @@ -1,8 +0,0 @@ -Release "funny-bunny" has been upgraded. Happy Helming! -NAME: funny-bunny -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 6 -DESCRIPTION: Upgrade complete -TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/output/upgrade-with-timeout.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-timeout.txt deleted file mode 100644 index b1edac3a..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/upgrade-with-timeout.txt +++ /dev/null @@ -1,8 +0,0 @@ -Release "funny-bunny" has been upgraded. Happy Helming! -NAME: funny-bunny -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 4 -DESCRIPTION: Upgrade complete -TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/output/upgrade-with-wait-for-jobs.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-wait-for-jobs.txt deleted file mode 100644 index 21784413..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/upgrade-with-wait-for-jobs.txt +++ /dev/null @@ -1,8 +0,0 @@ -Release "crazy-bunny" has been upgraded. Happy Helming! -NAME: crazy-bunny -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 3 -DESCRIPTION: Upgrade complete -TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/output/upgrade-with-wait.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade-with-wait.txt deleted file mode 100644 index 21784413..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/upgrade-with-wait.txt +++ /dev/null @@ -1,8 +0,0 @@ -Release "crazy-bunny" has been upgraded. Happy Helming! -NAME: crazy-bunny -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 3 -DESCRIPTION: Upgrade complete -TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/output/upgrade.txt b/pkg/helm/pkg/cmd/testdata/output/upgrade.txt deleted file mode 100644 index 59267651..00000000 --- a/pkg/helm/pkg/cmd/testdata/output/upgrade.txt +++ /dev/null @@ -1,8 +0,0 @@ -Release "funny-bunny" has been upgraded. Happy Helming! -NAME: funny-bunny -LAST DEPLOYED: Fri Sep 2 22:04:05 1977 -NAMESPACE: default -STATUS: deployed -REVISION: 3 -DESCRIPTION: Upgrade complete -TEST SUITE: None diff --git a/pkg/helm/pkg/cmd/testdata/plugins.yaml b/pkg/helm/pkg/cmd/testdata/plugins.yaml deleted file mode 100644 index 69086973..00000000 --- a/pkg/helm/pkg/cmd/testdata/plugins.yaml +++ /dev/null @@ -1,3 +0,0 @@ -plugins: -- name: testplugin - url: testdata/testplugin diff --git a/pkg/helm/pkg/cmd/testdata/testplugin/plugin.yaml b/pkg/helm/pkg/cmd/testdata/testplugin/plugin.yaml deleted file mode 100644 index 3ee5d04f..00000000 --- a/pkg/helm/pkg/cmd/testdata/testplugin/plugin.yaml +++ /dev/null @@ -1,12 +0,0 @@ ---- -apiVersion: v1 -name: testplugin -type: cli/v1 -runtime: subprocess -config: - shortHelp: "echo test" - longHelp: "This echos test" - ignoreFlags: false -runtimeConfig: - platformCommand: - - command: "echo test" diff --git a/pkg/helm/pkg/getter/getter_test.go b/pkg/helm/pkg/getter/getter_test.go index 64d92c0f..74628968 100644 --- a/pkg/helm/pkg/getter/getter_test.go +++ b/pkg/helm/pkg/getter/getter_test.go @@ -18,12 +18,8 @@ package getter import ( "testing" "time" - - "github.com/werf/nelm/pkg/helm/pkg/cli" ) -const pluginDir = "testdata/plugins" - func TestProvider(t *testing.T) { p := Provider{ []string{"one", "three"}, @@ -69,30 +65,3 @@ func TestProvidersWithTimeout(t *testing.T) { t.Errorf("Expected %q, got %q", want, got) } } - -func TestAll(t *testing.T) { - env := cli.New() - env.PluginsDirectory = pluginDir - - all := All(env) - if len(all) != 4 { - t.Errorf("expected 4 providers (default plus three plugins), got %d", len(all)) - } - - if _, err := all.ByScheme("test2"); err != nil { - t.Error(err) - } -} - -func TestByScheme(t *testing.T) { - env := cli.New() - env.PluginsDirectory = pluginDir - - g := All(env) - if _, err := g.ByScheme("test"); err != nil { - t.Error(err) - } - if _, err := g.ByScheme("https"); err != nil { - t.Error(err) - } -} diff --git a/pkg/helm/pkg/getter/testdata/plugins/testgetter/plugin.yaml b/pkg/helm/pkg/getter/testdata/plugins/testgetter/plugin.yaml deleted file mode 100644 index ca11b95e..00000000 --- a/pkg/helm/pkg/getter/testdata/plugins/testgetter/plugin.yaml +++ /dev/null @@ -1,13 +0,0 @@ -name: "testgetter" -version: "0.1.0" -type: getter/v1 -apiVersion: v1 -runtime: subprocess -config: - protocols: - - "test" -runtimeConfig: - protocolCommands: - - command: "echo" - protocols: - - "test" diff --git a/pkg/helm/pkg/getter/testdata/plugins/testgetter2/plugin.yaml b/pkg/helm/pkg/getter/testdata/plugins/testgetter2/plugin.yaml deleted file mode 100644 index 1c944a7c..00000000 --- a/pkg/helm/pkg/getter/testdata/plugins/testgetter2/plugin.yaml +++ /dev/null @@ -1,13 +0,0 @@ -name: "testgetter2" -version: "0.1.0" -type: getter/v1 -apiVersion: v1 -runtime: subprocess -config: - protocols: - - "test2" -runtimeConfig: - protocolCommands: - - command: "echo" - protocols: - - "test2" diff --git a/pkg/helm/pkg/postrenderer/postrenderer_test.go b/pkg/helm/pkg/postrenderer/postrenderer_test.go deleted file mode 100644 index 47355f21..00000000 --- a/pkg/helm/pkg/postrenderer/postrenderer_test.go +++ /dev/null @@ -1,81 +0,0 @@ -/* -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 postrenderer - -import ( - "bytes" - "runtime" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/werf/nelm/pkg/helm/pkg/cli" -) - -func TestNewPostRenderPluginRunWithNoOutput(t *testing.T) { - if runtime.GOOS == "windows" { - // the actual Run test uses a basic sed example, so skip this test on windows - t.Skip("skipping on windows") - } - is := assert.New(t) - s := cli.New() - s.PluginsDirectory = "testdata/plugins" - name := "postrenderer-v1" - - renderer, err := NewPostRendererPlugin(s, name, "") - require.NoError(t, err) - - _, err = renderer.Run(bytes.NewBufferString("")) - is.Error(err) -} - -func TestNewPostRenderPluginWithOneArgsRun(t *testing.T) { - if runtime.GOOS == "windows" { - // the actual Run test uses a basic sed example, so skip this test on windows - t.Skip("skipping on windows") - } - is := assert.New(t) - s := cli.New() - s.PluginsDirectory = "testdata/plugins" - name := "postrenderer-v1" - - renderer, err := NewPostRendererPlugin(s, name, "ARG1") - require.NoError(t, err) - - output, err := renderer.Run(bytes.NewBufferString("FOOTEST")) - is.NoError(err) - is.Contains(output.String(), "ARG1") -} - -func TestNewPostRenderPluginWithTwoArgsRun(t *testing.T) { - if runtime.GOOS == "windows" { - // the actual Run test uses a basic sed example, so skip this test on windows - t.Skip("skipping on windows") - } - is := assert.New(t) - s := cli.New() - s.PluginsDirectory = "testdata/plugins" - name := "postrenderer-v1" - - renderer, err := NewPostRendererPlugin(s, name, "ARG1", "ARG2") - require.NoError(t, err) - - output, err := renderer.Run(bytes.NewBufferString("FOOTEST")) - is.NoError(err) - is.Contains(output.String(), "ARG1 ARG2") -} diff --git a/pkg/helm/pkg/postrenderer/testdata/plugins/postrenderer-v1/plugin.yaml b/pkg/helm/pkg/postrenderer/testdata/plugins/postrenderer-v1/plugin.yaml deleted file mode 100644 index 423a5191..00000000 --- a/pkg/helm/pkg/postrenderer/testdata/plugins/postrenderer-v1/plugin.yaml +++ /dev/null @@ -1,8 +0,0 @@ -name: "postrenderer-v1" -version: "1.2.3" -type: postrenderer/v1 -apiVersion: v1 -runtime: subprocess -runtimeConfig: - platformCommand: - - command: "${HELM_PLUGIN_DIR}/sed-test.sh" diff --git a/pkg/helm/pkg/postrenderer/testdata/plugins/postrenderer-v1/sed-test.sh b/pkg/helm/pkg/postrenderer/testdata/plugins/postrenderer-v1/sed-test.sh deleted file mode 100755 index a016e398..00000000 --- a/pkg/helm/pkg/postrenderer/testdata/plugins/postrenderer-v1/sed-test.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/sh -if [ $# -eq 0 ]; then - sed s/FOOTEST/BARTEST/g <&0 -else - sed s/FOOTEST/"$*"/g <&0 -fi From 3e7ff155a755a3f0f92d7ed8d36cbe48112046e1 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Tue, 28 Apr 2026 19:42:45 +0300 Subject: [PATCH 18/85] refactor: change Version to APIVersion in ReleaseReportV3 (#607) Signed-off-by: Dmitry Mordvinov --- pkg/action/common.go | 3 +-- pkg/action/release_install.go | 12 ++++++------ pkg/action/release_rollback.go | 12 ++++++------ pkg/action/release_uninstall.go | 2 +- 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/pkg/action/common.go b/pkg/action/common.go index 1485c73e..2a9d585f 100644 --- a/pkg/action/common.go +++ b/pkg/action/common.go @@ -73,9 +73,8 @@ var syntaxHighlightTheme = fmt.Sprintf(` `, syntaxHighlightThemeName) -// TODO(major): Version > APIVersion as string "v3" type ReleaseReportV3 struct { - Version int `json:"version,omitempty"` + APIVersion string `json:"apiVersion,omitempty"` Release string `json:"release,omitempty"` Namespace string `json:"namespace,omitempty"` Revision int `json:"revision,omitempty"` diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index a88f21ed..6de3977e 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -509,11 +509,11 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re if releaseIsUpToDate && installPlanIsUseless { if opts.InstallReportPath != "" { if err := saveReport(opts.InstallReportPath, &ReleaseReportV3{ - Version: 3, - Release: releaseName, - Namespace: releaseNamespace, - Revision: newRelease.Version, - Status: helmreleasestatus.Status("skipped"), + APIVersion: "v3", + Release: releaseName, + Namespace: releaseNamespace, + Revision: newRelease.Version, + Status: helmreleasestatus.Status("skipped"), }); err != nil { return fmt.Errorf("save release install report: %w", err) } @@ -653,7 +653,7 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re sort.Strings(reportFailedOps) report := &ReleaseReportV3{ - Version: 3, + APIVersion: "v3", Release: releaseName, Namespace: releaseNamespace, Revision: newRelease.Version, diff --git a/pkg/action/release_rollback.go b/pkg/action/release_rollback.go index 93c5dfa6..08884a15 100644 --- a/pkg/action/release_rollback.go +++ b/pkg/action/release_rollback.go @@ -344,11 +344,11 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r if releaseIsUpToDate && installPlanIsUseless { if opts.RollbackReportPath != "" { if err := saveReport(opts.RollbackReportPath, &ReleaseReportV3{ - Version: 3, - Release: releaseName, - Namespace: releaseNamespace, - Revision: newRelease.Version, - Status: helmreleasestatus.Status("skipped"), + APIVersion: "v3", + Release: releaseName, + Namespace: releaseNamespace, + Revision: newRelease.Version, + Status: helmreleasestatus.Status("skipped"), }); err != nil { return fmt.Errorf("save release install report: %w", err) } @@ -458,7 +458,7 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r sort.Strings(reportFailedOps) report := &ReleaseReportV3{ - Version: 3, + APIVersion: "v3", Release: releaseName, Namespace: releaseNamespace, Revision: newRelease.Version, diff --git a/pkg/action/release_uninstall.go b/pkg/action/release_uninstall.go index a9f1d223..c0b71fd2 100644 --- a/pkg/action/release_uninstall.go +++ b/pkg/action/release_uninstall.go @@ -345,7 +345,7 @@ func releaseUninstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, sort.Strings(reportFailedOps) report := &ReleaseReportV3{ - Version: 3, + APIVersion: "v3", Release: releaseName, Namespace: releaseNamespace, Revision: prevRelease.Version, From 2159f962147e50f6ad8e33b9d24039799a8947d0 Mon Sep 17 00:00:00 2001 From: Ilya Lesikov Date: Wed, 29 Apr 2026 10:57:20 +0300 Subject: [PATCH 19/85] feat: bump default local kube version for rendering to 1.36.0 Signed-off-by: Ilya Lesikov --- pkg/common/common.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/common/common.go b/pkg/common/common.go index 1b79466a..a4dfd1d8 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -99,8 +99,7 @@ const ( DefaultDeletePropagation = metav1.DeletePropagationForeground DefaultDiffContextLines = 3 DefaultFieldManager = "helm" - // TODO(major): update to a more recent version? Not sure about backwards compatibility. - DefaultLocalKubeVersion = "1.20.0" + DefaultLocalKubeVersion = "1.36.0" DefaultLogColorMode = log.LogColorModeAuto DefaultNetworkParallelism = 30 DefaultProgressPrintInterval = 5 * time.Second From 55bc4628d79e77d9fc5a82cee24d293aeb50ddcd Mon Sep 17 00:00:00 2001 From: Ilya Lesikov Date: Wed, 29 Apr 2026 11:04:20 +0300 Subject: [PATCH 20/85] fix: simpler sorting for resources Signed-off-by: Ilya Lesikov --- pkg/resource/spec/sort.go | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/pkg/resource/spec/sort.go b/pkg/resource/spec/sort.go index 1237797d..5f104724 100644 --- a/pkg/resource/spec/sort.go +++ b/pkg/resource/spec/sort.go @@ -1,21 +1,11 @@ package spec -import "github.com/werf/nelm/pkg/common" - func ResourceSpecSortHandler(r1, r2 *ResourceSpec) bool { - sortAs1 := r1.StoreAs - sortAs2 := r2.StoreAs - - // TODO(major): sorted based on sortAs for compatibility. In future should just probably sort - // like this: first CRDs (any type), then helm.sh/hook hooks, then the rest - if sortAs1 != sortAs2 { - if sortAs1 == common.StoreAsNone { - return true - } else if sortAs1 == common.StoreAsHook && sortAs2 != common.StoreAsNone { - return true - } else { - return false - } + isCRD1 := IsCRD(r1.GroupVersionKind.GroupKind()) + isCRD2 := IsCRD(r2.GroupVersionKind.GroupKind()) + + if isCRD1 != isCRD2 { + return isCRD1 } return ResourceMetaSortHandler(r1.ResourceMeta, r2.ResourceMeta) From 365147407ec5e9d1624c2132062b45695615d39c Mon Sep 17 00:00:00 2001 From: Ilya Lesikov Date: Wed, 29 Apr 2026 11:13:08 +0300 Subject: [PATCH 21/85] fix: don't hard-fail on unsupported env vars Signed-off-by: Ilya Lesikov --- cmd/nelm/main.go | 2 +- pkg/common/common.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/nelm/main.go b/cmd/nelm/main.go index ef2da382..785e0f34 100644 --- a/cmd/nelm/main.go +++ b/cmd/nelm/main.go @@ -71,7 +71,7 @@ func main() { }) if unsupportedEnvVars := lo.Without(cli.FindUndefinedFlagEnvVarsInEnviron(), featGatesEnvVars...); len(unsupportedEnvVars) > 0 { - abort(ctx, fmt.Errorf("unsupported environment variable(s): %s", strings.Join(unsupportedEnvVars, ",")), 1) + log.Default.Warn(ctx, "Unsupported environment variable(s): %s", strings.Join(unsupportedEnvVars, ",")) } if err := rootCmd.ExecuteContext(ctx); err != nil { diff --git a/pkg/common/common.go b/pkg/common/common.go index a4dfd1d8..e4418d7a 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -96,9 +96,9 @@ const ( // TODO(major): switch to if-possible DefaultChartProvenanceStrategy = "never" // TODO(major): reconsider? - DefaultDeletePropagation = metav1.DeletePropagationForeground - DefaultDiffContextLines = 3 - DefaultFieldManager = "helm" + DefaultDeletePropagation = metav1.DeletePropagationForeground + DefaultDiffContextLines = 3 + DefaultFieldManager = "helm" DefaultLocalKubeVersion = "1.36.0" DefaultLogColorMode = log.LogColorModeAuto DefaultNetworkParallelism = 30 From 3a2264e5ca5bdeed6734a9f6cdcbb1c0cceb3a20 Mon Sep 17 00:00:00 2001 From: Ilya Lesikov Date: Wed, 29 Apr 2026 21:36:32 +0300 Subject: [PATCH 22/85] fix: remove support for all legacy HELM_* env vars (#611) Signed-off-by: Ilya Lesikov --- Taskfile.dist.yaml | 1 + pkg/chart/chart_download.go | 14 +++++--------- pkg/chart/chart_render.go | 32 +++++++++++-------------------- pkg/helm/pkg/cmd/root_test.go | 18 ----------------- pkg/helm/pkg/helmpath/lazypath.go | 13 ++----------- pkg/resource/helpers_ai_test.go | 1 - 6 files changed, 19 insertions(+), 60 deletions(-) diff --git a/Taskfile.dist.yaml b/Taskfile.dist.yaml index 5f4f8122..02e9b5f5 100644 --- a/Taskfile.dist.yaml +++ b/Taskfile.dist.yaml @@ -288,6 +288,7 @@ tasks: generate:reference: desc: "Generate CLI reference documentation with TOC." env: + # TODO(major): not supported anymore HELM_CONFIG_HOME: "~/.config/helm" HELM_CACHE_HOME: "~/.cache/helm" HELM_DATA_HOME: "~/.local/share/helm" diff --git a/pkg/chart/chart_download.go b/pkg/chart/chart_download.go index f62de0ee..ac7f7964 100644 --- a/pkg/chart/chart_download.go +++ b/pkg/chart/chart_download.go @@ -36,13 +36,11 @@ func downloadChart(ctx context.Context, chartPath string, registryClient *helmre return "", fmt.Errorf("construct chart downloader: %w", err) } - // TODO(major): get rid of HELM_ env vars support - if err := os.MkdirAll(envOr("HELM_REPOSITORY_CACHE", helmpath.CachePath("repository")), 0o755); err != nil { + if err := os.MkdirAll(helmpath.CachePath("repository"), 0o755); err != nil { return "", fmt.Errorf("create repository cache directory: %w", err) } - // TODO(major): get rid of HELM_ env vars support - chartPath, _, err = chartDownloader.DownloadTo(chartRef, opts.ChartVersion, envOr("HELM_REPOSITORY_CACHE", helmpath.CachePath("repository"))) + chartPath, _, err = chartDownloader.DownloadTo(chartRef, opts.ChartVersion, helmpath.CachePath("repository")) if err != nil { return "", fmt.Errorf("download chart %q: %w", chartRef, err) } @@ -73,11 +71,9 @@ func newChartDownloader(ctx context.Context, chartRef string, registryClient *he helmgetter.WithRegistryClient(registryClient), helmgetter.WithTimeout(opts.ChartRepoRequestTimeout), }, - RegistryClient: registryClient, - // TODO(major): get rid of HELM_ env vars support - RepositoryConfig: envOr("HELM_REPOSITORY_CONFIG", helmpath.ConfigPath("repositories.yaml")), - // TODO(major): get rid of HELM_ env vars support - RepositoryCache: envOr("HELM_REPOSITORY_CACHE", helmpath.CachePath("repository")), + RegistryClient: registryClient, + RepositoryConfig: helmpath.ConfigPath("repositories.yaml"), + RepositoryCache: helmpath.CachePath("repository"), } if opts.ChartRepoURL != "" { diff --git a/pkg/chart/chart_render.go b/pkg/chart/chart_render.go index 5dcf79a3..4666957e 100644 --- a/pkg/chart/chart_render.go +++ b/pkg/chart/chart_render.go @@ -85,19 +85,17 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s } depDownloader := &helmdownloader.Manager{ - Out: os.Stdout, - ChartPath: chartPath, - Verify: parseVerificationStrategy(opts.ChartProvenanceStrategy), - Debug: log.Default.AcceptLevel(ctx, log.DebugLevel), - Keyring: opts.ChartProvenanceKeyring, - SkipUpdate: opts.ChartRepoNoUpdate, - Getters: getter.Getters(), - RegistryClient: registryClient, - // TODO(major): don't read HELM_REPOSITORY_CONFIG anymore - RepositoryConfig: envOr("HELM_REPOSITORY_CONFIG", helmpath.ConfigPath("repositories.yaml")), - // TODO(major): don't read HELM_REPOSITORY_CACHE anymore - RepositoryCache: envOr("HELM_REPOSITORY_CACHE", helmpath.CachePath("repository")), - ContentCache: envOr("HELM_CONTENT_CACHE", helmpath.CachePath("content")), + Out: os.Stdout, + ChartPath: chartPath, + Verify: parseVerificationStrategy(opts.ChartProvenanceStrategy), + Debug: log.Default.AcceptLevel(ctx, log.DebugLevel), + Keyring: opts.ChartProvenanceKeyring, + SkipUpdate: opts.ChartRepoNoUpdate, + Getters: getter.Getters(), + RegistryClient: registryClient, + RepositoryConfig: helmpath.ConfigPath("repositories.yaml"), + RepositoryCache: helmpath.CachePath("repository"), + ContentCache: helmpath.CachePath("content"), AllowMissingRepos: true, } @@ -524,14 +522,6 @@ func convertV3DependencyToV2(src *v3chart.Dependency) *v2chart.Dependency { } } -func envOr(envVar, defaultVal string) string { - if v := os.Getenv(envVar); v != "" { - return v - } - - return defaultVal -} - func isLocalChart(path string) bool { return filepath.IsAbs(path) || filepath.HasPrefix(path, "..") || filepath.HasPrefix(path, ".") } diff --git a/pkg/helm/pkg/cmd/root_test.go b/pkg/helm/pkg/cmd/root_test.go index b4e89ae2..19595614 100644 --- a/pkg/helm/pkg/cmd/root_test.go +++ b/pkg/helm/pkg/cmd/root_test.go @@ -58,24 +58,6 @@ func TestRootCmd(t *testing.T) { envvars: map[string]string{xdg.DataHomeEnvVar: "/bar"}, dataPath: "/bar/helm", }, - { - name: "with $HELM_CACHE_HOME set", - args: "env", - envvars: map[string]string{helmpath.CacheHomeEnvVar: "/foo/helm"}, - cachePath: "/foo/helm", - }, - { - name: "with $HELM_CONFIG_HOME set", - args: "env", - envvars: map[string]string{helmpath.ConfigHomeEnvVar: "/foo/helm"}, - configPath: "/foo/helm", - }, - { - name: "with $HELM_DATA_HOME set", - args: "env", - envvars: map[string]string{helmpath.DataHomeEnvVar: "/foo/helm"}, - dataPath: "/foo/helm", - }, } for _, tt := range tests { diff --git a/pkg/helm/pkg/helmpath/lazypath.go b/pkg/helm/pkg/helmpath/lazypath.go index a33fc166..b71ebb3e 100644 --- a/pkg/helm/pkg/helmpath/lazypath.go +++ b/pkg/helm/pkg/helmpath/lazypath.go @@ -37,17 +37,8 @@ const ( // lazypath is a lazy-loaded path buffer for the XDG base directory specification. type lazypath string -func (l lazypath) path(helmEnvVar, xdgEnvVar string, defaultFn func() string, elem ...string) string { - - // There is an order to checking for a path. - // 1. See if a Helm specific environment variable has been set. - // 2. Check if an XDG environment variable is set - // 3. Fall back to a default - base := os.Getenv(helmEnvVar) - if base != "" { - return filepath.Join(base, filepath.Join(elem...)) - } - base = os.Getenv(xdgEnvVar) +func (l lazypath) path(_, xdgEnvVar string, defaultFn func() string, elem ...string) string { + base := os.Getenv(xdgEnvVar) if base == "" { base = defaultFn() } diff --git a/pkg/resource/helpers_ai_test.go b/pkg/resource/helpers_ai_test.go index b16ffc35..2ab2272a 100644 --- a/pkg/resource/helpers_ai_test.go +++ b/pkg/resource/helpers_ai_test.go @@ -149,5 +149,4 @@ func setupLocalSchemaDir(t *testing.T, schemas map[string]string) string { func setupTestEnvironment(t *testing.T) { t.Helper() - t.Setenv("HELM_CACHE_HOME", t.TempDir()) } From 64ef06036d51cff11a43819a1191bc6329ce7e51 Mon Sep 17 00:00:00 2001 From: Ilya Lesikov Date: Tue, 5 May 2026 09:35:51 +0300 Subject: [PATCH 23/85] fix: --set/set-file/set-string flags incorrectly split on commas Signed-off-by: Ilya Lesikov --- cmd/nelm/common_flags.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/nelm/common_flags.go b/cmd/nelm/common_flags.go index 8d5f00a8..e7117689 100644 --- a/cmd/nelm/common_flags.go +++ b/cmd/nelm/common_flags.go @@ -461,11 +461,10 @@ func AddValuesFlags(cmd *cobra.Command, cfg *common.ValuesOptions) error { return fmt.Errorf("add flag: %w", err) } - // TODO(major): revise all flags in nelm/werf to make sure they are all parsed as it happens in - // Helm (see https://github.com/werf/nelm/issues/337) if err := cli.AddFlag(cmd, &cfg.ValuesSet, "set", []string{}, "Set new values, where the key is the value path and the value is the value", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: valuesFlagGroup, + NoSplitOnCommas: true, }); err != nil { return fmt.Errorf("add flag: %w", err) } @@ -473,6 +472,7 @@ func AddValuesFlags(cmd *cobra.Command, cfg *common.ValuesOptions) error { if err := cli.AddFlag(cmd, &cfg.ValuesSetFile, "set-file", []string{}, "Set new values, where the key is the value path and the value is the path to the file with the value content", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: valuesFlagGroup, + NoSplitOnCommas: true, }); err != nil { return fmt.Errorf("add flag: %w", err) } @@ -496,6 +496,7 @@ func AddValuesFlags(cmd *cobra.Command, cfg *common.ValuesOptions) error { if err := cli.AddFlag(cmd, &cfg.ValuesSetString, "set-string", []string{}, "Set new values, where the key is the value path and the value is the value. The value will always become a string", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: valuesFlagGroup, + NoSplitOnCommas: true, }); err != nil { return fmt.Errorf("add flag: %w", err) } From 7f6b86347afb7d964da7e5f3816b089ad9fcda17 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Tue, 5 May 2026 13:41:44 +0300 Subject: [PATCH 24/85] refactor: join Hooks and Resources as ResourceSpecs (#612) Signed-off-by: Dmitry Mordvinov --- pkg/action/release_get.go | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/pkg/action/release_get.go b/pkg/action/release_get.go index f8fb9e50..8c7f57d4 100644 --- a/pkg/action/release_get.go +++ b/pkg/action/release_get.go @@ -59,15 +59,13 @@ type ReleaseGetOptions struct { TempDirPath string } -type ReleaseGetResultV1 struct { - APIVersion string `json:"apiVersion"` - Release *ReleaseGetResultRelease `json:"release"` - Chart *ReleaseGetResultChart `json:"chart"` - Notes string `json:"notes,omitempty"` - Values map[string]interface{} `json:"values,omitempty"` - // TODO(major): Join Hooks and Resources together as ResourceSpecs? - Hooks []map[string]interface{} `json:"hooks,omitempty"` - Resources []map[string]interface{} `json:"resources,omitempty"` +type ReleaseGetResultV2 struct { + APIVersion string `json:"apiVersion"` + Release *ReleaseGetResultRelease `json:"release"` + Chart *ReleaseGetResultChart `json:"chart"` + Notes string `json:"notes,omitempty"` + Values map[string]interface{} `json:"values,omitempty"` + ResourceSpecs []*spec.ResourceSpec `json:"resourceSpecs,omitempty"` } type ReleaseGetResultRelease struct { @@ -92,7 +90,7 @@ type ReleaseGetResultChart struct { } // Retrieves detailed information about the Helm release from the cluster. -func ReleaseGet(ctx context.Context, releaseName, releaseNamespace string, opts ReleaseGetOptions) (*ReleaseGetResultV1, error) { +func ReleaseGet(ctx context.Context, releaseName, releaseNamespace string, opts ReleaseGetOptions) (*ReleaseGetResultV2, error) { homeDir, err := os.UserHomeDir() if err != nil { return nil, fmt.Errorf("get home directory: %w", err) @@ -161,8 +159,8 @@ func ReleaseGet(ctx context.Context, releaseName, releaseNamespace string, opts return nil, fmt.Errorf("coalesce release values: %w", err) } - result := &ReleaseGetResultV1{ - APIVersion: "v1", + result := &ReleaseGetResultV2{ + APIVersion: "v2", Chart: &ReleaseGetResultChart{ Name: rel.Chart.Name(), Version: rel.Chart.Metadata.Version, @@ -189,13 +187,7 @@ func ReleaseGet(ctx context.Context, releaseName, releaseNamespace string, opts return nil, fmt.Errorf("convert release to resource specs: %w", err) } - for _, res := range resSpecs { - if spec.IsHook(res.Annotations) { - result.Hooks = append(result.Hooks, res.Unstruct.Object) - } else { - result.Resources = append(result.Resources, res.Unstruct.Object) - } - } + result.ResourceSpecs = append(result.ResourceSpecs, resSpecs...) if opts.OutputNoPrint { return result, nil From 4b380c79569a1080f116f9361960d785944c1be5 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Thu, 7 May 2026 12:12:22 +0300 Subject: [PATCH 25/85] refactor: use `encoding/json` for marshaling and unmarshaling (#613) Signed-off-by: Dmitry Mordvinov --- pkg/kube/fake/client_dynamic.go | 2 +- pkg/plan/resource_info.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/kube/fake/client_dynamic.go b/pkg/kube/fake/client_dynamic.go index 88b41103..fd4287a0 100644 --- a/pkg/kube/fake/client_dynamic.go +++ b/pkg/kube/fake/client_dynamic.go @@ -1,6 +1,7 @@ package fake import ( + "encoding/json" "fmt" "reflect" @@ -9,7 +10,6 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/json" dynamicfake "k8s.io/client-go/dynamic/fake" staticfake "k8s.io/client-go/kubernetes/fake" "k8s.io/client-go/kubernetes/scheme" diff --git a/pkg/plan/resource_info.go b/pkg/plan/resource_info.go index e8b1ec6e..ed7590eb 100644 --- a/pkg/plan/resource_info.go +++ b/pkg/plan/resource_info.go @@ -2,6 +2,7 @@ package plan import ( "context" + "encoding/json" "fmt" "sort" "strings" @@ -13,7 +14,6 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/json" "github.com/werf/kubedog/pkg/dyntracker/statestore" "github.com/werf/nelm/pkg/common" From d0b1f53ad8413973d67045b330288892c07e2770 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Tue, 12 May 2026 17:39:36 +0300 Subject: [PATCH 26/85] feat: add DockerConfig flag (#605) Signed-off-by: Dmitry Mordvinov --- cmd/nelm/chart_lint.go | 7 ++++++- cmd/nelm/chart_render.go | 7 ++++++- cmd/nelm/common_flags.go | 22 ++++++++++++++++++++++ cmd/nelm/release_install.go | 7 ++++++- cmd/nelm/release_plan_install.go | 7 ++++++- go.mod | 2 +- go.sum | 2 ++ pkg/action/chart_lint.go | 7 +++++-- pkg/action/chart_render.go | 6 ++++-- pkg/action/release_install.go | 7 +++++-- pkg/action/release_plan_install.go | 7 +++++-- pkg/common/common.go | 13 ++----------- 12 files changed, 70 insertions(+), 24 deletions(-) diff --git a/cmd/nelm/chart_lint.go b/cmd/nelm/chart_lint.go index e2e6e30f..bfd4a71f 100644 --- a/cmd/nelm/chart_lint.go +++ b/cmd/nelm/chart_lint.go @@ -189,9 +189,14 @@ func newChartLintCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co return fmt.Errorf("add flag: %w", err) } - if err := cli.AddFlag(cmd, &cfg.RegistryCredentialsPath, "oci-chart-repos-creds", common.DefaultRegistryCredentialsPath, "Credentials to access OCI chart repositories", cli.AddFlagOptions{ + if err := AddDockerConfigFlag(cmd, &cfg.DockerConfig); err != nil { + return fmt.Errorf("add docker config flag: %w", err) + } + + if err := cli.AddFlag(cmd, &cfg.RegistryCredentialsPath, "oci-chart-repos-creds", "", "Credentials to access OCI chart repositories", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: chartRepoFlagGroup, + Type: cli.FlagTypeFile, }); err != nil { return fmt.Errorf("add flag: %w", err) } diff --git a/cmd/nelm/chart_render.go b/cmd/nelm/chart_render.go index 438000fa..a9e2e38c 100644 --- a/cmd/nelm/chart_render.go +++ b/cmd/nelm/chart_render.go @@ -159,9 +159,14 @@ func newChartRenderCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[* return fmt.Errorf("add flag: %w", err) } - if err := cli.AddFlag(cmd, &cfg.RegistryCredentialsPath, "oci-chart-repos-creds", common.DefaultRegistryCredentialsPath, "Credentials to access OCI chart repositories", cli.AddFlagOptions{ + if err := AddDockerConfigFlag(cmd, &cfg.DockerConfig); err != nil { + return fmt.Errorf("add docker config flag: %w", err) + } + + if err := cli.AddFlag(cmd, &cfg.RegistryCredentialsPath, "oci-chart-repos-creds", "", "Credentials to access OCI chart repositories", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: chartRepoFlagGroup, + Type: cli.FlagTypeFile, }); err != nil { return fmt.Errorf("add flag: %w", err) } diff --git a/cmd/nelm/common_flags.go b/cmd/nelm/common_flags.go index e7117689..7821b3c5 100644 --- a/cmd/nelm/common_flags.go +++ b/cmd/nelm/common_flags.go @@ -86,6 +86,28 @@ func AddChartRepoConnectionFlags(cmd *cobra.Command, cfg *common.ChartRepoConnec return nil } +func AddDockerConfigFlag(cmd *cobra.Command, dockerConfig *string) error { + if err := cli.AddFlag(cmd, dockerConfig, "docker-config", common.DefaultDockerConfig, "Docker config directory path", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: func(cmd *cobra.Command, flagName string) ([]*cli.FlagRegexExpr, error) { + regexes := []*cli.FlagRegexExpr{cli.NewFlagRegexExpr("^DOCKER_CONFIG$", "$DOCKER_CONFIG")} + + if r, err := cli.GetFlagGlobalAndLocalEnvVarRegexes(cmd, flagName); err != nil { + return nil, fmt.Errorf("get env var regexes: %w", err) + } else { + regexes = append(regexes, r...) + } + + return regexes, nil + }, + Group: chartRepoFlagGroup, + Type: cli.FlagTypeDir, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + + return nil +} + func AddKubeConnectionFlags(cmd *cobra.Command, cfg *common.KubeConnectionOptions) error { if err := cli.AddFlag(cmd, &cfg.KubeAPIServerAddress, "kube-api-server", "", "Kubernetes API server address", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, diff --git a/cmd/nelm/release_install.go b/cmd/nelm/release_install.go index e587134f..0a5a7b25 100644 --- a/cmd/nelm/release_install.go +++ b/cmd/nelm/release_install.go @@ -208,9 +208,14 @@ func newReleaseInstallCommand(ctx context.Context, afterAllCommandsBuiltFuncs ma return fmt.Errorf("add flag: %w", err) } - if err := cli.AddFlag(cmd, &cfg.RegistryCredentialsPath, "oci-chart-repos-creds", common.DefaultRegistryCredentialsPath, "Credentials to access OCI chart repositories", cli.AddFlagOptions{ + if err := AddDockerConfigFlag(cmd, &cfg.DockerConfig); err != nil { + return fmt.Errorf("add docker config flag: %w", err) + } + + if err := cli.AddFlag(cmd, &cfg.RegistryCredentialsPath, "oci-chart-repos-creds", "", "Credentials to access OCI chart repositories", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: chartRepoFlagGroup, + Type: cli.FlagTypeFile, }); err != nil { return fmt.Errorf("add flag: %w", err) } diff --git a/cmd/nelm/release_plan_install.go b/cmd/nelm/release_plan_install.go index 449a2e95..87576e55 100644 --- a/cmd/nelm/release_plan_install.go +++ b/cmd/nelm/release_plan_install.go @@ -205,9 +205,14 @@ func newReleasePlanInstallCommand(ctx context.Context, afterAllCommandsBuiltFunc return fmt.Errorf("add flag: %w", err) } - if err := cli.AddFlag(cmd, &cfg.RegistryCredentialsPath, "oci-chart-repos-creds", common.DefaultRegistryCredentialsPath, "Credentials to access OCI chart repositories", cli.AddFlagOptions{ + if err := AddDockerConfigFlag(cmd, &cfg.DockerConfig); err != nil { + return fmt.Errorf("add docker config flag: %w", err) + } + + if err := cli.AddFlag(cmd, &cfg.RegistryCredentialsPath, "oci-chart-repos-creds", "", "Credentials to access OCI chart repositories", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: chartRepoFlagGroup, + Type: cli.FlagTypeFile, }); err != nil { return fmt.Errorf("add flag: %w", err) } diff --git a/go.mod b/go.mod index 46242a80..0dc18372 100644 --- a/go.mod +++ b/go.mod @@ -56,7 +56,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tidwall/sjson v1.2.5 github.com/wI2L/jsondiff v0.7.0 - github.com/werf/common-go v0.0.0-20260212174520-adf7d95a1579 + github.com/werf/common-go v0.0.0-20260428201303-b0dcadceca5c github.com/werf/kubedog v0.13.1-0.20260320165832-7d97aaf7aab9 github.com/werf/lockgate v0.1.1 github.com/werf/logboek v0.6.1 diff --git a/go.sum b/go.sum index a6da4310..4b4df940 100644 --- a/go.sum +++ b/go.sum @@ -404,6 +404,8 @@ github.com/werf/3p-cobra v0.0.0-20260403075225-552c82797324 h1:aqEM5aboMpBfsILja github.com/werf/3p-cobra v0.0.0-20260403075225-552c82797324/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/werf/common-go v0.0.0-20260212174520-adf7d95a1579 h1:LojMRgEoMNrUnfsbDG5GT4M5HXC9LAYH+5DWBY3p4uU= github.com/werf/common-go v0.0.0-20260212174520-adf7d95a1579/go.mod h1:MXS0JR9zut+oR9oEM8PEkdXXoEbKDILTmWopt0z1eZs= +github.com/werf/common-go v0.0.0-20260428201303-b0dcadceca5c h1:4/qKB2licflIlCZGX4U1o4Ij/oraYyuNc2cWFiutZ10= +github.com/werf/common-go v0.0.0-20260428201303-b0dcadceca5c/go.mod h1:DlN/hD9tXLxYgdAMkulQdzHkiaPrvs3MzgPZvjluea4= github.com/werf/kubedog v0.13.1-0.20260320165832-7d97aaf7aab9 h1:N+XKTPiXT5pf5lxThhaQQPARLUpZTlYJeMNoNtn+540= github.com/werf/kubedog v0.13.1-0.20260320165832-7d97aaf7aab9/go.mod h1:93L6aIdpj7iIhL30Obkv7bWgUyTeuxas1ijtzjmyb4Q= github.com/werf/lockgate v0.1.1 h1:S400JFYjtWfE4i4LY9FA8zx0fMdfui9DPrBiTciCrx4= diff --git a/pkg/action/chart_lint.go b/pkg/action/chart_lint.go index 1de25d00..72914579 100644 --- a/pkg/action/chart_lint.go +++ b/pkg/action/chart_lint.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "path/filepath" "github.com/samber/lo" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -59,6 +60,8 @@ type ChartLintOptions struct { DefaultDeletePropagation string // DenoBinaryPath, if specified, uses this path as the Deno binary instead of auto-downloading. DenoBinaryPath string + // DockerConfig is the path to the Docker configuration directory (e.g., ~/.docker). + DockerConfig string // ExtraAPIVersions is a list of additional Kubernetes API versions to include during linting. // Used by Capabilities.APIVersions in templates to check for API availability. ExtraAPIVersions []string @@ -100,7 +103,7 @@ type ChartLintOptions struct { // Used in the validation dry-run to check resource compatibility. NoRemoveManualChanges bool // RegistryCredentialsPath is the path to Docker config.json file with registry credentials. - // Defaults to DefaultRegistryCredentialsPath (~/.docker/config.json) if not set. + // Defaults to DockerConfig/config.json if not set. // Used for authenticating to OCI registries when pulling charts. RegistryCredentialsPath string // ReleaseName is the name of the release to use for linting. @@ -422,7 +425,7 @@ func applyChartLintOptionsDefaults(opts ChartLintOptions, currentDir, homeDir st } if opts.RegistryCredentialsPath == "" { - opts.RegistryCredentialsPath = common.DefaultRegistryCredentialsPath + opts.RegistryCredentialsPath = filepath.Join(opts.DockerConfig, "config.json") } if opts.ChartProvenanceStrategy == "" { diff --git a/pkg/action/chart_render.go b/pkg/action/chart_render.go index 284b78ac..38b436ed 100644 --- a/pkg/action/chart_render.go +++ b/pkg/action/chart_render.go @@ -59,6 +59,8 @@ type ChartRenderOptions struct { DefaultChartVersion string // DenoBinaryPath, if specified, uses this path as the Deno binary instead of auto-downloading. DenoBinaryPath string + // DockerConfig is the path to the Docker configuration directory (e.g., ~/.docker). + DockerConfig string // ExtraAPIVersions is a list of additional Kubernetes API versions to include when rendering. // Used by Capabilities.APIVersions in templates to check for API availability. ExtraAPIVersions []string @@ -94,7 +96,7 @@ type ChartRenderOptions struct { // Useful when only the result data structure is needed. OutputNoPrint bool // RegistryCredentialsPath is the path to Docker config.json file with registry credentials. - // Defaults to DefaultRegistryCredentialsPath (~/.docker/config.json) if not set. + // Defaults to DockerConfig/config.json if not set. // Used for authenticating to OCI registries when pulling charts. RegistryCredentialsPath string // ReleaseName is the name of the release to use in templates. @@ -421,7 +423,7 @@ func applyChartRenderOptionsDefaults(opts ChartRenderOptions, currentDir, homeDi } if opts.RegistryCredentialsPath == "" { - opts.RegistryCredentialsPath = common.DefaultRegistryCredentialsPath + opts.RegistryCredentialsPath = filepath.Join(opts.DockerConfig, "config.json") } if opts.ChartProvenanceStrategy == "" { diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index 6de3977e..374544e7 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "path/filepath" "sort" "time" @@ -75,6 +76,8 @@ type ReleaseInstallOptions struct { DefaultChartVersion string // DenoBinaryPath, if specified, uses this path as the Deno binary instead of auto-downloading. DenoBinaryPath string + // DockerConfig is the path to the Docker configuration directory (e.g., ~/.docker). + DockerConfig string // IgnoreBundleJS, when true, ignores the existing bundle.js and rebuilds it from TypeScript sources. IgnoreBundleJS bool // InstallGraphPath, if specified, saves the Graphviz representation of the install plan to this file path. @@ -110,7 +113,7 @@ type ReleaseInstallOptions struct { // PlanArtifactPath, if specified, saves the install plan artifact to this file path. PlanArtifactPath string // RegistryCredentialsPath is the path to Docker config.json file with registry credentials. - // Defaults to DefaultRegistryCredentialsPath (~/.docker/config.json) if not set. + // Defaults to DockerConfig/config.json if not set. // Used for authenticating to OCI registries when pulling charts. RegistryCredentialsPath string // RollbackGraphPath, if specified, saves the Graphviz representation of the rollback plan (if auto-rollback occurs) @@ -728,7 +731,7 @@ func applyReleaseInstallOptionsDefaults(opts ReleaseInstallOptions, currentDir, } if opts.RegistryCredentialsPath == "" { - opts.RegistryCredentialsPath = common.DefaultRegistryCredentialsPath + opts.RegistryCredentialsPath = filepath.Join(opts.DockerConfig, "config.json") } if opts.ChartProvenanceStrategy == "" { diff --git a/pkg/action/release_plan_install.go b/pkg/action/release_plan_install.go index ba09cc70..ae5ad28c 100644 --- a/pkg/action/release_plan_install.go +++ b/pkg/action/release_plan_install.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "time" @@ -69,6 +70,8 @@ type ReleasePlanInstallOptions struct { DefaultChartVersion string // DenoBinaryPath, if specified, uses this path as the Deno binary instead of auto-downloading. DenoBinaryPath string + // DockerConfig is the path to the Docker configuration directory (e.g., ~/.docker). + DockerConfig string // ErrorIfChangesPlanned, when true, returns ErrChangesPlanned if any changes are detected. // Used with --exit-code flag to return exit code 2 if changes are planned, 0 if no changes, 1 on error. ErrorIfChangesPlanned bool @@ -97,7 +100,7 @@ type ReleasePlanInstallOptions struct { // PlanArtifactPath, if specified, saves the install plan artifact to this file path. PlanArtifactPath string // RegistryCredentialsPath is the path to Docker config.json file with registry credentials. - // Defaults to DefaultRegistryCredentialsPath (~/.docker/config.json) if not set. + // Defaults to DockerConfig/config.json if not set. // Used for authenticating to OCI registries when pulling charts. RegistryCredentialsPath string // TempDirPath is the directory for temporary files during the operation. @@ -538,7 +541,7 @@ func applyReleasePlanInstallOptionsDefaults(opts ReleasePlanInstallOptions, curr } if opts.RegistryCredentialsPath == "" { - opts.RegistryCredentialsPath = common.DefaultRegistryCredentialsPath + opts.RegistryCredentialsPath = filepath.Join(opts.DockerConfig, "config.json") } if opts.ChartProvenanceStrategy == "" { diff --git a/pkg/common/common.go b/pkg/common/common.go index e4418d7a..a5ee070a 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -152,9 +152,7 @@ var ( StagePostPostUninstall, StageFinal, } - OrderedStoreAs = []StoreAs{StoreAsNone, StoreAsHook, StoreAsRegular} - // TODO(major): now it respects DOCKER_CONFIG? Is it a breaking change? Anyways, I feel like it shouldn't be a constant, but a proper option for actions - DefaultRegistryCredentialsPath = filepath.Join(dockerConfigDir(), "config.json") + OrderedStoreAs = []StoreAs{StoreAsNone, StoreAsHook, StoreAsRegular} LabelKeyHumanManagedBy = "app.kubernetes.io/managed-by" LabelKeyPatternManagedBy = regexp.MustCompile(`^app.kubernetes.io/managed-by$`) AnnotationKeyHumanReleaseName = "meta.helm.sh/release-name" @@ -225,6 +223,7 @@ var ( AnnotationKeyHumanDeletePropagation = "werf.io/delete-propagation" AnnotationKeyPatternDeletePropagation = regexp.MustCompile(`^werf.io/delete-propagation$`) SprigFuncs = sprig.TxtFuncMap() + DefaultDockerConfig = filepath.Join(userHomeDir(), ".docker") DefaultPlanArtifactLifetime = 2 * time.Hour DefaultResourceValidationSchema = []string{ "https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/{{ .NormalizedKubernetesVersion }}-standalone{{ .StrictSuffix }}/{{ .ResourceKind }}{{ .KindSuffix }}.json", @@ -308,14 +307,6 @@ func SubStageWeighted(stage Stage, weight int) Stage { return Stage(fmt.Sprintf("%s/weight:%d", stage, weight)) } -func dockerConfigDir() string { - if d := os.Getenv("DOCKER_CONFIG"); d != "" { - return d - } - - return filepath.Join(userHomeDir(), ".docker") -} - func userHomeDir() string { home, _ := os.UserHomeDir() if home == "" && runtime.GOOS != "windows" { From 73d4a88af39e19e57ac4e5762260ae20c6363f1c Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Wed, 13 May 2026 14:58:53 +0300 Subject: [PATCH 27/85] feat: improve runtime annotations and labels handling (#615) Signed-off-by: Ilya Drey --- pkg/action/chart_lint.go | 4 +++- pkg/action/chart_render.go | 17 +++-------------- pkg/action/release_install.go | 8 ++++++-- pkg/action/release_plan_install.go | 4 +++- pkg/action/release_rollback.go | 2 ++ pkg/action/release_uninstall.go | 9 +++++++++ pkg/plan/resource_info.go | 16 +++++++++++----- pkg/plan/resource_info_test.go | 2 +- pkg/resource/spec/resource_spec.go | 2 +- pkg/resource/spec/unstruct.go | 15 +++++++++++---- 10 files changed, 50 insertions(+), 29 deletions(-) diff --git a/pkg/action/chart_lint.go b/pkg/action/chart_lint.go index 72914579..49911a1d 100644 --- a/pkg/action/chart_lint.go +++ b/pkg/action/chart_lint.go @@ -283,7 +283,7 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { log.Default.Debug(ctx, "Build releasable resource specs") - releasableResSpecs, err := spec.BuildReleasableResourceSpecs(ctx, opts.ReleaseNamespace, transformedResSpecs, []spec.ResourcePatcher{ + releasableResSpecs, err := spec.BuildPatchedResourceSpecs(ctx, opts.ReleaseNamespace, transformedResSpecs, []spec.ResourcePatcher{ spec.NewExtraMetadataPatcher(opts.ExtraAnnotations, opts.ExtraLabels), spec.NewSecretStringDataPatcher(), }) @@ -352,6 +352,8 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { NetworkParallelism: opts.NetworkParallelism, NoRemoveManualChanges: opts.NoRemoveManualChanges, LastDeployedOrLastRelResourceSpecs: lastDeployedOrLastRelResSpecs, + ExtraRuntimeAnnotations: opts.ExtraRuntimeAnnotations, + ExtraRuntimeLabels: opts.ExtraRuntimeLabels, }) if err != nil { return fmt.Errorf("build resource infos: %w", err) diff --git a/pkg/action/chart_render.go b/pkg/action/chart_render.go index 38b436ed..a21aa30f 100644 --- a/pkg/action/chart_render.go +++ b/pkg/action/chart_render.go @@ -71,8 +71,8 @@ type ChartRenderOptions struct { // These are added during chart rendering. ExtraLabels map[string]string // ExtraRuntimeAnnotations are additional annotations to add to resources at runtime. - // TODO(major): remove or implement custom logic for this field. ExtraRuntimeAnnotations map[string]string + ExtraRuntimeLabels map[string]string // IgnoreBundleJS, when true, ignores the existing bundle.js and rebuilds it from TypeScript sources. IgnoreBundleJS bool // LegacyChartType specifies the chart type for legacy compatibility. @@ -282,26 +282,15 @@ func ChartRender(ctx context.Context, opts ChartRenderOptions) (*ChartRenderResu log.Default.Debug(ctx, "Build releasable resource specs") - releasableResSpecs, err := spec.BuildReleasableResourceSpecs(ctx, opts.ReleaseNamespace, transformedResSpecs, []spec.ResourcePatcher{ + resSpecs, err := spec.BuildPatchedResourceSpecs(ctx, opts.ReleaseNamespace, transformedResSpecs, []spec.ResourcePatcher{ spec.NewExtraMetadataPatcher(opts.ExtraAnnotations, opts.ExtraLabels), + spec.NewExtraMetadataPatcher(opts.ExtraRuntimeAnnotations, opts.ExtraRuntimeLabels), spec.NewSecretStringDataPatcher(), }) if err != nil { return nil, fmt.Errorf("build releasable resource specs: %w", err) } - newRelease, err := release.NewRelease(opts.ReleaseName, opts.ReleaseNamespace, newRevision, deployType, releasableResSpecs, renderChartResult.Chart, renderChartResult.ReleaseConfig, release.ReleaseOptions{}) - if err != nil { - return nil, fmt.Errorf("construct new release: %w", err) - } - - log.Default.Debug(ctx, "Convert new release to resource specs") - - resSpecs, err := release.ReleaseToResourceSpecs(newRelease, opts.ReleaseNamespace, true) - if err != nil { - return nil, fmt.Errorf("convert new release to resource specs: %w", err) - } - var showFiles []string for _, file := range opts.ShowOnlyFiles { diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index 374544e7..f03e6029 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -391,7 +391,7 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re patchers = append(patchers, spec.NewLegacyOnlyTrackJobsPatcher()) } - releasableResSpecs, err := spec.BuildReleasableResourceSpecs(ctx, releaseNamespace, transformedResSpecs, patchers) + releasableResSpecs, err := spec.BuildPatchedResourceSpecs(ctx, releaseNamespace, transformedResSpecs, patchers) if err != nil { return fmt.Errorf("build releasable resource specs: %w", err) } @@ -459,6 +459,8 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re NetworkParallelism: opts.NetworkParallelism, NoRemoveManualChanges: opts.NoRemoveManualChanges, LastDeployedOrLastRelResourceSpecs: lastDeployedOrLastRelResSpecs, + ExtraRuntimeAnnotations: opts.ExtraRuntimeAnnotations, + ExtraRuntimeLabels: opts.ExtraRuntimeLabels, }) if err != nil { return fmt.Errorf("build resource infos: %w", err) @@ -808,7 +810,7 @@ func runRollbackPlan(ctx context.Context, releaseName, releaseNamespace string, patchers = append(patchers, spec.NewLegacyOnlyTrackJobsPatcher()) } - releasableResSpecs, err := spec.BuildReleasableResourceSpecs(ctx, releaseNamespace, transformedResSpecs, patchers) + releasableResSpecs, err := spec.BuildPatchedResourceSpecs(ctx, releaseNamespace, transformedResSpecs, patchers) if err != nil { return nil, nonCritErrs, critErrs.Add(fmt.Errorf("build releasable resource specs: %w", err)) } @@ -866,6 +868,8 @@ func runRollbackPlan(ctx context.Context, releaseName, releaseNamespace string, NetworkParallelism: opts.NetworkParallelism, NoRemoveManualChanges: opts.NoRemoveManualChanges, LastDeployedOrLastRelResourceSpecs: lastDeployedOrLastRelResSpecs, + ExtraRuntimeAnnotations: opts.ExtraRuntimeAnnotations, + ExtraRuntimeLabels: opts.ExtraRuntimeLabels, }) if err != nil { return nil, nonCritErrs, critErrs.Add(fmt.Errorf("build resource infos: %w", err)) diff --git a/pkg/action/release_plan_install.go b/pkg/action/release_plan_install.go index ae5ad28c..73ca3a86 100644 --- a/pkg/action/release_plan_install.go +++ b/pkg/action/release_plan_install.go @@ -297,7 +297,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc patchers = append(patchers, spec.NewLegacyOnlyTrackJobsPatcher()) } - releasableResSpecs, err := spec.BuildReleasableResourceSpecs(ctx, releaseNamespace, transformedResSpecs, patchers) + releasableResSpecs, err := spec.BuildPatchedResourceSpecs(ctx, releaseNamespace, transformedResSpecs, patchers) if err != nil { return nil, fmt.Errorf("build releasable resource specs: %w", err) } @@ -363,6 +363,8 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc NetworkParallelism: opts.NetworkParallelism, NoRemoveManualChanges: opts.NoRemoveManualChanges, LastDeployedOrLastRelResourceSpecs: lastDeployedOrLastRelResSpecs, + ExtraRuntimeAnnotations: opts.ExtraRuntimeAnnotations, + ExtraRuntimeLabels: opts.ExtraRuntimeLabels, }) if err != nil { return nil, fmt.Errorf("build resource infos: %w", err) diff --git a/pkg/action/release_rollback.go b/pkg/action/release_rollback.go index 08884a15..07d8ddea 100644 --- a/pkg/action/release_rollback.go +++ b/pkg/action/release_rollback.go @@ -292,6 +292,8 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r NetworkParallelism: opts.NetworkParallelism, NoRemoveManualChanges: opts.NoRemoveManualChanges, LastDeployedOrLastRelResourceSpecs: lastDeployedOrLastRelResSpecs, + ExtraRuntimeAnnotations: opts.ExtraRuntimeAnnotations, + ExtraRuntimeLabels: opts.ExtraRuntimeLabels, }) if err != nil { return fmt.Errorf("build resource infos: %w", err) diff --git a/pkg/action/release_uninstall.go b/pkg/action/release_uninstall.go index c0b71fd2..12bb364b 100644 --- a/pkg/action/release_uninstall.go +++ b/pkg/action/release_uninstall.go @@ -186,6 +186,7 @@ func releaseUninstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, } log.Default.Debug(ctx, "Build release history") + history, err := release.BuildHistory(releaseName, releaseStorage, release.HistoryOptions{}) if err != nil { return fmt.Errorf("build release history: %w", err) @@ -203,6 +204,7 @@ func releaseUninstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, deployType := common.DeployTypeUninstall log.Default.Debug(ctx, "Convert previous release to resource specs") + prevRelResSpecs, err := release.ReleaseToResourceSpecs(prevRelease, releaseNamespace, false) if err != nil { return fmt.Errorf("convert previous release to resource specs: %w", err) @@ -217,6 +219,7 @@ func releaseUninstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, } log.Default.Debug(ctx, "Build resources") + instResources, delResources, err := resource.BuildResources(ctx, deployType, releaseNamespace, prevRelResSpecs, nil, patchers, clientFactory, resource.BuildResourcesOptions{ Remote: true, DefaultDeletePropagation: metav1.DeletionPropagation(opts.DefaultDeletePropagation), @@ -226,6 +229,7 @@ func releaseUninstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, } log.Default.Debug(ctx, "Build resource infos") + instResInfos, delResInfos, err := plan.BuildResourceInfos(ctx, deployType, releaseName, releaseNamespace, instResources, delResources, prevReleaseFailed, clientFactory, plan.BuildResourceInfosOptions{ NetworkParallelism: opts.NetworkParallelism, NoRemoveManualChanges: opts.NoRemoveManualChanges, @@ -236,12 +240,14 @@ func releaseUninstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, } log.Default.Debug(ctx, "Build release infos") + relInfos, err := plan.BuildReleaseInfos(ctx, deployType, releases, nil) if err != nil { return fmt.Errorf("build release infos: %w", err) } log.Default.Debug(ctx, "Build delete plan") + deletePlan, err := plan.BuildPlan(instResInfos, delResInfos, relInfos, plan.BuildPlanOptions{ NoFinalTracking: opts.NoFinalTracking, }) @@ -261,6 +267,7 @@ func releaseUninstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, logStore := kdutil.NewConcurrent(logstore.NewLogStore()) log.Default.Debug(ctx, "Start tracking") + var progressPrinter *track.ProgressTablesPrinter if !opts.NoProgressTablePrint { progressPrinter = track.NewProgressTablesPrinter(taskStore, logStore, track.ProgressTablesPrinterOptions{ @@ -278,6 +285,7 @@ func releaseUninstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, } log.Default.Debug(ctx, "Execute release delete plan") + executePlanErr := plan.ExecutePlan(ctx, releaseNamespace, deletePlan, taskStore, logStore, informerFactory, history, clientFactory, plan.ExecutePlanOptions{ LegacyProgressReporter: reporter, TrackingOptions: opts.TrackingOptions, @@ -312,6 +320,7 @@ func releaseUninstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, criticalErrs.Add(critErrs) nonCriticalErrs.Add(nonCritErrs) + if runFailurePlanResult != nil { completedResourceOps = append(completedResourceOps, runFailurePlanResult.CompletedResourceOps...) canceledResourceOps = append(canceledResourceOps, runFailurePlanResult.CanceledResourceOps...) diff --git a/pkg/plan/resource_info.go b/pkg/plan/resource_info.go index ed7590eb..8328dfe9 100644 --- a/pkg/plan/resource_info.go +++ b/pkg/plan/resource_info.go @@ -76,6 +76,8 @@ type DeletableResourceInfo struct { } type BuildResourceInfosOptions struct { + ExtraRuntimeAnnotations map[string]string + ExtraRuntimeLabels map[string]string LastDeployedOrLastRelResourceSpecs []*spec.ResourceSpec NetworkParallelism int NoRemoveManualChanges bool @@ -93,7 +95,7 @@ func BuildResourceInfos(ctx context.Context, deployType common.DeployType, relea instResourcesPool := pool.NewWithResults[[]*InstallableResourceInfo]().WithContext(ctx).WithMaxGoroutines(routines).WithCancelOnError().WithFirstError() for _, res := range instResources { instResourcesPool.Go(func(ctx context.Context) ([]*InstallableResourceInfo, error) { - infos, err := buildInstallableResourceInfo(ctx, res, deployType, releaseNamespace, prevReleaseFailed, opts.NoRemoveManualChanges, clientFactory, opts.LastDeployedOrLastRelResourceSpecs) + infos, err := buildInstallableResourceInfo(ctx, res, deployType, releaseNamespace, prevReleaseFailed, opts.NoRemoveManualChanges, clientFactory, opts) if err != nil { return nil, fmt.Errorf("build installable resource info: %w", err) } @@ -154,7 +156,7 @@ func ResourceInstallTypeSortHandler(type1, type2 ResourceInstallType) bool { } // TODO(major): keep annotation should probably forbid resource recreations -func buildInstallableResourceInfo(ctx context.Context, localRes *resource.InstallableResource, deployType common.DeployType, releaseNamespace string, prevRelFailed, noRemoveManualChanges bool, clientFactory kube.ClientFactorier, lastDeployedOrLastRelResSpecs []*spec.ResourceSpec) ([]*InstallableResourceInfo, error) { +func buildInstallableResourceInfo(ctx context.Context, localRes *resource.InstallableResource, deployType common.DeployType, releaseNamespace string, prevRelFailed, noRemoveManualChanges bool, clientFactory kube.ClientFactorier, opts BuildResourceInfosOptions) ([]*InstallableResourceInfo, error) { var stages []common.Stage switch deployType { case common.DeployTypeInitial, common.DeployTypeInstall: @@ -201,7 +203,7 @@ func buildInstallableResourceInfo(ctx context.Context, localRes *resource.Instal var err error - getObj, err = fixManagedFieldsInCluster(ctx, releaseNamespace, getObj, localRes, noRemoveManualChanges, clientFactory, lastDeployedOrLastRelResSpecs) + getObj, err = fixManagedFieldsInCluster(ctx, releaseNamespace, getObj, localRes, noRemoveManualChanges, clientFactory, opts.LastDeployedOrLastRelResourceSpecs) if err != nil { return nil, fmt.Errorf("fix managed fields for resource %q: %w", localRes.IDHuman(), err) } @@ -211,7 +213,7 @@ func buildInstallableResourceInfo(ctx context.Context, localRes *resource.Instal DryRun: true, }) - installType, err := resourceInstallType(ctx, localRes, getObj, dryApplyObj, dryApplyErr) + installType, err := resourceInstallType(ctx, localRes, getObj, dryApplyObj, dryApplyErr, opts.ExtraRuntimeAnnotations, opts.ExtraRuntimeLabels) if err != nil { return nil, fmt.Errorf("determine install type for resource %q: %w", localRes.IDHuman(), err) } @@ -801,7 +803,7 @@ func removeUndesirableManagers(managedFields []v1.ManagedFieldsEntry, oursEntry return newManagedFields, newOursEntry, changed } -func resourceInstallType(ctx context.Context, localRes *resource.InstallableResource, getObj, dryApplyObj *unstructured.Unstructured, dryApplyErr error) (ResourceInstallType, error) { +func resourceInstallType(ctx context.Context, localRes *resource.InstallableResource, getObj, dryApplyObj *unstructured.Unstructured, dryApplyErr error, extraRuntimeAnnotations, extraRuntimeLabels map[string]string) (ResourceInstallType, error) { isImmutable := dryApplyErr != nil && kube.IsImmutableErr(dryApplyErr) if isImmutable && !localRes.Recreate && !localRes.RecreateOnImmutable { return "", fmt.Errorf("immutable fields change in resource %q, but recreation is not requested: %w", localRes.IDHuman(), dryApplyErr) @@ -819,12 +821,16 @@ func resourceInstallType(ctx context.Context, localRes *resource.InstallableReso CleanHelmShAnnos: true, CleanWerfIoAnnos: true, CleanRuntimeData: true, + CleanAnnotations: extraRuntimeAnnotations, + CleanLabels: extraRuntimeLabels, }) diffableDryApplyObj := spec.CleanUnstruct(dryApplyObj, spec.CleanUnstructOptions{ CleanHelmShAnnos: true, CleanWerfIoAnnos: true, CleanRuntimeData: true, + CleanAnnotations: extraRuntimeAnnotations, + CleanLabels: extraRuntimeLabels, }) if patch, err := jsondiff.Compare(diffableGetObj, diffableDryApplyObj); err != nil { diff --git a/pkg/plan/resource_info_test.go b/pkg/plan/resource_info_test.go index 3bad6fc5..7797bcd2 100644 --- a/pkg/plan/resource_info_test.go +++ b/pkg/plan/resource_info_test.go @@ -631,7 +631,7 @@ func runBuildInstallableResourceInfoTest(tc buildInstallableResourceInfoTestCase localRes, deployType, prevRelFailed := tc.input() - resInfos, err := plan.BuildInstallableResourceInfo(context.Background(), localRes, deployType, s.releaseNamespace, prevRelFailed, true, s.clientFactory, nil) + resInfos, err := plan.BuildInstallableResourceInfo(context.Background(), localRes, deployType, s.releaseNamespace, prevRelFailed, true, s.clientFactory, plan.BuildResourceInfosOptions{}) s.Require().NoError(err) expectResInfos := tc.expect(localRes) diff --git a/pkg/resource/spec/resource_spec.go b/pkg/resource/spec/resource_spec.go index 1cae4a22..2785b49f 100644 --- a/pkg/resource/spec/resource_spec.go +++ b/pkg/resource/spec/resource_spec.go @@ -79,7 +79,7 @@ type ResourceSpecOptions struct { // Patch ResourceSpecs to make them releasable, after which they can be saved into the Helm release. // Don't try to add/delete/expand specs here, use transformers in BuildTransformedResourceSpecs // instead. -func BuildReleasableResourceSpecs(ctx context.Context, releaseNamespace string, transformedResources []*ResourceSpec, patchers []ResourcePatcher) ([]*ResourceSpec, error) { +func BuildPatchedResourceSpecs(ctx context.Context, releaseNamespace string, transformedResources []*ResourceSpec, patchers []ResourcePatcher) ([]*ResourceSpec, error) { var releasableResources []*ResourceSpec for _, res := range transformedResources { diff --git a/pkg/resource/spec/unstruct.go b/pkg/resource/spec/unstruct.go index f54974e6..b9cd77d8 100644 --- a/pkg/resource/spec/unstruct.go +++ b/pkg/resource/spec/unstruct.go @@ -10,7 +10,9 @@ import ( ) type CleanUnstructOptions struct { + CleanAnnotations map[string]string CleanHelmShAnnos bool + CleanLabels map[string]string CleanManagedFields bool CleanNullFields bool CleanReleaseAnnosLabels bool @@ -46,7 +48,8 @@ func CleanUnstruct(unstruct *unstructured.Unstructured, opts CleanUnstructOption } if opts.CleanWerfIoRuntimeAnnos { - cleanAnnotationsRegexes = append(cleanAnnotationsRegexes, + cleanAnnotationsRegexes = append( + cleanAnnotationsRegexes, regexp.MustCompile(`.*ci\.werf\.io/.+`), regexp.MustCompile(`^project\.werf\.io/.+`), regexp.MustCompile(`^werf\.io/version$`), regexp.MustCompile(`^werf\.io/release-channel$`), @@ -59,12 +62,12 @@ func CleanUnstruct(unstruct *unstructured.Unstructured, opts CleanUnstructOption } if annos := unstructCopy.GetAnnotations(); len(annos) > 0 { - filteredAnnos := filterAnnosOrLabels(annos, cleanAnnotationsRegexes) + filteredAnnos := filterAnnosOrLabels(annos, cleanAnnotationsRegexes, opts.CleanAnnotations) unstructCopy.SetAnnotations(filteredAnnos) } if labels := unstructCopy.GetLabels(); len(labels) > 0 { - filteredLabels := filterAnnosOrLabels(labels, cleanLabelsRegexes) + filteredLabels := filterAnnosOrLabels(labels, cleanLabelsRegexes, opts.CleanLabels) unstructCopy.SetLabels(filteredLabels) } @@ -119,11 +122,15 @@ func cleanRuntimeDataFromUnstruct(unstruct *unstructured.Unstructured) { unstruct.SetManagedFields(managedFields) } -func filterAnnosOrLabels(annosOrLabels map[string]string, regexes []*regexp.Regexp) map[string]string { +func filterAnnosOrLabels(annosOrLabels map[string]string, regexes []*regexp.Regexp, excludeMap map[string]string) map[string]string { filtered := map[string]string{} annoOrLabelLoop: for key, val := range annosOrLabels { + if _, found := excludeMap[key]; found { + continue annoOrLabelLoop + } + for _, regex := range regexes { if regex.MatchString(key) { continue annoOrLabelLoop From a8c983f5ba6874303988438204d50b1ceb917fef Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Wed, 13 May 2026 16:34:20 +0300 Subject: [PATCH 28/85] feat: fail on invalid annotations or labels (#614) Signed-off-by: Ilya Drey --- pkg/action/chart_lint.go | 38 ++++++++++--------- pkg/action/chart_render.go | 32 ++++++++-------- pkg/action/release_get.go | 2 +- pkg/action/release_install.go | 47 +++++++++++------------ pkg/action/release_plan_install.go | 36 +++++++++--------- pkg/action/release_rollback.go | 8 ++-- pkg/action/release_uninstall.go | 2 +- pkg/chart/chart_render.go | 45 +++++++++++----------- pkg/release/release.go | 24 +++++++----- pkg/resource/spec/resource_spec.go | 26 ++++++++++--- pkg/resource/spec/transform.go | 60 +----------------------------- pkg/resource/spec/util.go | 23 ++++++++++++ 12 files changed, 170 insertions(+), 173 deletions(-) diff --git a/pkg/action/chart_lint.go b/pkg/action/chart_lint.go index 49911a1d..35bd0def 100644 --- a/pkg/action/chart_lint.go +++ b/pkg/action/chart_lint.go @@ -62,6 +62,8 @@ type ChartLintOptions struct { DenoBinaryPath string // DockerConfig is the path to the Docker configuration directory (e.g., ~/.docker). DockerConfig string + // DropInvalidAnnotationsAndLabels disables strict annotations and labels validation. + DropInvalidAnnotationsAndLabels bool // ExtraAPIVersions is a list of additional Kubernetes API versions to include during linting. // Used by Capabilities.APIVersions in templates to check for API availability. ExtraAPIVersions []string @@ -248,20 +250,21 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { } chartTreeOptions := chart.RenderChartOptions{ - ChartRepoConnectionOptions: opts.ChartRepoConnectionOptions, - ValuesOptions: opts.ValuesOptions, - ChartProvenanceKeyring: opts.ChartProvenanceKeyring, - ChartProvenanceStrategy: opts.ChartProvenanceStrategy, - ChartRepoNoUpdate: opts.ChartRepoSkipUpdate, - ChartVersion: opts.ChartVersion, - ExtraAPIVersions: opts.ExtraAPIVersions, - HelmOptions: helmOptions, - LocalKubeVersion: opts.LocalKubeVersion, - Remote: opts.Remote, - TemplatesAllowDNS: opts.TemplatesAllowDNS, - TempDirPath: opts.TempDirPath, - IgnoreBundleJS: opts.IgnoreBundleJS, - DenoBinaryPath: opts.DenoBinaryPath, + ChartRepoConnectionOptions: opts.ChartRepoConnectionOptions, + ValuesOptions: opts.ValuesOptions, + ChartProvenanceKeyring: opts.ChartProvenanceKeyring, + ChartProvenanceStrategy: opts.ChartProvenanceStrategy, + ChartRepoNoUpdate: opts.ChartRepoSkipUpdate, + ChartVersion: opts.ChartVersion, + ExtraAPIVersions: opts.ExtraAPIVersions, + HelmOptions: helmOptions, + LocalKubeVersion: opts.LocalKubeVersion, + DropInvalidAnnotationsAndLabels: opts.DropInvalidAnnotationsAndLabels, + Remote: opts.Remote, + TemplatesAllowDNS: opts.TemplatesAllowDNS, + TempDirPath: opts.TempDirPath, + IgnoreBundleJS: opts.IgnoreBundleJS, + DenoBinaryPath: opts.DenoBinaryPath, } log.Default.Debug(ctx, "Render chart") @@ -275,7 +278,6 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { transformedResSpecs, err := spec.BuildTransformedResourceSpecs(ctx, opts.ReleaseNamespace, renderChartResult.ResourceSpecs, []spec.ResourceTransformer{ spec.NewResourceListsTransformer(), - spec.NewDropInvalidAnnotationsAndLabelsTransformer(), }) if err != nil { return fmt.Errorf("build transformed resource specs: %w", err) @@ -300,7 +302,7 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { var prevRelResSpecs []*spec.ResourceSpec if prevRelease != nil { - prevRelResSpecs, err = release.ReleaseToResourceSpecs(prevRelease, opts.ReleaseNamespace, false) + prevRelResSpecs, err = release.ReleaseToResourceSpecs(ctx, prevRelease, opts.ReleaseNamespace, false) if err != nil { return fmt.Errorf("convert previous release to resource specs: %w", err) } @@ -308,7 +310,7 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { log.Default.Debug(ctx, "Convert new release to resource specs") - newRelResSpecs, err := release.ReleaseToResourceSpecs(newRelease, opts.ReleaseNamespace, false) + newRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, newRelease, opts.ReleaseNamespace, false) if err != nil { return fmt.Errorf("convert new release to resource specs: %w", err) } @@ -342,7 +344,7 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { var lastDeployedOrLastRelResSpecs []*spec.ResourceSpec if lastDeployedOrLastRelease != nil { - lastDeployedOrLastRelResSpecs, err = release.ReleaseToResourceSpecs(lastDeployedOrLastRelease, opts.ReleaseNamespace, false) + lastDeployedOrLastRelResSpecs, err = release.ReleaseToResourceSpecs(ctx, lastDeployedOrLastRelease, opts.ReleaseNamespace, false) if err != nil { return fmt.Errorf("convert last deployed or last release to resource specs: %w", err) } diff --git a/pkg/action/chart_render.go b/pkg/action/chart_render.go index a21aa30f..46252e58 100644 --- a/pkg/action/chart_render.go +++ b/pkg/action/chart_render.go @@ -61,6 +61,8 @@ type ChartRenderOptions struct { DenoBinaryPath string // DockerConfig is the path to the Docker configuration directory (e.g., ~/.docker). DockerConfig string + // DropInvalidAnnotationsAndLabels disables strict annotations and labels validation. + DropInvalidAnnotationsAndLabels bool // ExtraAPIVersions is a list of additional Kubernetes API versions to include when rendering. // Used by Capabilities.APIVersions in templates to check for API availability. ExtraAPIVersions []string @@ -247,20 +249,21 @@ func ChartRender(ctx context.Context, opts ChartRenderOptions) (*ChartRenderResu } chartTreeOptions := chart.RenderChartOptions{ - ChartRepoConnectionOptions: opts.ChartRepoConnectionOptions, - ValuesOptions: opts.ValuesOptions, - ChartProvenanceKeyring: opts.ChartProvenanceKeyring, - ChartProvenanceStrategy: opts.ChartProvenanceStrategy, - ChartRepoNoUpdate: opts.ChartRepoSkipUpdate, - ChartVersion: opts.ChartVersion, - ExtraAPIVersions: opts.ExtraAPIVersions, - HelmOptions: helmOptions, - LocalKubeVersion: opts.LocalKubeVersion, - Remote: opts.Remote, - TemplatesAllowDNS: opts.TemplatesAllowDNS, - TempDirPath: opts.TempDirPath, - IgnoreBundleJS: opts.IgnoreBundleJS, - DenoBinaryPath: opts.DenoBinaryPath, + ChartRepoConnectionOptions: opts.ChartRepoConnectionOptions, + ValuesOptions: opts.ValuesOptions, + ChartProvenanceKeyring: opts.ChartProvenanceKeyring, + ChartProvenanceStrategy: opts.ChartProvenanceStrategy, + ChartRepoNoUpdate: opts.ChartRepoSkipUpdate, + ChartVersion: opts.ChartVersion, + ExtraAPIVersions: opts.ExtraAPIVersions, + HelmOptions: helmOptions, + LocalKubeVersion: opts.LocalKubeVersion, + DropInvalidAnnotationsAndLabels: opts.DropInvalidAnnotationsAndLabels, + Remote: opts.Remote, + TemplatesAllowDNS: opts.TemplatesAllowDNS, + TempDirPath: opts.TempDirPath, + IgnoreBundleJS: opts.IgnoreBundleJS, + DenoBinaryPath: opts.DenoBinaryPath, } log.Default.Debug(ctx, "Render chart") @@ -274,7 +277,6 @@ func ChartRender(ctx context.Context, opts ChartRenderOptions) (*ChartRenderResu transformedResSpecs, err := spec.BuildTransformedResourceSpecs(ctx, opts.ReleaseNamespace, renderChartResult.ResourceSpecs, []spec.ResourceTransformer{ spec.NewResourceListsTransformer(), - spec.NewDropInvalidAnnotationsAndLabelsTransformer(), }) if err != nil { return nil, fmt.Errorf("build transformed resource specs: %w", err) diff --git a/pkg/action/release_get.go b/pkg/action/release_get.go index 8c7f57d4..fd53b04c 100644 --- a/pkg/action/release_get.go +++ b/pkg/action/release_get.go @@ -182,7 +182,7 @@ func ReleaseGet(ctx context.Context, releaseName, releaseNamespace string, opts Values: values, } - resSpecs, err := release.ReleaseToResourceSpecs(rel, releaseNamespace, false) + resSpecs, err := release.ReleaseToResourceSpecs(ctx, rel, releaseNamespace, false) if err != nil { return nil, fmt.Errorf("convert release to resource specs: %w", err) } diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index f03e6029..724468f2 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -78,6 +78,8 @@ type ReleaseInstallOptions struct { DenoBinaryPath string // DockerConfig is the path to the Docker configuration directory (e.g., ~/.docker). DockerConfig string + // DropInvalidAnnotationsAndLabels disables strict annotations and labels validation. + DropInvalidAnnotationsAndLabels bool // IgnoreBundleJS, when true, ignores the existing bundle.js and rebuilds it from TypeScript sources. IgnoreBundleJS bool // InstallGraphPath, if specified, saves the Graphviz representation of the install plan to this file path. @@ -351,20 +353,21 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re log.Default.Debug(ctx, "Render chart") renderChartResult, err := chart.RenderChart(ctx, opts.Chart, releaseName, releaseNamespace, newRevision, deployType, helmRegistryClient, clientFactory, chart.RenderChartOptions{ - ChartRepoConnectionOptions: opts.ChartRepoConnectionOptions, - ValuesOptions: opts.ValuesOptions, - ChartProvenanceKeyring: opts.ChartProvenanceKeyring, - ChartProvenanceStrategy: opts.ChartProvenanceStrategy, - ChartRepoNoUpdate: opts.ChartRepoSkipUpdate, - ChartVersion: opts.ChartVersion, - HelmOptions: helmOptions, - NoStandaloneCRDs: opts.NoInstallStandaloneCRDs, - Remote: true, - SubchartNotes: opts.ShowSubchartNotes, - TemplatesAllowDNS: opts.TemplatesAllowDNS, - IgnoreBundleJS: opts.IgnoreBundleJS, - DenoBinaryPath: opts.DenoBinaryPath, - TempDirPath: opts.TempDirPath, + ChartRepoConnectionOptions: opts.ChartRepoConnectionOptions, + ValuesOptions: opts.ValuesOptions, + ChartProvenanceKeyring: opts.ChartProvenanceKeyring, + ChartProvenanceStrategy: opts.ChartProvenanceStrategy, + ChartRepoNoUpdate: opts.ChartRepoSkipUpdate, + ChartVersion: opts.ChartVersion, + HelmOptions: helmOptions, + DropInvalidAnnotationsAndLabels: opts.DropInvalidAnnotationsAndLabels, + NoStandaloneCRDs: opts.NoInstallStandaloneCRDs, + Remote: true, + SubchartNotes: opts.ShowSubchartNotes, + TemplatesAllowDNS: opts.TemplatesAllowDNS, + IgnoreBundleJS: opts.IgnoreBundleJS, + DenoBinaryPath: opts.DenoBinaryPath, + TempDirPath: opts.TempDirPath, }) if err != nil { return fmt.Errorf("render chart: %w", err) @@ -374,7 +377,6 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re transformedResSpecs, err := spec.BuildTransformedResourceSpecs(ctx, releaseNamespace, renderChartResult.ResourceSpecs, []spec.ResourceTransformer{ spec.NewResourceListsTransformer(), - spec.NewDropInvalidAnnotationsAndLabelsTransformer(), }) if err != nil { return fmt.Errorf("build transformed resource specs: %w", err) @@ -409,7 +411,7 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re var prevRelResSpecs []*spec.ResourceSpec if prevRelease != nil { - prevRelResSpecs, err = release.ReleaseToResourceSpecs(prevRelease, releaseNamespace, false) + prevRelResSpecs, err = release.ReleaseToResourceSpecs(ctx, prevRelease, releaseNamespace, false) if err != nil { return fmt.Errorf("convert previous release to resource specs: %w", err) } @@ -417,7 +419,7 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re log.Default.Debug(ctx, "Convert new release to resource specs") - newRelResSpecs, err := release.ReleaseToResourceSpecs(newRelease, releaseNamespace, false) + newRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, newRelease, releaseNamespace, false) if err != nil { return fmt.Errorf("convert new release to resource specs: %w", err) } @@ -449,7 +451,7 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re var lastDeployedOrLastRelResSpecs []*spec.ResourceSpec if lastDeployedOrLastRelease != nil { - lastDeployedOrLastRelResSpecs, err = release.ReleaseToResourceSpecs(lastDeployedOrLastRelease, releaseNamespace, false) + lastDeployedOrLastRelResSpecs, err = release.ReleaseToResourceSpecs(ctx, lastDeployedOrLastRelease, releaseNamespace, false) if err != nil { return fmt.Errorf("convert last deployed or last release to resource specs: %w", err) } @@ -784,7 +786,7 @@ func runRollbackPlan(ctx context.Context, releaseName, releaseNamespace string, log.Default.Debug(ctx, "Convert prev deployed release to resource specs") - resSpecs, err := release.ReleaseToResourceSpecs(prevDeployedRelease, releaseNamespace, false) + resSpecs, err := release.ReleaseToResourceSpecs(ctx, prevDeployedRelease, releaseNamespace, false) if err != nil { return nil, nonCritErrs, critErrs.Add(fmt.Errorf("convert previous deployed release to resource specs: %w", err)) } @@ -793,7 +795,6 @@ func runRollbackPlan(ctx context.Context, releaseName, releaseNamespace string, transformedResSpecs, err := spec.BuildTransformedResourceSpecs(ctx, releaseNamespace, resSpecs, []spec.ResourceTransformer{ spec.NewResourceListsTransformer(), - spec.NewDropInvalidAnnotationsAndLabelsTransformer(), }) if err != nil { return nil, nonCritErrs, critErrs.Add(fmt.Errorf("build transformed resource specs: %w", err)) @@ -826,14 +827,14 @@ func runRollbackPlan(ctx context.Context, releaseName, releaseNamespace string, log.Default.Debug(ctx, "Convert failed release to resource specs") - failedRelResSpecs, err := release.ReleaseToResourceSpecs(failedRelease, releaseNamespace, false) + failedRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, failedRelease, releaseNamespace, false) if err != nil { return nil, nonCritErrs, critErrs.Add(fmt.Errorf("convert previous release to resource specs: %w", err)) } log.Default.Debug(ctx, "Convert new release to resource specs") - newRelResSpecs, err := release.ReleaseToResourceSpecs(newRelease, releaseNamespace, false) + newRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, newRelease, releaseNamespace, false) if err != nil { return nil, nonCritErrs, critErrs.Add(fmt.Errorf("convert new release to resource specs: %w", err)) } @@ -859,7 +860,7 @@ func runRollbackPlan(ctx context.Context, releaseName, releaseNamespace string, log.Default.Debug(ctx, "Build resource infos") - lastDeployedOrLastRelResSpecs, err := release.ReleaseToResourceSpecs(prevDeployedRelease, releaseNamespace, false) + lastDeployedOrLastRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, prevDeployedRelease, releaseNamespace, false) if err != nil { return nil, nonCritErrs, critErrs.Add(fmt.Errorf("convert last deployed or last release to resource specs: %w", err)) } diff --git a/pkg/action/release_plan_install.go b/pkg/action/release_plan_install.go index 73ca3a86..f1d969da 100644 --- a/pkg/action/release_plan_install.go +++ b/pkg/action/release_plan_install.go @@ -72,6 +72,8 @@ type ReleasePlanInstallOptions struct { DenoBinaryPath string // DockerConfig is the path to the Docker configuration directory (e.g., ~/.docker). DockerConfig string + // DropInvalidAnnotationsAndLabels disables strict annotations and labels validation. + DropInvalidAnnotationsAndLabels bool // ErrorIfChangesPlanned, when true, returns ErrChangesPlanned if any changes are detected. // Used with --exit-code flag to return exit code 2 if changes are planned, 0 if no changes, 1 on error. ErrorIfChangesPlanned bool @@ -258,19 +260,20 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc log.Default.Debug(ctx, "Render chart") renderChartResult, err := chart.RenderChart(ctx, opts.Chart, releaseName, releaseNamespace, newRevision, deployType, helmRegistryClient, clientFactory, chart.RenderChartOptions{ - ChartRepoConnectionOptions: opts.ChartRepoConnectionOptions, - ValuesOptions: opts.ValuesOptions, - ChartProvenanceKeyring: opts.ChartProvenanceKeyring, - ChartProvenanceStrategy: opts.ChartProvenanceStrategy, - ChartRepoNoUpdate: opts.ChartRepoSkipUpdate, - ChartVersion: opts.ChartVersion, - HelmOptions: helmOptions, - NoStandaloneCRDs: opts.NoInstallStandaloneCRDs, - Remote: true, - TemplatesAllowDNS: opts.TemplatesAllowDNS, - TempDirPath: opts.TempDirPath, - IgnoreBundleJS: opts.IgnoreBundleJS, - DenoBinaryPath: opts.DenoBinaryPath, + ChartRepoConnectionOptions: opts.ChartRepoConnectionOptions, + ValuesOptions: opts.ValuesOptions, + ChartProvenanceKeyring: opts.ChartProvenanceKeyring, + ChartProvenanceStrategy: opts.ChartProvenanceStrategy, + ChartRepoNoUpdate: opts.ChartRepoSkipUpdate, + ChartVersion: opts.ChartVersion, + HelmOptions: helmOptions, + DropInvalidAnnotationsAndLabels: opts.DropInvalidAnnotationsAndLabels, + NoStandaloneCRDs: opts.NoInstallStandaloneCRDs, + Remote: true, + TemplatesAllowDNS: opts.TemplatesAllowDNS, + TempDirPath: opts.TempDirPath, + IgnoreBundleJS: opts.IgnoreBundleJS, + DenoBinaryPath: opts.DenoBinaryPath, }) if err != nil { return nil, fmt.Errorf("render chart: %w", err) @@ -280,7 +283,6 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc transformedResSpecs, err := spec.BuildTransformedResourceSpecs(ctx, releaseNamespace, renderChartResult.ResourceSpecs, []spec.ResourceTransformer{ spec.NewResourceListsTransformer(), - spec.NewDropInvalidAnnotationsAndLabelsTransformer(), }) if err != nil { return nil, fmt.Errorf("build transformed resource specs: %w", err) @@ -315,7 +317,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc var prevRelResSpecs []*spec.ResourceSpec if prevRelease != nil { - prevRelResSpecs, err = release.ReleaseToResourceSpecs(prevRelease, releaseNamespace, false) + prevRelResSpecs, err = release.ReleaseToResourceSpecs(ctx, prevRelease, releaseNamespace, false) if err != nil { return nil, fmt.Errorf("convert previous release to resource specs: %w", err) } @@ -323,7 +325,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc log.Default.Debug(ctx, "Convert new release to resource specs") - newRelResSpecs, err := release.ReleaseToResourceSpecs(newRelease, releaseNamespace, false) + newRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, newRelease, releaseNamespace, false) if err != nil { return nil, fmt.Errorf("convert new release to resource specs: %w", err) } @@ -353,7 +355,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc var lastDeployedOrLastRelResSpecs []*spec.ResourceSpec if lastDeployedOrLastRelease != nil { - lastDeployedOrLastRelResSpecs, err = release.ReleaseToResourceSpecs(lastDeployedOrLastRelease, releaseNamespace, false) + lastDeployedOrLastRelResSpecs, err = release.ReleaseToResourceSpecs(ctx, lastDeployedOrLastRelease, releaseNamespace, false) if err != nil { return nil, fmt.Errorf("convert last deployed or last release to resource specs: %w", err) } diff --git a/pkg/action/release_rollback.go b/pkg/action/release_rollback.go index 07d8ddea..b5f351f2 100644 --- a/pkg/action/release_rollback.go +++ b/pkg/action/release_rollback.go @@ -223,7 +223,7 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r log.Default.Debug(ctx, "Convert release to resource specs") - rollbackReleaseResSpecs, err := release.ReleaseToResourceSpecs(rollbackRelease, releaseNamespace, false) + rollbackReleaseResSpecs, err := release.ReleaseToResourceSpecs(ctx, rollbackRelease, releaseNamespace, false) if err != nil { return fmt.Errorf("convert release to rollback to resource specs: %w", err) } @@ -239,14 +239,14 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r log.Default.Debug(ctx, "Convert previous release to resource specs") - prevRelResSpecs, err := release.ReleaseToResourceSpecs(prevRelease, releaseNamespace, false) + prevRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, prevRelease, releaseNamespace, false) if err != nil { return fmt.Errorf("convert previous release to resource specs: %w", err) } log.Default.Debug(ctx, "Convert new release to resource specs") - newRelResSpecs, err := release.ReleaseToResourceSpecs(newRelease, releaseNamespace, false) + newRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, newRelease, releaseNamespace, false) if err != nil { return fmt.Errorf("convert new release to resource specs: %w", err) } @@ -282,7 +282,7 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r var lastDeployedOrLastRelResSpecs []*spec.ResourceSpec if lastDeployedOrLastRelease != nil { - lastDeployedOrLastRelResSpecs, err = release.ReleaseToResourceSpecs(lastDeployedOrLastRelease, releaseNamespace, false) + lastDeployedOrLastRelResSpecs, err = release.ReleaseToResourceSpecs(ctx, lastDeployedOrLastRelease, releaseNamespace, false) if err != nil { return fmt.Errorf("convert last deployed or last release to resource specs: %w", err) } diff --git a/pkg/action/release_uninstall.go b/pkg/action/release_uninstall.go index 12bb364b..61a64ffe 100644 --- a/pkg/action/release_uninstall.go +++ b/pkg/action/release_uninstall.go @@ -205,7 +205,7 @@ func releaseUninstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, log.Default.Debug(ctx, "Convert previous release to resource specs") - prevRelResSpecs, err := release.ReleaseToResourceSpecs(prevRelease, releaseNamespace, false) + prevRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, prevRelease, releaseNamespace, false) if err != nil { return fmt.Errorf("convert previous release to resource specs: %w", err) } diff --git a/pkg/chart/chart_render.go b/pkg/chart/chart_render.go index 4666957e..190a5f16 100644 --- a/pkg/chart/chart_render.go +++ b/pkg/chart/chart_render.go @@ -46,20 +46,21 @@ type RenderChartOptions struct { common.ChartRepoConnectionOptions common.ValuesOptions - ChartProvenanceKeyring string - ChartProvenanceStrategy string - ChartRepoNoUpdate bool - ChartVersion string - DenoBinaryPath string - ExtraAPIVersions []string - HelmOptions common.HelmOptions - IgnoreBundleJS bool - LocalKubeVersion string - NoStandaloneCRDs bool - Remote bool - SubchartNotes bool - TempDirPath string - TemplatesAllowDNS bool + ChartProvenanceKeyring string + ChartProvenanceStrategy string + ChartRepoNoUpdate bool + ChartVersion string + DenoBinaryPath string + DropInvalidAnnotationsAndLabels bool + ExtraAPIVersions []string + HelmOptions common.HelmOptions + IgnoreBundleJS bool + LocalKubeVersion string + NoStandaloneCRDs bool + Remote bool + SubchartNotes bool + TempDirPath string + TemplatesAllowDNS bool } type RenderChartResult struct { @@ -260,9 +261,10 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s for _, crd := range crds { for _, manifest := range util.SplitManifests(string(crd.data)) { - if res, err := spec.NewResourceSpecFromManifest(manifest, releaseNamespace, spec.ResourceSpecOptions{ - StoreAs: common.StoreAsNone, - FilePath: crd.filename, + if res, err := spec.NewResourceSpecFromManifest(ctx, manifest, releaseNamespace, spec.ResourceSpecOptions{ + StoreAs: common.StoreAsNone, + FilePath: crd.filename, + DropInvalidAnnotationsAndLabels: opts.DropInvalidAnnotationsAndLabels, }); err != nil { return nil, fmt.Errorf("construct standalone CRD for chart at %q: %w", chartPath, err) } else { @@ -310,7 +312,7 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s log.Default.Debug(ctx, "---\n# Source: %s\n%s\n", filePath, fileContent) } - if r, err := renderedTemplatesToResourceSpecs(renderedTemplates, releaseNamespace, opts); err != nil { + if r, err := renderedTemplatesToResourceSpecs(ctx, renderedTemplates, releaseNamespace, opts); err != nil { return nil, fmt.Errorf("convert rendered templates to installable resources for chart at %q: %w", chartPath, err) } else { resources = append(resources, r...) @@ -539,7 +541,7 @@ func parseVerificationStrategy(s string) helmdownloader.VerificationStrategy { } } -func renderedTemplatesToResourceSpecs(renderedTemplates map[string]string, releaseNamespace string, opts RenderChartOptions) ([]*spec.ResourceSpec, error) { +func renderedTemplatesToResourceSpecs(ctx context.Context, renderedTemplates map[string]string, releaseNamespace string, opts RenderChartOptions) ([]*spec.ResourceSpec, error) { var resources []*spec.ResourceSpec for filePath, fileContent := range renderedTemplates { @@ -562,8 +564,9 @@ func renderedTemplatesToResourceSpecs(renderedTemplates map[string]string, relea return nil, fmt.Errorf("parse YAML resource #%d for %q: %w", idx+1, filePath, err) } - if res, err := spec.NewResourceSpecFromManifest(manifest, releaseNamespace, spec.ResourceSpecOptions{ - FilePath: filePath, + if res, err := spec.NewResourceSpecFromManifest(ctx, manifest, releaseNamespace, spec.ResourceSpecOptions{ + FilePath: filePath, + DropInvalidAnnotationsAndLabels: opts.DropInvalidAnnotationsAndLabels, }); err != nil { return nil, fmt.Errorf("construct resource spec for %q: %w", filePath, err) } else { diff --git a/pkg/release/release.go b/pkg/release/release.go index 2bf6b568..b31e3a95 100644 --- a/pkg/release/release.go +++ b/pkg/release/release.go @@ -1,6 +1,7 @@ package release import ( + "context" "fmt" "hash" "hash/fnv" @@ -203,12 +204,13 @@ func NewRelease(name, namespace string, revision int, deployType common.DeployTy } // Constructs ResourceSpecs from a Release object. -func ReleaseToResourceSpecs(rel *helmrelease.Release, releaseNamespace string, noCleanNullFields bool /* TODO(major): get rid */) ([]*spec.ResourceSpec, error) { +func ReleaseToResourceSpecs(ctx context.Context, rel *helmrelease.Release, releaseNamespace string, noCleanNullFields bool) ([]*spec.ResourceSpec, error) { var resources []*spec.ResourceSpec for _, manifest := range util.SplitManifests(rel.UnstoredManifest) { - if res, err := spec.NewResourceSpecFromManifest(manifest, releaseNamespace, spec.ResourceSpecOptions{ - StoreAs: common.StoreAsNone, - LegacyNoCleanNullFields: noCleanNullFields, + if res, err := spec.NewResourceSpecFromManifest(ctx, manifest, releaseNamespace, spec.ResourceSpecOptions{ + StoreAs: common.StoreAsNone, + LegacyNoCleanNullFields: noCleanNullFields, + DropInvalidAnnotationsAndLabels: true, }); err != nil { return nil, fmt.Errorf("construct resource spec from unstored manifest: %w", err) } else { @@ -217,9 +219,10 @@ func ReleaseToResourceSpecs(rel *helmrelease.Release, releaseNamespace string, n } for _, manifest := range util.SplitManifests(rel.Manifest) { - if res, err := spec.NewResourceSpecFromManifest(manifest, releaseNamespace, spec.ResourceSpecOptions{ - StoreAs: common.StoreAsRegular, - LegacyNoCleanNullFields: noCleanNullFields, + if res, err := spec.NewResourceSpecFromManifest(ctx, manifest, releaseNamespace, spec.ResourceSpecOptions{ + StoreAs: common.StoreAsRegular, + LegacyNoCleanNullFields: noCleanNullFields, + DropInvalidAnnotationsAndLabels: true, }); err != nil { return nil, fmt.Errorf("construct resource spec from regular manifest: %w", err) } else { @@ -228,9 +231,10 @@ func ReleaseToResourceSpecs(rel *helmrelease.Release, releaseNamespace string, n } for _, hook := range rel.Hooks { - if res, err := spec.NewResourceSpecFromManifest(hook.Manifest, releaseNamespace, spec.ResourceSpecOptions{ - StoreAs: common.StoreAsHook, - LegacyNoCleanNullFields: noCleanNullFields, + if res, err := spec.NewResourceSpecFromManifest(ctx, hook.Manifest, releaseNamespace, spec.ResourceSpecOptions{ + StoreAs: common.StoreAsHook, + LegacyNoCleanNullFields: noCleanNullFields, + DropInvalidAnnotationsAndLabels: true, }); err != nil { return nil, fmt.Errorf("construct resource spec from hook manifest: %w", err) } else { diff --git a/pkg/resource/spec/resource_spec.go b/pkg/resource/spec/resource_spec.go index 2785b49f..ef26405a 100644 --- a/pkg/resource/spec/resource_spec.go +++ b/pkg/resource/spec/resource_spec.go @@ -46,7 +46,7 @@ func NewResourceSpec(unstruct *unstructured.Unstructured, releaseNamespace strin } } -func NewResourceSpecFromManifest(manifest, releaseNamespace string, opts ResourceSpecOptions) (*ResourceSpec, error) { +func NewResourceSpecFromManifest(ctx context.Context, manifest, releaseNamespace string, opts ResourceSpecOptions) (*ResourceSpec, error) { if opts.FilePath == "" && strings.HasPrefix(manifest, "# Source: ") { firstLine := strings.TrimSpace(strings.Split(manifest, "\n")[0]) opts.FilePath = strings.TrimPrefix(firstLine, "# Source: ") @@ -57,7 +57,22 @@ func NewResourceSpecFromManifest(manifest, releaseNamespace string, opts Resourc return nil, fmt.Errorf("decode resource (file: %q): %w", opts.FilePath, err) } - return NewResourceSpec(obj.(*unstructured.Unstructured), releaseNamespace, opts), nil + unstruct := obj.(*unstructured.Unstructured) + + if opts.DropInvalidAnnotationsAndLabels { + unstruct.SetAnnotations(stripInvalidEntries(ctx, opts.FilePath, unstruct.Object, "metadata", "annotations")) + unstruct.SetLabels(stripInvalidEntries(ctx, opts.FilePath, unstruct.Object, "metadata", "labels")) + } else { + if _, _, err := unstructured.NestedNullCoercingStringMap(unstruct.Object, "metadata", "annotations"); err != nil { + return nil, fmt.Errorf("decode resource (file: %q): %w", opts.FilePath, err) + } + + if _, _, err := unstructured.NestedNullCoercingStringMap(unstruct.Object, "metadata", "labels"); err != nil { + return nil, fmt.Errorf("decode resource (file: %q): %w", opts.FilePath, err) + } + } + + return NewResourceSpec(unstruct, releaseNamespace, opts), nil } func (s *ResourceSpec) SetAnnotations(annotations map[string]string) { @@ -71,9 +86,10 @@ func (s *ResourceSpec) SetLabels(labels map[string]string) { } type ResourceSpecOptions struct { - FilePath string - LegacyNoCleanNullFields bool // TODO(major): always clean - StoreAs common.StoreAs + DropInvalidAnnotationsAndLabels bool + FilePath string + LegacyNoCleanNullFields bool // TODO(major): always clean + StoreAs common.StoreAs } // Patch ResourceSpecs to make them releasable, after which they can be saved into the Helm release. diff --git a/pkg/resource/spec/transform.go b/pkg/resource/spec/transform.go index 49c54d9f..c205cd2d 100644 --- a/pkg/resource/spec/transform.go +++ b/pkg/resource/spec/transform.go @@ -6,19 +6,11 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" - - "github.com/werf/nelm/pkg/log" ) -const ( - TypeDropInvalidAnnotationsAndLabelsTransformer ResourceTransformerType = "drop-invalid-annotations-and-labels-transformer" - TypeResourceListsTransformer ResourceTransformerType = "resource-lists-transformer" -) +const TypeResourceListsTransformer ResourceTransformerType = "resource-lists-transformer" -var ( - _ ResourceTransformer = (*DropInvalidAnnotationsAndLabelsTransformer)(nil) - _ ResourceTransformer = (*ResourceListsTransformer)(nil) -) +var _ ResourceTransformer = (*ResourceListsTransformer)(nil) type ResourceTransformerType string @@ -32,54 +24,6 @@ type ResourceTransformerResourceInfo struct { Obj *unstructured.Unstructured } -// TODO(major): remove this transformer. Replace it with proper early validation of resource Heads. -type DropInvalidAnnotationsAndLabelsTransformer struct{} - -func NewDropInvalidAnnotationsAndLabelsTransformer() *DropInvalidAnnotationsAndLabelsTransformer { - return &DropInvalidAnnotationsAndLabelsTransformer{} -} - -func (t *DropInvalidAnnotationsAndLabelsTransformer) Match(ctx context.Context, info *ResourceTransformerResourceInfo) (matched bool, err error) { - return true, nil -} - -func (t *DropInvalidAnnotationsAndLabelsTransformer) Transform(ctx context.Context, info *ResourceTransformerResourceInfo) ([]*unstructured.Unstructured, error) { - annotations, _, _ := unstructured.NestedMap(info.Obj.Object, "metadata", "annotations") - - resultAnnotations := make(map[string]string) - for annoKey, rawAnnoValue := range annotations { - annoValue, valIsString := rawAnnoValue.(string) - if !valIsString { - log.Default.Warn(ctx, "Dropped invalid annotation %q in resource %q (%s): key is not a string", annoKey, info.Obj.GetName(), info.Obj.GroupVersionKind().String()) - continue - } - - resultAnnotations[annoKey] = annoValue - } - - labels, _, _ := unstructured.NestedMap(info.Obj.Object, "metadata", "labels") - - resultLabels := make(map[string]string) - for labelKey, rawLabelValue := range labels { - labelValue, valIsString := rawLabelValue.(string) - if !valIsString { - log.Default.Warn(ctx, "Dropped invalid label %q in resource %q (%s): key is not a string", labelKey, info.Obj.GetName(), info.Obj.GroupVersionKind().String()) - continue - } - - resultLabels[labelKey] = labelValue - } - - info.Obj.SetAnnotations(resultAnnotations) - info.Obj.SetLabels(resultLabels) - - return []*unstructured.Unstructured{info.Obj}, nil -} - -func (t *DropInvalidAnnotationsAndLabelsTransformer) Type() ResourceTransformerType { - return TypeDropInvalidAnnotationsAndLabelsTransformer -} - type ResourceListsTransformer struct{} func NewResourceListsTransformer() *ResourceListsTransformer { diff --git a/pkg/resource/spec/util.go b/pkg/resource/spec/util.go index 6ce590e6..8a22d9b2 100644 --- a/pkg/resource/spec/util.go +++ b/pkg/resource/spec/util.go @@ -1,6 +1,7 @@ package spec import ( + "context" "fmt" "regexp" "strings" @@ -11,6 +12,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "github.com/werf/nelm/pkg/common" + "github.com/werf/nelm/pkg/log" ) func IsHook(annotations map[string]string) bool { @@ -152,3 +154,24 @@ func setAnnotationsAndLabels(res *unstructured.Unstructured, annotations, labels res.SetLabels(lbls) } } + +func stripInvalidEntries(ctx context.Context, filePath string, obj map[string]interface{}, fields ...string) map[string]string { + result := make(map[string]string) + + data, _, err := unstructured.NestedMap(obj, fields...) + if err != nil { + log.Default.Warn(ctx, "decode resource file (%s): %q data was stripped due to invalid format", filePath, strings.Join(fields, ".")) + + return result + } + + for key, value := range data { + if s, ok := value.(string); ok { + result[key] = s + } else { + log.Default.Warn(ctx, "decode resource file (%s): key %q from %q was stipped due to invalid value fromat", filePath, key, strings.Join(fields, ".")) + } + } + + return result +} From ec40944e34248a4c86bdffdfdf93a4f21dded69e Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Wed, 13 May 2026 17:33:56 +0300 Subject: [PATCH 29/85] feat: allow to disable values schema validation (#616) Signed-off-by: Ilya Drey --- cmd/nelm/common_flags.go | 7 +++++++ pkg/action/chart_lint.go | 3 ++- pkg/action/release_install.go | 3 ++- pkg/action/release_plan_install.go | 3 ++- pkg/chart/chart_render.go | 5 +++-- pkg/common/options.go | 2 ++ 6 files changed, 18 insertions(+), 5 deletions(-) diff --git a/cmd/nelm/common_flags.go b/cmd/nelm/common_flags.go index 7821b3c5..5766b6ca 100644 --- a/cmd/nelm/common_flags.go +++ b/cmd/nelm/common_flags.go @@ -325,6 +325,13 @@ func AddResourceValidationFlags(cmd *cobra.Command, cfg *common.ResourceValidati return fmt.Errorf("add flag: %w", err) } + if err := cli.AddFlag(cmd, &cfg.NoValuesSchemaValidation, "no-values-schema-validation", false, "Disable values validation against JSON schema", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: resourceValidationGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + if err := cli.AddFlag(cmd, &cfg.LocalResourceValidation, "local-resource-validation", false, "Do not use external json schema sources", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: resourceValidationGroup, diff --git a/pkg/action/chart_lint.go b/pkg/action/chart_lint.go index 35bd0def..38650a6a 100644 --- a/pkg/action/chart_lint.go +++ b/pkg/action/chart_lint.go @@ -256,10 +256,11 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { ChartProvenanceStrategy: opts.ChartProvenanceStrategy, ChartRepoNoUpdate: opts.ChartRepoSkipUpdate, ChartVersion: opts.ChartVersion, + DropInvalidAnnotationsAndLabels: opts.DropInvalidAnnotationsAndLabels, ExtraAPIVersions: opts.ExtraAPIVersions, HelmOptions: helmOptions, LocalKubeVersion: opts.LocalKubeVersion, - DropInvalidAnnotationsAndLabels: opts.DropInvalidAnnotationsAndLabels, + NoValuesSchemaValidation: opts.NoValuesSchemaValidation, Remote: opts.Remote, TemplatesAllowDNS: opts.TemplatesAllowDNS, TempDirPath: opts.TempDirPath, diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index 724468f2..32da99f2 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -359,8 +359,9 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re ChartProvenanceStrategy: opts.ChartProvenanceStrategy, ChartRepoNoUpdate: opts.ChartRepoSkipUpdate, ChartVersion: opts.ChartVersion, - HelmOptions: helmOptions, DropInvalidAnnotationsAndLabels: opts.DropInvalidAnnotationsAndLabels, + HelmOptions: helmOptions, + NoValuesSchemaValidation: opts.NoValuesSchemaValidation, NoStandaloneCRDs: opts.NoInstallStandaloneCRDs, Remote: true, SubchartNotes: opts.ShowSubchartNotes, diff --git a/pkg/action/release_plan_install.go b/pkg/action/release_plan_install.go index f1d969da..87c76489 100644 --- a/pkg/action/release_plan_install.go +++ b/pkg/action/release_plan_install.go @@ -266,8 +266,9 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc ChartProvenanceStrategy: opts.ChartProvenanceStrategy, ChartRepoNoUpdate: opts.ChartRepoSkipUpdate, ChartVersion: opts.ChartVersion, - HelmOptions: helmOptions, DropInvalidAnnotationsAndLabels: opts.DropInvalidAnnotationsAndLabels, + HelmOptions: helmOptions, + NoValuesSchemaValidation: opts.NoValuesSchemaValidation, NoStandaloneCRDs: opts.NoInstallStandaloneCRDs, Remote: true, TemplatesAllowDNS: opts.TemplatesAllowDNS, diff --git a/pkg/chart/chart_render.go b/pkg/chart/chart_render.go index 190a5f16..9da106d3 100644 --- a/pkg/chart/chart_render.go +++ b/pkg/chart/chart_render.go @@ -57,6 +57,7 @@ type RenderChartOptions struct { IgnoreBundleJS bool LocalKubeVersion string NoStandaloneCRDs bool + NoValuesSchemaValidation bool Remote bool SubchartNotes bool TempDirPath string @@ -209,13 +210,13 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s log.Default.Debug(ctx, "Rendering values for chart at %q", chartPath) - renderedValues, err := chartcommonutil.ToRenderValues(loadedChart, overrideValues, chartcommon.ReleaseOptions{ + renderedValues, err := chartcommonutil.ToRenderValuesWithSchemaValidation(loadedChart, overrideValues, chartcommon.ReleaseOptions{ Name: releaseName, Namespace: releaseNamespace, Revision: revision, IsInstall: !isUpgrade, IsUpgrade: isUpgrade, - }, caps) + }, caps, opts.NoValuesSchemaValidation) if err != nil { return nil, fmt.Errorf("build rendered values for chart %q: %w", chartAccessor.Name(), err) } diff --git a/pkg/common/options.go b/pkg/common/options.go index 21b477bf..d2b84c2b 100644 --- a/pkg/common/options.go +++ b/pkg/common/options.go @@ -220,6 +220,8 @@ func (opts *TrackingOptions) ApplyDefaults() { type ResourceValidationOptions struct { // NoResourceValidation Disable resource validation. NoResourceValidation bool `json:"noResourceValidation"` + // NoValuesSchemaValidation disables values validation against json schema. + NoValuesSchemaValidation bool `json:"noValuesSchemaValidation"` // LocalResourceValidation Disable KubeConform resource validation. LocalResourceValidation bool `json:"localResourceValidation"` // ValidationKubeVersion sets specific Kubernetes version and respective schemas to use on resource validation. From 46e98e9ce2cf08cdaf879c6606754b8acb47badc Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Fri, 15 May 2026 13:20:13 +0300 Subject: [PATCH 30/85] fix: pass NoPodLogs to kubedog (#621) Signed-off-by: Ilya Drey --- pkg/action/release_install.go | 2 ++ pkg/action/release_rollback.go | 1 + pkg/action/release_uninstall.go | 1 + pkg/resource/metadata.go | 6 +++++- pkg/resource/resource.go | 7 ++++++- 5 files changed, 15 insertions(+), 2 deletions(-) diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index 32da99f2..93bd6068 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -433,6 +433,7 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re }, clientFactory, resource.BuildResourcesOptions{ Remote: true, DefaultDeletePropagation: metav1.DeletionPropagation(opts.DefaultDeletePropagation), + NoPodLogs: opts.NoPodLogs, }) if err != nil { return fmt.Errorf("build resources: %w", err) @@ -848,6 +849,7 @@ func runRollbackPlan(ctx context.Context, releaseName, releaseNamespace string, }, clientFactory, resource.BuildResourcesOptions{ Remote: true, DefaultDeletePropagation: metav1.DeletionPropagation(opts.DefaultDeletePropagation), + NoPodLogs: opts.NoPodLogs, }) if err != nil { return nil, nonCritErrs, critErrs.Add(fmt.Errorf("build resources: %w", err)) diff --git a/pkg/action/release_rollback.go b/pkg/action/release_rollback.go index b5f351f2..b9ecd6a3 100644 --- a/pkg/action/release_rollback.go +++ b/pkg/action/release_rollback.go @@ -265,6 +265,7 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r instResources, delResources, err := resource.BuildResources(ctx, deployType, releaseNamespace, prevRelResSpecs, newRelResSpecs, patchers, clientFactory, resource.BuildResourcesOptions{ Remote: true, DefaultDeletePropagation: metav1.DeletionPropagation(opts.DefaultDeletePropagation), + NoPodLogs: opts.NoPodLogs, }) if err != nil { return fmt.Errorf("build resources: %w", err) diff --git a/pkg/action/release_uninstall.go b/pkg/action/release_uninstall.go index 61a64ffe..d886c2ec 100644 --- a/pkg/action/release_uninstall.go +++ b/pkg/action/release_uninstall.go @@ -223,6 +223,7 @@ func releaseUninstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, instResources, delResources, err := resource.BuildResources(ctx, deployType, releaseNamespace, prevRelResSpecs, nil, patchers, clientFactory, resource.BuildResourcesOptions{ Remote: true, DefaultDeletePropagation: metav1.DeletionPropagation(opts.DefaultDeletePropagation), + NoPodLogs: opts.NoPodLogs, }) if err != nil { return fmt.Errorf("build resources: %w", err) diff --git a/pkg/resource/metadata.go b/pkg/resource/metadata.go index 02c1bf7d..a508b5b1 100644 --- a/pkg/resource/metadata.go +++ b/pkg/resource/metadata.go @@ -682,7 +682,11 @@ func skipLogRegexesForContainers(meta *spec.ResourceMeta) map[string]*regexp.Reg return regexByContainer } -func skipLogs(meta *spec.ResourceMeta) bool { +func skipLogs(meta *spec.ResourceMeta, noPodLogs bool) bool { + if noPodLogs { + return true + } + _, value, found := spec.FindAnnotationOrLabelByKeyPattern(meta.Annotations, common.AnnotationKeyPatternSkipLogs) if !found { return false diff --git a/pkg/resource/resource.go b/pkg/resource/resource.go index 06d40955..7c6bf9be 100644 --- a/pkg/resource/resource.go +++ b/pkg/resource/resource.go @@ -133,7 +133,7 @@ func NewInstallableResource(res *spec.ResourceSpec, releaseNamespace string, cli ShowLogsOnlyForContainers: showLogsOnlyForContainers(res.ResourceMeta), ShowLogsOnlyForNumberOfReplicas: showLogsOnlyForNumberOfReplicas(res.ResourceMeta), ShowServiceMessages: showServiceMessages(res.ResourceMeta), - SkipLogs: skipLogs(res.ResourceMeta), + SkipLogs: skipLogs(res.ResourceMeta, opts.NoPodLogs), SkipLogsForContainers: skipLogsForContainers(res.ResourceMeta), SkipLogsRegex: skipLogRegex(res.ResourceMeta), SkipLogsRegexForContainers: skipLogRegexesForContainers(res.ResourceMeta), @@ -144,6 +144,7 @@ func NewInstallableResource(res *spec.ResourceSpec, releaseNamespace string, cli type InstallableResourceOptions struct { DefaultDeletePropagation metav1.DeletionPropagation + NoPodLogs bool Remote bool } @@ -209,6 +210,7 @@ type DeletableResourceOptions struct { type BuildResourcesOptions struct { DefaultDeletePropagation metav1.DeletionPropagation + NoPodLogs bool Remote bool } @@ -229,6 +231,7 @@ func BuildResources(ctx context.Context, deployType common.DeployType, releaseNa for _, resSpec := range prevRelResSpecs { installableResource, err := NewInstallableResource(resSpec, releaseNamespace, clientFactory, InstallableResourceOptions{ DefaultDeletePropagation: opts.DefaultDeletePropagation, + NoPodLogs: opts.NoPodLogs, Remote: opts.Remote, }) if err != nil { @@ -242,6 +245,7 @@ func BuildResources(ctx context.Context, deployType common.DeployType, releaseNa for _, resSpec := range newRelResSpecs { installableResource, err := NewInstallableResource(resSpec, releaseNamespace, clientFactory, InstallableResourceOptions{ DefaultDeletePropagation: opts.DefaultDeletePropagation, + NoPodLogs: opts.NoPodLogs, Remote: opts.Remote, }) if err != nil { @@ -338,6 +342,7 @@ func BuildResources(ctx context.Context, deployType common.DeployType, releaseNa instRes, err = NewInstallableResource(resSpec, releaseNamespace, clientFactory, InstallableResourceOptions{ DefaultDeletePropagation: opts.DefaultDeletePropagation, + NoPodLogs: opts.NoPodLogs, Remote: opts.Remote, }) if err != nil { From 4a0b1e832649640fd1546e1675364eff98e1263a Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Mon, 18 May 2026 12:18:38 +0300 Subject: [PATCH 31/85] feat: set default delete propagation policy to backgound (#623) Signed-off-by: Ilya Drey --- pkg/common/common.go | 19 +++++++++---------- pkg/resource/resource_test.go | 11 ++++++----- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/pkg/common/common.go b/pkg/common/common.go index a5ee070a..d417f5f1 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -95,16 +95,15 @@ const ( DefaultBurstLimit = 100 // TODO(major): switch to if-possible DefaultChartProvenanceStrategy = "never" - // TODO(major): reconsider? - DefaultDeletePropagation = metav1.DeletePropagationForeground - DefaultDiffContextLines = 3 - DefaultFieldManager = "helm" - DefaultLocalKubeVersion = "1.36.0" - DefaultLogColorMode = log.LogColorModeAuto - DefaultNetworkParallelism = 30 - DefaultProgressPrintInterval = 5 * time.Second - DefaultQPSLimit = 30 - DefaultReleaseHistoryLimit = 10 + DefaultDeletePropagation = metav1.DeletePropagationBackground + DefaultDiffContextLines = 3 + DefaultFieldManager = "helm" + DefaultLocalKubeVersion = "1.36.0" + DefaultLogColorMode = log.LogColorModeAuto + DefaultNetworkParallelism = 30 + DefaultProgressPrintInterval = 5 * time.Second + DefaultQPSLimit = 30 + DefaultReleaseHistoryLimit = 10 // DefaultResourceValidationKubeVersion Kubernetes version to use during resource validation by kubeconform DefaultResourceValidationKubeVersion = "1.35.0" KubectlEditFieldManager = "kubectl-edit" diff --git a/pkg/resource/resource_test.go b/pkg/resource/resource_test.go index 3958352c..0282edf2 100644 --- a/pkg/resource/resource_test.go +++ b/pkg/resource/resource_test.go @@ -389,9 +389,10 @@ func (s *InstallableResourceSuite) TestNewInstallableResourceForDependencies() { }, input: func() *spec.ResourceSpec { resSpec := defaultResourceSpec(s.releaseNamespace) - resSpec.SetAnnotations(lo.Assign(resSpec.Annotations, map[string]string{ - "werf.io/deploy-dependency-backend": "state=present,name=backend", - }, + resSpec.SetAnnotations(lo.Assign( + resSpec.Annotations, map[string]string{ + "werf.io/deploy-dependency-backend": "state=present,name=backend", + }, )) return resSpec @@ -1184,7 +1185,7 @@ func defaultDeletableResource(resMeta *spec.ResourceMeta) *resource.DeletableRes return &resource.DeletableResource{ ResourceMeta: resMeta, Ownership: common.OwnershipRelease, - DeletePropagation: metav1.DeletePropagationForeground, + DeletePropagation: metav1.DeletePropagationBackground, } } @@ -1217,7 +1218,7 @@ func defaultInstallableResource(resSpec *spec.ResourceSpec) *resource.Installabl common.InstallOnUpgrade: {common.StageInstall}, common.InstallOnRollback: {common.StageInstall}, }, - DeletePropagation: metav1.DeletePropagationForeground, + DeletePropagation: metav1.DeletePropagationBackground, } } From 581acf90db86251e22fad8e9e8e933ce89f83336 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Mon, 18 May 2026 16:45:07 +0300 Subject: [PATCH 32/85] feat: support external dependencies for deploy-dependency and delete-dependency annotations (#617) Signed-off-by: Dmitry Mordvinov --- pkg/action/chart_lint.go | 2 +- pkg/action/release_install.go | 4 +- pkg/action/release_plan_install.go | 2 +- pkg/action/release_rollback.go | 2 +- pkg/action/release_uninstall.go | 2 +- pkg/common/common.go | 30 +- pkg/plan/plan_build.go | 422 ++++++++++++++------ pkg/plan/plan_build_ai_test.go | 16 +- pkg/plan/plan_build_test.go | 9 +- pkg/resource/dependency.go | 106 ++--- pkg/resource/metadata.go | 613 +++++++++++++++++------------ pkg/resource/resource.go | 118 +++--- pkg/resource/resource_test.go | 24 +- pkg/test/comparer.go | 2 +- 14 files changed, 842 insertions(+), 510 deletions(-) diff --git a/pkg/action/chart_lint.go b/pkg/action/chart_lint.go index 38650a6a..39b21125 100644 --- a/pkg/action/chart_lint.go +++ b/pkg/action/chart_lint.go @@ -377,7 +377,7 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { log.Default.Debug(ctx, "Build install plan") - if _, err := plan.BuildPlan(instResInfos, delResInfos, relInfos, plan.BuildPlanOptions{ + if _, err := plan.BuildPlan(ctx, instResInfos, delResInfos, relInfos, opts.ReleaseNamespace, plan.BuildPlanOptions{ NoFinalTracking: opts.NoFinalTracking, }); err != nil { return fmt.Errorf("build install plan: %w", err) diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index 93bd6068..60e09db8 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -485,7 +485,7 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re log.Default.Debug(ctx, "Build install plan") - installPlan, err = plan.BuildPlan(instResInfos, delResInfos, relInfos, plan.BuildPlanOptions{ + installPlan, err = plan.BuildPlan(ctx, instResInfos, delResInfos, relInfos, releaseNamespace, plan.BuildPlanOptions{ NoFinalTracking: opts.NoFinalTracking, }) if err != nil { @@ -896,7 +896,7 @@ func runRollbackPlan(ctx context.Context, releaseName, releaseNamespace string, log.Default.Debug(ctx, "Build rollback plan") - rollbackPlan, err := plan.BuildPlan(instResInfos, delResInfos, relInfos, plan.BuildPlanOptions{ + rollbackPlan, err := plan.BuildPlan(ctx, instResInfos, delResInfos, relInfos, releaseNamespace, plan.BuildPlanOptions{ NoFinalTracking: opts.NoFinalTracking, }) if err != nil { diff --git a/pkg/action/release_plan_install.go b/pkg/action/release_plan_install.go index 87c76489..07a45ac2 100644 --- a/pkg/action/release_plan_install.go +++ b/pkg/action/release_plan_install.go @@ -388,7 +388,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc log.Default.Debug(ctx, "Build install plan") - installPlan, err := plan.BuildPlan(instResInfos, delResInfos, relInfos, plan.BuildPlanOptions{ + installPlan, err := plan.BuildPlan(ctx, instResInfos, delResInfos, relInfos, releaseNamespace, plan.BuildPlanOptions{ NoFinalTracking: opts.NoFinalTracking, }) if err != nil { diff --git a/pkg/action/release_rollback.go b/pkg/action/release_rollback.go index b9ecd6a3..00ea8ca6 100644 --- a/pkg/action/release_rollback.go +++ b/pkg/action/release_rollback.go @@ -315,7 +315,7 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r log.Default.Debug(ctx, "Build install plan") - installPlan, err := plan.BuildPlan(instResInfos, delResInfos, relInfos, plan.BuildPlanOptions{ + installPlan, err := plan.BuildPlan(ctx, instResInfos, delResInfos, relInfos, releaseNamespace, plan.BuildPlanOptions{ NoFinalTracking: opts.NoFinalTracking, }) if err != nil { diff --git a/pkg/action/release_uninstall.go b/pkg/action/release_uninstall.go index d886c2ec..f3b7934c 100644 --- a/pkg/action/release_uninstall.go +++ b/pkg/action/release_uninstall.go @@ -249,7 +249,7 @@ func releaseUninstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, log.Default.Debug(ctx, "Build delete plan") - deletePlan, err := plan.BuildPlan(instResInfos, delResInfos, relInfos, plan.BuildPlanOptions{ + deletePlan, err := plan.BuildPlan(ctx, instResInfos, delResInfos, relInfos, releaseNamespace, plan.BuildPlanOptions{ NoFinalTracking: opts.NoFinalTracking, }) if err != nil { diff --git a/pkg/common/common.go b/pkg/common/common.go index d417f5f1..8f2e8d28 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -29,6 +29,10 @@ const ( // updating it. DeletePolicyBeforeCreationIfImmutable DeletePolicy = "before-creation-if-immutable" + DependencyExternalAuto DependencyExternal = "auto" + DependencyExternalFalse DependencyExternal = "false" + DependencyExternalTrue DependencyExternal = "true" + // Installing revision number 1 of the release always considered "Initial". DeployTypeInitial DeployType = "Initial" // Revision number > 1 with no successful revisions between revision 1 and the last revision @@ -94,16 +98,19 @@ const ( ChartTSSourceDir = "ts/" DefaultBurstLimit = 100 // TODO(major): switch to if-possible - DefaultChartProvenanceStrategy = "never" - DefaultDeletePropagation = metav1.DeletePropagationBackground - DefaultDiffContextLines = 3 - DefaultFieldManager = "helm" - DefaultLocalKubeVersion = "1.36.0" - DefaultLogColorMode = log.LogColorModeAuto - DefaultNetworkParallelism = 30 - DefaultProgressPrintInterval = 5 * time.Second - DefaultQPSLimit = 30 - DefaultReleaseHistoryLimit = 10 + DefaultChartProvenanceStrategy = "never" + DefaultDeletePropagation = metav1.DeletePropagationBackground + DefaultDependencyExternal = DependencyExternalAuto + DefaultDiffContextLines = 3 + DefaultExternalDependencyMaxMatches = 30 + DefaultExternalDependencyMinMatches = 1 + DefaultFieldManager = "helm" + DefaultLocalKubeVersion = "1.36.0" + DefaultLogColorMode = log.LogColorModeAuto + DefaultNetworkParallelism = 30 + DefaultProgressPrintInterval = 5 * time.Second + DefaultQPSLimit = 30 + DefaultReleaseHistoryLimit = 10 // DefaultResourceValidationKubeVersion Kubernetes version to use during resource validation by kubeconform DefaultResourceValidationKubeVersion = "1.35.0" KubectlEditFieldManager = "kubectl-edit" @@ -234,6 +241,9 @@ var ( // Type of the current operation. type DeployType string +// External dependency mode. +type DependencyExternal string + // Configures resource deletions during deployment of this resource. type DeletePolicy string diff --git a/pkg/plan/plan_build.go b/pkg/plan/plan_build.go index e414aab0..308c0387 100644 --- a/pkg/plan/plan_build.go +++ b/pkg/plan/plan_build.go @@ -1,6 +1,8 @@ package plan import ( + "context" + "errors" "fmt" "sort" @@ -10,7 +12,9 @@ import ( "github.com/werf/nelm/pkg/common" helmreleasecommon "github.com/werf/nelm/pkg/helm/pkg/release/common" helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" + "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/resource" + "github.com/werf/nelm/pkg/resource/spec" ) type BuildPlanOptions struct { @@ -21,6 +25,12 @@ type BuildFailurePlanOptions struct { NoFinalTracking bool } +type matchedDeletableResource struct { + *spec.ResourceMeta + + Iteration int +} + // When the main plan fails, the failure plan must be built and executed. func BuildFailurePlan(failedPlan *Plan, installableInfos []*InstallableResourceInfo, releaseInfos []*ReleaseInfo, opts BuildFailurePlanOptions) (*Plan, error) { plan := NewPlan() @@ -49,7 +59,7 @@ func BuildFailurePlan(failedPlan *Plan, installableInfos []*InstallableResourceI // different kinds of plans must be figured out earlier, e.g. at BuildResourceInfos level. This // generic design must be preserved. Keep it simple: if something can be done on earlier stages, do // it there. -func BuildPlan(installableInfos []*InstallableResourceInfo, deletableInfos []*DeletableResourceInfo, releaseInfos []*ReleaseInfo, opts BuildPlanOptions) (*Plan, error) { +func BuildPlan(ctx context.Context, installableInfos []*InstallableResourceInfo, deletableInfos []*DeletableResourceInfo, releaseInfos []*ReleaseInfo, releaseNamespace string, opts BuildPlanOptions) (*Plan, error) { plan := NewPlan() if err := addMainStages(plan); err != nil { @@ -72,11 +82,11 @@ func BuildPlan(installableInfos []*InstallableResourceInfo, deletableInfos []*De return plan, fmt.Errorf("add install resource operations: %w", err) } - if err := connectInternalDeployDependencies(plan, installableInfos, deletableInfos); err != nil { + if err := connectInternalDeployDependencies(ctx, plan, installableInfos, deletableInfos, releaseNamespace); err != nil { return plan, fmt.Errorf("connect internal dependencies: %w", err) } - if err := connectInternalDeleteDependencies(plan, deletableInfos, installableInfos); err != nil { + if err := connectInternalDeleteDependencies(ctx, plan, deletableInfos, installableInfos, releaseNamespace); err != nil { return plan, fmt.Errorf("connect internal delete dependencies: %w", err) } @@ -87,9 +97,49 @@ func BuildPlan(installableInfos []*InstallableResourceInfo, deletableInfos []*De return plan, nil } -func connectInternalDeployDependencies(plan *Plan, instInfos []*InstallableResourceInfo, delInfos []*DeletableResourceInfo) error { +func connectInternalDeleteDependencies(ctx context.Context, plan *Plan, delInfos []*DeletableResourceInfo, instInfos []*InstallableResourceInfo, releaseNamespace string) error { + for _, info := range delInfos { + internalDeps := lo.Union(info.LocalResource.AutoInternalDependencies, info.LocalResource.ManualDependencies) + if len(internalDeps) == 0 { + continue + } + + deleteOp, found := getDeleteOp(plan, info) + if !found { + continue + } + + for _, dep := range internalDeps { + var ( + dependUponOps []*Operation + err error + ) + + switch dep.ResourceState { + case common.ResourceStateAbsent: + dependUponOps, err = resolveTrackAbsenceOpInStage(ctx, plan, delInfos, instInfos, dep, info.Stage, releaseNamespace) + default: + panic("unexpected internal dependency resource state") + } + + if err != nil { + return fmt.Errorf("find internal dependency ops: %w", err) + } + + for _, dependUponOp := range dependUponOps { + if err := plan.Connect(dependUponOp.ID(), deleteOp.ID()); err != nil { + return fmt.Errorf("depend %q from %q: %w", deleteOp.ID(), dependUponOp.ID(), err) + } + } + } + } + + return nil +} + +func connectInternalDeployDependencies(ctx context.Context, plan *Plan, instInfos []*InstallableResourceInfo, delInfos []*DeletableResourceInfo, releaseNamespace string) error { for _, info := range instInfos { - internalDeps := lo.Union(info.LocalResource.AutoInternalDependencies, info.LocalResource.ManualInternalDependencies) + internalDeps := lo.Union(info.LocalResource.AutoInternalDependencies, info.LocalResource.ManualDependencies) if len(internalDeps) == 0 { continue } @@ -100,19 +150,26 @@ func connectInternalDeployDependencies(plan *Plan, instInfos []*InstallableResou } for _, dep := range internalDeps { - var dependUponOps []*Operation + var ( + dependUponOps []*Operation + err error + ) switch dep.ResourceState { case common.ResourceStatePresent: - dependUponOps = findDeployOpInStage(plan, instInfos, dep, info.Stage) + dependUponOps, err = resolveDeployOpInStage(ctx, plan, instInfos, dep, info.Stage, releaseNamespace) case common.ResourceStateReady: - dependUponOps = findTrackReadinessOpInStage(plan, instInfos, dep, info.Stage) + dependUponOps, err = resolveTrackReadinessOpInStage(ctx, plan, instInfos, dep, info.Stage, releaseNamespace) case common.ResourceStateAbsent: - dependUponOps = findTrackAbsenceOpInStage(plan, delInfos, instInfos, dep, info.Stage) + dependUponOps, err = resolveTrackAbsenceOpInStage(ctx, plan, delInfos, instInfos, dep, info.Stage, releaseNamespace) default: panic("unexpected internal dependency resource state") } + if err != nil { + return fmt.Errorf("find internal dependency ops: %w", err) + } + for _, dependUponOp := range dependUponOps { if err := plan.Connect(dependUponOp.ID(), deployOp.ID()); err != nil { return fmt.Errorf("depend %q from %q: %w", deployOp.ID(), dependUponOp.ID(), err) @@ -193,45 +250,202 @@ func addReleaseOperations(plan *Plan, releaseInfos []*ReleaseInfo) error { return nil } -func connectInternalDeleteDependencies(plan *Plan, delInfos []*DeletableResourceInfo, instInfos []*InstallableResourceInfo) error { - for _, info := range delInfos { - internalDeps := lo.Union(info.LocalResource.AutoInternalDependencies, info.LocalResource.ManualInternalDependencies) - if len(internalDeps) == 0 { +func resolveDeployOpInStage(ctx context.Context, plan *Plan, instInfos []*InstallableResourceInfo, dep *resource.Dependency, sourceStage common.Stage, releaseNamespace string) ([]*Operation, error) { + if dep.External { + resMeta := resource.NewResourceMetaFromDependency(dep, releaseNamespace) + + opID := OperationID(OperationTypeTrackPresence, OperationVersionTrackPresence, OperationIteration(0), resMeta.ID()) + if op, found := plan.Operation(opID); found { + return []*Operation{op}, nil + } + + trackOp := &Operation{ + Type: OperationTypeTrackPresence, + Version: OperationVersionTrackPresence, + Category: OperationCategoryTrack, + Config: &OperationConfigTrackPresence{ + ResourceMeta: resMeta, + }, + } + + if err := plan.AddOperationChain().AddOperation(trackOp).Stage(sourceStage).SkipOnDuplicate().Do(); err != nil { + return nil, fmt.Errorf("add track presence operation: %w", err) + } + + return []*Operation{trackOp}, nil + } + + matchByID := make(map[string]*InstallableResourceInfo) + for _, candidate := range instInfos { + match, found := matchByID[candidate.ID()] + if candidate.Stage != sourceStage || + !dep.Match(candidate.ResourceMeta) || + (found && candidate.Iteration >= match.Iteration) { continue } - deleteOp, found := getDeleteOp(plan, info) - if !found { + matchByID[candidate.ID()] = candidate + } + + matched, err := truncateInstallableMatches(ctx, lo.Values(matchByID), dep) + if err != nil { + return nil, err + } + + if len(matched) == 0 { + return nil, nil + } + + var foundOps []*Operation + for _, match := range matched { + if op, found := getDeployOp(plan, match); found { + foundOps = append(foundOps, op) + } else { + trackOp := &Operation{ + Type: OperationTypeTrackPresence, + Version: OperationVersionTrackPresence, + Category: OperationCategoryTrack, + Config: &OperationConfigTrackPresence{ + ResourceMeta: match.ResourceMeta, + }, + } + + if err := plan.AddOperationChain().AddOperation(trackOp).Stage(sourceStage).SkipOnDuplicate().Do(); err != nil { + return nil, fmt.Errorf("add track presence operation: %w", err) + } + + foundOps = append(foundOps, trackOp) + } + } + + return foundOps, nil +} + +func resolveTrackAbsenceOpInStage(ctx context.Context, plan *Plan, delInfos []*DeletableResourceInfo, instInfos []*InstallableResourceInfo, dep *resource.Dependency, sourceStage common.Stage, releaseNamespace string) ([]*Operation, error) { + if dep.External { + resMeta := resource.NewResourceMetaFromDependency(dep, releaseNamespace) + + opID := OperationID(OperationTypeTrackAbsence, OperationVersionTrackAbsence, OperationIteration(0), resMeta.ID()) + if op, found := plan.Operation(opID); found { + return []*Operation{op}, nil + } + + trackOp := &Operation{ + Type: OperationTypeTrackAbsence, + Version: OperationVersionTrackAbsence, + Category: OperationCategoryTrack, + Config: &OperationConfigTrackAbsence{ + ResourceMeta: resMeta, + }, + } + + if err := plan.AddOperationChain().AddOperation(trackOp).Stage(sourceStage).SkipOnDuplicate().Do(); err != nil { + return nil, fmt.Errorf("add track absence operation: %w", err) + } + + return []*Operation{trackOp}, nil + } + + matchByID := make(map[string]*matchedDeletableResource) + for _, candidate := range delInfos { + if candidate.Stage != sourceStage || + !dep.Match(candidate.ResourceMeta) { continue } - for _, dep := range internalDeps { - var dependUponOps []*Operation + matchByID[candidate.ID()] = &matchedDeletableResource{ + ResourceMeta: candidate.ResourceMeta, + Iteration: 0, + } + } - switch dep.ResourceState { - case common.ResourceStateAbsent: - dependUponOps = findTrackAbsenceOpInStage(plan, delInfos, instInfos, dep, info.Stage) - default: - panic("unexpected internal dependency resource state") + for _, candidate := range instInfos { + match, found := matchByID[candidate.ID()] + if !candidate.MustDeleteOnSuccessfulInstall || + candidate.StageDeleteOnSuccessfulInstall != sourceStage || + !dep.Match(candidate.ResourceMeta) || + (found && candidate.Iteration >= match.Iteration) { + continue + } + + matchByID[candidate.ID()] = &matchedDeletableResource{ + ResourceMeta: candidate.ResourceMeta, + Iteration: candidate.Iteration, + } + } + + matched, err := truncateDeletableMatches(ctx, lo.Values(matchByID), dep) + if err != nil { + return nil, err + } + + if len(matched) == 0 { + return nil, nil + } + + var foundOps []*Operation + for _, match := range matched { + opID := OperationID(OperationTypeTrackAbsence, OperationVersionTrackAbsence, OperationIteration(match.Iteration), match.ID()) + + if op, found := plan.Operation(opID); found { + foundOps = append(foundOps, op) + } else { + trackOp := &Operation{ + Type: OperationTypeTrackAbsence, + Version: OperationVersionTrackAbsence, + Category: OperationCategoryTrack, + Config: &OperationConfigTrackAbsence{ + ResourceMeta: match.ResourceMeta, + }, } - for _, dependUponOp := range dependUponOps { - if err := plan.Connect(dependUponOp.ID(), deleteOp.ID()); err != nil { - return fmt.Errorf("depend %q from %q: %w", deleteOp.ID(), dependUponOp.ID(), err) + if err := plan.AddOperationChain().AddOperation(trackOp).Stage(sourceStage).SkipOnDuplicate().Do(); err != nil { + return nil, fmt.Errorf("add track absence operation: %w", err) + } + + deleteOpID := OperationID(OperationTypeDelete, OperationVersionDelete, OperationIteration(match.Iteration), match.ID()) + if deleteOp, deleteFound := plan.Operation(deleteOpID); deleteFound { + if err := plan.Connect(deleteOp.ID(), trackOp.ID()); err != nil { + return nil, fmt.Errorf("connect delete to track absence: %w", err) } } + + foundOps = append(foundOps, trackOp) } } - return nil + return foundOps, nil } -func findDeployOpInStage(plan *Plan, instInfos []*InstallableResourceInfo, dep *resource.InternalDependency, sourceStage common.Stage) []*Operation { +func resolveTrackReadinessOpInStage(ctx context.Context, plan *Plan, instInfos []*InstallableResourceInfo, dep *resource.Dependency, sourceStage common.Stage, releaseNamespace string) ([]*Operation, error) { + if dep.External { + resMeta := resource.NewResourceMetaFromDependency(dep, releaseNamespace) + + opID := OperationID(OperationTypeTrackReadiness, OperationVersionTrackReadiness, OperationIteration(0), resMeta.ID()) + if op, found := plan.Operation(opID); found { + return []*Operation{op}, nil + } + + trackOp := &Operation{ + Type: OperationTypeTrackReadiness, + Version: OperationVersionTrackReadiness, + Category: OperationCategoryTrack, + Config: &OperationConfigTrackReadiness{ + ResourceMeta: resMeta, + }, + } + + if err := plan.AddOperationChain().AddOperation(trackOp).Stage(sourceStage).SkipOnDuplicate().Do(); err != nil { + return nil, fmt.Errorf("add track readiness operation: %w", err) + } + + return []*Operation{trackOp}, nil + } + matchByID := make(map[string]*InstallableResourceInfo) for _, candidate := range instInfos { match, found := matchByID[candidate.ID()] - if candidate.MustInstall == ResourceInstallTypeNone || - candidate.Stage != sourceStage || + if candidate.Stage != sourceStage || !dep.Match(candidate.ResourceMeta) || (found && candidate.Iteration >= match.Iteration) { continue @@ -240,18 +454,45 @@ func findDeployOpInStage(plan *Plan, instInfos []*InstallableResourceInfo, dep * matchByID[candidate.ID()] = candidate } - if len(matchByID) == 0 { - return nil + matched, err := truncateInstallableMatches(ctx, lo.Values(matchByID), dep) + if err != nil { + return nil, err + } + + if len(matched) == 0 { + return nil, nil } var foundOps []*Operation - for _, match := range matchByID { - if op, found := getDeployOp(plan, match); found { + for _, match := range matched { + opID := OperationID(OperationTypeTrackReadiness, OperationVersionTrackReadiness, OperationIteration(match.Iteration), match.ID()) + if op, found := plan.Operation(opID); found { foundOps = append(foundOps, op) + } else { + trackOp := &Operation{ + Type: OperationTypeTrackReadiness, + Version: OperationVersionTrackReadiness, + Category: OperationCategoryTrack, + Config: &OperationConfigTrackReadiness{ + ResourceMeta: match.ResourceMeta, + }, + } + + if err := plan.AddOperationChain().AddOperation(trackOp).Stage(sourceStage).SkipOnDuplicate().Do(); err != nil { + return nil, fmt.Errorf("add track readiness operation: %w", err) + } + + if deployOp, deployFound := getDeployOp(plan, match); deployFound { + if err := plan.Connect(deployOp.ID(), trackOp.ID()); err != nil { + return nil, fmt.Errorf("connect deploy to track readiness: %w", err) + } + } + + foundOps = append(foundOps, trackOp) } } - return foundOps + return foundOps, nil } func addDeleteReleaseOps(plan *Plan, info *ReleaseInfo) { @@ -708,83 +949,6 @@ func addWeightedSubStages(plan *Plan, infos []*InstallableResourceInfo) error { return nil } -func findTrackAbsenceOpInStage(plan *Plan, delInfos []*DeletableResourceInfo, instInfos []*InstallableResourceInfo, dep *resource.InternalDependency, sourceStage common.Stage) []*Operation { - var foundOps []*Operation - for _, candidate := range delInfos { - if !candidate.MustTrackAbsence || - candidate.Stage != sourceStage || - !dep.Match(candidate.ResourceMeta) { - continue - } - - opID := OperationID(OperationTypeTrackAbsence, OperationVersionTrackAbsence, 0, candidate.ID()) - - if op, found := plan.Operation(opID); found { - foundOps = append(foundOps, op) - } - } - - if len(foundOps) > 0 { - return foundOps - } - - matchByID := make(map[string]*InstallableResourceInfo) - for _, candidate := range instInfos { - match, found := matchByID[candidate.ID()] - if !candidate.MustDeleteOnSuccessfulInstall || - candidate.StageDeleteOnSuccessfulInstall != sourceStage || - !dep.Match(candidate.ResourceMeta) || - (found && candidate.Iteration >= match.Iteration) { - continue - } - - matchByID[candidate.ID()] = candidate - } - - if len(matchByID) == 0 { - return nil - } - - for _, match := range matchByID { - opID := OperationID(OperationTypeTrackAbsence, OperationVersionTrackAbsence, OperationIteration(match.Iteration), match.ID()) - - if op, found := plan.Operation(opID); found { - foundOps = append(foundOps, op) - } - } - - return foundOps -} - -func findTrackReadinessOpInStage(plan *Plan, instInfos []*InstallableResourceInfo, dep *resource.InternalDependency, sourceStage common.Stage) []*Operation { - matchByID := make(map[string]*InstallableResourceInfo) - for _, candidate := range instInfos { - match, found := matchByID[candidate.ID()] - if !candidate.MustTrackReadiness || - candidate.Stage != sourceStage || - !dep.Match(candidate.ResourceMeta) || - (found && candidate.Iteration >= match.Iteration) { - continue - } - - matchByID[candidate.ID()] = candidate - } - - if len(matchByID) == 0 { - return nil - } - - var foundOps []*Operation - for _, match := range matchByID { - opID := OperationID(OperationTypeTrackReadiness, OperationVersionTrackReadiness, OperationIteration(match.Iteration), match.ID()) - if op, found := plan.Operation(opID); found { - foundOps = append(foundOps, op) - } - } - - return foundOps -} - func getDeleteOp(plan *Plan, info *DeletableResourceInfo) (*Operation, bool) { if !info.MustDelete { return nil, false @@ -814,3 +978,39 @@ func getDeployOp(plan *Plan, info *InstallableResourceInfo) (op *Operation, foun return lo.Must(plan.Operation(deployOpID)), true } + +func truncateDeletableMatches(ctx context.Context, matched []*matchedDeletableResource, dep *resource.Dependency) ([]*matchedDeletableResource, error) { + if len(matched) < dep.MinMatches { + return nil, errors.New("matched resources count is less than minimum required") + } + + if dep.MaxMatches > 0 && len(matched) > dep.MaxMatches { + log.Default.Warn(ctx, "Dependency matched %d resources, but maxMatches is %d, only first %d will be used", len(matched), dep.MaxMatches, dep.MaxMatches) + + sort.SliceStable(matched, func(i, j int) bool { + return spec.ResourceMetaSortHandler(matched[i].ResourceMeta, matched[j].ResourceMeta) + }) + + matched = matched[:dep.MaxMatches] + } + + return matched, nil +} + +func truncateInstallableMatches(ctx context.Context, matched []*InstallableResourceInfo, dep *resource.Dependency) ([]*InstallableResourceInfo, error) { + if len(matched) < dep.MinMatches { + return nil, errors.New("matched resources count is less than minimum required") + } + + if dep.MaxMatches > 0 && len(matched) > dep.MaxMatches { + log.Default.Warn(ctx, "Dependency matched %d resources, but maxMatches is %d, only first %d will be used", len(matched), dep.MaxMatches, dep.MaxMatches) + + sort.SliceStable(matched, func(i, j int) bool { + return spec.ResourceMetaSortHandler(matched[i].ResourceMeta, matched[j].ResourceMeta) + }) + + matched = matched[:dep.MaxMatches] + } + + return matched, nil +} diff --git a/pkg/plan/plan_build_ai_test.go b/pkg/plan/plan_build_ai_test.go index 48ee21a7..6680a536 100644 --- a/pkg/plan/plan_build_ai_test.go +++ b/pkg/plan/plan_build_ai_test.go @@ -112,7 +112,7 @@ func (s *BuildPlanAISuite) TestAI_BuildPlanConnectsAllMatchingDependencies() { dependentRes.Unstruct.SetKind("Secret") dependentRes.GroupVersionKind.Kind = "Secret" dependentRes.Weight = nil - dependentRes.AutoInternalDependencies = []*resource.InternalDependency{ + dependentRes.AutoInternalDependencies = []*resource.Dependency{ { ResourceMatcher: &spec.ResourceMatcher{ Kinds: []string{"ConfigMap"}, @@ -273,7 +273,7 @@ func (s *BuildPlanAISuite) TestAI_BuildPlanConnectsAllMatchingDependencies() { dependentRes.Unstruct.SetKind("Secret") dependentRes.GroupVersionKind.Kind = "Secret" dependentRes.Weight = nil - dependentRes.AutoInternalDependencies = []*resource.InternalDependency{ + dependentRes.AutoInternalDependencies = []*resource.Dependency{ { ResourceMatcher: &spec.ResourceMatcher{ Kinds: []string{"ConfigMap"}, @@ -412,7 +412,7 @@ func (s *BuildPlanAISuite) TestAI_BuildPlanConnectsAllMatchingDependencies() { dependentRes.Unstruct.SetKind("Secret") dependentRes.GroupVersionKind.Kind = "Secret" dependentRes.Weight = nil - dependentRes.AutoInternalDependencies = []*resource.InternalDependency{ + dependentRes.AutoInternalDependencies = []*resource.Dependency{ { ResourceMatcher: &spec.ResourceMatcher{ Kinds: []string{"ConfigMap"}, @@ -554,7 +554,7 @@ func (s *BuildPlanAISuite) TestAI_BuildPlanConnectsAllMatchingDependencies() { dependentDelRes := defaultDeletableResource(s.releaseName, s.releaseNamespace) dependentDelRes.Name = "dependent-secret" dependentDelRes.GroupVersionKind.Kind = "Secret" - dependentDelRes.AutoInternalDependencies = []*resource.InternalDependency{ + dependentDelRes.AutoInternalDependencies = []*resource.Dependency{ { ResourceMatcher: &spec.ResourceMatcher{ Kinds: []string{"ConfigMap"}, @@ -655,7 +655,7 @@ func (s *BuildPlanAISuite) TestAI_BuildPlanConnectsOnlySpecificMatchingDependenc dependentRes.Unstruct.SetKind("Secret") dependentRes.GroupVersionKind.Kind = "Secret" dependentRes.Weight = nil - dependentRes.AutoInternalDependencies = []*resource.InternalDependency{{ + dependentRes.AutoInternalDependencies = []*resource.Dependency{{ ResourceMatcher: &spec.ResourceMatcher{Names: []string{"test-configmap-1", "test-configmap-2"}, Kinds: []string{"ConfigMap"}}, ResourceState: common.ResourceStatePresent, }} @@ -730,7 +730,7 @@ func (s *BuildPlanAISuite) TestAI_BuildPlanConnectsOnlySpecificMatchingDependenc dependentRes.Unstruct.SetKind("Secret") dependentRes.GroupVersionKind.Kind = "Secret" dependentRes.Weight = nil - dependentRes.AutoInternalDependencies = []*resource.InternalDependency{{ + dependentRes.AutoInternalDependencies = []*resource.Dependency{{ ResourceMatcher: &spec.ResourceMatcher{Names: []string{"test-configmap-1", "test-configmap-2"}, Kinds: []string{"ConfigMap"}}, ResourceState: common.ResourceStateReady, }} @@ -797,7 +797,7 @@ func (s *BuildPlanAISuite) TestAI_BuildPlanConnectsOnlySpecificMatchingDependenc dependentRes.Unstruct.SetKind("Secret") dependentRes.GroupVersionKind.Kind = "Secret" dependentRes.Weight = nil - dependentRes.AutoInternalDependencies = []*resource.InternalDependency{{ + dependentRes.AutoInternalDependencies = []*resource.Dependency{{ ResourceMatcher: &spec.ResourceMatcher{Names: []string{"test-configmap-1", "test-configmap-2"}, Kinds: []string{"ConfigMap"}}, ResourceState: common.ResourceStateAbsent, }} @@ -852,7 +852,7 @@ func (s *BuildPlanAISuite) TestAI_BuildPlanConnectsOnlySpecificMatchingDependenc dependentDelRes := defaultDeletableResource(s.releaseName, s.releaseNamespace) dependentDelRes.Name = "dependent-secret" dependentDelRes.GroupVersionKind.Kind = "Secret" - dependentDelRes.AutoInternalDependencies = []*resource.InternalDependency{{ + dependentDelRes.AutoInternalDependencies = []*resource.Dependency{{ ResourceMatcher: &spec.ResourceMatcher{Names: []string{"test-configmap-1", "test-configmap-2"}, Kinds: []string{"ConfigMap"}}, ResourceState: common.ResourceStateAbsent, }} diff --git a/pkg/plan/plan_build_test.go b/pkg/plan/plan_build_test.go index fc8fe24d..06246a3b 100644 --- a/pkg/plan/plan_build_test.go +++ b/pkg/plan/plan_build_test.go @@ -1,6 +1,7 @@ package plan_test import ( + "context" "fmt" "testing" @@ -476,7 +477,7 @@ func (s *BuildPlanSuite) TestBuildPlan() { dependentResSpec := defaultInstallableResource(s.releaseName, s.releaseNamespace) dependentResSpec.Name = "dependent-resource" dependentResSpec.Unstruct.SetName("dependent-resource") - dependentResSpec.AutoInternalDependencies = []*resource.InternalDependency{ + dependentResSpec.AutoInternalDependencies = []*resource.Dependency{ { ResourceMatcher: &spec.ResourceMatcher{ Names: []string{resInfo.Name}, @@ -593,7 +594,7 @@ func (s *BuildPlanSuite) TestBuildPlan() { dependentResSpec.Name = "dependent-resource" dependentResSpec.Unstruct.SetName("dependent-resource") dependentResSpec.Weight = nil - dependentResSpec.ManualInternalDependencies = []*resource.InternalDependency{ + dependentResSpec.ManualDependencies = []*resource.Dependency{ { ResourceMatcher: &spec.ResourceMatcher{ Names: []string{resInfo.Name}, @@ -733,7 +734,7 @@ func (s *BuildPlanSuite) TestBuildPlan() { dependentResSpec.Name = "dependent-resource" dependentResSpec.Unstruct.SetName("dependent-resource") dependentResSpec.Weight = nil - dependentResSpec.ManualInternalDependencies = []*resource.InternalDependency{ + dependentResSpec.ManualDependencies = []*resource.Dependency{ { ResourceMatcher: &spec.ResourceMatcher{ Names: []string{resInfo.Name}, @@ -1430,7 +1431,7 @@ func runBuildPlanTest(tc buildPlanTestCase, s *BuildPlanSuite) func() { instInfos, delInfos, relInfos, opts := tc.input() - plan, err := plan.BuildPlan(instInfos, delInfos, relInfos, opts) + plan, err := plan.BuildPlan(context.Background(), instInfos, delInfos, relInfos, s.releaseNamespace, opts) s.Require().NoError(err) operations := plan.Operations() diff --git a/pkg/resource/dependency.go b/pkg/resource/dependency.go index edd27196..0180761b 100644 --- a/pkg/resource/dependency.go +++ b/pkg/resource/dependency.go @@ -10,25 +10,37 @@ import ( ) // Represents a dependency on a Kubernetes resource in the Helm release. -type InternalDependency struct { +type Dependency struct { *spec.ResourceMatcher `json:"resourceMatcher"` ResourceState common.ResourceState `json:"resourceState"` + External bool `json:"external"` + MinMatches int `json:"minMatches"` + MaxMatches int `json:"maxMatches"` } +// TODO(major): remove with external dependency annotations // Represents a dependency on an external resource outside of the Helm release. type ExternalDependency struct { *spec.ResourceMeta `json:"resourceMeta"` } +func NewResourceMetaFromDependency(dep *Dependency, releaseNamespace string) *spec.ResourceMeta { + return spec.NewResourceMeta(dep.Names[0], lo.FirstOrEmpty(dep.Namespaces), releaseNamespace, "", schema.GroupVersionKind{ + Group: lo.FirstOrEmpty(dep.Groups), + Version: lo.FirstOrEmpty(dep.Versions), + Kind: lo.FirstOrEmpty(dep.Kinds), + }, nil, nil) +} + // Automatically detects internal dependencies on resources by examining specific fields in the // resource spec. As an example, examining "envFrom" in a Pod container spec produces an internal // dependency on a ConfigMap or a Secret. -func internalDeployDependencies(unstruct *unstructured.Unstructured) []*InternalDependency { +func internalDeployDependencies(unstruct *unstructured.Unstructured) []*Dependency { gvk := unstruct.GroupVersionKind() gk := gvk.GroupKind() - var dependencies []*InternalDependency + var dependencies []*Dependency switch gk { case schema.GroupKind{Kind: "Deployment", Group: "apps"}: if pod, found := nestedMap(unstruct.Object, "spec", "template"); found { @@ -89,7 +101,7 @@ func internalDeployDependencies(unstruct *unstructured.Unstructured) []*Internal return dependencies } -func parsePod(unstruct *unstructured.Unstructured, pod interface{}) (dependencies []*InternalDependency, found bool) { +func parsePod(unstruct *unstructured.Unstructured, pod interface{}) (dependencies []*Dependency, found bool) { containers, _ := nestedSlice(pod, "spec", "containers") for _, container := range containers { if deps, found := parseContainer(unstruct, container); found { @@ -155,8 +167,8 @@ func parsePod(unstruct *unstructured.Unstructured, pod interface{}) (dependencie return dependencies, len(dependencies) > 0 } -func internalDeleteDependencies(unstruct *unstructured.Unstructured, otherUnstructs []*unstructured.Unstructured) []*InternalDependency { - var dependencies []*InternalDependency +func internalDeleteDependencies(unstruct *unstructured.Unstructured, otherUnstructs []*unstructured.Unstructured) []*Dependency { + var dependencies []*Dependency switch unstruct.GroupVersionKind().GroupKind() { case schema.GroupKind{Kind: "CustomResourceDefinition", Group: "apiextensions.k8s.io"}: if dep, found := parseCRD(unstruct); found { @@ -183,7 +195,7 @@ func internalDeleteDependencies(unstruct *unstructured.Unstructured, otherUnstru return dependencies } -func parseContainer(unstruct *unstructured.Unstructured, container interface{}) (dependencies []*InternalDependency, found bool) { +func parseContainer(unstruct *unstructured.Unstructured, container interface{}) (dependencies []*Dependency, found bool) { envs, _ := nestedSlice(container, "env") for _, env := range envs { if dep, found := parseConfigMapKeyRef(unstruct, env); found { @@ -205,7 +217,7 @@ func parseContainer(unstruct *unstructured.Unstructured, container interface{}) return dependencies, len(dependencies) > 0 } -func parseCRD(unstruct *unstructured.Unstructured) (dep *InternalDependency, found bool) { +func parseCRD(unstruct *unstructured.Unstructured) (dep *Dependency, found bool) { kind, found := nestedString(unstruct.Object, "spec", "names", "kind") if !found { return nil, false @@ -216,7 +228,7 @@ func parseCRD(unstruct *unstructured.Unstructured) (dep *InternalDependency, fou return nil, false } - dep = &InternalDependency{ + dep = &Dependency{ ResourceMatcher: &spec.ResourceMatcher{ Groups: []string{group}, Kinds: []string{kind}, @@ -227,7 +239,7 @@ func parseCRD(unstruct *unstructured.Unstructured) (dep *InternalDependency, fou return dep, true } -func parseClusterRoleDependencies(unstruct *unstructured.Unstructured, otherUnstructs []*unstructured.Unstructured) (dependencies []*InternalDependency, found bool) { +func parseClusterRoleDependencies(unstruct *unstructured.Unstructured, otherUnstructs []*unstructured.Unstructured) (dependencies []*Dependency, found bool) { bindingUnstructs := lo.Filter(otherUnstructs, func(unstruct *unstructured.Unstructured, _ int) bool { switch unstruct.GroupVersionKind().GroupKind() { case schema.GroupKind{Kind: "RoleBinding", Group: "rbac.authorization.k8s.io"}, @@ -254,7 +266,7 @@ func parseClusterRoleDependencies(unstruct *unstructured.Unstructured, otherUnst namespaces = []string{bindingUnstruct.GetNamespace()} } - dep := &InternalDependency{ + dep := &Dependency{ ResourceMatcher: &spec.ResourceMatcher{ Names: []string{bindingUnstruct.GetName()}, Namespaces: namespaces, @@ -269,7 +281,7 @@ func parseClusterRoleDependencies(unstruct *unstructured.Unstructured, otherUnst return dependencies, len(dependencies) > 0 } -func parseConfigMapKeyRef(unstruct *unstructured.Unstructured, env interface{}) (dep *InternalDependency, found bool) { +func parseConfigMapKeyRef(unstruct *unstructured.Unstructured, env interface{}) (dep *Dependency, found bool) { configMapKeyRef, found := nestedMap(env, "valueFrom", "configMapKeyRef") if !found { return nil, false @@ -285,7 +297,7 @@ func parseConfigMapKeyRef(unstruct *unstructured.Unstructured, env interface{}) return nil, false } - dep = &InternalDependency{ + dep = &Dependency{ ResourceMatcher: &spec.ResourceMatcher{ Names: []string{name}, Namespaces: []string{unstruct.GetNamespace()}, @@ -298,7 +310,7 @@ func parseConfigMapKeyRef(unstruct *unstructured.Unstructured, env interface{}) return dep, true } -func parseConfigMapRef(unstruct *unstructured.Unstructured, env interface{}) (dep *InternalDependency, found bool) { +func parseConfigMapRef(unstruct *unstructured.Unstructured, env interface{}) (dep *Dependency, found bool) { configMapKeyRef, found := nestedMap(env, "valueFrom", "configMapRef") if !found { return nil, false @@ -314,7 +326,7 @@ func parseConfigMapRef(unstruct *unstructured.Unstructured, env interface{}) (de return nil, false } - dep = &InternalDependency{ + dep = &Dependency{ ResourceMatcher: &spec.ResourceMatcher{ Names: []string{name}, Namespaces: []string{unstruct.GetNamespace()}, @@ -327,13 +339,13 @@ func parseConfigMapRef(unstruct *unstructured.Unstructured, env interface{}) (de return dep, true } -func parseImagePullSecret(unstruct *unstructured.Unstructured, secret interface{}) (dep *InternalDependency, found bool) { +func parseImagePullSecret(unstruct *unstructured.Unstructured, secret interface{}) (dep *Dependency, found bool) { name, found := nestedStringNotEmpty(secret, "name") if !found { return nil, false } - dep = &InternalDependency{ + dep = &Dependency{ ResourceMatcher: &spec.ResourceMatcher{ Names: []string{name}, Namespaces: []string{unstruct.GetNamespace()}, @@ -346,13 +358,13 @@ func parseImagePullSecret(unstruct *unstructured.Unstructured, secret interface{ return dep, true } -func parseNodeName(pod interface{}) (dep *InternalDependency, found bool) { +func parseNodeName(pod interface{}) (dep *Dependency, found bool) { nodeName, found := nestedStringNotEmpty(pod, "spec", "nodeName") if !found { return nil, false } - dep = &InternalDependency{ + dep = &Dependency{ ResourceMatcher: &spec.ResourceMatcher{ Names: []string{nodeName}, Groups: []string{""}, @@ -364,13 +376,13 @@ func parseNodeName(pod interface{}) (dep *InternalDependency, found bool) { return dep, true } -func parsePriorityClassName(unstruct *unstructured.Unstructured, pod interface{}) (dep *InternalDependency, found bool) { +func parsePriorityClassName(unstruct *unstructured.Unstructured, pod interface{}) (dep *Dependency, found bool) { priorityClassName, found := nestedStringNotEmpty(pod, "spec", "priorityClassName") if !found { return nil, false } - dep = &InternalDependency{ + dep = &Dependency{ ResourceMatcher: &spec.ResourceMatcher{ Names: []string{priorityClassName}, Groups: []string{"scheduling.k8s.io"}, @@ -382,7 +394,7 @@ func parsePriorityClassName(unstruct *unstructured.Unstructured, pod interface{} return dep, true } -func parseResourceClaim(unstruct *unstructured.Unstructured, claim interface{}) (dep *InternalDependency, found bool) { +func parseResourceClaim(unstruct *unstructured.Unstructured, claim interface{}) (dep *Dependency, found bool) { source, found := nestedMap(claim, "source") if !found { return nil, false @@ -390,7 +402,7 @@ func parseResourceClaim(unstruct *unstructured.Unstructured, claim interface{}) resourceClaimName, resourceClaimNameFound := nestedStringNotEmpty(source, "resourceClaimName") if resourceClaimNameFound { - dep = &InternalDependency{ + dep = &Dependency{ ResourceMatcher: &spec.ResourceMatcher{ Names: []string{resourceClaimName}, Namespaces: []string{unstruct.GetNamespace()}, @@ -405,7 +417,7 @@ func parseResourceClaim(unstruct *unstructured.Unstructured, claim interface{}) resourceClaimNameTemplate, resourceClaimNameTemplateFound := nestedStringNotEmpty(source, "resourceClaimNameTemplate") if resourceClaimNameTemplateFound { - dep = &InternalDependency{ + dep = &Dependency{ ResourceMatcher: &spec.ResourceMatcher{ Names: []string{resourceClaimNameTemplate}, Namespaces: []string{unstruct.GetNamespace()}, @@ -421,7 +433,7 @@ func parseResourceClaim(unstruct *unstructured.Unstructured, claim interface{}) return nil, false } -func parseRoleDependencies(unstruct *unstructured.Unstructured, otherUnstructs []*unstructured.Unstructured) (dependencies []*InternalDependency, found bool) { +func parseRoleDependencies(unstruct *unstructured.Unstructured, otherUnstructs []*unstructured.Unstructured) (dependencies []*Dependency, found bool) { bindingUnstructs := lo.Filter(otherUnstructs, func(unstruct *unstructured.Unstructured, _ int) bool { return unstruct.GroupVersionKind().GroupKind() == (schema.GroupKind{Kind: "RoleBinding", Group: "rbac.authorization.k8s.io"}) }) @@ -437,7 +449,7 @@ func parseRoleDependencies(unstruct *unstructured.Unstructured, otherUnstructs [ continue } - dep := &InternalDependency{ + dep := &Dependency{ ResourceMatcher: newExactResourceMatcher(bindingUnstruct), ResourceState: common.ResourceStateAbsent, } @@ -447,7 +459,7 @@ func parseRoleDependencies(unstruct *unstructured.Unstructured, otherUnstructs [ return dependencies, len(dependencies) > 0 } -func parseRoleRef(unstruct unstructured.Unstructured) (dep *InternalDependency, found bool) { +func parseRoleRef(unstruct unstructured.Unstructured) (dep *Dependency, found bool) { roleRef, found := nestedMap(unstruct.Object, "roleRef") if !found { return nil, false @@ -473,7 +485,7 @@ func parseRoleRef(unstruct unstructured.Unstructured) (dep *InternalDependency, namespaces = []string{unstruct.GetNamespace()} } - dep = &InternalDependency{ + dep = &Dependency{ ResourceMatcher: &spec.ResourceMatcher{ Names: []string{name}, Namespaces: namespaces, @@ -486,13 +498,13 @@ func parseRoleRef(unstruct unstructured.Unstructured) (dep *InternalDependency, return dep, true } -func parseRuntimeClassName(pod interface{}) (dep *InternalDependency, found bool) { +func parseRuntimeClassName(pod interface{}) (dep *Dependency, found bool) { runtimeClassName, found := nestedStringNotEmpty(pod, "spec", "runtimeClassName") if !found { return nil, false } - dep = &InternalDependency{ + dep = &Dependency{ ResourceMatcher: &spec.ResourceMatcher{ Names: []string{runtimeClassName}, Groups: []string{"node.k8s.io"}, @@ -504,7 +516,7 @@ func parseRuntimeClassName(pod interface{}) (dep *InternalDependency, found bool return dep, true } -func parseSecretKeyRef(unstruct *unstructured.Unstructured, env interface{}) (dep *InternalDependency, found bool) { +func parseSecretKeyRef(unstruct *unstructured.Unstructured, env interface{}) (dep *Dependency, found bool) { secretKeyRef, found := nestedMap(env, "valueFrom", "secretKeyRef") if !found { return nil, false @@ -520,7 +532,7 @@ func parseSecretKeyRef(unstruct *unstructured.Unstructured, env interface{}) (de return nil, false } - dep = &InternalDependency{ + dep = &Dependency{ ResourceMatcher: &spec.ResourceMatcher{ Names: []string{name}, Namespaces: []string{unstruct.GetNamespace()}, @@ -533,7 +545,7 @@ func parseSecretKeyRef(unstruct *unstructured.Unstructured, env interface{}) (de return dep, true } -func parseSecretRef(unstruct *unstructured.Unstructured, env interface{}) (dep *InternalDependency, found bool) { +func parseSecretRef(unstruct *unstructured.Unstructured, env interface{}) (dep *Dependency, found bool) { secretKeyRef, found := nestedMap(env, "valueFrom", "secretRef") if !found { return nil, false @@ -549,7 +561,7 @@ func parseSecretRef(unstruct *unstructured.Unstructured, env interface{}) (dep * return nil, false } - dep = &InternalDependency{ + dep = &Dependency{ ResourceMatcher: &spec.ResourceMatcher{ Names: []string{name}, Namespaces: []string{unstruct.GetNamespace()}, @@ -562,13 +574,13 @@ func parseSecretRef(unstruct *unstructured.Unstructured, env interface{}) (dep * return dep, true } -func parseServiceAccount(unstruct *unstructured.Unstructured, pod interface{}) (dep *InternalDependency, found bool) { +func parseServiceAccount(unstruct *unstructured.Unstructured, pod interface{}) (dep *Dependency, found bool) { serviceAccount, found := nestedStringNotEmpty(pod, "spec", "serviceAccount") if !found { return nil, false } - dep = &InternalDependency{ + dep = &Dependency{ ResourceMatcher: &spec.ResourceMatcher{ Names: []string{serviceAccount}, Namespaces: []string{unstruct.GetNamespace()}, @@ -581,7 +593,7 @@ func parseServiceAccount(unstruct *unstructured.Unstructured, pod interface{}) ( return dep, true } -func parseServiceAccountDependencies(unstruct *unstructured.Unstructured, otherUnstructs []*unstructured.Unstructured) (dependencies []*InternalDependency, found bool) { +func parseServiceAccountDependencies(unstruct *unstructured.Unstructured, otherUnstructs []*unstructured.Unstructured) (dependencies []*Dependency, found bool) { for _, otherUnstruct := range otherUnstructs { var nestedPath []string switch otherUnstruct.GroupVersionKind().GroupKind() { @@ -605,7 +617,7 @@ func parseServiceAccountDependencies(unstruct *unstructured.Unstructured, otherU continue } - dep := &InternalDependency{ + dep := &Dependency{ ResourceMatcher: newExactResourceMatcher(otherUnstruct), ResourceState: common.ResourceStateAbsent, } @@ -615,13 +627,13 @@ func parseServiceAccountDependencies(unstruct *unstructured.Unstructured, otherU return dependencies, len(dependencies) > 0 } -func parseServiceAccountName(unstruct *unstructured.Unstructured, pod interface{}) (dep *InternalDependency, found bool) { +func parseServiceAccountName(unstruct *unstructured.Unstructured, pod interface{}) (dep *Dependency, found bool) { serviceAccountName, found := nestedStringNotEmpty(pod, "spec", "serviceAccountName") if !found { return nil, false } - dep = &InternalDependency{ + dep = &Dependency{ ResourceMatcher: &spec.ResourceMatcher{ Names: []string{serviceAccountName}, Namespaces: []string{unstruct.GetNamespace()}, @@ -634,13 +646,13 @@ func parseServiceAccountName(unstruct *unstructured.Unstructured, pod interface{ return dep, true } -func parseServiceName(unstruct *unstructured.Unstructured, resSpec interface{}) (dep *InternalDependency, found bool) { +func parseServiceName(unstruct *unstructured.Unstructured, resSpec interface{}) (dep *Dependency, found bool) { name, found := nestedStringNotEmpty(resSpec, "serviceName") if !found { return nil, false } - dep = &InternalDependency{ + dep = &Dependency{ ResourceMatcher: &spec.ResourceMatcher{ Names: []string{name}, Namespaces: []string{unstruct.GetNamespace()}, @@ -653,7 +665,7 @@ func parseServiceName(unstruct *unstructured.Unstructured, resSpec interface{}) return dep, true } -func parseVolume(unstruct *unstructured.Unstructured, volume interface{}) (dep *InternalDependency, found bool) { +func parseVolume(unstruct *unstructured.Unstructured, volume interface{}) (dep *Dependency, found bool) { configMap, found := nestedMap(volume, "configMap") if found { name, found := nestedStringNotEmpty(configMap, "name") @@ -666,7 +678,7 @@ func parseVolume(unstruct *unstructured.Unstructured, volume interface{}) (dep * return nil, false } - dep = &InternalDependency{ + dep = &Dependency{ ResourceMatcher: &spec.ResourceMatcher{ Names: []string{name}, Namespaces: []string{unstruct.GetNamespace()}, @@ -691,7 +703,7 @@ func parseVolume(unstruct *unstructured.Unstructured, volume interface{}) (dep * return nil, false } - dep = &InternalDependency{ + dep = &Dependency{ ResourceMatcher: &spec.ResourceMatcher{ Names: []string{name}, Namespaces: []string{unstruct.GetNamespace()}, @@ -707,7 +719,7 @@ func parseVolume(unstruct *unstructured.Unstructured, volume interface{}) (dep * return nil, false } -func parseYandexInstanceClassDependencies(unstruct *unstructured.Unstructured, otherUnstructs []*unstructured.Unstructured) (dependencies []*InternalDependency, found bool) { +func parseYandexInstanceClassDependencies(unstruct *unstructured.Unstructured, otherUnstructs []*unstructured.Unstructured) (dependencies []*Dependency, found bool) { ngUnstructs := lo.Filter(otherUnstructs, func(unstruct *unstructured.Unstructured, _ int) bool { return unstruct.GroupVersionKind().GroupKind() == (schema.GroupKind{Kind: "NodeGroup", Group: "deckhouse.io"}) }) @@ -723,7 +735,7 @@ func parseYandexInstanceClassDependencies(unstruct *unstructured.Unstructured, o continue } - dep := &InternalDependency{ + dep := &Dependency{ ResourceMatcher: newExactResourceMatcher(ngUnstruct), ResourceState: common.ResourceStateAbsent, } diff --git a/pkg/resource/metadata.go b/pkg/resource/metadata.go index a508b5b1..941c838a 100644 --- a/pkg/resource/metadata.go +++ b/pkg/resource/metadata.go @@ -119,6 +119,7 @@ func deployConditions(meta *spec.ResourceMeta, hasManualInternalDeps bool) map[c } } +// TODO(major): use deploy/delete deps instead func externalDependencies(meta *spec.ResourceMeta, releaseNamespace string, clientFactory kube.ClientFactorier, remote bool) ([]*ExternalDependency, error) { if spec.IsCRD(meta.GroupVersionKind.GroupKind()) { return nil, nil @@ -146,6 +147,92 @@ func externalDependencies(meta *spec.ResourceMeta, releaseNamespace string, clie return uniqResult, nil } +func manualDeleteDependencies(meta *spec.ResourceMeta, otherResMeta []*spec.ResourceMeta) []*Dependency { + deps := map[string]*Dependency{} + + if annotations, found := spec.FindAnnotationsOrLabelsByKeyPattern(meta.Annotations, common.AnnotationKeyPatternDeleteDependency); found { + for key, value := range annotations { + matches := common.AnnotationKeyPatternDeleteDependency.FindStringSubmatch(key) + idSubexpIndex := common.AnnotationKeyPatternDeleteDependency.SubexpIndex("id") + depID := matches[idSubexpIndex] + properties := lo.Must(util.ParseProperties(context.TODO(), value)) + + var depState common.ResourceState + if s := properties["state"].(string); s != "" { + depState = common.ResourceState(s) + } else { + depState = common.ResourceStateAbsent + } + + dep := &Dependency{ + ResourceMatcher: dependencyMatcher(properties), + ResourceState: depState, + } + + depExternal := common.DefaultDependencyExternal + if ext := properties["external"]; ext != nil { + depExternal = common.DependencyExternal(ext.(string)) + } + + dep.External = isExternalDependency(dep.ResourceMatcher, otherResMeta, depExternal) + + if dep.External { + dep.MinMatches = common.DefaultExternalDependencyMinMatches + dep.MaxMatches = common.DefaultExternalDependencyMaxMatches + } + + deps[depID] = dep + } + } + + return lo.Values(deps) +} + +func manualDeployDependencies(meta *spec.ResourceMeta, otherResMeta []*spec.ResourceMeta) []*Dependency { + if spec.IsCRD(meta.GroupVersionKind.GroupKind()) { + return nil + } + + deps := map[string]*Dependency{} + + if annotations, found := spec.FindAnnotationsOrLabelsByKeyPattern(meta.Annotations, common.AnnotationKeyPatternDeployDependency); found { + for key, value := range annotations { + matches := common.AnnotationKeyPatternDeployDependency.FindStringSubmatch(key) + idSubexpIndex := common.AnnotationKeyPatternDeployDependency.SubexpIndex("id") + depID := matches[idSubexpIndex] + properties := lo.Must(util.ParseProperties(context.TODO(), value)) + + var depState common.ResourceState + if s := properties["state"].(string); s != "" { + depState = common.ResourceState(s) + } else { + depState = common.ResourceStatePresent + } + + dep := &Dependency{ + ResourceMatcher: dependencyMatcher(properties), + ResourceState: depState, + } + + depExternal := common.DefaultDependencyExternal + if ext := properties["external"]; ext != nil { + depExternal = common.DependencyExternal(ext.(string)) + } + + dep.External = isExternalDependency(dep.ResourceMatcher, otherResMeta, depExternal) + + if dep.External { + dep.MinMatches = common.DefaultExternalDependencyMinMatches + dep.MaxMatches = common.DefaultExternalDependencyMaxMatches + } + + deps[depID] = dep + } + } + + return lo.Values(deps) +} + func recreate(meta *spec.ResourceMeta) bool { deletePolicies := deletePolicies(meta) @@ -158,6 +245,218 @@ func recreateOnImmutable(meta *spec.ResourceMeta) bool { return lo.Contains(deletePolicies, common.DeletePolicyBeforeCreationIfImmutable) } +func validateDeleteDependencies(meta *spec.ResourceMeta, otherResMeta []*spec.ResourceMeta) error { + if annotations, found := spec.FindAnnotationsOrLabelsByKeyPattern(meta.Annotations, common.AnnotationKeyPatternDeleteDependency); found { + for key, value := range annotations { + keyMatches := common.AnnotationKeyPatternDeleteDependency.FindStringSubmatch(key) + if keyMatches == nil { + return fmt.Errorf("invalid key for annotation %q", key) + } + + idSubexpIndex := common.AnnotationKeyPatternDeleteDependency.SubexpIndex("id") + if idSubexpIndex == -1 { + return fmt.Errorf("invalid regexp pattern %q for annotation %q", common.AnnotationKeyPatternDeleteDependency.String(), key) + } + + if len(keyMatches) < idSubexpIndex+1 { + return fmt.Errorf("can't parse delete dependency id from annotation key %q", key) + } + + if value == "" { + return fmt.Errorf("invalid value %q for annotation %q, expected non-empty string value", value, key) + } + + properties, err := util.ParseProperties(context.TODO(), value) + if err != nil { + return fmt.Errorf("invalid value %q for annotation %q: %w", value, key, err) + } + + if !lo.Some(lo.Keys(properties), []string{"group", "version", "kind", "name", "namespace"}) { + return fmt.Errorf("invalid value %q for annotation %q, target not specified", value, key) + } + + for propKey, propVal := range properties { + switch propKey { + case "group", "version", "kind", "name", "namespace": + switch pv := propVal.(type) { + case string: + if pv == "" { + return fmt.Errorf("invalid value %q for property %q, expected non-empty string value", pv, propKey) + } + case bool: + return fmt.Errorf("invalid boolean value %t for property %q, expected string value", pv, propKey) + default: + panic(fmt.Sprintf("unexpected type %T for property %q", pv, propKey)) + } + case "state": + switch pv := propVal.(type) { + case string: + switch pv { + case "absent": + case "": + return fmt.Errorf("invalid value %q for property %q, expected non-empty string value", pv, propKey) + default: + return fmt.Errorf("unknown value %q for property %q", pv, propKey) + } + case bool: + return fmt.Errorf("invalid boolean value %t for property %q, expected string value", pv, propKey) + default: + panic(fmt.Sprintf("unexpected type %T for property %q", pv, propKey)) + } + case "external": + switch pv := propVal.(type) { + case string: + switch common.DependencyExternal(pv) { + case common.DependencyExternalAuto, common.DependencyExternalTrue, common.DependencyExternalFalse: + default: + return fmt.Errorf("invalid value %q for property %q, expected %q, %q or %q", pv, propKey, common.DependencyExternalAuto, common.DependencyExternalTrue, common.DependencyExternalFalse) + } + case bool: + return fmt.Errorf("invalid boolean value %t for property %q, expected string value", pv, propKey) + default: + panic(fmt.Sprintf("unexpected type %T for property %q", pv, propKey)) + } + default: + return fmt.Errorf("unknown property %q in value of annotation %q", propKey, key) + } + } + + depExternal := common.DefaultDependencyExternal + if ext := properties["external"]; ext != nil { + depExternal = common.DependencyExternal(ext.(string)) + } + + matcher := dependencyMatcher(properties) + + if isExternalDependency(matcher, otherResMeta, depExternal) { + depID := keyMatches[idSubexpIndex] + + if len(matcher.Names) == 0 { + return fmt.Errorf("external delete dependency %q must have \"name\" property set", depID) + } + + if len(matcher.Kinds) == 0 { + return fmt.Errorf("external delete dependency %q must have \"kind\" property set", depID) + } + + if len(matcher.Versions) == 0 { + return fmt.Errorf("external delete dependency %q must have \"version\" property set", depID) + } + } + } + } + + return nil +} + +func validateDeployDependencies(meta *spec.ResourceMeta, otherResMeta []*spec.ResourceMeta) error { + if annotations, found := spec.FindAnnotationsOrLabelsByKeyPattern(meta.Annotations, common.AnnotationKeyPatternDeployDependency); found { + for key, value := range annotations { + keyMatches := common.AnnotationKeyPatternDeployDependency.FindStringSubmatch(key) + if keyMatches == nil { + return fmt.Errorf("invalid key for annotation %q", key) + } + + idSubexpIndex := common.AnnotationKeyPatternDeployDependency.SubexpIndex("id") + if idSubexpIndex == -1 { + return fmt.Errorf("invalid regexp pattern %q for annotation %q", common.AnnotationKeyPatternDeployDependency.String(), key) + } + + if len(keyMatches) < idSubexpIndex+1 { + return fmt.Errorf("can't parse deploy dependency id from annotation key %q", key) + } + + if value == "" { + return fmt.Errorf("invalid value %q for annotation %q, expected non-empty string value", value, key) + } + + properties, err := util.ParseProperties(context.TODO(), value) + if err != nil { + return fmt.Errorf("invalid value %q for annotation %q: %w", value, key, err) + } + + if !lo.Some(lo.Keys(properties), []string{"group", "version", "kind", "name", "namespace"}) { + return fmt.Errorf("invalid value %q for annotation %q, target not specified", value, key) + } + + if _, found := properties["state"]; !found { + return fmt.Errorf(`invalid value %q for annotation %q, "state" property must be set`, value, key) + } + + for propKey, propVal := range properties { + switch propKey { + case "group", "version", "kind", "name", "namespace": + switch pv := propVal.(type) { + case string: + if pv == "" { + return fmt.Errorf("invalid value %q for property %q, expected non-empty string value", pv, propKey) + } + case bool: + return fmt.Errorf("invalid boolean value %t for property %q, expected string value", pv, propKey) + default: + panic(fmt.Sprintf("unexpected type %T for property %q", pv, propKey)) + } + case "state": + switch pv := propVal.(type) { + case string: + switch pv { + case "present", "ready": + case "": + return fmt.Errorf("invalid value %q for property %q, expected non-empty string value", pv, propKey) + default: + return fmt.Errorf("unknown value %q for property %q", pv, propKey) + } + case bool: + return fmt.Errorf("invalid boolean value %t for property %q, expected string value", pv, propKey) + default: + panic(fmt.Sprintf("unexpected type %T for property %q", pv, propKey)) + } + case "external": + switch pv := propVal.(type) { + case string: + switch common.DependencyExternal(pv) { + case common.DependencyExternalAuto, common.DependencyExternalTrue, common.DependencyExternalFalse: + default: + return fmt.Errorf("invalid value %q for property %q, expected %q, %q or %q", pv, propKey, common.DependencyExternalAuto, common.DependencyExternalTrue, common.DependencyExternalFalse) + } + case bool: + return fmt.Errorf("invalid boolean value %t for property %q, expected string value", pv, propKey) + default: + panic(fmt.Sprintf("unexpected type %T for property %q", pv, propKey)) + } + default: + return fmt.Errorf("unknown property %q in value of annotation %q", propKey, key) + } + } + + depExternal := common.DefaultDependencyExternal + if ext := properties["external"]; ext != nil { + depExternal = common.DependencyExternal(ext.(string)) + } + + matcher := dependencyMatcher(properties) + + if isExternalDependency(matcher, otherResMeta, depExternal) { + depID := keyMatches[idSubexpIndex] + + if len(matcher.Names) == 0 { + return fmt.Errorf("external deploy dependency %q must have \"name\" property set", depID) + } + + if len(matcher.Kinds) == 0 { + return fmt.Errorf("external deploy dependency %q must have \"kind\" property set", depID) + } + + if len(matcher.Versions) == 0 { + return fmt.Errorf("external deploy dependency %q must have \"version\" property set", depID) + } + } + } + } + + return nil +} + func defaultReplicasOnCreation(meta *spec.ResourceMeta, releaseNamespace string) *int { _, value, found := spec.FindAnnotationOrLabelByKeyPattern(meta.Annotations, common.AnnotationKeyPatternReplicasOnCreation) if !found { @@ -225,6 +524,41 @@ func deletePropagation(meta *spec.ResourceMeta, defaultDeletePropagation apiv1.D return common.DefaultDeletePropagation } +func dependencyMatcher(properties map[string]any) *spec.ResourceMatcher { + var depNames []string + if depName, found := properties["name"]; found { + depNames = []string{depName.(string)} + } + + var depNamespaces []string + if depNamespace, found := properties["namespace"]; found { + depNamespaces = []string{depNamespace.(string)} + } + + var depGroups []string + if depGroup, found := properties["group"]; found { + depGroups = []string{depGroup.(string)} + } + + var depVersions []string + if depVersion, found := properties["version"]; found { + depVersions = []string{depVersion.(string)} + } + + var depKinds []string + if depKind, found := properties["kind"]; found { + depKinds = []string{depKind.(string)} + } + + return &spec.ResourceMatcher{ + Names: depNames, + Namespaces: depNamespaces, + Groups: depGroups, + Versions: depVersions, + Kinds: depKinds, + } +} + func deployConditionsForAnnotation(meta *spec.ResourceMeta, annoPattern *regexp.Regexp) map[common.On][]common.Stage { key, value, found := spec.FindAnnotationOrLabelByKeyPattern(meta.Annotations, annoPattern) if !found { @@ -385,6 +719,23 @@ func ignoreReadinessProbeFailsForContainers(meta *spec.ResourceMeta) map[string] return durationByContainer } +func isExternalDependency(matcher *spec.ResourceMatcher, otherResMeta []*spec.ResourceMeta, external common.DependencyExternal) bool { + switch external { + case common.DependencyExternalAuto: + matched := lo.Filter(otherResMeta, func(resMeta *spec.ResourceMeta, _ int) bool { + return matcher.Match(resMeta) + }) + + return len(matched) == 0 + case common.DependencyExternalTrue: + return true + case common.DependencyExternalFalse: + return false + default: + panic(fmt.Sprintf("unexpected external dependency value: %q", external)) + } +} + // TODO(major): get rid of legacy external deps func legacyExternalDeps(resMeta *spec.ResourceMeta, releaseNamespace string, mapper apimeta.ResettableRESTMapper) (map[string]*ExternalDependency, error) { deps := map[string]*ExternalDependency{} @@ -468,128 +819,6 @@ func logRegexesForContainers(meta *spec.ResourceMeta) map[string]*regexp.Regexp return regexByContainer } -func manualInternalDeleteDependencies(meta *spec.ResourceMeta) []*InternalDependency { - deps := map[string]*InternalDependency{} - - if annotations, found := spec.FindAnnotationsOrLabelsByKeyPattern(meta.Annotations, common.AnnotationKeyPatternDeleteDependency); found { - for key, value := range annotations { - matches := common.AnnotationKeyPatternDeleteDependency.FindStringSubmatch(key) - idSubexpIndex := common.AnnotationKeyPatternDeleteDependency.SubexpIndex("id") - depID := matches[idSubexpIndex] - properties := lo.Must(util.ParseProperties(context.TODO(), value)) - - var depNames []string - if depName, found := properties["name"]; found { - depNames = []string{depName.(string)} - } - - var depNamespaces []string - if depNamespace, found := properties["namespace"]; found { - depNamespaces = []string{depNamespace.(string)} - } - - var depGroups []string - if depGroup, found := properties["group"]; found { - depGroups = []string{depGroup.(string)} - } - - var depVersions []string - if depVersion, found := properties["version"]; found { - depVersions = []string{depVersion.(string)} - } - - var depKinds []string - if depKind, found := properties["kind"]; found { - depKinds = []string{depKind.(string)} - } - - var depState common.ResourceState - if s := properties["state"].(string); s != "" { - depState = common.ResourceState(s) - } else { - depState = common.ResourceStatePresent - } - - dep := &InternalDependency{ - ResourceMatcher: &spec.ResourceMatcher{ - Names: depNames, - Namespaces: depNamespaces, - Groups: depGroups, - Versions: depVersions, - Kinds: depKinds, - }, - ResourceState: depState, - } - deps[depID] = dep - } - } - - return lo.Values(deps) -} - -func manualInternalDeployDependencies(meta *spec.ResourceMeta) []*InternalDependency { - if spec.IsCRD(meta.GroupVersionKind.GroupKind()) { - return nil - } - - deps := map[string]*InternalDependency{} - - if annotations, found := spec.FindAnnotationsOrLabelsByKeyPattern(meta.Annotations, common.AnnotationKeyPatternDeployDependency); found { - for key, value := range annotations { - matches := common.AnnotationKeyPatternDeployDependency.FindStringSubmatch(key) - idSubexpIndex := common.AnnotationKeyPatternDeployDependency.SubexpIndex("id") - depID := matches[idSubexpIndex] - properties := lo.Must(util.ParseProperties(context.TODO(), value)) - - var depNames []string - if depName, found := properties["name"]; found { - depNames = []string{depName.(string)} - } - - var depNamespaces []string - if depNamespace, found := properties["namespace"]; found { - depNamespaces = []string{depNamespace.(string)} - } - - var depGroups []string - if depGroup, found := properties["group"]; found { - depGroups = []string{depGroup.(string)} - } - - var depVersions []string - if depVersion, found := properties["version"]; found { - depVersions = []string{depVersion.(string)} - } - - var depKinds []string - if depKind, found := properties["kind"]; found { - depKinds = []string{depKind.(string)} - } - - var depState common.ResourceState - if s := properties["state"].(string); s != "" { - depState = common.ResourceState(s) - } else { - depState = common.ResourceStatePresent - } - - dep := &InternalDependency{ - ResourceMatcher: &spec.ResourceMatcher{ - Names: depNames, - Namespaces: depNamespaces, - Groups: depGroups, - Versions: depVersions, - Kinds: depKinds, - }, - ResourceState: depState, - } - deps[depID] = dep - } - } - - return lo.Values(deps) -} - func noActivityTimeout(meta *spec.ResourceMeta) time.Duration { _, value, found := spec.FindAnnotationOrLabelByKeyPattern(meta.Annotations, common.AnnotationKeyPatternNoActivityTimeout) if !found { @@ -719,74 +948,6 @@ func trackTerminationMode(meta *spec.ResourceMeta) statestore.TrackTerminationMo return statestore.TrackTerminationMode(value) } -func validateDeleteDependencies(meta *spec.ResourceMeta) error { - if annotations, found := spec.FindAnnotationsOrLabelsByKeyPattern(meta.Annotations, common.AnnotationKeyPatternDeleteDependency); found { - for key, value := range annotations { - keyMatches := common.AnnotationKeyPatternDeleteDependency.FindStringSubmatch(key) - if keyMatches == nil { - return fmt.Errorf("invalid key for annotation %q", key) - } - - idSubexpIndex := common.AnnotationKeyPatternDeleteDependency.SubexpIndex("id") - if idSubexpIndex == -1 { - return fmt.Errorf("invalid regexp pattern %q for annotation %q", common.AnnotationKeyPatternDeleteDependency.String(), key) - } - - if len(keyMatches) < idSubexpIndex+1 { - return fmt.Errorf("can't parse delete dependency id from annotation key %q", key) - } - - if value == "" { - return fmt.Errorf("invalid value %q for annotation %q, expected non-empty string value", value, key) - } - - properties, err := util.ParseProperties(context.TODO(), value) - if err != nil { - return fmt.Errorf("invalid value %q for annotation %q: %w", value, key, err) - } - - if !lo.Some(lo.Keys(properties), []string{"group", "version", "kind", "name", "namespace"}) { - return fmt.Errorf("invalid value %q for annotation %q, target not specified", value, key) - } - - for propKey, propVal := range properties { - switch propKey { - case "group", "version", "kind", "name", "namespace": - switch pv := propVal.(type) { - case string: - if pv == "" { - return fmt.Errorf("invalid value %q for property %q, expected non-empty string value", pv, propKey) - } - case bool: - return fmt.Errorf("invalid boolean value %t for property %q, expected string value", pv, propKey) - default: - panic(fmt.Sprintf("unexpected type %T for property %q", pv, propKey)) - } - case "state": - switch pv := propVal.(type) { - case string: - switch pv { - case "absent": - case "": - return fmt.Errorf("invalid value %q for property %q, expected non-empty string value", pv, propKey) - default: - return fmt.Errorf("unknown value %q for property %q", pv, propKey) - } - case bool: - return fmt.Errorf("invalid boolean value %t for property %q, expected string value", pv, propKey) - default: - panic(fmt.Sprintf("unexpected type %T for property %q", pv, propKey)) - } - default: - return fmt.Errorf("unknown property %q in value of annotation %q", propKey, key) - } - } - } - } - - return nil -} - func validateDeletePolicy(meta *spec.ResourceMeta) error { annotations := meta.Annotations @@ -846,78 +1007,6 @@ func validateDeletePropagation(meta *spec.ResourceMeta) error { return nil } -func validateDeployDependencies(meta *spec.ResourceMeta) error { - if annotations, found := spec.FindAnnotationsOrLabelsByKeyPattern(meta.Annotations, common.AnnotationKeyPatternDeployDependency); found { - for key, value := range annotations { - keyMatches := common.AnnotationKeyPatternDeployDependency.FindStringSubmatch(key) - if keyMatches == nil { - return fmt.Errorf("invalid key for annotation %q", key) - } - - idSubexpIndex := common.AnnotationKeyPatternDeployDependency.SubexpIndex("id") - if idSubexpIndex == -1 { - return fmt.Errorf("invalid regexp pattern %q for annotation %q", common.AnnotationKeyPatternDeployDependency.String(), key) - } - - if len(keyMatches) < idSubexpIndex+1 { - return fmt.Errorf("can't parse deploy dependency id from annotation key %q", key) - } - - if value == "" { - return fmt.Errorf("invalid value %q for annotation %q, expected non-empty string value", value, key) - } - - properties, err := util.ParseProperties(context.TODO(), value) - if err != nil { - return fmt.Errorf("invalid value %q for annotation %q: %w", value, key, err) - } - - if !lo.Some(lo.Keys(properties), []string{"group", "version", "kind", "name", "namespace"}) { - return fmt.Errorf("invalid value %q for annotation %q, target not specified", value, key) - } - - if _, found := properties["state"]; !found { - return fmt.Errorf(`invalid value %q for annotation %q, "state" property must be set`, value, key) - } - - for propKey, propVal := range properties { - switch propKey { - case "group", "version", "kind", "name", "namespace": - switch pv := propVal.(type) { - case string: - if pv == "" { - return fmt.Errorf("invalid value %q for property %q, expected non-empty string value", pv, propKey) - } - case bool: - return fmt.Errorf("invalid boolean value %t for property %q, expected string value", pv, propKey) - default: - panic(fmt.Sprintf("unexpected type %T for property %q", pv, propKey)) - } - case "state": - switch pv := propVal.(type) { - case string: - switch pv { - case "present", "ready": - case "": - return fmt.Errorf("invalid value %q for property %q, expected non-empty string value", pv, propKey) - default: - return fmt.Errorf("unknown value %q for property %q", pv, propKey) - } - case bool: - return fmt.Errorf("invalid boolean value %t for property %q, expected string value", pv, propKey) - default: - panic(fmt.Sprintf("unexpected type %T for property %q", pv, propKey)) - } - default: - return fmt.Errorf("unknown property %q in value of annotation %q", propKey, key) - } - } - } - } - - return nil -} - func validateDeployOn(meta *spec.ResourceMeta) error { if key, value, found := spec.FindAnnotationOrLabelByKeyPattern(meta.Annotations, common.AnnotationKeyPatternDeployOn); found { if value == "" { diff --git a/pkg/resource/resource.go b/pkg/resource/resource.go index 7c6bf9be..6d239519 100644 --- a/pkg/resource/resource.go +++ b/pkg/resource/resource.go @@ -45,8 +45,8 @@ type InstallableResource struct { SkipLogsRegexForContainers map[string]*regexp.Regexp `json:"skipLogsRegexForContainers"` TrackTerminationMode statestore.TrackTerminationMode `json:"trackTerminationMode"` Weight *int `json:"weight,omitempty"` - ManualInternalDependencies []*InternalDependency `json:"manualInternalDependencies,omitempty"` - AutoInternalDependencies []*InternalDependency `json:"autoInternalDependencies,omitempty"` + ManualDependencies []*Dependency `json:"manualDependencies,omitempty"` + AutoInternalDependencies []*Dependency `json:"autoInternalDependencies,omitempty"` ExternalDependencies []*ExternalDependency `json:"externalDependencies,omitempty"` DeployConditions map[common.On][]common.Stage `json:"deployConditions"` DeletePropagation metav1.DeletionPropagation `json:"deletePropagation"` @@ -54,7 +54,11 @@ type InstallableResource struct { // Construct an InstallableResource from a ResourceSpec. Must never contact the cluster, because // this is called even when no cluster access allowed. -func NewInstallableResource(res *spec.ResourceSpec, releaseNamespace string, clientFactory kube.ClientFactorier, opts InstallableResourceOptions) (*InstallableResource, error) { +func NewInstallableResource(res *spec.ResourceSpec, otherResourceSpecs []*spec.ResourceSpec, releaseNamespace string, clientFactory kube.ClientFactorier, opts InstallableResourceOptions) (*InstallableResource, error) { + otherResourceMetaList := lo.Map(otherResourceSpecs, func(resSpec *spec.ResourceSpec, _ int) *spec.ResourceMeta { + return resSpec.ResourceMeta + }) + if err := validateHook(res.ResourceMeta); err != nil { return nil, fmt.Errorf("validate hook configuration: %w", err) } @@ -79,10 +83,14 @@ func NewInstallableResource(res *spec.ResourceSpec, releaseNamespace string, cli return nil, fmt.Errorf("validate weight: %w", err) } - if err := validateDeployDependencies(res.ResourceMeta); err != nil { + if err := validateDeployDependencies(res.ResourceMeta, otherResourceMetaList); err != nil { return nil, fmt.Errorf("validate deploy dependencies: %w", err) } + if err := validateDeleteDependencies(res.ResourceMeta, otherResourceMetaList); err != nil { + return nil, fmt.Errorf("validate delete dependencies: %w", err) + } + if err := validateExternalDependencies(res.ResourceMeta); err != nil { return nil, fmt.Errorf("validate external dependencies: %w", err) } @@ -108,7 +116,10 @@ func NewInstallableResource(res *spec.ResourceSpec, releaseNamespace string, cli return nil, fmt.Errorf("get external dependencies: %w", err) } - manIntDeps := manualInternalDeployDependencies(res.ResourceMeta) + manDeps := manualDeployDependencies(res.ResourceMeta, otherResourceMetaList) + internalDeps := lo.Filter(manDeps, func(item *Dependency, _ int) bool { + return !item.External + }) return &InstallableResource{ ResourceSpec: res, @@ -117,7 +128,7 @@ func NewInstallableResource(res *spec.ResourceSpec, releaseNamespace string, cli DeleteOnFailed: deleteOnFailed(res.ResourceMeta), DeleteOnSucceeded: deleteOnSucceeded(res.ResourceMeta), DeletePropagation: deletePropagation(res.ResourceMeta, opts.DefaultDeletePropagation), - DeployConditions: deployConditions(res.ResourceMeta, len(manIntDeps) > 0), + DeployConditions: deployConditions(res.ResourceMeta, len(internalDeps) > 0), ExternalDependencies: extDeps, FailMode: failMode(res.ResourceMeta), FailuresAllowed: failuresAllowed(res.Unstruct), @@ -125,7 +136,7 @@ func NewInstallableResource(res *spec.ResourceSpec, releaseNamespace string, cli KeepOnDelete: KeepOnDelete(res.ResourceMeta, releaseNamespace), LogRegex: logRegex(res.ResourceMeta), LogRegexesForContainers: logRegexesForContainers(res.ResourceMeta), - ManualInternalDependencies: manIntDeps, + ManualDependencies: manDeps, NoActivityTimeout: noActivityTimeout(res.ResourceMeta), Ownership: ownership(res.ResourceMeta, releaseNamespace, res.StoreAs), Recreate: recreate(res.ResourceMeta), @@ -138,7 +149,7 @@ func NewInstallableResource(res *spec.ResourceSpec, releaseNamespace string, cli SkipLogsRegex: skipLogRegex(res.ResourceMeta), SkipLogsRegexForContainers: skipLogRegexesForContainers(res.ResourceMeta), TrackTerminationMode: trackTerminationMode(res.ResourceMeta), - Weight: weight(res.ResourceMeta, len(manIntDeps) > 0), + Weight: weight(res.ResourceMeta, len(internalDeps) > 0), }, nil } @@ -154,16 +165,16 @@ type InstallableResourceOptions struct { type DeletableResource struct { *spec.ResourceMeta - AutoInternalDependencies []*InternalDependency - DeletePropagation metav1.DeletionPropagation - KeepOnDelete bool - ManualInternalDependencies []*InternalDependency - Ownership common.Ownership + AutoInternalDependencies []*Dependency + DeletePropagation metav1.DeletionPropagation + KeepOnDelete bool + ManualDependencies []*Dependency + Ownership common.Ownership } // Construct a DeletableResource from a ResourceSpec. Must never contact the cluster, because // this is called even when no cluster access allowed. -func NewDeletableResource(resourceSpec *spec.ResourceSpec, otherResourceSpecs []*spec.ResourceSpec, releaseNamespace string, opts DeletableResourceOptions) *DeletableResource { +func NewDeletableResource(resourceSpec *spec.ResourceSpec, otherResourceSpecs []*spec.ResourceSpec, releaseNamespace string, opts DeletableResourceOptions) (*DeletableResource, error) { var keep bool if err := ValidateResourcePolicy(resourceSpec.ResourceMeta); err != nil { keep = true @@ -185,9 +196,12 @@ func NewDeletableResource(resourceSpec *spec.ResourceSpec, otherResourceSpecs [] delPropagation = deletePropagation(resourceSpec.ResourceMeta, opts.DefaultDeletePropagation) } - var manIntDeps []*InternalDependency - if err := validateDeleteDependencies(resourceSpec.ResourceMeta); err == nil { - manIntDeps = manualInternalDeleteDependencies(resourceSpec.ResourceMeta) + otherResourceMetaList := lo.Map(otherResourceSpecs, func(resSpec *spec.ResourceSpec, _ int) *spec.ResourceMeta { + return resSpec.ResourceMeta + }) + + if err := validateDeleteDependencies(resourceSpec.ResourceMeta, otherResourceMetaList); err != nil { + return nil, fmt.Errorf("validate delete dependencies: %w", err) } unstructList := lo.Map(otherResourceSpecs, func(resSpec *spec.ResourceSpec, _ int) *unstructured.Unstructured { @@ -195,13 +209,13 @@ func NewDeletableResource(resourceSpec *spec.ResourceSpec, otherResourceSpecs [] }) return &DeletableResource{ - ResourceMeta: resourceSpec.ResourceMeta, - AutoInternalDependencies: internalDeleteDependencies(resourceSpec.Unstruct, unstructList), - DeletePropagation: delPropagation, - KeepOnDelete: keep, - ManualInternalDependencies: manIntDeps, - Ownership: owner, - } + ResourceMeta: resourceSpec.ResourceMeta, + AutoInternalDependencies: internalDeleteDependencies(resourceSpec.Unstruct, unstructList), + DeletePropagation: delPropagation, + KeepOnDelete: keep, + ManualDependencies: manualDeleteDependencies(resourceSpec.ResourceMeta, otherResourceMetaList), + Ownership: owner, + }, nil } type DeletableResourceOptions struct { @@ -220,16 +234,19 @@ type BuildResourcesOptions struct { func BuildResources(ctx context.Context, deployType common.DeployType, releaseNamespace string, prevRelResSpecs, newRelResSpecs []*spec.ResourceSpec, patchers []spec.ResourcePatcher, clientFactory kube.ClientFactorier, opts BuildResourcesOptions) ([]*InstallableResource, []*DeletableResource, error) { var prevRelDelResources []*DeletableResource for _, resSpec := range prevRelResSpecs { - deletableRes := NewDeletableResource(resSpec, lo.Without(prevRelResSpecs, resSpec), releaseNamespace, DeletableResourceOptions{ + deletableRes, err := NewDeletableResource(resSpec, lo.Without(prevRelResSpecs, resSpec), releaseNamespace, DeletableResourceOptions{ DefaultDeletePropagation: opts.DefaultDeletePropagation, }) + if err != nil { + return nil, nil, fmt.Errorf("construct deletable resource: %w", err) + } prevRelDelResources = append(prevRelDelResources, deletableRes) } var prevRelInstResources []*InstallableResource for _, resSpec := range prevRelResSpecs { - installableResource, err := NewInstallableResource(resSpec, releaseNamespace, clientFactory, InstallableResourceOptions{ + installableResource, err := NewInstallableResource(resSpec, lo.Without(prevRelResSpecs, resSpec), releaseNamespace, clientFactory, InstallableResourceOptions{ DefaultDeletePropagation: opts.DefaultDeletePropagation, NoPodLogs: opts.NoPodLogs, Remote: opts.Remote, @@ -243,7 +260,7 @@ func BuildResources(ctx context.Context, deployType common.DeployType, releaseNa var newRelInstResources []*InstallableResource for _, resSpec := range newRelResSpecs { - installableResource, err := NewInstallableResource(resSpec, releaseNamespace, clientFactory, InstallableResourceOptions{ + installableResource, err := NewInstallableResource(resSpec, lo.Without(newRelResSpecs, resSpec), releaseNamespace, clientFactory, InstallableResourceOptions{ DefaultDeletePropagation: opts.DefaultDeletePropagation, NoPodLogs: opts.NoPodLogs, Remote: opts.Remote, @@ -304,50 +321,53 @@ func BuildResources(ctx context.Context, deployType common.DeployType, releaseNa } }) - var instResources []*InstallableResource + var patchedResSpecs []*spec.ResourceSpec for _, r := range append(filteredPrevRelInstResources, filteredNewRelInstResources...) { - instRes := r + unstruct := r.Unstruct var deepCopied bool for _, patcher := range patchers { if matched, err := patcher.Match(ctx, &spec.ResourcePatcherResourceInfo{ - Obj: instRes.Unstruct, - Ownership: instRes.Ownership, + Obj: unstruct, + Ownership: r.Ownership, }); err != nil { return nil, nil, fmt.Errorf("match deployable resource for patching by %q: %w", patcher.Type(), err) } else if !matched { continue } - var unstruct *unstructured.Unstructured - if deepCopied { - unstruct = instRes.Unstruct - } else { - unstruct = instRes.Unstruct.DeepCopy() + if !deepCopied { + unstruct = unstruct.DeepCopy() deepCopied = true } patchedObj, err := patcher.Patch(ctx, &spec.ResourcePatcherResourceInfo{ Obj: unstruct, - Ownership: instRes.Ownership, + Ownership: r.Ownership, }) if err != nil { return nil, nil, fmt.Errorf("patch deployable resource by %q: %w", patcher.Type(), err) } - resSpec := spec.NewResourceSpec(patchedObj, releaseNamespace, spec.ResourceSpecOptions{ - StoreAs: instRes.StoreAs, - FilePath: instRes.FilePath, - }) + unstruct = patchedObj + } - instRes, err = NewInstallableResource(resSpec, releaseNamespace, clientFactory, InstallableResourceOptions{ - DefaultDeletePropagation: opts.DefaultDeletePropagation, - NoPodLogs: opts.NoPodLogs, - Remote: opts.Remote, - }) - if err != nil { - return nil, nil, fmt.Errorf("construct deployable resource from patched object by %q: %w", patcher.Type(), err) - } + resSpec := spec.NewResourceSpec(unstruct, releaseNamespace, spec.ResourceSpecOptions{ + StoreAs: r.StoreAs, + FilePath: r.FilePath, + }) + patchedResSpecs = append(patchedResSpecs, resSpec) + } + + var instResources []*InstallableResource + for _, resSpec := range patchedResSpecs { + instRes, err := NewInstallableResource(resSpec, lo.Without(patchedResSpecs, resSpec), releaseNamespace, clientFactory, InstallableResourceOptions{ + DefaultDeletePropagation: opts.DefaultDeletePropagation, + NoPodLogs: opts.NoPodLogs, + Remote: opts.Remote, + }) + if err != nil { + return nil, nil, fmt.Errorf("construct deployable resource: %w", err) } instResources = append(instResources, instRes) diff --git a/pkg/resource/resource_test.go b/pkg/resource/resource_test.go index 0282edf2..460c9217 100644 --- a/pkg/resource/resource_test.go +++ b/pkg/resource/resource_test.go @@ -375,7 +375,7 @@ func (s *InstallableResourceSuite) TestNewInstallableResourceForDependencies() { { expect: func(resSpec *spec.ResourceSpec) *resource.InstallableResource { res := defaultInstallableResource(resSpec) - res.ManualInternalDependencies = []*resource.InternalDependency{ + res.ManualDependencies = []*resource.Dependency{ { ResourceMatcher: &spec.ResourceMatcher{ Names: []string{"backend"}, @@ -391,18 +391,18 @@ func (s *InstallableResourceSuite) TestNewInstallableResourceForDependencies() { resSpec := defaultResourceSpec(s.releaseNamespace) resSpec.SetAnnotations(lo.Assign( resSpec.Annotations, map[string]string{ - "werf.io/deploy-dependency-backend": "state=present,name=backend", + "werf.io/deploy-dependency-backend": "state=present,name=backend,external=false", }, )) return resSpec }, - name: `for resource with werf.io/deploy-dependency-backend="state=present,name=backend"`, + name: `for resource with werf.io/deploy-dependency-backend="state=present,name=backend,external=false"`, }, { expect: func(resSpec *spec.ResourceSpec) *resource.InstallableResource { res := defaultInstallableResource(resSpec) - res.ManualInternalDependencies = []*resource.InternalDependency{ + res.ManualDependencies = []*resource.Dependency{ { ResourceMatcher: &spec.ResourceMatcher{ Names: []string{"backend"}, @@ -431,8 +431,8 @@ func (s *InstallableResourceSuite) TestNewInstallableResourceForDependencies() { input: func() *spec.ResourceSpec { resSpec := defaultResourceSpec(s.releaseNamespace) resSpec.SetAnnotations(lo.Assign(resSpec.Annotations, map[string]string{ - "werf.io/deploy-dependency-backend": "state=ready,kind=Deployment,group=apps,version=v1,name=backend,namespace=app", - "werf.io/deploy-dependency-frontend": "state=ready,kind=StatefulSet,group=apps,version=v1,name=frontend,namespace=app", + "werf.io/deploy-dependency-backend": "state=ready,kind=Deployment,group=apps,version=v1,name=backend,namespace=app,external=false", + "werf.io/deploy-dependency-frontend": "state=ready,kind=StatefulSet,group=apps,version=v1,name=frontend,namespace=app,external=false", })) return resSpec @@ -442,7 +442,7 @@ func (s *InstallableResourceSuite) TestNewInstallableResourceForDependencies() { { expect: func(resSpec *spec.ResourceSpec) *resource.InstallableResource { res := defaultHookInstallableResource(resSpec) - res.ManualInternalDependencies = []*resource.InternalDependency{ + res.ManualDependencies = []*resource.Dependency{ { ResourceMatcher: &spec.ResourceMatcher{ Names: []string{"backend"}, @@ -457,19 +457,19 @@ func (s *InstallableResourceSuite) TestNewInstallableResourceForDependencies() { input: func() *spec.ResourceSpec { resSpec := defaultHookResourceSpec(s.releaseNamespace) resSpec.SetAnnotations(lo.Assign(resSpec.Annotations, map[string]string{ - "werf.io/deploy-dependency-backend": "state=ready,name=backend", + "werf.io/deploy-dependency-backend": "state=ready,name=backend,external=false", "werf.io/weight": "10", "helm.sh/hook-weight": "20", })) return resSpec }, - name: `for hook resource with werf.io/deploy-dependency-backend="state=ready,name=backend" and werf.io/weight="10" and helm.sh/hook-weight="20"`, + name: `for hook resource with werf.io/deploy-dependency-backend="state=ready,name=backend,external=false" and werf.io/weight="10" and helm.sh/hook-weight="20"`, }, { expect: func(resSpec *spec.ResourceSpec) *resource.InstallableResource { res := defaultDeploymentInstallableResource(resSpec) - res.AutoInternalDependencies = []*resource.InternalDependency{ + res.AutoInternalDependencies = []*resource.Dependency{ { ResourceMatcher: &spec.ResourceMatcher{ Names: []string{"configmap-envs"}, @@ -1270,7 +1270,7 @@ func runDeletableResourceTest(tc deletableResourceTestCase, s *DeletableResource resSpec := tc.inputFunc() - res := resource.NewDeletableResource(resSpec, []*spec.ResourceSpec{}, s.releaseNamespace, resource.DeletableResourceOptions{}) + res, _ := resource.NewDeletableResource(resSpec, []*spec.ResourceSpec{}, s.releaseNamespace, resource.DeletableResourceOptions{}) expectRes := tc.expectFunc(resSpec) @@ -1288,7 +1288,7 @@ func runInstallableResourceTest(tc installableResourceTestCase, s *InstallableRe resSpec := tc.input() - res, err := resource.NewInstallableResource(resSpec, s.releaseNamespace, s.clientFactory, resource.InstallableResourceOptions{}) + res, err := resource.NewInstallableResource(resSpec, nil, s.releaseNamespace, s.clientFactory, resource.InstallableResourceOptions{}) s.Require().NoError(err) expectRes := tc.expect(resSpec) diff --git a/pkg/test/comparer.go b/pkg/test/comparer.go index 82cb09dd..4e1e5624 100644 --- a/pkg/test/comparer.go +++ b/pkg/test/comparer.go @@ -21,7 +21,7 @@ func CompareInternalDependencyOption() cmp.Option { SpewKeys: true, } - return cmpopts.SortSlices(func(a, b *resource.InternalDependency) bool { + return cmpopts.SortSlices(func(a, b *resource.Dependency) bool { return sp.Sdump(a) < sp.Sdump(b) }) } From 438a1e2b230532fcc45a2ed6bfcff348c009938f Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Mon, 18 May 2026 16:46:10 +0300 Subject: [PATCH 33/85] fix: add SetupColorLevel function for enhanced logging color configuration (for werf) (#620) Signed-off-by: Dmitry Mordvinov --- pkg/action/setup_logging.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/action/setup_logging.go b/pkg/action/setup_logging.go index f5b02516..ae0fec97 100644 --- a/pkg/action/setup_logging.go +++ b/pkg/action/setup_logging.go @@ -114,12 +114,16 @@ func SetupLogging(ctx context.Context, logLevel log.Level, opts SetupLoggingOpti panic(fmt.Sprintf("unknown log level %q", logLevel)) } + SetupColorLevel(opts) + + return ctx +} + +func SetupColorLevel(opts SetupLoggingOptions) { colorLevel := getColorLevel(opts.ColorMode, opts.LogIsParseable) color.Enable = colorLevel != terminfo.ColorLevelNone color.ForceSetColorLevel(colorLevel) - - return ctx } func getColorLevel(mode string, logIsParseable bool) terminfo.ColorLevel { From 18ad551d41f5acfd7cee25762b91cdc103764814 Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Mon, 25 May 2026 16:48:45 +0300 Subject: [PATCH 34/85] feat: use transformers and patchers on rollback (#624) Signed-off-by: Ilya Drey --- pkg/action/release_rollback.go | 36 +++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/pkg/action/release_rollback.go b/pkg/action/release_rollback.go index 00ea8ca6..ec2133f5 100644 --- a/pkg/action/release_rollback.go +++ b/pkg/action/release_rollback.go @@ -228,7 +228,31 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r return fmt.Errorf("convert release to rollback to resource specs: %w", err) } - newRelease, err := release.NewRelease(releaseName, releaseNamespace, newRevision, deployType, rollbackReleaseResSpecs, rollbackRelease.Chart, rollbackRelease.Config, release.ReleaseOptions{ + log.Default.Debug(ctx, "Build transformed resource specs") + + transformedResSpecs, err := spec.BuildTransformedResourceSpecs(ctx, releaseNamespace, rollbackReleaseResSpecs, []spec.ResourceTransformer{ + spec.NewResourceListsTransformer(), + }) + if err != nil { + return fmt.Errorf("build transformed resource specs: %w", err) + } + + log.Default.Debug(ctx, "Build releasable resource specs") + + patchers := []spec.ResourcePatcher{ + spec.NewSecretStringDataPatcher(), + } + + if opts.LegacyHelmCompatibleTracking { + patchers = append(patchers, spec.NewLegacyOnlyTrackJobsPatcher()) + } + + releasableResSpecs, err := spec.BuildPatchedResourceSpecs(ctx, releaseNamespace, transformedResSpecs, patchers) + if err != nil { + return fmt.Errorf("build releasable resource specs: %w", err) + } + + newRelease, err := release.NewRelease(releaseName, releaseNamespace, newRevision, deployType, releasableResSpecs, rollbackRelease.Chart, rollbackRelease.Config, release.ReleaseOptions{ InfoAnnotations: opts.ReleaseInfoAnnotations, Labels: lo.Assign(rollbackRelease.Labels, opts.ReleaseLabels), Notes: rollbackRelease.Info.Notes, @@ -253,16 +277,10 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r log.Default.Debug(ctx, "Build resources") - patchers := []spec.ResourcePatcher{ + instResources, delResources, err := resource.BuildResources(ctx, deployType, releaseNamespace, prevRelResSpecs, newRelResSpecs, []spec.ResourcePatcher{ spec.NewReleaseMetadataPatcher(releaseName, releaseNamespace), spec.NewExtraMetadataPatcher(opts.ExtraRuntimeAnnotations, opts.ExtraRuntimeLabels), - } - - if opts.LegacyHelmCompatibleTracking { - patchers = append(patchers, spec.NewLegacyOnlyTrackJobsPatcher()) - } - - instResources, delResources, err := resource.BuildResources(ctx, deployType, releaseNamespace, prevRelResSpecs, newRelResSpecs, patchers, clientFactory, resource.BuildResourcesOptions{ + }, clientFactory, resource.BuildResourcesOptions{ Remote: true, DefaultDeletePropagation: metav1.DeletionPropagation(opts.DefaultDeletePropagation), NoPodLogs: opts.NoPodLogs, From 8528a0abf3f90d97560ab59b557499c412a4dfdb Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Tue, 26 May 2026 18:23:57 +0300 Subject: [PATCH 35/85] feat: remove deprecated external dependencies annotations (#625) Signed-off-by: Dmitry Mordvinov --- README.md | 29 +--- pkg/action/chart_lint.go | 3 +- pkg/action/release_install.go | 6 +- pkg/action/release_plan_install.go | 3 +- pkg/action/release_rollback.go | 3 +- pkg/action/release_uninstall.go | 3 +- pkg/common/common.go | 35 ++--- pkg/plan/plan_build.go | 14 -- pkg/resource/dependency.go | 6 - pkg/resource/helpers_ai_test.go | 3 +- pkg/resource/metadata.go | 233 ++--------------------------- pkg/resource/resource.go | 27 +--- pkg/resource/resource_test.go | 12 +- 13 files changed, 41 insertions(+), 336 deletions(-) diff --git a/README.md b/README.md index d739695f..23d4394f 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,7 @@ Nelm is production-ready: as the werf deployment engine, it was battle-tested ac - [`werf.io/weight` annotation](#werfioweight-annotation) - [`werf.io/deploy-dependency-` annotation](#werfiodeploy-dependency-id-annotation) - [`werf.io/delete-dependency-` annotation](#werfiodelete-dependency-id-annotation) - - [`.external-dependency.werf.io/resource` annotation](#idexternal-dependencywerfioresource-annotation) - - [`.external-dependency.werf.io/name` annotation](#idexternal-dependencywerfioname-annotation) + - [`werf.io/ownership` annotation](#werfioownership-annotation) - [`werf.io/deploy-on` annotation](#werfiodeploy-on-annotation) - [`werf.io/delete-policy` annotation](#werfiodelete-policy-annotation) @@ -215,7 +214,6 @@ Generally, the migration from Helm to Nelm should be as simple as changing Helm The resource deployment subsystem of Helm is rewritten from scratch in Nelm. During the deployment, Nelm builds the Directed Acyclic Graph (DAG) of all operations we want to perform in the cluster to do the release, then the DAG is executed. The DAG allowed us to implement advanced resource ordering capabilities, such as: * The `werf.io/weight` annotation: similar to `helm.sh/hook-weight`, but also works for non-hook resources. Resources with the same weight deployed in parallel. * The `werf.io/deploy-dependency-` annotation: do not deploy the annotated resource until the dependency is present or ready. This is the most powerful and effective way to enforce deployment order in Nelm. -* The `.external-dependency.werf.io/resource` annotation: do not deploy the annotated resource until the dependency is ready. The dependency can be an external, non-release resource, e.g. a resource created by a third-party operator. * Helm Hooks and their weights are supported, too. ![ordering](resources/images/graph.png) @@ -390,32 +388,7 @@ Format: werf.io/delete-dependency-: state=absent[,name=][,namespace=][,kind=][,group=][,version=] ``` -### `.external-dependency.werf.io/resource` annotation - -The resource will deploy only after all of its external dependencies are satisfied. It waits until the specified resource is `present` and `ready`. You can only point to resources outside the release. - -Example: -```yaml -secret.external-dependency.werf.io/resource: secret/config -someapp.external-dependency.werf.io/resource: deployments.v1.apps/app -``` -Format: -``` -.external-dependency.werf.io/resource: [..]/ -``` -### `.external-dependency.werf.io/name` annotation - -Set the namespace of the external dependency defined by `.external-dependency.werf.io/resource`. `` must match on both annotations. If not specified, the release namespace is used. - -Example: -```yaml -someapp.external-dependency.werf.io/name: someapp-production -``` -Format: -``` -.external-dependency.werf.io/name: -``` ### `werf.io/ownership` annotation diff --git a/pkg/action/chart_lint.go b/pkg/action/chart_lint.go index 39b21125..b67a16b7 100644 --- a/pkg/action/chart_lint.go +++ b/pkg/action/chart_lint.go @@ -321,8 +321,7 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { instResources, delResources, err := resource.BuildResources(ctx, deployType, opts.ReleaseNamespace, prevRelResSpecs, newRelResSpecs, []spec.ResourcePatcher{ spec.NewReleaseMetadataPatcher(opts.ReleaseName, opts.ReleaseNamespace), spec.NewExtraMetadataPatcher(opts.ExtraRuntimeAnnotations, opts.ExtraRuntimeLabels), - }, clientFactory, resource.BuildResourcesOptions{ - Remote: opts.Remote, + }, resource.BuildResourcesOptions{ DefaultDeletePropagation: metav1.DeletionPropagation(opts.DefaultDeletePropagation), }) if err != nil { diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index 60e09db8..b64a85bc 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -430,8 +430,7 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re instResources, delResources, err := resource.BuildResources(ctx, deployType, releaseNamespace, prevRelResSpecs, newRelResSpecs, []spec.ResourcePatcher{ spec.NewReleaseMetadataPatcher(releaseName, releaseNamespace), spec.NewExtraMetadataPatcher(opts.ExtraRuntimeAnnotations, opts.ExtraRuntimeLabels), - }, clientFactory, resource.BuildResourcesOptions{ - Remote: true, + }, resource.BuildResourcesOptions{ DefaultDeletePropagation: metav1.DeletionPropagation(opts.DefaultDeletePropagation), NoPodLogs: opts.NoPodLogs, }) @@ -846,8 +845,7 @@ func runRollbackPlan(ctx context.Context, releaseName, releaseNamespace string, instResources, delResources, err := resource.BuildResources(ctx, common.DeployTypeRollback, releaseNamespace, failedRelResSpecs, newRelResSpecs, []spec.ResourcePatcher{ spec.NewReleaseMetadataPatcher(releaseName, releaseNamespace), spec.NewExtraMetadataPatcher(opts.ExtraRuntimeAnnotations, opts.ExtraRuntimeLabels), - }, clientFactory, resource.BuildResourcesOptions{ - Remote: true, + }, resource.BuildResourcesOptions{ DefaultDeletePropagation: metav1.DeletionPropagation(opts.DefaultDeletePropagation), NoPodLogs: opts.NoPodLogs, }) diff --git a/pkg/action/release_plan_install.go b/pkg/action/release_plan_install.go index 07a45ac2..eebcd1af 100644 --- a/pkg/action/release_plan_install.go +++ b/pkg/action/release_plan_install.go @@ -336,8 +336,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc instResources, delResources, err := resource.BuildResources(ctx, deployType, releaseNamespace, prevRelResSpecs, newRelResSpecs, []spec.ResourcePatcher{ spec.NewReleaseMetadataPatcher(releaseName, releaseNamespace), spec.NewExtraMetadataPatcher(opts.ExtraRuntimeAnnotations, opts.ExtraRuntimeLabels), - }, clientFactory, resource.BuildResourcesOptions{ - Remote: true, + }, resource.BuildResourcesOptions{ DefaultDeletePropagation: metav1.DeletionPropagation(opts.DefaultDeletePropagation), }) if err != nil { diff --git a/pkg/action/release_rollback.go b/pkg/action/release_rollback.go index ec2133f5..dd24cdc7 100644 --- a/pkg/action/release_rollback.go +++ b/pkg/action/release_rollback.go @@ -280,8 +280,7 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r instResources, delResources, err := resource.BuildResources(ctx, deployType, releaseNamespace, prevRelResSpecs, newRelResSpecs, []spec.ResourcePatcher{ spec.NewReleaseMetadataPatcher(releaseName, releaseNamespace), spec.NewExtraMetadataPatcher(opts.ExtraRuntimeAnnotations, opts.ExtraRuntimeLabels), - }, clientFactory, resource.BuildResourcesOptions{ - Remote: true, + }, resource.BuildResourcesOptions{ DefaultDeletePropagation: metav1.DeletionPropagation(opts.DefaultDeletePropagation), NoPodLogs: opts.NoPodLogs, }) diff --git a/pkg/action/release_uninstall.go b/pkg/action/release_uninstall.go index f3b7934c..9004fb07 100644 --- a/pkg/action/release_uninstall.go +++ b/pkg/action/release_uninstall.go @@ -220,8 +220,7 @@ func releaseUninstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, log.Default.Debug(ctx, "Build resources") - instResources, delResources, err := resource.BuildResources(ctx, deployType, releaseNamespace, prevRelResSpecs, nil, patchers, clientFactory, resource.BuildResourcesOptions{ - Remote: true, + instResources, delResources, err := resource.BuildResources(ctx, deployType, releaseNamespace, prevRelResSpecs, nil, patchers, resource.BuildResourcesOptions{ DefaultDeletePropagation: metav1.DeletionPropagation(opts.DefaultDeletePropagation), NoPodLogs: opts.NoPodLogs, }) diff --git a/pkg/common/common.go b/pkg/common/common.go index 8f2e8d28..0099a30a 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -211,27 +211,20 @@ var ( AnnotationKeyPatternDeployDependency = regexp.MustCompile(`^werf.io/deploy-dependency-(?P.+)$`) AnnotationKeyHumanDeleteDependency = "werf.io/delete-dependency-" AnnotationKeyPatternDeleteDependency = regexp.MustCompile(`^werf.io/delete-dependency-(?P.+)$`) - // TODO(major): get rid - AnnotationKeyHumanExternalDependency = ".external-dependency.werf.io" - AnnotationKeyPatternExternalDependency = regexp.MustCompile(`^(?P.+).external-dependency.werf.io$`) - AnnotationKeyHumanLegacyExternalDependencyResource = ".external-dependency.werf.io/resource" - AnnotationKeyPatternLegacyExternalDependencyResource = regexp.MustCompile(`^(?P.+).external-dependency.werf.io/resource$`) - AnnotationKeyHumanLegacyExternalDependencyNamespace = ".external-dependency.werf.io/namespace" - AnnotationKeyPatternLegacyExternalDependencyNamespace = regexp.MustCompile(`^(?P.+).external-dependency.werf.io/namespace$`) - AnnotationKeyHumanSensitive = "werf.io/sensitive" - AnnotationKeyPatternSensitive = regexp.MustCompile(`^werf.io/sensitive$`) - AnnotationKeyHumanSensitivePaths = "werf.io/sensitive-paths" - AnnotationKeyPatternSensitivePaths = regexp.MustCompile(`^werf.io/sensitive-paths$`) - AnnotationKeyHumanDeployOn = "werf.io/deploy-on" - AnnotationKeyPatternDeployOn = regexp.MustCompile(`^werf.io/deploy-on$`) - AnnotationKeyHumanOwnership = "werf.io/ownership" - AnnotationKeyPatternOwnership = regexp.MustCompile(`^werf.io/ownership$`) - AnnotationKeyHumanDeletePropagation = "werf.io/delete-propagation" - AnnotationKeyPatternDeletePropagation = regexp.MustCompile(`^werf.io/delete-propagation$`) - SprigFuncs = sprig.TxtFuncMap() - DefaultDockerConfig = filepath.Join(userHomeDir(), ".docker") - DefaultPlanArtifactLifetime = 2 * time.Hour - DefaultResourceValidationSchema = []string{ + AnnotationKeyHumanSensitive = "werf.io/sensitive" + AnnotationKeyPatternSensitive = regexp.MustCompile(`^werf.io/sensitive$`) + AnnotationKeyHumanSensitivePaths = "werf.io/sensitive-paths" + AnnotationKeyPatternSensitivePaths = regexp.MustCompile(`^werf.io/sensitive-paths$`) + AnnotationKeyHumanDeployOn = "werf.io/deploy-on" + AnnotationKeyPatternDeployOn = regexp.MustCompile(`^werf.io/deploy-on$`) + AnnotationKeyHumanOwnership = "werf.io/ownership" + AnnotationKeyPatternOwnership = regexp.MustCompile(`^werf.io/ownership$`) + AnnotationKeyHumanDeletePropagation = "werf.io/delete-propagation" + AnnotationKeyPatternDeletePropagation = regexp.MustCompile(`^werf.io/delete-propagation$`) + SprigFuncs = sprig.TxtFuncMap() + DefaultDockerConfig = filepath.Join(userHomeDir(), ".docker") + DefaultPlanArtifactLifetime = 2 * time.Hour + DefaultResourceValidationSchema = []string{ "https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/{{ .NormalizedKubernetesVersion }}-standalone{{ .StrictSuffix }}/{{ .ResourceKind }}{{ .KindSuffix }}.json", "https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json", } diff --git a/pkg/plan/plan_build.go b/pkg/plan/plan_build.go index 308c0387..d77829ce 100644 --- a/pkg/plan/plan_build.go +++ b/pkg/plan/plan_build.go @@ -638,20 +638,6 @@ func addInstallResourceOps(plan *Plan, infos []*InstallableResourceInfo) error { stg = info.Stage } - if info.MustInstall != ResourceInstallTypeNone { - for _, extDep := range info.LocalResource.ExternalDependencies { - trackOp := &Operation{ - Type: OperationTypeTrackPresence, - Version: OperationVersionTrackPresence, - Category: OperationCategoryTrack, - Config: &OperationConfigTrackPresence{ - ResourceMeta: extDep.ResourceMeta, - }, - } - chain.AddOperation(trackOp).Stage(stg).SkipOnDuplicate() - } - } - switch info.MustInstall { case ResourceInstallTypeCreate: createOp := &Operation{ diff --git a/pkg/resource/dependency.go b/pkg/resource/dependency.go index 0180761b..d40fe7b6 100644 --- a/pkg/resource/dependency.go +++ b/pkg/resource/dependency.go @@ -19,12 +19,6 @@ type Dependency struct { MaxMatches int `json:"maxMatches"` } -// TODO(major): remove with external dependency annotations -// Represents a dependency on an external resource outside of the Helm release. -type ExternalDependency struct { - *spec.ResourceMeta `json:"resourceMeta"` -} - func NewResourceMetaFromDependency(dep *Dependency, releaseNamespace string) *spec.ResourceMeta { return spec.NewResourceMeta(dep.Names[0], lo.FirstOrEmpty(dep.Namespaces), releaseNamespace, "", schema.GroupVersionKind{ Group: lo.FirstOrEmpty(dep.Groups), diff --git a/pkg/resource/helpers_ai_test.go b/pkg/resource/helpers_ai_test.go index 2ab2272a..1c88241a 100644 --- a/pkg/resource/helpers_ai_test.go +++ b/pkg/resource/helpers_ai_test.go @@ -3,6 +3,7 @@ package resource_test import ( + "context" "net/http" "net/http/httptest" "os" @@ -102,7 +103,7 @@ func makeInstallableResource(t *testing.T, obj map[string]interface{}, releaseNa unstruct := &unstructured.Unstructured{Object: obj} resSpec := spec.NewResourceSpec(unstruct, releaseNamespace, spec.ResourceSpecOptions{}) - instRes, err := resource.NewInstallableResource(resSpec, releaseNamespace, nil, resource.InstallableResourceOptions{}) + instRes, err := resource.NewInstallableResource(context.Background(), resSpec, nil, releaseNamespace, resource.InstallableResourceOptions{}) require.NoError(t, err) return instRes diff --git a/pkg/resource/metadata.go b/pkg/resource/metadata.go index 941c838a..eaa9771b 100644 --- a/pkg/resource/metadata.go +++ b/pkg/resource/metadata.go @@ -13,7 +13,6 @@ import ( "github.com/ohler55/ojg/jp" "github.com/samber/lo" corev1 "k8s.io/api/core/v1" - apimeta "k8s.io/apimachinery/pkg/api/meta" apiv1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" @@ -21,7 +20,7 @@ import ( "github.com/werf/kubedog/pkg/dyntracker/statestore" "github.com/werf/nelm/pkg/common" helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" - "github.com/werf/nelm/pkg/kube" + "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/resource/spec" "github.com/werf/nelm/pkg/util" ) @@ -119,34 +118,6 @@ func deployConditions(meta *spec.ResourceMeta, hasManualInternalDeps bool) map[c } } -// TODO(major): use deploy/delete deps instead -func externalDependencies(meta *spec.ResourceMeta, releaseNamespace string, clientFactory kube.ClientFactorier, remote bool) ([]*ExternalDependency, error) { - if spec.IsCRD(meta.GroupVersionKind.GroupKind()) { - return nil, nil - } - - deps := externalDeps(meta, releaseNamespace) - - legacyExtDeps := map[string]*ExternalDependency{} - - // Pretend that we don't have any external dependencies when we don't have cluster access, since we need cluster access to map GVR to GVK. - if remote { - var err error - - legacyExtDeps, err = legacyExternalDeps(meta, releaseNamespace, clientFactory.Mapper()) - if err != nil { - return nil, fmt.Errorf("get legacy external dependencies: %w", err) - } - } - - duplResult := lo.Values(lo.Assign(legacyExtDeps, deps)) - uniqResult := lo.UniqBy(duplResult, func(d *ExternalDependency) string { - return d.ID() - }) - - return uniqResult, nil -} - func manualDeleteDependencies(meta *spec.ResourceMeta, otherResMeta []*spec.ResourceMeta) []*Dependency { deps := map[string]*Dependency{} @@ -612,49 +583,6 @@ func deployConditionsForAnnotation(meta *spec.ResourceMeta, annoPattern *regexp. return result } -func externalDeps(resMeta *spec.ResourceMeta, releaseNamespace string) map[string]*ExternalDependency { - deps := map[string]*ExternalDependency{} - if annotations, found := spec.FindAnnotationsOrLabelsByKeyPattern(resMeta.Annotations, common.AnnotationKeyPatternExternalDependency); found { - for key, value := range annotations { - matches := common.AnnotationKeyPatternExternalDependency.FindStringSubmatch(key) - idSubexpIndex := common.AnnotationKeyPatternExternalDependency.SubexpIndex("id") - depID := matches[idSubexpIndex] - valParts := strings.Split(value, ":") - depAPIVersionParts := strings.SplitN(valParts[0], "/", 2) - - var gvk schema.GroupVersionKind - if len(depAPIVersionParts) == 1 { - gvk = schema.GroupVersionKind{ - Version: depAPIVersionParts[0], - Kind: valParts[1], - } - } else { - gvk = schema.GroupVersionKind{ - Group: depAPIVersionParts[0], - Version: depAPIVersionParts[1], - Kind: valParts[1], - } - } - - var depNamespace string - if len(valParts) == 4 { - depNamespace = valParts[2] - } - - depName := valParts[len(valParts)-1] - - resMeta := spec.NewResourceMeta(depName, depNamespace, releaseNamespace, "", gvk, nil, nil) - dep := &ExternalDependency{ - ResourceMeta: resMeta, - } - - deps[depID] = dep - } - } - - return deps -} - func failMode(meta *spec.ResourceMeta) statestore.FailMode { _, value, found := spec.FindAnnotationOrLabelByKeyPattern(meta.Annotations, common.AnnotationKeyPatternFailMode) if !found { @@ -736,63 +664,6 @@ func isExternalDependency(matcher *spec.ResourceMatcher, otherResMeta []*spec.Re } } -// TODO(major): get rid of legacy external deps -func legacyExternalDeps(resMeta *spec.ResourceMeta, releaseNamespace string, mapper apimeta.ResettableRESTMapper) (map[string]*ExternalDependency, error) { - deps := map[string]*ExternalDependency{} - - type DepInfo struct { - Name string - Namespace string - Type string - } - - extDepInfos := map[string]*DepInfo{} - - if annotations, found := spec.FindAnnotationsOrLabelsByKeyPattern(resMeta.Annotations, common.AnnotationKeyPatternLegacyExternalDependencyResource); found { - for key, value := range annotations { - matches := common.AnnotationKeyPatternLegacyExternalDependencyResource.FindStringSubmatch(key) - idSubexpIndex := common.AnnotationKeyPatternLegacyExternalDependencyResource.SubexpIndex("id") - extDepID := matches[idSubexpIndex] - extDepType := strings.Split(value, "/")[0] - extDepName := strings.Split(value, "/")[1] - - extDepInfos[extDepID] = &DepInfo{ - Name: extDepName, - Type: extDepType, - } - } - } - - if annotations, found := spec.FindAnnotationsOrLabelsByKeyPattern(resMeta.Annotations, common.AnnotationKeyPatternLegacyExternalDependencyNamespace); found { - for key, value := range annotations { - matches := common.AnnotationKeyPatternLegacyExternalDependencyNamespace.FindStringSubmatch(key) - idSubexpIndex := common.AnnotationKeyPatternLegacyExternalDependencyNamespace.SubexpIndex("id") - extDepID := matches[idSubexpIndex] - extDepNamespace := value - - if extDepInfo, hasKey := extDepInfos[extDepID]; hasKey { - extDepInfo.Namespace = extDepNamespace - } - } - } - - for extDepID, extDepInfo := range extDepInfos { - gvk, err := spec.ParseKubectlResourceStringtoGVK(extDepInfo.Type, mapper) - if err != nil { - return nil, fmt.Errorf("parse external dependency resource type %q for dependency %q (namespace: %q): %w", extDepInfo.Type, extDepInfo.Name, extDepInfo.Namespace, err) - } - - resMeta := spec.NewResourceMeta(extDepInfo.Name, extDepInfo.Namespace, releaseNamespace, "", gvk, nil, nil) - dep := &ExternalDependency{ - ResourceMeta: resMeta, - } - - deps[extDepID] = dep - } - - return deps, nil -} - func logRegex(meta *spec.ResourceMeta) *regexp.Regexp { _, value, found := spec.FindAnnotationOrLabelByKeyPattern(meta.Annotations, common.AnnotationKeyPatternLogRegex) if !found { @@ -1043,98 +914,6 @@ func validateDeployOn(meta *spec.ResourceMeta) error { return nil } -func validateExternalDependencies(meta *spec.ResourceMeta) error { - if annotations, found := spec.FindAnnotationsOrLabelsByKeyPattern(meta.Annotations, common.AnnotationKeyPatternExternalDependency); found { - for key, value := range annotations { - keyMatches := common.AnnotationKeyPatternExternalDependency.FindStringSubmatch(key) - if keyMatches == nil { - return fmt.Errorf("invalid key for annotation %q", key) - } - - idSubexpIndex := common.AnnotationKeyPatternExternalDependency.SubexpIndex("id") - if idSubexpIndex == -1 { - return fmt.Errorf("invalid regexp pattern %q for annotation %q", common.AnnotationKeyPatternExternalDependency.String(), key) - } - - if len(keyMatches) < idSubexpIndex+1 { - return fmt.Errorf("can't parse external dependency id from annotation key %q", key) - } - - valueElems := strings.Split(value, ":") - - if len(valueElems) != 3 && len(valueElems) != 4 { - return fmt.Errorf(`invalid format of value %q for annotation %q, should be: apiVersion:kind[:namespace]:name`, value, key) - } - } - } - - if annotations, found := spec.FindAnnotationsOrLabelsByKeyPattern(meta.Annotations, common.AnnotationKeyPatternLegacyExternalDependencyResource); found { - for key, value := range annotations { - keyMatches := common.AnnotationKeyPatternLegacyExternalDependencyResource.FindStringSubmatch(key) - if keyMatches == nil { - return fmt.Errorf("invalid key for annotation %q", key) - } - - idSubexpIndex := common.AnnotationKeyPatternLegacyExternalDependencyResource.SubexpIndex("id") - if idSubexpIndex == -1 { - return fmt.Errorf("invalid regexp pattern %q for annotation %q", common.AnnotationKeyPatternLegacyExternalDependencyResource.String(), key) - } - - if len(keyMatches) < idSubexpIndex+1 { - return fmt.Errorf("can't parse external dependency id from annotation key %q", key) - } - - valueElems := strings.Split(value, "/") - - if len(valueElems) != 2 { - return fmt.Errorf(`invalid format of value %q for annotation %q, should be: type/name`, value, key) - } - - switch valueElems[0] { - case "": - return fmt.Errorf("value %q of annotation %q can't have empty resource type", value, key) - case "all": - return fmt.Errorf(`"all" resource type in value %q of annotation %q is not allowed`, value, key) - } - - resourceTypeParts := strings.Split(valueElems[0], ".") - for _, part := range resourceTypeParts { - if part == "" { - return fmt.Errorf("resource type in value %q of annotation %q should have dots (.) delimiting only non-empty resource.version.group", value, key) - } - } - - if valueElems[1] == "" { - return fmt.Errorf("in value %q of annotation %q resource name can't be empty", value, key) - } - } - } - - if annotations, found := spec.FindAnnotationsOrLabelsByKeyPattern(meta.Annotations, common.AnnotationKeyPatternLegacyExternalDependencyNamespace); found { - for key, value := range annotations { - keyMatches := common.AnnotationKeyPatternLegacyExternalDependencyNamespace.FindStringSubmatch(key) - if keyMatches == nil { - return fmt.Errorf("invalid key for annotation %q", key) - } - - idSubexpIndex := common.AnnotationKeyPatternLegacyExternalDependencyNamespace.SubexpIndex("id") - if idSubexpIndex == -1 { - return fmt.Errorf("invalid regexp pattern %q for annotation %q", common.AnnotationKeyPatternLegacyExternalDependencyNamespace.String(), key) - } - - if len(keyMatches) < idSubexpIndex+1 { - return fmt.Errorf("can't parse external dependency id from annotation key %q", key) - } - - if value == "" { - return fmt.Errorf("invalid value %q for annotation %q, value must not be empty", value, key) - } - } - } - - return nil -} - func validateHook(meta *spec.ResourceMeta) error { if key, value, found := spec.FindAnnotationOrLabelByKeyPattern(meta.Annotations, common.AnnotationKeyPatternHook); found { if value == "" { @@ -1487,6 +1266,16 @@ func validateWeight(meta *spec.ResourceMeta) error { return nil } +func warnDeprecatedExternalDependencies(ctx context.Context, meta *spec.ResourceMeta) { + deprecatedAnnotationPattern := regexp.MustCompile(`^(?P.+)\.external-dependency\.werf\.io(/resource|/namespace)?$`) + + if annotations, found := spec.FindAnnotationsOrLabelsByKeyPattern(meta.Annotations, deprecatedAnnotationPattern); found { + for key := range annotations { + log.Default.Warn(ctx, "annotation %q in resource %q is no longer supported, use %q or %q instead", key, meta.IDHuman(), common.AnnotationKeyHumanDeployDependency, common.AnnotationKeyHumanDeleteDependency) + } + } +} + func weight(meta *spec.ResourceMeta, hasManualInternalDeps bool) *int { if hasManualInternalDeps { return nil diff --git a/pkg/resource/resource.go b/pkg/resource/resource.go index 6d239519..6bb3e79b 100644 --- a/pkg/resource/resource.go +++ b/pkg/resource/resource.go @@ -13,7 +13,6 @@ import ( "github.com/werf/kubedog/pkg/dyntracker/statestore" "github.com/werf/nelm/pkg/common" - "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/resource/spec" ) @@ -47,14 +46,13 @@ type InstallableResource struct { Weight *int `json:"weight,omitempty"` ManualDependencies []*Dependency `json:"manualDependencies,omitempty"` AutoInternalDependencies []*Dependency `json:"autoInternalDependencies,omitempty"` - ExternalDependencies []*ExternalDependency `json:"externalDependencies,omitempty"` DeployConditions map[common.On][]common.Stage `json:"deployConditions"` DeletePropagation metav1.DeletionPropagation `json:"deletePropagation"` } // Construct an InstallableResource from a ResourceSpec. Must never contact the cluster, because // this is called even when no cluster access allowed. -func NewInstallableResource(res *spec.ResourceSpec, otherResourceSpecs []*spec.ResourceSpec, releaseNamespace string, clientFactory kube.ClientFactorier, opts InstallableResourceOptions) (*InstallableResource, error) { +func NewInstallableResource(ctx context.Context, res *spec.ResourceSpec, otherResourceSpecs []*spec.ResourceSpec, releaseNamespace string, opts InstallableResourceOptions) (*InstallableResource, error) { otherResourceMetaList := lo.Map(otherResourceSpecs, func(resSpec *spec.ResourceSpec, _ int) *spec.ResourceMeta { return resSpec.ResourceMeta }) @@ -91,9 +89,7 @@ func NewInstallableResource(res *spec.ResourceSpec, otherResourceSpecs []*spec.R return nil, fmt.Errorf("validate delete dependencies: %w", err) } - if err := validateExternalDependencies(res.ResourceMeta); err != nil { - return nil, fmt.Errorf("validate external dependencies: %w", err) - } + warnDeprecatedExternalDependencies(ctx, res.ResourceMeta) if err := validateSensitive(res.ResourceMeta); err != nil { return nil, fmt.Errorf("validate sensitive: %w", err) @@ -111,11 +107,6 @@ func NewInstallableResource(res *spec.ResourceSpec, otherResourceSpecs []*spec.R return nil, fmt.Errorf("validate delete propagation: %w", err) } - extDeps, err := externalDependencies(res.ResourceMeta, releaseNamespace, clientFactory, opts.Remote) - if err != nil { - return nil, fmt.Errorf("get external dependencies: %w", err) - } - manDeps := manualDeployDependencies(res.ResourceMeta, otherResourceMetaList) internalDeps := lo.Filter(manDeps, func(item *Dependency, _ int) bool { return !item.External @@ -129,7 +120,6 @@ func NewInstallableResource(res *spec.ResourceSpec, otherResourceSpecs []*spec.R DeleteOnSucceeded: deleteOnSucceeded(res.ResourceMeta), DeletePropagation: deletePropagation(res.ResourceMeta, opts.DefaultDeletePropagation), DeployConditions: deployConditions(res.ResourceMeta, len(internalDeps) > 0), - ExternalDependencies: extDeps, FailMode: failMode(res.ResourceMeta), FailuresAllowed: failuresAllowed(res.Unstruct), IgnoreReadinessProbeFailsForContainers: ignoreReadinessProbeFailsForContainers(res.ResourceMeta), @@ -156,7 +146,6 @@ func NewInstallableResource(res *spec.ResourceSpec, otherResourceSpecs []*spec.R type InstallableResourceOptions struct { DefaultDeletePropagation metav1.DeletionPropagation NoPodLogs bool - Remote bool } // Represent a Kubernetes resource that can be deleted. Higher level than ResourceMeta, but lower @@ -225,13 +214,12 @@ type DeletableResourceOptions struct { type BuildResourcesOptions struct { DefaultDeletePropagation metav1.DeletionPropagation NoPodLogs bool - Remote bool } // Build Installable/DeletableResources from ResourceSpecs. Resulting Resources can be used to // construct Installable/DeletableResourceInfos later. Must never contact the cluster, because this // is called even when no cluster access allowed. -func BuildResources(ctx context.Context, deployType common.DeployType, releaseNamespace string, prevRelResSpecs, newRelResSpecs []*spec.ResourceSpec, patchers []spec.ResourcePatcher, clientFactory kube.ClientFactorier, opts BuildResourcesOptions) ([]*InstallableResource, []*DeletableResource, error) { +func BuildResources(ctx context.Context, deployType common.DeployType, releaseNamespace string, prevRelResSpecs, newRelResSpecs []*spec.ResourceSpec, patchers []spec.ResourcePatcher, opts BuildResourcesOptions) ([]*InstallableResource, []*DeletableResource, error) { var prevRelDelResources []*DeletableResource for _, resSpec := range prevRelResSpecs { deletableRes, err := NewDeletableResource(resSpec, lo.Without(prevRelResSpecs, resSpec), releaseNamespace, DeletableResourceOptions{ @@ -246,10 +234,9 @@ func BuildResources(ctx context.Context, deployType common.DeployType, releaseNa var prevRelInstResources []*InstallableResource for _, resSpec := range prevRelResSpecs { - installableResource, err := NewInstallableResource(resSpec, lo.Without(prevRelResSpecs, resSpec), releaseNamespace, clientFactory, InstallableResourceOptions{ + installableResource, err := NewInstallableResource(ctx, resSpec, lo.Without(prevRelResSpecs, resSpec), releaseNamespace, InstallableResourceOptions{ DefaultDeletePropagation: opts.DefaultDeletePropagation, NoPodLogs: opts.NoPodLogs, - Remote: opts.Remote, }) if err != nil { return nil, nil, fmt.Errorf("construct installable resource: %w", err) @@ -260,10 +247,9 @@ func BuildResources(ctx context.Context, deployType common.DeployType, releaseNa var newRelInstResources []*InstallableResource for _, resSpec := range newRelResSpecs { - installableResource, err := NewInstallableResource(resSpec, lo.Without(newRelResSpecs, resSpec), releaseNamespace, clientFactory, InstallableResourceOptions{ + installableResource, err := NewInstallableResource(ctx, resSpec, lo.Without(newRelResSpecs, resSpec), releaseNamespace, InstallableResourceOptions{ DefaultDeletePropagation: opts.DefaultDeletePropagation, NoPodLogs: opts.NoPodLogs, - Remote: opts.Remote, }) if err != nil { return nil, nil, fmt.Errorf("construct installable resource: %w", err) @@ -361,10 +347,9 @@ func BuildResources(ctx context.Context, deployType common.DeployType, releaseNa var instResources []*InstallableResource for _, resSpec := range patchedResSpecs { - instRes, err := NewInstallableResource(resSpec, lo.Without(patchedResSpecs, resSpec), releaseNamespace, clientFactory, InstallableResourceOptions{ + instRes, err := NewInstallableResource(ctx, resSpec, lo.Without(patchedResSpecs, resSpec), releaseNamespace, InstallableResourceOptions{ DefaultDeletePropagation: opts.DefaultDeletePropagation, NoPodLogs: opts.NoPodLogs, - Remote: opts.Remote, }) if err != nil { return nil, nil, fmt.Errorf("construct deployable resource: %w", err) diff --git a/pkg/resource/resource_test.go b/pkg/resource/resource_test.go index 460c9217..968448b5 100644 --- a/pkg/resource/resource_test.go +++ b/pkg/resource/resource_test.go @@ -16,8 +16,6 @@ import ( "github.com/werf/kubedog/pkg/dyntracker/statestore" "github.com/werf/nelm/pkg/common" - "github.com/werf/nelm/pkg/kube" - "github.com/werf/nelm/pkg/kube/fake" "github.com/werf/nelm/pkg/resource" "github.com/werf/nelm/pkg/resource/spec" "github.com/werf/nelm/pkg/test" @@ -26,25 +24,17 @@ import ( type InstallableResourceSuite struct { suite.Suite - clientFactory kube.ClientFactorier cmpOpts cmp.Options releaseNamespace string } func (s *InstallableResourceSuite) SetupSuite() { - ctx := context.Background() - s.releaseNamespace = "test-namespace" s.cmpOpts = cmp.Options{ cmpopts.EquateEmpty(), test.CompareRegexpOption(), test.CompareInternalDependencyOption(), } - - var err error - - s.clientFactory, err = fake.NewClientFactory(ctx) - s.Require().NoError(err) } func (s *InstallableResourceSuite) TestNewInstallableResourceForDefaults() { @@ -1288,7 +1278,7 @@ func runInstallableResourceTest(tc installableResourceTestCase, s *InstallableRe resSpec := tc.input() - res, err := resource.NewInstallableResource(resSpec, nil, s.releaseNamespace, s.clientFactory, resource.InstallableResourceOptions{}) + res, err := resource.NewInstallableResource(context.Background(), resSpec, nil, s.releaseNamespace, resource.InstallableResourceOptions{}) s.Require().NoError(err) expectRes := tc.expect(resSpec) From adf8a42899d5bbd523883ad4a0594fe67d16ccf7 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Tue, 26 May 2026 19:40:40 +0300 Subject: [PATCH 36/85] feat: case insensitive group/version/kind for `deploy/delete-dependency` (#626) Signed-off-by: Dmitry Mordvinov --- go.mod | 2 +- go.sum | 2 ++ pkg/action/release_uninstall.go | 3 ++- pkg/plan/plan_execute.go | 14 ++++++++++---- pkg/resource/spec/resource_match.go | 8 +++++--- 5 files changed, 20 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 0dc18372..93c3e13a 100644 --- a/go.mod +++ b/go.mod @@ -57,7 +57,7 @@ require ( github.com/tidwall/sjson v1.2.5 github.com/wI2L/jsondiff v0.7.0 github.com/werf/common-go v0.0.0-20260428201303-b0dcadceca5c - github.com/werf/kubedog v0.13.1-0.20260320165832-7d97aaf7aab9 + github.com/werf/kubedog v0.13.1-0.20260521123751-aabed6e8f8ac github.com/werf/lockgate v0.1.1 github.com/werf/logboek v0.6.1 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e diff --git a/go.sum b/go.sum index 4b4df940..efa5b736 100644 --- a/go.sum +++ b/go.sum @@ -408,6 +408,8 @@ github.com/werf/common-go v0.0.0-20260428201303-b0dcadceca5c h1:4/qKB2licflIlCZG github.com/werf/common-go v0.0.0-20260428201303-b0dcadceca5c/go.mod h1:DlN/hD9tXLxYgdAMkulQdzHkiaPrvs3MzgPZvjluea4= github.com/werf/kubedog v0.13.1-0.20260320165832-7d97aaf7aab9 h1:N+XKTPiXT5pf5lxThhaQQPARLUpZTlYJeMNoNtn+540= github.com/werf/kubedog v0.13.1-0.20260320165832-7d97aaf7aab9/go.mod h1:93L6aIdpj7iIhL30Obkv7bWgUyTeuxas1ijtzjmyb4Q= +github.com/werf/kubedog v0.13.1-0.20260521123751-aabed6e8f8ac h1:iQlaWzQdWkl/Ad6SZ0a0Un3WQNPz7OmzJ3RBFdj7ICk= +github.com/werf/kubedog v0.13.1-0.20260521123751-aabed6e8f8ac/go.mod h1:tU8RyC5qXvJMPT2LmaNnVINWvwDHao0Ca3Bw9P8JC3E= github.com/werf/lockgate v0.1.1 h1:S400JFYjtWfE4i4LY9FA8zx0fMdfui9DPrBiTciCrx4= github.com/werf/lockgate v0.1.1/go.mod h1:0yIFSLq9ausy6ejNxF5uUBf/Ib6daMAfXuCaTMZJzIE= github.com/werf/logboek v0.6.1 h1:oEe6FkmlKg0z0n80oZjLplj6sXcBeLleCkjfOOZEL2g= diff --git a/pkg/action/release_uninstall.go b/pkg/action/release_uninstall.go index 9004fb07..c37e1c1b 100644 --- a/pkg/action/release_uninstall.go +++ b/pkg/action/release_uninstall.go @@ -401,7 +401,8 @@ func releaseUninstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, statestore.NewAbsenceTaskState(nsMeta.Name, "", nsMeta.GroupVersionKind, statestore.AbsenceTaskStateOptions{}), ) tracker := dyntracker.NewDynamicAbsenceTracker(taskState, informerFactory, clientFactory.Dynamic(), clientFactory.Mapper(), dyntracker.DynamicAbsenceTrackerOptions{ - Timeout: opts.TrackDeletionTimeout, + Timeout: opts.TrackDeletionTimeout, + CaseInsensitiveGVKMatching: true, }) if err := tracker.Track(ctx); err != nil { diff --git a/pkg/plan/plan_execute.go b/pkg/plan/plan_execute.go index da97d655..b89349f2 100644 --- a/pkg/plan/plan_execute.go +++ b/pkg/plan/plan_execute.go @@ -178,7 +178,8 @@ func execOpRecreate(ctx context.Context, op *Operation, releaseNamespace string, }) tracker := dyntracker.NewDynamicAbsenceTracker(taskState, informerFactory, clientFactory.Dynamic(), clientFactory.Mapper(), dyntracker.DynamicAbsenceTrackerOptions{ - Timeout: absenceTimeout, + Timeout: absenceTimeout, + CaseInsensitiveGVKMatching: true, }) if err := tracker.Track(ctx); err != nil { @@ -212,7 +213,8 @@ func execOpTrackAbsence(ctx context.Context, op *Operation, releaseNamespace str }) tracker := dyntracker.NewDynamicAbsenceTracker(taskState, informerFactory, clientFactory.Dynamic(), clientFactory.Mapper(), dyntracker.DynamicAbsenceTrackerOptions{ - Timeout: timeout, + Timeout: timeout, + CaseInsensitiveGVKMatching: true, }) if err := tracker.Track(ctx); err != nil { @@ -239,7 +241,8 @@ func execOpTrackPresence(ctx context.Context, op *Operation, releaseNamespace st }) tracker := dyntracker.NewDynamicPresenceTracker(taskState, informerFactory, clientFactory.Dynamic(), clientFactory.Mapper(), dyntracker.DynamicPresenceTrackerOptions{ - Timeout: timeout, + Timeout: timeout, + CaseInsensitiveGVKMatching: true, }) if err := tracker.Track(ctx); err != nil { @@ -272,6 +275,7 @@ func execOpTrackReadiness(ctx context.Context, op *Operation, releaseNamespace s Timeout: timeout, NoActivityTimeout: opConfig.NoActivityTimeout, IgnoreReadinessProbeFailsByContainerName: opConfig.IgnoreReadinessProbeFailsByContainerName, + CaseInsensitiveGVKMatching: true, SaveLogsOnlyForNumberOfReplicas: opConfig.SaveLogsOnlyForNumberOfReplicas, SaveLogsOnlyForContainers: opConfig.SaveLogsOnlyForContainers, SaveLogsByRegex: opConfig.SaveLogsByRegex, @@ -385,8 +389,10 @@ func findExecutableOpsIDs(opsMap map[string]map[string]graph.Edge[string]) []str } func getNamespace(resMeta *spec.ResourceMeta, releaseNamespace string, clientFactory kube.ClientFactorier) (string, error) { + gvk := kdutil.LowercaseGVK(resMeta.GroupVersionKind) + var namespace string - if namespaced, err := spec.Namespaced(resMeta.GroupVersionKind, clientFactory.Mapper()); err != nil { + if namespaced, err := spec.Namespaced(gvk, clientFactory.Mapper()); err != nil { return "", fmt.Errorf("check if resource is namespaced: %w", err) } else if namespaced { if resMeta.Namespace != "" { diff --git a/pkg/resource/spec/resource_match.go b/pkg/resource/spec/resource_match.go index f03c3b66..21cd3a7c 100644 --- a/pkg/resource/spec/resource_match.go +++ b/pkg/resource/spec/resource_match.go @@ -1,5 +1,7 @@ package spec +import "strings" + type ResourceMatcher struct { Names []string `json:"names,omitempty"` Namespaces []string `json:"namespaces,omitempty"` @@ -46,7 +48,7 @@ func (s *ResourceMatcher) Match(resMeta *ResourceMeta) bool { groupMatch = true } else { for _, group := range s.Groups { - if resMeta.GroupVersionKind.Group == group { + if strings.EqualFold(resMeta.GroupVersionKind.Group, group) { groupMatch = true break } @@ -62,7 +64,7 @@ func (s *ResourceMatcher) Match(resMeta *ResourceMeta) bool { versionMatch = true } else { for _, version := range s.Versions { - if resMeta.GroupVersionKind.Version == version { + if strings.EqualFold(resMeta.GroupVersionKind.Version, version) { versionMatch = true break } @@ -78,7 +80,7 @@ func (s *ResourceMatcher) Match(resMeta *ResourceMeta) bool { kindMatch = true } else { for _, kind := range s.Kinds { - if resMeta.GroupVersionKind.Kind == kind { + if strings.EqualFold(resMeta.GroupVersionKind.Kind, kind) { kindMatch = true break } From 74d3ea4ff724173eddb4ded8f33efc5194e50902 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 27 May 2026 14:22:37 +0300 Subject: [PATCH 37/85] fix: correct fork:helm:sync task Signed-off-by: Ilya Drey --- Taskfile.dist.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/Taskfile.dist.yaml b/Taskfile.dist.yaml index 02e9b5f5..9806fede 100644 --- a/Taskfile.dist.yaml +++ b/Taskfile.dist.yaml @@ -505,6 +505,7 @@ tasks: git checkout -B "{{.forkSyncBranch}}" FETCH_HEAD --no-track prev_split=$(git log --grep="git-subtree-dir: {{.forkPrefix}}" --format="%b" "${original_ref}" | grep "^git-subtree-split:" | head -n1 | cut -d' ' -f2) + git fetch "{{.forkTempRemote}}" "$prev_split" if [ -z "$prev_split" ]; then echo "WARNING: no previous subtree split found for {{.forkPrefix}}, skipping file stripping" else From ea85faf1e8c8209d9b58879bcc952583b02e9d82 Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:00:57 +0300 Subject: [PATCH 38/85] feat: upgrade to helm v4.2.0 (#627) Signed-off-by: Ilya Drey --- pkg/helm/intern/chart/v3/chart.go | 4 +- pkg/helm/intern/chart/v3/chart_test.go | 4 +- pkg/helm/intern/chart/v3/dependency.go | 2 +- pkg/helm/intern/chart/v3/errors.go | 2 +- pkg/helm/intern/chart/v3/lint/lint.go | 2 +- pkg/helm/intern/chart/v3/lint/lint_test.go | 8 +- .../intern/chart/v3/lint/rules/chartfile.go | 12 +- .../chart/v3/lint/rules/chartfile_test.go | 12 +- pkg/helm/intern/chart/v3/lint/rules/crds.go | 5 +- .../chart/v3/lint/rules/dependencies_test.go | 4 +- .../chart/v3/lint/rules/deprecations_test.go | 2 +- .../intern/chart/v3/lint/rules/template.go | 6 +- .../chart/v3/lint/rules/template_test.go | 4 +- pkg/helm/intern/chart/v3/lint/rules/values.go | 9 +- .../intern/chart/v3/lint/rules/values_test.go | 20 +- pkg/helm/intern/chart/v3/loader/archive.go | 2 +- pkg/helm/intern/chart/v3/loader/load.go | 14 +- pkg/helm/intern/chart/v3/loader/load_test.go | 26 +- pkg/helm/intern/chart/v3/metadata.go | 3 + pkg/helm/intern/chart/v3/metadata_test.go | 13 +- .../intern/chart/v3/util/chartfile_test.go | 12 +- pkg/helm/intern/chart/v3/util/create.go | 2 +- pkg/helm/intern/chart/v3/util/create_test.go | 2 +- pkg/helm/intern/chart/v3/util/dependencies.go | 32 +- .../intern/chart/v3/util/dependencies_test.go | 8 +- pkg/helm/intern/chart/v3/util/expand.go | 17 + pkg/helm/intern/chart/v3/util/expand_test.go | 84 + pkg/helm/intern/chart/v3/util/save_test.go | 8 +- .../v3/util/testdata/dotdotname/Chart.yaml | 4 + .../chart/v3/util/testdata/dotname/Chart.yaml | 4 + .../v3/util/testdata/slashinname/Chart.yaml | 4 + pkg/helm/intern/gates/doc.go | 19 + pkg/helm/intern/gates/gates.go | 21 + pkg/helm/intern/release/v2/doc.go | 17 + pkg/helm/intern/release/v2/hook.go | 189 +++ pkg/helm/intern/release/v2/hook_test.go | 231 +++ pkg/helm/intern/release/v2/info.go | 125 ++ pkg/helm/intern/release/v2/info_test.go | 285 ++++ pkg/helm/intern/release/v2/mock.go | 143 ++ pkg/helm/intern/release/v2/release.go | 60 + pkg/helm/intern/release/v2/util/filter.go | 81 + .../intern/release/v2/util/filter_test.go | 60 + .../intern/release/v2/util/kind_sorter.go | 165 ++ .../release/v2/util/kind_sorter_test.go | 347 ++++ pkg/helm/intern/release/v2/util/manifest.go | 82 + .../intern/release/v2/util/manifest_sorter.go | 244 +++ .../release/v2/util/manifest_sorter_test.go | 227 +++ .../intern/release/v2/util/manifest_test.go | 517 ++++++ pkg/helm/intern/release/v2/util/sorter.go | 61 + .../intern/release/v2/util/sorter_test.go | 109 ++ pkg/helm/intern/resolver/resolver.go | 2 +- pkg/helm/intern/test/test.go | 4 +- pkg/helm/intern/third_party/dep/fs/fs.go | 3 +- pkg/helm/intern/third_party/dep/fs/fs_test.go | 5 +- pkg/helm/intern/third_party/dep/fs/rename.go | 6 +- pkg/helm/intern/tlsutil/tls.go | 2 +- pkg/helm/intern/tlsutil/tls_test.go | 12 +- pkg/helm/intern/version/clientgo.go | 6 +- pkg/helm/intern/version/version.go | 2 +- pkg/helm/pkg/action/action.go | 253 ++- pkg/helm/pkg/action/action_test.go | 1466 +++++++++++++++-- pkg/helm/pkg/action/dependency.go | 2 +- pkg/helm/pkg/action/package.go | 2 +- pkg/helm/pkg/action/package_test.go | 3 +- pkg/helm/pkg/action/push_test.go | 4 +- pkg/helm/pkg/action/registry_login_test.go | 4 +- pkg/helm/pkg/action/show_test.go | 2 +- pkg/helm/pkg/action/testdata/rbac.txt | 1 + pkg/helm/pkg/chart/common.go | 21 +- pkg/helm/pkg/chart/common/capabilities.go | 6 +- .../pkg/chart/common/capabilities_test.go | 6 +- pkg/helm/pkg/chart/common/util/coalesce.go | 91 +- .../pkg/chart/common/util/coalesce_test.go | 327 +++- pkg/helm/pkg/chart/common/util/jsonschema.go | 2 +- .../pkg/chart/common/util/jsonschema_test.go | 28 +- pkg/helm/pkg/chart/common/util/values.go | 8 +- pkg/helm/pkg/chart/common/util/values_test.go | 16 +- pkg/helm/pkg/chart/common/values.go | 18 +- pkg/helm/pkg/chart/common/values_test.go | 14 +- pkg/helm/pkg/chart/interfaces.go | 8 +- pkg/helm/pkg/chart/loader/archive/archive.go | 2 +- pkg/helm/pkg/chart/v2/chart.go | 4 +- pkg/helm/pkg/chart/v2/chart_test.go | 4 +- pkg/helm/pkg/chart/v2/dependency.go | 2 +- pkg/helm/pkg/chart/v2/errors.go | 2 +- pkg/helm/pkg/chart/v2/lint/lint.go | 2 +- pkg/helm/pkg/chart/v2/lint/lint_test.go | 8 +- pkg/helm/pkg/chart/v2/lint/rules/chartfile.go | 12 +- .../pkg/chart/v2/lint/rules/chartfile_test.go | 12 +- pkg/helm/pkg/chart/v2/lint/rules/crds.go | 5 +- .../chart/v2/lint/rules/dependencies_test.go | 4 +- .../chart/v2/lint/rules/deprecations_test.go | 2 +- .../pkg/chart/v2/lint/rules/template_test.go | 4 +- pkg/helm/pkg/chart/v2/lint/rules/values.go | 9 +- .../pkg/chart/v2/lint/rules/values_test.go | 20 +- pkg/helm/pkg/chart/v2/loader/load.go | 18 +- pkg/helm/pkg/chart/v2/loader/load_test.go | 26 +- pkg/helm/pkg/chart/v2/metadata.go | 3 + pkg/helm/pkg/chart/v2/metadata_test.go | 13 +- pkg/helm/pkg/chart/v2/util/chartfile_test.go | 14 +- pkg/helm/pkg/chart/v2/util/create.go | 2 +- pkg/helm/pkg/chart/v2/util/create_test.go | 4 +- pkg/helm/pkg/chart/v2/util/dependencies.go | 31 +- .../pkg/chart/v2/util/dependencies_test.go | 8 +- pkg/helm/pkg/chart/v2/util/expand.go | 17 + pkg/helm/pkg/chart/v2/util/expand_test.go | 84 + pkg/helm/pkg/chart/v2/util/save_test.go | 12 +- .../v2/util/testdata/dotdotname/Chart.yaml | 4 + .../chart/v2/util/testdata/dotname/Chart.yaml | 4 + .../v2/util/testdata/slashinname/Chart.yaml | 4 + pkg/helm/pkg/cli/environment.go | 7 +- pkg/helm/pkg/cli/output/output.go | 7 +- pkg/helm/pkg/cli/values/options.go | 8 +- pkg/helm/pkg/cli/values/options_test.go | 25 +- pkg/helm/pkg/cmd/completion.go | 2 +- pkg/helm/pkg/cmd/create.go | 56 +- pkg/helm/pkg/cmd/dependency_build.go | 4 +- pkg/helm/pkg/cmd/dependency_update_test.go | 2 +- pkg/helm/pkg/cmd/history.go | 4 +- pkg/helm/pkg/cmd/history_test.go | 2 +- pkg/helm/pkg/cmd/list.go | 2 +- pkg/helm/pkg/cmd/package.go | 2 +- pkg/helm/pkg/cmd/printer.go | 2 +- pkg/helm/pkg/cmd/pull_test.go | 12 +- pkg/helm/pkg/cmd/push.go | 2 +- pkg/helm/pkg/cmd/repo_add_test.go | 4 +- pkg/helm/pkg/cmd/repo_remove_test.go | 6 +- pkg/helm/pkg/cmd/require/args_test.go | 4 +- pkg/helm/pkg/cmd/search_hub.go | 7 +- pkg/helm/pkg/cmd/search_hub_test.go | 2 +- pkg/helm/pkg/cmd/search_repo.go | 10 +- pkg/helm/pkg/cmd/show_test.go | 16 +- pkg/helm/pkg/cmd/status.go | 6 +- .../helm/plugins/fullenv/plugin.yaml | 1 + .../helmhome/helm/plugins/args/plugin.yaml | 1 + .../helmhome/helm/plugins/echo/plugin.yaml | 1 + .../helm/plugins/exitwith/plugin.yaml | 1 + .../helmhome/helm/plugins/fullenv/plugin.yaml | 1 + .../helm/plugins/noversion/plugin.yaml | 7 + .../helm/plugins/shortenv/plugin.yaml | 1 + .../pkg/cmd/testdata/output/issue-9027.txt | 11 +- .../pkg/cmd/testdata/output/object-order.txt | 1 + .../pkg/cmd/testdata/output/version-short.txt | 2 +- .../cmd/testdata/output/version-template.txt | 2 +- pkg/helm/pkg/cmd/testdata/output/version.txt | 2 +- .../templates/horizontalpodautoscaler.yaml | 9 - .../templates/poddisruptionbudget.yaml | 9 + pkg/helm/pkg/downloader/cache.go | 3 +- pkg/helm/pkg/downloader/chart_downloader.go | 8 +- .../pkg/downloader/chart_downloader_test.go | 3 +- pkg/helm/pkg/downloader/manager.go | 4 +- pkg/helm/pkg/engine/engine.go | 30 +- pkg/helm/pkg/engine/engine_test.go | 137 +- pkg/helm/pkg/engine/funcs.go | 69 +- pkg/helm/pkg/engine/funcs_test.go | 51 +- pkg/helm/pkg/engine/lookup_func.go | 14 +- pkg/helm/pkg/getter/getter_test.go | 3 +- pkg/helm/pkg/getter/httpgetter.go | 86 +- pkg/helm/pkg/getter/httpgetter_test.go | 48 +- pkg/helm/pkg/getter/ocigetter.go | 2 +- pkg/helm/pkg/getter/ocigetter_test.go | 6 +- pkg/helm/pkg/kube/client.go | 45 +- pkg/helm/pkg/kube/client_test.go | 65 +- pkg/helm/pkg/kube/fake/printer.go | 3 +- pkg/helm/pkg/kube/roundtripper.go | 66 +- pkg/helm/pkg/kube/statuswait_test.go | 43 +- pkg/helm/pkg/kube/wait.go | 6 +- pkg/helm/pkg/kube/wait_test.go | 10 +- pkg/helm/pkg/kubeenv/roundtripper.go | 84 + .../{kube => kubeenv}/roundtripper_test.go | 2 +- pkg/helm/pkg/provenance/sign.go | 8 +- pkg/helm/pkg/provenance/sign_test.go | 10 +- pkg/helm/pkg/pusher/ocipusher.go | 4 +- pkg/helm/pkg/registry/client.go | 11 +- pkg/helm/pkg/registry/client_http_test.go | 5 +- pkg/helm/pkg/registry/client_test.go | 6 +- pkg/helm/pkg/registry/plugin.go | 3 +- pkg/helm/pkg/registry/plugin_test.go | 2 +- pkg/helm/pkg/registry/reference.go | 3 +- pkg/helm/pkg/registry/registry_test.go | 10 +- pkg/helm/pkg/registry/tag_test.go | 4 +- pkg/helm/pkg/release/common.go | 73 + pkg/helm/pkg/release/common_test.go | 70 + pkg/helm/pkg/release/interfaces.go | 4 +- pkg/helm/pkg/release/v1/hook.go | 4 +- pkg/helm/pkg/release/v1/hook_test.go | 2 +- pkg/helm/pkg/release/v1/info.go | 2 +- pkg/helm/pkg/release/v1/info_test.go | 2 +- pkg/helm/pkg/release/v1/mock.go | 6 +- pkg/helm/pkg/release/v1/release.go | 2 +- pkg/helm/pkg/release/v1/util/kind_sorter.go | 2 +- pkg/helm/pkg/release/v1/util/manifest.go | 18 +- pkg/helm/pkg/release/v1/util/manifest_test.go | 480 +++++- pkg/helm/pkg/repo/v1/chartrepo_test.go | 24 +- pkg/helm/pkg/repo/v1/index.go | 6 +- pkg/helm/pkg/repo/v1/index_test.go | 20 +- pkg/helm/pkg/repo/v1/repo_test.go | 8 +- pkg/helm/pkg/repo/v1/repotest/server.go | 10 +- pkg/helm/pkg/storage/driver/cfgmaps.go | 4 +- pkg/helm/pkg/storage/driver/cfgmaps_test.go | 4 +- pkg/helm/pkg/storage/driver/memory_test.go | 2 +- pkg/helm/pkg/storage/driver/secrets.go | 4 +- pkg/helm/pkg/storage/driver/secrets_test.go | 4 +- pkg/helm/pkg/storage/driver/sql_test.go | 5 +- pkg/helm/pkg/storage/storage.go | 6 +- pkg/helm/pkg/storage/storage_test.go | 40 +- pkg/helm/pkg/strvals/literal_parser.go | 36 +- pkg/helm/pkg/strvals/literal_parser_test.go | 140 +- pkg/helm/pkg/strvals/parser.go | 76 +- pkg/helm/pkg/strvals/parser_test.go | 232 +-- 210 files changed, 6993 insertions(+), 1362 deletions(-) create mode 100644 pkg/helm/intern/chart/v3/util/testdata/dotdotname/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/util/testdata/dotname/Chart.yaml create mode 100644 pkg/helm/intern/chart/v3/util/testdata/slashinname/Chart.yaml create mode 100644 pkg/helm/intern/gates/doc.go create mode 100644 pkg/helm/intern/gates/gates.go create mode 100644 pkg/helm/intern/release/v2/doc.go create mode 100644 pkg/helm/intern/release/v2/hook.go create mode 100644 pkg/helm/intern/release/v2/hook_test.go create mode 100644 pkg/helm/intern/release/v2/info.go create mode 100644 pkg/helm/intern/release/v2/info_test.go create mode 100644 pkg/helm/intern/release/v2/mock.go create mode 100644 pkg/helm/intern/release/v2/release.go create mode 100644 pkg/helm/intern/release/v2/util/filter.go create mode 100644 pkg/helm/intern/release/v2/util/filter_test.go create mode 100644 pkg/helm/intern/release/v2/util/kind_sorter.go create mode 100644 pkg/helm/intern/release/v2/util/kind_sorter_test.go create mode 100644 pkg/helm/intern/release/v2/util/manifest.go create mode 100644 pkg/helm/intern/release/v2/util/manifest_sorter.go create mode 100644 pkg/helm/intern/release/v2/util/manifest_sorter_test.go create mode 100644 pkg/helm/intern/release/v2/util/manifest_test.go create mode 100644 pkg/helm/intern/release/v2/util/sorter.go create mode 100644 pkg/helm/intern/release/v2/util/sorter_test.go create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dotdotname/Chart.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/dotname/Chart.yaml create mode 100644 pkg/helm/pkg/chart/v2/util/testdata/slashinname/Chart.yaml create mode 100644 pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/noversion/plugin.yaml delete mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/templates/horizontalpodautoscaler.yaml create mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/templates/poddisruptionbudget.yaml create mode 100644 pkg/helm/pkg/kubeenv/roundtripper.go rename pkg/helm/pkg/{kube => kubeenv}/roundtripper_test.go (99%) diff --git a/pkg/helm/intern/chart/v3/chart.go b/pkg/helm/intern/chart/v3/chart.go index 577a7843..5f06f4ad 100644 --- a/pkg/helm/intern/chart/v3/chart.go +++ b/pkg/helm/intern/chart/v3/chart.go @@ -45,11 +45,11 @@ type Chart struct { // Templates for this chart. Templates []*common.File `json:"templates"` // Values are default config for this chart. - Values map[string]interface{} `json:"values"` + Values map[string]any `json:"values"` // Schema is an optional JSON schema for imposing structure on Values Schema []byte `json:"schema"` // SchemaModTime the schema was last modified - SchemaModTime time.Time `json:"schemamodtime,omitempty"` + SchemaModTime time.Time `json:"schemamodtime"` // Files are miscellaneous files in a chart archive, // e.g. README, LICENSE, etc. Files []*common.File `json:"files"` diff --git a/pkg/helm/intern/chart/v3/chart_test.go b/pkg/helm/intern/chart/v3/chart_test.go index 979e4818..e0d7cf86 100644 --- a/pkg/helm/intern/chart/v3/chart_test.go +++ b/pkg/helm/intern/chart/v3/chart_test.go @@ -124,8 +124,8 @@ func TestIsRoot(t *testing.T) { is := assert.New(t) - is.Equal(false, chrt1.IsRoot()) - is.Equal(true, chrt2.IsRoot()) + is.False(chrt1.IsRoot()) + is.True(chrt2.IsRoot()) } func TestChartPath(t *testing.T) { diff --git a/pkg/helm/intern/chart/v3/dependency.go b/pkg/helm/intern/chart/v3/dependency.go index 2d956b54..50ee5552 100644 --- a/pkg/helm/intern/chart/v3/dependency.go +++ b/pkg/helm/intern/chart/v3/dependency.go @@ -44,7 +44,7 @@ type Dependency struct { Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` // ImportValues holds the mapping of source values to parent key to be imported. Each item can be a // string or pair of child/parent sublist items. - ImportValues []interface{} `json:"import-values,omitempty" yaml:"import-values,omitempty"` + ImportValues []any `json:"import-values,omitempty" yaml:"import-values,omitempty"` // Alias usable alias to be used for the chart Alias string `json:"alias,omitempty" yaml:"alias,omitempty"` } diff --git a/pkg/helm/intern/chart/v3/errors.go b/pkg/helm/intern/chart/v3/errors.go index 059e43f0..156dce1d 100644 --- a/pkg/helm/intern/chart/v3/errors.go +++ b/pkg/helm/intern/chart/v3/errors.go @@ -25,6 +25,6 @@ func (v ValidationError) Error() string { } // ValidationErrorf takes a message and formatting options and creates a ValidationError -func ValidationErrorf(msg string, args ...interface{}) ValidationError { +func ValidationErrorf(msg string, args ...any) ValidationError { return ValidationError(fmt.Sprintf(msg, args...)) } diff --git a/pkg/helm/intern/chart/v3/lint/lint.go b/pkg/helm/intern/chart/v3/lint/lint.go index 85b46322..f81dad02 100644 --- a/pkg/helm/intern/chart/v3/lint/lint.go +++ b/pkg/helm/intern/chart/v3/lint/lint.go @@ -43,7 +43,7 @@ func WithSkipSchemaValidation(skipSchemaValidation bool) LinterOption { } } -func RunAll(baseDir string, values map[string]interface{}, namespace string, options ...LinterOption) support.Linter { +func RunAll(baseDir string, values map[string]any, namespace string, options ...LinterOption) support.Linter { chartDir, _ := filepath.Abs(baseDir) diff --git a/pkg/helm/intern/chart/v3/lint/lint_test.go b/pkg/helm/intern/chart/v3/lint/lint_test.go index 1b776600..09c76d60 100644 --- a/pkg/helm/intern/chart/v3/lint/lint_test.go +++ b/pkg/helm/intern/chart/v3/lint/lint_test.go @@ -95,7 +95,7 @@ func TestInvalidYaml(t *testing.T) { t.Fatalf("All didn't fail with expected errors, got %#v", m) } if !strings.Contains(m[0].Err.Error(), "deliberateSyntaxError") { - t.Errorf("All didn't have the error for deliberateSyntaxError") + t.Error("All didn't have the error for deliberateSyntaxError") } } @@ -107,7 +107,7 @@ func TestInvalidChartYamlV3(t *testing.T) { t.Fatalf("All didn't fail with expected errors, got %#v", m) } if !strings.Contains(m[0].Err.Error(), "failed to strictly parse chart metadata file") { - t.Errorf("All didn't have the error for duplicate YAML keys") + t.Error("All didn't have the error for duplicate YAML keys") } } @@ -231,13 +231,13 @@ func TestMalformedTemplate(t *testing.T) { }() select { case <-c: - t.Fatalf("lint malformed template timeout") + t.Fatal("lint malformed template timeout") case <-ch: if len(m) != 1 { t.Fatalf("All didn't fail with expected errors, got %#v", m) } if !strings.Contains(m[0].Err.Error(), "invalid character '{'") { - t.Errorf("All didn't have the error for invalid character '{'") + t.Error("All didn't have the error for invalid character '{'") } } } diff --git a/pkg/helm/intern/chart/v3/lint/rules/chartfile.go b/pkg/helm/intern/chart/v3/lint/rules/chartfile.go index aab053eb..739a75bb 100644 --- a/pkg/helm/intern/chart/v3/lint/rules/chartfile.go +++ b/pkg/helm/intern/chart/v3/lint/rules/chartfile.go @@ -69,15 +69,15 @@ func Chartfile(linter *support.Linter) { linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartDependencies(chartFile)) } -func validateChartVersionType(data map[string]interface{}) error { +func validateChartVersionType(data map[string]any) error { return isStringValue(data, "version") } -func validateChartAppVersionType(data map[string]interface{}) error { +func validateChartAppVersionType(data map[string]any) error { return isStringValue(data, "appVersion") } -func isStringValue(data map[string]interface{}, key string) error { +func isStringValue(data map[string]any, key string) error { value, ok := data[key] if !ok { return nil @@ -152,7 +152,7 @@ func validateChartVersion(cf *chart.Metadata) error { valid, msg := c.Validate(version) if !valid && len(msg) > 0 { - return fmt.Errorf("version %v", msg[0]) + return fmt.Errorf("version %w", msg[0]) } return nil @@ -214,12 +214,12 @@ func validateChartType(cf *chart.Metadata) error { // loadChartFileForTypeCheck loads the Chart.yaml // in a generic form of a map[string]interface{}, so that the type // of the values can be checked -func loadChartFileForTypeCheck(filename string) (map[string]interface{}, error) { +func loadChartFileForTypeCheck(filename string) (map[string]any, error) { b, err := os.ReadFile(filename) if err != nil { return nil, err } - y := make(map[string]interface{}) + y := make(map[string]any) err = yaml.Unmarshal(b, &y) return y, err } diff --git a/pkg/helm/intern/chart/v3/lint/rules/chartfile_test.go b/pkg/helm/intern/chart/v3/lint/rules/chartfile_test.go index b8b1b859..c9e77fe0 100644 --- a/pkg/helm/intern/chart/v3/lint/rules/chartfile_test.go +++ b/pkg/helm/intern/chart/v3/lint/rules/chartfile_test.go @@ -50,26 +50,26 @@ func TestValidateChartYamlNotDirectory(t *testing.T) { err := validateChartYamlNotDirectory(nonExistingChartFilePath) if err == nil { - t.Errorf("validateChartYamlNotDirectory to return a linter error, got no error") + t.Error("validateChartYamlNotDirectory to return a linter error, got no error") } } func TestValidateChartYamlFormat(t *testing.T) { err := validateChartYamlFormat(errors.New("Read error")) if err == nil { - t.Errorf("validateChartYamlFormat to return a linter error, got no error") + t.Error("validateChartYamlFormat to return a linter error, got no error") } err = validateChartYamlFormat(nil) if err != nil { - t.Errorf("validateChartYamlFormat to return no error, got a linter error") + t.Error("validateChartYamlFormat to return no error, got a linter error") } } func TestValidateChartName(t *testing.T) { err := validateChartName(badChart) if err == nil { - t.Errorf("validateChartName to return a linter error, got no error") + t.Error("validateChartName to return a linter error, got no error") } err = validateChartName(badChartName) @@ -149,7 +149,7 @@ func TestValidateChartMaintainer(t *testing.T) { badChart.Maintainers = []*chart.Maintainer{nil} err := validateChartMaintainer(badChart) if err == nil { - t.Errorf("validateChartMaintainer did not return error for nil maintainer as expected") + t.Error("validateChartMaintainer did not return error for nil maintainer as expected") } if err.Error() != "a maintainer entry is empty" { t.Errorf("validateChartMaintainer returned unexpected error for nil maintainer: %s", err.Error()) @@ -185,7 +185,7 @@ func TestValidateChartIconPresence(t *testing.T) { err := validateChartIconPresence(testChart) if err == nil { - t.Errorf("validateChartIconPresence to return a linter error, got no error") + t.Error("validateChartIconPresence to return a linter error, got no error") } else if !strings.Contains(err.Error(), "icon is recommended") { t.Errorf("expected %q, got %q", "icon is recommended", err.Error()) } diff --git a/pkg/helm/intern/chart/v3/lint/rules/crds.go b/pkg/helm/intern/chart/v3/lint/rules/crds.go index 8313a771..0c7750f4 100644 --- a/pkg/helm/intern/chart/v3/lint/rules/crds.go +++ b/pkg/helm/intern/chart/v3/lint/rules/crds.go @@ -20,7 +20,6 @@ import ( "bytes" "context" "errors" - "fmt" "io" "io/fs" "os" @@ -103,14 +102,14 @@ func validateCrdsDir(crdsPath string) error { func validateCrdAPIVersion(obj *k8sYamlStruct) error { if !strings.HasPrefix(obj.APIVersion, "apiextensions.k8s.io") { - return fmt.Errorf("apiVersion is not in 'apiextensions.k8s.io'") + return errors.New("apiVersion is not in 'apiextensions.k8s.io'") } return nil } func validateCrdKind(obj *k8sYamlStruct) error { if obj.Kind != "CustomResourceDefinition" { - return fmt.Errorf("object kind is not 'CustomResourceDefinition'") + return errors.New("object kind is not 'CustomResourceDefinition'") } return nil } diff --git a/pkg/helm/intern/chart/v3/lint/rules/dependencies_test.go b/pkg/helm/intern/chart/v3/lint/rules/dependencies_test.go index 465a40ca..30dde3ef 100644 --- a/pkg/helm/intern/chart/v3/lint/rules/dependencies_test.go +++ b/pkg/helm/intern/chart/v3/lint/rules/dependencies_test.go @@ -72,7 +72,7 @@ func TestValidateDependencyInMetadata(t *testing.T) { c := chartWithBadDependencies() if err := validateDependencyInMetadata(&c); err == nil { - t.Errorf("chart should have been flagged for missing deps in chart metadata") + t.Error("chart should have been flagged for missing deps in chart metadata") } } @@ -132,7 +132,7 @@ func TestValidateDependenciesUnique(t *testing.T) { for _, tt := range tests { if err := validateDependenciesUnique(&tt.chart); err == nil { - t.Errorf("chart should have been flagged for dependency shadowing") + t.Error("chart should have been flagged for dependency shadowing") } } } diff --git a/pkg/helm/intern/chart/v3/lint/rules/deprecations_test.go b/pkg/helm/intern/chart/v3/lint/rules/deprecations_test.go index 210ab353..936acb28 100644 --- a/pkg/helm/intern/chart/v3/lint/rules/deprecations_test.go +++ b/pkg/helm/intern/chart/v3/lint/rules/deprecations_test.go @@ -36,6 +36,6 @@ func TestValidateNoDeprecations(t *testing.T) { APIVersion: "v1", Kind: "Pod", }, nil); err != nil { - t.Errorf("Expected a v1 Pod to not be deprecated") + t.Error("Expected a v1 Pod to not be deprecated") } } diff --git a/pkg/helm/intern/chart/v3/lint/rules/template.go b/pkg/helm/intern/chart/v3/lint/rules/template.go index adef230b..30cb1397 100644 --- a/pkg/helm/intern/chart/v3/lint/rules/template.go +++ b/pkg/helm/intern/chart/v3/lint/rules/template.go @@ -43,17 +43,17 @@ import ( ) // Templates lints the templates in the Linter. -func Templates(linter *support.Linter, values map[string]interface{}, namespace string, _ bool) { +func Templates(linter *support.Linter, values map[string]any, namespace string, _ bool) { TemplatesWithKubeVersion(linter, values, namespace, nil) } // TemplatesWithKubeVersion lints the templates in the Linter, allowing to specify the kubernetes version. -func TemplatesWithKubeVersion(linter *support.Linter, values map[string]interface{}, namespace string, kubeVersion *common.KubeVersion) { +func TemplatesWithKubeVersion(linter *support.Linter, values map[string]any, namespace string, kubeVersion *common.KubeVersion) { TemplatesWithSkipSchemaValidation(linter, values, namespace, kubeVersion, false) } // TemplatesWithSkipSchemaValidation lints the templates in the Linter, allowing to specify the kubernetes version and if schema validation is enabled or not. -func TemplatesWithSkipSchemaValidation(linter *support.Linter, values map[string]interface{}, namespace string, kubeVersion *common.KubeVersion, skipSchemaValidation bool) { +func TemplatesWithSkipSchemaValidation(linter *support.Linter, values map[string]any, namespace string, kubeVersion *common.KubeVersion, skipSchemaValidation bool) { fpath := "templates/" templatesPath := filepath.Join(linter.ChartDir, fpath) diff --git a/pkg/helm/intern/chart/v3/lint/rules/template_test.go b/pkg/helm/intern/chart/v3/lint/rules/template_test.go index 514579c9..0da04df6 100644 --- a/pkg/helm/intern/chart/v3/lint/rules/template_test.go +++ b/pkg/helm/intern/chart/v3/lint/rules/template_test.go @@ -49,7 +49,7 @@ func TestValidateAllowedExtension(t *testing.T) { } } -var values = map[string]interface{}{"nameOverride": "", "httpPort": 80} +var values = map[string]any{"nameOverride": "", "httpPort": 80} const namespace = "testNamespace" const strict = false @@ -249,7 +249,7 @@ func TestStrictTemplateParsingMapError(t *testing.T) { APIVersion: "v2", Version: "0.1.0", }, - Values: map[string]interface{}{ + Values: map[string]any{ "mymap": map[string]string{ "key1": "val1", }, diff --git a/pkg/helm/intern/chart/v3/lint/rules/values.go b/pkg/helm/intern/chart/v3/lint/rules/values.go index ae00a2d6..3054e886 100644 --- a/pkg/helm/intern/chart/v3/lint/rules/values.go +++ b/pkg/helm/intern/chart/v3/lint/rules/values.go @@ -17,6 +17,7 @@ limitations under the License. package rules import ( + "errors" "fmt" "os" "path/filepath" @@ -32,7 +33,7 @@ import ( // they are only tested for well-formedness. // // If additional values are supplied, they are coalesced into the values in values.yaml. -func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]interface{}, skipSchemaValidation bool) { +func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]any, skipSchemaValidation bool) { file := "values.yaml" vf := filepath.Join(linter.ChartDir, file) fileExists := linter.RunLinterRule(support.InfoSev, file, validateValuesFileExistence(vf)) @@ -47,12 +48,12 @@ func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]inter func validateValuesFileExistence(valuesPath string) error { _, err := os.Stat(valuesPath) if err != nil { - return fmt.Errorf("file does not exist") + return errors.New("file does not exist") } return nil } -func validateValuesFile(valuesPath string, overrides map[string]interface{}, skipSchemaValidation bool) error { +func validateValuesFile(valuesPath string, overrides map[string]any, skipSchemaValidation bool) error { values, err := common.ReadValuesFile(valuesPath) if err != nil { return fmt.Errorf("unable to parse YAML: %w", err) @@ -63,7 +64,7 @@ func validateValuesFile(valuesPath string, overrides map[string]interface{}, ski // We could change that. For now, though, we retain that strategy, and thus can // coalesce tables (like reuse-values does) instead of doing the full chart // CoalesceValues - coalescedValues := util.CoalesceTables(make(map[string]interface{}, len(overrides)), overrides) + coalescedValues := util.CoalesceTables(make(map[string]any, len(overrides)), overrides) coalescedValues = util.CoalesceTables(coalescedValues, values) ext := filepath.Ext(valuesPath) diff --git a/pkg/helm/intern/chart/v3/lint/rules/values_test.go b/pkg/helm/intern/chart/v3/lint/rules/values_test.go index a2a5345d..189a9d32 100644 --- a/pkg/helm/intern/chart/v3/lint/rules/values_test.go +++ b/pkg/helm/intern/chart/v3/lint/rules/values_test.go @@ -57,7 +57,7 @@ func TestValidateValuesYamlNotDirectory(t *testing.T) { err := validateValuesFileExistence(nonExistingValuesFilePath) if err == nil { - t.Errorf("validateValuesFileExistence to return a linter error, got no error") + t.Error("validateValuesFileExistence to return a linter error, got no error") } } @@ -67,7 +67,7 @@ func TestValidateValuesFileWellFormed(t *testing.T) { ` tmpdir := ensure.TempFile(t, "values.yaml", []byte(badYaml)) valfile := filepath.Join(tmpdir, "values.yaml") - if err := validateValuesFile(valfile, map[string]interface{}{}, false); err == nil { + if err := validateValuesFile(valfile, map[string]any{}, false); err == nil { t.Fatal("expected values file to fail parsing") } } @@ -78,7 +78,7 @@ func TestValidateValuesFileSchema(t *testing.T) { createTestingSchema(t, tmpdir) valfile := filepath.Join(tmpdir, "values.yaml") - if err := validateValuesFile(valfile, map[string]interface{}{}, false); err != nil { + if err := validateValuesFile(valfile, map[string]any{}, false); err != nil { t.Fatalf("Failed validation with %s", err) } } @@ -91,7 +91,7 @@ func TestValidateValuesFileSchemaFailure(t *testing.T) { valfile := filepath.Join(tmpdir, "values.yaml") - err := validateValuesFile(valfile, map[string]interface{}{}, false) + err := validateValuesFile(valfile, map[string]any{}, false) if err == nil { t.Fatal("expected values file to fail parsing") } @@ -107,7 +107,7 @@ func TestValidateValuesFileSchemaFailureButWithSkipSchemaValidation(t *testing.T valfile := filepath.Join(tmpdir, "values.yaml") - err := validateValuesFile(valfile, map[string]interface{}{}, true) + err := validateValuesFile(valfile, map[string]any{}, true) if err != nil { t.Fatal("expected values file to pass parsing because of skipSchemaValidation") } @@ -115,7 +115,7 @@ func TestValidateValuesFileSchemaFailureButWithSkipSchemaValidation(t *testing.T func TestValidateValuesFileSchemaOverrides(t *testing.T) { yaml := "username: admin" - overrides := map[string]interface{}{ + overrides := map[string]any{ "password": "swordfish", } tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) @@ -131,24 +131,24 @@ func TestValidateValuesFile(t *testing.T) { tests := []struct { name string yaml string - overrides map[string]interface{} + overrides map[string]any errorMessage string }{ { name: "value added", yaml: "username: admin", - overrides: map[string]interface{}{"password": "swordfish"}, + overrides: map[string]any{"password": "swordfish"}, }, { name: "value not overridden", yaml: "username: admin\npassword:", - overrides: map[string]interface{}{"username": "anotherUser"}, + overrides: map[string]any{"username": "anotherUser"}, errorMessage: "- at '/password': got null, want string", }, { name: "value overridden", yaml: "username: admin\npassword:", - overrides: map[string]interface{}{"username": "anotherUser", "password": "swordfish"}, + overrides: map[string]any{"username": "anotherUser", "password": "swordfish"}, }, } diff --git a/pkg/helm/intern/chart/v3/loader/archive.go b/pkg/helm/intern/chart/v3/loader/archive.go index b097c84d..7f48388b 100644 --- a/pkg/helm/intern/chart/v3/loader/archive.go +++ b/pkg/helm/intern/chart/v3/loader/archive.go @@ -58,7 +58,7 @@ func LoadFile(ctx context.Context, name string) (*chart.Chart, error) { c, err := LoadArchive(ctx, raw) if err != nil { if errors.Is(err, gzip.ErrHeader) { - return nil, fmt.Errorf("file '%s' does not appear to be a valid chart file (details: %s)", name, err) + return nil, fmt.Errorf("file '%s' does not appear to be a valid chart file (details: %w)", name, err) } } return c, err diff --git a/pkg/helm/intern/chart/v3/loader/load.go b/pkg/helm/intern/chart/v3/loader/load.go index 6cb44f8d..120b06e5 100644 --- a/pkg/helm/intern/chart/v3/loader/load.go +++ b/pkg/helm/intern/chart/v3/loader/load.go @@ -311,11 +311,11 @@ func convertBufferedFiles(files []*archive.BufferedFile) []*nelmcommon.BufferedF // // The reader is expected to contain one or more YAML documents, the values of which are merged. // And the values can be either a chart's default values or user-supplied values. -func LoadValues(data io.Reader) (map[string]interface{}, error) { - values := map[string]interface{}{} +func LoadValues(data io.Reader) (map[string]any, error) { + values := map[string]any{} reader := utilyaml.NewYAMLReader(bufio.NewReader(data)) for { - currentMap := map[string]interface{}{} + currentMap := map[string]any{} raw, err := reader.Read() if err != nil { if errors.Is(err, io.EOF) { @@ -333,13 +333,13 @@ func LoadValues(data io.Reader) (map[string]interface{}, error) { // MergeMaps merges two maps. If a key exists in both maps, the value from b will be used. // If the value is a map, the maps will be merged recursively. -func MergeMaps(a, b map[string]interface{}) map[string]interface{} { - out := make(map[string]interface{}, len(a)) +func MergeMaps(a, b map[string]any) map[string]any { + out := make(map[string]any, len(a)) maps.Copy(out, a) for k, v := range b { - if v, ok := v.(map[string]interface{}); ok { + if v, ok := v.(map[string]any); ok { if bv, ok := out[k]; ok { - if bv, ok := bv.(map[string]interface{}); ok { + if bv, ok := bv.(map[string]any); ok { out[k] = MergeMaps(bv, v) continue } diff --git a/pkg/helm/intern/chart/v3/loader/load_test.go b/pkg/helm/intern/chart/v3/loader/load_test.go index 19f4a61a..eaaca0fb 100644 --- a/pkg/helm/intern/chart/v3/loader/load_test.go +++ b/pkg/helm/intern/chart/v3/loader/load_test.go @@ -62,7 +62,7 @@ func TestLoadDirWithDevNull(t *testing.T) { t.Fatalf("Failed to load testdata: %s", err) } if _, err := l.Load(context.Background()); err == nil { - t.Errorf("packages with an irregular file (/dev/null) should not load") + t.Error("packages with an irregular file (/dev/null) should not load") } } @@ -456,7 +456,7 @@ func TestLoadInvalidArchive(t *testing.T) { func TestLoadValues(t *testing.T) { testCases := map[string]struct { data []byte - expctedValues map[string]interface{} + expctedValues map[string]any }{ "It should load values correctly": { data: []byte(` @@ -465,11 +465,11 @@ foo: bar: version: v2 `), - expctedValues: map[string]interface{}{ - "foo": map[string]interface{}{ + expctedValues: map[string]any{ + "foo": map[string]any{ "image": "foo:v1", }, - "bar": map[string]interface{}{ + "bar": map[string]any{ "version": "v2", }, }, @@ -484,11 +484,11 @@ bar: foo: image: foo:v2 `), - expctedValues: map[string]interface{}{ - "foo": map[string]interface{}{ + expctedValues: map[string]any{ + "foo": map[string]any{ "image": "foo:v2", }, - "bar": map[string]interface{}{ + "bar": map[string]any{ "version": "v2", }, }, @@ -508,24 +508,24 @@ foo: } func TestMergeValuesV3(t *testing.T) { - nestedMap := map[string]interface{}{ + nestedMap := map[string]any{ "foo": "bar", "baz": map[string]string{ "cool": "stuff", }, } - anotherNestedMap := map[string]interface{}{ + anotherNestedMap := map[string]any{ "foo": "bar", "baz": map[string]string{ "cool": "things", "awesome": "stuff", }, } - flatMap := map[string]interface{}{ + flatMap := map[string]any{ "foo": "bar", "baz": "stuff", } - anotherFlatMap := map[string]interface{}{ + anotherFlatMap := map[string]any{ "testing": "fun", } @@ -548,7 +548,7 @@ func TestMergeValuesV3(t *testing.T) { } testMap = MergeMaps(anotherFlatMap, anotherNestedMap) - expectedMap := map[string]interface{}{ + expectedMap := map[string]any{ "testing": "fun", "foo": "bar", "baz": map[string]string{ diff --git a/pkg/helm/intern/chart/v3/metadata.go b/pkg/helm/intern/chart/v3/metadata.go index 4629d571..5f7cea89 100644 --- a/pkg/helm/intern/chart/v3/metadata.go +++ b/pkg/helm/intern/chart/v3/metadata.go @@ -112,6 +112,9 @@ func (md *Metadata) Validate() error { return ValidationError("chart.metadata.name is required") } + if md.Name == "." || md.Name == ".." { + return ValidationErrorf("chart.metadata.name %q is not allowed", md.Name) + } if md.Name != filepath.Base(md.Name) { return ValidationErrorf("chart.metadata.name %q is invalid", md.Name) } diff --git a/pkg/helm/intern/chart/v3/metadata_test.go b/pkg/helm/intern/chart/v3/metadata_test.go index 596a0369..5f88552e 100644 --- a/pkg/helm/intern/chart/v3/metadata_test.go +++ b/pkg/helm/intern/chart/v3/metadata_test.go @@ -16,6 +16,7 @@ limitations under the License. package v3 import ( + "errors" "testing" ) @@ -40,6 +41,16 @@ func TestValidate(t *testing.T) { &Metadata{APIVersion: "v3", Version: "1.0"}, ValidationError("chart.metadata.name is required"), }, + { + "chart with dot name", + &Metadata{Name: ".", APIVersion: "v3", Version: "1.0"}, + ValidationError("chart.metadata.name \".\" is not allowed"), + }, + { + "chart with dotdot name", + &Metadata{Name: "..", APIVersion: "v3", Version: "1.0"}, + ValidationError("chart.metadata.name \"..\" is not allowed"), + }, { "chart without name", &Metadata{Name: "../../test", APIVersion: "v3", Version: "1.0"}, @@ -181,7 +192,7 @@ func TestValidate(t *testing.T) { for _, tt := range tests { result := tt.md.Validate() - if result != tt.err { + if !errors.Is(result, tt.err) { t.Errorf("expected %q, got %q in test %q", tt.err, result, tt.name) } } diff --git a/pkg/helm/intern/chart/v3/util/chartfile_test.go b/pkg/helm/intern/chart/v3/util/chartfile_test.go index 8d721a55..3ded5ea0 100644 --- a/pkg/helm/intern/chart/v3/util/chartfile_test.go +++ b/pkg/helm/intern/chart/v3/util/chartfile_test.go @@ -35,7 +35,7 @@ func TestLoadChartfile(t *testing.T) { func verifyChartfile(t *testing.T, f *chart.Metadata, name string) { t.Helper() - if f == nil { //nolint:staticcheck + if f == nil { t.Fatal("Failed verifyChartfile because f is nil") } @@ -56,15 +56,15 @@ func verifyChartfile(t *testing.T, f *chart.Metadata, name string) { } if f.Maintainers[0].Name != "The Helm Team" { - t.Errorf("Unexpected maintainer name.") + t.Error("Unexpected maintainer name.") } if f.Maintainers[1].Email != "nobody@example.com" { - t.Errorf("Unexpected maintainer email.") + t.Error("Unexpected maintainer email.") } if len(f.Sources) != 1 { - t.Fatalf("Unexpected number of sources") + t.Fatal("Unexpected number of sources") } if f.Sources[0] != "https://example.com/foo/bar" { @@ -84,7 +84,7 @@ func verifyChartfile(t *testing.T, f *chart.Metadata, name string) { } if len(f.Annotations) != 2 { - t.Fatalf("Unexpected annotations") + t.Fatal("Unexpected annotations") } if want, got := "extravalue", f.Annotations["extrakey"]; want != got { @@ -111,7 +111,7 @@ func TestIsChartDir(t *testing.T) { } validChartDir, err = IsChartDir("testdata") if validChartDir || err == nil { - t.Errorf("expected error but did not get any") + t.Error("expected error but did not get any") return } } diff --git a/pkg/helm/intern/chart/v3/util/create.go b/pkg/helm/intern/chart/v3/util/create.go index 6051ba33..31a0808b 100644 --- a/pkg/helm/intern/chart/v3/util/create.go +++ b/pkg/helm/intern/chart/v3/util/create.go @@ -671,7 +671,7 @@ func CreateFrom(chartfile *chart.Metadata, dest, src string) error { return fmt.Errorf("reading values file: %w", err) } - var m map[string]interface{} + var m map[string]any if err := yaml.Unmarshal(transform(string(b), schart.Name()), &m); err != nil { return fmt.Errorf("transforming values file: %w", err) } diff --git a/pkg/helm/intern/chart/v3/util/create_test.go b/pkg/helm/intern/chart/v3/util/create_test.go index 3702d2d3..630fd3fa 100644 --- a/pkg/helm/intern/chart/v3/util/create_test.go +++ b/pkg/helm/intern/chart/v3/util/create_test.go @@ -141,7 +141,7 @@ func TestCreate_Overwrite(t *testing.T) { } if errlog.Len() == 0 { - t.Errorf("Expected warnings about overwriting files.") + t.Error("Expected warnings about overwriting files.") } } diff --git a/pkg/helm/intern/chart/v3/util/dependencies.go b/pkg/helm/intern/chart/v3/util/dependencies.go index a9da2756..2048d6f0 100644 --- a/pkg/helm/intern/chart/v3/util/dependencies.go +++ b/pkg/helm/intern/chart/v3/util/dependencies.go @@ -16,6 +16,7 @@ limitations under the License. package util import ( + "errors" "fmt" "log/slog" "strings" @@ -44,6 +45,7 @@ func processDependencyConditions(reqs []*chart.Dependency, cvals common.Values, if len(c) > 0 { // retrieve value vv, err := cvals.PathValue(cpath + c) + var errNoValue common.ErrNoValue if err == nil { // if not bool, warn if bv, ok := vv.(bool); ok { @@ -51,7 +53,7 @@ func processDependencyConditions(reqs []*chart.Dependency, cvals common.Values, break } slog.Warn("returned non-bool value", "path", c, "chart", r.Name) - } else if _, ok := err.(common.ErrNoValue); !ok { + } else if errors.As(err, &errNoValue) { // this is a real error slog.Warn("the method PathValue returned error", slog.Any("error", err)) } @@ -140,7 +142,7 @@ func copyMetadata(metadata *chart.Metadata) *chart.Metadata { } // processDependencyEnabled removes disabled charts from dependencies -func processDependencyEnabled(c *chart.Chart, v map[string]interface{}, path string) error { +func processDependencyEnabled(c *chart.Chart, v map[string]any, path string) error { if c.Metadata.Dependencies == nil { return nil } @@ -226,7 +228,7 @@ Loop: } // pathToMap creates a nested map given a YAML path in dot notation. -func pathToMap(path string, data map[string]interface{}) map[string]interface{} { +func pathToMap(path string, data map[string]any) map[string]any { if path == "." { return data } @@ -235,13 +237,13 @@ func pathToMap(path string, data map[string]interface{}) map[string]interface{} func parsePath(key string) []string { return strings.Split(key, ".") } -func set(path []string, data map[string]interface{}) map[string]interface{} { +func set(path []string, data map[string]any) map[string]any { if len(path) == 0 { return nil } cur := data for i := len(path) - 1; i >= 0; i-- { - cur = map[string]interface{}{path[i]: cur} + cur = map[string]any{path[i]: cur} } return cur } @@ -262,13 +264,13 @@ func processImportValues(c *chart.Chart, merge bool) error { if err != nil { return err } - b := make(map[string]interface{}) + b := make(map[string]any) // import values from each dependency if specified in import-values for _, r := range c.Metadata.Dependencies { - var outiv []interface{} + var outiv []any for _, riv := range r.ImportValues { switch iv := riv.(type) { - case map[string]interface{}: + case map[string]any: child := fmt.Sprintf("%v", iv["child"]) parent := fmt.Sprintf("%v", iv["parent"]) @@ -336,27 +338,27 @@ func processImportValues(c *chart.Chart, merge bool) error { return nil } -func deepCopyMap(vals map[string]interface{}) map[string]interface{} { +func deepCopyMap(vals map[string]any) map[string]any { valsCopy, err := copystructure.Copy(vals) if err != nil { return vals } - return valsCopy.(map[string]interface{}) + return valsCopy.(map[string]any) } -func trimNilValues(vals map[string]interface{}) map[string]interface{} { +func trimNilValues(vals map[string]any) map[string]any { valsCopy, err := copystructure.Copy(vals) if err != nil { return vals } - valsCopyMap := valsCopy.(map[string]interface{}) + valsCopyMap := valsCopy.(map[string]any) for key, val := range valsCopyMap { if val == nil { // Iterate over the values and remove nil keys delete(valsCopyMap, key) } else if istable(val) { // Recursively call into ourselves to remove keys from inner tables - valsCopyMap[key] = trimNilValues(val.(map[string]interface{})) + valsCopyMap[key] = trimNilValues(val.(map[string]any)) } } @@ -364,8 +366,8 @@ func trimNilValues(vals map[string]interface{}) map[string]interface{} { } // istable is a special-purpose function to see if the present thing matches the definition of a YAML table. -func istable(v interface{}) bool { - _, ok := v.(map[string]interface{}) +func istable(v any) bool { + _, ok := v.(map[string]any) return ok } diff --git a/pkg/helm/intern/chart/v3/util/dependencies_test.go b/pkg/helm/intern/chart/v3/util/dependencies_test.go index 62b2725b..48a807a4 100644 --- a/pkg/helm/intern/chart/v3/util/dependencies_test.go +++ b/pkg/helm/intern/chart/v3/util/dependencies_test.go @@ -64,7 +64,7 @@ func TestLoadDependency(t *testing.T) { } func TestDependencyEnabled(t *testing.T) { - type M = map[string]interface{} + type M = map[string]any tests := []struct { name string v M @@ -386,7 +386,7 @@ func TestGetAliasDependency(t *testing.T) { req := c.Metadata.Dependencies if len(req) == 0 { - t.Fatalf("there are no dependencies to test") + t.Fatal("there are no dependencies to test") } // Success case @@ -404,7 +404,7 @@ func TestGetAliasDependency(t *testing.T) { if req[0].Version != "" { if !IsCompatibleRange(req[0].Version, aliasChart.Metadata.Version) { - t.Fatalf("dependency chart version is not in the compatible range") + t.Fatal("dependency chart version is not in the compatible range") } } @@ -416,7 +416,7 @@ func TestGetAliasDependency(t *testing.T) { req[0].Version = "something else which is not in the compatible range" if IsCompatibleRange(req[0].Version, aliasChart.Metadata.Version) { - t.Fatalf("dependency chart version which is not in the compatible range should cause a failure other than a success ") + t.Fatal("dependency chart version outside the compatible range should not be considered compatible") } } diff --git a/pkg/helm/intern/chart/v3/util/expand.go b/pkg/helm/intern/chart/v3/util/expand.go index 7a9584bf..47a03be4 100644 --- a/pkg/helm/intern/chart/v3/util/expand.go +++ b/pkg/helm/intern/chart/v3/util/expand.go @@ -52,6 +52,17 @@ func Expand(dir string, r io.Reader) error { return errors.New("chart name not specified") } + // Reject chart names that are POSIX path dot-segments or dot-dot segments or contain path separators. + // A dot-segment name (e.g. ".") causes SecureJoin to resolve to the root + // directory and extraction then to write files directly into that extraction root + // instead of a per-chart subdirectory. + if chartName == "." || chartName == ".." { + return fmt.Errorf("chart name %q is not allowed", chartName) + } + if chartName != filepath.Base(chartName) { + return fmt.Errorf("chart name %q must not contain path separators", chartName) + } + // Find the base directory // The directory needs to be cleaned prior to passing to SecureJoin or the location may end up // being wrong or returning an error. This was introduced in v0.4.0. @@ -61,6 +72,12 @@ func Expand(dir string, r io.Reader) error { return err } + // Defense-in-depth: the chart directory must be a subdirectory of dir, + // never dir itself. + if chartdir == dir { + return fmt.Errorf("chart name %q resolves to the extraction root", chartName) + } + // Copy all files verbatim. We don't parse these files because parsing can remove // comments. for _, file := range files { diff --git a/pkg/helm/intern/chart/v3/util/expand_test.go b/pkg/helm/intern/chart/v3/util/expand_test.go index 280995f7..e9e298b8 100644 --- a/pkg/helm/intern/chart/v3/util/expand_test.go +++ b/pkg/helm/intern/chart/v3/util/expand_test.go @@ -17,11 +17,73 @@ limitations under the License. package util import ( + "archive/tar" + "bytes" + "compress/gzip" + "io/fs" "os" "path/filepath" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +// makeTestChartArchive builds a gzipped tar archive from the given sourceDir directory, file entries are prefixed with the given chartName +func makeTestChartArchive(t *testing.T, chartName, sourceDir string) *bytes.Buffer { + t.Helper() + + var result bytes.Buffer + gw := gzip.NewWriter(&result) + tw := tar.NewWriter(gw) + + dir := os.DirFS(sourceDir) + + writeFile := func(relPath string) { + t.Helper() + f, err := dir.Open(relPath) + require.NoError(t, err) + + fStat, err := f.Stat() + require.NoError(t, err) + + err = tw.WriteHeader(&tar.Header{ + Name: filepath.Join(chartName, relPath), + Mode: int64(fStat.Mode()), + Size: fStat.Size(), + }) + require.NoError(t, err) + + data, err := fs.ReadFile(dir, relPath) + require.NoError(t, err) + tw.Write(data) + } + + err := fs.WalkDir(dir, ".", func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + if d.IsDir() { + return nil + } + + writeFile(path) + + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = tw.Close() + require.NoError(t, err) + err = gw.Close() + require.NoError(t, err) + + return &result +} + func TestExpand(t *testing.T) { dest := t.TempDir() @@ -75,6 +137,28 @@ func TestExpand(t *testing.T) { } } +func TestExpandError(t *testing.T) { + tests := map[string]struct { + chartName string + chartDir string + wantErr string + }{ + "dot name": {"dotname", "testdata/dotname", "not allowed"}, + "dotdot name": {"dotdotname", "testdata/dotdotname", "not allowed"}, + "slash in name": {"slashinname", "testdata/slashinname", "must not contain path separators"}, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + archive := makeTestChartArchive(t, tt.chartName, tt.chartDir) + + dest := t.TempDir() + err := Expand(dest, archive) + assert.ErrorContains(t, err, tt.wantErr) + }) + } +} + func TestExpandFile(t *testing.T) { dest := t.TempDir() diff --git a/pkg/helm/intern/chart/v3/util/save_test.go b/pkg/helm/intern/chart/v3/util/save_test.go index b7d8abec..4c68fc41 100644 --- a/pkg/helm/intern/chart/v3/util/save_test.go +++ b/pkg/helm/intern/chart/v3/util/save_test.go @@ -22,8 +22,8 @@ import ( "compress/gzip" "context" "crypto/sha256" + "encoding/hex" "errors" - "fmt" "io" "os" "path" @@ -88,7 +88,7 @@ func TestSave(t *testing.T) { t.Fatalf("Schema data did not match.\nExpected:\n%s\nActual:\n%s", formattedExpected, formattedActual) } if _, err := Save(&chartWithInvalidJSON, dest); err == nil { - t.Fatalf("Invalid JSON was not caught while saving chart") + t.Fatal("Invalid JSON was not caught while saving chart") } c.Metadata.APIVersion = chart.APIVersionV3 @@ -154,7 +154,7 @@ func TestSavePreservesTimestamps(t *testing.T) { Version: "1.2.3", }, ModTime: initialCreateTime, - Values: map[string]interface{}{ + Values: map[string]any{ "imageName": "testimage", "imageId": 42, }, @@ -354,5 +354,5 @@ func sha256Sum(filePath string) (string, error) { return "", err } - return fmt.Sprintf("%x", h.Sum(nil)), nil + return hex.EncodeToString(h.Sum(nil)), nil } diff --git a/pkg/helm/intern/chart/v3/util/testdata/dotdotname/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/dotdotname/Chart.yaml new file mode 100644 index 00000000..9b081f27 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/dotdotname/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v3 +name: .. +description: A Helm chart for Kubernetes +version: 0.1.0 \ No newline at end of file diff --git a/pkg/helm/intern/chart/v3/util/testdata/dotname/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/dotname/Chart.yaml new file mode 100644 index 00000000..597c1629 --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/dotname/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v3 +name: . +description: A Helm chart for Kubernetes +version: 0.1.0 \ No newline at end of file diff --git a/pkg/helm/intern/chart/v3/util/testdata/slashinname/Chart.yaml b/pkg/helm/intern/chart/v3/util/testdata/slashinname/Chart.yaml new file mode 100644 index 00000000..0c522a4b --- /dev/null +++ b/pkg/helm/intern/chart/v3/util/testdata/slashinname/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v3 +name: a/../b +description: A Helm chart for Kubernetes +version: 0.1.0 \ No newline at end of file diff --git a/pkg/helm/intern/gates/doc.go b/pkg/helm/intern/gates/doc.go new file mode 100644 index 00000000..09501e00 --- /dev/null +++ b/pkg/helm/intern/gates/doc.go @@ -0,0 +1,19 @@ +/* +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 gates contains internal feature gates that can be used to enable or disable experimental features. +// This is a separate internal package instead of using the pkg/gates package to avoid circular dependencies. +package gates diff --git a/pkg/helm/intern/gates/gates.go b/pkg/helm/intern/gates/gates.go new file mode 100644 index 00000000..2b868bed --- /dev/null +++ b/pkg/helm/intern/gates/gates.go @@ -0,0 +1,21 @@ +/* +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 gates + +import "github.com/werf/nelm/pkg/helm/pkg/gates" + +// ChartV3 is the feature gate for chart API version v3. +const ChartV3 gates.Gate = "HELM_EXPERIMENTAL_CHART_V3" diff --git a/pkg/helm/intern/release/v2/doc.go b/pkg/helm/intern/release/v2/doc.go new file mode 100644 index 00000000..5b8cebee --- /dev/null +++ b/pkg/helm/intern/release/v2/doc.go @@ -0,0 +1,17 @@ +/* +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 v2 provides release handling for apiVersion v3 charts. +package v2 diff --git a/pkg/helm/intern/release/v2/hook.go b/pkg/helm/intern/release/v2/hook.go new file mode 100644 index 00000000..5009ffbd --- /dev/null +++ b/pkg/helm/intern/release/v2/hook.go @@ -0,0 +1,189 @@ +/* +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 v2 + +import ( + "encoding/json" + "time" +) + +// HookEvent specifies the hook event +type HookEvent string + +// Hook event types +const ( + HookPreInstall HookEvent = "pre-install" + HookPostInstall HookEvent = "post-install" + HookPreDelete HookEvent = "pre-delete" + HookPostDelete HookEvent = "post-delete" + HookPreUpgrade HookEvent = "pre-upgrade" + HookPostUpgrade HookEvent = "post-upgrade" + HookPreRollback HookEvent = "pre-rollback" + HookPostRollback HookEvent = "post-rollback" + HookTest HookEvent = "test" +) + +func (x HookEvent) String() string { return string(x) } + +// HookDeletePolicy specifies the hook delete policy +type HookDeletePolicy string + +// Hook delete policy types +const ( + HookSucceeded HookDeletePolicy = "hook-succeeded" + HookFailed HookDeletePolicy = "hook-failed" + HookBeforeHookCreation HookDeletePolicy = "before-hook-creation" +) + +func (x HookDeletePolicy) String() string { return string(x) } + +// HookOutputLogPolicy specifies the hook output log policy +type HookOutputLogPolicy string + +// Hook output log policy types +const ( + HookOutputOnSucceeded HookOutputLogPolicy = "hook-succeeded" + HookOutputOnFailed HookOutputLogPolicy = "hook-failed" +) + +func (x HookOutputLogPolicy) String() string { return string(x) } + +// HookAnnotation is the label name for a hook +const HookAnnotation = "helm.sh/hook" + +// HookWeightAnnotation is the label name for a hook weight +const HookWeightAnnotation = "helm.sh/hook-weight" + +// HookDeleteAnnotation is the label name for the delete policy for a hook +const HookDeleteAnnotation = "helm.sh/hook-delete-policy" + +// HookOutputLogAnnotation is the label name for the output log policy for a hook +const HookOutputLogAnnotation = "helm.sh/hook-output-log-policy" + +// Hook defines a hook object. +type Hook struct { + Name string `json:"name,omitempty"` + // Kind is the Kubernetes kind. + Kind string `json:"kind,omitempty"` + // Path is the chart-relative path to the template. + Path string `json:"path,omitempty"` + // Manifest is the manifest contents. + Manifest string `json:"manifest,omitempty"` + // Events are the events that this hook fires on. + Events []HookEvent `json:"events,omitempty"` + // LastRun indicates the date/time this was last run. + LastRun HookExecution `json:"last_run"` + // Weight indicates the sort order for execution among similar Hook type + Weight int `json:"weight,omitempty"` + // DeletePolicies are the policies that indicate when to delete the hook + DeletePolicies []HookDeletePolicy `json:"delete_policies,omitempty"` + // OutputLogPolicies defines whether we should copy hook logs back to main process + OutputLogPolicies []HookOutputLogPolicy `json:"output_log_policies,omitempty"` +} + +// A HookExecution records the result for the last execution of a hook for a given release. +type HookExecution struct { + // StartedAt indicates the date/time this hook was started + StartedAt time.Time `json:"started_at,omitzero"` + // CompletedAt indicates the date/time this hook was completed. + CompletedAt time.Time `json:"completed_at,omitzero"` + // Phase indicates whether the hook completed successfully + Phase HookPhase `json:"phase"` +} + +// A HookPhase indicates the state of a hook execution +type HookPhase string + +const ( + // HookPhaseUnknown indicates that a hook is in an unknown state + HookPhaseUnknown HookPhase = "Unknown" + // HookPhaseRunning indicates that a hook is currently executing + HookPhaseRunning HookPhase = "Running" + // HookPhaseSucceeded indicates that hook execution succeeded + HookPhaseSucceeded HookPhase = "Succeeded" + // HookPhaseFailed indicates that hook execution failed + HookPhaseFailed HookPhase = "Failed" +) + +// String converts a hook phase to a printable string +func (x HookPhase) String() string { return string(x) } + +// hookExecutionJSON is used for custom JSON marshaling/unmarshaling +type hookExecutionJSON struct { + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Phase HookPhase `json:"phase"` +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +// It handles empty string time fields by treating them as zero values. +func (h *HookExecution) UnmarshalJSON(data []byte) error { + // First try to unmarshal into a map to handle empty string time fields + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + // Replace empty string time fields with nil + for _, field := range []string{"started_at", "completed_at"} { + if val, ok := raw[field]; ok { + if str, ok := val.(string); ok && str == "" { + raw[field] = nil + } + } + } + + // Re-marshal with cleaned data + cleaned, err := json.Marshal(raw) + if err != nil { + return err + } + + // Unmarshal into temporary struct with pointer time fields + var tmp hookExecutionJSON + if err := json.Unmarshal(cleaned, &tmp); err != nil { + return err + } + + // Copy values to HookExecution struct + if tmp.StartedAt != nil { + h.StartedAt = *tmp.StartedAt + } + if tmp.CompletedAt != nil { + h.CompletedAt = *tmp.CompletedAt + } + h.Phase = tmp.Phase + + return nil +} + +// MarshalJSON implements the json.Marshaler interface. +// It omits zero-value time fields from the JSON output. +func (h HookExecution) MarshalJSON() ([]byte, error) { + tmp := hookExecutionJSON{ + Phase: h.Phase, + } + + if !h.StartedAt.IsZero() { + tmp.StartedAt = &h.StartedAt + } + if !h.CompletedAt.IsZero() { + tmp.CompletedAt = &h.CompletedAt + } + + return json.Marshal(tmp) +} diff --git a/pkg/helm/intern/release/v2/hook_test.go b/pkg/helm/intern/release/v2/hook_test.go new file mode 100644 index 00000000..5a086739 --- /dev/null +++ b/pkg/helm/intern/release/v2/hook_test.go @@ -0,0 +1,231 @@ +/* +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 v2 + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHookExecutionMarshalJSON(t *testing.T) { + started := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + completed := time.Date(2025, 10, 8, 12, 5, 0, 0, time.UTC) + + tests := []struct { + name string + exec HookExecution + expected string + }{ + { + name: "all fields populated", + exec: HookExecution{ + StartedAt: started, + CompletedAt: completed, + Phase: HookPhaseSucceeded, + }, + expected: `{"started_at":"2025-10-08T12:00:00Z","completed_at":"2025-10-08T12:05:00Z","phase":"Succeeded"}`, + }, + { + name: "only phase", + exec: HookExecution{ + Phase: HookPhaseRunning, + }, + expected: `{"phase":"Running"}`, + }, + { + name: "with started time only", + exec: HookExecution{ + StartedAt: started, + Phase: HookPhaseRunning, + }, + expected: `{"started_at":"2025-10-08T12:00:00Z","phase":"Running"}`, + }, + { + name: "failed phase", + exec: HookExecution{ + StartedAt: started, + CompletedAt: completed, + Phase: HookPhaseFailed, + }, + expected: `{"started_at":"2025-10-08T12:00:00Z","completed_at":"2025-10-08T12:05:00Z","phase":"Failed"}`, + }, + { + name: "unknown phase", + exec: HookExecution{ + Phase: HookPhaseUnknown, + }, + expected: `{"phase":"Unknown"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(&tt.exec) + require.NoError(t, err) + assert.JSONEq(t, tt.expected, string(data)) + }) + } +} + +func TestHookExecutionUnmarshalJSON(t *testing.T) { + started := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + completed := time.Date(2025, 10, 8, 12, 5, 0, 0, time.UTC) + + tests := []struct { + name string + input string + expected HookExecution + wantErr bool + }{ + { + name: "all fields populated", + input: `{"started_at":"2025-10-08T12:00:00Z","completed_at":"2025-10-08T12:05:00Z","phase":"Succeeded"}`, + expected: HookExecution{ + StartedAt: started, + CompletedAt: completed, + Phase: HookPhaseSucceeded, + }, + }, + { + name: "only phase", + input: `{"phase":"Running"}`, + expected: HookExecution{ + Phase: HookPhaseRunning, + }, + }, + { + name: "empty string time fields", + input: `{"started_at":"","completed_at":"","phase":"Succeeded"}`, + expected: HookExecution{ + Phase: HookPhaseSucceeded, + }, + }, + { + name: "missing time fields", + input: `{"phase":"Failed"}`, + expected: HookExecution{ + Phase: HookPhaseFailed, + }, + }, + { + name: "null time fields", + input: `{"started_at":null,"completed_at":null,"phase":"Unknown"}`, + expected: HookExecution{ + Phase: HookPhaseUnknown, + }, + }, + { + name: "mixed empty and valid time fields", + input: `{"started_at":"2025-10-08T12:00:00Z","completed_at":"","phase":"Running"}`, + expected: HookExecution{ + StartedAt: started, + Phase: HookPhaseRunning, + }, + }, + { + name: "with started time only", + input: `{"started_at":"2025-10-08T12:00:00Z","phase":"Running"}`, + expected: HookExecution{ + StartedAt: started, + Phase: HookPhaseRunning, + }, + }, + { + name: "failed phase with times", + input: `{"started_at":"2025-10-08T12:00:00Z","completed_at":"2025-10-08T12:05:00Z","phase":"Failed"}`, + expected: HookExecution{ + StartedAt: started, + CompletedAt: completed, + Phase: HookPhaseFailed, + }, + }, + { + name: "invalid time format", + input: `{"started_at":"invalid-time","phase":"Running"}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var exec HookExecution + err := json.Unmarshal([]byte(tt.input), &exec) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.expected.StartedAt.Unix(), exec.StartedAt.Unix()) + assert.Equal(t, tt.expected.CompletedAt.Unix(), exec.CompletedAt.Unix()) + assert.Equal(t, tt.expected.Phase, exec.Phase) + }) + } +} + +func TestHookExecutionRoundTrip(t *testing.T) { + started := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + completed := time.Date(2025, 10, 8, 12, 5, 0, 0, time.UTC) + + original := HookExecution{ + StartedAt: started, + CompletedAt: completed, + Phase: HookPhaseSucceeded, + } + + data, err := json.Marshal(&original) + require.NoError(t, err) + + var decoded HookExecution + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, original.StartedAt.Unix(), decoded.StartedAt.Unix()) + assert.Equal(t, original.CompletedAt.Unix(), decoded.CompletedAt.Unix()) + assert.Equal(t, original.Phase, decoded.Phase) +} + +func TestHookExecutionEmptyStringRoundTrip(t *testing.T) { + // This test specifically verifies that empty string time fields + // are handled correctly during parsing + input := `{"started_at":"","completed_at":"","phase":"Succeeded"}` + + var exec HookExecution + err := json.Unmarshal([]byte(input), &exec) + require.NoError(t, err) + + // Verify time fields are zero values + assert.True(t, exec.StartedAt.IsZero()) + assert.True(t, exec.CompletedAt.IsZero()) + assert.Equal(t, HookPhaseSucceeded, exec.Phase) + + // Marshal back and verify empty time fields are omitted + data, err := json.Marshal(&exec) + require.NoError(t, err) + + var result map[string]any + err = json.Unmarshal(data, &result) + require.NoError(t, err) + + // Zero time values should be omitted + assert.NotContains(t, result, "started_at") + assert.NotContains(t, result, "completed_at") + assert.Equal(t, "Succeeded", result["phase"]) +} diff --git a/pkg/helm/intern/release/v2/info.go b/pkg/helm/intern/release/v2/info.go new file mode 100644 index 00000000..f9b918ec --- /dev/null +++ b/pkg/helm/intern/release/v2/info.go @@ -0,0 +1,125 @@ +/* +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 v2 + +import ( + "encoding/json" + "time" + + "github.com/werf/nelm/pkg/helm/pkg/release/common" + + "k8s.io/apimachinery/pkg/runtime" +) + +// Info describes release information. +type Info struct { + // FirstDeployed is when the release was first deployed. + FirstDeployed time.Time `json:"first_deployed,omitzero"` + // LastDeployed is when the release was last deployed. + LastDeployed time.Time `json:"last_deployed,omitzero"` + // Deleted tracks when this object was deleted. + Deleted time.Time `json:"deleted,omitzero"` + // Description is human-friendly "log entry" about this release. + Description string `json:"description,omitempty"` + // Status is the current state of the release + Status common.Status `json:"status,omitempty"` + // Contains the rendered templates/NOTES.txt if available + Notes string `json:"notes,omitempty"` + // Contains the deployed resources information + Resources map[string][]runtime.Object `json:"resources,omitempty"` +} + +// infoJSON is used for custom JSON marshaling/unmarshaling +type infoJSON struct { + FirstDeployed *time.Time `json:"first_deployed,omitempty"` + LastDeployed *time.Time `json:"last_deployed,omitempty"` + Deleted *time.Time `json:"deleted,omitempty"` + Description string `json:"description,omitempty"` + Status common.Status `json:"status,omitempty"` + Notes string `json:"notes,omitempty"` + Resources map[string][]runtime.Object `json:"resources,omitempty"` +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +// It handles empty string time fields by treating them as zero values. +func (i *Info) UnmarshalJSON(data []byte) error { + // First try to unmarshal into a map to handle empty string time fields + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + // Replace empty string time fields with nil + for _, field := range []string{"first_deployed", "last_deployed", "deleted"} { + if val, ok := raw[field]; ok { + if str, ok := val.(string); ok && str == "" { + raw[field] = nil + } + } + } + + // Re-marshal with cleaned data + cleaned, err := json.Marshal(raw) + if err != nil { + return err + } + + // Unmarshal into temporary struct with pointer time fields + var tmp infoJSON + if err := json.Unmarshal(cleaned, &tmp); err != nil { + return err + } + + // Copy values to Info struct + if tmp.FirstDeployed != nil { + i.FirstDeployed = *tmp.FirstDeployed + } + if tmp.LastDeployed != nil { + i.LastDeployed = *tmp.LastDeployed + } + if tmp.Deleted != nil { + i.Deleted = *tmp.Deleted + } + i.Description = tmp.Description + i.Status = tmp.Status + i.Notes = tmp.Notes + i.Resources = tmp.Resources + + return nil +} + +// MarshalJSON implements the json.Marshaler interface. +// It omits zero-value time fields from the JSON output. +func (i Info) MarshalJSON() ([]byte, error) { + tmp := infoJSON{ + Description: i.Description, + Status: i.Status, + Notes: i.Notes, + Resources: i.Resources, + } + + if !i.FirstDeployed.IsZero() { + tmp.FirstDeployed = &i.FirstDeployed + } + if !i.LastDeployed.IsZero() { + tmp.LastDeployed = &i.LastDeployed + } + if !i.Deleted.IsZero() { + tmp.Deleted = &i.Deleted + } + + return json.Marshal(tmp) +} diff --git a/pkg/helm/intern/release/v2/info_test.go b/pkg/helm/intern/release/v2/info_test.go new file mode 100644 index 00000000..11d1c0df --- /dev/null +++ b/pkg/helm/intern/release/v2/info_test.go @@ -0,0 +1,285 @@ +/* +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 v2 + +import ( + "encoding/json" + "testing" + "time" + + "github.com/werf/nelm/pkg/helm/pkg/release/common" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInfoMarshalJSON(t *testing.T) { + now := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + later := time.Date(2025, 10, 8, 13, 0, 0, 0, time.UTC) + deleted := time.Date(2025, 10, 8, 14, 0, 0, 0, time.UTC) + + tests := []struct { + name string + info Info + expected string + }{ + { + name: "all fields populated", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Deleted: deleted, + Description: "Test release", + Status: common.StatusDeployed, + Notes: "Test notes", + }, + expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","deleted":"2025-10-08T14:00:00Z","description":"Test release","status":"deployed","notes":"Test notes"}`, + }, + { + name: "only required fields", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusDeployed, + }, + expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"deployed"}`, + }, + { + name: "zero time values omitted", + info: Info{ + Description: "Test release", + Status: common.StatusDeployed, + }, + expected: `{"description":"Test release","status":"deployed"}`, + }, + { + name: "with pending status", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusPendingInstall, + Description: "Installing release", + }, + expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","description":"Installing release","status":"pending-install"}`, + }, + { + name: "uninstalled with deleted time", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Deleted: deleted, + Status: common.StatusUninstalled, + Description: "Uninstalled release", + }, + expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","deleted":"2025-10-08T14:00:00Z","description":"Uninstalled release","status":"uninstalled"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(&tt.info) + require.NoError(t, err) + assert.JSONEq(t, tt.expected, string(data)) + }) + } +} + +func TestInfoUnmarshalJSON(t *testing.T) { + now := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + later := time.Date(2025, 10, 8, 13, 0, 0, 0, time.UTC) + deleted := time.Date(2025, 10, 8, 14, 0, 0, 0, time.UTC) + + tests := []struct { + name string + input string + expected Info + wantErr bool + }{ + { + name: "all fields populated", + input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","deleted":"2025-10-08T14:00:00Z","description":"Test release","status":"deployed","notes":"Test notes"}`, + expected: Info{ + FirstDeployed: now, + LastDeployed: later, + Deleted: deleted, + Description: "Test release", + Status: common.StatusDeployed, + Notes: "Test notes", + }, + }, + { + name: "only required fields", + input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"deployed"}`, + expected: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusDeployed, + }, + }, + { + name: "empty string time fields", + input: `{"first_deployed":"","last_deployed":"","deleted":"","description":"Test release","status":"deployed"}`, + expected: Info{ + Description: "Test release", + Status: common.StatusDeployed, + }, + }, + { + name: "missing time fields", + input: `{"description":"Test release","status":"deployed"}`, + expected: Info{ + Description: "Test release", + Status: common.StatusDeployed, + }, + }, + { + name: "null time fields", + input: `{"first_deployed":null,"last_deployed":null,"deleted":null,"description":"Test release","status":"deployed"}`, + expected: Info{ + Description: "Test release", + Status: common.StatusDeployed, + }, + }, + { + name: "mixed empty and valid time fields", + input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"","deleted":"","status":"deployed"}`, + expected: Info{ + FirstDeployed: now, + Status: common.StatusDeployed, + }, + }, + { + name: "pending install status", + input: `{"first_deployed":"2025-10-08T12:00:00Z","status":"pending-install","description":"Installing"}`, + expected: Info{ + FirstDeployed: now, + Status: common.StatusPendingInstall, + Description: "Installing", + }, + }, + { + name: "uninstalled with deleted time", + input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","deleted":"2025-10-08T14:00:00Z","status":"uninstalled"}`, + expected: Info{ + FirstDeployed: now, + LastDeployed: later, + Deleted: deleted, + Status: common.StatusUninstalled, + }, + }, + { + name: "failed status", + input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"failed","description":"Deployment failed"}`, + expected: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusFailed, + Description: "Deployment failed", + }, + }, + { + name: "invalid time format", + input: `{"first_deployed":"invalid-time","status":"deployed"}`, + wantErr: true, + }, + { + name: "empty object", + input: `{}`, + expected: Info{ + Status: "", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var info Info + err := json.Unmarshal([]byte(tt.input), &info) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.expected.FirstDeployed.Unix(), info.FirstDeployed.Unix()) + assert.Equal(t, tt.expected.LastDeployed.Unix(), info.LastDeployed.Unix()) + assert.Equal(t, tt.expected.Deleted.Unix(), info.Deleted.Unix()) + assert.Equal(t, tt.expected.Description, info.Description) + assert.Equal(t, tt.expected.Status, info.Status) + assert.Equal(t, tt.expected.Notes, info.Notes) + assert.Equal(t, tt.expected.Resources, info.Resources) + }) + } +} + +func TestInfoRoundTrip(t *testing.T) { + now := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + later := time.Date(2025, 10, 8, 13, 0, 0, 0, time.UTC) + + original := Info{ + FirstDeployed: now, + LastDeployed: later, + Description: "Test release", + Status: common.StatusDeployed, + Notes: "Release notes", + } + + data, err := json.Marshal(&original) + require.NoError(t, err) + + var decoded Info + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, original.FirstDeployed.Unix(), decoded.FirstDeployed.Unix()) + assert.Equal(t, original.LastDeployed.Unix(), decoded.LastDeployed.Unix()) + assert.Equal(t, original.Deleted.Unix(), decoded.Deleted.Unix()) + assert.Equal(t, original.Description, decoded.Description) + assert.Equal(t, original.Status, decoded.Status) + assert.Equal(t, original.Notes, decoded.Notes) +} + +func TestInfoEmptyStringRoundTrip(t *testing.T) { + // This test specifically verifies that empty string time fields + // are handled correctly during parsing + input := `{"first_deployed":"","last_deployed":"","deleted":"","status":"deployed","description":"test"}` + + var info Info + err := json.Unmarshal([]byte(input), &info) + require.NoError(t, err) + + // Verify time fields are zero values + assert.True(t, info.FirstDeployed.IsZero()) + assert.True(t, info.LastDeployed.IsZero()) + assert.True(t, info.Deleted.IsZero()) + assert.Equal(t, common.StatusDeployed, info.Status) + assert.Equal(t, "test", info.Description) + + // Marshal back and verify empty time fields are omitted + data, err := json.Marshal(&info) + require.NoError(t, err) + + var result map[string]any + err = json.Unmarshal(data, &result) + require.NoError(t, err) + + // Zero time values should be omitted due to omitzero tag + assert.NotContains(t, result, "first_deployed") + assert.NotContains(t, result, "last_deployed") + assert.NotContains(t, result, "deleted") + assert.Equal(t, "deployed", result["status"]) + assert.Equal(t, "test", result["description"]) +} diff --git a/pkg/helm/intern/release/v2/mock.go b/pkg/helm/intern/release/v2/mock.go new file mode 100644 index 00000000..0b57e5c0 --- /dev/null +++ b/pkg/helm/intern/release/v2/mock.go @@ -0,0 +1,143 @@ +/* +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 v2 + +import ( + "math/rand" + "strconv" + "time" + + v3 "github.com/werf/nelm/pkg/helm/intern/chart/v3" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" + rcommon "github.com/werf/nelm/pkg/helm/pkg/release/common" +) + +// MockHookTemplate is the hook template used for all mock release objects. +var MockHookTemplate = `apiVersion: v1 +kind: Job +metadata: + annotations: + "helm.sh/hook": pre-install +` + +// MockManifest is the manifest used for all mock release objects. +var MockManifest = `apiVersion: v1 +kind: Secret +metadata: + name: fixture +` + +// MockReleaseOptions allows for user-configurable options on mock release objects. +type MockReleaseOptions struct { + Name string + Version int + Chart *v3.Chart + Status rcommon.Status + Namespace string + Labels map[string]string +} + +// Mock creates a mock release object based on options set by MockReleaseOptions. This function should typically not be used outside of testing. +func Mock(opts *MockReleaseOptions) *Release { + date := time.Unix(242085845, 0).UTC() + + name := opts.Name + if name == "" { + name = "testrelease-" + strconv.Itoa(rand.Intn(100)) + } + + version := 1 + if opts.Version != 0 { + version = opts.Version + } + + namespace := opts.Namespace + if namespace == "" { + namespace = "default" + } + var labels map[string]string + if len(opts.Labels) > 0 { + labels = opts.Labels + } + + ch := opts.Chart + if opts.Chart == nil { + ch = &v3.Chart{ + Metadata: &v3.Metadata{ + Name: "foo", + Version: "0.1.0-beta.1", + AppVersion: "1.0", + APIVersion: v3.APIVersionV3, + Annotations: map[string]string{ + "category": "web-apps", + "supported": "true", + }, + Dependencies: []*v3.Dependency{ + { + Name: "cool-plugin", + Version: "1.0.0", + Repository: "https://coolplugin.io/charts", + Condition: "coolPlugin.enabled", + Enabled: true, + }, + { + Name: "crds", + Version: "2.7.1", + Condition: "crds.enabled", + }, + }, + }, + Templates: []*common.File{ + {Name: "templates/foo.tpl", ModTime: time.Now(), Data: []byte(MockManifest)}, + }, + } + } + + scode := rcommon.StatusDeployed + if len(opts.Status) > 0 { + scode = opts.Status + } + + info := &Info{ + FirstDeployed: date, + LastDeployed: date, + Status: scode, + Description: "Release mock", + Notes: "Some mock release notes!", + } + + return &Release{ + Name: name, + Info: info, + Chart: ch, + Config: map[string]any{"name": "value"}, + Version: version, + Namespace: namespace, + Hooks: []*Hook{ + { + Name: "pre-install-hook", + Kind: "Job", + Path: "pre-install-hook.yaml", + Manifest: MockHookTemplate, + LastRun: HookExecution{}, + Events: []HookEvent{HookPreInstall}, + }, + }, + Manifest: MockManifest, + Labels: labels, + } +} diff --git a/pkg/helm/intern/release/v2/release.go b/pkg/helm/intern/release/v2/release.go new file mode 100644 index 00000000..5d83f1e4 --- /dev/null +++ b/pkg/helm/intern/release/v2/release.go @@ -0,0 +1,60 @@ +/* +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 v2 + +import ( + chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" + "github.com/werf/nelm/pkg/helm/pkg/release/common" +) + +type ApplyMethod string + +const ApplyMethodClientSideApply ApplyMethod = "csa" +const ApplyMethodServerSideApply ApplyMethod = "ssa" + +// Release describes a deployment of a chart, together with the chart +// and the variables used to deploy that chart. +type Release struct { + // Name is the name of the release + Name string `json:"name,omitempty"` + // Info provides information about a release + Info *Info `json:"info,omitempty"` + // Chart is the chart that was released. + Chart *chart.Chart `json:"chart,omitempty"` + // Config is the set of extra Values added to the chart. + // These values override the default values inside of the chart. + Config map[string]any `json:"config,omitempty"` + // Manifest is the string representation of the rendered template. + Manifest string `json:"manifest,omitempty"` + // Hooks are all of the hooks declared for this release. + Hooks []*Hook `json:"hooks,omitempty"` + // Version is an int which represents the revision of the release. + Version int `json:"version,omitempty"` + // Namespace is the kubernetes namespace of the release. + Namespace string `json:"namespace,omitempty"` + // Labels of the release. + // Disabled encoding into Json cause labels are stored in storage driver metadata field. + Labels map[string]string `json:"-"` + // ApplyMethod stores whether server-side or client-side apply was used for the release + // Unset (empty string) should be treated as the default of client-side apply + ApplyMethod string `json:"apply_method,omitempty"` // "ssa" | "csa" +} + +// SetStatus is a helper for setting the status on a release. +func (r *Release) SetStatus(status common.Status, msg string) { + r.Info.Status = status + r.Info.Description = msg +} diff --git a/pkg/helm/intern/release/v2/util/filter.go b/pkg/helm/intern/release/v2/util/filter.go new file mode 100644 index 00000000..efb336e4 --- /dev/null +++ b/pkg/helm/intern/release/v2/util/filter.go @@ -0,0 +1,81 @@ +/* +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 util // import "helm.sh/helm/v4/internal/release/v2/util" + +import ( + v2 "github.com/werf/nelm/pkg/helm/intern/release/v2" + "github.com/werf/nelm/pkg/helm/pkg/release/common" +) + +// FilterFunc returns true if the release object satisfies +// the predicate of the underlying filter func. +type FilterFunc func(*v2.Release) bool + +// Check applies the FilterFunc to the release object. +func (fn FilterFunc) Check(rls *v2.Release) bool { + if rls == nil { + return false + } + return fn(rls) +} + +// Filter applies the filter(s) to the list of provided releases +// returning the list that satisfies the filtering predicate. +func (fn FilterFunc) Filter(rels []*v2.Release) (rets []*v2.Release) { + for _, rel := range rels { + if fn.Check(rel) { + rets = append(rets, rel) + } + } + return +} + +// Any returns a FilterFunc that filters a list of releases +// determined by the predicate 'f0 || f1 || ... || fn'. +func Any(filters ...FilterFunc) FilterFunc { + return func(rls *v2.Release) bool { + for _, filter := range filters { + if filter(rls) { + return true + } + } + return false + } +} + +// All returns a FilterFunc that filters a list of releases +// determined by the predicate 'f0 && f1 && ... && fn'. +func All(filters ...FilterFunc) FilterFunc { + return func(rls *v2.Release) bool { + for _, filter := range filters { + if !filter(rls) { + return false + } + } + return true + } +} + +// StatusFilter filters a set of releases by status code. +func StatusFilter(status common.Status) FilterFunc { + return FilterFunc(func(rls *v2.Release) bool { + if rls == nil { + return true + } + return rls.Info.Status == status + }) +} diff --git a/pkg/helm/intern/release/v2/util/filter_test.go b/pkg/helm/intern/release/v2/util/filter_test.go new file mode 100644 index 00000000..89cd13a2 --- /dev/null +++ b/pkg/helm/intern/release/v2/util/filter_test.go @@ -0,0 +1,60 @@ +/* +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 util // import "helm.sh/helm/v4/internal/release/v2/util" + +import ( + "testing" + + rspb "github.com/werf/nelm/pkg/helm/intern/release/v2" + "github.com/werf/nelm/pkg/helm/pkg/release/common" +) + +func TestFilterAny(t *testing.T) { + ls := Any(StatusFilter(common.StatusUninstalled)).Filter(releases) + if len(ls) != 2 { + t.Fatalf("expected 2 results, got '%d'", len(ls)) + } + + r0, r1 := ls[0], ls[1] + switch { + case r0.Info.Status != common.StatusUninstalled: + t.Fatalf("expected UNINSTALLED result, got '%s'", r1.Info.Status.String()) + case r1.Info.Status != common.StatusUninstalled: + t.Fatalf("expected UNINSTALLED result, got '%s'", r1.Info.Status.String()) + } +} + +func TestFilterAll(t *testing.T) { + fn := FilterFunc(func(rls *rspb.Release) bool { + // true if not uninstalled and version < 4 + v0 := !StatusFilter(common.StatusUninstalled).Check(rls) + v1 := rls.Version < 4 + return v0 && v1 + }) + + ls := All(fn).Filter(releases) + if len(ls) != 1 { + t.Fatalf("expected 1 result, got '%d'", len(ls)) + } + + switch r0 := ls[0]; { + case r0.Version == 4: + t.Fatal("got release with status revision 4") + case r0.Info.Status == common.StatusUninstalled: + t.Fatal("got release with status UNINSTALLED") + } +} diff --git a/pkg/helm/intern/release/v2/util/kind_sorter.go b/pkg/helm/intern/release/v2/util/kind_sorter.go new file mode 100644 index 00000000..9ede14f3 --- /dev/null +++ b/pkg/helm/intern/release/v2/util/kind_sorter.go @@ -0,0 +1,165 @@ +/* +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 util // import "helm.sh/helm/v4/internal/release/v2/util" + +import ( + "sort" + + release "github.com/werf/nelm/pkg/helm/intern/release/v2" +) + +// KindSortOrder is an ordering of Kinds. +type KindSortOrder []string + +// InstallOrder is the order in which manifests should be installed (by Kind). +// +// Those occurring earlier in the list get installed before those occurring later in the list. +var InstallOrder KindSortOrder = []string{ + "PriorityClass", + "Namespace", + "NetworkPolicy", + "ResourceQuota", + "LimitRange", + "PodSecurityPolicy", + "PodDisruptionBudget", + "ServiceAccount", + "Secret", + "SecretList", + "ConfigMap", + "StorageClass", + "PersistentVolume", + "PersistentVolumeClaim", + "CustomResourceDefinition", + "ClusterRole", + "ClusterRoleList", + "ClusterRoleBinding", + "ClusterRoleBindingList", + "Role", + "RoleList", + "RoleBinding", + "RoleBindingList", + "Service", + "DaemonSet", + "Pod", + "ReplicationController", + "ReplicaSet", + "Deployment", + "HorizontalPodAutoscaler", + "StatefulSet", + "Job", + "CronJob", + "IngressClass", + "Ingress", + "APIService", + "MutatingWebhookConfiguration", + "ValidatingWebhookConfiguration", +} + +// UninstallOrder is the order in which manifests should be uninstalled (by Kind). +// +// Those occurring earlier in the list get uninstalled before those occurring later in the list. +var UninstallOrder KindSortOrder = []string{ + // For uninstall, we remove validation before mutation to ensure webhooks don't block removal + "ValidatingWebhookConfiguration", + "MutatingWebhookConfiguration", + "APIService", + "Ingress", + "IngressClass", + "Service", + "CronJob", + "Job", + "StatefulSet", + "HorizontalPodAutoscaler", + "Deployment", + "ReplicaSet", + "ReplicationController", + "Pod", + "DaemonSet", + "RoleBindingList", + "RoleBinding", + "RoleList", + "Role", + "ClusterRoleBindingList", + "ClusterRoleBinding", + "ClusterRoleList", + "ClusterRole", + "CustomResourceDefinition", + "PersistentVolumeClaim", + "PersistentVolume", + "StorageClass", + "ConfigMap", + "SecretList", + "Secret", + "ServiceAccount", + "PodDisruptionBudget", + "PodSecurityPolicy", + "LimitRange", + "ResourceQuota", + "NetworkPolicy", + "Namespace", + "PriorityClass", +} + +// sort manifests by kind. +// +// Results are sorted by 'ordering', keeping order of items with equal kind/priority +func sortManifestsByKind(manifests []Manifest, ordering KindSortOrder) []Manifest { + sort.SliceStable(manifests, func(i, j int) bool { + return lessByKind(manifests[i], manifests[j], manifests[i].Head.Kind, manifests[j].Head.Kind, ordering) + }) + + return manifests +} + +// sort hooks by kind, using an out-of-place sort to preserve the input parameters. +// +// Results are sorted by 'ordering', keeping order of items with equal kind/priority +func sortHooksByKind(hooks []*release.Hook, ordering KindSortOrder) []*release.Hook { + h := hooks + sort.SliceStable(h, func(i, j int) bool { + return lessByKind(h[i], h[j], h[i].Kind, h[j].Kind, ordering) + }) + + return h +} + +func lessByKind(_ any, _ any, kindA string, kindB string, o KindSortOrder) bool { + ordering := make(map[string]int, len(o)) + for v, k := range o { + ordering[k] = v + } + + first, aok := ordering[kindA] + second, bok := ordering[kindB] + + if !aok && !bok { + // if both are unknown then sort alphabetically by kind, keep original order if same kind + if kindA != kindB { + return kindA < kindB + } + return first < second + } + // unknown kind is last + if !aok { + return false + } + if !bok { + return true + } + // sort different kinds, keep original order if same priority + return first < second +} diff --git a/pkg/helm/intern/release/v2/util/kind_sorter_test.go b/pkg/helm/intern/release/v2/util/kind_sorter_test.go new file mode 100644 index 00000000..78f58467 --- /dev/null +++ b/pkg/helm/intern/release/v2/util/kind_sorter_test.go @@ -0,0 +1,347 @@ +/* +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 util // import "helm.sh/helm/v4/internal/release/v2/util" + +import ( + "bytes" + "testing" + + release "github.com/werf/nelm/pkg/helm/intern/release/v2" +) + +func TestKindSorter(t *testing.T) { + manifests := []Manifest{ + { + Name: "U", + Head: &SimpleHead{Kind: "IngressClass"}, + }, + { + Name: "E", + Head: &SimpleHead{Kind: "SecretList"}, + }, + { + Name: "i", + Head: &SimpleHead{Kind: "ClusterRole"}, + }, + { + Name: "I", + Head: &SimpleHead{Kind: "ClusterRoleList"}, + }, + { + Name: "j", + Head: &SimpleHead{Kind: "ClusterRoleBinding"}, + }, + { + Name: "J", + Head: &SimpleHead{Kind: "ClusterRoleBindingList"}, + }, + { + Name: "f", + Head: &SimpleHead{Kind: "ConfigMap"}, + }, + { + Name: "u", + Head: &SimpleHead{Kind: "CronJob"}, + }, + { + Name: "2", + Head: &SimpleHead{Kind: "CustomResourceDefinition"}, + }, + { + Name: "n", + Head: &SimpleHead{Kind: "DaemonSet"}, + }, + { + Name: "r", + Head: &SimpleHead{Kind: "Deployment"}, + }, + { + Name: "!", + Head: &SimpleHead{Kind: "HonkyTonkSet"}, + }, + { + Name: "v", + Head: &SimpleHead{Kind: "Ingress"}, + }, + { + Name: "t", + Head: &SimpleHead{Kind: "Job"}, + }, + { + Name: "c", + Head: &SimpleHead{Kind: "LimitRange"}, + }, + { + Name: "a", + Head: &SimpleHead{Kind: "Namespace"}, + }, + { + Name: "A", + Head: &SimpleHead{Kind: "NetworkPolicy"}, + }, + { + Name: "g", + Head: &SimpleHead{Kind: "PersistentVolume"}, + }, + { + Name: "h", + Head: &SimpleHead{Kind: "PersistentVolumeClaim"}, + }, + { + Name: "o", + Head: &SimpleHead{Kind: "Pod"}, + }, + { + Name: "3", + Head: &SimpleHead{Kind: "PodDisruptionBudget"}, + }, + { + Name: "C", + Head: &SimpleHead{Kind: "PodSecurityPolicy"}, + }, + { + Name: "q", + Head: &SimpleHead{Kind: "ReplicaSet"}, + }, + { + Name: "p", + Head: &SimpleHead{Kind: "ReplicationController"}, + }, + { + Name: "b", + Head: &SimpleHead{Kind: "ResourceQuota"}, + }, + { + Name: "k", + Head: &SimpleHead{Kind: "Role"}, + }, + { + Name: "K", + Head: &SimpleHead{Kind: "RoleList"}, + }, + { + Name: "l", + Head: &SimpleHead{Kind: "RoleBinding"}, + }, + { + Name: "L", + Head: &SimpleHead{Kind: "RoleBindingList"}, + }, + { + Name: "e", + Head: &SimpleHead{Kind: "Secret"}, + }, + { + Name: "m", + Head: &SimpleHead{Kind: "Service"}, + }, + { + Name: "d", + Head: &SimpleHead{Kind: "ServiceAccount"}, + }, + { + Name: "s", + Head: &SimpleHead{Kind: "StatefulSet"}, + }, + { + Name: "1", + Head: &SimpleHead{Kind: "StorageClass"}, + }, + { + Name: "w", + Head: &SimpleHead{Kind: "APIService"}, + }, + { + Name: "x", + Head: &SimpleHead{Kind: "HorizontalPodAutoscaler"}, + }, + { + Name: "F", + Head: &SimpleHead{Kind: "PriorityClass"}, + }, + { + Name: "M", + Head: &SimpleHead{Kind: "MutatingWebhookConfiguration"}, + }, + { + Name: "V", + Head: &SimpleHead{Kind: "ValidatingWebhookConfiguration"}, + }, + } + + for _, test := range []struct { + description string + order KindSortOrder + expected string + }{ + {"install", InstallOrder, "FaAbcC3deEf1gh2iIjJkKlLmnopqrxstuUvwMV!"}, + {"uninstall", UninstallOrder, "VMwvUmutsxrqponLlKkJjIi2hg1fEed3CcbAaF!"}, + } { + var buf bytes.Buffer + t.Run(test.description, func(t *testing.T) { + if got, want := len(test.expected), len(manifests); got != want { + t.Fatalf("Expected %d names in order, got %d", want, got) + } + defer buf.Reset() + orig := manifests + for _, r := range sortManifestsByKind(manifests, test.order) { + buf.WriteString(r.Name) + } + if got := buf.String(); got != test.expected { + t.Errorf("Expected %q, got %q", test.expected, got) + } + for i, manifest := range orig { + if manifest != manifests[i] { + t.Fatal("Expected input to sortManifestsByKind to stay the same") + } + } + }) + } +} + +// TestKindSorterKeepOriginalOrder verifies manifests of same kind are kept in original order +func TestKindSorterKeepOriginalOrder(t *testing.T) { + manifests := []Manifest{ + { + Name: "a", + Head: &SimpleHead{Kind: "ClusterRole"}, + }, + { + Name: "A", + Head: &SimpleHead{Kind: "ClusterRole"}, + }, + { + Name: "0", + Head: &SimpleHead{Kind: "ConfigMap"}, + }, + { + Name: "1", + Head: &SimpleHead{Kind: "ConfigMap"}, + }, + { + Name: "z", + Head: &SimpleHead{Kind: "ClusterRoleBinding"}, + }, + { + Name: "!", + Head: &SimpleHead{Kind: "ClusterRoleBinding"}, + }, + { + Name: "u2", + Head: &SimpleHead{Kind: "Unknown"}, + }, + { + Name: "u1", + Head: &SimpleHead{Kind: "Unknown"}, + }, + { + Name: "t3", + Head: &SimpleHead{Kind: "Unknown2"}, + }, + } + for _, test := range []struct { + description string + order KindSortOrder + expected string + }{ + // expectation is sorted by kind (unknown is last) and within each group of same kind, the order is kept + {"cm,clusterRole,clusterRoleBinding,Unknown,Unknown2", InstallOrder, "01aAz!u2u1t3"}, + } { + var buf bytes.Buffer + t.Run(test.description, func(t *testing.T) { + defer buf.Reset() + for _, r := range sortManifestsByKind(manifests, test.order) { + buf.WriteString(r.Name) + } + if got := buf.String(); got != test.expected { + t.Errorf("Expected %q, got %q", test.expected, got) + } + }) + } +} + +func TestKindSorterNamespaceAgainstUnknown(t *testing.T) { + unknown := Manifest{ + Name: "a", + Head: &SimpleHead{Kind: "Unknown"}, + } + namespace := Manifest{ + Name: "b", + Head: &SimpleHead{Kind: "Namespace"}, + } + + manifests := []Manifest{unknown, namespace} + manifests = sortManifestsByKind(manifests, InstallOrder) + + expectedOrder := []Manifest{namespace, unknown} + for i, manifest := range manifests { + if expectedOrder[i].Name != manifest.Name { + t.Errorf("Expected %s, got %s", expectedOrder[i].Name, manifest.Name) + } + } +} + +// test hook sorting with a small subset of kinds, since it uses the same algorithm as sortManifestsByKind +func TestKindSorterForHooks(t *testing.T) { + hooks := []*release.Hook{ + { + Name: "i", + Kind: "ClusterRole", + }, + { + Name: "j", + Kind: "ClusterRoleBinding", + }, + { + Name: "c", + Kind: "LimitRange", + }, + { + Name: "a", + Kind: "Namespace", + }, + } + + for _, test := range []struct { + description string + order KindSortOrder + expected string + }{ + {"install", InstallOrder, "acij"}, + {"uninstall", UninstallOrder, "jica"}, + } { + var buf bytes.Buffer + t.Run(test.description, func(t *testing.T) { + if got, want := len(test.expected), len(hooks); got != want { + t.Fatalf("Expected %d names in order, got %d", want, got) + } + defer buf.Reset() + orig := hooks + for _, r := range sortHooksByKind(hooks, test.order) { + buf.WriteString(r.Name) + } + for i, hook := range orig { + if hook != hooks[i] { + t.Fatal("Expected input to sortHooksByKind to stay the same") + } + } + if got := buf.String(); got != test.expected { + t.Errorf("Expected %q, got %q", test.expected, got) + } + }) + } +} diff --git a/pkg/helm/intern/release/v2/util/manifest.go b/pkg/helm/intern/release/v2/util/manifest.go new file mode 100644 index 00000000..5dbcdaea --- /dev/null +++ b/pkg/helm/intern/release/v2/util/manifest.go @@ -0,0 +1,82 @@ +/* +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 util // import "helm.sh/helm/v4/internal/release/v2/util" + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "unicode" +) + +// SimpleHead defines what the structure of the head of a manifest file +type SimpleHead struct { + Version string `json:"apiVersion"` + Kind string `json:"kind,omitempty"` + Metadata *struct { + Name string `json:"name"` + Annotations map[string]string `json:"annotations"` + } `json:"metadata,omitempty"` +} + +var sep = regexp.MustCompile("(?:^|\\s*\n)---\\s*") + +// SplitManifests takes a manifest string and returns a map containing individual manifests. +// +// **Note for Chart API v3**: This function (due to the regex above) has allowed _WRONG_ +// Go templates to be defined inside charts across the years. The generated text from Go +// templates may contain `---apiVersion: v1`, and this function magically splits this back +// to `---\napiVersion: v1`. This has caused issues recently after Helm 4 introduced +// kio.ParseAll to inject annotations when post-renderers are used. In Chart API v3, +// we should kill this regex with fire (or change it) and expose charts doing the wrong +// thing Go template-wise. Helm should say a big _NO_ to charts doing the wrong thing, +// with or without post-renderers. +func SplitManifests(bigFile string) map[string]string { + // Basically, we're quickly splitting a stream of YAML documents into an + // array of YAML docs. The file name is just a place holder, but should be + // integer-sortable so that manifests get output in the same order as the + // input (see `BySplitManifestsOrder`). + tpl := "manifest-%d" + res := map[string]string{} + // Making sure that any extra whitespace in YAML stream doesn't interfere in splitting documents correctly. + bigFileTmp := strings.TrimLeftFunc(bigFile, unicode.IsSpace) + docs := sep.Split(bigFileTmp, -1) + var count int + for _, d := range docs { + if strings.TrimSpace(d) == "" { + continue + } + + d = strings.TrimLeftFunc(d, unicode.IsSpace) + res[fmt.Sprintf(tpl, count)] = d + count = count + 1 + } + return res +} + +// BySplitManifestsOrder sorts by in-file manifest order, as provided in function `SplitManifests` +type BySplitManifestsOrder []string + +func (a BySplitManifestsOrder) Len() int { return len(a) } +func (a BySplitManifestsOrder) Less(i, j int) bool { + // Split `manifest-%d` + anum, _ := strconv.ParseInt(a[i][len("manifest-"):], 10, 0) + bnum, _ := strconv.ParseInt(a[j][len("manifest-"):], 10, 0) + return anum < bnum +} +func (a BySplitManifestsOrder) Swap(i, j int) { a[i], a[j] = a[j], a[i] } diff --git a/pkg/helm/intern/release/v2/util/manifest_sorter.go b/pkg/helm/intern/release/v2/util/manifest_sorter.go new file mode 100644 index 00000000..9d40911a --- /dev/null +++ b/pkg/helm/intern/release/v2/util/manifest_sorter.go @@ -0,0 +1,244 @@ +/* +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 util // import "helm.sh/helm/v4/internal/release/v2/util" + +import ( + "fmt" + "log/slog" + "path" + "sort" + "strconv" + "strings" + + "sigs.k8s.io/yaml" + + v2 "github.com/werf/nelm/pkg/helm/intern/release/v2" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" +) + +// Manifest represents a manifest file, which has a name and some content. +type Manifest struct { + Name string + Content string + Head *SimpleHead +} + +// manifestFile represents a file that contains a manifest. +type manifestFile struct { + entries map[string]string + path string +} + +// result is an intermediate structure used during sorting. +type result struct { + hooks []*v2.Hook + generic []Manifest +} + +// TODO: Refactor this out. It's here because naming conventions were not followed through. +// So fix the Test hook names and then remove this. +var events = map[string]v2.HookEvent{ + v2.HookPreInstall.String(): v2.HookPreInstall, + v2.HookPostInstall.String(): v2.HookPostInstall, + v2.HookPreDelete.String(): v2.HookPreDelete, + v2.HookPostDelete.String(): v2.HookPostDelete, + v2.HookPreUpgrade.String(): v2.HookPreUpgrade, + v2.HookPostUpgrade.String(): v2.HookPostUpgrade, + v2.HookPreRollback.String(): v2.HookPreRollback, + v2.HookPostRollback.String(): v2.HookPostRollback, + v2.HookTest.String(): v2.HookTest, + // Support test-success for backward compatibility with Helm 2 tests + "test-success": v2.HookTest, +} + +// SortManifests takes a map of filename/YAML contents, splits the file +// by manifest entries, and sorts the entries into hook types. +// +// The resulting hooks struct will be populated with all of the generated hooks. +// Any file that does not declare one of the hook types will be placed in the +// 'generic' bucket. +// +// Files that do not parse into the expected format are simply placed into a map and +// returned. +func SortManifests(files map[string]string, _ common.VersionSet, ordering KindSortOrder) ([]*v2.Hook, []Manifest, error) { + result := &result{} + + var sortedFilePaths []string + for filePath := range files { + sortedFilePaths = append(sortedFilePaths, filePath) + } + sort.Strings(sortedFilePaths) + + for _, filePath := range sortedFilePaths { + content := files[filePath] + + // Skip partials. We could return these as a separate map, but there doesn't + // seem to be any need for that at this time. + if strings.HasPrefix(path.Base(filePath), "_") { + continue + } + // Skip empty files and log this. + if strings.TrimSpace(content) == "" { + continue + } + + manifestFile := &manifestFile{ + entries: SplitManifests(content), + path: filePath, + } + + if err := manifestFile.sort(result); err != nil { + return result.hooks, result.generic, err + } + } + + return sortHooksByKind(result.hooks, ordering), sortManifestsByKind(result.generic, ordering), nil +} + +// sort takes a manifestFile object which may contain multiple resource definition +// entries and sorts each entry by hook types, and saves the resulting hooks and +// generic manifests (or non-hooks) to the result struct. +// +// To determine hook type, it looks for a YAML structure like this: +// +// kind: SomeKind +// apiVersion: v1 +// metadata: +// annotations: +// helm.sh/hook: pre-install +// +// To determine the policy to delete the hook, it looks for a YAML structure like this: +// +// kind: SomeKind +// apiVersion: v1 +// metadata: +// annotations: +// helm.sh/hook-delete-policy: hook-succeeded +// +// To determine the policy to output logs of the hook (for Pod and Job only), it looks for a YAML structure like this: +// +// kind: Pod +// apiVersion: v1 +// metadata: +// annotations: +// helm.sh/hook-output-log-policy: hook-succeeded,hook-failed +func (file *manifestFile) sort(result *result) error { + // Go through manifests in order found in file (function `SplitManifests` creates integer-sortable keys) + var sortedEntryKeys []string + for entryKey := range file.entries { + sortedEntryKeys = append(sortedEntryKeys, entryKey) + } + sort.Sort(BySplitManifestsOrder(sortedEntryKeys)) + + for _, entryKey := range sortedEntryKeys { + m := file.entries[entryKey] + + var entry SimpleHead + if err := yaml.Unmarshal([]byte(m), &entry); err != nil { + return fmt.Errorf("YAML parse error on %s: %w", file.path, err) + } + + if !hasAnyAnnotation(entry) { + result.generic = append(result.generic, Manifest{ + Name: file.path, + Content: m, + Head: &entry, + }) + continue + } + + hookTypes, ok := entry.Metadata.Annotations[v2.HookAnnotation] + if !ok { + result.generic = append(result.generic, Manifest{ + Name: file.path, + Content: m, + Head: &entry, + }) + continue + } + + hw := calculateHookWeight(entry) + + h := &v2.Hook{ + Name: entry.Metadata.Name, + Kind: entry.Kind, + Path: file.path, + Manifest: m, + Events: []v2.HookEvent{}, + Weight: hw, + DeletePolicies: []v2.HookDeletePolicy{}, + OutputLogPolicies: []v2.HookOutputLogPolicy{}, + } + + isUnknownHook := false + for hookType := range strings.SplitSeq(hookTypes, ",") { + hookType = strings.ToLower(strings.TrimSpace(hookType)) + e, ok := events[hookType] + if !ok { + isUnknownHook = true + break + } + h.Events = append(h.Events, e) + } + + if isUnknownHook { + slog.Info("skipping unknown hooks", "hookTypes", hookTypes) + continue + } + + result.hooks = append(result.hooks, h) + + operateAnnotationValues(entry, v2.HookDeleteAnnotation, func(value string) { + h.DeletePolicies = append(h.DeletePolicies, v2.HookDeletePolicy(value)) + }) + + operateAnnotationValues(entry, v2.HookOutputLogAnnotation, func(value string) { + h.OutputLogPolicies = append(h.OutputLogPolicies, v2.HookOutputLogPolicy(value)) + }) + } + + return nil +} + +// hasAnyAnnotation returns true if the given entry has any annotations at all. +func hasAnyAnnotation(entry SimpleHead) bool { + return entry.Metadata != nil && + entry.Metadata.Annotations != nil && + len(entry.Metadata.Annotations) != 0 +} + +// calculateHookWeight finds the weight in the hook weight annotation. +// +// If no weight is found, the assigned weight is 0 +func calculateHookWeight(entry SimpleHead) int { + hws := entry.Metadata.Annotations[v2.HookWeightAnnotation] + hw, err := strconv.Atoi(hws) + if err != nil { + hw = 0 + } + return hw +} + +// operateAnnotationValues finds the given annotation and runs the operate function with the value of that annotation +func operateAnnotationValues(entry SimpleHead, annotation string, operate func(p string)) { + if dps, ok := entry.Metadata.Annotations[annotation]; ok { + for dp := range strings.SplitSeq(dps, ",") { + dp = strings.ToLower(strings.TrimSpace(dp)) + operate(dp) + } + } +} diff --git a/pkg/helm/intern/release/v2/util/manifest_sorter_test.go b/pkg/helm/intern/release/v2/util/manifest_sorter_test.go new file mode 100644 index 00000000..a49b7366 --- /dev/null +++ b/pkg/helm/intern/release/v2/util/manifest_sorter_test.go @@ -0,0 +1,227 @@ +/* +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 util // import "helm.sh/helm/v4/internal/release/v2/util" + +import ( + "reflect" + "testing" + + "sigs.k8s.io/yaml" + + release "github.com/werf/nelm/pkg/helm/intern/release/v2" +) + +func TestSortManifests(t *testing.T) { + + data := []struct { + name []string + path string + kind []string + hooks map[string][]release.HookEvent + manifest string + }{ + { + name: []string{"first"}, + path: "one", + kind: []string{"Job"}, + hooks: map[string][]release.HookEvent{"first": {release.HookPreInstall}}, + manifest: `apiVersion: v1 +kind: Job +metadata: + name: first + labels: + doesnot: matter + annotations: + "helm.sh/hook": pre-install +`, + }, + { + name: []string{"second"}, + path: "two", + kind: []string{"ReplicaSet"}, + hooks: map[string][]release.HookEvent{"second": {release.HookPostInstall}}, + manifest: `kind: ReplicaSet +apiVersion: v1beta1 +metadata: + name: second + annotations: + "helm.sh/hook": post-install +`, + }, { + name: []string{"third"}, + path: "three", + kind: []string{"ReplicaSet"}, + hooks: map[string][]release.HookEvent{"third": nil}, + manifest: `kind: ReplicaSet +apiVersion: v1beta1 +metadata: + name: third + annotations: + "helm.sh/hook": no-such-hook +`, + }, { + name: []string{"fourth"}, + path: "four", + kind: []string{"Pod"}, + hooks: map[string][]release.HookEvent{"fourth": nil}, + manifest: `kind: Pod +apiVersion: v1 +metadata: + name: fourth + annotations: + nothing: here`, + }, { + name: []string{"fifth"}, + path: "five", + kind: []string{"ReplicaSet"}, + hooks: map[string][]release.HookEvent{"fifth": {release.HookPostDelete, release.HookPostInstall}}, + manifest: `kind: ReplicaSet +apiVersion: v1beta1 +metadata: + name: fifth + annotations: + "helm.sh/hook": post-delete, post-install +`, + }, { + // Regression test: files with an underscore in the base name should be skipped. + name: []string{"sixth"}, + path: "six/_six", + kind: []string{"ReplicaSet"}, + hooks: map[string][]release.HookEvent{"sixth": nil}, + manifest: `invalid manifest`, // This will fail if partial is not skipped. + }, { + // Regression test: files with no content should be skipped. + name: []string{"seventh"}, + path: "seven", + kind: []string{"ReplicaSet"}, + hooks: map[string][]release.HookEvent{"seventh": nil}, + manifest: "", + }, + { + name: []string{"eighth", "example-test"}, + path: "eight", + kind: []string{"ConfigMap", "Pod"}, + hooks: map[string][]release.HookEvent{"eighth": nil, "example-test": {release.HookTest}}, + manifest: `kind: ConfigMap +apiVersion: v1 +metadata: + name: eighth +data: + name: value +--- +apiVersion: v1 +kind: Pod +metadata: + name: example-test + annotations: + "helm.sh/hook": test +`, + }, + } + + manifests := make(map[string]string, len(data)) + for _, o := range data { + manifests[o.path] = o.manifest + } + + hs, generic, err := SortManifests(manifests, nil, InstallOrder) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + // This test will fail if 'six' or 'seven' was added. + if len(generic) != 2 { + t.Errorf("Expected 2 generic manifests, got %d", len(generic)) + } + + if len(hs) != 4 { + t.Errorf("Expected 4 hooks, got %d", len(hs)) + } + + for _, out := range hs { + found := false + for _, expect := range data { + if out.Path == expect.path { + found = true + if out.Path != expect.path { + t.Errorf("Expected path %s, got %s", expect.path, out.Path) + } + nameFound := false + for _, expectedName := range expect.name { + if out.Name == expectedName { + nameFound = true + } + } + if !nameFound { + t.Errorf("Got unexpected name %s", out.Name) + } + kindFound := false + for _, expectedKind := range expect.kind { + if out.Kind == expectedKind { + kindFound = true + } + } + if !kindFound { + t.Errorf("Got unexpected kind %s", out.Kind) + } + + expectedHooks := expect.hooks[out.Name] + if !reflect.DeepEqual(expectedHooks, out.Events) { + t.Errorf("expected events: %v but got: %v", expectedHooks, out.Events) + } + + } + } + if !found { + t.Errorf("Result not found: %v", out) + } + } + + // Verify the sort order + sorted := []Manifest{} + for _, s := range data { + manifests := SplitManifests(s.manifest) + + for _, m := range manifests { + var sh SimpleHead + if err := yaml.Unmarshal([]byte(m), &sh); err != nil { + // This is expected for manifests that are corrupt or empty. + t.Log(err) + continue + } + + name := sh.Metadata.Name + + // only keep track of non-hook manifests + if s.hooks[name] == nil { + another := Manifest{ + Content: m, + Name: name, + Head: &sh, + } + sorted = append(sorted, another) + } + } + } + + sorted = sortManifestsByKind(sorted, InstallOrder) + for i, m := range generic { + if m.Content != sorted[i].Content { + t.Errorf("Expected %q, got %q", m.Content, sorted[i].Content) + } + } +} diff --git a/pkg/helm/intern/release/v2/util/manifest_test.go b/pkg/helm/intern/release/v2/util/manifest_test.go new file mode 100644 index 00000000..72b09539 --- /dev/null +++ b/pkg/helm/intern/release/v2/util/manifest_test.go @@ -0,0 +1,517 @@ +/* +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 util // import "helm.sh/helm/v4/internal/release/v2/util" + +import ( + "reflect" + "testing" +) + +func TestSplitManifests(t *testing.T) { + tests := []struct { + name string + input string + expected map[string]string + }{ + { + name: "single doc with leading separator and whitespace", + input: ` + +--- +apiVersion: v1 +kind: Pod +metadata: + name: finding-nemo, + annotations: + "helm.sh/hook": test +spec: + containers: + - name: nemo-test + image: fake-image + cmd: fake-command +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: Pod +metadata: + name: finding-nemo, + annotations: + "helm.sh/hook": test +spec: + containers: + - name: nemo-test + image: fake-image + cmd: fake-command +`, + }, + }, + { + name: "empty input", + input: "", + expected: map[string]string{}, + }, + { + name: "whitespace only", + input: " \n\n \n", + expected: map[string]string{}, + }, + { + name: "whitespace-only doc after separator is skipped", + input: "---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cm1\n---\n \n", + expected: map[string]string{ + "manifest-0": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cm1", + }, + }, + { + name: "single doc no separator", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +`, + }, + }, + { + name: "two docs with proper separator", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1`, + "manifest-1": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +`, + }, + }, + + // Block scalar chomping indicator tests using | (clip), |- (strip), and |+ (keep) + // inputs with 0, 1, and 2 trailing newlines after the block content. + // Note: the emitter may normalize the output chomping indicator when the + // trailing newline count makes another indicator equivalent for the result. + + // | (clip) input — clips trailing newlines to exactly one, though with + // 0 trailing newlines the emitted output may normalize to |-. + { + name: "block scalar clip (|) with 0 trailing newlines", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello`, + }, + }, + { + name: "block scalar clip (|) with 1 trailing newline", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello +`, + }, + }, + { + name: "block scalar clip (|) with 2 trailing newlines", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello + +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello + +`, + }, + }, + + // |- (strip) + { + name: "block scalar strip (|-) with 0 trailing newlines", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello`, + }, + }, + { + name: "block scalar strip (|-) with 1 trailing newline", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello +`, + }, + }, + { + name: "block scalar strip (|-) with 2 trailing newlines", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello + +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello + +`, + }, + }, + + // |+ (keep) + { + name: "block scalar keep (|+) with 0 trailing newlines", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello`, + }, + }, + { + name: "block scalar keep (|+) with 1 trailing newline", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello +`, + }, + }, + { + name: "block scalar keep (|+) with 2 trailing newlines", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello + +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello + +`, + }, + }, + + // Multi-doc with block scalars: the regex consumes \s*\n before ---, + // so trailing newlines from non-last docs are stripped. + { + name: "multi-doc block scalar clip (|) before separator", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello`, + "manifest-1": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +`, + }, + }, + { + name: "multi-doc block scalar keep (|+) with 2 trailing newlines before separator", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello + + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello`, + "manifest-1": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +`, + }, + }, + + // **Note for Chart API v3**: The following tests exercise the lenient + // regex that splits `---apiVersion` back into separate documents. + // In Chart API v3, these inputs should return an _ERROR_ instead. + // See the comment on the SplitManifests function for more details. + { + name: "leading glued separator (---apiVersion)", + input: ` +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +`, + }, + }, + { + name: "mid-content glued separator (---apiVersion)", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1`, + "manifest-1": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +`, + }, + }, + { + name: "multiple glued separators", + input: ` +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm3 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1`, + "manifest-1": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2`, + "manifest-2": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm3 +`, + }, + }, + { + name: "mixed glued and proper separators", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm3 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1`, + "manifest-1": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2`, + "manifest-2": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm3 +`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SplitManifests(tt.input) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("SplitManifests() =\n%v\nwant:\n%v", result, tt.expected) + } + }) + } +} diff --git a/pkg/helm/intern/release/v2/util/sorter.go b/pkg/helm/intern/release/v2/util/sorter.go new file mode 100644 index 00000000..e676cba2 --- /dev/null +++ b/pkg/helm/intern/release/v2/util/sorter.go @@ -0,0 +1,61 @@ +/* +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 util // import "helm.sh/helm/v4/internal/release/v2/util" + +import ( + "sort" + + rspb "github.com/werf/nelm/pkg/helm/intern/release/v2" +) + +// Reverse reverses the list of releases sorted by the sort func. +func Reverse(list []*rspb.Release, sortFn func([]*rspb.Release)) { + sortFn(list) + for i, j := 0, len(list)-1; i < j; i, j = i+1, j-1 { + list[i], list[j] = list[j], list[i] + } +} + +// SortByName returns the list of releases sorted +// in lexicographical order. +func SortByName(list []*rspb.Release) { + sort.Slice(list, func(i, j int) bool { + return list[i].Name < list[j].Name + }) +} + +// SortByDate returns the list of releases sorted by a +// release's last deployed time (in seconds). +func SortByDate(list []*rspb.Release) { + sort.Slice(list, func(i, j int) bool { + ti := list[i].Info.LastDeployed.Unix() + tj := list[j].Info.LastDeployed.Unix() + if ti != tj { + return ti < tj + } + // Use name as tie-breaker for stable sorting + return list[i].Name < list[j].Name + }) +} + +// SortByRevision returns the list of releases sorted by a +// release's revision number (release.Version). +func SortByRevision(list []*rspb.Release) { + sort.Slice(list, func(i, j int) bool { + return list[i].Version < list[j].Version + }) +} diff --git a/pkg/helm/intern/release/v2/util/sorter_test.go b/pkg/helm/intern/release/v2/util/sorter_test.go new file mode 100644 index 00000000..d12b8d5d --- /dev/null +++ b/pkg/helm/intern/release/v2/util/sorter_test.go @@ -0,0 +1,109 @@ +/* +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 util // import "helm.sh/helm/v4/internal/release/v2/util" + +import ( + "testing" + "time" + + rspb "github.com/werf/nelm/pkg/helm/intern/release/v2" + "github.com/werf/nelm/pkg/helm/pkg/release/common" +) + +// note: this test data is shared with filter_test.go. + +var releases = []*rspb.Release{ + tsRelease("quiet-bear", 2, 2000, common.StatusSuperseded), + tsRelease("angry-bird", 4, 3000, common.StatusDeployed), + tsRelease("happy-cats", 1, 4000, common.StatusUninstalled), + tsRelease("vocal-dogs", 3, 6000, common.StatusUninstalled), +} + +func tsRelease(name string, vers int, dur time.Duration, status common.Status) *rspb.Release { + info := &rspb.Info{Status: status, LastDeployed: time.Now().Add(dur)} + return &rspb.Release{ + Name: name, + Version: vers, + Info: info, + } +} + +func check(t *testing.T, by string, fn func(int, int) bool) { + t.Helper() + for i := len(releases) - 1; i > 0; i-- { + if fn(i, i-1) { + t.Errorf("release at positions '(%d,%d)' not sorted by %s", i-1, i, by) + } + } +} + +func TestSortByName(t *testing.T) { + SortByName(releases) + + check(t, "ByName", func(i, j int) bool { + ni := releases[i].Name + nj := releases[j].Name + return ni < nj + }) +} + +func TestSortByDate(t *testing.T) { + SortByDate(releases) + + check(t, "ByDate", func(i, j int) bool { + ti := releases[i].Info.LastDeployed.Second() + tj := releases[j].Info.LastDeployed.Second() + return ti < tj + }) +} + +func TestSortByRevision(t *testing.T) { + SortByRevision(releases) + + check(t, "ByRevision", func(i, j int) bool { + vi := releases[i].Version + vj := releases[j].Version + return vi < vj + }) +} + +func TestReverseSortByName(t *testing.T) { + Reverse(releases, SortByName) + check(t, "ByName", func(i, j int) bool { + ni := releases[i].Name + nj := releases[j].Name + return ni > nj + }) +} + +func TestReverseSortByDate(t *testing.T) { + Reverse(releases, SortByDate) + check(t, "ByDate", func(i, j int) bool { + ti := releases[i].Info.LastDeployed.Second() + tj := releases[j].Info.LastDeployed.Second() + return ti > tj + }) +} + +func TestReverseSortByRevision(t *testing.T) { + Reverse(releases, SortByRevision) + check(t, "ByRevision", func(i, j int) bool { + vi := releases[i].Version + vj := releases[j].Version + return vi > vj + }) +} diff --git a/pkg/helm/intern/resolver/resolver.go b/pkg/helm/intern/resolver/resolver.go index 5d29806f..d6b79bd9 100644 --- a/pkg/helm/intern/resolver/resolver.go +++ b/pkg/helm/intern/resolver/resolver.go @@ -150,7 +150,7 @@ func (r *Resolver) Resolve(ctx context.Context, reqs []*chart.Dependency, repoNa } else { // Retrieve list of tags for repository - ref := fmt.Sprintf("%s/%s", strings.TrimPrefix(d.Repository, fmt.Sprintf("%s://", registry.OCIScheme)), d.Name) + ref := fmt.Sprintf("%s/%s", strings.TrimPrefix(d.Repository, registry.OCIScheme+"://"), d.Name) tags, err := r.registryClient.Tags(ref) if err != nil { return nil, fmt.Errorf("could not retrieve list of tags for repository %s: %w", d.Repository, err) diff --git a/pkg/helm/intern/test/test.go b/pkg/helm/intern/test/test.go index 632bc72f..202e015a 100644 --- a/pkg/helm/intern/test/test.go +++ b/pkg/helm/intern/test/test.go @@ -29,8 +29,8 @@ var updateGolden = flag.Bool("update", false, "update golden files") // TestingT describes a testing object compatible with the critical functions from the testing.T type type TestingT interface { - Fatal(...interface{}) - Fatalf(string, ...interface{}) + Fatal(...any) + Fatalf(string, ...any) HelperT } diff --git a/pkg/helm/intern/third_party/dep/fs/fs.go b/pkg/helm/intern/third_party/dep/fs/fs.go index 6e2720f3..3140cf91 100644 --- a/pkg/helm/intern/third_party/dep/fs/fs.go +++ b/pkg/helm/intern/third_party/dep/fs/fs.go @@ -164,7 +164,8 @@ func CopyFile(src, dst string) (err error) { // // ERROR_PRIVILEGE_NOT_HELD is 1314 (0x522): // https://msdn.microsoft.com/en-us/library/windows/desktop/ms681385(v=vs.85).aspx - if lerr, ok := err.(*os.LinkError); ok && lerr.Err != syscall.Errno(1314) { + lerr := &os.LinkError{} + if errors.As(err, &lerr) && !errors.Is(lerr.Err, syscall.Errno(1314)) { return err } } else { diff --git a/pkg/helm/intern/third_party/dep/fs/fs_test.go b/pkg/helm/intern/third_party/dep/fs/fs_test.go index 610771bc..8f28c3af 100644 --- a/pkg/helm/intern/third_party/dep/fs/fs_test.go +++ b/pkg/helm/intern/third_party/dep/fs/fs_test.go @@ -32,6 +32,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. package fs import ( + "errors" "os" "path/filepath" "runtime" @@ -234,7 +235,7 @@ func TestCopyDirFail_SrcIsNotDir(t *testing.T) { t.Fatalf("expected error for CopyDir(%s, %s), got none", srcdir, dstdir) } - if err != errSrcNotDir { + if !errors.Is(err, errSrcNotDir) { t.Fatalf("expected %v error for CopyDir(%s, %s), got %s", errSrcNotDir, srcdir, dstdir, err) } @@ -260,7 +261,7 @@ func TestCopyDirFail_DstExists(t *testing.T) { t.Fatalf("expected error for CopyDir(%s, %s), got none", srcdir, dstdir) } - if err != errDstExist { + if !errors.Is(err, errDstExist) { t.Fatalf("expected %v error for CopyDir(%s, %s), got %s", errDstExist, srcdir, dstdir, err) } } diff --git a/pkg/helm/intern/third_party/dep/fs/rename.go b/pkg/helm/intern/third_party/dep/fs/rename.go index 5f13b1ca..77c93b7a 100644 --- a/pkg/helm/intern/third_party/dep/fs/rename.go +++ b/pkg/helm/intern/third_party/dep/fs/rename.go @@ -34,6 +34,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. package fs import ( + "errors" "fmt" "os" "syscall" @@ -46,10 +47,11 @@ func renameFallback(err error, src, dst string) error { // copy if we detect that case. syscall.EXDEV is the common name for the // cross device link error which has varying output text across different // operating systems. - terr, ok := err.(*os.LinkError) + terr := &os.LinkError{} + ok := errors.As(err, &terr) if !ok { return err - } else if terr.Err != syscall.EXDEV { + } else if !errors.Is(terr.Err, syscall.EXDEV) { return fmt.Errorf("link error: cannot rename %s to %s: %w", src, dst, terr) } diff --git a/pkg/helm/intern/tlsutil/tls.go b/pkg/helm/intern/tlsutil/tls.go index 88f26d47..e986e690 100644 --- a/pkg/helm/intern/tlsutil/tls.go +++ b/pkg/helm/intern/tlsutil/tls.go @@ -112,7 +112,7 @@ func NewTLSConfig(options ...TLSConfigOption) (*tls.Config, error) { if len(to.caPEMBlock) > 0 { cp := x509.NewCertPool() if !cp.AppendCertsFromPEM(to.caPEMBlock) { - return nil, fmt.Errorf("failed to append certificates from pem block") + return nil, errors.New("failed to append certificates from pem block") } config.RootCAs = cp diff --git a/pkg/helm/intern/tlsutil/tls_test.go b/pkg/helm/intern/tlsutil/tls_test.go index f16eb218..e6859f99 100644 --- a/pkg/helm/intern/tlsutil/tls_test.go +++ b/pkg/helm/intern/tlsutil/tls_test.go @@ -58,10 +58,10 @@ func TestNewTLSConfig(t *testing.T) { t.Fatalf("expecting 1 client certificates, got %d", got) } if cfg.InsecureSkipVerify { - t.Fatalf("insecure skip verify mismatch, expecting false") + t.Fatal("insecure skip verify mismatch, expecting false") } if cfg.RootCAs == nil { - t.Fatalf("mismatch tls RootCAs, expecting non-nil") + t.Fatal("mismatch tls RootCAs, expecting non-nil") } } { @@ -77,10 +77,10 @@ func TestNewTLSConfig(t *testing.T) { t.Fatalf("expecting 0 client certificates, got %d", got) } if cfg.InsecureSkipVerify { - t.Fatalf("insecure skip verify mismatch, expecting false") + t.Fatal("insecure skip verify mismatch, expecting false") } if cfg.RootCAs == nil { - t.Fatalf("mismatch tls RootCAs, expecting non-nil") + t.Fatal("mismatch tls RootCAs, expecting non-nil") } } @@ -97,10 +97,10 @@ func TestNewTLSConfig(t *testing.T) { t.Fatalf("expecting 1 client certificates, got %d", got) } if cfg.InsecureSkipVerify { - t.Fatalf("insecure skip verify mismatch, expecting false") + t.Fatal("insecure skip verify mismatch, expecting false") } if cfg.RootCAs != nil { - t.Fatalf("mismatch tls RootCAs, expecting nil") + t.Fatal("mismatch tls RootCAs, expecting nil") } } } diff --git a/pkg/helm/intern/version/clientgo.go b/pkg/helm/intern/version/clientgo.go index ab2a38fd..50a4fd5c 100644 --- a/pkg/helm/intern/version/clientgo.go +++ b/pkg/helm/intern/version/clientgo.go @@ -17,7 +17,7 @@ limitations under the License. package version import ( - "fmt" + "errors" "runtime/debug" "slices" @@ -27,7 +27,7 @@ import ( func K8sIOClientGoModVersion() (string, error) { info, ok := debug.ReadBuildInfo() if !ok { - return "", fmt.Errorf("failed to read build info") + return "", errors.New("failed to read build info") } idx := slices.IndexFunc(info.Deps, func(m *debug.Module) bool { @@ -35,7 +35,7 @@ func K8sIOClientGoModVersion() (string, error) { }) if idx == -1 { - return "", fmt.Errorf("k8s.io/client-go not found in build info") + return "", errors.New("k8s.io/client-go not found in build info") } m := info.Deps[idx] diff --git a/pkg/helm/intern/version/version.go b/pkg/helm/intern/version/version.go index 3daf8089..007f79f1 100644 --- a/pkg/helm/intern/version/version.go +++ b/pkg/helm/intern/version/version.go @@ -34,7 +34,7 @@ var ( // // Increment major number for new feature additions and behavioral changes. // Increment minor number for bug fixes and performance enhancements. - version = "v4.1" + version = "v4.2" // metadata is extra build time data metadata = "" diff --git a/pkg/helm/pkg/action/action.go b/pkg/helm/pkg/action/action.go index ad835b8b..39f2489e 100644 --- a/pkg/helm/pkg/action/action.go +++ b/pkg/helm/pkg/action/action.go @@ -28,6 +28,7 @@ import ( "path" "path/filepath" "slices" + "sort" "strings" "sync" "text/template" @@ -88,6 +89,33 @@ const ( DryRunServer DryRunStrategy = "server" ) +// PostRenderStrategy determines how hooks and regular templates are passed +// to the configured post-renderer. +type PostRenderStrategy string + +const ( + // PostRenderStrategyCombined sends hooks and regular templates together + // as a single stream to the post-renderer. This is the default in Helm 4. + PostRenderStrategyCombined PostRenderStrategy = "combined" + + // PostRenderStrategySeparate sends hooks and regular templates to the + // post-renderer in independent invocations. This avoids duplicate-resource + // errors from post-renderers that de-duplicate by resource identity + // (for example Kustomize) when the same resource appears in both a hook + // and a regular template. Passing hooks to post-renderers was introduced + // in Helm 4; Helm 3 never did so, which is why the issue only surfaces + // with the Helm 4 combined default. + PostRenderStrategySeparate PostRenderStrategy = "separate" + + // PostRenderStrategyNoHooks sends only regular templates to the + // post-renderer and leaves hooks untouched. This matches the Helm 3 + // behavior and is useful for post-renderers that declare transforms + // targeting template-only resources (for example Kustomize patches + // against a Deployment that exists in templates but not in hooks), + // which would otherwise fail against the hook stream. + PostRenderStrategyNoHooks PostRenderStrategy = "nohooks" +) + // Configuration injects the dependencies that all actions share. type Configuration struct { // RESTClientGetter is an interface that loads Kubernetes clients. @@ -145,39 +173,6 @@ const ( filenameAnnotation = "postrenderer.helm.sh/postrender-filename" ) -// fixDocSeparators ensures YAML document separators ("---") are always -// followed by a newline in rendered template content. Go template whitespace -// trimming ({{-) can remove the newline after "---", producing e.g. -// "---apiVersion: v1" which is not a valid YAML document separator. -// This function inserts a newline after any "---" at the start of a line -// that is immediately followed by non-whitespace content. -func fixDocSeparators(content string) string { - var b strings.Builder - remaining := content - for { - // Find "---" at the start of a line (or start of content). - idx := strings.Index(remaining, "---") - if idx == -1 { - b.WriteString(remaining) - break - } - // "---" must be at the start of a line: either idx==0 or preceded by '\n'. - if idx > 0 && remaining[idx-1] != '\n' { - b.WriteString(remaining[:idx+3]) - remaining = remaining[idx+3:] - continue - } - b.WriteString(remaining[:idx+3]) - remaining = remaining[idx+3:] - // If "---" is followed by non-whitespace (e.g. "---apiVersion"), - // insert a newline to make it a proper document separator. - if len(remaining) > 0 && remaining[0] != '\n' && remaining[0] != '\r' && remaining[0] != ' ' && remaining[0] != '\t' { - b.WriteByte('\n') - } - } - return b.String() -} - // annotateAndMerge combines multiple YAML files into a single stream of documents, // adding filename annotations to each document for later reconstruction. func annotateAndMerge(files map[string]string) (string, error) { @@ -193,22 +188,32 @@ func annotateAndMerge(files map[string]string) (string, error) { continue } - // Fix document separators where Go template whitespace trimming - // ({{-) has removed the newline after "---", producing e.g. - // "---apiVersion: v1" which is not a valid YAML document - // separator. Insert the missing newline so kio.ParseAll can - // parse the content correctly. - content = fixDocSeparators(content) - - manifests, err := kio.ParseAll(content) - if err != nil { - return "", fmt.Errorf("parsing %s: %w", fname, err) + // For consistency with the non-post-renderers code path, we need + // to use releaseutil.SplitManifests here to split the file into + // individual documents before feeding them to kio.ParseAll. In + // Chart API before v3 this function had leniency for badly-written + // Go templates, so this must be preserved for older charts. + splitDocs := releaseutil.SplitManifests(content) + keys := make([]string, 0, len(splitDocs)) + for k := range splitDocs { + keys = append(keys, k) } - for _, manifest := range manifests { - if err := manifest.PipeE(kyaml.SetAnnotation(filenameAnnotation, fname)); err != nil { - return "", fmt.Errorf("annotating %s: %w", fname, err) + sort.Sort(releaseutil.BySplitManifestsOrder(keys)) + for _, key := range keys { + doc := splitDocs[key] + if strings.TrimSpace(doc) == "" { + continue + } + manifests, err := kio.ParseAll(doc) + if err != nil { + return "", fmt.Errorf("parsing %s: %w", fname, err) + } + for _, manifest := range manifests { + if err := manifest.PipeE(kyaml.SetAnnotation(filenameAnnotation, fname)); err != nil { + return "", fmt.Errorf("annotating %s: %w", fname, err) + } + combinedManifests = append(combinedManifests, manifest) } - combinedManifests = append(combinedManifests, manifest) } } @@ -221,7 +226,14 @@ func annotateAndMerge(files map[string]string) (string, error) { // splitAndDeannotate reconstructs individual files from a merged YAML stream, // removing filename annotations and grouping documents by their original filenames. -func splitAndDeannotate(postrendered string) (map[string]string, error) { +// Documents without a filename annotation are assigned a synthesized name of the +// form "generated-by-postrender--.yaml" (or +// "generated-by-postrender-.yaml" when fallbackPrefix is empty). The prefix +// disambiguates fallback filenames across multiple post-render invocations (for +// example when PostRenderStrategySeparate runs the post-renderer once per +// group), so that merging results from different invocations does not collide +// on the same synthetic key. +func splitAndDeannotate(postrendered, fallbackPrefix string) (map[string]string, error) { manifests, err := kio.ParseAll(postrendered) if err != nil { return nil, fmt.Errorf("error parsing YAML: %w", err) @@ -235,7 +247,11 @@ func splitAndDeannotate(postrendered string) (map[string]string, error) { } fname := meta.Annotations[filenameAnnotation] if fname == "" { - fname = fmt.Sprintf("generated-by-postrender-%d.yaml", i) + if fallbackPrefix == "" { + fname = fmt.Sprintf("generated-by-postrender-%d.yaml", i) + } else { + fname = fmt.Sprintf("generated-by-postrender-%s-%d.yaml", fallbackPrefix, i) + } } if err := manifest.PipeE(kyaml.ClearAnnotation(filenameAnnotation)); err != nil { return nil, fmt.Errorf("clearing filename annotation: %w", err) @@ -260,7 +276,7 @@ func splitAndDeannotate(postrendered string) (map[string]string, error) { // TODO: As part of the refactor the duplicate code in cmd/helm/template.go should be removed // // This code has to do with writing files to disk. -func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrenderer.PostRenderer, interactWithRemote, enableDNS, hideSecret bool) ([]*release.Hook, *bytes.Buffer, string, error) { +func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrenderer.PostRenderer, interactWithRemote, enableDNS, hideSecret bool, postRenderStrategy PostRenderStrategy) ([]*release.Hook, *bytes.Buffer, string, error) { var hs []*release.Hook b := bytes.NewBuffer(nil) @@ -324,29 +340,122 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values, notes := notesBuffer.String() if pr != nil { - // We need to send files to the post-renderer before sorting and splitting - // hooks from manifests. The post-renderer interface expects a stream of - // manifests (similar to what tools like Kustomize and kubectl expect), whereas - // the sorter uses filenames. - // Here, we merge the documents into a stream, post-render them, and then split - // them back into a map of filename -> content. - - // Merge files as stream of documents for sending to post renderer - merged, err := annotateAndMerge(files) - if err != nil { - return hs, b, notes, fmt.Errorf("error merging manifests: %w", err) - } + switch postRenderStrategy { + case PostRenderStrategySeparate, PostRenderStrategyNoHooks: + // Split hooks from manifests before post-rendering. For "separate", + // hooks and templates are sent to the post-renderer as independent + // streams to avoid duplicate-resource errors when the same resource + // appears in both (e.g. a ServiceAccount used by a pre-install hook + // that is also declared in the chart's regular templates). For + // "nohooks", hooks skip the post-renderer entirely, matching the + // Helm 3 behavior. + sortedHooks, sortedManifests, err := releaseutil.SortManifests(files, nil, releaseutil.InstallOrder) + if err != nil { + for name, content := range files { + if strings.TrimSpace(content) == "" { + continue + } + fmt.Fprintf(b, "---\n# Source: %s\n%s\n", name, content) + } + return hs, b, "", err + } - // Run the post renderer - postRendered, err := pr.Run(bytes.NewBufferString(merged)) - if err != nil { - return hs, b, notes, fmt.Errorf("error while running post render on files: %w", err) - } + // Build separate files maps for hooks and manifests. + hookFiles := make(map[string]string) + for _, h := range sortedHooks { + if existing, ok := hookFiles[h.Path]; ok { + hookFiles[h.Path] = existing + "\n---\n" + h.Manifest + } else { + hookFiles[h.Path] = h.Manifest + } + } + manifestFiles := make(map[string]string) + for _, m := range sortedManifests { + if existing, ok := manifestFiles[m.Name]; ok { + manifestFiles[m.Name] = existing + "\n---\n" + m.Content + } else { + manifestFiles[m.Name] = m.Content + } + } - // Use the file list and contents received from the post renderer - files, err = splitAndDeannotate(postRendered.String()) - if err != nil { - return hs, b, notes, fmt.Errorf("error while parsing post rendered output: %w", err) + // Decide which groups to post-render. "nohooks" passes hooks + // through untouched and only post-renders manifests. + groups := []struct { + name string + files map[string]string + postRender bool + }{ + {"hooks", hookFiles, postRenderStrategy == PostRenderStrategySeparate}, + {"manifests", manifestFiles, true}, + } + + files = make(map[string]string) + for _, group := range groups { + if len(group.files) == 0 { + continue + } + + if !group.postRender { + for k, v := range group.files { + if existing, ok := files[k]; ok { + files[k] = existing + "\n---\n" + v + } else { + files[k] = v + } + } + continue + } + + merged, err := annotateAndMerge(group.files) + if err != nil { + return hs, b, notes, fmt.Errorf("error merging %s: %w", group.name, err) + } + + postRendered, err := pr.Run(bytes.NewBufferString(merged)) + if err != nil { + return hs, b, notes, fmt.Errorf("error while running post render on %s: %w", group.name, err) + } + + rendered, err := splitAndDeannotate(postRendered.String(), group.name) + if err != nil { + return hs, b, notes, fmt.Errorf("error while parsing post rendered output for %s: %w", group.name, err) + } + + for k, v := range rendered { + if existing, ok := files[k]; ok { + files[k] = existing + "\n---\n" + v + } else { + files[k] = v + } + } + } + case PostRenderStrategyCombined, "": + // We need to send files to the post-renderer before sorting and splitting + // hooks from manifests. The post-renderer interface expects a stream of + // manifests (similar to what tools like Kustomize and kubectl expect), whereas + // the sorter uses filenames. + // Here, we merge the documents into a stream, post-render them, and then split + // them back into a map of filename -> content. + + // Merge files as stream of documents for sending to post renderer + merged, err := annotateAndMerge(files) + if err != nil { + return hs, b, notes, fmt.Errorf("error merging manifests: %w", err) + } + + // Run the post renderer + postRendered, err := pr.Run(bytes.NewBufferString(merged)) + if err != nil { + return hs, b, notes, fmt.Errorf("error while running post render on files: %w", err) + } + + // Use the file list and contents received from the post renderer + files, err = splitAndDeannotate(postRendered.String(), "") + if err != nil { + return hs, b, notes, fmt.Errorf("error while parsing post rendered output: %w", err) + } + default: + return hs, b, notes, fmt.Errorf("unknown post-render strategy: '%s'", postRenderStrategy) } } @@ -507,7 +616,7 @@ func GetVersionSet(client discovery.ServerResourcesInterface) (common.VersionSet return common.DefaultVersionSet, nil } - versionMap := make(map[string]interface{}) + versionMap := make(map[string]any) var versions []string // Extract the groups diff --git a/pkg/helm/pkg/action/action_test.go b/pkg/helm/pkg/action/action_test.go index 499fac10..077fdc99 100644 --- a/pkg/helm/pkg/action/action_test.go +++ b/pkg/helm/pkg/action/action_test.go @@ -157,12 +157,12 @@ func withName(name string) chartOption { } func withSampleValues() chartOption { - values := map[string]interface{}{ + values := map[string]any{ "someKey": "someValue", - "nestedKey": map[string]interface{}{ + "nestedKey": map[string]any{ "simpleKey": "simpleValue", - "anotherNestedKey": map[string]interface{}{ - "yetAnotherNestedKey": map[string]interface{}{ + "anotherNestedKey": map[string]any{ + "yetAnotherNestedKey": map[string]any{ "youReadyForAnotherNestedKey": "No", }, }, @@ -173,7 +173,7 @@ func withSampleValues() chartOption { } } -func withValues(values map[string]interface{}) chartOption { +func withValues(values map[string]any) chartOption { return func(opts *chartOptions) { opts.Values = values } @@ -274,7 +274,7 @@ func namedReleaseStub(name string, status rcommon.Status) *release.Release { Description: "Named Release Stub", }, Chart: buildChart(withSampleTemplates()), - Config: map[string]interface{}{"name": "value"}, + Config: map[string]any{"name": "value"}, Version: 1, Hooks: []*release.Hook{ { @@ -304,7 +304,7 @@ func TestConfiguration_Init(t *testing.T) { tests := []struct { name string helmDriver string - expectedDriverType interface{} + expectedDriverType any expectErr bool errMsg string }{ @@ -377,7 +377,7 @@ func TestGetVersionSet(t *testing.T) { } if !vs.Has("v1") { - t.Errorf("Expected supported versions to at least include v1.") + t.Error("Expected supported versions to at least include v1.") } if vs.Has("nosuchversion/v1") { t.Error("Non-existent version is reported found.") @@ -403,96 +403,6 @@ func (m *mockPostRenderer) Run(renderedManifests *bytes.Buffer) (*bytes.Buffer, return bytes.NewBufferString(content), nil } -func TestFixDocSeparators(t *testing.T) { - tests := []struct { - name string - input string - expected string - }{ - { - name: "no separator", - input: "apiVersion: v1\nkind: Service\n", - expected: "apiVersion: v1\nkind: Service\n", - }, - { - name: "separator on its own line", - input: "---\napiVersion: v1\nkind: Service\n", - expected: "---\napiVersion: v1\nkind: Service\n", - }, - { - name: "leading separator glued to content", - input: "---apiVersion: v1\nkind: Service\n", - expected: "---\napiVersion: v1\nkind: Service\n", - }, - { - name: "mid-content separator glued to content", - input: "apiVersion: v1\nkind: ConfigMap\n---apiVersion: v1\nkind: Service\n", - expected: "apiVersion: v1\nkind: ConfigMap\n---\napiVersion: v1\nkind: Service\n", - }, - { - name: "multiple separators all proper", - input: "---\napiVersion: v1\n---\napiVersion: v1\n", - expected: "---\napiVersion: v1\n---\napiVersion: v1\n", - }, - { - name: "multiple separators some glued", - input: "---apiVersion: v1\nkind: ConfigMap\n---apiVersion: v1\nkind: Service\n", - expected: "---\napiVersion: v1\nkind: ConfigMap\n---\napiVersion: v1\nkind: Service\n", - }, - { - name: "empty string", - input: "", - expected: "", - }, - { - name: "only separator", - input: "---\n", - expected: "---\n", - }, - { - name: "triple dash in a value is not a separator", - input: "data:\n key: ---value\n", - expected: "data:\n key: ---value\n", - }, - { - name: "realistic multi-doc template output", - input: "apiVersion: v1\nkind: Deployment\n---\napiVersion: v1\nkind: Ingress\n---apiVersion: v1\nkind: Service\n", - expected: "apiVersion: v1\nkind: Deployment\n---\napiVersion: v1\nkind: Ingress\n---\napiVersion: v1\nkind: Service\n", - }, - { - name: "separator followed by carriage return", - input: "---\r\napiVersion: v1\n", - expected: "---\r\napiVersion: v1\n", - }, - { - name: "separator followed by space", - input: "--- \napiVersion: v1\n", - expected: "--- \napiVersion: v1\n", - }, - { - name: "separator followed by tab", - input: "---\t\napiVersion: v1\n", - expected: "---\t\napiVersion: v1\n", - }, - { - name: "four dashes on its own line", - input: "----\napiVersion: v1\n", - expected: "---\n-\napiVersion: v1\n", - }, - { - name: "four dashes followed by text", - input: "----more\napiVersion: v1\n", - expected: "---\n-more\napiVersion: v1\n", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, fixDocSeparators(tt.input)) - }) - } -} - func TestAnnotateAndMerge(t *testing.T) { tests := []struct { name string @@ -508,7 +418,8 @@ func TestAnnotateAndMerge(t *testing.T) { { name: "single file with single manifest", files: map[string]string{ - "templates/configmap.yaml": `apiVersion: v1 + "templates/configmap.yaml": ` +apiVersion: v1 kind: ConfigMap metadata: name: test-cm @@ -528,13 +439,15 @@ data: { name: "multiple files with multiple manifests", files: map[string]string{ - "templates/configmap.yaml": `apiVersion: v1 + "templates/configmap.yaml": ` +apiVersion: v1 kind: ConfigMap metadata: name: test-cm data: key: value`, - "templates/secret.yaml": `apiVersion: v1 + "templates/secret.yaml": ` +apiVersion: v1 kind: Secret metadata: name: test-secret @@ -563,7 +476,8 @@ data: { name: "file with multiple manifests", files: map[string]string{ - "templates/multi.yaml": `apiVersion: v1 + "templates/multi.yaml": ` +apiVersion: v1 kind: ConfigMap metadata: name: test-cm1 @@ -599,7 +513,8 @@ data: { name: "partials and empty files are removed", files: map[string]string{ - "templates/cm.yaml": `apiVersion: v1 + "templates/cm.yaml": ` +apiVersion: v1 kind: ConfigMap metadata: name: test-cm1 @@ -621,14 +536,16 @@ metadata: { name: "empty file", files: map[string]string{ - "templates/empty.yaml": "", + "templates/empty.yaml": ` +`, }, expected: ``, }, { name: "invalid yaml", files: map[string]string{ - "templates/invalid.yaml": `invalid: yaml: content: + "templates/invalid.yaml": ` +invalid: yaml: content: - malformed`, }, expectedError: "parsing templates/invalid.yaml", @@ -636,60 +553,1019 @@ metadata: { name: "leading doc separator glued to content by template whitespace trimming", files: map[string]string{ - "templates/service.yaml": "---apiVersion: v1\nkind: Service\nmetadata:\n name: test-svc\n", + "templates/service.yaml": ` +---apiVersion: v1 +kind: Service +metadata: + name: test-svc +`, + }, + expected: `apiVersion: v1 +kind: Service +metadata: + name: test-svc + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/service.yaml' +`, + }, + { + name: "leading doc separator on its own line", + files: map[string]string{ + "templates/service.yaml": ` +--- +apiVersion: v1 +kind: Service +metadata: + name: test-svc +`, + }, + expected: `apiVersion: v1 +kind: Service +metadata: + name: test-svc + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/service.yaml' +`, + }, + { + name: "multiple leading doc separators", + files: map[string]string{ + "templates/service.yaml": ` +--- +--- +apiVersion: v1 +kind: Service +metadata: + name: test-svc +`, + }, + expected: `apiVersion: v1 +kind: Service +metadata: + name: test-svc + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/service.yaml' +`, + }, + { + name: "mid-content doc separator glued to content by template whitespace trimming", + files: map[string]string{ + "templates/all.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm +---apiVersion: v1 +kind: Service +metadata: + name: test-svc +`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/all.yaml' +--- +apiVersion: v1 +kind: Service +metadata: + name: test-svc + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/all.yaml' +`, + }, + { + name: "ConfigMap with embedded CA certificate", + files: map[string]string{ + "templates/configmap.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: ca-bundle +data: + ca.crt: | + ------BEGIN CERTIFICATE------ + MIICEzCCAXygAwIBAgIQMIMChMLGrR+QvmQvpwAU6zAKBggqhkjOPQQDAzASMRAw + DgYDVQQKEwdBY21lIENvMCAXDTcwMDEwMTAwMDAwMFoYDzIwODQwMTI5MTYwMDAw + WjASMRAwDgYDVQQKEwdBY21lIENvMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE7Rmm + ------END CERTIFICATE------ + ------BEGIN CERTIFICATE------ + MIICEzCCAXygAwIBAgIQMIMChMLGrR+QvmQvpwAU6zAKBggqhkjOPQQDAzASMRAw + DgYDVQQKEwdBY21lIENvMCAXDTcwMDEwMTAwMDAwMFoYDzIwODQwMTI5MTYwMDAw + WjASMRAwDgYDVQQKEwdBY21lIENvMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE7Rmm + ------END CERTIFICATE------ +`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: ca-bundle + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/configmap.yaml' +data: + ca.crt: | + ------BEGIN CERTIFICATE------ + MIICEzCCAXygAwIBAgIQMIMChMLGrR+QvmQvpwAU6zAKBggqhkjOPQQDAzASMRAw + DgYDVQQKEwdBY21lIENvMCAXDTcwMDEwMTAwMDAwMFoYDzIwODQwMTI5MTYwMDAw + WjASMRAwDgYDVQQKEwdBY21lIENvMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE7Rmm + ------END CERTIFICATE------ + ------BEGIN CERTIFICATE------ + MIICEzCCAXygAwIBAgIQMIMChMLGrR+QvmQvpwAU6zAKBggqhkjOPQQDAzASMRAw + DgYDVQQKEwdBY21lIENvMCAXDTcwMDEwMTAwMDAwMFoYDzIwODQwMTI5MTYwMDAw + WjASMRAwDgYDVQQKEwdBY21lIENvMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE7Rmm + ------END CERTIFICATE------ +`, + }, + { + name: "consecutive dashes in YAML value are not treated as document separators", + files: map[string]string{ + "templates/configmap.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm +data: + config: | + # --------------------------------------------------------------------------- + [section] + key = value + # --------------------------------------------------------------------------- +`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/configmap.yaml' +data: + config: | + # --------------------------------------------------------------------------- + [section] + key = value + # --------------------------------------------------------------------------- +`, + }, + { + name: "JSON with dashes in values is not corrupted", + files: map[string]string{ + "templates/dashboard.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: dashboard +data: + dashboard.json: | + {"options":{"---------":{"color":"#292929","text":"N/A"}}} +`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: dashboard + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/dashboard.yaml' +data: + dashboard.json: | + {"options":{"---------":{"color":"#292929","text":"N/A"}}} +`, + }, + + // **Note for Chart API v3**: This input should return an _ERROR_ in Chart API v3. + // See the comment on the releaseutil.SplitManifests function for more details. + { + name: "multiple glued separators in same file", + files: map[string]string{ + "templates/multi.yaml": ` +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm3 +`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/multi.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/multi.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm3 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/multi.yaml' +`, + }, + + // **Note for Chart API v3**: This input should return an _ERROR_ in Chart API v3. + // See the comment on the releaseutil.SplitManifests function for more details. + { + name: "mixed glued and proper separators", + files: map[string]string{ + "templates/mixed.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm3 +`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/mixed.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/mixed.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm3 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/mixed.yaml' +`, + }, + { + name: "12 documents preserve in-file order", + files: map[string]string{ + "templates/many.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-01 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-02 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-03 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-04 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-05 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-06 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-07 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-08 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-09 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-10 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-11 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-12 +`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-01 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/many.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-02 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/many.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-03 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/many.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-04 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/many.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-05 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/many.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-06 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/many.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-07 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/many.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-08 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/many.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-09 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/many.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-10 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/many.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-11 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/many.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-12 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/many.yaml' +`, + }, + + // Block scalar chomping indicator tests using | (clip), |- (strip), and |+ (keep) + // inputs with 0, 1, and 2 trailing newlines after the block content. + // Note: the emitter may normalize the output chomping indicator when the + // trailing newline count makes another indicator equivalent for the result. + + // | (clip) input — clips trailing newlines to exactly one, though with + // 0 trailing newlines the emitted output may normalize to |-. + { + name: "block scalar clip (|) with 0 trailing newlines", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |- + hello +`, + }, + { + name: "block scalar clip (|) with 1 trailing newline", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello +`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: | + hello +`, + }, + { + name: "block scalar clip (|) with 2 trailing newlines", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello + +`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: | + hello +`, + }, + + // |- (strip) — strips all trailing newlines + { + name: "block scalar strip (|-) with 0 trailing newlines", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |- + hello +`, + }, + { + name: "block scalar strip (|-) with 1 trailing newline", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello +`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |- + hello +`, + }, + { + name: "block scalar strip (|-) with 2 trailing newlines", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello + +`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |- + hello +`, + }, + + // |+ (keep) — preserves all trailing newlines + { + name: "block scalar keep (|+) with 0 trailing newlines", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |- + hello +`, + }, + { + name: "block scalar keep (|+) with 1 trailing newline", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello +`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: | + hello +`, + }, + { + name: "block scalar keep (|+) with 2 trailing newlines", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello + +`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |+ + hello + +`, + }, + + // Multi-doc tests: block scalar doc is NOT the last document. + // SplitManifests' regex consumes \s*\n before ---, so trailing + // newlines from non-last docs are always stripped. + + // | (clip) in multi-doc (first doc) + { + name: "multi-doc block scalar clip (|) with 0 trailing newlines", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +data: + val: simple`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |- + hello +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + val: simple +`, + }, + { + name: "multi-doc block scalar clip (|) with 1 trailing newline", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +data: + val: simple`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |- + hello +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + val: simple +`, + }, + { + name: "multi-doc block scalar clip (|) with 2 trailing newlines", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello + + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +data: + val: simple`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |- + hello +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + val: simple +`, + }, + + // |- (strip) in multi-doc (first doc) + { + name: "multi-doc block scalar strip (|-) with 0 trailing newlines", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +data: + val: simple`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |- + hello +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + val: simple +`, + }, + { + name: "multi-doc block scalar strip (|-) with 1 trailing newline", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +data: + val: simple`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |- + hello +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + val: simple +`, + }, + { + name: "multi-doc block scalar strip (|-) with 2 trailing newlines", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello + + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +data: + val: simple`, }, expected: `apiVersion: v1 -kind: Service +kind: ConfigMap metadata: - name: test-svc + name: test annotations: - postrenderer.helm.sh/postrender-filename: 'templates/service.yaml' + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |- + hello +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + val: simple `, }, + + // |+ (keep) in multi-doc (first doc) { - name: "leading doc separator on its own line", + name: "multi-doc block scalar keep (|+) with 0 trailing newlines", files: map[string]string{ - "templates/service.yaml": "---\napiVersion: v1\nkind: Service\nmetadata:\n name: test-svc\n", + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +data: + val: simple`, }, expected: `apiVersion: v1 -kind: Service +kind: ConfigMap metadata: - name: test-svc + name: test annotations: - postrenderer.helm.sh/postrender-filename: 'templates/service.yaml' + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |- + hello +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + val: simple `, }, { - name: "multiple leading doc separators", + name: "multi-doc block scalar keep (|+) with 1 trailing newline", files: map[string]string{ - "templates/service.yaml": "---\n---\napiVersion: v1\nkind: Service\nmetadata:\n name: test-svc\n", + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +data: + val: simple`, }, expected: `apiVersion: v1 -kind: Service +kind: ConfigMap metadata: - name: test-svc + name: test annotations: - postrenderer.helm.sh/postrender-filename: 'templates/service.yaml' + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |- + hello +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + val: simple `, }, { - name: "mid-content doc separator glued to content by template whitespace trimming", + name: "multi-doc block scalar keep (|+) with 2 trailing newlines", files: map[string]string{ - "templates/all.yaml": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: test-cm\n---apiVersion: v1\nkind: Service\nmetadata:\n name: test-svc\n", + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello + + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +data: + val: simple`, }, expected: `apiVersion: v1 kind: ConfigMap metadata: - name: test-cm + name: test annotations: - postrenderer.helm.sh/postrender-filename: 'templates/all.yaml' + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |- + hello --- apiVersion: v1 -kind: Service +kind: ConfigMap metadata: - name: test-svc + name: test2 annotations: - postrenderer.helm.sh/postrender-filename: 'templates/all.yaml' + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + val: simple `, }, } @@ -846,7 +1722,7 @@ metadata: data: key: value`, expectedFiles: map[string]string{ - "generated-by-postrender-0.yaml": `apiVersion: v1 + "generated-by-postrender-test-0.yaml": `apiVersion: v1 kind: ConfigMap metadata: name: test-cm @@ -859,7 +1735,7 @@ data: for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - files, err := splitAndDeannotate(tt.input) + files, err := splitAndDeannotate(tt.input, "test") if tt.expectedError != "" { assert.Error(t, err) @@ -913,7 +1789,7 @@ data: require.NoError(t, err) // Split and deannotate - reconstructed, err := splitAndDeannotate(merged) + reconstructed, err := splitAndDeannotate(merged, "test") require.NoError(t, err) // Compare the results @@ -944,11 +1820,11 @@ func TestRenderResources_PostRenderer_Success(t *testing.T) { } ch := buildChart(withSampleTemplates()) - values := map[string]interface{}{} + values := map[string]any{} hooks, buf, notes, err := cfg.renderResources( ch, values, "test-release", "", false, false, false, - mockPR, false, false, false, + mockPR, false, false, false, PostRenderStrategyCombined, ) assert.NoError(t, err) @@ -958,12 +1834,15 @@ func TestRenderResources_PostRenderer_Success(t *testing.T) { expectedBuf := `--- # Source: yellow/templates/foodpie foodpie: world + --- # Source: yellow/templates/with-partials yellow: Earth + --- # Source: yellow/templates/yellow yellow: world + ` expectedHook := `kind: ConfigMap metadata: @@ -971,7 +1850,8 @@ metadata: annotations: "helm.sh/hook": post-install,pre-delete,post-upgrade data: - name: value` + name: value +` assert.Equal(t, expectedBuf, buf.String()) assert.Len(t, hooks, 1) @@ -987,11 +1867,11 @@ func TestRenderResources_PostRenderer_Error(t *testing.T) { } ch := buildChart(withSampleTemplates()) - values := map[string]interface{}{} + values := map[string]any{} _, _, _, err := cfg.renderResources( ch, values, "test-release", "", false, false, false, - mockPR, false, false, false, + mockPR, false, false, false, PostRenderStrategyCombined, ) assert.Error(t, err) @@ -1015,11 +1895,11 @@ func TestRenderResources_PostRenderer_MergeError(t *testing.T) { {Name: "templates/invalid", ModTime: time.Now(), Data: []byte("invalid: yaml: content:")}, }, } - values := map[string]interface{}{} + values := map[string]any{} _, _, _, err := cfg.renderResources( ch, values, "test-release", "", false, false, false, - mockPR, false, false, false, + mockPR, false, false, false, PostRenderStrategyCombined, ) assert.Error(t, err) @@ -1037,11 +1917,11 @@ func TestRenderResources_PostRenderer_SplitError(t *testing.T) { } ch := buildChart(withSampleTemplates()) - values := map[string]interface{}{} + values := map[string]any{} _, _, _, err := cfg.renderResources( ch, values, "test-release", "", false, false, false, - mockPR, false, false, false, + mockPR, false, false, false, PostRenderStrategyCombined, ) assert.Error(t, err) @@ -1058,11 +1938,11 @@ func TestRenderResources_PostRenderer_Integration(t *testing.T) { } ch := buildChart(withSampleTemplates()) - values := map[string]interface{}{} + values := map[string]any{} hooks, buf, notes, err := cfg.renderResources( ch, values, "test-release", "", false, false, false, - mockPR, false, false, false, + mockPR, false, false, false, PostRenderStrategyCombined, ) assert.NoError(t, err) @@ -1076,14 +1956,17 @@ func TestRenderResources_PostRenderer_Integration(t *testing.T) { # Source: hello/templates/goodbye goodbye: world color: blue + --- # Source: hello/templates/hello hello: world color: blue + --- # Source: hello/templates/with-partials hello: Earth color: blue + ` assert.Contains(t, output, "color: blue") assert.Equal(t, 3, strings.Count(output, "color: blue")) @@ -1094,11 +1977,11 @@ func TestRenderResources_NoPostRenderer(t *testing.T) { cfg := actionConfigFixture(t) ch := buildChart(withSampleTemplates()) - values := map[string]interface{}{} + values := map[string]any{} hooks, buf, notes, err := cfg.renderResources( ch, values, "test-release", "", false, false, false, - nil, false, false, false, + nil, false, false, false, PostRenderStrategyCombined, ) assert.NoError(t, err) @@ -1107,6 +1990,305 @@ func TestRenderResources_NoPostRenderer(t *testing.T) { assert.Equal(t, "", notes) } +func TestRenderResources_PostRenderer_DuplicateResourceInHookAndTemplate(t *testing.T) { + cfg := actionConfigFixture(t) + + // Simulate a chart where the same ServiceAccount appears both as a + // pre-install hook and as a regular template. This is a valid Helm pattern + // but previously caused post-renderers like Kustomize to fail with + // "may not add resource with an already registered id" because hooks and + // templates were merged into a single stream before post-rendering. + saHook := `apiVersion: v1 +kind: ServiceAccount +metadata: + name: my-app + annotations: + "helm.sh/hook": pre-install + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded` + + saTemplate := `apiVersion: v1 +kind: ServiceAccount +metadata: + name: my-app` + + deployment := `apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-app +spec: + template: + spec: + serviceAccountName: my-app` + + modTime := time.Now() + ch := buildChartWithTemplates([]*common.File{ + {Name: "templates/sa-hook.yaml", ModTime: modTime, Data: []byte(saHook)}, + {Name: "templates/sa.yaml", ModTime: modTime, Data: []byte(saTemplate)}, + {Name: "templates/deployment.yaml", ModTime: modTime, Data: []byte(deployment)}, + }) + + // Use a post-renderer that rejects duplicate resource IDs, similar to + // how Kustomize behaves. We verify that no single post-render call + // receives the ServiceAccount twice. + mockPR := &mockPostRenderer{ + transform: func(content string) string { + count := strings.Count(content, "kind: ServiceAccount") + if count > 1 { + t.Errorf("post-renderer received %d ServiceAccount resources in a single stream, expected at most 1", count) + } + return content + }, + } + + hooks, buf, _, err := cfg.renderResources( + ch, nil, "test-release", "", false, false, false, + mockPR, false, false, false, PostRenderStrategySeparate, + ) + + assert.NoError(t, err) + assert.Len(t, hooks, 1) + assert.Equal(t, "my-app", hooks[0].Name) + assert.Contains(t, buf.String(), "kind: Deployment") + assert.Contains(t, buf.String(), "kind: ServiceAccount") +} + +func TestRenderResources_PostRenderer_CombinedInvokesOnceWithEverything(t *testing.T) { + cfg := actionConfigFixture(t) + + hookManifest := `apiVersion: v1 +kind: ConfigMap +metadata: + name: hook-cm + annotations: + "helm.sh/hook": pre-install` + templateManifest := `apiVersion: v1 +kind: ConfigMap +metadata: + name: template-cm` + + modTime := time.Now() + ch := buildChartWithTemplates([]*common.File{ + {Name: "templates/hook.yaml", ModTime: modTime, Data: []byte(hookManifest)}, + {Name: "templates/cm.yaml", ModTime: modTime, Data: []byte(templateManifest)}, + }) + + var calls int + var lastInput string + mockPR := &mockPostRenderer{ + transform: func(content string) string { + calls++ + lastInput = content + return content + }, + } + + _, _, _, err := cfg.renderResources( + ch, nil, "test-release", "", false, false, false, + mockPR, false, false, false, PostRenderStrategyCombined, + ) + + assert.NoError(t, err) + assert.Equal(t, 1, calls, "combined strategy should invoke the post-renderer exactly once") + assert.Contains(t, lastInput, "hook-cm") + assert.Contains(t, lastInput, "template-cm") +} + +func TestRenderResources_PostRenderer_ZeroValueStrategyActsAsCombined(t *testing.T) { + cfg := actionConfigFixture(t) + + modTime := time.Now() + ch := buildChartWithTemplates([]*common.File{ + {Name: "templates/cm.yaml", ModTime: modTime, Data: []byte(`apiVersion: v1 +kind: ConfigMap +metadata: + name: template-cm`)}, + {Name: "templates/hook.yaml", ModTime: modTime, Data: []byte(`apiVersion: v1 +kind: ConfigMap +metadata: + name: hook-cm + annotations: + "helm.sh/hook": pre-install`)}, + }) + + var calls int + mockPR := &mockPostRenderer{ + transform: func(content string) string { + calls++ + return content + }, + } + + _, _, _, err := cfg.renderResources( + ch, nil, "test-release", "", false, false, false, + mockPR, false, false, false, PostRenderStrategy(""), + ) + + assert.NoError(t, err) + assert.Equal(t, 1, calls, "unset strategy must preserve backwards-compatible combined behavior") +} + +func TestRenderResources_PostRenderer_SeparateSplitsHooksAndTemplates(t *testing.T) { + cfg := actionConfigFixture(t) + + modTime := time.Now() + ch := buildChartWithTemplates([]*common.File{ + {Name: "templates/hook.yaml", ModTime: modTime, Data: []byte(`apiVersion: v1 +kind: ConfigMap +metadata: + name: hook-cm + annotations: + "helm.sh/hook": pre-install`)}, + {Name: "templates/cm.yaml", ModTime: modTime, Data: []byte(`apiVersion: v1 +kind: ConfigMap +metadata: + name: template-cm`)}, + }) + + var inputs []string + mockPR := &mockPostRenderer{ + transform: func(content string) string { + inputs = append(inputs, content) + return content + }, + } + + _, _, _, err := cfg.renderResources( + ch, nil, "test-release", "", false, false, false, + mockPR, false, false, false, PostRenderStrategySeparate, + ) + + assert.NoError(t, err) + assert.Len(t, inputs, 2, "separate strategy should invoke the post-renderer twice when both hooks and templates exist") + for _, in := range inputs { + hasHook := strings.Contains(in, "hook-cm") + hasTemplate := strings.Contains(in, "template-cm") + assert.False(t, hasHook && hasTemplate, "a single post-render invocation must not contain both hook and template resources") + assert.True(t, hasHook || hasTemplate, "each post-render invocation must contain either a hook or a template") + } +} + +func TestRenderResources_PostRenderer_SeparateWithOnlyTemplates(t *testing.T) { + cfg := actionConfigFixture(t) + + modTime := time.Now() + ch := buildChartWithTemplates([]*common.File{ + {Name: "templates/cm.yaml", ModTime: modTime, Data: []byte(`apiVersion: v1 +kind: ConfigMap +metadata: + name: template-cm`)}, + }) + + var calls int + mockPR := &mockPostRenderer{ + transform: func(content string) string { + calls++ + return content + }, + } + + _, _, _, err := cfg.renderResources( + ch, nil, "test-release", "", false, false, false, + mockPR, false, false, false, PostRenderStrategySeparate, + ) + + assert.NoError(t, err) + assert.Equal(t, 1, calls, "separate strategy should skip the empty hook group and invoke the post-renderer only once") +} + +func TestRenderResources_PostRenderer_NoHooksSkipsHooks(t *testing.T) { + cfg := actionConfigFixture(t) + + modTime := time.Now() + ch := buildChartWithTemplates([]*common.File{ + {Name: "templates/hook.yaml", ModTime: modTime, Data: []byte(`apiVersion: v1 +kind: ConfigMap +metadata: + name: hook-cm + annotations: + "helm.sh/hook": pre-install`)}, + {Name: "templates/cm.yaml", ModTime: modTime, Data: []byte(`apiVersion: v1 +kind: ConfigMap +metadata: + name: template-cm`)}, + }) + + var inputs []string + mockPR := &mockPostRenderer{ + transform: func(content string) string { + inputs = append(inputs, content) + return content + }, + } + + hooks, manifestDoc, _, err := cfg.renderResources( + ch, nil, "test-release", "", false, false, false, + mockPR, false, false, false, PostRenderStrategyNoHooks, + ) + + assert.NoError(t, err) + assert.Len(t, inputs, 1, "nohooks strategy should invoke the post-renderer exactly once (for templates only)") + assert.NotContains(t, inputs[0], "hook-cm", "hooks must not be sent to the post-renderer") + assert.Contains(t, inputs[0], "template-cm", "templates must be sent to the post-renderer") + + // Hooks still round-trip through the release so they can execute. + require.Len(t, hooks, 1) + assert.Contains(t, hooks[0].Manifest, "hook-cm") + assert.Contains(t, manifestDoc.String(), "template-cm") +} + +func TestRenderResources_PostRenderer_NoHooksWithOnlyHooks(t *testing.T) { + cfg := actionConfigFixture(t) + + modTime := time.Now() + ch := buildChartWithTemplates([]*common.File{ + {Name: "templates/hook.yaml", ModTime: modTime, Data: []byte(`apiVersion: v1 +kind: ConfigMap +metadata: + name: hook-cm + annotations: + "helm.sh/hook": pre-install`)}, + }) + + var calls int + mockPR := &mockPostRenderer{ + transform: func(content string) string { + calls++ + return content + }, + } + + _, _, _, err := cfg.renderResources( + ch, nil, "test-release", "", false, false, false, + mockPR, false, false, false, PostRenderStrategyNoHooks, + ) + + assert.NoError(t, err) + assert.Equal(t, 0, calls, "nohooks strategy should not invoke the post-renderer when the chart only has hooks") +} + +func TestRenderResources_PostRenderer_UnknownStrategyErrors(t *testing.T) { + cfg := actionConfigFixture(t) + + modTime := time.Now() + ch := buildChartWithTemplates([]*common.File{ + {Name: "templates/cm.yaml", ModTime: modTime, Data: []byte(`apiVersion: v1 +kind: ConfigMap +metadata: + name: template-cm`)}, + }) + + mockPR := &mockPostRenderer{} + + _, _, _, err := cfg.renderResources( + ch, nil, "test-release", "", false, false, false, + mockPR, false, false, false, PostRenderStrategy("bogus"), + ) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown post-render strategy") + assert.Contains(t, err.Error(), "bogus") +} + func TestDetermineReleaseSSAApplyMethod(t *testing.T) { assert.Equal(t, release.ApplyMethodClientSideApply, determineReleaseSSApplyMethod(false)) assert.Equal(t, release.ApplyMethodServerSideApply, determineReleaseSSApplyMethod(true)) diff --git a/pkg/helm/pkg/action/dependency.go b/pkg/helm/pkg/action/dependency.go index a2c786f8..5057db3c 100644 --- a/pkg/helm/pkg/action/dependency.go +++ b/pkg/helm/pkg/action/dependency.go @@ -93,7 +93,7 @@ func (d *Dependency) dependencyStatus(chartpath string, dep *chart.Dependency, p for _, arc := range archives { // we need to trip the prefix dirs and the extension off. filename = strings.TrimSuffix(filepath.Base(arc), ".tgz") - maybeVersion := strings.TrimPrefix(filename, fmt.Sprintf("%s-", dep.Name)) + maybeVersion := strings.TrimPrefix(filename, dep.Name+"-") if _, err := semver.StrictNewVersion(maybeVersion); err == nil { // If the version parsed without an error, it is possibly a valid diff --git a/pkg/helm/pkg/action/package.go b/pkg/helm/pkg/action/package.go index b6d7e1a0..365f8faa 100644 --- a/pkg/helm/pkg/action/package.go +++ b/pkg/helm/pkg/action/package.go @@ -76,7 +76,7 @@ func NewPackage() *Package { } // Run executes 'helm package' against the given chart and returns the path to the packaged chart. -func (p *Package) Run(path string, _ map[string]interface{}) (string, error) { +func (p *Package) Run(path string, _ map[string]any) (string, error) { chrt, err := loader.LoadDir(context.Background(), path) if err != nil { return "", err diff --git a/pkg/helm/pkg/action/package_test.go b/pkg/helm/pkg/action/package_test.go index 9b6d7705..19add0e8 100644 --- a/pkg/helm/pkg/action/package_test.go +++ b/pkg/helm/pkg/action/package_test.go @@ -17,6 +17,7 @@ limitations under the License. package action import ( + "errors" "os" "path" "testing" @@ -146,7 +147,7 @@ func TestValidateVersion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := validateVersion(tt.args.ver); err != nil { - if err != tt.wantErr { + if !errors.Is(err, tt.wantErr) { t.Errorf("Expected {%v}, got {%v}", tt.wantErr, err) } diff --git a/pkg/helm/pkg/action/push_test.go b/pkg/helm/pkg/action/push_test.go index 35c6f3ef..12579925 100644 --- a/pkg/helm/pkg/action/push_test.go +++ b/pkg/helm/pkg/action/push_test.go @@ -47,14 +47,14 @@ func TestNewPushWithInsecureSkipTLSVerify(t *testing.T) { client := NewPushWithOpts(WithInsecureSkipTLSVerify(true)) assert.NotNil(t, client) - assert.Equal(t, true, client.insecureSkipTLSVerify) + assert.True(t, client.insecureSkipTLSVerify) } func TestNewPushWithPlainHTTP(t *testing.T) { client := NewPushWithOpts(WithPlainHTTP(true)) assert.NotNil(t, client) - assert.Equal(t, true, client.plainHTTP) + assert.True(t, client.plainHTTP) } func TestNewPushWithPushOptWriter(t *testing.T) { diff --git a/pkg/helm/pkg/action/registry_login_test.go b/pkg/helm/pkg/action/registry_login_test.go index de2450d9..590673b3 100644 --- a/pkg/helm/pkg/action/registry_login_test.go +++ b/pkg/helm/pkg/action/registry_login_test.go @@ -48,7 +48,7 @@ func TestWithInsecure(t *testing.T) { opt := WithInsecure(true) assert.Nil(t, opt(client)) - assert.Equal(t, true, client.insecure) + assert.True(t, client.insecure) } func TestWithKeyFile(t *testing.T) { @@ -80,5 +80,5 @@ func TestWithPlainHTTPLogin(t *testing.T) { opt := WithPlainHTTPLogin(true) assert.Nil(t, opt(client)) - assert.Equal(t, true, client.plainHTTP) + assert.True(t, client.plainHTTP) } diff --git a/pkg/helm/pkg/action/show_test.go b/pkg/helm/pkg/action/show_test.go index eadf3ce5..2f253a32 100644 --- a/pkg/helm/pkg/action/show_test.go +++ b/pkg/helm/pkg/action/show_test.go @@ -43,7 +43,7 @@ func TestShow(t *testing.T) { Raw: []*common.File{ {Name: "values.yaml", ModTime: modTime, Data: []byte("VALUES\n")}, }, - Values: map[string]interface{}{}, + Values: map[string]any{}, } output, err := client.Run("") diff --git a/pkg/helm/pkg/action/testdata/rbac.txt b/pkg/helm/pkg/action/testdata/rbac.txt index 0cb15b86..91938d5c 100644 --- a/pkg/helm/pkg/action/testdata/rbac.txt +++ b/pkg/helm/pkg/action/testdata/rbac.txt @@ -23,3 +23,4 @@ subjects: - kind: ServiceAccount name: schedule-agents namespace: spaced + diff --git a/pkg/helm/pkg/chart/common.go b/pkg/helm/pkg/chart/common.go index 455a9066..6f490654 100644 --- a/pkg/helm/pkg/chart/common.go +++ b/pkg/helm/pkg/chart/common.go @@ -17,7 +17,6 @@ package chart import ( "errors" - "fmt" "log/slog" "reflect" "strings" @@ -56,8 +55,8 @@ func (r *v2Accessor) IsRoot() bool { return r.chrt.IsRoot() } -func (r *v2Accessor) MetadataAsMap() map[string]interface{} { - var ret map[string]interface{} +func (r *v2Accessor) MetadataAsMap() map[string]any { + var ret map[string]any if r.chrt.Metadata == nil { return ret } @@ -101,7 +100,7 @@ func (r *v2Accessor) MetaDependencies() []Dependency { return deps } -func (r *v2Accessor) Values() map[string]interface{} { +func (r *v2Accessor) Values() map[string]any { return r.chrt.Values } @@ -125,8 +124,8 @@ func (r *v3Accessor) IsRoot() bool { return r.chrt.IsRoot() } -func (r *v3Accessor) MetadataAsMap() map[string]interface{} { - var ret map[string]interface{} +func (r *v3Accessor) MetadataAsMap() map[string]any { + var ret map[string]any if r.chrt.Metadata == nil { return ret } @@ -170,7 +169,7 @@ func (r *v3Accessor) MetaDependencies() []Dependency { return deps } -func (r *v3Accessor) Values() map[string]interface{} { +func (r *v3Accessor) Values() map[string]any { return r.chrt.Values } @@ -182,7 +181,7 @@ func (r *v3Accessor) Deprecated() bool { return r.chrt.Metadata.Deprecated } -func structToMap(obj interface{}) (map[string]interface{}, error) { +func structToMap(obj any) (map[string]any, error) { objValue := reflect.ValueOf(obj) // If the value is a pointer, dereference it @@ -192,10 +191,10 @@ func structToMap(obj interface{}) (map[string]interface{}, error) { // Check if the input is a struct if objValue.Kind() != reflect.Struct { - return nil, fmt.Errorf("input must be a struct or a pointer to a struct") + return nil, errors.New("input must be a struct or a pointer to a struct") } - result := make(map[string]interface{}) + result := make(map[string]any) objType := objValue.Type() for i := 0; i < objValue.NumField(); i++ { @@ -221,7 +220,7 @@ func structToMap(obj interface{}) (map[string]interface{}, error) { result[field.Name] = nestedMap } case reflect.Slice: - sliceOfMaps := make([]interface{}, value.Len()) + sliceOfMaps := make([]any, value.Len()) for j := 0; j < value.Len(); j++ { sliceElement := value.Index(j) if sliceElement.Kind() == reflect.Struct || sliceElement.Kind() == reflect.Pointer { diff --git a/pkg/helm/pkg/chart/common/capabilities.go b/pkg/helm/pkg/chart/common/capabilities.go index 375da741..990e6c02 100644 --- a/pkg/helm/pkg/chart/common/capabilities.go +++ b/pkg/helm/pkg/chart/common/capabilities.go @@ -157,7 +157,7 @@ func makeDefaultCapabilities() (*Capabilities, error) { v, err := semver.NewVersion(vstr) if err != nil { - return nil, fmt.Errorf("unable to parse k8s.io/client-go version %q: %v", vstr, err) + return nil, fmt.Errorf("unable to parse k8s.io/client-go version %q: %w", vstr, err) } kubeVersionMajor := v.Major() + 1 @@ -173,8 +173,8 @@ func newCapabilities(kubeVersionMajor, kubeVersionMinor uint64) (*Capabilities, KubeVersion: KubeVersion{ Version: version, normalizedVersion: version, - Major: fmt.Sprintf("%d", kubeVersionMajor), - Minor: fmt.Sprintf("%d", kubeVersionMinor), + Major: strconv.FormatUint(kubeVersionMajor, 10), + Minor: strconv.FormatUint(kubeVersionMinor, 10), }, APIVersions: DefaultVersionSet, HelmVersion: helmversion.Get(), diff --git a/pkg/helm/pkg/chart/common/capabilities_test.go b/pkg/helm/pkg/chart/common/capabilities_test.go index b96d7d29..c8d47581 100644 --- a/pkg/helm/pkg/chart/common/capabilities_test.go +++ b/pkg/helm/pkg/chart/common/capabilities_test.go @@ -60,15 +60,15 @@ func TestDefaultCapabilities(t *testing.T) { } hv := caps.HelmVersion - if hv.Version != "v4.1" { - t.Errorf("Expected default HelmVersion to be v4.1, got %q", hv.Version) + if hv.Version != "v4.2" { + t.Errorf("Expected default HelmVersion to be v4.2, got %q", hv.Version) } } func TestParseKubeVersion(t *testing.T) { kv, err := ParseKubeVersion("v1.16.0") if err != nil { - t.Errorf("Expected v1.16.0 to parse successfully") + t.Error("Expected v1.16.0 to parse successfully") } if kv.Version != "v1.16.0" { t.Errorf("Expected parsed KubeVersion.Version to be v1.16.0, got %q", kv.String()) diff --git a/pkg/helm/pkg/chart/common/util/coalesce.go b/pkg/helm/pkg/chart/common/util/coalesce.go index f754adfe..f88b443c 100644 --- a/pkg/helm/pkg/chart/common/util/coalesce.go +++ b/pkg/helm/pkg/chart/common/util/coalesce.go @@ -46,7 +46,7 @@ func concatPrefix(a, b string) string { // - Scalar values and arrays are replaced, maps are merged // - A chart has access to all of the variables for it, as well as all of // the values destined for its dependencies. -func CoalesceValues(chrt chart.Charter, vals map[string]interface{}) (common.Values, error) { +func CoalesceValues(chrt chart.Charter, vals map[string]any) (common.Values, error) { vals, err := makeValues(chrt, vals) if err != nil { return vals, err @@ -73,7 +73,7 @@ func CoalesceValues(chrt chart.Charter, vals map[string]interface{}) (common.Val // Retaining Nils is useful when processes early in a Helm action or business // logic need to retain them for when Coalescing will happen again later in the // business logic. -func MergeValues(chrt chart.Charter, vals map[string]interface{}) (common.Values, error) { +func MergeValues(chrt chart.Charter, vals map[string]any) (common.Values, error) { vals, err := makeValues(chrt, vals) if err != nil { return vals, err @@ -86,22 +86,22 @@ func MergeValues(chrt chart.Charter, vals map[string]interface{}) (common.Values return coalesce(log.Printf, chrt, valsCopy, "", true) } -func copyValues(vals map[string]interface{}) (common.Values, error) { +func copyValues(vals map[string]any) (common.Values, error) { v, err := copystructure.Copy(vals) if err != nil { return vals, err } - valsCopy := v.(map[string]interface{}) + valsCopy := v.(map[string]any) // if we have an empty map, make sure it is initialized if valsCopy == nil { - valsCopy = make(map[string]interface{}) + valsCopy = make(map[string]any) } return valsCopy, nil } -type printFn func(format string, v ...interface{}) +type printFn func(format string, v ...any) // coalesce coalesces the dest values and the chart values, giving priority to the dest values. // @@ -110,13 +110,13 @@ type printFn func(format string, v ...interface{}) // Note, the merge argument specifies whether this is being used by MergeValues // or CoalesceValues. Coalescing removes null values and their keys in some // situations while merging keeps the null values. -func coalesce(printf printFn, ch chart.Charter, dest map[string]interface{}, prefix string, merge bool) (map[string]interface{}, error) { +func coalesce(printf printFn, ch chart.Charter, dest map[string]any, prefix string, merge bool) (map[string]any, error) { coalesceValues(printf, ch, dest, prefix, merge) return coalesceDeps(printf, ch, dest, prefix, merge) } // coalesceDeps coalesces the dependencies of the given chart. -func coalesceDeps(printf printFn, chrt chart.Charter, dest map[string]interface{}, prefix string, merge bool) (map[string]interface{}, error) { +func coalesceDeps(printf printFn, chrt chart.Charter, dest map[string]any, prefix string, merge bool) (map[string]any, error) { ch, err := chart.NewAccessor(chrt) if err != nil { return dest, err @@ -128,12 +128,12 @@ func coalesceDeps(printf printFn, chrt chart.Charter, dest map[string]interface{ } if c, ok := dest[sub.Name()]; !ok { // If dest doesn't already have the key, create it. - dest[sub.Name()] = make(map[string]interface{}) + dest[sub.Name()] = make(map[string]any) } else if !istable(c) { return dest, fmt.Errorf("type mismatch on %s: %t", sub.Name(), c) } if dv, ok := dest[sub.Name()]; ok { - dvmap := dv.(map[string]interface{}) + dvmap := dv.(map[string]any) subPrefix := concatPrefix(prefix, ch.Name()) // Get globals out of dest and merge them into dvmap. coalesceGlobals(printf, dvmap, dest, subPrefix, merge) @@ -151,19 +151,19 @@ func coalesceDeps(printf printFn, chrt chart.Charter, dest map[string]interface{ // coalesceGlobals copies the globals out of src and merges them into dest. // // For convenience, returns dest. -func coalesceGlobals(printf printFn, dest, src map[string]interface{}, prefix string, _ bool) { - var dg, sg map[string]interface{} +func coalesceGlobals(printf printFn, dest, src map[string]any, prefix string, _ bool) { + var dg, sg map[string]any if destglob, ok := dest[common.GlobalKey]; !ok { - dg = make(map[string]interface{}) - } else if dg, ok = destglob.(map[string]interface{}); !ok { + dg = make(map[string]any) + } else if dg, ok = destglob.(map[string]any); !ok { printf("warning: skipping globals because destination %s is not a table.", common.GlobalKey) return } if srcglob, ok := src[common.GlobalKey]; !ok { - sg = make(map[string]interface{}) - } else if sg, ok = srcglob.(map[string]interface{}); !ok { + sg = make(map[string]any) + } else if sg, ok = srcglob.(map[string]any); !ok { printf("warning: skipping globals because source %s is not a table.", common.GlobalKey) return } @@ -174,12 +174,12 @@ func coalesceGlobals(printf printFn, dest, src map[string]interface{}, prefix st // tables in globals. for key, val := range sg { if istable(val) { - vv := copyMap(val.(map[string]interface{})) + vv := copyMap(val.(map[string]any)) if destv, ok := dg[key]; !ok { // Here there is no merge. We're just adding. dg[key] = vv } else { - if destvmap, ok := destv.(map[string]interface{}); !ok { + if destvmap, ok := destv.(map[string]any); !ok { printf("Conflict: cannot merge map onto non-map for %q. Skipping.", key) } else { // Basically, we reverse order of coalesce here to merge @@ -203,8 +203,8 @@ func coalesceGlobals(printf printFn, dest, src map[string]interface{}, prefix st dest[common.GlobalKey] = dg } -func copyMap(src map[string]interface{}) map[string]interface{} { - m := make(map[string]interface{}, len(src)) +func copyMap(src map[string]any) map[string]any { + m := make(map[string]any, len(src)) maps.Copy(m, src) return m } @@ -212,7 +212,7 @@ func copyMap(src map[string]interface{}) map[string]interface{} { // coalesceValues builds up a values map for a particular chart. // // Values in v will override the values in the chart. -func coalesceValues(printf printFn, c chart.Charter, v map[string]interface{}, prefix string, merge bool) { +func coalesceValues(printf printFn, c chart.Charter, v map[string]any, prefix string, merge bool) { ch, err := chart.NewAccessor(c) if err != nil { return @@ -224,7 +224,7 @@ func coalesceValues(printf printFn, c chart.Charter, v map[string]interface{}, p // the original c.Values is altered. Creating a deep copy stops the problem. // This section is fault-tolerant as there is no ability to return an error. valuesCopy, err := copystructure.Copy(ch.Values()) - var vc map[string]interface{} + var vc map[string]any var ok bool if err != nil { // If there is an error something is wrong with copying c.Values it @@ -234,7 +234,7 @@ func coalesceValues(printf printFn, c chart.Charter, v map[string]interface{}, p printf("warning: unable to copy values, err: %s", err) vc = ch.Values() } else { - vc, ok = valuesCopy.(map[string]interface{}) + vc, ok = valuesCopy.(map[string]any) if !ok { // c.Values has a map[string]interface{} structure. If the copy of // it cannot be treated as map[string]interface{} there is something @@ -252,9 +252,9 @@ func coalesceValues(printf printFn, c chart.Charter, v map[string]interface{}, p // This allows Helm's various sources of values (value files or --set) to // remove incompatible keys from any previous chart, file, or set values. delete(v, key) - } else if dest, ok := value.(map[string]interface{}); ok { + } else if dest, ok := value.(map[string]any); ok { // if v[key] is a table, merge nv's val table into v[key]. - src, ok := val.(map[string]interface{}) + src, ok := val.(map[string]any) if !ok { // If the original value is nil, there is nothing to coalesce, so we don't print // the warning @@ -265,6 +265,12 @@ func coalesceValues(printf printFn, c chart.Charter, v map[string]interface{}, p // If the key is a child chart, coalesce tables with Merge set to true merge := childChartMergeTrue(c, key, merge) + // When coalescing, clean nils from chart defaults before merging + // so they don't leak into the result. + if !merge { + cleanNilValues(src) + } + // Because v has higher precedence than nv, dest values override src // values. coalesceTablesFullKey(printf, dest, src, concatPrefix(subPrefix, key), merge) @@ -272,6 +278,16 @@ func coalesceValues(printf printFn, c chart.Charter, v map[string]interface{}, p } } else { // If the key is not in v, copy it from nv. + // When coalescing, skip chart default nils and clean nils from + // nested maps so they don't shadow globals or produce %!s(). + if !merge { + if val == nil { + continue + } + if sub, ok := val.(map[string]any); ok { + cleanNilValues(sub) + } + } v[key] = val } } @@ -297,18 +313,18 @@ func childChartMergeTrue(chrt chart.Charter, key string, merge bool) bool { // CoalesceTables merges a source map into a destination map. // // dest is considered authoritative. -func CoalesceTables(dst, src map[string]interface{}) map[string]interface{} { +func CoalesceTables(dst, src map[string]any) map[string]any { return coalesceTablesFullKey(log.Printf, dst, src, "", false) } -func MergeTables(dst, src map[string]interface{}) map[string]interface{} { +func MergeTables(dst, src map[string]any) map[string]any { return coalesceTablesFullKey(log.Printf, dst, src, "", true) } // coalesceTablesFullKey merges a source map into a destination map. // // dest is considered authoritative. -func coalesceTablesFullKey(printf printFn, dst, src map[string]interface{}, prefix string, merge bool) map[string]interface{} { +func coalesceTablesFullKey(printf printFn, dst, src map[string]any, prefix string, merge bool) map[string]any { // When --reuse-values is set but there are no modifications yet, return new values if src == nil { return dst @@ -340,11 +356,10 @@ func coalesceTablesFullKey(printf printFn, dst, src map[string]interface{}, pref // But if src also has nil (or key not in src), preserve the nil delete(dst, key) } else if !ok { - // key not in user values, preserve src value (including nil) dst[key] = val } else if istable(val) { if istable(dv) { - coalesceTablesFullKey(printf, dv.(map[string]interface{}), val.(map[string]interface{}), fullkey, merge) + coalesceTablesFullKey(printf, dv.(map[string]any), val.(map[string]any), fullkey, merge) } else { printf("warning: cannot overwrite table with non table for %s (%v)", fullkey, val) } @@ -355,9 +370,21 @@ func coalesceTablesFullKey(printf printFn, dst, src map[string]interface{}, pref return dst } +// cleanNilValues recursively removes nil entries in-place from a map so that chart +// default nils don't leak into the coalesced result. +func cleanNilValues(m map[string]any) { + for key, val := range m { + if val == nil { + delete(m, key) + } else if sub, ok := val.(map[string]any); ok { + cleanNilValues(sub) + } + } +} + // istable is a special-purpose function to see if the present thing matches the definition of a YAML table. -func istable(v interface{}) bool { - _, ok := v.(map[string]interface{}) +func istable(v any) bool { + _, ok := v.(map[string]any) return ok } diff --git a/pkg/helm/pkg/chart/common/util/coalesce_test.go b/pkg/helm/pkg/chart/common/util/coalesce_test.go index 24e0b376..8cf4fef6 100644 --- a/pkg/helm/pkg/chart/common/util/coalesce_test.go +++ b/pkg/helm/pkg/chart/common/util/coalesce_test.go @@ -75,65 +75,65 @@ func TestCoalesceValues(t *testing.T) { c := withDeps(&chart.Chart{ Metadata: &chart.Metadata{Name: "moby"}, - Values: map[string]interface{}{ + Values: map[string]any{ "back": "exists", "bottom": "exists", "front": "exists", "left": "exists", "name": "moby", - "nested": map[string]interface{}{"boat": true}, + "nested": map[string]any{"boat": true}, "override": "bad", "right": "exists", "scope": "moby", "top": "nope", - "global": map[string]interface{}{ - "nested2": map[string]interface{}{"l0": "moby"}, + "global": map[string]any{ + "nested2": map[string]any{"l0": "moby"}, }, - "pequod": map[string]interface{}{ + "pequod": map[string]any{ "boat": "maybe", - "ahab": map[string]interface{}{ + "ahab": map[string]any{ "boat": "maybe", - "nested": map[string]interface{}{"boat": "maybe"}, + "nested": map[string]any{"boat": "maybe"}, }, }, }, }, withDeps(&chart.Chart{ Metadata: &chart.Metadata{Name: "pequod"}, - Values: map[string]interface{}{ + Values: map[string]any{ "name": "pequod", "scope": "pequod", - "global": map[string]interface{}{ - "nested2": map[string]interface{}{"l1": "pequod"}, + "global": map[string]any{ + "nested2": map[string]any{"l1": "pequod"}, }, "boat": false, - "ahab": map[string]interface{}{ + "ahab": map[string]any{ "boat": false, - "nested": map[string]interface{}{"boat": false}, + "nested": map[string]any{"boat": false}, }, }, }, &chart.Chart{ Metadata: &chart.Metadata{Name: "ahab"}, - Values: map[string]interface{}{ - "global": map[string]interface{}{ - "nested": map[string]interface{}{"foo": "bar", "foo2": "bar2"}, - "nested2": map[string]interface{}{"l2": "ahab"}, + Values: map[string]any{ + "global": map[string]any{ + "nested": map[string]any{"foo": "bar", "foo2": "bar2"}, + "nested2": map[string]any{"l2": "ahab"}, }, "scope": "ahab", "name": "ahab", "boat": true, - "nested": map[string]interface{}{"foo": false, "boat": true}, - "object": map[string]interface{}{"foo": "bar"}, + "nested": map[string]any{"foo": false, "boat": true}, + "object": map[string]any{"foo": "bar"}, }, }, ), &chart.Chart{ Metadata: &chart.Metadata{Name: "spouter"}, - Values: map[string]interface{}{ + Values: map[string]any{ "scope": "spouter", - "global": map[string]interface{}{ - "nested2": map[string]interface{}{"l1": "spouter"}, + "global": map[string]any{ + "nested2": map[string]any{"l1": "spouter"}, }, }, }, @@ -215,21 +215,21 @@ func TestCoalesceValues(t *testing.T) { } } - if _, ok := v["nested"].(map[string]interface{})["boat"]; ok { + if _, ok := v["nested"].(map[string]any)["boat"]; ok { t.Error("Expected nested boat key to be removed, still present") } - subchart := v["pequod"].(map[string]interface{}) + subchart := v["pequod"].(map[string]any) if _, ok := subchart["boat"]; ok { t.Error("Expected subchart boat key to be removed, still present") } - subsubchart := subchart["ahab"].(map[string]interface{}) + subsubchart := subchart["ahab"].(map[string]any) if _, ok := subsubchart["boat"]; ok { t.Error("Expected sub-subchart ahab boat key to be removed, still present") } - if _, ok := subsubchart["nested"].(map[string]interface{})["boat"]; ok { + if _, ok := subsubchart["nested"].(map[string]any)["boat"]; ok { t.Error("Expected sub-subchart nested boat key to be removed, still present") } @@ -241,7 +241,7 @@ func TestCoalesceValues(t *testing.T) { is.Equal(valsCopy, vals) } -func ttpl(tpl string, v map[string]interface{}) (string, error) { +func ttpl(tpl string, v map[string]any) (string, error) { var b bytes.Buffer tt := template.Must(template.New("t").Parse(tpl)) err := tt.Execute(&b, v) @@ -253,52 +253,52 @@ func TestMergeValues(t *testing.T) { c := withDeps(&chart.Chart{ Metadata: &chart.Metadata{Name: "moby"}, - Values: map[string]interface{}{ + Values: map[string]any{ "back": "exists", "bottom": "exists", "front": "exists", "left": "exists", "name": "moby", - "nested": map[string]interface{}{"boat": true}, + "nested": map[string]any{"boat": true}, "override": "bad", "right": "exists", "scope": "moby", "top": "nope", - "global": map[string]interface{}{ - "nested2": map[string]interface{}{"l0": "moby"}, + "global": map[string]any{ + "nested2": map[string]any{"l0": "moby"}, }, }, }, withDeps(&chart.Chart{ Metadata: &chart.Metadata{Name: "pequod"}, - Values: map[string]interface{}{ + Values: map[string]any{ "name": "pequod", "scope": "pequod", - "global": map[string]interface{}{ - "nested2": map[string]interface{}{"l1": "pequod"}, + "global": map[string]any{ + "nested2": map[string]any{"l1": "pequod"}, }, }, }, &chart.Chart{ Metadata: &chart.Metadata{Name: "ahab"}, - Values: map[string]interface{}{ - "global": map[string]interface{}{ - "nested": map[string]interface{}{"foo": "bar"}, - "nested2": map[string]interface{}{"l2": "ahab"}, + Values: map[string]any{ + "global": map[string]any{ + "nested": map[string]any{"foo": "bar"}, + "nested2": map[string]any{"l2": "ahab"}, }, "scope": "ahab", "name": "ahab", "boat": true, - "nested": map[string]interface{}{"foo": false, "bar": true}, + "nested": map[string]any{"foo": false, "bar": true}, }, }, ), &chart.Chart{ Metadata: &chart.Metadata{Name: "spouter"}, - Values: map[string]interface{}{ + Values: map[string]any{ "scope": "spouter", - "global": map[string]interface{}{ - "nested2": map[string]interface{}{"l1": "spouter"}, + "global": map[string]any{ + "nested2": map[string]any{"l1": "spouter"}, }, }, }, @@ -383,16 +383,16 @@ func TestMergeValues(t *testing.T) { } } - if _, ok := v["nested"].(map[string]interface{})["boat"]; !ok { + if _, ok := v["nested"].(map[string]any)["boat"]; !ok { t.Error("Expected nested boat key to be present but it was removed") } - subchart := v["pequod"].(map[string]interface{})["ahab"].(map[string]interface{}) + subchart := v["pequod"].(map[string]any)["ahab"].(map[string]any) if _, ok := subchart["boat"]; !ok { t.Error("Expected subchart boat key to be present but it was removed") } - if _, ok := subchart["nested"].(map[string]interface{})["bar"]; !ok { + if _, ok := subchart["nested"].(map[string]any)["bar"]; !ok { t.Error("Expected subchart nested bar key to be present but it was removed") } @@ -401,28 +401,28 @@ func TestMergeValues(t *testing.T) { } func TestCoalesceTables(t *testing.T) { - dst := map[string]interface{}{ + dst := map[string]any{ "name": "Ishmael", - "address": map[string]interface{}{ + "address": map[string]any{ "street": "123 Spouter Inn Ct.", "city": "Nantucket", "country": nil, }, - "details": map[string]interface{}{ + "details": map[string]any{ "friends": []string{"Tashtego"}, }, "boat": "pequod", "hole": nil, } - src := map[string]interface{}{ + src := map[string]any{ "occupation": "whaler", - "address": map[string]interface{}{ + "address": map[string]any{ "state": "MA", "street": "234 Spouter Inn Ct.", "country": "US", }, "details": "empty", - "boat": map[string]interface{}{ + "boat": map[string]any{ "mast": true, }, "hole": "black", @@ -439,7 +439,7 @@ func TestCoalesceTables(t *testing.T) { t.Errorf("Unexpected occupation: %s", dst["occupation"]) } - addr, ok := dst["address"].(map[string]interface{}) + addr, ok := dst["address"].(map[string]any) if !ok { t.Fatal("Address went away.") } @@ -460,7 +460,7 @@ func TestCoalesceTables(t *testing.T) { t.Error("The country is not left out.") } - if det, ok := dst["details"].(map[string]interface{}); !ok { + if det, ok := dst["details"].(map[string]any); !ok { t.Fatalf("Details is the wrong type: %v", dst["details"]) } else if _, ok := det["friends"]; !ok { t.Error("Could not find your friends. Maybe you don't have any. :-(") @@ -474,14 +474,14 @@ func TestCoalesceTables(t *testing.T) { t.Error("The hole still exists.") } - dst2 := map[string]interface{}{ + dst2 := map[string]any{ "name": "Ishmael", - "address": map[string]interface{}{ + "address": map[string]any{ "street": "123 Spouter Inn Ct.", "city": "Nantucket", "country": "US", }, - "details": map[string]interface{}{ + "details": map[string]any{ "friends": []string{"Tashtego"}, }, "boat": "pequod", @@ -496,7 +496,7 @@ func TestCoalesceTables(t *testing.T) { t.Errorf("Unexpected name: %s", dst2["name"]) } - addr2, ok := dst2["address"].(map[string]interface{}) + addr2, ok := dst2["address"].(map[string]any) if !ok { t.Fatal("Address went away.") } @@ -513,7 +513,7 @@ func TestCoalesceTables(t *testing.T) { t.Errorf("Unexpected Country: %v", addr2["country"]) } - if det2, ok := dst2["details"].(map[string]interface{}); !ok { + if det2, ok := dst2["details"].(map[string]any); !ok { t.Fatalf("Details is the wrong type: %v", dst2["details"]) } else if _, ok := det2["friends"]; !ok { t.Error("Could not find your friends. Maybe you don't have any. :-(") @@ -529,28 +529,28 @@ func TestCoalesceTables(t *testing.T) { } func TestMergeTables(t *testing.T) { - dst := map[string]interface{}{ + dst := map[string]any{ "name": "Ishmael", - "address": map[string]interface{}{ + "address": map[string]any{ "street": "123 Spouter Inn Ct.", "city": "Nantucket", "country": nil, }, - "details": map[string]interface{}{ + "details": map[string]any{ "friends": []string{"Tashtego"}, }, "boat": "pequod", "hole": nil, } - src := map[string]interface{}{ + src := map[string]any{ "occupation": "whaler", - "address": map[string]interface{}{ + "address": map[string]any{ "state": "MA", "street": "234 Spouter Inn Ct.", "country": "US", }, "details": "empty", - "boat": map[string]interface{}{ + "boat": map[string]any{ "mast": true, }, "hole": "black", @@ -567,7 +567,7 @@ func TestMergeTables(t *testing.T) { t.Errorf("Unexpected occupation: %s", dst["occupation"]) } - addr, ok := dst["address"].(map[string]interface{}) + addr, ok := dst["address"].(map[string]any) if !ok { t.Fatal("Address went away.") } @@ -590,7 +590,7 @@ func TestMergeTables(t *testing.T) { t.Error("The country is left out.") } - if det, ok := dst["details"].(map[string]interface{}); !ok { + if det, ok := dst["details"].(map[string]any); !ok { t.Fatalf("Details is the wrong type: %v", dst["details"]) } else if _, ok := det["friends"]; !ok { t.Error("Could not find your friends. Maybe you don't have any. :-(") @@ -606,14 +606,14 @@ func TestMergeTables(t *testing.T) { t.Error("The hole no longer exists.") } - dst2 := map[string]interface{}{ + dst2 := map[string]any{ "name": "Ishmael", - "address": map[string]interface{}{ + "address": map[string]any{ "street": "123 Spouter Inn Ct.", "city": "Nantucket", "country": "US", }, - "details": map[string]interface{}{ + "details": map[string]any{ "friends": []string{"Tashtego"}, }, "boat": "pequod", @@ -629,7 +629,7 @@ func TestMergeTables(t *testing.T) { t.Errorf("Unexpected name: %s", dst2["name"]) } - addr2, ok := dst2["address"].(map[string]interface{}) + addr2, ok := dst2["address"].(map[string]any) if !ok { t.Fatal("Address went away.") } @@ -646,7 +646,7 @@ func TestMergeTables(t *testing.T) { t.Errorf("Unexpected Country: %v", addr2["country"]) } - if det2, ok := dst2["details"].(map[string]interface{}); !ok { + if det2, ok := dst2["details"].(map[string]any); !ok { t.Fatalf("Details is the wrong type: %v", dst2["details"]) } else if _, ok := det2["friends"]; !ok { t.Error("Could not find your friends. Maybe you don't have any. :-(") @@ -669,24 +669,24 @@ func TestCoalesceValuesWarnings(t *testing.T) { c := withDeps(&chart.Chart{ Metadata: &chart.Metadata{Name: "level1"}, - Values: map[string]interface{}{ + Values: map[string]any{ "name": "moby", }, }, withDeps(&chart.Chart{ Metadata: &chart.Metadata{Name: "level2"}, - Values: map[string]interface{}{ + Values: map[string]any{ "name": "pequod", }, }, &chart.Chart{ Metadata: &chart.Metadata{Name: "level3"}, - Values: map[string]interface{}{ + Values: map[string]any{ "name": "ahab", "boat": true, - "spear": map[string]interface{}{ + "spear": map[string]any{ "tip": true, - "sail": map[string]interface{}{ + "sail": map[string]any{ "cotton": true, }, }, @@ -695,12 +695,12 @@ func TestCoalesceValuesWarnings(t *testing.T) { ), ) - vals := map[string]interface{}{ - "level2": map[string]interface{}{ - "level3": map[string]interface{}{ - "boat": map[string]interface{}{"mast": true}, - "spear": map[string]interface{}{ - "tip": map[string]interface{}{ + vals := map[string]any{ + "level2": map[string]any{ + "level3": map[string]any{ + "boat": map[string]any{"mast": true}, + "spear": map[string]any{ + "tip": map[string]any{ "sharp": true, }, "sail": true, @@ -710,7 +710,7 @@ func TestCoalesceValuesWarnings(t *testing.T) { } warnings := make([]string, 0) - printf := func(format string, v ...interface{}) { + printf := func(format string, v ...any) { t.Logf(format, v...) warnings = append(warnings, fmt.Sprintf(format, v...)) } @@ -765,3 +765,166 @@ func TestCoalesceValuesEmptyMapWithNils(t *testing.T) { is.True(ok, "Expected data.baz key to be present but it was removed") is.Nil(data["baz"], "Expected data.baz key to be nil but it is not") } + +// TestCoalesceValuesSubchartDefaultNilsCleaned tests that nil values in subchart defaults +// are cleaned up during coalescing when the parent doesn't set those keys. +// Regression test for issue #31919. +func TestCoalesceValuesSubchartDefaultNilsCleaned(t *testing.T) { + is := assert.New(t) + + // Subchart has a default with nil values (e.g. keyMapping: {password: null}) + subchart := &chart.Chart{ + Metadata: &chart.Metadata{Name: "child"}, + Values: map[string]any{ + "keyMapping": map[string]any{ + "password": nil, + }, + }, + } + + parent := withDeps(&chart.Chart{ + Metadata: &chart.Metadata{Name: "parent"}, + Values: map[string]any{}, + }, subchart) + + // Parent user values don't mention keyMapping at all + vals := map[string]any{} + + v, err := CoalesceValues(parent, vals) + is.NoError(err) + + childVals, ok := v["child"].(map[string]any) + is.True(ok, "child values should be a map") + + keyMapping, ok := childVals["keyMapping"].(map[string]any) + is.True(ok, "keyMapping should be a map") + + // The nil "password" key from chart defaults should be cleaned up + _, ok = keyMapping["password"] + is.False(ok, "Expected keyMapping.password (nil from chart defaults) to be removed, but it is still present") +} + +// TestCoalesceValuesUserNullErasesSubchartDefault tests that a user-supplied null +// value erases a subchart's default value during coalescing. +// Regression test for issue #31919. +func TestCoalesceValuesUserNullErasesSubchartDefault(t *testing.T) { + is := assert.New(t) + + subchart := &chart.Chart{ + Metadata: &chart.Metadata{Name: "child"}, + Values: map[string]any{ + "someKey": "default", + }, + } + + parent := withDeps(&chart.Chart{ + Metadata: &chart.Metadata{Name: "parent"}, + Values: map[string]any{}, + }, subchart) + + // User explicitly nullifies the subchart key via parent values + vals := map[string]any{ + "child": map[string]any{ + "someKey": nil, + }, + } + + v, err := CoalesceValues(parent, vals) + is.NoError(err) + + childVals, ok := v["child"].(map[string]any) + is.True(ok, "child values should be a map") + + // someKey should be erased — user null overrides subchart default + _, ok = childVals["someKey"] + is.False(ok, "Expected someKey to be removed by user null override, but it is still present") +} + +// TestCoalesceValuesSubchartNilDoesNotShadowGlobal tests that a nil value in +// subchart defaults doesn't shadow a global value accessible via pluck-like access. +// Regression test for issue #31971. +func TestCoalesceValuesSubchartNilDoesNotShadowGlobal(t *testing.T) { + is := assert.New(t) + + subchart := &chart.Chart{ + Metadata: &chart.Metadata{Name: "child"}, + Values: map[string]any{ + "ingress": map[string]any{ + "feature": nil, // nil in subchart defaults + }, + }, + } + + parent := withDeps(&chart.Chart{ + Metadata: &chart.Metadata{Name: "parent"}, + Values: map[string]any{}, + }, subchart) + + // Parent sets the global value + vals := map[string]any{ + "global": map[string]any{ + "ingress": map[string]any{ + "feature": true, + }, + }, + } + + v, err := CoalesceValues(parent, vals) + is.NoError(err) + + childVals, ok := v["child"].(map[string]any) + is.True(ok, "child values should be a map") + + ingress, ok := childVals["ingress"].(map[string]any) + is.True(ok, "ingress should be a map") + + // The nil "feature" from subchart defaults should be cleaned up, + // so that pluck can fall through to the global value + _, ok = ingress["feature"] + is.False(ok, "Expected ingress.feature (nil from chart defaults) to be removed so global can be used via pluck, but it is still present") +} + +// TestCoalesceValuesSubchartNilCleanedWhenUserPartiallyOverrides tests that nil +// values in subchart defaults are cleaned even when the user partially overrides +// the same map. Regression test for the coalesceTablesFullKey merge path. +func TestCoalesceValuesSubchartNilCleanedWhenUserPartiallyOverrides(t *testing.T) { + is := assert.New(t) + + subchart := &chart.Chart{ + Metadata: &chart.Metadata{Name: "child"}, + Values: map[string]any{ + "keyMapping": map[string]any{ + "password": nil, + "format": "bcrypt", + }, + }, + } + + parent := withDeps(&chart.Chart{ + Metadata: &chart.Metadata{Name: "parent"}, + Values: map[string]any{}, + }, subchart) + + // User overrides format but doesn't mention password + vals := map[string]any{ + "child": map[string]any{ + "keyMapping": map[string]any{ + "format": "sha256", + }, + }, + } + + v, err := CoalesceValues(parent, vals) + is.NoError(err) + + childVals, ok := v["child"].(map[string]any) + is.True(ok, "child values should be a map") + + keyMapping, ok := childVals["keyMapping"].(map[string]any) + is.True(ok, "keyMapping should be a map") + + is.Equal("sha256", keyMapping["format"], "User override should be preserved") + + _, ok = keyMapping["password"] + is.False(ok, "Expected keyMapping.password (nil from chart defaults) to be removed even when user partially overrides the map") +} diff --git a/pkg/helm/pkg/chart/common/util/jsonschema.go b/pkg/helm/pkg/chart/common/util/jsonschema.go index 25f50009..0c38f90e 100644 --- a/pkg/helm/pkg/chart/common/util/jsonschema.go +++ b/pkg/helm/pkg/chart/common/util/jsonschema.go @@ -73,7 +73,7 @@ func newHTTPURLLoader() *HTTPURLLoader { } // ValidateAgainstSchema checks that values does not violate the structure laid out in schema -func ValidateAgainstSchema(ch chart.Charter, values map[string]interface{}) error { +func ValidateAgainstSchema(ch chart.Charter, values map[string]any) error { chrt, err := chart.NewAccessor(ch) if err != nil { return err diff --git a/pkg/helm/pkg/chart/common/util/jsonschema_test.go b/pkg/helm/pkg/chart/common/util/jsonschema_test.go index 507c0a46..5603e36e 100644 --- a/pkg/helm/pkg/chart/common/util/jsonschema_test.go +++ b/pkg/helm/pkg/chart/common/util/jsonschema_test.go @@ -54,7 +54,7 @@ func TestValidateAgainstInvalidSingleSchema(t *testing.T) { var errString string if err := ValidateAgainstSingleSchema(values, schema); err == nil { - t.Fatalf("Expected an error, but got nil") + t.Fatal("Expected an error, but got nil") } else { errString = err.Error() } @@ -78,7 +78,7 @@ func TestValidateAgainstSingleSchemaNegative(t *testing.T) { var errString string if err := ValidateAgainstSingleSchema(values, schema); err == nil { - t.Fatalf("Expected an error, but got nil") + t.Fatal("Expected an error, but got nil") } else { errString = err.Error() } @@ -138,9 +138,9 @@ func TestValidateAgainstSchema(t *testing.T) { } chrt.AddDependency(subchart) - vals := map[string]interface{}{ + vals := map[string]any{ "name": "John", - "subchart": map[string]interface{}{ + "subchart": map[string]any{ "age": 25, }, } @@ -165,14 +165,14 @@ func TestValidateAgainstSchemaNegative(t *testing.T) { } chrt.AddDependency(subchart) - vals := map[string]interface{}{ + vals := map[string]any{ "name": "John", - "subchart": map[string]interface{}{}, + "subchart": map[string]any{}, } var errString string if err := ValidateAgainstSchema(chrt, vals); err == nil { - t.Fatalf("Expected an error, but got nil") + t.Fatal("Expected an error, but got nil") } else { errString = err.Error() } @@ -200,9 +200,9 @@ func TestValidateAgainstSchema2020(t *testing.T) { } chrt.AddDependency(subchart) - vals := map[string]interface{}{ + vals := map[string]any{ "name": "John", - "subchart": map[string]interface{}{ + "subchart": map[string]any{ "data": []any{"hello", 12}, }, } @@ -227,16 +227,16 @@ func TestValidateAgainstSchema2020Negative(t *testing.T) { } chrt.AddDependency(subchart) - vals := map[string]interface{}{ + vals := map[string]any{ "name": "John", - "subchart": map[string]interface{}{ + "subchart": map[string]any{ "data": []any{12}, }, } var errString string if err := ValidateAgainstSchema(chrt, vals); err == nil { - t.Fatalf("Expected an error, but got nil") + t.Fatal("Expected an error, but got nil") } else { errString = err.Error() } @@ -294,7 +294,7 @@ func TestValidateAgainstSingleSchema_UnresolvedURN_Ignored(t *testing.T) { "$schema": "https://json-schema.org/draft-07/schema#", "$ref": "urn:example:helm:schemas:v1:helm-schema-validation-conditions:v1/helmSchemaValidation-true" }`) - vals := map[string]interface{}{"any": "value"} + vals := map[string]any{"any": "value"} if err := ValidateAgainstSingleSchema(vals, schema); err != nil { t.Fatalf("expected no error when URN unresolved is ignored, got: %v", err) } @@ -386,6 +386,6 @@ func TestValidateAgainstSchema_InvalidSubchartValuesType_NoPanic(t *testing.T) { // We expect a non-nil error (invalid type), but crucially no panic. if err := ValidateAgainstSchema(chrt, vals); err == nil { - t.Fatalf("expected an error when subchart values have invalid type, got nil") + t.Fatal("expected an error when subchart values have invalid type, got nil") } } diff --git a/pkg/helm/pkg/chart/common/util/values.go b/pkg/helm/pkg/chart/common/util/values.go index 509de42d..5cd9e81e 100644 --- a/pkg/helm/pkg/chart/common/util/values.go +++ b/pkg/helm/pkg/chart/common/util/values.go @@ -26,14 +26,14 @@ import ( // ToRenderValues composes the struct from the data coming from the Releases, Charts and Values files // // This takes both ReleaseOptions and Capabilities to merge into the render values. -func ToRenderValues(chrt chart.Charter, chrtVals map[string]interface{}, options common.ReleaseOptions, caps *common.Capabilities) (common.Values, error) { +func ToRenderValues(chrt chart.Charter, chrtVals map[string]any, options common.ReleaseOptions, caps *common.Capabilities) (common.Values, error) { return ToRenderValuesWithSchemaValidation(chrt, chrtVals, options, caps, false) } // ToRenderValuesWithSchemaValidation composes the struct from the data coming from the Releases, Charts and Values files // // This takes both ReleaseOptions and Capabilities to merge into the render values. -func ToRenderValuesWithSchemaValidation(chrt chart.Charter, chrtVals map[string]interface{}, options common.ReleaseOptions, caps *common.Capabilities, skipSchemaValidation bool) (common.Values, error) { +func ToRenderValuesWithSchemaValidation(chrt chart.Charter, chrtVals map[string]any, options common.ReleaseOptions, caps *common.Capabilities, skipSchemaValidation bool) (common.Values, error) { if caps == nil { caps = common.DefaultCapabilities } @@ -41,10 +41,10 @@ func ToRenderValuesWithSchemaValidation(chrt chart.Charter, chrtVals map[string] if err != nil { return nil, err } - top := map[string]interface{}{ + top := map[string]any{ "Chart": accessor.MetadataAsMap(), "Capabilities": caps, - "Release": map[string]interface{}{ + "Release": map[string]any{ "Name": options.Name, "Namespace": options.Namespace, "IsUpgrade": options.IsUpgrade, diff --git a/pkg/helm/pkg/chart/common/util/values_test.go b/pkg/helm/pkg/chart/common/util/values_test.go index b983319a..c6b4db3b 100644 --- a/pkg/helm/pkg/chart/common/util/values_test.go +++ b/pkg/helm/pkg/chart/common/util/values_test.go @@ -26,17 +26,17 @@ import ( func TestToRenderValues(t *testing.T) { - chartValues := map[string]interface{}{ + chartValues := map[string]any{ "name": "al Rashid", - "where": map[string]interface{}{ + "where": map[string]any{ "city": "Basrah", "title": "caliph", }, } - overrideValues := map[string]interface{}{ + overrideValues := map[string]any{ "name": "Haroun", - "where": map[string]interface{}{ + "where": map[string]any{ "city": "Baghdad", "date": "809 CE", }, @@ -67,11 +67,11 @@ func TestToRenderValues(t *testing.T) { } // Ensure that the top-level values are all set. - metamap := res["Chart"].(map[string]interface{}) + metamap := res["Chart"].(map[string]any) if name := metamap["Name"]; name.(string) != "test" { t.Errorf("Expected chart name 'test', got %q", name) } - relmap := res["Release"].(map[string]interface{}) + relmap := res["Release"].(map[string]any) if name := relmap["Name"]; name.(string) != "Seven Voyages" { t.Errorf("Expected release name 'Seven Voyages', got %q", name) } @@ -85,7 +85,7 @@ func TestToRenderValues(t *testing.T) { t.Error("Expected upgrade to be false.") } if !relmap["IsInstall"].(bool) { - t.Errorf("Expected install to be true.") + t.Error("Expected install to be true.") } if !res["Capabilities"].(*common.Capabilities).APIVersions.Has("v1") { t.Error("Expected Capabilities to have v1 as an API") @@ -98,7 +98,7 @@ func TestToRenderValues(t *testing.T) { if vals["name"] != "Haroun" { t.Errorf("Expected 'Haroun', got %q (%v)", vals["name"], vals) } - where := vals["where"].(map[string]interface{}) + where := vals["where"].(map[string]any) expects := map[string]string{ "city": "Baghdad", "date": "809 CE", diff --git a/pkg/helm/pkg/chart/common/values.go b/pkg/helm/pkg/chart/common/values.go index 94958a77..17a06779 100644 --- a/pkg/helm/pkg/chart/common/values.go +++ b/pkg/helm/pkg/chart/common/values.go @@ -29,7 +29,7 @@ import ( const GlobalKey = "global" // Values represents a collection of chart values. -type Values map[string]interface{} +type Values map[string]any // YAML encodes the Values into a YAML string. func (v Values) YAML() (string, error) { @@ -64,9 +64,9 @@ func (v Values) Table(name string) (Values, error) { // AsMap is a utility function for converting Values to a map[string]interface{}. // // It protects against nil map panics. -func (v Values) AsMap() map[string]interface{} { +func (v Values) AsMap() map[string]any { if len(v) == 0 { - return map[string]interface{}{} + return map[string]any{} } return v } @@ -86,7 +86,7 @@ func tableLookup(v Values, simple string) (Values, error) { if !ok { return v, ErrNoTable{simple} } - if vv, ok := v2.(map[string]interface{}); ok { + if vv, ok := v2.(map[string]any); ok { return vv, nil } @@ -113,7 +113,7 @@ func ReadValues(data []byte) (vals Values, err error) { func ReadValuesFile(filename string) (Values, error) { data, err := os.ReadFile(filename) if err != nil { - return map[string]interface{}{}, err + return map[string]any{}, err } return ReadValues(data) } @@ -129,8 +129,8 @@ type ReleaseOptions struct { } // istable is a special-purpose function to see if the present thing matches the definition of a YAML table. -func istable(v interface{}) bool { - _, ok := v.(map[string]interface{}) +func istable(v any) bool { + _, ok := v.(map[string]any) return ok } @@ -141,14 +141,14 @@ func istable(v interface{}) bool { // chapter: // one: // title: "Loomings" -func (v Values) PathValue(path string) (interface{}, error) { +func (v Values) PathValue(path string) (any, error) { if path == "" { return nil, errors.New("YAML path cannot be empty") } return v.pathValue(parsePath(path)) } -func (v Values) pathValue(path []string) (interface{}, error) { +func (v Values) pathValue(path []string) (any, error) { if len(path) == 1 { // if exists must be root key not table if _, ok := v[path[0]]; ok && !istable(v[path[0]]) { diff --git a/pkg/helm/pkg/chart/common/values_test.go b/pkg/helm/pkg/chart/common/values_test.go index 3cceeb2b..9743869e 100644 --- a/pkg/helm/pkg/chart/common/values_test.go +++ b/pkg/helm/pkg/chart/common/values_test.go @@ -113,7 +113,7 @@ chapter: } if _, err := d.Table("title"); err == nil { - t.Fatalf("Title is not a table.") + t.Fatal("Title is not a table.") } if _, err := d.Table("chapter"); err != nil { @@ -131,11 +131,11 @@ chapter: } if _, err := d.Table("chapter.OneHundredThirtySix"); err == nil { - t.Errorf("I think you mean 'Epilogue'") + t.Error("I think you mean 'Epilogue'") } } -func matchValues(t *testing.T, data map[string]interface{}) { +func matchValues(t *testing.T, data map[string]any) { t.Helper() if data["poet"] != "Coleridge" { t.Errorf("Unexpected poet: %s", data["poet"]) @@ -150,17 +150,17 @@ func matchValues(t *testing.T, data map[string]interface{}) { if o, err := ttpl("{{.mariner.shot}}", data); err != nil { t.Errorf(".mariner.shot: %s", err) } else if o != "ALBATROSS" { - t.Errorf("Expected that mariner shot ALBATROSS") + t.Error("Expected that mariner shot ALBATROSS") } if o, err := ttpl("{{.water.water.where}}", data); err != nil { t.Errorf(".water.water.where: %s", err) } else if o != "everywhere" { - t.Errorf("Expected water water everywhere") + t.Error("Expected water water everywhere") } } -func ttpl(tpl string, v map[string]interface{}) (string, error) { +func ttpl(tpl string, v map[string]any) (string, error) { var b bytes.Buffer tt := template.Must(template.New("t").Parse(tpl)) err := tt.Execute(&b, v) @@ -199,7 +199,7 @@ chapter: } if v, err := d.PathValue("title"); err == nil { if v != "Moby Dick" { - t.Errorf("Failed to return values for root key title") + t.Error("Failed to return values for root key title") } } } diff --git a/pkg/helm/pkg/chart/interfaces.go b/pkg/helm/pkg/chart/interfaces.go index 508dfb54..e2b3dea7 100644 --- a/pkg/helm/pkg/chart/interfaces.go +++ b/pkg/helm/pkg/chart/interfaces.go @@ -19,21 +19,21 @@ import ( common "github.com/werf/nelm/pkg/helm/pkg/chart/common" ) -type Charter interface{} +type Charter any -type Dependency interface{} +type Dependency any type Accessor interface { Name() string IsRoot() bool - MetadataAsMap() map[string]interface{} + MetadataAsMap() map[string]any Files() []*common.File Templates() []*common.File ChartFullPath() string IsLibraryChart() bool Dependencies() []Charter MetaDependencies() []Dependency - Values() map[string]interface{} + Values() map[string]any Schema() []byte Deprecated() bool } diff --git a/pkg/helm/pkg/chart/loader/archive/archive.go b/pkg/helm/pkg/chart/loader/archive/archive.go index e98f5c33..a35c0152 100644 --- a/pkg/helm/pkg/chart/loader/archive/archive.go +++ b/pkg/helm/pkg/chart/loader/archive/archive.go @@ -172,7 +172,7 @@ func EnsureArchive(name string, raw *os.File) error { buffer := make([]byte, 512) _, err := raw.Read(buffer) if err != nil && err != io.EOF { - return fmt.Errorf("file '%s' cannot be read: %s", name, err) + return fmt.Errorf("file '%s' cannot be read: %w", name, err) } // Helm may identify achieve of the application/x-gzip as application/vnd.ms-fontobject. diff --git a/pkg/helm/pkg/chart/v2/chart.go b/pkg/helm/pkg/chart/v2/chart.go index ab9e69d2..67a2290a 100644 --- a/pkg/helm/pkg/chart/v2/chart.go +++ b/pkg/helm/pkg/chart/v2/chart.go @@ -50,11 +50,11 @@ type Chart struct { // Templates for this chart. Templates []*common.File `json:"templates"` // Values are default config for this chart. - Values map[string]interface{} `json:"values"` + Values map[string]any `json:"values"` // Schema is an optional JSON schema for imposing structure on Values Schema []byte `json:"schema"` // SchemaModTime the schema was last modified - SchemaModTime time.Time `json:"schemamodtime,omitempty"` + SchemaModTime time.Time `json:"schemamodtime"` // Files are miscellaneous files in a chart archive, // e.g. README, LICENSE, etc. Files []*common.File `json:"files"` diff --git a/pkg/helm/pkg/chart/v2/chart_test.go b/pkg/helm/pkg/chart/v2/chart_test.go index bdd11b17..1968b9cd 100644 --- a/pkg/helm/pkg/chart/v2/chart_test.go +++ b/pkg/helm/pkg/chart/v2/chart_test.go @@ -124,8 +124,8 @@ func TestIsRoot(t *testing.T) { is := assert.New(t) - is.Equal(false, chrt1.IsRoot()) - is.Equal(true, chrt2.IsRoot()) + is.False(chrt1.IsRoot()) + is.True(chrt2.IsRoot()) } func TestChartPath(t *testing.T) { diff --git a/pkg/helm/pkg/chart/v2/dependency.go b/pkg/helm/pkg/chart/v2/dependency.go index 1fb4534d..a851890c 100644 --- a/pkg/helm/pkg/chart/v2/dependency.go +++ b/pkg/helm/pkg/chart/v2/dependency.go @@ -44,7 +44,7 @@ type Dependency struct { Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` // ImportValues holds the mapping of source values to parent key to be imported. Each item can be a // string or pair of child/parent sublist items. - ImportValues []interface{} `json:"import-values,omitempty" yaml:"import-values,omitempty"` + ImportValues []any `json:"import-values,omitempty" yaml:"import-values,omitempty"` // Alias usable alias to be used for the chart Alias string `json:"alias,omitempty" yaml:"alias,omitempty"` ExportValues []interface{} `json:"export-values,omitempty" yaml:"export-values,omitempty"` diff --git a/pkg/helm/pkg/chart/v2/errors.go b/pkg/helm/pkg/chart/v2/errors.go index eeef7531..5f7f5e73 100644 --- a/pkg/helm/pkg/chart/v2/errors.go +++ b/pkg/helm/pkg/chart/v2/errors.go @@ -25,6 +25,6 @@ func (v ValidationError) Error() string { } // ValidationErrorf takes a message and formatting options and creates a ValidationError -func ValidationErrorf(msg string, args ...interface{}) ValidationError { +func ValidationErrorf(msg string, args ...any) ValidationError { return ValidationError(fmt.Sprintf(msg, args...)) } diff --git a/pkg/helm/pkg/chart/v2/lint/lint.go b/pkg/helm/pkg/chart/v2/lint/lint.go index 2e028a13..a0284611 100644 --- a/pkg/helm/pkg/chart/v2/lint/lint.go +++ b/pkg/helm/pkg/chart/v2/lint/lint.go @@ -43,7 +43,7 @@ func WithSkipSchemaValidation(skipSchemaValidation bool) LinterOption { } } -func RunAll(baseDir string, values map[string]interface{}, namespace string, options ...LinterOption) support.Linter { +func RunAll(baseDir string, values map[string]any, namespace string, options ...LinterOption) support.Linter { chartDir, _ := filepath.Abs(baseDir) diff --git a/pkg/helm/pkg/chart/v2/lint/lint_test.go b/pkg/helm/pkg/chart/v2/lint/lint_test.go index 0e136a99..2bd5bdb0 100644 --- a/pkg/helm/pkg/chart/v2/lint/lint_test.go +++ b/pkg/helm/pkg/chart/v2/lint/lint_test.go @@ -100,7 +100,7 @@ func TestInvalidYaml(t *testing.T) { t.Fatalf("All didn't fail with expected errors, got %#v", m) } if !strings.Contains(m[0].Err.Error(), "deliberateSyntaxError") { - t.Errorf("All didn't have the error for deliberateSyntaxError") + t.Error("All didn't have the error for deliberateSyntaxError") } } @@ -111,7 +111,7 @@ func TestInvalidChartYaml(t *testing.T) { t.Fatalf("All didn't fail with expected errors, got %#v", m) } if !strings.Contains(m[0].Err.Error(), "failed to strictly parse chart metadata file") { - t.Errorf("All didn't have the error for duplicate YAML keys") + t.Error("All didn't have the error for duplicate YAML keys") } } @@ -235,13 +235,13 @@ func TestMalformedTemplate(t *testing.T) { }() select { case <-c: - t.Fatalf("lint malformed template timeout") + t.Fatal("lint malformed template timeout") case <-ch: if len(m) != 1 { t.Fatalf("All didn't fail with expected errors, got %#v", m) } if !strings.Contains(m[0].Err.Error(), "invalid character '{'") { - t.Errorf("All didn't have the error for invalid character '{'") + t.Error("All didn't have the error for invalid character '{'") } } } diff --git a/pkg/helm/pkg/chart/v2/lint/rules/chartfile.go b/pkg/helm/pkg/chart/v2/lint/rules/chartfile.go index a4ee83e0..97703db6 100644 --- a/pkg/helm/pkg/chart/v2/lint/rules/chartfile.go +++ b/pkg/helm/pkg/chart/v2/lint/rules/chartfile.go @@ -70,15 +70,15 @@ func Chartfile(linter *support.Linter) { linter.RunLinterRule(support.WarningSev, chartFileName, validateChartVersionStrictSemVerV2(chartFile)) } -func validateChartVersionType(data map[string]interface{}) error { +func validateChartVersionType(data map[string]any) error { return isStringValue(data, "version") } -func validateChartAppVersionType(data map[string]interface{}) error { +func validateChartAppVersionType(data map[string]any) error { return isStringValue(data, "appVersion") } -func isStringValue(data map[string]interface{}, key string) error { +func isStringValue(data map[string]any, key string) error { value, ok := data[key] if !ok { return nil @@ -153,7 +153,7 @@ func validateChartVersion(cf *chart.Metadata) error { valid, msg := c.Validate(version) if !valid && len(msg) > 0 { - return fmt.Errorf("version %v", msg[0]) + return fmt.Errorf("version %w", msg[0]) } return nil @@ -225,12 +225,12 @@ func validateChartType(cf *chart.Metadata) error { // loadChartFileForTypeCheck loads the Chart.yaml // in a generic form of a map[string]interface{}, so that the type // of the values can be checked -func loadChartFileForTypeCheck(filename string) (map[string]interface{}, error) { +func loadChartFileForTypeCheck(filename string) (map[string]any, error) { b, err := os.ReadFile(filename) if err != nil { return nil, err } - y := make(map[string]interface{}) + y := make(map[string]any) err = yaml.Unmarshal(b, &y) return y, err } diff --git a/pkg/helm/pkg/chart/v2/lint/rules/chartfile_test.go b/pkg/helm/pkg/chart/v2/lint/rules/chartfile_test.go index c1752a9c..77865b2e 100644 --- a/pkg/helm/pkg/chart/v2/lint/rules/chartfile_test.go +++ b/pkg/helm/pkg/chart/v2/lint/rules/chartfile_test.go @@ -50,26 +50,26 @@ func TestValidateChartYamlNotDirectory(t *testing.T) { err := validateChartYamlNotDirectory(nonExistingChartFilePath) if err == nil { - t.Errorf("validateChartYamlNotDirectory to return a linter error, got no error") + t.Error("validateChartYamlNotDirectory to return a linter error, got no error") } } func TestValidateChartYamlFormat(t *testing.T) { err := validateChartYamlFormat(errors.New("Read error")) if err == nil { - t.Errorf("validateChartYamlFormat to return a linter error, got no error") + t.Error("validateChartYamlFormat to return a linter error, got no error") } err = validateChartYamlFormat(nil) if err != nil { - t.Errorf("validateChartYamlFormat to return no error, got a linter error") + t.Error("validateChartYamlFormat to return no error, got a linter error") } } func TestValidateChartName(t *testing.T) { err := validateChartName(badChart) if err == nil { - t.Errorf("validateChartName to return a linter error, got no error") + t.Error("validateChartName to return a linter error, got no error") } err = validateChartName(badChartName) @@ -176,7 +176,7 @@ func TestValidateChartMaintainer(t *testing.T) { badChart.Maintainers = []*chart.Maintainer{nil} err := validateChartMaintainer(badChart) if err == nil { - t.Errorf("validateChartMaintainer did not return error for nil maintainer as expected") + t.Error("validateChartMaintainer did not return error for nil maintainer as expected") } if err.Error() != "a maintainer entry is empty" { t.Errorf("validateChartMaintainer returned unexpected error for nil maintainer: %s", err.Error()) @@ -212,7 +212,7 @@ func TestValidateChartIconPresence(t *testing.T) { err := validateChartIconPresence(testChart) if err == nil { - t.Errorf("validateChartIconPresence to return a linter error, got no error") + t.Error("validateChartIconPresence to return a linter error, got no error") } else if !strings.Contains(err.Error(), "icon is recommended") { t.Errorf("expected %q, got %q", "icon is recommended", err.Error()) } diff --git a/pkg/helm/pkg/chart/v2/lint/rules/crds.go b/pkg/helm/pkg/chart/v2/lint/rules/crds.go index 2c1c327f..a1fe1138 100644 --- a/pkg/helm/pkg/chart/v2/lint/rules/crds.go +++ b/pkg/helm/pkg/chart/v2/lint/rules/crds.go @@ -20,7 +20,6 @@ import ( "bytes" "context" "errors" - "fmt" "io" "io/fs" "os" @@ -103,14 +102,14 @@ func validateCrdsDir(crdsPath string) error { func validateCrdAPIVersion(obj *k8sYamlStruct) error { if !strings.HasPrefix(obj.APIVersion, "apiextensions.k8s.io") { - return fmt.Errorf("apiVersion is not in 'apiextensions.k8s.io'") + return errors.New("apiVersion is not in 'apiextensions.k8s.io'") } return nil } func validateCrdKind(obj *k8sYamlStruct) error { if obj.Kind != "CustomResourceDefinition" { - return fmt.Errorf("object kind is not 'CustomResourceDefinition'") + return errors.New("object kind is not 'CustomResourceDefinition'") } return nil } diff --git a/pkg/helm/pkg/chart/v2/lint/rules/dependencies_test.go b/pkg/helm/pkg/chart/v2/lint/rules/dependencies_test.go index 4122e7ce..5f379299 100644 --- a/pkg/helm/pkg/chart/v2/lint/rules/dependencies_test.go +++ b/pkg/helm/pkg/chart/v2/lint/rules/dependencies_test.go @@ -72,7 +72,7 @@ func TestValidateDependencyInMetadata(t *testing.T) { c := chartWithBadDependencies() if err := validateDependencyInMetadata(&c); err == nil { - t.Errorf("chart should have been flagged for missing deps in chart metadata") + t.Error("chart should have been flagged for missing deps in chart metadata") } } @@ -132,7 +132,7 @@ func TestValidateDependenciesUnique(t *testing.T) { for _, tt := range tests { if err := validateDependenciesUnique(&tt.chart); err == nil { - t.Errorf("chart should have been flagged for dependency shadowing") + t.Error("chart should have been flagged for dependency shadowing") } } } diff --git a/pkg/helm/pkg/chart/v2/lint/rules/deprecations_test.go b/pkg/helm/pkg/chart/v2/lint/rules/deprecations_test.go index dbba1bd1..2a254688 100644 --- a/pkg/helm/pkg/chart/v2/lint/rules/deprecations_test.go +++ b/pkg/helm/pkg/chart/v2/lint/rules/deprecations_test.go @@ -36,6 +36,6 @@ func TestValidateNoDeprecations(t *testing.T) { APIVersion: "v1", Kind: "Pod", }, nil); err != nil { - t.Errorf("Expected a v1 Pod to not be deprecated") + t.Error("Expected a v1 Pod to not be deprecated") } } diff --git a/pkg/helm/pkg/chart/v2/lint/rules/template_test.go b/pkg/helm/pkg/chart/v2/lint/rules/template_test.go index f757d6ca..bf5fa908 100644 --- a/pkg/helm/pkg/chart/v2/lint/rules/template_test.go +++ b/pkg/helm/pkg/chart/v2/lint/rules/template_test.go @@ -49,7 +49,7 @@ func TestValidateAllowedExtension(t *testing.T) { } } -var values = map[string]interface{}{"nameOverride": "", "httpPort": 80} +var values = map[string]any{"nameOverride": "", "httpPort": 80} const namespace = "testNamespace" @@ -264,7 +264,7 @@ func TestStrictTemplateParsingMapError(t *testing.T) { APIVersion: "v2", Version: "0.1.0", }, - Values: map[string]interface{}{ + Values: map[string]any{ "mymap": map[string]string{ "key1": "val1", }, diff --git a/pkg/helm/pkg/chart/v2/lint/rules/values.go b/pkg/helm/pkg/chart/v2/lint/rules/values.go index 320ee6c2..8a48fc5b 100644 --- a/pkg/helm/pkg/chart/v2/lint/rules/values.go +++ b/pkg/helm/pkg/chart/v2/lint/rules/values.go @@ -17,6 +17,7 @@ limitations under the License. package rules import ( + "errors" "fmt" "os" "path/filepath" @@ -32,7 +33,7 @@ import ( // they are only tested for well-formedness. // // If additional values are supplied, they are coalesced into the values in values.yaml. -func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]interface{}, skipSchemaValidation bool) { +func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]any, skipSchemaValidation bool) { file := "values.yaml" vf := filepath.Join(linter.ChartDir, file) fileExists := linter.RunLinterRule(support.InfoSev, file, validateValuesFileExistence(vf)) @@ -47,12 +48,12 @@ func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]inter func validateValuesFileExistence(valuesPath string) error { _, err := os.Stat(valuesPath) if err != nil { - return fmt.Errorf("file does not exist") + return errors.New("file does not exist") } return nil } -func validateValuesFile(valuesPath string, overrides map[string]interface{}, skipSchemaValidation bool) error { +func validateValuesFile(valuesPath string, overrides map[string]any, skipSchemaValidation bool) error { values, err := common.ReadValuesFile(valuesPath) if err != nil { return fmt.Errorf("unable to parse YAML: %w", err) @@ -63,7 +64,7 @@ func validateValuesFile(valuesPath string, overrides map[string]interface{}, ski // We could change that. For now, though, we retain that strategy, and thus can // coalesce tables (like reuse-values does) instead of doing the full chart // CoalesceValues - coalescedValues := util.CoalesceTables(make(map[string]interface{}, len(overrides)), overrides) + coalescedValues := util.CoalesceTables(make(map[string]any, len(overrides)), overrides) coalescedValues = util.CoalesceTables(coalescedValues, values) ext := filepath.Ext(valuesPath) diff --git a/pkg/helm/pkg/chart/v2/lint/rules/values_test.go b/pkg/helm/pkg/chart/v2/lint/rules/values_test.go index a2a5345d..189a9d32 100644 --- a/pkg/helm/pkg/chart/v2/lint/rules/values_test.go +++ b/pkg/helm/pkg/chart/v2/lint/rules/values_test.go @@ -57,7 +57,7 @@ func TestValidateValuesYamlNotDirectory(t *testing.T) { err := validateValuesFileExistence(nonExistingValuesFilePath) if err == nil { - t.Errorf("validateValuesFileExistence to return a linter error, got no error") + t.Error("validateValuesFileExistence to return a linter error, got no error") } } @@ -67,7 +67,7 @@ func TestValidateValuesFileWellFormed(t *testing.T) { ` tmpdir := ensure.TempFile(t, "values.yaml", []byte(badYaml)) valfile := filepath.Join(tmpdir, "values.yaml") - if err := validateValuesFile(valfile, map[string]interface{}{}, false); err == nil { + if err := validateValuesFile(valfile, map[string]any{}, false); err == nil { t.Fatal("expected values file to fail parsing") } } @@ -78,7 +78,7 @@ func TestValidateValuesFileSchema(t *testing.T) { createTestingSchema(t, tmpdir) valfile := filepath.Join(tmpdir, "values.yaml") - if err := validateValuesFile(valfile, map[string]interface{}{}, false); err != nil { + if err := validateValuesFile(valfile, map[string]any{}, false); err != nil { t.Fatalf("Failed validation with %s", err) } } @@ -91,7 +91,7 @@ func TestValidateValuesFileSchemaFailure(t *testing.T) { valfile := filepath.Join(tmpdir, "values.yaml") - err := validateValuesFile(valfile, map[string]interface{}{}, false) + err := validateValuesFile(valfile, map[string]any{}, false) if err == nil { t.Fatal("expected values file to fail parsing") } @@ -107,7 +107,7 @@ func TestValidateValuesFileSchemaFailureButWithSkipSchemaValidation(t *testing.T valfile := filepath.Join(tmpdir, "values.yaml") - err := validateValuesFile(valfile, map[string]interface{}{}, true) + err := validateValuesFile(valfile, map[string]any{}, true) if err != nil { t.Fatal("expected values file to pass parsing because of skipSchemaValidation") } @@ -115,7 +115,7 @@ func TestValidateValuesFileSchemaFailureButWithSkipSchemaValidation(t *testing.T func TestValidateValuesFileSchemaOverrides(t *testing.T) { yaml := "username: admin" - overrides := map[string]interface{}{ + overrides := map[string]any{ "password": "swordfish", } tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) @@ -131,24 +131,24 @@ func TestValidateValuesFile(t *testing.T) { tests := []struct { name string yaml string - overrides map[string]interface{} + overrides map[string]any errorMessage string }{ { name: "value added", yaml: "username: admin", - overrides: map[string]interface{}{"password": "swordfish"}, + overrides: map[string]any{"password": "swordfish"}, }, { name: "value not overridden", yaml: "username: admin\npassword:", - overrides: map[string]interface{}{"username": "anotherUser"}, + overrides: map[string]any{"username": "anotherUser"}, errorMessage: "- at '/password': got null, want string", }, { name: "value overridden", yaml: "username: admin\npassword:", - overrides: map[string]interface{}{"username": "anotherUser", "password": "swordfish"}, + overrides: map[string]any{"username": "anotherUser", "password": "swordfish"}, }, } diff --git a/pkg/helm/pkg/chart/v2/loader/load.go b/pkg/helm/pkg/chart/v2/loader/load.go index e751510e..ed51bdfa 100644 --- a/pkg/helm/pkg/chart/v2/loader/load.go +++ b/pkg/helm/pkg/chart/v2/loader/load.go @@ -132,7 +132,7 @@ func LoadFiles(ctx context.Context, files []*archive.BufferedFile) (*chart.Chart c.Metadata = new(chart.Metadata) } if c.Metadata.APIVersion != chart.APIVersionV1 { - log.Printf("Warning: Dependencies are handled in Chart.yaml since apiVersion \"v2\". We recommend migrating dependencies to Chart.yaml.") + log.Print("Warning: Dependencies are handled in Chart.yaml since apiVersion \"v2\". We recommend migrating dependencies to Chart.yaml.") } if err := yaml.Unmarshal(f.Data, c.Metadata); err != nil { return c, fmt.Errorf("cannot load requirements.yaml: %w", err) @@ -150,7 +150,7 @@ func LoadFiles(ctx context.Context, files []*archive.BufferedFile) (*chart.Chart c.Metadata = new(chart.Metadata) } if c.Metadata.APIVersion != chart.APIVersionV1 { - log.Printf("Warning: Dependency locking is handled in Chart.lock since apiVersion \"v2\". We recommend migrating to Chart.lock.") + log.Print("Warning: Dependency locking is handled in Chart.lock since apiVersion \"v2\". We recommend migrating to Chart.lock.") } if c.Metadata.APIVersion == chart.APIVersionV1 { c.Files = append(c.Files, &chartcommon.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data}) @@ -340,11 +340,11 @@ func convertBufferedFiles(files []*archive.BufferedFile) []*nelmcommon.BufferedF // // The reader is expected to contain one or more YAML documents, the values of which are merged. // And the values can be either a chart's default values or user-supplied values. -func LoadValues(data io.Reader) (map[string]interface{}, error) { - values := map[string]interface{}{} +func LoadValues(data io.Reader) (map[string]any, error) { + values := map[string]any{} reader := utilyaml.NewYAMLReader(bufio.NewReader(data)) for { - currentMap := map[string]interface{}{} + currentMap := map[string]any{} raw, err := reader.Read() if err != nil { if errors.Is(err, io.EOF) { @@ -362,13 +362,13 @@ func LoadValues(data io.Reader) (map[string]interface{}, error) { // MergeMaps merges two maps. If a key exists in both maps, the value from b will be used. // If the value is a map, the maps will be merged recursively. -func MergeMaps(a, b map[string]interface{}) map[string]interface{} { - out := make(map[string]interface{}, len(a)) +func MergeMaps(a, b map[string]any) map[string]any { + out := make(map[string]any, len(a)) maps.Copy(out, a) for k, v := range b { - if v, ok := v.(map[string]interface{}); ok { + if v, ok := v.(map[string]any); ok { if bv, ok := out[k]; ok { - if bv, ok := bv.(map[string]interface{}); ok { + if bv, ok := bv.(map[string]any); ok { out[k] = MergeMaps(bv, v) continue } diff --git a/pkg/helm/pkg/chart/v2/loader/load_test.go b/pkg/helm/pkg/chart/v2/loader/load_test.go index b7c38851..d19167ae 100644 --- a/pkg/helm/pkg/chart/v2/loader/load_test.go +++ b/pkg/helm/pkg/chart/v2/loader/load_test.go @@ -62,7 +62,7 @@ func TestLoadDirWithDevNull(t *testing.T) { t.Fatalf("Failed to load testdata: %s", err) } if _, err := l.Load(context.Background()); err == nil { - t.Errorf("packages with an irregular file (/dev/null) should not load") + t.Error("packages with an irregular file (/dev/null) should not load") } } @@ -509,7 +509,7 @@ func TestLoadInvalidArchive(t *testing.T) { func TestLoadValues(t *testing.T) { testCases := map[string]struct { data []byte - expctedValues map[string]interface{} + expctedValues map[string]any }{ "It should load values correctly": { data: []byte(` @@ -518,11 +518,11 @@ foo: bar: version: v2 `), - expctedValues: map[string]interface{}{ - "foo": map[string]interface{}{ + expctedValues: map[string]any{ + "foo": map[string]any{ "image": "foo:v1", }, - "bar": map[string]interface{}{ + "bar": map[string]any{ "version": "v2", }, }, @@ -537,11 +537,11 @@ bar: foo: image: foo:v2 `), - expctedValues: map[string]interface{}{ - "foo": map[string]interface{}{ + expctedValues: map[string]any{ + "foo": map[string]any{ "image": "foo:v2", }, - "bar": map[string]interface{}{ + "bar": map[string]any{ "version": "v2", }, }, @@ -561,24 +561,24 @@ foo: } func TestMergeValuesV2(t *testing.T) { - nestedMap := map[string]interface{}{ + nestedMap := map[string]any{ "foo": "bar", "baz": map[string]string{ "cool": "stuff", }, } - anotherNestedMap := map[string]interface{}{ + anotherNestedMap := map[string]any{ "foo": "bar", "baz": map[string]string{ "cool": "things", "awesome": "stuff", }, } - flatMap := map[string]interface{}{ + flatMap := map[string]any{ "foo": "bar", "baz": "stuff", } - anotherFlatMap := map[string]interface{}{ + anotherFlatMap := map[string]any{ "testing": "fun", } @@ -601,7 +601,7 @@ func TestMergeValuesV2(t *testing.T) { } testMap = MergeMaps(anotherFlatMap, anotherNestedMap) - expectedMap := map[string]interface{}{ + expectedMap := map[string]any{ "testing": "fun", "foo": "bar", "baz": map[string]string{ diff --git a/pkg/helm/pkg/chart/v2/metadata.go b/pkg/helm/pkg/chart/v2/metadata.go index c4600786..5df30732 100644 --- a/pkg/helm/pkg/chart/v2/metadata.go +++ b/pkg/helm/pkg/chart/v2/metadata.go @@ -112,6 +112,9 @@ func (md *Metadata) Validate() error { return ValidationError("chart.metadata.name is required") } + if md.Name == "." || md.Name == ".." { + return ValidationErrorf("chart.metadata.name %q is not allowed", md.Name) + } if md.Name != filepath.Base(md.Name) { return ValidationErrorf("chart.metadata.name %q is invalid", md.Name) } diff --git a/pkg/helm/pkg/chart/v2/metadata_test.go b/pkg/helm/pkg/chart/v2/metadata_test.go index 7892f020..63dd99e5 100644 --- a/pkg/helm/pkg/chart/v2/metadata_test.go +++ b/pkg/helm/pkg/chart/v2/metadata_test.go @@ -16,6 +16,7 @@ limitations under the License. package v2 import ( + "errors" "testing" ) @@ -40,6 +41,16 @@ func TestValidate(t *testing.T) { &Metadata{APIVersion: "v2", Version: "1.0"}, ValidationError("chart.metadata.name is required"), }, + { + "chart with dot name", + &Metadata{Name: ".", APIVersion: "v2", Version: "1.0"}, + ValidationError("chart.metadata.name \".\" is not allowed"), + }, + { + "chart with dotdot name", + &Metadata{Name: "..", APIVersion: "v2", Version: "1.0"}, + ValidationError("chart.metadata.name \"..\" is not allowed"), + }, { "chart without name", &Metadata{Name: "../../test", APIVersion: "v2", Version: "1.0"}, @@ -181,7 +192,7 @@ func TestValidate(t *testing.T) { for _, tt := range tests { result := tt.md.Validate() - if result != tt.err { + if !errors.Is(result, tt.err) { t.Errorf("expected %q, got %q in test %q", tt.err, result, tt.name) } } diff --git a/pkg/helm/pkg/chart/v2/util/chartfile_test.go b/pkg/helm/pkg/chart/v2/util/chartfile_test.go index e7c4790d..59ab003b 100644 --- a/pkg/helm/pkg/chart/v2/util/chartfile_test.go +++ b/pkg/helm/pkg/chart/v2/util/chartfile_test.go @@ -35,11 +35,11 @@ func TestLoadChartfile(t *testing.T) { func verifyChartfile(t *testing.T, f *chart.Metadata, name string) { t.Helper() - if f == nil { //nolint:staticcheck + if f == nil { t.Fatal("Failed verifyChartfile because f is nil") } - if f.APIVersion != chart.APIVersionV1 { //nolint:staticcheck + if f.APIVersion != chart.APIVersionV1 { t.Errorf("Expected API Version %q, got %q", chart.APIVersionV1, f.APIVersion) } @@ -60,15 +60,15 @@ func verifyChartfile(t *testing.T, f *chart.Metadata, name string) { } if f.Maintainers[0].Name != "The Helm Team" { - t.Errorf("Unexpected maintainer name.") + t.Error("Unexpected maintainer name.") } if f.Maintainers[1].Email != "nobody@example.com" { - t.Errorf("Unexpected maintainer email.") + t.Error("Unexpected maintainer email.") } if len(f.Sources) != 1 { - t.Fatalf("Unexpected number of sources") + t.Fatal("Unexpected number of sources") } if f.Sources[0] != "https://example.com/foo/bar" { @@ -88,7 +88,7 @@ func verifyChartfile(t *testing.T, f *chart.Metadata, name string) { } if len(f.Annotations) != 2 { - t.Fatalf("Unexpected annotations") + t.Fatal("Unexpected annotations") } if want, got := "extravalue", f.Annotations["extrakey"]; want != got { @@ -115,7 +115,7 @@ func TestIsChartDir(t *testing.T) { } validChartDir, err = IsChartDir("testdata") if validChartDir || err == nil { - t.Errorf("expected error but did not get any") + t.Error("expected error but did not get any") return } } diff --git a/pkg/helm/pkg/chart/v2/util/create.go b/pkg/helm/pkg/chart/v2/util/create.go index a850b3a9..e2bd71d6 100644 --- a/pkg/helm/pkg/chart/v2/util/create.go +++ b/pkg/helm/pkg/chart/v2/util/create.go @@ -670,7 +670,7 @@ func CreateFrom(chartfile *chart.Metadata, dest, src string) error { return fmt.Errorf("reading values file: %w", err) } - var m map[string]interface{} + var m map[string]any if err := yaml.Unmarshal(transform(string(b), schart.Name()), &m); err != nil { return fmt.Errorf("transforming values file: %w", err) } diff --git a/pkg/helm/pkg/chart/v2/util/create_test.go b/pkg/helm/pkg/chart/v2/util/create_test.go index 34bf6951..5fbc9114 100644 --- a/pkg/helm/pkg/chart/v2/util/create_test.go +++ b/pkg/helm/pkg/chart/v2/util/create_test.go @@ -17,8 +17,8 @@ limitations under the License. package util import ( - "context" "bytes" + "context" "os" "path/filepath" "testing" @@ -141,7 +141,7 @@ func TestCreate_Overwrite(t *testing.T) { } if errlog.Len() == 0 { - t.Errorf("Expected warnings about overwriting files.") + t.Error("Expected warnings about overwriting files.") } } diff --git a/pkg/helm/pkg/chart/v2/util/dependencies.go b/pkg/helm/pkg/chart/v2/util/dependencies.go index 6eb8881e..fbcad12a 100644 --- a/pkg/helm/pkg/chart/v2/util/dependencies.go +++ b/pkg/helm/pkg/chart/v2/util/dependencies.go @@ -55,6 +55,7 @@ func processDependencyConditions(reqs []*chart.Dependency, cvals common.Values, if len(c) > 0 { // retrieve value vv, err := cvals.PathValue(cpath + c) + var errNoValue common.ErrNoValue if err == nil { // if not bool, warn if bv, ok := vv.(bool); ok { @@ -62,7 +63,7 @@ func processDependencyConditions(reqs []*chart.Dependency, cvals common.Values, break } slog.Warn("returned non-bool value", "path", c, "chart", r.Name) - } else if _, ok := err.(common.ErrNoValue); !ok { + } else if !errors.As(err, &errNoValue) { // this is a real error slog.Warn("the method PathValue returned error", slog.Any("error", err)) } @@ -151,7 +152,7 @@ func copyMetadata(metadata *chart.Metadata) *chart.Metadata { } // processDependencyEnabled removes disabled charts from dependencies -func processDependencyEnabled(c *chart.Chart, v map[string]interface{}, path string) error { +func processDependencyEnabled(c *chart.Chart, v map[string]any, path string) error { if c.Metadata.Dependencies == nil { return nil } @@ -237,7 +238,7 @@ Loop: } // pathToMap creates a nested map given a YAML path in dot notation. -func pathToMap(path string, data map[string]interface{}) map[string]interface{} { +func pathToMap(path string, data map[string]any) map[string]any { if path == "." { return data } @@ -246,13 +247,13 @@ func pathToMap(path string, data map[string]interface{}) map[string]interface{} func parsePath(key string) []string { return strings.Split(key, ".") } -func set(path []string, data map[string]interface{}) map[string]interface{} { +func set(path []string, data map[string]any) map[string]any { if len(path) == 0 { return nil } cur := data for i := len(path) - 1; i >= 0; i-- { - cur = map[string]interface{}{path[i]: cur} + cur = map[string]any{path[i]: cur} } return cur } @@ -273,13 +274,13 @@ func processImportValues(c *chart.Chart, merge bool) error { if err != nil { return err } - b := make(map[string]interface{}) + b := make(map[string]any) // import values from each dependency if specified in import-values for _, r := range c.Metadata.Dependencies { - var outiv []interface{} + var outiv []any for _, riv := range r.ImportValues { switch iv := riv.(type) { - case map[string]interface{}: + case map[string]any: child := fmt.Sprintf("%v", iv["child"]) parent := fmt.Sprintf("%v", iv["parent"]) @@ -346,27 +347,27 @@ func processImportValues(c *chart.Chart, merge bool) error { return nil } -func deepCopyMap(vals map[string]interface{}) map[string]interface{} { +func deepCopyMap(vals map[string]any) map[string]any { valsCopy, err := copystructure.Copy(vals) if err != nil { return vals } - return valsCopy.(map[string]interface{}) + return valsCopy.(map[string]any) } -func trimNilValues(vals map[string]interface{}) map[string]interface{} { +func trimNilValues(vals map[string]any) map[string]any { valsCopy, err := copystructure.Copy(vals) if err != nil { return vals } - valsCopyMap := valsCopy.(map[string]interface{}) + valsCopyMap := valsCopy.(map[string]any) for key, val := range valsCopyMap { if val == nil { // Iterate over the values and remove nil keys delete(valsCopyMap, key) } else if istable(val) { // Recursively call into ourselves to remove keys from inner tables - valsCopyMap[key] = trimNilValues(val.(map[string]interface{})) + valsCopyMap[key] = trimNilValues(val.(map[string]any)) } } @@ -374,8 +375,8 @@ func trimNilValues(vals map[string]interface{}) map[string]interface{} { } // istable is a special-purpose function to see if the present thing matches the definition of a YAML table. -func istable(v interface{}) bool { - _, ok := v.(map[string]interface{}) +func istable(v any) bool { + _, ok := v.(map[string]any) return ok } diff --git a/pkg/helm/pkg/chart/v2/util/dependencies_test.go b/pkg/helm/pkg/chart/v2/util/dependencies_test.go index dfd9b8af..7431db7c 100644 --- a/pkg/helm/pkg/chart/v2/util/dependencies_test.go +++ b/pkg/helm/pkg/chart/v2/util/dependencies_test.go @@ -64,7 +64,7 @@ func TestLoadDependency(t *testing.T) { } func TestDependencyEnabled(t *testing.T) { - type M = map[string]interface{} + type M = map[string]any tests := []struct { name string v M @@ -386,7 +386,7 @@ func TestGetAliasDependency(t *testing.T) { req := c.Metadata.Dependencies if len(req) == 0 { - t.Fatalf("there are no dependencies to test") + t.Fatal("there are no dependencies to test") } // Success case @@ -404,7 +404,7 @@ func TestGetAliasDependency(t *testing.T) { if req[0].Version != "" { if !IsCompatibleRange(req[0].Version, aliasChart.Metadata.Version) { - t.Fatalf("dependency chart version is not in the compatible range") + t.Fatal("dependency chart version is not in the compatible range") } } @@ -416,7 +416,7 @@ func TestGetAliasDependency(t *testing.T) { req[0].Version = "something else which is not in the compatible range" if IsCompatibleRange(req[0].Version, aliasChart.Metadata.Version) { - t.Fatalf("dependency chart version which is not in the compatible range should cause a failure other than a success ") + t.Fatal("dependency chart version outside the compatible range should fail, but it succeeded") } } diff --git a/pkg/helm/pkg/chart/v2/util/expand.go b/pkg/helm/pkg/chart/v2/util/expand.go index 3537867a..860dae09 100644 --- a/pkg/helm/pkg/chart/v2/util/expand.go +++ b/pkg/helm/pkg/chart/v2/util/expand.go @@ -52,6 +52,17 @@ func Expand(dir string, r io.Reader) error { return errors.New("chart name not specified") } + // Reject chart names that are POSIX path dot-segments or dot-dot segments or contain path separators. + // A dot-segment name (e.g. ".") causes SecureJoin to resolve to the root + // directory and extraction then to write files directly into that extraction root + // instead of a per-chart subdirectory. + if chartName == "." || chartName == ".." { + return fmt.Errorf("chart name %q is not allowed", chartName) + } + if chartName != filepath.Base(chartName) { + return fmt.Errorf("chart name %q must not contain path separators", chartName) + } + // Find the base directory // The directory needs to be cleaned prior to passing to SecureJoin or the location may end up // being wrong or returning an error. This was introduced in v0.4.0. @@ -61,6 +72,12 @@ func Expand(dir string, r io.Reader) error { return err } + // Defense-in-depth: the chart directory must be a subdirectory of dir, + // never dir itself. + if chartdir == dir { + return fmt.Errorf("chart name %q resolves to the extraction root", chartName) + } + // Copy all files verbatim. We don't parse these files because parsing can remove // comments. for _, file := range files { diff --git a/pkg/helm/pkg/chart/v2/util/expand_test.go b/pkg/helm/pkg/chart/v2/util/expand_test.go index 280995f7..e9e298b8 100644 --- a/pkg/helm/pkg/chart/v2/util/expand_test.go +++ b/pkg/helm/pkg/chart/v2/util/expand_test.go @@ -17,11 +17,73 @@ limitations under the License. package util import ( + "archive/tar" + "bytes" + "compress/gzip" + "io/fs" "os" "path/filepath" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +// makeTestChartArchive builds a gzipped tar archive from the given sourceDir directory, file entries are prefixed with the given chartName +func makeTestChartArchive(t *testing.T, chartName, sourceDir string) *bytes.Buffer { + t.Helper() + + var result bytes.Buffer + gw := gzip.NewWriter(&result) + tw := tar.NewWriter(gw) + + dir := os.DirFS(sourceDir) + + writeFile := func(relPath string) { + t.Helper() + f, err := dir.Open(relPath) + require.NoError(t, err) + + fStat, err := f.Stat() + require.NoError(t, err) + + err = tw.WriteHeader(&tar.Header{ + Name: filepath.Join(chartName, relPath), + Mode: int64(fStat.Mode()), + Size: fStat.Size(), + }) + require.NoError(t, err) + + data, err := fs.ReadFile(dir, relPath) + require.NoError(t, err) + tw.Write(data) + } + + err := fs.WalkDir(dir, ".", func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + if d.IsDir() { + return nil + } + + writeFile(path) + + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = tw.Close() + require.NoError(t, err) + err = gw.Close() + require.NoError(t, err) + + return &result +} + func TestExpand(t *testing.T) { dest := t.TempDir() @@ -75,6 +137,28 @@ func TestExpand(t *testing.T) { } } +func TestExpandError(t *testing.T) { + tests := map[string]struct { + chartName string + chartDir string + wantErr string + }{ + "dot name": {"dotname", "testdata/dotname", "not allowed"}, + "dotdot name": {"dotdotname", "testdata/dotdotname", "not allowed"}, + "slash in name": {"slashinname", "testdata/slashinname", "must not contain path separators"}, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + archive := makeTestChartArchive(t, tt.chartName, tt.chartDir) + + dest := t.TempDir() + err := Expand(dest, archive) + assert.ErrorContains(t, err, tt.wantErr) + }) + } +} + func TestExpandFile(t *testing.T) { dest := t.TempDir() diff --git a/pkg/helm/pkg/chart/v2/util/save_test.go b/pkg/helm/pkg/chart/v2/util/save_test.go index a1335daa..2f6d8124 100644 --- a/pkg/helm/pkg/chart/v2/util/save_test.go +++ b/pkg/helm/pkg/chart/v2/util/save_test.go @@ -17,13 +17,13 @@ limitations under the License. package util import ( - "context" "archive/tar" "bytes" "compress/gzip" + "context" "crypto/sha256" + "encoding/hex" "errors" - "fmt" "io" "os" "path" @@ -91,7 +91,7 @@ func TestSave(t *testing.T) { t.Fatalf("Schema data did not match.\nExpected:\n%s\nActual:\n%s", formattedExpected, formattedActual) } if _, err := Save(&chartWithInvalidJSON, dest); err == nil { - t.Fatalf("Invalid JSON was not caught while saving chart") + t.Fatal("Invalid JSON was not caught while saving chart") } c.Metadata.APIVersion = chart.APIVersionV2 @@ -158,7 +158,7 @@ func TestSavePreservesTimestamps(t *testing.T) { Version: "1.2.3", }, ModTime: initialCreateTime, - Values: map[string]interface{}{ + Values: map[string]any{ "imageName": "testimage", "imageId": 42, }, @@ -243,7 +243,7 @@ func TestSaveDir(t *testing.T) { t.Fatalf("Failed to save: %s", err) } - c2, err := loader.LoadDir(context.Background(), tmp + "/ahab") + c2, err := loader.LoadDir(context.Background(), tmp+"/ahab") if err != nil { t.Fatal(err) } @@ -358,5 +358,5 @@ func sha256Sum(filePath string) (string, error) { return "", err } - return fmt.Sprintf("%x", h.Sum(nil)), nil + return hex.EncodeToString(h.Sum(nil)), nil } diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dotdotname/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dotdotname/Chart.yaml new file mode 100644 index 00000000..9b081f27 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dotdotname/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v3 +name: .. +description: A Helm chart for Kubernetes +version: 0.1.0 \ No newline at end of file diff --git a/pkg/helm/pkg/chart/v2/util/testdata/dotname/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/dotname/Chart.yaml new file mode 100644 index 00000000..597c1629 --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/dotname/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v3 +name: . +description: A Helm chart for Kubernetes +version: 0.1.0 \ No newline at end of file diff --git a/pkg/helm/pkg/chart/v2/util/testdata/slashinname/Chart.yaml b/pkg/helm/pkg/chart/v2/util/testdata/slashinname/Chart.yaml new file mode 100644 index 00000000..0c522a4b --- /dev/null +++ b/pkg/helm/pkg/chart/v2/util/testdata/slashinname/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v3 +name: a/../b +description: A Helm chart for Kubernetes +version: 0.1.0 \ No newline at end of file diff --git a/pkg/helm/pkg/cli/environment.go b/pkg/helm/pkg/cli/environment.go index 679abfed..3aba2761 100644 --- a/pkg/helm/pkg/cli/environment.go +++ b/pkg/helm/pkg/cli/environment.go @@ -24,7 +24,6 @@ These dependencies are expressed as interfaces so that alternate implementations package cli import ( - "fmt" "net/http" "os" "strconv" @@ -36,7 +35,7 @@ import ( "github.com/werf/nelm/pkg/helm/intern/version" "github.com/werf/nelm/pkg/helm/pkg/helmpath" - "github.com/werf/nelm/pkg/helm/pkg/kube" + "github.com/werf/nelm/pkg/helm/pkg/kubeenv" ) // defaultMaxHistory sets the maximum number of releases to 0: unlimited @@ -135,7 +134,7 @@ func New() *EnvSettings { config.Burst = env.BurstLimit config.QPS = env.QPS config.Wrap(func(rt http.RoundTripper) http.RoundTripper { - return &kube.RetryingRoundTripper{Wrapped: rt} + return &kubeenv.RetryingRoundTripper{Wrapped: rt} }) config.UserAgent = version.GetUserAgent() return config @@ -246,7 +245,7 @@ func (s *EnvSettings) EnvVars() map[string]string { "HELM_CACHE_HOME": helmpath.CachePath(""), "HELM_CONFIG_HOME": helmpath.ConfigPath(""), "HELM_DATA_HOME": helmpath.DataPath(""), - "HELM_DEBUG": fmt.Sprint(s.Debug), + "HELM_DEBUG": strconv.FormatBool(s.Debug), "HELM_PLUGINS": s.PluginsDirectory, "HELM_REGISTRY_CONFIG": s.RegistryConfig, "HELM_REPOSITORY_CACHE": s.RepositoryCache, diff --git a/pkg/helm/pkg/cli/output/output.go b/pkg/helm/pkg/cli/output/output.go index 28d50374..a9bd846f 100644 --- a/pkg/helm/pkg/cli/output/output.go +++ b/pkg/helm/pkg/cli/output/output.go @@ -18,6 +18,7 @@ package output import ( "encoding/json" + "errors" "fmt" "io" @@ -50,7 +51,7 @@ func FormatsWithDesc() map[string]string { } // ErrInvalidFormatType is returned when an unsupported format type is used -var ErrInvalidFormatType = fmt.Errorf("invalid format type") +var ErrInvalidFormatType = errors.New("invalid format type") // String returns the string representation of the Format func (o Format) String() string { @@ -102,7 +103,7 @@ type Writer interface { // EncodeJSON is a helper function to decorate any error message with a bit more // context and avoid writing the same code over and over for printers. -func EncodeJSON(out io.Writer, obj interface{}) error { +func EncodeJSON(out io.Writer, obj any) error { enc := json.NewEncoder(out) err := enc.Encode(obj) if err != nil { @@ -113,7 +114,7 @@ func EncodeJSON(out io.Writer, obj interface{}) error { // EncodeYAML is a helper function to decorate any error message with a bit more // context and avoid writing the same code over and over for printers -func EncodeYAML(out io.Writer, obj interface{}) error { +func EncodeYAML(out io.Writer, obj any) error { raw, err := yaml.Marshal(obj) if err != nil { return fmt.Errorf("unable to write YAML output: %w", err) diff --git a/pkg/helm/pkg/cli/values/options.go b/pkg/helm/pkg/cli/values/options.go index 1c99960f..ce7d667e 100644 --- a/pkg/helm/pkg/cli/values/options.go +++ b/pkg/helm/pkg/cli/values/options.go @@ -44,8 +44,8 @@ type Options struct { // MergeValues merges values from files specified via -f/--values and directly // via --set-json, --set, --set-string, or --set-file, marshaling them to YAML -func (opts *Options) MergeValues(ctx context.Context, p getter.Providers) (map[string]interface{}, error) { - base := map[string]interface{}{} +func (opts *Options) MergeValues(ctx context.Context, p getter.Providers) (map[string]any, error) { + base := map[string]any{} ho := common.HelmOptionsFromContext(ctx) @@ -75,7 +75,7 @@ func (opts *Options) MergeValues(ctx context.Context, p getter.Providers) (map[s trimmedValue := strings.TrimSpace(value) if len(trimmedValue) > 0 && trimmedValue[0] == '{' { // If value is JSON object format, parse it as map - var jsonMap map[string]interface{} + var jsonMap map[string]any if err := json.Unmarshal([]byte(trimmedValue), &jsonMap); err != nil { return nil, fmt.Errorf("failed parsing --set-json data JSON: %s", value) } @@ -104,7 +104,7 @@ func (opts *Options) MergeValues(ctx context.Context, p getter.Providers) (map[s // User specified a value via --set-file for _, value := range opts.FileValues { - reader := func(rs []rune) (interface{}, error) { + reader := func(rs []rune) (any, error) { var bytes []byte var err error if ho.ChartLoadOpts.ChartType == common.LegacyChartTypeChart && common.ChartFileReader != nil { diff --git a/pkg/helm/pkg/cli/values/options_test.go b/pkg/helm/pkg/cli/values/options_test.go index 5f036287..866609c9 100644 --- a/pkg/helm/pkg/cli/values/options_test.go +++ b/pkg/helm/pkg/cli/values/options_test.go @@ -20,7 +20,6 @@ import ( "bytes" "context" "errors" - "fmt" "os" "path/filepath" "reflect" @@ -266,7 +265,7 @@ func TestReadFileErrorMessages(t *testing.T) { { name: "getter error with message", filePath: "http://example.com/file", - providers: getter.Providers{mockProvider([]string{"http"}, nil, fmt.Errorf("connection refused"))}, + providers: getter.Providers{mockProvider([]string{"http"}, nil, errors.New("connection refused"))}, wantErr: "connection refused", }, } @@ -291,7 +290,7 @@ func TestReadFileOriginal(t *testing.T) { filePath := "%a.txt" _, err := readFile(filePath, p) if err == nil { - t.Errorf("Expected error when has special strings") + t.Error("Expected error when has special strings") } } @@ -299,7 +298,7 @@ func TestMergeValuesCLI(t *testing.T) { tests := []struct { name string opts Options - expected map[string]interface{} + expected map[string]any wantErr bool }{ { @@ -307,8 +306,8 @@ func TestMergeValuesCLI(t *testing.T) { opts: Options{ JSONValues: []string{`{"foo": {"bar": "baz"}}`}, }, - expected: map[string]interface{}{ - "foo": map[string]interface{}{ + expected: map[string]any{ + "foo": map[string]any{ "bar": "baz", }, }, @@ -318,9 +317,9 @@ func TestMergeValuesCLI(t *testing.T) { opts: Options{ JSONValues: []string{"foo.bar=[1,2,3]"}, }, - expected: map[string]interface{}{ - "foo": map[string]interface{}{ - "bar": []interface{}{1.0, 2.0, 3.0}, + expected: map[string]any{ + "foo": map[string]any{ + "bar": []any{1.0, 2.0, 3.0}, }, }, }, @@ -329,7 +328,7 @@ func TestMergeValuesCLI(t *testing.T) { opts: Options{ Values: []string{"foo=bar"}, }, - expected: map[string]interface{}{ + expected: map[string]any{ "foo": "bar", }, }, @@ -338,7 +337,7 @@ func TestMergeValuesCLI(t *testing.T) { opts: Options{ StringValues: []string{"foo=123"}, }, - expected: map[string]interface{}{ + expected: map[string]any{ "foo": "123", }, }, @@ -347,7 +346,7 @@ func TestMergeValuesCLI(t *testing.T) { opts: Options{ LiteralValues: []string{"foo=true"}, }, - expected: map[string]interface{}{ + expected: map[string]any{ "foo": "true", }, }, @@ -359,7 +358,7 @@ func TestMergeValuesCLI(t *testing.T) { JSONValues: []string{`{"c": "foo1"}`}, LiteralValues: []string{"d=bar1"}, }, - expected: map[string]interface{}{ + expected: map[string]any{ "a": "foo", "b": "bar", "c": "foo1", diff --git a/pkg/helm/pkg/cmd/completion.go b/pkg/helm/pkg/cmd/completion.go index abf4f18a..7cc2ef21 100644 --- a/pkg/helm/pkg/cmd/completion.go +++ b/pkg/helm/pkg/cmd/completion.go @@ -193,7 +193,7 @@ compdef _helm %[1]s } // Cobra doesn't source zsh completion file, explicitly doing it here - fmt.Fprintf(out, "compdef _helm helm") + fmt.Fprint(out, "compdef _helm helm") return err } diff --git a/pkg/helm/pkg/cmd/create.go b/pkg/helm/pkg/cmd/create.go index 8b014474..8cb7e2e5 100644 --- a/pkg/helm/pkg/cmd/create.go +++ b/pkg/helm/pkg/cmd/create.go @@ -23,6 +23,9 @@ import ( "github.com/spf13/cobra" + chartv3 "github.com/werf/nelm/pkg/helm/intern/chart/v3" + chartutilv3 "github.com/werf/nelm/pkg/helm/intern/chart/v3/util" + "github.com/werf/nelm/pkg/helm/intern/gates" chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" chartutil "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" "github.com/werf/nelm/pkg/helm/pkg/cmd/require" @@ -51,9 +54,10 @@ will be overwritten, but other files will be left alone. ` type createOptions struct { - starter string // --starter - name string - starterDir string + starter string // --starter + name string + starterDir string + chartAPIVersion string // --chart-api-version } func newCreateCmd(out io.Writer) *cobra.Command { @@ -81,12 +85,32 @@ func newCreateCmd(out io.Writer) *cobra.Command { } cmd.Flags().StringVarP(&o.starter, "starter", "p", "", "the name or absolute path to Helm starter scaffold") + cmd.Flags().StringVar(&o.chartAPIVersion, "chart-api-version", chart.APIVersionV2, "chart API version to use (v2 or v3)") + + if !gates.ChartV3.IsEnabled() { + cmd.Flags().MarkHidden("chart-api-version") + } + return cmd } func (o *createOptions) run(out io.Writer) error { fmt.Fprintf(out, "Creating %s\n", o.name) + switch o.chartAPIVersion { + case chart.APIVersionV2, "": + return o.createV2Chart(out) + case chartv3.APIVersionV3: + if !gates.ChartV3.IsEnabled() { + return gates.ChartV3.Error() + } + return o.createV3Chart(out) + default: + return fmt.Errorf("unsupported chart API version: %s (supported: v2, v3)", o.chartAPIVersion) + } +} + +func (o *createOptions) createV2Chart(out io.Writer) error { chartname := filepath.Base(o.name) cfile := &chart.Metadata{ Name: chartname, @@ -111,3 +135,29 @@ func (o *createOptions) run(out io.Writer) error { _, err := chartutil.Create(chartname, filepath.Dir(o.name)) return err } + +func (o *createOptions) createV3Chart(out io.Writer) error { + chartname := filepath.Base(o.name) + cfile := &chartv3.Metadata{ + Name: chartname, + Description: "A Helm chart for Kubernetes", + Type: "application", + Version: "0.1.0", + AppVersion: "0.1.0", + APIVersion: chartv3.APIVersionV3, + } + + if o.starter != "" { + // Create from the starter + lstarter := filepath.Join(o.starterDir, o.starter) + // If path is absolute, we don't want to prefix it with helm starters folder + if filepath.IsAbs(o.starter) { + lstarter = o.starter + } + return chartutilv3.CreateFrom(cfile, filepath.Dir(o.name), lstarter) + } + + chartutilv3.Stderr = out + _, err := chartutilv3.Create(chartname, filepath.Dir(o.name)) + return err +} diff --git a/pkg/helm/pkg/cmd/dependency_build.go b/pkg/helm/pkg/cmd/dependency_build.go index 923abe7f..3fdf2c19 100644 --- a/pkg/helm/pkg/cmd/dependency_build.go +++ b/pkg/helm/pkg/cmd/dependency_build.go @@ -17,6 +17,7 @@ package cmd import ( "context" + "errors" "fmt" "io" "os" @@ -77,7 +78,8 @@ func newDependencyBuildCmd(out io.Writer) *cobra.Command { man.Verify = downloader.VerifyIfPossible } err = man.Build(context.Background()) - if e, ok := err.(downloader.ErrRepoNotFound); ok { + var e downloader.ErrRepoNotFound + if errors.As(err, &e) { return fmt.Errorf("%s. Please add the missing repos via 'helm repo add'", e.Error()) } return err diff --git a/pkg/helm/pkg/cmd/dependency_update_test.go b/pkg/helm/pkg/cmd/dependency_update_test.go index 3f57c662..35b047cd 100644 --- a/pkg/helm/pkg/cmd/dependency_update_test.go +++ b/pkg/helm/pkg/cmd/dependency_update_test.go @@ -208,7 +208,7 @@ func TestDependencyUpdateCmd_DoNotDeleteOldChartsOnError(t *testing.T) { // Make sure tmpcharts-x is deleted tmpPath := filepath.Join(dir(chartname), fmt.Sprintf("tmpcharts-%d", os.Getpid())) if _, err := os.Stat(tmpPath); !errors.Is(err, fs.ErrNotExist) { - t.Fatalf("tmpcharts dir still exists") + t.Fatal("tmpcharts dir still exists") } } diff --git a/pkg/helm/pkg/cmd/history.go b/pkg/helm/pkg/cmd/history.go index 902a0628..f30630dd 100644 --- a/pkg/helm/pkg/cmd/history.go +++ b/pkg/helm/pkg/cmd/history.go @@ -106,7 +106,7 @@ type releaseInfoJSON struct { // It handles empty string time fields by treating them as zero values. func (r *releaseInfo) UnmarshalJSON(data []byte) error { // First try to unmarshal into a map to handle empty string time fields - var raw map[string]interface{} + var raw map[string]any if err := json.Unmarshal(data, &raw); err != nil { return err } @@ -260,7 +260,7 @@ func compListRevisions(_ string, cfg *action.Configuration, releaseName string) return nil, cobra.ShellCompDirectiveError } for _, version := range hist { - appVersion := fmt.Sprintf("App: %s", version.Chart.Metadata.AppVersion) + appVersion := "App: " + version.Chart.Metadata.AppVersion chartDesc := fmt.Sprintf("Chart: %s-%s", version.Chart.Metadata.Name, version.Chart.Metadata.Version) revisions = append(revisions, fmt.Sprintf("%s\t%s, %s", strconv.Itoa(version.Version), appVersion, chartDesc)) } diff --git a/pkg/helm/pkg/cmd/history_test.go b/pkg/helm/pkg/cmd/history_test.go index 27856eb3..f086a192 100644 --- a/pkg/helm/pkg/cmd/history_test.go +++ b/pkg/helm/pkg/cmd/history_test.go @@ -321,7 +321,7 @@ func TestReleaseInfoEmptyStringRoundTrip(t *testing.T) { data, err := json.Marshal(&info) require.NoError(t, err) - var result map[string]interface{} + var result map[string]any err = json.Unmarshal(data, &result) require.NoError(t, err) diff --git a/pkg/helm/pkg/cmd/list.go b/pkg/helm/pkg/cmd/list.go index dd75f631..7f23b6de 100644 --- a/pkg/helm/pkg/cmd/list.go +++ b/pkg/helm/pkg/cmd/list.go @@ -254,7 +254,7 @@ func filterReleases(releases []*release.Release, ignoredReleaseNames []string) [ // Provide dynamic auto-completion for release names func compListReleases(toComplete string, ignoredReleaseNames []string, cfg *action.Configuration) ([]string, cobra.ShellCompDirective) { - cobra.CompDebugln(fmt.Sprintf("compListReleases with toComplete %s", toComplete), settings.Debug) + cobra.CompDebugln("compListReleases with toComplete "+toComplete, settings.Debug) client := action.NewList(cfg) client.All = true diff --git a/pkg/helm/pkg/cmd/package.go b/pkg/helm/pkg/cmd/package.go index 2fdfe892..be85e425 100644 --- a/pkg/helm/pkg/cmd/package.go +++ b/pkg/helm/pkg/cmd/package.go @@ -59,7 +59,7 @@ func newPackageCmd(out io.Writer) *cobra.Command { Long: packageDesc, RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { - return fmt.Errorf("need at least one argument, the path to the chart") + return errors.New("need at least one argument, the path to the chart") } if client.Sign { if client.Key == "" { diff --git a/pkg/helm/pkg/cmd/printer.go b/pkg/helm/pkg/cmd/printer.go index 30238f5b..3a3840cf 100644 --- a/pkg/helm/pkg/cmd/printer.go +++ b/pkg/helm/pkg/cmd/printer.go @@ -21,7 +21,7 @@ import ( "text/template" ) -func tpl(t string, vals map[string]interface{}, out io.Writer) error { +func tpl(t string, vals map[string]any, out io.Writer) error { tt, err := template.New("_").Parse(t) if err != nil { return err diff --git a/pkg/helm/pkg/cmd/pull_test.go b/pkg/helm/pkg/cmd/pull_test.go index d60b5660..7de0995b 100644 --- a/pkg/helm/pkg/cmd/pull_test.go +++ b/pkg/helm/pkg/cmd/pull_test.go @@ -342,7 +342,7 @@ func runPullTests(t *testing.T, tests []struct { func buildOCIURL(registryURL, chartName, version, username, password string) string { baseURL := fmt.Sprintf("oci://%s/u/ocitestuser/%s", registryURL, chartName) if version != "" { - baseURL += fmt.Sprintf(" --version %s", version) + baseURL += " --version " + version } if username != "" && password != "" { baseURL += fmt.Sprintf(" --username %s --password %s", username, password) @@ -416,23 +416,23 @@ func TestPullVersionCompletion(t *testing.T) { tests := []cmdTestCase{{ name: "completion for pull version flag", - cmd: fmt.Sprintf("%s __complete pull testing/alpine --version ''", repoSetup), + cmd: repoSetup + " __complete pull testing/alpine --version ''", golden: "output/version-comp.txt", }, { name: "completion for pull version flag, no filter", - cmd: fmt.Sprintf("%s __complete pull testing/alpine --version 0.3", repoSetup), + cmd: repoSetup + " __complete pull testing/alpine --version 0.3", golden: "output/version-comp.txt", }, { name: "completion for pull version flag too few args", - cmd: fmt.Sprintf("%s __complete pull --version ''", repoSetup), + cmd: repoSetup + " __complete pull --version ''", golden: "output/version-invalid-comp.txt", }, { name: "completion for pull version flag too many args", - cmd: fmt.Sprintf("%s __complete pull testing/alpine badarg --version ''", repoSetup), + cmd: repoSetup + " __complete pull testing/alpine badarg --version ''", golden: "output/version-invalid-comp.txt", }, { name: "completion for pull version flag invalid chart", - cmd: fmt.Sprintf("%s __complete pull invalid/invalid --version ''", repoSetup), + cmd: repoSetup + " __complete pull invalid/invalid --version ''", golden: "output/version-invalid-comp.txt", }} runTestCmd(t, tests) diff --git a/pkg/helm/pkg/cmd/push.go b/pkg/helm/pkg/cmd/push.go index a142d794..18dbc02b 100644 --- a/pkg/helm/pkg/cmd/push.go +++ b/pkg/helm/pkg/cmd/push.go @@ -62,7 +62,7 @@ func newPushCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { var comps []string for _, p := range providers { for _, scheme := range p.Schemes { - comps = append(comps, fmt.Sprintf("%s://", scheme)) + comps = append(comps, scheme+"://") } } return comps, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace diff --git a/pkg/helm/pkg/cmd/repo_add_test.go b/pkg/helm/pkg/cmd/repo_add_test.go index 9b65cc2f..9db5fb35 100644 --- a/pkg/helm/pkg/cmd/repo_add_test.go +++ b/pkg/helm/pkg/cmd/repo_add_test.go @@ -130,7 +130,7 @@ func TestRepoAdd(t *testing.T) { } if err := o.run(io.Discard); err != nil { - t.Errorf("Duplicate repository name was added") + t.Error("Duplicate repository name was added") } } @@ -162,7 +162,7 @@ func TestRepoAddCheckLegalName(t *testing.T) { t.Fatalf("Actual error %s, not equal to expected error %s", err, wantErrorMsg) } } else { - t.Fatalf("expect reported an error.") + t.Fatal("expect reported an error.") } } diff --git a/pkg/helm/pkg/cmd/repo_remove_test.go b/pkg/helm/pkg/cmd/repo_remove_test.go index c86bef9d..db651723 100644 --- a/pkg/helm/pkg/cmd/repo_remove_test.go +++ b/pkg/helm/pkg/cmd/repo_remove_test.go @@ -196,15 +196,15 @@ func TestRepoRemoveCompletion(t *testing.T) { // and that port changes each time we run the test. tests := []cmdTestCase{{ name: "completion for repo remove", - cmd: fmt.Sprintf("%s __completeNoDesc repo remove ''", repoSetup), + cmd: repoSetup + " __completeNoDesc repo remove ''", golden: "output/repo_list_comp.txt", }, { name: "completion for repo remove, no filter", - cmd: fmt.Sprintf("%s __completeNoDesc repo remove fo", repoSetup), + cmd: repoSetup + " __completeNoDesc repo remove fo", golden: "output/repo_list_comp.txt", }, { name: "completion for repo remove repetition", - cmd: fmt.Sprintf("%s __completeNoDesc repo remove foo ''", repoSetup), + cmd: repoSetup + " __completeNoDesc repo remove foo ''", golden: "output/repo_repeat_comp.txt", }} for _, test := range tests { diff --git a/pkg/helm/pkg/cmd/require/args_test.go b/pkg/helm/pkg/cmd/require/args_test.go index 3dbd4df7..c5573ddf 100644 --- a/pkg/helm/pkg/cmd/require/args_test.go +++ b/pkg/helm/pkg/cmd/require/args_test.go @@ -16,8 +16,8 @@ limitations under the License. package require import ( - "fmt" "io" + "strconv" "strings" "testing" @@ -65,7 +65,7 @@ type testCase struct { func runTestCases(t *testing.T, testCases []testCase) { t.Helper() for i, tc := range testCases { - t.Run(fmt.Sprint(i), func(t *testing.T) { + t.Run(strconv.Itoa(i), func(t *testing.T) { cmd := &cobra.Command{ Use: "root", Run: func(*cobra.Command, []string) {}, diff --git a/pkg/helm/pkg/cmd/search_hub.go b/pkg/helm/pkg/cmd/search_hub.go index 6f327743..8807ff8b 100644 --- a/pkg/helm/pkg/cmd/search_hub.go +++ b/pkg/helm/pkg/cmd/search_hub.go @@ -17,6 +17,7 @@ limitations under the License. package cmd import ( + "errors" "fmt" "io" "log/slog" @@ -136,12 +137,12 @@ func (h *hubSearchWriter) WriteTable(out io.Writer) error { if len(h.elements) == 0 { // Fail if no results found and --fail-on-no-result is enabled if h.failOnNoResult { - return fmt.Errorf("no results found") + return errors.New("no results found") } _, err := out.Write([]byte("No results found\n")) if err != nil { - return fmt.Errorf("unable to write results: %s", err) + return fmt.Errorf("unable to write results: %w", err) } return nil } @@ -175,7 +176,7 @@ func (h *hubSearchWriter) WriteYAML(out io.Writer) error { func (h *hubSearchWriter) encodeByFormat(out io.Writer, format output.Format) error { // Fail if no results found and --fail-on-no-result is enabled if len(h.elements) == 0 && h.failOnNoResult { - return fmt.Errorf("no results found") + return errors.New("no results found") } // Initialize the array so no results returns an empty array instead of null diff --git a/pkg/helm/pkg/cmd/search_hub_test.go b/pkg/helm/pkg/cmd/search_hub_test.go index 8e056f77..62881cbd 100644 --- a/pkg/helm/pkg/cmd/search_hub_test.go +++ b/pkg/helm/pkg/cmd/search_hub_test.go @@ -168,7 +168,7 @@ func TestSearchHubCmd_FailOnNoResponseTests(t *testing.T) { _, out, err := executeActionCommandC(storage, tt.cmd) if tt.wantErr { if err == nil { - t.Errorf("expected error due to no record in response, got nil") + t.Error("expected error due to no record in response, got nil") } } else { if err != nil { diff --git a/pkg/helm/pkg/cmd/search_repo.go b/pkg/helm/pkg/cmd/search_repo.go index b386757f..2f1aebe1 100644 --- a/pkg/helm/pkg/cmd/search_repo.go +++ b/pkg/helm/pkg/cmd/search_repo.go @@ -216,12 +216,12 @@ func (r *repoSearchWriter) WriteTable(out io.Writer) error { if len(r.results) == 0 { // Fail if no results found and --fail-on-no-result is enabled if r.failOnNoResult { - return fmt.Errorf("no results found") + return errors.New("no results found") } _, err := out.Write([]byte("No results found\n")) if err != nil { - return fmt.Errorf("unable to write results: %s", err) + return fmt.Errorf("unable to write results: %w", err) } return nil } @@ -245,7 +245,7 @@ func (r *repoSearchWriter) WriteYAML(out io.Writer) error { func (r *repoSearchWriter) encodeByFormat(out io.Writer, format output.Format) error { // Fail if no results found and --fail-on-no-result is enabled if len(r.results) == 0 && r.failOnNoResult { - return fmt.Errorf("no results found") + return errors.New("no results found") } // Initialize the array so no results returns an empty array instead of null @@ -307,7 +307,7 @@ func compListChartsOfRepo(repoName string, prefix string) []string { // Provide dynamic auto-completion for commands that operate on charts (e.g., helm show) // When true, the includeFiles argument indicates that completion should include local files (e.g., local charts) func compListCharts(toComplete string, includeFiles bool) ([]string, cobra.ShellCompDirective) { - cobra.CompDebugln(fmt.Sprintf("compListCharts with toComplete %s", toComplete), settings.Debug) + cobra.CompDebugln("compListCharts with toComplete "+toComplete, settings.Debug) noSpace := false noFile := false @@ -323,7 +323,7 @@ func compListCharts(toComplete string, includeFiles bool) ([]string, cobra.Shell if len(repoInfo) > 1 { repoDesc = repoInfo[1] } - repoWithSlash := fmt.Sprintf("%s/", repo) + repoWithSlash := repo + "/" if strings.HasPrefix(toComplete, repoWithSlash) { // Must complete with charts within the specified repo. // Don't filter on toComplete to allow for shell fuzzy matching diff --git a/pkg/helm/pkg/cmd/show_test.go b/pkg/helm/pkg/cmd/show_test.go index 7f773ce8..6d655059 100644 --- a/pkg/helm/pkg/cmd/show_test.go +++ b/pkg/helm/pkg/cmd/show_test.go @@ -99,35 +99,35 @@ func TestShowVersionCompletion(t *testing.T) { tests := []cmdTestCase{{ name: "completion for show version flag", - cmd: fmt.Sprintf("%s __complete show chart testing/alpine --version ''", repoSetup), + cmd: repoSetup + " __complete show chart testing/alpine --version ''", golden: "output/version-comp.txt", }, { name: "completion for show version flag, no filter", - cmd: fmt.Sprintf("%s __complete show chart testing/alpine --version 0.3", repoSetup), + cmd: repoSetup + " __complete show chart testing/alpine --version 0.3", golden: "output/version-comp.txt", }, { name: "completion for show version flag too few args", - cmd: fmt.Sprintf("%s __complete show chart --version ''", repoSetup), + cmd: repoSetup + " __complete show chart --version ''", golden: "output/version-invalid-comp.txt", }, { name: "completion for show version flag too many args", - cmd: fmt.Sprintf("%s __complete show chart testing/alpine badarg --version ''", repoSetup), + cmd: repoSetup + " __complete show chart testing/alpine badarg --version ''", golden: "output/version-invalid-comp.txt", }, { name: "completion for show version flag invalid chart", - cmd: fmt.Sprintf("%s __complete show chart invalid/invalid --version ''", repoSetup), + cmd: repoSetup + " __complete show chart invalid/invalid --version ''", golden: "output/version-invalid-comp.txt", }, { name: "completion for show version flag with all", - cmd: fmt.Sprintf("%s __complete show all testing/alpine --version ''", repoSetup), + cmd: repoSetup + " __complete show all testing/alpine --version ''", golden: "output/version-comp.txt", }, { name: "completion for show version flag with readme", - cmd: fmt.Sprintf("%s __complete show readme testing/alpine --version ''", repoSetup), + cmd: repoSetup + " __complete show readme testing/alpine --version ''", golden: "output/version-comp.txt", }, { name: "completion for show version flag with values", - cmd: fmt.Sprintf("%s __complete show values testing/alpine --version ''", repoSetup), + cmd: repoSetup + " __complete show values testing/alpine --version ''", golden: "output/version-comp.txt", }} runTestCmd(t, tests) diff --git a/pkg/helm/pkg/cmd/status.go b/pkg/helm/pkg/cmd/status.go index 6879711e..005bee68 100644 --- a/pkg/helm/pkg/cmd/status.go +++ b/pkg/helm/pkg/cmd/status.go @@ -197,9 +197,9 @@ func (s statusPrinter) WriteTable(out io.Writer) error { } _, _ = fmt.Fprintf(out, "TEST SUITE: %s\n%s\n%s\n%s\n", h.Name, - fmt.Sprintf("Last Started: %s", h.LastRun.StartedAt.Format(time.ANSIC)), - fmt.Sprintf("Last Completed: %s", h.LastRun.CompletedAt.Format(time.ANSIC)), - fmt.Sprintf("Phase: %s", h.LastRun.Phase), + "Last Started: "+h.LastRun.StartedAt.Format(time.ANSIC), + "Last Completed: "+h.LastRun.CompletedAt.Format(time.ANSIC), + "Phase: "+h.LastRun.Phase, ) } } diff --git a/pkg/helm/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml b/pkg/helm/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml index a58544b0..c99e5122 100644 --- a/pkg/helm/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml +++ b/pkg/helm/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml @@ -1,6 +1,7 @@ --- apiVersion: v1 name: fullenv +version: 0.1.0 type: cli/v1 runtime: subprocess config: diff --git a/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/args/plugin.yaml b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/args/plugin.yaml index 4156e7f1..24d79ac7 100644 --- a/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/args/plugin.yaml +++ b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/args/plugin.yaml @@ -1,4 +1,5 @@ name: args +version: 0.1.0 type: cli/v1 apiVersion: v1 runtime: subprocess diff --git a/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/echo/plugin.yaml b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/echo/plugin.yaml index a0a0b525..a707c337 100644 --- a/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/echo/plugin.yaml +++ b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/echo/plugin.yaml @@ -1,4 +1,5 @@ name: echo +version: 0.1.0 type: cli/v1 apiVersion: v1 runtime: subprocess diff --git a/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/plugin.yaml b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/plugin.yaml index ba950825..93930219 100644 --- a/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/plugin.yaml +++ b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/plugin.yaml @@ -1,6 +1,7 @@ --- apiVersion: v1 name: exitwith +version: 0.1.0 type: cli/v1 runtime: subprocess config: diff --git a/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/plugin.yaml b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/plugin.yaml index a58544b0..c99e5122 100644 --- a/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/plugin.yaml +++ b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/plugin.yaml @@ -1,6 +1,7 @@ --- apiVersion: v1 name: fullenv +version: 0.1.0 type: cli/v1 runtime: subprocess config: diff --git a/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/noversion/plugin.yaml b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/noversion/plugin.yaml new file mode 100644 index 00000000..70c356de --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/noversion/plugin.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +name: noversion +type: cli/v1 +runtime: subprocess +runtimeConfig: + platformCommand: + - command: "echo hello" diff --git a/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/shortenv/plugin.yaml b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/shortenv/plugin.yaml index 5fe053ed..3f935db4 100644 --- a/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/shortenv/plugin.yaml +++ b/pkg/helm/pkg/cmd/testdata/helmhome/helm/plugins/shortenv/plugin.yaml @@ -1,6 +1,7 @@ --- apiVersion: v1 name: shortenv +version: 0.1.0 type: cli/v1 runtime: subprocess config: diff --git a/pkg/helm/pkg/cmd/testdata/output/issue-9027.txt b/pkg/helm/pkg/cmd/testdata/output/issue-9027.txt index eb19fc38..f4303249 100644 --- a/pkg/helm/pkg/cmd/testdata/output/issue-9027.txt +++ b/pkg/helm/pkg/cmd/testdata/output/issue-9027.txt @@ -2,30 +2,37 @@ # Source: issue-9027/charts/subchart/templates/values.yaml global: hash: + key1: 1 + key2: 2 key3: 13 key4: 4 key5: 5 key6: 6 hash: + key1: 1 + key2: 2 key3: 13 key4: 4 key5: 5 key6: 6 + --- # Source: issue-9027/templates/values.yaml global: hash: - key1: null - key2: null key3: 13 subchart: global: hash: + key1: 1 + key2: 2 key3: 13 key4: 4 key5: 5 key6: 6 hash: + key1: 1 + key2: 2 key3: 13 key4: 4 key5: 5 diff --git a/pkg/helm/pkg/cmd/testdata/output/object-order.txt b/pkg/helm/pkg/cmd/testdata/output/object-order.txt index 307f928f..1ff39f33 100644 --- a/pkg/helm/pkg/cmd/testdata/output/object-order.txt +++ b/pkg/helm/pkg/cmd/testdata/output/object-order.txt @@ -155,6 +155,7 @@ spec: policyTypes: - Egress - Ingress + --- # Source: object-order/templates/01-a.yml # 4 (Deployment should come after all NetworkPolicy manifests, since 'helm template' outputs in install order) diff --git a/pkg/helm/pkg/cmd/testdata/output/version-short.txt b/pkg/helm/pkg/cmd/testdata/output/version-short.txt index 8cf4318f..2fa2c570 100644 --- a/pkg/helm/pkg/cmd/testdata/output/version-short.txt +++ b/pkg/helm/pkg/cmd/testdata/output/version-short.txt @@ -1 +1 @@ -v4.1 +v4.2 diff --git a/pkg/helm/pkg/cmd/testdata/output/version-template.txt b/pkg/helm/pkg/cmd/testdata/output/version-template.txt index 8fd8b496..8f2491e5 100644 --- a/pkg/helm/pkg/cmd/testdata/output/version-template.txt +++ b/pkg/helm/pkg/cmd/testdata/output/version-template.txt @@ -1 +1 @@ -Version: v4.1 \ No newline at end of file +Version: v4.2 \ No newline at end of file diff --git a/pkg/helm/pkg/cmd/testdata/output/version.txt b/pkg/helm/pkg/cmd/testdata/output/version.txt index 1f4cf4d4..331c6d5e 100644 --- a/pkg/helm/pkg/cmd/testdata/output/version.txt +++ b/pkg/helm/pkg/cmd/testdata/output/version.txt @@ -1 +1 @@ -version.BuildInfo{Version:"v4.1", GitCommit:"", GitTreeState:"", GoVersion:"", KubeClientVersion:"v1.20"} +version.BuildInfo{Version:"v4.2", GitCommit:"", GitTreeState:"", GoVersion:"", KubeClientVersion:"v1.20"} diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/templates/horizontalpodautoscaler.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/templates/horizontalpodautoscaler.yaml deleted file mode 100644 index b77a4bee..00000000 --- a/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/templates/horizontalpodautoscaler.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: autoscaling/v2beta1 -kind: HorizontalPodAutoscaler -metadata: - name: deprecated -spec: - scaleTargetRef: - kind: Pod - name: pod - maxReplicas: 3 \ No newline at end of file diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/templates/poddisruptionbudget.yaml b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/templates/poddisruptionbudget.yaml new file mode 100644 index 00000000..214d3cb6 --- /dev/null +++ b/pkg/helm/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/templates/poddisruptionbudget.yaml @@ -0,0 +1,9 @@ +apiVersion: policy/v1beta1 +kind: PodDisruptionBudget +metadata: + name: deprecated +spec: + maxUnavailable: 1 + selector: + matchLabels: + app: deprecated diff --git a/pkg/helm/pkg/downloader/cache.go b/pkg/helm/pkg/downloader/cache.go index 7d4f2858..a4491a28 100644 --- a/pkg/helm/pkg/downloader/cache.go +++ b/pkg/helm/pkg/downloader/cache.go @@ -17,6 +17,7 @@ package downloader import ( "crypto/sha256" + "encoding/hex" "errors" "fmt" "io" @@ -85,5 +86,5 @@ func (c *DiskCache) Put(key [sha256.Size]byte, data io.Reader, cacheType string) // fileName generates the filename in a structured manner where the first part is the // directory and the full hash is the filename. func (c *DiskCache) fileName(id [sha256.Size]byte, cacheType string) string { - return filepath.Join(c.Root, fmt.Sprintf("%02x", id[0]), fmt.Sprintf("%x", id)+cacheType) + return filepath.Join(c.Root, fmt.Sprintf("%02x", id[0]), hex.EncodeToString(id[:])+cacheType) } diff --git a/pkg/helm/pkg/downloader/chart_downloader.go b/pkg/helm/pkg/downloader/chart_downloader.go index 80ddd6e3..fb8ddeba 100644 --- a/pkg/helm/pkg/downloader/chart_downloader.go +++ b/pkg/helm/pkg/downloader/chart_downloader.go @@ -185,7 +185,7 @@ func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *proven } } if !found { - body, err = g.Get(u.String() + ".prov") + body, err = g.Get(u.String()+".prov", c.Options...) if err != nil { if c.Verify == VerifyAlways { return destfile, ver, fmt.Errorf("failed to fetch provenance %q", u.String()+".prov") @@ -395,7 +395,7 @@ func (c *ChartDownloader) ResolveChartVersion(ref, version string) (string, *url if err != nil { // If there is no special config, return the default HTTP client and // swallow the error. - if err == ErrNoOwnerRepo { + if errors.Is(err, ErrNoOwnerRepo) { // Make sure to add the ref URL as the URL for the getter c.Options = append(c.Options, getter.WithURL(ref)) return "", u, nil @@ -597,8 +597,8 @@ func loadRepoConfig(file string) (*repo.File, error) { // stripDigestAlgorithm removes the algorithm prefix (e.g., "sha256:") from a digest string. // If no prefix is present, the original string is returned unchanged. func stripDigestAlgorithm(digest string) string { - if idx := strings.Index(digest, ":"); idx >= 0 { - return digest[idx+1:] + if _, after, ok := strings.Cut(digest, ":"); ok { + return after } return digest } diff --git a/pkg/helm/pkg/downloader/chart_downloader_test.go b/pkg/helm/pkg/downloader/chart_downloader_test.go index f8bdbfbf..37392856 100644 --- a/pkg/helm/pkg/downloader/chart_downloader_test.go +++ b/pkg/helm/pkg/downloader/chart_downloader_test.go @@ -18,6 +18,7 @@ package downloader import ( "crypto/sha256" "encoding/hex" + "errors" "os" "path/filepath" "testing" @@ -377,7 +378,7 @@ func TestScanReposForURL(t *testing.T) { // A lookup failure should produce an ErrNoOwnerRepo u = "https://no.such.repo/foo/bar-1.23.4.tgz" - if _, err = c.scanReposForURL(u, rf); err != ErrNoOwnerRepo { + if _, err = c.scanReposForURL(u, rf); !errors.Is(err, ErrNoOwnerRepo) { t.Fatalf("expected ErrNoOwnerRepo, got %v", err) } } diff --git a/pkg/helm/pkg/downloader/manager.go b/pkg/helm/pkg/downloader/manager.go index 4a4c0ed0..c8ccff70 100644 --- a/pkg/helm/pkg/downloader/manager.go +++ b/pkg/helm/pkg/downloader/manager.go @@ -54,7 +54,7 @@ type ErrRepoNotFound struct { // Error implements the error interface. func (e ErrRepoNotFound) Error() string { - return fmt.Sprintf("no repository definition for %s", strings.Join(e.Repos, ", ")) + return "no repository definition for " + strings.Join(e.Repos, ", ") } // Manager handles the lifecycle of fetching, resolving, and storing dependencies. @@ -138,7 +138,7 @@ func (m *Manager) Build(ctx context.Context) error { return errors.New("the lock file (requirements.lock) is out of sync with the dependencies file (requirements.yaml). Please update the dependencies") } } else { - return errors.New("the lock file (Chart.lock) is out of sync with the dependencies file (Chart.yaml). Please update the dependencies") + return errors.New("the lock file (Chart.lock) is out of sync with the dependencies file (Chart.yaml). Please update the dependencies with 'helm dependency update'") } } diff --git a/pkg/helm/pkg/engine/engine.go b/pkg/helm/pkg/engine/engine.go index 6824baa6..4261ca6a 100644 --- a/pkg/helm/pkg/engine/engine.go +++ b/pkg/helm/pkg/engine/engine.go @@ -145,8 +145,8 @@ func warnWrap(warn string) string { // 'include' needs to be defined in the scope of a 'tpl' template as // well as regular file-loaded templates. -func includeFun(t *template.Template, includedNames map[string]int) func(string, interface{}) (string, error) { - return func(name string, data interface{}) (string, error) { +func includeFun(t *template.Template, includedNames map[string]int) func(string, any) (string, error) { + return func(name string, data any) (string, error) { var buf strings.Builder if v, ok := includedNames[name]; ok { if v > recursionMaxNums { @@ -172,8 +172,8 @@ func includeFun(t *template.Template, includedNames map[string]int) func(string, // As does 'tpl', so that nested calls to 'tpl' see the templates // defined by their enclosing contexts. -func tplFun(parent *template.Template, includedNames map[string]int, strict bool) func(string, interface{}) (string, error) { - return func(tpl string, vals interface{}) (string, error) { +func tplFun(parent *template.Template, includedNames map[string]int, strict bool) func(string, any) (string, error) { + return func(tpl string, vals any) (string, error) { t, err := parent.Clone() if err != nil { return "", fmt.Errorf("cannot clone template: %w", err) @@ -268,7 +268,7 @@ func (e Engine) initFunMap(ctx context.Context, t *template.Template) { } // Add the `required` function here so we can use lintMode - funcMap["required"] = func(warn string, val interface{}) (interface{}, error) { + funcMap["required"] = func(warn string, val any) (any, error) { if val == nil { if e.LintMode { // Don't fail on missing required values when linting @@ -389,7 +389,7 @@ func cleanupParseError(filename string, err error) error { tokens := strings.Split(err.Error(), ": ") if len(tokens) == 1 { // This might happen if a non-templating error occurs - return fmt.Errorf("parse error in (%s): %s", filename, err) + return fmt.Errorf("parse error in (%s): %w", filename, err) } // The first token is "template" // The second token is either "filename:lineno" or "filename:lineNo:columnNo" @@ -480,9 +480,7 @@ func parseTemplateSimpleErrorString(remainder string) (TraceableError, bool) { // Executing form: ": executing \"\" at <>: [ template:...]" // Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=141 func parseTemplateExecutingAtErrorType(remainder string) (TraceableError, bool) { - if idx := strings.Index(remainder, ": executing "); idx != -1 { - templateName := remainder[:idx] - after := remainder[idx+len(": executing "):] + if templateName, after, found := strings.Cut(remainder, ": executing "); found { if len(after) == 0 || after[0] != '"' { return TraceableError{}, false } @@ -501,12 +499,10 @@ func parseTemplateExecutingAtErrorType(remainder string) (TraceableError, bool) return TraceableError{}, false } afterAt := afterFunc[len(atPrefix):] - endLoc := strings.Index(afterAt, ">: ") - if endLoc == -1 { + locationName, errMsg, found := strings.Cut(afterAt, ">: ") + if !found { return TraceableError{}, false } - locationName := afterAt[:endLoc] - errMsg := afterAt[endLoc+len(">: "):] // trim chained next error starting with space + "template:" if present if cut := strings.Index(errMsg, " template:"); cut != -1 { @@ -536,7 +532,7 @@ func reformatExecErrorMsg(filename string, err error) error { tokens := strings.SplitN(err.Error(), ": ", 3) if len(tokens) != 3 { // This might happen if a non-templating error occurs - return fmt.Errorf("execution error in (%s): %s", filename, err) + return fmt.Errorf("execution error in (%s): %w", filename, err) } // The first token is "template" @@ -744,9 +740,9 @@ func allTemplates(c ci.Charter, vals chartcommon.Values) map[string]renderable { // // As it recurses, it also sets the values to be appropriate for the template // scope. -func recAllTpls(c ci.Charter, templates map[string]renderable, values chartcommon.Values) map[string]interface{} { +func recAllTpls(c ci.Charter, templates map[string]renderable, values chartcommon.Values) map[string]any { vals := values.AsMap() - subCharts := make(map[string]interface{}) + subCharts := make(map[string]any) accessor, err := ci.NewAccessor(c) if err != nil { slog.Error("error accessing chart", "error", err) @@ -754,7 +750,7 @@ func recAllTpls(c ci.Charter, templates map[string]renderable, values chartcommo chartMetaData := accessor.MetadataAsMap() chartMetaData["IsRoot"] = accessor.IsRoot() - next := map[string]interface{}{ + next := map[string]any{ "Chart": chartMetaData, "Files": newFiles(accessor.Files()), "Release": vals["Release"], diff --git a/pkg/helm/pkg/engine/engine_test.go b/pkg/helm/pkg/engine/engine_test.go index 1b3e298a..9f92ce5b 100644 --- a/pkg/helm/pkg/engine/engine_test.go +++ b/pkg/helm/pkg/engine/engine_test.go @@ -18,6 +18,7 @@ package engine import ( "context" + "errors" "fmt" "path" "strings" @@ -105,14 +106,14 @@ func TestRender(t *testing.T) { {Name: "templates/test4", ModTime: modTime, Data: []byte("{{toJson .Values}}")}, {Name: "templates/test5", ModTime: modTime, Data: []byte("{{getHostByName \"helm.sh\"}}")}, }, - Values: map[string]interface{}{"outer": "DEFAULT", "inner": "DEFAULT"}, + Values: map[string]any{"outer": "DEFAULT", "inner": "DEFAULT"}, } - vals := map[string]interface{}{ - "Values": map[string]interface{}{ + vals := map[string]any{ + "Values": map[string]any{ "outer": "spouter", "inner": "inn", - "global": map[string]interface{}{ + "global": map[string]any{ "callme": "Ishmael", }, }, @@ -227,11 +228,11 @@ func TestRenderWithDNS(t *testing.T) { Templates: []*common.File{ {Name: "templates/test1", ModTime: time.Now(), Data: []byte("{{getHostByName \"helm.sh\"}}")}, }, - Values: map[string]interface{}{}, + Values: map[string]any{}, } - vals := map[string]interface{}{ - "Values": map[string]interface{}{}, + vals := map[string]any{ + "Values": map[string]any{}, } v, err := util.CoalesceValues(c, vals) @@ -278,15 +279,15 @@ var _ ClientProvider = &testClientProvider{} // makeUnstructured is a convenience function for single-line creation of Unstructured objects. func makeUnstructured(apiVersion, kind, name, namespace string) *unstructured.Unstructured { - ret := &unstructured.Unstructured{Object: map[string]interface{}{ + ret := &unstructured.Unstructured{Object: map[string]any{ "apiVersion": apiVersion, "kind": kind, - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": name, }, }} if namespace != "" { - ret.Object["metadata"].(map[string]interface{})["namespace"] = namespace + ret.Object["metadata"].(map[string]any)["namespace"] = namespace } return ret } @@ -357,7 +358,7 @@ func TestRenderWithClientProvider(t *testing.T) { Name: "moby", Version: "1.2.3", }, - Values: map[string]interface{}{}, + Values: map[string]any{}, } modTime := time.Now() @@ -369,8 +370,8 @@ func TestRenderWithClientProvider(t *testing.T) { }) } - vals := map[string]interface{}{ - "Values": map[string]interface{}{}, + vals := map[string]any{ + "Values": map[string]any{}, } v, err := util.CoalesceValues(c, vals) @@ -402,11 +403,11 @@ func TestRenderWithClientProvider_error(t *testing.T) { Templates: []*common.File{ {Name: "templates/error", ModTime: time.Now(), Data: []byte(`{{ lookup "v1" "Error" "" "" }}`)}, }, - Values: map[string]interface{}{}, + Values: map[string]any{}, } - vals := map[string]interface{}{ - "Values": map[string]interface{}{}, + vals := map[string]any{ + "Values": map[string]any{}, } v, err := util.CoalesceValues(c, vals) @@ -418,7 +419,7 @@ func TestRenderWithClientProvider_error(t *testing.T) { t: t, scheme: map[string]kindProps{ "v1/Error": { - shouldErr: fmt.Errorf("kaboom"), + shouldErr: errors.New("kaboom"), }, }, } @@ -439,7 +440,7 @@ func TestParallelRenderInternals(t *testing.T) { tpls := map[string]renderable{ "t": { tpl: `{{.val}}`, - vals: map[string]interface{}{"val": tt}, + vals: map[string]any{"val": tt}, }, } out, err := e.render(context.Background(), tpls) @@ -456,7 +457,7 @@ func TestParallelRenderInternals(t *testing.T) { } func TestParseErrors(t *testing.T) { - vals := common.Values{"Values": map[string]interface{}{}} + vals := common.Values{"Values": map[string]any{}} tplsUndefinedFunction := map[string]renderable{ "undefined_function": {tpl: `{{foo}}`, vals: vals}, @@ -472,7 +473,7 @@ func TestParseErrors(t *testing.T) { } func TestExecErrors(t *testing.T) { - vals := common.Values{"Values": map[string]interface{}{}} + vals := common.Values{"Values": map[string]any{}} cases := []struct { name string tpls map[string]renderable @@ -536,7 +537,7 @@ linebreak`, } func TestFailErrors(t *testing.T) { - vals := common.Values{"Values": map[string]interface{}{}} + vals := common.Values{"Values": map[string]any{}} failtpl := `All your base are belong to us{{ fail "This is an error" }}` tplsFailed := map[string]renderable{ @@ -644,7 +645,7 @@ func TestRenderDependency(t *testing.T) { }, }) - out, err := Render(context.Background(), ch, map[string]interface{}{}) + out, err := Render(context.Background(), ch, map[string]any{}) if err != nil { t.Fatalf("failed to render chart: %s", err) } @@ -676,7 +677,7 @@ func TestRenderNestedValues(t *testing.T) { {Name: deepestpath, ModTime: modTime, Data: []byte(`And this same {{.Values.what}} that smiles {{.Values.global.when}}`)}, {Name: checkrelease, ModTime: modTime, Data: []byte(`Tomorrow will be {{default "happy" .Release.Name }}`)}, }, - Values: map[string]interface{}{"what": "milkshake", "where": "here"}, + Values: map[string]any{"what": "milkshake", "where": "here"}, } inner := &chart.Chart{ @@ -684,7 +685,7 @@ func TestRenderNestedValues(t *testing.T) { Templates: []*common.File{ {Name: innerpath, ModTime: modTime, Data: []byte(`Old {{.Values.who}} is still a-flyin'`)}, }, - Values: map[string]interface{}{"who": "Robert", "what": "glasses"}, + Values: map[string]any{"who": "Robert", "what": "glasses"}, } inner.AddDependency(deepest) @@ -694,10 +695,10 @@ func TestRenderNestedValues(t *testing.T) { {Name: outerpath, ModTime: modTime, Data: []byte(`Gather ye {{.Values.what}} while ye may`)}, {Name: subchartspath, ModTime: modTime, Data: []byte(`The glorious Lamp of {{.Subcharts.herrick.Subcharts.deepest.Values.where}}, the {{.Subcharts.herrick.Values.what}}`)}, }, - Values: map[string]interface{}{ + Values: map[string]any{ "what": "stinkweed", "who": "me", - "herrick": map[string]interface{}{ + "herrick": map[string]any{ "who": "time", "what": "Sun", }, @@ -705,15 +706,15 @@ func TestRenderNestedValues(t *testing.T) { } outer.AddDependency(inner) - injValues := map[string]interface{}{ + injValues := map[string]any{ "what": "rosebuds", - "herrick": map[string]interface{}{ - "deepest": map[string]interface{}{ + "herrick": map[string]any{ + "deepest": map[string]any{ "what": "flower", "where": "Heaven", }, }, - "global": map[string]interface{}{ + "global": map[string]any{ "when": "to-day", }, } @@ -1060,9 +1061,11 @@ func TestRenderRecursionLimit(t *testing.T) { } var expect string + var expectSb1062 strings.Builder for range times { - expect += phrase + "\n" + expectSb1062.WriteString(phrase + "\n") } + expect += expectSb1062.String() if got := out["overlook/templates/quote"]; got != expect { t.Errorf("Expected %q, got %q (%v)", expect, got, out) } @@ -1350,7 +1353,7 @@ NestedHelperFunctions/charts/common/templates/_helpers_2.tpl:1:49 v := common.Values{} val, _ := util.CoalesceValues(c, v) - vals := map[string]interface{}{ + vals := map[string]any{ "Values": val.AsMap(), } _, err := Render(context.Background(), c, vals) @@ -1384,7 +1387,7 @@ template: no template "nested_helper.name" associated with template "gotpl"` v := common.Values{} val, _ := util.CoalesceValues(c, v) - vals := map[string]interface{}{ + vals := map[string]any{ "Values": val.AsMap(), } _, err := Render(context.Background(), c, vals) @@ -1464,7 +1467,7 @@ func TestTraceableError_SimpleForm(t *testing.T) { for _, errString := range testStrings { trace, done := parseTemplateSimpleErrorString(errString) if !done { - t.Errorf("Expected parse to pass but did not") + t.Error("Expected parse to pass but did not") } if trace.message != "error calling include" { t.Errorf("Expected %q, got %q", errString, trace.message) @@ -1481,7 +1484,7 @@ func TestTraceableError_ExecutingForm(t *testing.T) { expectedLocation := errTuple[1] trace, done := parseTemplateExecutingAtErrorType(errString) if !done { - t.Errorf("Expected parse to pass but did not") + t.Error("Expected parse to pass but did not") } if trace.location != expectedLocation { t.Errorf("Expected %q, got %q", expectedLocation, trace.location) @@ -1496,10 +1499,72 @@ func TestTraceableError_NoTemplateForm(t *testing.T) { for _, errString := range testStrings { trace, done := parseTemplateNoTemplateError(errString, errString) if !done { - t.Errorf("Expected parse to pass but did not") + t.Error("Expected parse to pass but did not") } if trace.message != errString { t.Errorf("Expected %q, got %q", errString, trace.message) } } } + +// TestRenderSubchartDefaultNilNoStringify tests the full pipeline: subchart default +// nil values should not produce "%!s()" in rendered template output. +// Regression test for the Bitnami common.secrets.key issue. +func TestRenderSubchartDefaultNilNoStringify(t *testing.T) { + modTime := time.Now() + + // Subchart has a default with nil values + subchart := &chart.Chart{ + Metadata: &chart.Metadata{Name: "child"}, + Templates: []*common.File{ + { + Name: "templates/test.yaml", + ModTime: modTime, + Data: []byte(`{{- if hasKey .Values.keyMapping "password" -}}{{- printf "subPath: %s" (index .Values.keyMapping "password") -}}{{- else -}}subPath: fallback{{- end -}}`), + }, + }, + Values: map[string]any{ + "keyMapping": map[string]any{ + "password": nil, // nil in chart defaults + }, + }, + } + + parent := &chart.Chart{ + Metadata: &chart.Metadata{Name: "parent"}, + Values: map[string]any{}, + } + parent.AddDependency(subchart) + + // Parent user values don't set keyMapping + injValues := map[string]any{} + + tmp, err := util.CoalesceValues(parent, injValues) + if err != nil { + t.Fatalf("Failed to coalesce values: %s", err) + } + + inject := common.Values{ + "Values": tmp, + "Chart": parent.Metadata, + "Release": common.Values{ + "Name": "test-release", + }, + } + + out, err := Render(context.Background(), parent, inject) + if err != nil { + t.Fatalf("Failed to render templates: %s", err) + } + + rendered := out["parent/charts/child/templates/test.yaml"] + + if strings.Contains(rendered, "%!s()") { + t.Errorf("Rendered output contains %%!s(), got: %q", rendered) + } + + expected := "subPath: fallback" + if rendered != expected { + t.Errorf("Expected %q, got %q", expected, rendered) + } +} diff --git a/pkg/helm/pkg/engine/funcs.go b/pkg/helm/pkg/engine/funcs.go index a97f8f10..e03c13b3 100644 --- a/pkg/helm/pkg/engine/funcs.go +++ b/pkg/helm/pkg/engine/funcs.go @@ -50,6 +50,7 @@ func funcMap() template.FuncMap { // Add some extra functionality extra := template.FuncMap{ "toToml": toTOML, + "mustToToml": mustToTOML, "fromToml": fromTOML, "toYaml": toYAML, "mustToYaml": mustToYAML, @@ -64,13 +65,13 @@ func funcMap() template.FuncMap { // This is a placeholder for the "include" function, which is // late-bound to a template. By declaring it here, we preserve the // integrity of the linter. - "include": func(string, interface{}) string { return "not implemented" }, - "tpl": func(string, interface{}) interface{} { return "not implemented" }, - "required": func(string, interface{}) (interface{}, error) { return "not implemented", nil }, + "include": func(string, any) string { return "not implemented" }, + "tpl": func(string, any) any { return "not implemented" }, + "required": func(string, any) (any, error) { return "not implemented", nil }, // Provide a placeholder for the "lookup" function, which requires a kubernetes // connection. - "lookup": func(string, string, string, string) (map[string]interface{}, error) { - return map[string]interface{}{}, nil + "lookup": func(string, string, string, string) (map[string]any, error) { + return map[string]any{}, nil }, } @@ -83,7 +84,7 @@ func funcMap() template.FuncMap { // always return a string, even on marshal error (empty string). // // This is designed to be called from a template. -func toYAML(v interface{}) string { +func toYAML(v any) string { data, err := yaml.Marshal(v) if err != nil { // Swallow errors inside of a template. @@ -97,7 +98,7 @@ func toYAML(v interface{}) string { // // This is designed to be called from a template when need to ensure that the // output YAML is valid. -func mustToYAML(v interface{}) string { +func mustToYAML(v any) string { data, err := yaml.Marshal(v) if err != nil { panic(err) @@ -105,7 +106,7 @@ func mustToYAML(v interface{}) string { return strings.TrimSuffix(string(data), "\n") } -func toYAMLPretty(v interface{}) string { +func toYAMLPretty(v any) string { var data bytes.Buffer encoder := goYaml.NewEncoder(&data) encoder.SetIndent(2) @@ -124,8 +125,8 @@ func toYAMLPretty(v interface{}) string { // YAML documents. Additionally, because its intended use is within templates // it tolerates errors. It will insert the returned error message string into // m["Error"] in the returned map. -func fromYAML(str string) map[string]interface{} { - m := map[string]interface{}{} +func fromYAML(str string) map[string]any { + m := map[string]any{} if err := yaml.Unmarshal([]byte(str), &m); err != nil { m["Error"] = err.Error() @@ -139,20 +140,21 @@ func fromYAML(str string) map[string]interface{} { // YAML documents. Additionally, because its intended use is within templates // it tolerates errors. It will insert the returned error message string as // the first and only item in the returned array. -func fromYAMLArray(str string) []interface{} { - a := []interface{}{} +func fromYAMLArray(str string) []any { + a := []any{} if err := yaml.Unmarshal([]byte(str), &a); err != nil { - a = []interface{}{err.Error()} + a = []any{err.Error()} } return a } -// toTOML takes an interface, marshals it to toml, and returns a string. It will -// always return a string, even on marshal error (empty string). +// toTOML takes an interface, marshals it to toml, and returns a string. +// On marshal error it returns the error string. // -// This is designed to be called from a template. -func toTOML(v interface{}) string { +// This is designed to be called from a template. Use mustToToml if you need +// the template to fail hard on marshal errors. +func toTOML(v any) string { b := bytes.NewBuffer(nil) e := toml.NewEncoder(b) err := e.Encode(v) @@ -162,14 +164,29 @@ func toTOML(v interface{}) string { return b.String() } +// mustToTOML takes an interface, marshals it to toml, and returns a string. +// It will panic if there is an error. +// +// This is designed to be called from a template when you need to ensure that the +// output TOML is valid. +func mustToTOML(v any) string { + b := bytes.NewBuffer(nil) + e := toml.NewEncoder(b) + err := e.Encode(v) + if err != nil { + panic(err) + } + return b.String() +} + // fromTOML converts a TOML document into a map[string]interface{}. // // This is not a general-purpose TOML parser, and will not parse all valid // TOML documents. Additionally, because its intended use is within templates // it tolerates errors. It will insert the returned error message string into // m["Error"] in the returned map. -func fromTOML(str string) map[string]interface{} { - m := make(map[string]interface{}) +func fromTOML(str string) map[string]any { + m := make(map[string]any) if err := toml.Unmarshal([]byte(str), &m); err != nil { m["Error"] = err.Error() @@ -181,7 +198,7 @@ func fromTOML(str string) map[string]interface{} { // always return a string, even on marshal error (empty string). // // This is designed to be called from a template. -func toJSON(v interface{}) string { +func toJSON(v any) string { data, err := json.Marshal(v) if err != nil { // Swallow errors inside of a template. @@ -195,7 +212,7 @@ func toJSON(v interface{}) string { // // This is designed to be called from a template when need to ensure that the // output JSON is valid. -func mustToJSON(v interface{}) string { +func mustToJSON(v any) string { data, err := json.Marshal(v) if err != nil { panic(err) @@ -209,8 +226,8 @@ func mustToJSON(v interface{}) string { // JSON documents. Additionally, because its intended use is within templates // it tolerates errors. It will insert the returned error message string into // m["Error"] in the returned map. -func fromJSON(str string) map[string]interface{} { - m := make(map[string]interface{}) +func fromJSON(str string) map[string]any { + m := make(map[string]any) if err := json.Unmarshal([]byte(str), &m); err != nil { m["Error"] = err.Error() @@ -224,11 +241,11 @@ func fromJSON(str string) map[string]interface{} { // JSON documents. Additionally, because its intended use is within templates // it tolerates errors. It will insert the returned error message string as // the first and only item in the returned array. -func fromJSONArray(str string) []interface{} { - a := []interface{}{} +func fromJSONArray(str string) []any { + a := []any{} if err := json.Unmarshal([]byte(str), &a); err != nil { - a = []interface{}{err.Error()} + a = []any{err.Error()} } return a } diff --git a/pkg/helm/pkg/engine/funcs_test.go b/pkg/helm/pkg/engine/funcs_test.go index 71a72e2e..be9d0153 100644 --- a/pkg/helm/pkg/engine/funcs_test.go +++ b/pkg/helm/pkg/engine/funcs_test.go @@ -28,19 +28,19 @@ func TestFuncs(t *testing.T) { //TODO write tests for failure cases tests := []struct { tpl, expect string - vars interface{} + vars any }{{ tpl: `{{ toYaml . }}`, expect: `foo: bar`, - vars: map[string]interface{}{"foo": "bar"}, + vars: map[string]any{"foo": "bar"}, }, { tpl: `{{ toYamlPretty . }}`, expect: "baz:\n - 1\n - 2\n - 3", - vars: map[string]interface{}{"baz": []int{1, 2, 3}}, + vars: map[string]any{"baz": []int{1, 2, 3}}, }, { tpl: `{{ toToml . }}`, expect: "foo = \"bar\"\n", - vars: map[string]interface{}{"foo": "bar"}, + vars: map[string]any{"foo": "bar"}, }, { tpl: `{{ fromToml . }}`, expect: "map[hello:world]", @@ -68,7 +68,7 @@ keyInElement1 = "valueInElement1"`, }, { tpl: `{{ toJson . }}`, expect: `{"foo":"bar"}`, - vars: map[string]interface{}{"foo": "bar"}, + vars: map[string]any{"foo": "bar"}, }, { tpl: `{{ fromYaml . }}`, expect: "map[hello:world]", @@ -109,11 +109,11 @@ keyInElement1 = "valueInElement1"`, }, { tpl: `{{ merge .dict (fromYaml .yaml) }}`, expect: `map[a:map[b:c]]`, - vars: map[string]interface{}{"dict": map[string]interface{}{"a": map[string]interface{}{"b": "c"}}, "yaml": `{"a":{"b":"d"}}`}, + vars: map[string]any{"dict": map[string]any{"a": map[string]any{"b": "c"}}, "yaml": `{"a":{"b":"d"}}`}, }, { tpl: `{{ merge (fromYaml .yaml) .dict }}`, expect: `map[a:map[b:d]]`, - vars: map[string]interface{}{"dict": map[string]interface{}{"a": map[string]interface{}{"b": "c"}}, "yaml": `{"a":{"b":"d"}}`}, + vars: map[string]any{"dict": map[string]any{"a": map[string]any{"b": "c"}}, "yaml": `{"a":{"b":"d"}}`}, }, { tpl: `{{ fromYaml . }}`, expect: `map[Error:error unmarshaling JSON: while decoding JSON: json: cannot unmarshal array into Go value of type map[string]interface {}]`, @@ -136,15 +136,15 @@ keyInElement1 = "valueInElement1"`, assert.Equal(t, tt.expect, b.String(), tt.tpl) } - loopMap := map[string]interface{}{ + loopMap := map[string]any{ "foo": "bar", } - loopMap["loop"] = []interface{}{loopMap} + loopMap["loop"] = []any{loopMap} mustFuncsTests := []struct { tpl string - expect interface{} - vars interface{} + expect any + vars any }{{ tpl: `{{ mustToYaml . }}`, vars: loopMap, @@ -159,6 +159,13 @@ keyInElement1 = "valueInElement1"`, tpl: `{{ toJson . }}`, expect: "", // should return empty string and swallow error vars: loopMap, + }, { + tpl: `{{ mustToToml . }}`, + vars: map[int]string{1: "one"}, // non-string key is invalid in TOML + }, { + tpl: `{{ mustToToml . }}`, + expect: "foo = \"bar\"\n", // should succeed and return TOML string + vars: map[string]string{"foo": "bar"}, }, } @@ -186,34 +193,34 @@ keyInElement1 = "valueInElement1"`, // be used to accidentally update mergo. This test and message should catch // the problem and explain why it's happening. func TestMerge(t *testing.T) { - dict := map[string]interface{}{ - "src2": map[string]interface{}{ + dict := map[string]any{ + "src2": map[string]any{ "h": 10, "i": "i", "j": "j", }, - "src1": map[string]interface{}{ + "src1": map[string]any{ "a": 1, "b": 2, - "d": map[string]interface{}{ + "d": map[string]any{ "e": "four", }, "g": []int{6, 7}, "i": "aye", "j": "jay", - "k": map[string]interface{}{ + "k": map[string]any{ "l": false, }, }, - "dst": map[string]interface{}{ + "dst": map[string]any{ "a": "one", "c": 3, - "d": map[string]interface{}{ + "d": map[string]any{ "f": 5, }, "g": []int{8, 9}, "i": "eye", - "k": map[string]interface{}{ + "k": map[string]any{ "l": true, }, }, @@ -223,11 +230,11 @@ func TestMerge(t *testing.T) { err := template.Must(template.New("test").Funcs(funcMap()).Parse(tpl)).Execute(&b, dict) assert.NoError(t, err) - expected := map[string]interface{}{ + expected := map[string]any{ "a": "one", // key overridden "b": 2, // merged from src1 "c": 3, // merged from dst - "d": map[string]interface{}{ // deep merge + "d": map[string]any{ // deep merge "e": "four", "f": 5, }, @@ -235,7 +242,7 @@ func TestMerge(t *testing.T) { "h": 10, // merged from src2 "i": "eye", // overridden twice "j": "jay", // overridden and merged - "k": map[string]interface{}{ + "k": map[string]any{ "l": true, // overridden }, } diff --git a/pkg/helm/pkg/engine/lookup_func.go b/pkg/helm/pkg/engine/lookup_func.go index c6ad8d25..52b6ffda 100644 --- a/pkg/helm/pkg/engine/lookup_func.go +++ b/pkg/helm/pkg/engine/lookup_func.go @@ -30,7 +30,7 @@ import ( "k8s.io/client-go/rest" ) -type lookupFunc = func(apiversion string, resource string, namespace string, name string) (map[string]interface{}, error) +type lookupFunc = func(apiversion string, resource string, namespace string, name string) (map[string]any, error) // NewLookupFunction returns a function for looking up objects in the cluster. // @@ -55,11 +55,11 @@ func (c clientProviderFromConfig) GetClientFor(apiVersion, kind string) (dynamic } func newLookupFunction(clientProvider ClientProvider) lookupFunc { - return func(apiversion string, kind string, namespace string, name string) (map[string]interface{}, error) { + return func(apiversion string, kind string, namespace string, name string) (map[string]any, error) { var client dynamic.ResourceInterface c, namespaced, err := clientProvider.GetClientFor(apiversion, kind) if err != nil { - return map[string]interface{}{}, err + return map[string]any{}, err } if namespaced && namespace != "" { client = c.Namespace(namespace) @@ -73,9 +73,9 @@ func newLookupFunction(clientProvider ClientProvider) lookupFunc { if apierrors.IsNotFound(err) { // Just return an empty interface when the object was not found. // That way, users can use `if not (lookup ...)` in their templates. - return map[string]interface{}{}, nil + return map[string]any{}, nil } - return map[string]interface{}{}, err + return map[string]any{}, err } return obj.UnstructuredContent(), nil } @@ -85,9 +85,9 @@ func newLookupFunction(clientProvider ClientProvider) lookupFunc { if apierrors.IsNotFound(err) { // Just return an empty interface when the object was not found. // That way, users can use `if not (lookup ...)` in their templates. - return map[string]interface{}{}, nil + return map[string]any{}, nil } - return map[string]interface{}{}, err + return map[string]any{}, err } return obj.UnstructuredContent(), nil } diff --git a/pkg/helm/pkg/getter/getter_test.go b/pkg/helm/pkg/getter/getter_test.go index 74628968..a707dd14 100644 --- a/pkg/helm/pkg/getter/getter_test.go +++ b/pkg/helm/pkg/getter/getter_test.go @@ -56,7 +56,8 @@ func TestProvidersWithTimeout(t *testing.T) { if err != nil { t.Error(err) } - client, err := getter.(*HTTPGetter).httpClient() + httpGetter := getter.(*HTTPGetter) + client, err := httpGetter.httpClient(httpGetter.opts) if err != nil { t.Error(err) } diff --git a/pkg/helm/pkg/getter/httpgetter.go b/pkg/helm/pkg/getter/httpgetter.go index 059fbfc9..f9d7f669 100644 --- a/pkg/helm/pkg/getter/httpgetter.go +++ b/pkg/helm/pkg/getter/httpgetter.go @@ -20,6 +20,7 @@ import ( "crypto/tls" "fmt" "io" + "log/slog" "net/http" "net/url" "sync" @@ -37,13 +38,15 @@ type HTTPGetter struct { // Get performs a Get from repo.Getter and returns the body. func (g *HTTPGetter) Get(href string, options ...Option) (*bytes.Buffer, error) { + // Create a local copy of options to avoid data races when Get is called concurrently + opts := g.opts for _, opt := range options { - opt(&g.opts) + opt(&opts) } - return g.get(href) + return g.get(href, opts) } -func (g *HTTPGetter) get(href string) (*bytes.Buffer, error) { +func (g *HTTPGetter) get(href string, opts getterOptions) (*bytes.Buffer, error) { // Set a helm specific user agent so that a repo server and metrics can // separate helm calls from other tools interacting with repos. req, err := http.NewRequest(http.MethodGet, href, nil) @@ -51,18 +54,18 @@ func (g *HTTPGetter) get(href string) (*bytes.Buffer, error) { return nil, err } - if g.opts.acceptHeader != "" { - req.Header.Set("Accept", g.opts.acceptHeader) + if opts.acceptHeader != "" { + req.Header.Set("Accept", opts.acceptHeader) } req.Header.Set("User-Agent", version.GetUserAgent()) - if g.opts.userAgent != "" { - req.Header.Set("User-Agent", g.opts.userAgent) + if opts.userAgent != "" { + req.Header.Set("User-Agent", opts.userAgent) } // Before setting the basic auth credentials, make sure the URL associated // with the basic auth is the one being fetched. - u1, err := url.Parse(g.opts.url) + u1, err := url.Parse(opts.url) if err != nil { return nil, fmt.Errorf("unable to parse getter URL: %w", err) } @@ -74,22 +77,24 @@ func (g *HTTPGetter) get(href string) (*bytes.Buffer, error) { // Host on URL (returned from url.Parse) contains the port if present. // This check ensures credentials are not passed between different // services on different ports. - if g.opts.passCredentialsAll || (u1.Scheme == u2.Scheme && u1.Host == u2.Host) { - if g.opts.username != "" && g.opts.password != "" { - req.SetBasicAuth(g.opts.username, g.opts.password) + if opts.passCredentialsAll || (u1.Scheme == u2.Scheme && u1.Host == u2.Host) { + if opts.username != "" && opts.password != "" { + req.SetBasicAuth(opts.username, opts.password) } } - client, err := g.httpClient() + client, err := g.httpClient(opts) if err != nil { return nil, err } + slog.Debug("fetching", "url", href) resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() + slog.Debug("fetch complete", "url", href, "status", resp.Status, "content-length", resp.ContentLength) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to fetch %s : %s", href, resp.Status) } @@ -110,51 +115,52 @@ func NewHTTPGetter(options ...Option) (Getter, error) { return &client, nil } -func (g *HTTPGetter) httpClient() (*http.Client, error) { - if g.opts.transport != nil { +func (g *HTTPGetter) httpClient(opts getterOptions) (*http.Client, error) { + if opts.transport != nil { return &http.Client{ - Transport: g.opts.transport, - Timeout: g.opts.timeout, + Transport: opts.transport, + Timeout: opts.timeout, }, nil } - g.once.Do(func() { - g.transport = &http.Transport{ + // Check if we need custom TLS configuration + needsCustomTLS := (opts.certFile != "" && opts.keyFile != "") || opts.caFile != "" || opts.insecureSkipVerifyTLS + + if needsCustomTLS { + // Create a new transport for custom TLS to avoid race conditions + transport := &http.Transport{ DisableCompression: true, Proxy: http.ProxyFromEnvironment, - // Being nil would cause the tls.Config default to be used - // "NewTLSConfig" modifies an empty TLS config, not the default one - TLSClientConfig: &tls.Config{}, } - }) - if (g.opts.certFile != "" && g.opts.keyFile != "") || g.opts.caFile != "" || g.opts.insecureSkipVerifyTLS { tlsConf, err := tlsutil.NewTLSConfig( - tlsutil.WithInsecureSkipVerify(g.opts.insecureSkipVerifyTLS), - tlsutil.WithCertKeyPairFiles(g.opts.certFile, g.opts.keyFile), - tlsutil.WithCAFile(g.opts.caFile), + tlsutil.WithInsecureSkipVerify(opts.insecureSkipVerifyTLS), + tlsutil.WithCertKeyPairFiles(opts.certFile, opts.keyFile), + tlsutil.WithCAFile(opts.caFile), ) if err != nil { return nil, fmt.Errorf("can't create TLS config for client: %w", err) } - g.transport.TLSClientConfig = tlsConf + transport.TLSClientConfig = tlsConf + + return &http.Client{ + Transport: transport, + Timeout: opts.timeout, + }, nil } - if g.opts.insecureSkipVerifyTLS { - if g.transport.TLSClientConfig == nil { - g.transport.TLSClientConfig = &tls.Config{ - InsecureSkipVerify: true, - } - } else { - g.transport.TLSClientConfig.InsecureSkipVerify = true + // Use shared transport for default case (no custom TLS) + g.once.Do(func() { + g.transport = &http.Transport{ + DisableCompression: true, + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: &tls.Config{}, } - } + }) - client := &http.Client{ + return &http.Client{ Transport: g.transport, - Timeout: g.opts.timeout, - } - - return client, nil + Timeout: opts.timeout, + }, nil } diff --git a/pkg/helm/pkg/getter/httpgetter_test.go b/pkg/helm/pkg/getter/httpgetter_test.go index cac1c165..64be6e07 100644 --- a/pkg/helm/pkg/getter/httpgetter_test.go +++ b/pkg/helm/pkg/getter/httpgetter_test.go @@ -403,7 +403,7 @@ func TestDownloadTLSWithRedirect(t *testing.T) { // request URL for every request (including redirects). Setting `tls.Config.ServerName` on the // client just overrides the remote endpoint's hostname. // See https://github.com/golang/go/blob/3979fb9/src/net/http/transport.go#L1505-L1513. - u.Host = fmt.Sprintf("localhost:%s", u.Port()) + u.Host = "localhost:" + u.Port() http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect) })) @@ -577,16 +577,16 @@ func TestHttpClientInsecureSkipVerify(t *testing.T) { func verifyInsecureSkipVerify(t *testing.T, g *HTTPGetter, caseName string, expectedValue bool) *http.Transport { t.Helper() - returnVal, err := g.httpClient() + returnVal, err := g.httpClient(g.opts) if err != nil { t.Fatal(err) } - if returnVal == nil { //nolint:staticcheck - t.Fatalf("Expected non nil value for http client") + if returnVal == nil { + t.Fatal("Expected non nil value for http client") } - transport := (returnVal.Transport).(*http.Transport) //nolint:staticcheck + transport := (returnVal.Transport).(*http.Transport) gotValue := false if transport.TLSClientConfig != nil { gotValue = transport.TLSClientConfig.InsecureSkipVerify @@ -601,32 +601,32 @@ func verifyInsecureSkipVerify(t *testing.T, g *HTTPGetter, caseName string, expe func TestDefaultHTTPTransportReuse(t *testing.T) { g := HTTPGetter{} - httpClient1, err := g.httpClient() + httpClient1, err := g.httpClient(g.opts) if err != nil { t.Fatal(err) } - if httpClient1 == nil { //nolint:staticcheck - t.Fatalf("Expected non nil value for http client") + if httpClient1 == nil { + t.Fatal("Expected non nil value for http client") } - transport1 := (httpClient1.Transport).(*http.Transport) //nolint:staticcheck + transport1 := (httpClient1.Transport).(*http.Transport) - httpClient2, err := g.httpClient() + httpClient2, err := g.httpClient(g.opts) if err != nil { t.Fatal(err) } - if httpClient2 == nil { //nolint:staticcheck - t.Fatalf("Expected non nil value for http client") + if httpClient2 == nil { + t.Fatal("Expected non nil value for http client") } - transport2 := (httpClient2.Transport).(*http.Transport) //nolint:staticcheck + transport2 := (httpClient2.Transport).(*http.Transport) if transport1 != transport2 { - t.Fatalf("Expected default transport to be reused") + t.Fatal("Expected default transport to be reused") } } @@ -635,36 +635,36 @@ func TestHTTPTransportOption(t *testing.T) { g := HTTPGetter{} g.opts.transport = transport - httpClient1, err := g.httpClient() + httpClient1, err := g.httpClient(g.opts) if err != nil { t.Fatal(err) } - if httpClient1 == nil { //nolint:staticcheck - t.Fatalf("Expected non nil value for http client") + if httpClient1 == nil { + t.Fatal("Expected non nil value for http client") } - transport1 := (httpClient1.Transport).(*http.Transport) //nolint:staticcheck + transport1 := (httpClient1.Transport).(*http.Transport) if transport1 != transport { - t.Fatalf("Expected transport option to be applied") + t.Fatal("Expected transport option to be applied") } - httpClient2, err := g.httpClient() + httpClient2, err := g.httpClient(g.opts) if err != nil { t.Fatal(err) } - if httpClient2 == nil { //nolint:staticcheck - t.Fatalf("Expected non nil value for http client") + if httpClient2 == nil { + t.Fatal("Expected non nil value for http client") } - transport2 := (httpClient2.Transport).(*http.Transport) //nolint:staticcheck + transport2 := (httpClient2.Transport).(*http.Transport) if transport1 != transport2 { - t.Fatalf("Expected applied transport to be reused") + t.Fatal("Expected applied transport to be reused") } g = HTTPGetter{} diff --git a/pkg/helm/pkg/getter/ocigetter.go b/pkg/helm/pkg/getter/ocigetter.go index 65bc6d26..d94ea190 100644 --- a/pkg/helm/pkg/getter/ocigetter.go +++ b/pkg/helm/pkg/getter/ocigetter.go @@ -58,7 +58,7 @@ func (g *OCIGetter) get(href string) (*bytes.Buffer, error) { client = c } - ref := strings.TrimPrefix(href, fmt.Sprintf("%s://", registry.OCIScheme)) + ref := strings.TrimPrefix(href, registry.OCIScheme+"://") if version := g.opts.version; version != "" && !strings.Contains(path.Base(ref), ":") { ref = fmt.Sprintf("%s:%s", ref, version) diff --git a/pkg/helm/pkg/getter/ocigetter_test.go b/pkg/helm/pkg/getter/ocigetter_test.go index ec79ea55..b2535355 100644 --- a/pkg/helm/pkg/getter/ocigetter_test.go +++ b/pkg/helm/pkg/getter/ocigetter_test.go @@ -128,7 +128,7 @@ func TestOCIHTTPTransportReuse(t *testing.T) { } if g.transport == nil { - t.Fatalf("Expected non nil value for transport") + t.Fatal("Expected non nil value for transport") } transport1 := g.transport @@ -140,12 +140,12 @@ func TestOCIHTTPTransportReuse(t *testing.T) { } if g.transport == nil { - t.Fatalf("Expected non nil value for transport") + t.Fatal("Expected non nil value for transport") } transport2 := g.transport if transport1 != transport2 { - t.Fatalf("Expected default transport to be reused") + t.Fatal("Expected default transport to be reused") } } diff --git a/pkg/helm/pkg/kube/client.go b/pkg/helm/pkg/kube/client.go index 0e07043c..af292cf7 100644 --- a/pkg/helm/pkg/kube/client.go +++ b/pkg/helm/pkg/kube/client.go @@ -272,12 +272,12 @@ type ClientCreateOption func(*clientCreateOptions) error // ClientCreateOptionServerSideApply enables performing object apply server-side // see: https://kubernetes.io/docs/reference/using-api/server-side-apply/ // -// `forceConflicts` forces conflicts to be resolved (may be when serverSideApply enabled only) +// `forceConflicts` forces conflicts to be resolved (may be used when serverSideApply enabled only) // see: https://kubernetes.io/docs/reference/using-api/server-side-apply/#conflicts func ClientCreateOptionServerSideApply(serverSideApply, forceConflicts bool) ClientCreateOption { return func(o *clientCreateOptions) error { if !serverSideApply && forceConflicts { - return fmt.Errorf("forceConflicts enabled when serverSideApply disabled") + return errors.New("forceConflicts enabled when serverSideApply disabled") } o.serverSideApply = serverSideApply @@ -600,7 +600,32 @@ func (c *Client) update(originals, targets ResourceList, createApplyFunc CreateA original := originals.Get(target) if original == nil { kind := target.Mapping.GroupVersionKind.Kind - return fmt.Errorf("original object %s with the name %q not found", kind, target.Name) + + slog.Warn("resource exists on cluster but not in original release, using cluster state as baseline", + "namespace", target.Namespace, "name", target.Name, "kind", kind) + + currentObj, err := helper.Get(target.Namespace, target.Name) + if err != nil { + return fmt.Errorf("original object %s with the name %q not found", kind, target.Name) + } + + // Create a temporary Info with the current cluster state to use as "original" + currentInfo := &resource.Info{ + Client: target.Client, + Mapping: target.Mapping, + Namespace: target.Namespace, + Name: target.Name, + Object: currentObj, + } + + if err := updateApplyFunc(currentInfo, target); err != nil { + updateErrors = append(updateErrors, err) + } + + // Because we check for errors later, append the info regardless + res.Updated = append(res.Updated, target) + + return nil } if err := updateApplyFunc(original, target); err != nil { @@ -656,7 +681,9 @@ func (c *Client) update(originals, targets ResourceList, createApplyFunc CreateA slog.Any("error", err), ) if !apierrors.IsNotFound(err) { - updateErrors = append(updateErrors, fmt.Errorf("failed to delete resource %s: %w", info.Name, err)) + updateErrors = append(updateErrors, fmt.Errorf( + "failed to delete resource namespace=%s, name=%s, kind=%s: %w", + info.Namespace, info.Name, info.Mapping.GroupVersionKind.Kind, err)) } continue } @@ -700,7 +727,7 @@ func ClientUpdateOptionThreeWayMergeForUnstructured(threeWayMergeForUnstructured func ClientUpdateOptionServerSideApply(serverSideApply, forceConflicts bool) ClientUpdateOption { return func(o *clientUpdateOptions) error { if !serverSideApply && forceConflicts { - return fmt.Errorf("forceConflicts enabled when serverSideApply disabled") + return errors.New("forceConflicts enabled when serverSideApply disabled") } o.serverSideApply = serverSideApply @@ -784,15 +811,15 @@ func (c *Client) Update(originals, targets ResourceList, options ...ClientUpdate } if updateOptions.threeWayMergeForUnstructured && updateOptions.serverSideApply { - return &Result{}, fmt.Errorf("invalid operation: cannot use three-way merge for unstructured and server-side apply together") + return &Result{}, errors.New("invalid operation: cannot use three-way merge for unstructured and server-side apply together") } if updateOptions.forceConflicts && updateOptions.forceReplace { - return &Result{}, fmt.Errorf("invalid operation: cannot use force conflicts and force replace together") + return &Result{}, errors.New("invalid operation: cannot use force conflicts and force replace together") } if updateOptions.serverSideApply && updateOptions.forceReplace { - return &Result{}, fmt.Errorf("invalid operation: cannot use server-side apply and force replace together") + return &Result{}, errors.New("invalid operation: cannot use server-side apply and force replace together") } createApplyFunc := c.makeCreateApplyFunc( @@ -1214,7 +1241,7 @@ func patchResourceServerSide(target *resource.Info, dryRun bool, forceConflicts return fmt.Errorf("conflict occurred while applying object %s/%s %s: %w", target.Namespace, target.Name, target.Mapping.GroupVersionKind.String(), err) } - return err + return fmt.Errorf("server-side apply failed for object %s/%s %s: %w", target.Namespace, target.Name, target.Mapping.GroupVersionKind.String(), err) } return target.Refresh(obj, true) diff --git a/pkg/helm/pkg/kube/client_test.go b/pkg/helm/pkg/kube/client_test.go index c44b0d7d..ed871c05 100644 --- a/pkg/helm/pkg/kube/client_test.go +++ b/pkg/helm/pkg/kube/client_test.go @@ -20,7 +20,6 @@ import ( "bytes" "context" "errors" - "fmt" "io" "net/http" "strings" @@ -419,7 +418,25 @@ func TestUpdate(t *testing.T) { "/namespaces/default/pods/forbidden:GET", "/namespaces/default/pods/forbidden:DELETE", ), - ExpectedError: "failed to delete resource forbidden:", + ExpectedError: "failed to delete resource namespace=default, name=forbidden, kind=Pod:", + }, + "rollback after failed upgrade with removed resource": { + // Simulates rollback scenario: + // - Revision 1 had "newpod" + // - Revision 2 removed "newpod" but upgrade failed (OriginalPods is empty) + // - Cluster still has "newpod" from Revision 1 + // - Rolling back to Revision 1 (TargetPods with "newpod") should succeed + OriginalPods: v1.PodList{}, // Revision 2 (failed) - resource was removed + TargetPods: newPodList("newpod"), // Revision 1 - rolling back to this + ThreeWayMergeForUnstructured: false, + ServerSideApply: true, + ExpectedActions: []string{ + "/namespaces/default/pods/newpod:GET", // Check if resource exists + "/namespaces/default/pods/newpod:GET", // Get current state (first call in update path) + "/namespaces/default/pods/newpod:GET", // Get current cluster state to use as baseline + "/namespaces/default/pods/newpod:PATCH", // Update using cluster state as baseline + }, + ExpectedError: "", }, } @@ -436,6 +453,10 @@ func TestUpdate(t *testing.T) { p, m := req.URL.Path, req.Method switch { + case p == "/namespaces/default/pods/newpod" && m == http.MethodGet: + return newResponse(http.StatusOK, &listTarget.Items[0]) + case p == "/namespaces/default/pods/newpod" && m == http.MethodPatch: + return newResponse(http.StatusOK, &listTarget.Items[0]) case p == "/namespaces/default/pods/starfish" && m == http.MethodGet: return newResponse(http.StatusOK, &listOriginal.Items[0]) case p == "/namespaces/default/pods/otter" && m == http.MethodGet: @@ -527,9 +548,23 @@ func TestUpdate(t *testing.T) { require.NoError(t, err) } - assert.Len(t, result.Created, 1, "expected 1 resource created, got %d", len(result.Created)) - assert.Len(t, result.Updated, 2, "expected 2 resource updated, got %d", len(result.Updated)) - assert.Len(t, result.Deleted, 1, "expected 1 resource deleted, got %d", len(result.Deleted)) + // Special handling for the rollback test case + if name == "rollback after failed upgrade with removed resource" { + assert.Len(t, result.Created, 0, "expected 0 resource created, got %d", len(result.Created)) + assert.Len(t, result.Updated, 1, "expected 1 resource updated, got %d", len(result.Updated)) + assert.Len(t, result.Deleted, 0, "expected 0 resource deleted, got %d", len(result.Deleted)) + } else { + assert.Len(t, result.Created, 1, "expected 1 resource created, got %d", len(result.Created)) + assert.Len(t, result.Updated, 2, "expected 2 resource updated, got %d", len(result.Updated)) + assert.Len(t, result.Deleted, 1, "expected 1 resource deleted, got %d", len(result.Deleted)) + } + + if tc.ExpectedError != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.ExpectedError) + } else { + require.NoError(t, err) + } actions := []string{} for _, action := range client.Actions { @@ -937,6 +972,7 @@ func TestGetPodList(t *testing.T) { podList, err := c.GetPodList(namespace, metav1.ListOptions{}) clientAssertions := assert.New(t) clientAssertions.NoError(err) + podList.ResourceVersion = "" clientAssertions.Equal(&responsePodList, podList) } @@ -1398,7 +1434,7 @@ func TestIsIncompatibleServerError(t *testing.T) { Want: false, }, "Generic error": { - Err: fmt.Errorf("some generic error"), + Err: errors.New("some generic error"), Want: false, }, } @@ -1767,6 +1803,23 @@ func TestPatchResourceServerSide(t *testing.T) { }, ExpectedErrorContains: "the server reported a conflict", }, + "generic server-side apply error": { + Pods: newPodList("whale"), + DryRun: false, + ForceConflicts: false, + FieldValidationDirective: FieldValidationDirectiveStrict, + Callback: func(t *testing.T, _ testCase, _ []RequestResponseAction, _ *http.Request) (*http.Response, error) { + t.Helper() + + return newResponse(http.StatusBadRequest, &metav1.Status{ + Status: metav1.StatusFailure, + Message: `failed to create typed patch object: .spec.template.spec.containers[name="test"].env: duplicate entries for key [name="SERVER_CONTEXT_PATH"]`, + Reason: metav1.StatusReasonBadRequest, + Code: http.StatusBadRequest, + }) + }, + ExpectedErrorContains: "server-side apply failed for object default/whale /v1, Kind=Pod: failed to create typed patch object", + }, } for name, tc := range testCases { diff --git a/pkg/helm/pkg/kube/fake/printer.go b/pkg/helm/pkg/kube/fake/printer.go index 7e0c98b4..2360329f 100644 --- a/pkg/helm/pkg/kube/fake/printer.go +++ b/pkg/helm/pkg/kube/fake/printer.go @@ -17,7 +17,6 @@ limitations under the License. package fake import ( - "fmt" "io" "strings" "time" @@ -133,7 +132,7 @@ func (p *PrintingKubeClient) GetPodList(_ string, _ metav1.ListOptions) (*v1.Pod // OutputContainerLogsForPodList implements KubeClient OutputContainerLogsForPodList. func (p *PrintingKubeClient) OutputContainerLogsForPodList(_ *v1.PodList, someNamespace string, _ func(namespace, pod, container string) io.Writer) error { - _, err := io.Copy(p.LogOutput, strings.NewReader(fmt.Sprintf("attempted to output logs for namespace: %s", someNamespace))) + _, err := io.Copy(p.LogOutput, strings.NewReader("attempted to output logs for namespace: "+someNamespace)) return err } diff --git a/pkg/helm/pkg/kube/roundtripper.go b/pkg/helm/pkg/kube/roundtripper.go index 52cb5bad..61403efa 100644 --- a/pkg/helm/pkg/kube/roundtripper.go +++ b/pkg/helm/pkg/kube/roundtripper.go @@ -16,65 +16,9 @@ limitations under the License. package kube -import ( - "bytes" - "encoding/json" - "io" - "net/http" - "strings" -) +import "github.com/werf/nelm/pkg/helm/pkg/kubeenv" -type RetryingRoundTripper struct { - Wrapped http.RoundTripper -} - -func (rt *RetryingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - return rt.roundTrip(req, 1, nil) -} - -func (rt *RetryingRoundTripper) roundTrip(req *http.Request, retry int, prevResp *http.Response) (*http.Response, error) { - if retry < 0 { - return prevResp, nil - } - resp, rtErr := rt.Wrapped.RoundTrip(req) - if rtErr != nil { - return resp, rtErr - } - if resp.StatusCode < 500 { - return resp, rtErr - } - if resp.Header.Get("content-type") != "application/json" { - return resp, rtErr - } - b, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - return resp, err - } - - var ke kubernetesError - r := bytes.NewReader(b) - err = json.NewDecoder(r).Decode(&ke) - r.Seek(0, io.SeekStart) - resp.Body = io.NopCloser(r) - if err != nil { - return resp, err - } - if ke.Code < 500 { - return resp, nil - } - // Matches messages like "etcdserver: leader changed" - if strings.HasSuffix(ke.Message, "etcdserver: leader changed") { - return rt.roundTrip(req, retry-1, resp) - } - // Matches messages like "rpc error: code = Unknown desc = raft proposal dropped" - if strings.HasSuffix(ke.Message, "raft proposal dropped") { - return rt.roundTrip(req, retry-1, resp) - } - return resp, nil -} - -type kubernetesError struct { - Message string `json:"message"` - Code int `json:"code"` -} +// RetryingRoundTripper retries transient Kubernetes API server errors on a +// wrapped [http.RoundTripper]. The implementation lives in [kubeenv] so +// consumers can depend on that package without importing all of kube. +type RetryingRoundTripper = kubeenv.RetryingRoundTripper diff --git a/pkg/helm/pkg/kube/statuswait_test.go b/pkg/helm/pkg/kube/statuswait_test.go index 85aaf879..6101491c 100644 --- a/pkg/helm/pkg/kube/statuswait_test.go +++ b/pkg/helm/pkg/kube/statuswait_test.go @@ -271,7 +271,7 @@ func getRuntimeObjFromManifests(t *testing.T, manifests []string) []runtime.Obje t.Helper() objects := []runtime.Object{} for _, manifest := range manifests { - m := make(map[string]interface{}) + m := make(map[string]any) err := yaml.Unmarshal([]byte(manifest), &m) assert.NoError(t, err) resource := &unstructured.Unstructured{Object: m} @@ -715,7 +715,7 @@ func setupRestrictedClient(fakeClient *dynamicfake.FakeDynamicClient, allowedNam return true, nil, apierrors.NewForbidden( action.GetResource().GroupResource(), "", - fmt.Errorf("user does not have cluster-wide LIST permissions for cluster-scoped resources"), + errors.New("user does not have cluster-wide LIST permissions for cluster-scoped resources"), ) } if !config.allowedNamespaces[ns] { @@ -739,7 +739,7 @@ func setupRestrictedClient(fakeClient *dynamicfake.FakeDynamicClient, allowedNam return true, nil, apierrors.NewForbidden( action.GetResource().GroupResource(), "", - fmt.Errorf("user does not have cluster-wide WATCH permissions for cluster-scoped resources"), + errors.New("user does not have cluster-wide WATCH permissions for cluster-scoped resources"), ) } if !config.allowedNamespaces[ns] { @@ -793,7 +793,7 @@ func TestStatusWaitRestrictedRBAC(t *testing.T) { name: "error when cluster-scoped resource included", objManifests: []string{podNamespace1Manifest, clusterRoleManifest}, allowedNamespaces: []string{"namespace-1"}, - expectErrs: []error{fmt.Errorf("user does not have cluster-wide LIST permissions for cluster-scoped resources")}, + expectErrs: []error{errors.New("user does not have cluster-wide LIST permissions for cluster-scoped resources")}, testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { return sw.Wait(rl, timeout) }, @@ -802,7 +802,7 @@ func TestStatusWaitRestrictedRBAC(t *testing.T) { name: "error when deleting cluster-scoped resource", objManifests: []string{podNamespace1Manifest, namespaceManifest}, allowedNamespaces: []string{"namespace-1"}, - expectErrs: []error{fmt.Errorf("user does not have cluster-wide LIST permissions for cluster-scoped resources")}, + expectErrs: []error{errors.New("user does not have cluster-wide LIST permissions for cluster-scoped resources")}, testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { return sw.WaitForDelete(rl, timeout) }, @@ -892,7 +892,7 @@ func TestStatusWaitMixedResources(t *testing.T) { name: "wait fails when cluster-scoped resource included", objManifests: []string{podNamespace1Manifest, clusterRoleManifest}, allowedNamespaces: []string{"namespace-1"}, - expectErrs: []error{fmt.Errorf("user does not have cluster-wide LIST permissions for cluster-scoped resources")}, + expectErrs: []error{errors.New("user does not have cluster-wide LIST permissions for cluster-scoped resources")}, testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { return sw.Wait(rl, timeout) }, @@ -901,7 +901,7 @@ func TestStatusWaitMixedResources(t *testing.T) { name: "waitForDelete fails when cluster-scoped resource included", objManifests: []string{podNamespace1Manifest, clusterRoleManifest}, allowedNamespaces: []string{"namespace-1"}, - expectErrs: []error{fmt.Errorf("user does not have cluster-wide LIST permissions for cluster-scoped resources")}, + expectErrs: []error{errors.New("user does not have cluster-wide LIST permissions for cluster-scoped resources")}, testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { return sw.WaitForDelete(rl, timeout) }, @@ -910,7 +910,7 @@ func TestStatusWaitMixedResources(t *testing.T) { name: "wait fails when namespace resource included", objManifests: []string{podNamespace1Manifest, namespaceManifest}, allowedNamespaces: []string{"namespace-1"}, - expectErrs: []error{fmt.Errorf("user does not have cluster-wide LIST permissions for cluster-scoped resources")}, + expectErrs: []error{errors.New("user does not have cluster-wide LIST permissions for cluster-scoped resources")}, testFunc: func(sw *statusWaiter, rl ResourceList, timeout time.Duration) error { return sw.Wait(rl, timeout) }, @@ -1680,8 +1680,6 @@ func TestMethodContextOverridesGeneralContext(t *testing.T) { t.Run("method-specific context overrides general context for WaitForDelete", func(t *testing.T) { t.Parallel() c := newTestClient(t) - timeout := time.Second - timeUntilPodDelete := time.Millisecond * 500 fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) fakeMapper := testutil.NewFakeRESTMapper( v1.SchemeGroupVersion.WithKind("Pod"), @@ -1698,27 +1696,14 @@ func TestMethodContextOverridesGeneralContext(t *testing.T) { waitForDeleteCtx: context.Background(), // Not cancelled - should be used } + // Use a non-existent resource: WaitForDelete should return immediately since + // the pod is already in the desired "deleted" state. + // This also validates context selection: if generalCtx (cancelled) were + // incorrectly used instead of waitForDeleteCtx, the watch context would be + // immediately cancelled and the call would return a context error. objs := getRuntimeObjFromManifests(t, []string{podCurrentManifest}) - for _, obj := range objs { - u := obj.(*unstructured.Unstructured) - gvr := getGVR(t, fakeMapper, u) - err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace()) - require.NoError(t, err) - } - - // Schedule deletion - for _, obj := range objs { - u := obj.(*unstructured.Unstructured) - gvr := getGVR(t, fakeMapper, u) - go func(gvr schema.GroupVersionResource, u *unstructured.Unstructured) { - time.Sleep(timeUntilPodDelete) - err := fakeClient.Tracker().Delete(gvr, u.GetNamespace(), u.GetName()) - assert.NoError(t, err) - }(gvr, u) - } - resourceList := getResourceListFromRuntimeObjs(t, c, objs) - err := sw.WaitForDelete(resourceList, timeout) + err := sw.WaitForDelete(resourceList, time.Second) // Should succeed because method context is used and it's not cancelled assert.NoError(t, err) }) diff --git a/pkg/helm/pkg/kube/wait.go b/pkg/helm/pkg/kube/wait.go index 747291a9..9a4125d5 100644 --- a/pkg/helm/pkg/kube/wait.go +++ b/pkg/helm/pkg/kube/wait.go @@ -18,6 +18,7 @@ package kube // import "github.com/werf/nelm/pkg/helm/pkg/kube" import ( "context" + "errors" "fmt" "log/slog" "net/http" @@ -107,7 +108,8 @@ func (hw *legacyWaiter) isRetryableError(err error, resource *resource.Info) boo slog.String("resource", resource.Name), slog.Any("error", err), ) - if ev, ok := err.(*apierrors.StatusError); ok { + ev := &apierrors.StatusError{} + if errors.As(err, &ev) { statusCode := ev.Status().Code retryable := hw.isRetryableHTTPStatusCode(statusCode) slog.Debug( @@ -244,7 +246,7 @@ func (hw *legacyWaiter) watchUntilReady(timeout time.Duration, info *resource.In // Use a selector on the name of the resource. This should be unique for the // given version and kind - selector, err := fields.ParseSelector(fmt.Sprintf("metadata.name=%s", info.Name)) + selector, err := fields.ParseSelector("metadata.name=" + info.Name) if err != nil { return err } diff --git a/pkg/helm/pkg/kube/wait_test.go b/pkg/helm/pkg/kube/wait_test.go index d96f2c48..73270b5a 100644 --- a/pkg/helm/pkg/kube/wait_test.go +++ b/pkg/helm/pkg/kube/wait_test.go @@ -17,7 +17,7 @@ limitations under the License. package kube import ( - "fmt" + "errors" "net/http" "strings" "testing" @@ -39,7 +39,7 @@ import ( func TestSelectorsForObject(t *testing.T) { tests := []struct { name string - object interface{} + object any expectError bool errorContains string expectedLabels map[string]string @@ -299,7 +299,7 @@ func TestLegacyWaiter_waitForPodSuccess(t *testing.T) { done, err := lw.waitForPodSuccess(tt.obj, "foo") if tt.wantErr { if err == nil { - t.Errorf("expected error, got none") + t.Error("expected error, got none") } else if !strings.Contains(err.Error(), tt.errMessage) { t.Errorf("expected error to contain %q, got %q", tt.errMessage, err.Error()) } @@ -391,7 +391,7 @@ func TestLegacyWaiter_waitForJob(t *testing.T) { done, err := lw.waitForJob(tt.obj, "test-job") if tt.wantErr { if err == nil { - t.Errorf("expected error, got none") + t.Error("expected error, got none") } else if !strings.Contains(err.Error(), tt.errMessage) { t.Errorf("expected error to contain %q, got %q", tt.errMessage, err.Error()) } @@ -451,7 +451,7 @@ func TestLegacyWaiter_isRetryableError(t *testing.T) { }, { name: "non-status error", - err: fmt.Errorf("some generic error"), + err: errors.New("some generic error"), wantRetry: true, }, } diff --git a/pkg/helm/pkg/kubeenv/roundtripper.go b/pkg/helm/pkg/kubeenv/roundtripper.go new file mode 100644 index 00000000..e00f9398 --- /dev/null +++ b/pkg/helm/pkg/kubeenv/roundtripper.go @@ -0,0 +1,84 @@ +/* +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 kubeenv holds small, cycle-free Kubernetes client helpers shared by +// higher-level packages (for example pkg/cli and pkg/kube). +package kubeenv + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strings" +) + +// RetryingRoundTripper retries transient Kubernetes API server errors on a +// wrapped [http.RoundTripper]. +type RetryingRoundTripper struct { + Wrapped http.RoundTripper +} + +func (rt *RetryingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return rt.roundTrip(req, 1, nil) +} + +func (rt *RetryingRoundTripper) roundTrip(req *http.Request, retry int, prevResp *http.Response) (*http.Response, error) { + if retry < 0 { + return prevResp, nil + } + resp, rtErr := rt.Wrapped.RoundTrip(req) + if rtErr != nil { + return resp, rtErr + } + if resp.StatusCode < 500 { + return resp, rtErr + } + if resp.Header.Get("content-type") != "application/json" { + return resp, rtErr + } + b, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return resp, err + } + + var ke kubernetesError + r := bytes.NewReader(b) + err = json.NewDecoder(r).Decode(&ke) + r.Seek(0, io.SeekStart) + resp.Body = io.NopCloser(r) + if err != nil { + return resp, err + } + if ke.Code < 500 { + return resp, nil + } + // Matches messages like "etcdserver: leader changed" + if strings.HasSuffix(ke.Message, "etcdserver: leader changed") { + return rt.roundTrip(req, retry-1, resp) + } + // Matches messages like "rpc error: code = Unknown desc = raft proposal dropped" + if strings.HasSuffix(ke.Message, "raft proposal dropped") { + return rt.roundTrip(req, retry-1, resp) + } + return resp, nil +} + +type kubernetesError struct { + Message string `json:"message"` + Code int `json:"code"` +} diff --git a/pkg/helm/pkg/kube/roundtripper_test.go b/pkg/helm/pkg/kubeenv/roundtripper_test.go similarity index 99% rename from pkg/helm/pkg/kube/roundtripper_test.go rename to pkg/helm/pkg/kubeenv/roundtripper_test.go index 96602c1f..b921eac8 100644 --- a/pkg/helm/pkg/kube/roundtripper_test.go +++ b/pkg/helm/pkg/kubeenv/roundtripper_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package kube +package kubeenv import ( "encoding/json" diff --git a/pkg/helm/pkg/provenance/sign.go b/pkg/helm/pkg/provenance/sign.go index 57af1ad4..45d4fe1a 100644 --- a/pkg/helm/pkg/provenance/sign.go +++ b/pkg/helm/pkg/provenance/sign.go @@ -25,9 +25,9 @@ import ( "os" "strings" - "github.com/ProtonMail/go-crypto/openpgp" //nolint - "github.com/ProtonMail/go-crypto/openpgp/clearsign" //nolint - "github.com/ProtonMail/go-crypto/openpgp/packet" //nolint + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/clearsign" + "github.com/ProtonMail/go-crypto/openpgp/packet" "sigs.k8s.io/yaml" ) @@ -332,7 +332,7 @@ func parseMessageBlock(data []byte) (*SumCollection, error) { // // This is the generic version that can work with any metadata type. // The metadata parameter should be a pointer to a struct that can be unmarshaled from YAML. -func ParseMessageBlock(data []byte, metadata interface{}, sums *SumCollection) error { +func ParseMessageBlock(data []byte, metadata any, sums *SumCollection) error { parts := bytes.Split(data, []byte("\n...\n")) if len(parts) < 2 { return errors.New("message block must have at least two parts") diff --git a/pkg/helm/pkg/provenance/sign_test.go b/pkg/helm/pkg/provenance/sign_test.go index 2fbf3b88..a06cd3b0 100644 --- a/pkg/helm/pkg/provenance/sign_test.go +++ b/pkg/helm/pkg/provenance/sign_test.go @@ -18,15 +18,15 @@ package provenance import ( "context" "crypto" - "fmt" + "errors" "io" "os" "path/filepath" "strings" "testing" - pgperrors "github.com/ProtonMail/go-crypto/openpgp/errors" //nolint - "github.com/ProtonMail/go-crypto/openpgp/packet" //nolint + pgperrors "github.com/ProtonMail/go-crypto/openpgp/errors" + "github.com/ProtonMail/go-crypto/openpgp/packet" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/yaml" @@ -135,7 +135,7 @@ func TestParseMessageBlock(t *testing.T) { } if hash, ok := sc.Files["hashtest-1.2.3.tgz"]; !ok { - t.Errorf("hashtest file not found in Files") + t.Error("hashtest file not found in Files") } else if hash != "sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888" { t.Errorf("Unexpected hash: %q", hash) } @@ -331,7 +331,7 @@ func (s failSigner) Public() crypto.PublicKey { } func (s failSigner) Sign(_ io.Reader, _ []byte, _ crypto.SignerOpts) ([]byte, error) { - return nil, fmt.Errorf("always fails") + return nil, errors.New("always fails") } func TestClearSignError(t *testing.T) { diff --git a/pkg/helm/pkg/pusher/ocipusher.go b/pkg/helm/pkg/pusher/ocipusher.go index 67074026..c624efcd 100644 --- a/pkg/helm/pkg/pusher/ocipusher.go +++ b/pkg/helm/pkg/pusher/ocipusher.go @@ -77,7 +77,7 @@ func (pusher *OCIPusher) push(chartRef, href string) error { } var pushOpts []registry.PushOption - provRef := fmt.Sprintf("%s.prov", chartRef) + provRef := chartRef + ".prov" if _, err := os.Stat(provRef); err == nil { provBytes, err := os.ReadFile(provRef) if err != nil { @@ -87,7 +87,7 @@ func (pusher *OCIPusher) push(chartRef, href string) error { } ref := fmt.Sprintf("%s:%s", - path.Join(strings.TrimPrefix(href, fmt.Sprintf("%s://", registry.OCIScheme)), meta.Metadata.Name), + path.Join(strings.TrimPrefix(href, registry.OCIScheme+"://"), meta.Metadata.Name), meta.Metadata.Version) // The time the chart was "created" is semantically the time the chart archive file was last written(modified) diff --git a/pkg/helm/pkg/registry/client.go b/pkg/helm/pkg/registry/client.go index 3295c862..0180ac5e 100644 --- a/pkg/helm/pkg/registry/client.go +++ b/pkg/helm/pkg/registry/client.go @@ -202,13 +202,15 @@ func ClientOptCredentialsFile(credentialsFile string) ClientOption { } } -// ClientOptHTTPClient returns a function that sets the httpClient setting on a client options set +// ClientOptHTTPClient returns a function that sets the HTTP client for the registry client. func ClientOptHTTPClient(httpClient *http.Client) ClientOption { return func(client *Client) { client.httpClient = httpClient } } +// ClientOptPlainHTTP returns a function that enables plain HTTP (non-TLS) +// communication for the registry client. func ClientOptPlainHTTP() ClientOption { return func(c *Client) { c.plainHTTP = true @@ -236,7 +238,7 @@ func warnIfHostHasPath(host string) bool { return false } -// Login logs into a registry +// Login authenticates the client with a remote OCI registry using the provided host and options. func (c *Client) Login(host string, options ...LoginOption) error { for _, option := range options { option(&loginOperation{host, c}) @@ -282,7 +284,8 @@ func LoginOptBasicAuth(username string, password string) LoginOption { } } -// LoginOptPlainText returns a function that allows plaintext (HTTP) login +// LoginOptPlainText returns a function that enables plaintext (HTTP) login +// instead of HTTPS for the registry client. func LoginOptPlainText(isPlainText bool) LoginOption { return func(o *loginOperation) { o.client.plainHTTP = isPlainText @@ -882,7 +885,7 @@ func (c *Client) ValidateReference(ref, version string, u *url.URL) (string, *ur tag = version } else { // Retrieve list of repository tags - tags, err := c.Tags(strings.TrimPrefix(ref, fmt.Sprintf("%s://", OCIScheme))) + tags, err := c.Tags(strings.TrimPrefix(ref, OCIScheme+"://")) if err != nil { return "", nil, err } diff --git a/pkg/helm/pkg/registry/client_http_test.go b/pkg/helm/pkg/registry/client_http_test.go index 546d837d..1c675155 100644 --- a/pkg/helm/pkg/registry/client_http_test.go +++ b/pkg/helm/pkg/registry/client_http_test.go @@ -18,7 +18,6 @@ package registry import ( "errors" - "fmt" "os" "testing" @@ -65,7 +64,7 @@ func (suite *HTTPRegistryClientTestSuite) Test_3_Tags() { } func (suite *HTTPRegistryClientTestSuite) Test_4_ManInTheMiddle() { - ref := fmt.Sprintf("%s/testrepo/supposedlysafechart:9.9.9", suite.CompromisedRegistryHost) + ref := suite.CompromisedRegistryHost + "/testrepo/supposedlysafechart:9.9.9" // returns content that does not match the expected digest _, err := suite.RegistryClient.Pull(ref) @@ -74,7 +73,7 @@ func (suite *HTTPRegistryClientTestSuite) Test_4_ManInTheMiddle() { } func (suite *HTTPRegistryClientTestSuite) Test_5_ImageIndex() { - ref := fmt.Sprintf("%s/testrepo/image-index:0.1.0", suite.FakeRegistryHost) + ref := suite.FakeRegistryHost + "/testrepo/image-index:0.1.0" _, err := suite.RegistryClient.Pull(ref) suite.Nil(err) diff --git a/pkg/helm/pkg/registry/client_test.go b/pkg/helm/pkg/registry/client_test.go index 98a8b2ea..702dfff6 100644 --- a/pkg/helm/pkg/registry/client_test.go +++ b/pkg/helm/pkg/registry/client_test.go @@ -82,7 +82,7 @@ func TestLogin_ResetsForceAttemptOAuth2_OnSuccess(t *testing.T) { } if c.authorizer == nil || c.authorizer.ForceAttemptOAuth2 { - t.Fatalf("expected ForceAttemptOAuth2 default to be false") + t.Fatal("expected ForceAttemptOAuth2 default to be false") } // Call Login with plain HTTP against our test server @@ -91,7 +91,7 @@ func TestLogin_ResetsForceAttemptOAuth2_OnSuccess(t *testing.T) { } if c.authorizer.ForceAttemptOAuth2 { - t.Errorf("ForceAttemptOAuth2 should be false after successful Login") + t.Error("ForceAttemptOAuth2 should be false after successful Login") } } @@ -117,7 +117,7 @@ func TestLogin_ResetsForceAttemptOAuth2_OnFailure(t *testing.T) { _ = c.Login(host, LoginOptPlainText(true), LoginOptBasicAuth("u", "p")) if c.authorizer.ForceAttemptOAuth2 { - t.Errorf("ForceAttemptOAuth2 should be false after failed Login") + t.Error("ForceAttemptOAuth2 should be false after failed Login") } } diff --git a/pkg/helm/pkg/registry/plugin.go b/pkg/helm/pkg/registry/plugin.go index e4b4afa2..959faaa7 100644 --- a/pkg/helm/pkg/registry/plugin.go +++ b/pkg/helm/pkg/registry/plugin.go @@ -18,6 +18,7 @@ package registry import ( "encoding/json" + "errors" "fmt" "strings" @@ -190,7 +191,7 @@ func GetPluginName(source string) (string, error) { // e.g., "ghcr.io/user/plugin-name:v1.0.0" -> Repository: "user/plugin-name" repository := ref.Repository if repository == "" { - return "", fmt.Errorf("invalid OCI reference: missing repository") + return "", errors.New("invalid OCI reference: missing repository") } // Get the last part of the repository path as the plugin name diff --git a/pkg/helm/pkg/registry/plugin_test.go b/pkg/helm/pkg/registry/plugin_test.go index f8525829..61995ece 100644 --- a/pkg/helm/pkg/registry/plugin_test.go +++ b/pkg/helm/pkg/registry/plugin_test.go @@ -75,7 +75,7 @@ func TestGetPluginName(t *testing.T) { if tt.expectErr { if err == nil { - t.Errorf("expected error but got none") + t.Error("expected error but got none") } return } diff --git a/pkg/helm/pkg/registry/reference.go b/pkg/helm/pkg/registry/reference.go index 9a98cf5c..45579253 100644 --- a/pkg/helm/pkg/registry/reference.go +++ b/pkg/helm/pkg/registry/reference.go @@ -17,7 +17,6 @@ limitations under the License. package registry import ( - "fmt" "strings" "oras.land/oras-go/v2/registry" @@ -80,5 +79,5 @@ func (r *reference) String() string { // IsOCI determines whether a URL is to be treated as an OCI URL func IsOCI(url string) bool { - return strings.HasPrefix(url, fmt.Sprintf("%s://", OCIScheme)) + return strings.HasPrefix(url, OCIScheme+"://") } diff --git a/pkg/helm/pkg/registry/registry_test.go b/pkg/helm/pkg/registry/registry_test.go index 873c1f12..b6dbb4cf 100644 --- a/pkg/helm/pkg/registry/registry_test.go +++ b/pkg/helm/pkg/registry/registry_test.go @@ -137,7 +137,7 @@ func setup(suite *TestRegistry, tlsEnabled, insecure bool) { config.HTTP.Addr = ln.Addr().String() config.HTTP.DrainTimeout = time.Duration(10) * time.Second - config.Storage = map[string]configuration.Parameters{"inmemory": map[string]interface{}{}} + config.Storage = map[string]configuration.Parameters{"inmemory": map[string]any{}} config.Auth = configuration.Auth{ "htpasswd": configuration.Parameters{ @@ -209,7 +209,7 @@ func initCompromisedRegistryTestServer() string { })) u, _ := url.Parse(s.URL) - return fmt.Sprintf("localhost:%s", u.Port()) + return "localhost:" + u.Port() } func initFakeRegistryTestServer() string { @@ -376,7 +376,7 @@ func initFakeRegistryTestServer() string { })) u, _ := url.Parse(s.URL) - return fmt.Sprintf("localhost:%s", u.Port()) + return "localhost:" + u.Port() } func testPush(suite *TestRegistry) { @@ -384,7 +384,7 @@ func testPush(suite *TestRegistry) { testingChartCreationTime := "1977-09-02T22:04:05Z" // Bad bytes - ref := fmt.Sprintf("%s/testrepo/testchart:1.2.3", suite.DockerRegistryHost) + ref := suite.DockerRegistryHost + "/testrepo/testchart:1.2.3" _, err := suite.RegistryClient.Push([]byte("hello"), ref, PushOptCreationTime(testingChartCreationTime)) suite.NotNil(err, "error pushing non-chart bytes") @@ -468,7 +468,7 @@ func testPush(suite *TestRegistry) { func testPull(suite *TestRegistry) { // bad/missing ref - ref := fmt.Sprintf("%s/testrepo/no-existy:1.2.3", suite.DockerRegistryHost) + ref := suite.DockerRegistryHost + "/testrepo/no-existy:1.2.3" _, err := suite.RegistryClient.Pull(ref) suite.NotNil(err, "error on bad/missing ref") diff --git a/pkg/helm/pkg/registry/tag_test.go b/pkg/helm/pkg/registry/tag_test.go index 09f0f12e..e3ec4770 100644 --- a/pkg/helm/pkg/registry/tag_test.go +++ b/pkg/helm/pkg/registry/tag_test.go @@ -71,7 +71,7 @@ func TestGetTagMatchingVersionOrConstraint_InvalidConstraint(t *testing.T) { tags := []string{"1.0.0"} _, err := GetTagMatchingVersionOrConstraint(tags, ">a1") if err == nil { - t.Fatalf("expected error for invalid constraint") + t.Fatal("expected error for invalid constraint") } } @@ -79,7 +79,7 @@ func TestGetTagMatchingVersionOrConstraint_NoMatches(t *testing.T) { tags := []string{"0.1.0", "0.2.0"} _, err := GetTagMatchingVersionOrConstraint(tags, ">=1.0.0") if err == nil { - t.Fatalf("expected error when no tags match") + t.Fatal("expected error when no tags match") } if !strings.Contains(err.Error(), ">=1.0.0") { t.Fatalf("expected error to contain version string, got: %v", err) diff --git a/pkg/helm/pkg/release/common.go b/pkg/helm/pkg/release/common.go index f5840c1d..232b8e63 100644 --- a/pkg/helm/pkg/release/common.go +++ b/pkg/helm/pkg/release/common.go @@ -21,6 +21,7 @@ import ( "fmt" "time" + v2release "github.com/werf/nelm/pkg/helm/intern/release/v2" "github.com/werf/nelm/pkg/helm/pkg/chart" v1release "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) @@ -35,6 +36,10 @@ func newDefaultAccessor(rel Releaser) (Accessor, error) { return &v1Accessor{&v}, nil case *v1release.Release: return &v1Accessor{v}, nil + case v2release.Release: + return &v2Accessor{&v}, nil + case *v2release.Release: + return &v2Accessor{v}, nil default: return nil, fmt.Errorf("unsupported release type: %T", rel) } @@ -46,6 +51,10 @@ func newDefaultHookAccessor(hook Hook) (HookAccessor, error) { return &v1HookAccessor{&h}, nil case *v1release.Hook: return &v1HookAccessor{h}, nil + case v2release.Hook: + return &v2HookAccessor{&h}, nil + case *v2release.Hook: + return &v2HookAccessor{h}, nil default: return nil, errors.New("unsupported release hook type") } @@ -114,3 +123,67 @@ func (a *v1HookAccessor) Path() string { func (a *v1HookAccessor) Manifest() string { return a.hook.Manifest } + +type v2Accessor struct { + rel *v2release.Release +} + +func (a *v2Accessor) Name() string { + return a.rel.Name +} + +func (a *v2Accessor) Namespace() string { + return a.rel.Namespace +} + +func (a *v2Accessor) Version() int { + return a.rel.Version +} + +func (a *v2Accessor) Hooks() []Hook { + var hooks = make([]Hook, len(a.rel.Hooks)) + for i, h := range a.rel.Hooks { + hooks[i] = h + } + return hooks +} + +func (a *v2Accessor) Manifest() string { + return a.rel.Manifest +} + +func (a *v2Accessor) Notes() string { + return a.rel.Info.Notes +} + +func (a *v2Accessor) Labels() map[string]string { + return a.rel.Labels +} + +func (a *v2Accessor) Chart() chart.Charter { + return a.rel.Chart +} + +func (a *v2Accessor) Status() string { + return a.rel.Info.Status.String() +} + +func (a *v2Accessor) ApplyMethod() string { + return a.rel.ApplyMethod +} + +func (a *v2Accessor) DeployedAt() time.Time { + return a.rel.Info.LastDeployed +} + +type v2HookAccessor struct { + hook *v2release.Hook +} + +func (a *v2HookAccessor) Path() string { + return a.hook.Path +} + +func (a *v2HookAccessor) Manifest() string { + return a.hook.Manifest +} diff --git a/pkg/helm/pkg/release/common_test.go b/pkg/helm/pkg/release/common_test.go index cdfbadb7..fbb9e641 100644 --- a/pkg/helm/pkg/release/common_test.go +++ b/pkg/helm/pkg/release/common_test.go @@ -22,6 +22,7 @@ import ( "github.com/stretchr/testify/assert" + v2release "github.com/werf/nelm/pkg/helm/intern/release/v2" "github.com/werf/nelm/pkg/helm/pkg/release/common" rspb "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) @@ -63,3 +64,72 @@ func TestNewDefaultAccessor(t *testing.T) { is.Equal(rel.ApplyMethod, accessor.ApplyMethod()) is.Equal(rel.Labels, accessor.Labels()) } + +func TestNewDefaultAccessorV2(t *testing.T) { + // Testing the default implementation for v2 releases (charts/v3) + is := assert.New(t) + + // Create v2 release + info := &v2release.Info{Status: common.StatusDeployed, LastDeployed: time.Now().Add(1000), Notes: "test notes"} + labels := make(map[string]string) + labels["foo"] = "bar" + rel := &v2release.Release{ + Name: "happy-cats-v2", + Version: 3, + Info: info, + Labels: labels, + Namespace: "test-namespace", + ApplyMethod: "ssa", + Manifest: "test manifest content", + Hooks: []*v2release.Hook{ + { + Name: "test-hook", + Kind: "Job", + Path: "templates/hook.yaml", + Manifest: "hook manifest", + }, + }, + } + + // Test accessor creation + accessor, err := newDefaultAccessor(rel) + is.NoError(err) + + // Verify all accessor methods return correct values + is.Equal(rel.Name, accessor.Name()) + is.Equal(rel.Namespace, accessor.Namespace()) + is.Equal(rel.Version, accessor.Version()) + is.Equal(rel.ApplyMethod, accessor.ApplyMethod()) + is.Equal(rel.Labels, accessor.Labels()) + is.Equal(rel.Manifest, accessor.Manifest()) + is.Equal(rel.Info.Notes, accessor.Notes()) + is.Equal(rel.Info.Status.String(), accessor.Status()) + is.Equal(rel.Info.LastDeployed, accessor.DeployedAt()) + + // Verify hooks are accessible + hooks := accessor.Hooks() + is.Len(hooks, 1) + + // Test hook accessor + hookAccessor, err := newDefaultHookAccessor(hooks[0]) + is.NoError(err) + is.Equal("templates/hook.yaml", hookAccessor.Path()) + is.Equal("hook manifest", hookAccessor.Manifest()) +} + +func TestNewDefaultAccessorV2ByValue(t *testing.T) { + // Test that passing v2 release by value also works + is := assert.New(t) + + info := &v2release.Info{Status: common.StatusDeployed, LastDeployed: time.Now()} + rel := v2release.Release{ + Name: "test-release", + Version: 1, + Info: info, + Namespace: "default", + } + + accessor, err := newDefaultAccessor(rel) + is.NoError(err) + is.Equal("test-release", accessor.Name()) +} diff --git a/pkg/helm/pkg/release/interfaces.go b/pkg/helm/pkg/release/interfaces.go index abadf899..e6c44249 100644 --- a/pkg/helm/pkg/release/interfaces.go +++ b/pkg/helm/pkg/release/interfaces.go @@ -22,9 +22,9 @@ import ( "github.com/werf/nelm/pkg/helm/pkg/chart" ) -type Releaser interface{} +type Releaser any -type Hook interface{} +type Hook any type Accessor interface { Name() string diff --git a/pkg/helm/pkg/release/v1/hook.go b/pkg/helm/pkg/release/v1/hook.go index f0a370c1..5c382cd7 100644 --- a/pkg/helm/pkg/release/v1/hook.go +++ b/pkg/helm/pkg/release/v1/hook.go @@ -86,7 +86,7 @@ type Hook struct { // Events are the events that this hook fires on. Events []HookEvent `json:"events,omitempty"` // LastRun indicates the date/time this was last run. - LastRun HookExecution `json:"last_run,omitempty"` + LastRun HookExecution `json:"last_run"` // Weight indicates the sort order for execution among similar Hook type Weight int `json:"weight,omitempty"` // DeletePolicies are the policies that indicate when to delete the hook @@ -133,7 +133,7 @@ type hookExecutionJSON struct { // It handles empty string time fields by treating them as zero values. func (h *HookExecution) UnmarshalJSON(data []byte) error { // First try to unmarshal into a map to handle empty string time fields - var raw map[string]interface{} + var raw map[string]any if err := json.Unmarshal(data, &raw); err != nil { return err } diff --git a/pkg/helm/pkg/release/v1/hook_test.go b/pkg/helm/pkg/release/v1/hook_test.go index cea2568b..f3b8811a 100644 --- a/pkg/helm/pkg/release/v1/hook_test.go +++ b/pkg/helm/pkg/release/v1/hook_test.go @@ -220,7 +220,7 @@ func TestHookExecutionEmptyStringRoundTrip(t *testing.T) { data, err := json.Marshal(&exec) require.NoError(t, err) - var result map[string]interface{} + var result map[string]any err = json.Unmarshal(data, &result) require.NoError(t, err) diff --git a/pkg/helm/pkg/release/v1/info.go b/pkg/helm/pkg/release/v1/info.go index d1aaacbd..340b4f46 100644 --- a/pkg/helm/pkg/release/v1/info.go +++ b/pkg/helm/pkg/release/v1/info.go @@ -58,7 +58,7 @@ type infoJSON struct { // It handles empty string time fields by treating them as zero values. func (i *Info) UnmarshalJSON(data []byte) error { // First try to unmarshal into a map to handle empty string time fields - var raw map[string]interface{} + var raw map[string]any if err := json.Unmarshal(data, &raw); err != nil { return err } diff --git a/pkg/helm/pkg/release/v1/info_test.go b/pkg/helm/pkg/release/v1/info_test.go index 4c3cca17..5aca499f 100644 --- a/pkg/helm/pkg/release/v1/info_test.go +++ b/pkg/helm/pkg/release/v1/info_test.go @@ -272,7 +272,7 @@ func TestInfoEmptyStringRoundTrip(t *testing.T) { data, err := json.Marshal(&info) require.NoError(t, err) - var result map[string]interface{} + var result map[string]any err = json.Unmarshal(data, &result) require.NoError(t, err) diff --git a/pkg/helm/pkg/release/v1/mock.go b/pkg/helm/pkg/release/v1/mock.go index b5642784..6680eb51 100644 --- a/pkg/helm/pkg/release/v1/mock.go +++ b/pkg/helm/pkg/release/v1/mock.go @@ -17,8 +17,8 @@ limitations under the License. package v1 import ( - "fmt" "math/rand" + "strconv" "time" "github.com/werf/nelm/pkg/helm/pkg/chart/common" @@ -57,7 +57,7 @@ func Mock(opts *MockReleaseOptions) *Release { name := opts.Name if name == "" { - name = "testrelease-" + fmt.Sprint(rand.Intn(100)) + name = "testrelease-" + strconv.Itoa(rand.Intn(100)) } version := 1 @@ -123,7 +123,7 @@ func Mock(opts *MockReleaseOptions) *Release { Name: name, Info: info, Chart: ch, - Config: map[string]interface{}{"name": "value"}, + Config: map[string]any{"name": "value"}, Version: version, Namespace: namespace, Hooks: []*Hook{ diff --git a/pkg/helm/pkg/release/v1/release.go b/pkg/helm/pkg/release/v1/release.go index fc027cbc..f16aa73d 100644 --- a/pkg/helm/pkg/release/v1/release.go +++ b/pkg/helm/pkg/release/v1/release.go @@ -36,7 +36,7 @@ type Release struct { Chart *chart.Chart `json:"chart,omitempty"` // Config is the set of extra Values added to the chart. // These values override the default values inside of the chart. - Config map[string]interface{} `json:"config,omitempty"` + Config map[string]any `json:"config,omitempty"` // Manifest is the string representation of the rendered template. Manifest string `json:"manifest,omitempty"` // Hooks are all of the hooks declared for this release. diff --git a/pkg/helm/pkg/release/v1/util/kind_sorter.go b/pkg/helm/pkg/release/v1/util/kind_sorter.go index 019228ae..4f778275 100644 --- a/pkg/helm/pkg/release/v1/util/kind_sorter.go +++ b/pkg/helm/pkg/release/v1/util/kind_sorter.go @@ -137,7 +137,7 @@ func sortHooksByKind(hooks []*release.Hook, ordering KindSortOrder) []*release.H return h } -func lessByKind(_ interface{}, _ interface{}, kindA string, kindB string, o KindSortOrder) bool { +func lessByKind(_ any, _ any, kindA string, kindB string, o KindSortOrder) bool { ordering := make(map[string]int, len(o)) for v, k := range o { ordering[k] = v diff --git a/pkg/helm/pkg/release/v1/util/manifest.go b/pkg/helm/pkg/release/v1/util/manifest.go index 9a87949f..fa26f625 100644 --- a/pkg/helm/pkg/release/v1/util/manifest.go +++ b/pkg/helm/pkg/release/v1/util/manifest.go @@ -21,6 +21,7 @@ import ( "regexp" "strconv" "strings" + "unicode" ) // SimpleHead defines what the structure of the head of a manifest file @@ -35,7 +36,16 @@ type SimpleHead struct { var sep = regexp.MustCompile("(?:^|\\s*\n)---\\s*") -// SplitManifests takes a string of manifest and returns a map contains individual manifests +// SplitManifests takes a manifest string and returns a map containing individual manifests. +// +// **Note for Chart API v3**: This function (due to the regex above) has allowed _WRONG_ +// Go templates to be defined inside charts across the years. The generated text from Go +// templates may contain `---apiVersion: v1`, and this function magically splits this back +// to `---\napiVersion: v1`. This has caused issues recently after Helm 4 introduced +// kio.ParseAll to inject annotations when post-renderers are used. In Chart API v3, +// we should kill this regex with fire (or change it) and expose charts doing the wrong +// thing Go template-wise. Helm should say a big _NO_ to charts doing the wrong thing, +// with or without post-renderers. func SplitManifests(bigFile string) map[string]string { // Basically, we're quickly splitting a stream of YAML documents into an // array of YAML docs. The file name is just a place holder, but should be @@ -44,15 +54,15 @@ func SplitManifests(bigFile string) map[string]string { tpl := "manifest-%d" res := map[string]string{} // Making sure that any extra whitespace in YAML stream doesn't interfere in splitting documents correctly. - bigFileTmp := strings.TrimSpace(bigFile) + bigFileTmp := strings.TrimLeftFunc(bigFile, unicode.IsSpace) docs := sep.Split(bigFileTmp, -1) var count int for _, d := range docs { - if d == "" { + if strings.TrimSpace(d) == "" { continue } - d = strings.TrimSpace(d) + d = strings.TrimLeftFunc(d, unicode.IsSpace) res[fmt.Sprintf(tpl, count)] = d count = count + 1 } diff --git a/pkg/helm/pkg/release/v1/util/manifest_test.go b/pkg/helm/pkg/release/v1/util/manifest_test.go index 754ac136..516ac42d 100644 --- a/pkg/helm/pkg/release/v1/util/manifest_test.go +++ b/pkg/helm/pkg/release/v1/util/manifest_test.go @@ -21,7 +21,15 @@ import ( "testing" ) -const mockManifestFile = ` +func TestSplitManifests(t *testing.T) { + tests := []struct { + name string + input string + expected map[string]string + }{ + { + name: "single doc with leading separator and whitespace", + input: ` --- apiVersion: v1 @@ -35,9 +43,9 @@ spec: - name: nemo-test image: fake-image cmd: fake-command -` - -const expectedManifest = `apiVersion: v1 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 kind: Pod metadata: name: finding-nemo, @@ -47,15 +55,463 @@ spec: containers: - name: nemo-test image: fake-image - cmd: fake-command` + cmd: fake-command +`, + }, + }, + { + name: "empty input", + input: "", + expected: map[string]string{}, + }, + { + name: "whitespace only", + input: " \n\n \n", + expected: map[string]string{}, + }, + { + name: "whitespace-only doc after separator is skipped", + input: "---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cm1\n---\n \n", + expected: map[string]string{ + "manifest-0": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cm1", + }, + }, + { + name: "single doc no separator", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +`, + }, + }, + { + name: "two docs with proper separator", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1`, + "manifest-1": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +`, + }, + }, -func TestSplitManifest(t *testing.T) { - manifests := SplitManifests(mockManifestFile) - if len(manifests) != 1 { - t.Errorf("Expected 1 manifest, got %v", len(manifests)) + // Block scalar chomping indicator tests using | (clip), |- (strip), and |+ (keep) + // inputs with 0, 1, and 2 trailing newlines after the block content. + // Note: the emitter may normalize the output chomping indicator when the + // trailing newline count makes another indicator equivalent for the result. + + // | (clip) input — clips trailing newlines to exactly one, though with + // 0 trailing newlines the emitted output may normalize to |-. + { + name: "block scalar clip (|) with 0 trailing newlines", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello`, + }, + }, + { + name: "block scalar clip (|) with 1 trailing newline", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello +`, + }, + }, + { + name: "block scalar clip (|) with 2 trailing newlines", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello + +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello + +`, + }, + }, + + // |- (strip) + { + name: "block scalar strip (|-) with 0 trailing newlines", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello`, + }, + }, + { + name: "block scalar strip (|-) with 1 trailing newline", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello +`, + }, + }, + { + name: "block scalar strip (|-) with 2 trailing newlines", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello + +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello + +`, + }, + }, + + // |+ (keep) + { + name: "block scalar keep (|+) with 0 trailing newlines", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello`, + }, + }, + { + name: "block scalar keep (|+) with 1 trailing newline", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello +`, + }, + }, + { + name: "block scalar keep (|+) with 2 trailing newlines", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello + +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello + +`, + }, + }, + + // Multi-doc with block scalars: the regex consumes \s*\n before ---, + // so trailing newlines from non-last docs are stripped. + { + name: "multi-doc block scalar clip (|) before separator", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello`, + "manifest-1": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +`, + }, + }, + { + name: "multi-doc block scalar keep (|+) with 2 trailing newlines before separator", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello + + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello`, + "manifest-1": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +`, + }, + }, + + // **Note for Chart API v3**: The following tests exercise the lenient + // regex that splits `---apiVersion` back into separate documents. + // In Chart API v3, these inputs should return an _ERROR_ instead. + // See the comment on the SplitManifests function for more details. + { + name: "leading glued separator (---apiVersion)", + input: ` +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +`, + }, + }, + { + name: "mid-content glued separator (---apiVersion)", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1`, + "manifest-1": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +`, + }, + }, + { + name: "multiple glued separators", + input: ` +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm3 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1`, + "manifest-1": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2`, + "manifest-2": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm3 +`, + }, + }, + { + name: "mixed glued and proper separators", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm3 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1`, + "manifest-1": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2`, + "manifest-2": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm3 +`, + }, + }, } - expected := map[string]string{"manifest-0": expectedManifest} - if !reflect.DeepEqual(manifests, expected) { - t.Errorf("Expected %v, got %v", expected, manifests) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SplitManifests(tt.input) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("SplitManifests() =\n%v\nwant:\n%v", result, tt.expected) + } + }) } } diff --git a/pkg/helm/pkg/repo/v1/chartrepo_test.go b/pkg/helm/pkg/repo/v1/chartrepo_test.go index 4b06bfa1..ffc47e11 100644 --- a/pkg/helm/pkg/repo/v1/chartrepo_test.go +++ b/pkg/helm/pkg/repo/v1/chartrepo_test.go @@ -85,7 +85,7 @@ func TestIndexCustomSchemeDownload(t *testing.T) { } if len(myCustomGetter.repoUrls) != 1 { - t.Fatalf("Custom Getter.Get should be called once") + t.Fatal("Custom Getter.Get should be called once") } expectedRepoIndexURL := repoURL + "/index.yaml" @@ -126,24 +126,20 @@ func TestConcurrencyDownloadIndex(t *testing.T) { // 2) read index.yaml via LoadIndexFile (read operation). // This checks for race conditions and ensures correct behavior under concurrent read/write access. for range 150 { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { idx, err := repo.DownloadIndexFile() if err != nil { t.Errorf("Failed to download index file to %s: %v", idx, err) } - }() + }) - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { _, err := LoadIndexFile(indexFName) if err != nil { t.Errorf("Failed to load index file: %v", err) } - }() + }) } wg.Wait() } @@ -244,7 +240,7 @@ func TestErrorFindChartInRepoURL(t *testing.T) { }) if _, err := FindChartInRepoURL("http://someserver/something", "nginx", g); err == nil { - t.Errorf("Expected error for bad chart URL, but did not get any errors") + t.Error("Expected error for bad chart URL, but did not get any errors") } else if !strings.Contains(err.Error(), `looks like "http://someserver/something" is not a valid chart repository or cannot be reached`) { t.Errorf("Expected error for bad chart URL, but got a different error (%v)", err) } @@ -256,22 +252,22 @@ func TestErrorFindChartInRepoURL(t *testing.T) { defer srv.Close() if _, err = FindChartInRepoURL(srv.URL, "nginx1", g); err == nil { - t.Errorf("Expected error for chart not found, but did not get any errors") + t.Error("Expected error for chart not found, but did not get any errors") } else if err.Error() != `chart "nginx1" not found in `+srv.URL+` repository` { t.Errorf("Expected error for chart not found, but got a different error (%v)", err) } if !errors.Is(err, ChartNotFoundError{}) { - t.Errorf("error is not of correct error type structure") + t.Error("error is not of correct error type structure") } if _, err = FindChartInRepoURL(srv.URL, "nginx1", g, WithChartVersion("0.1.0")); err == nil { - t.Errorf("Expected error for chart not found, but did not get any errors") + t.Error("Expected error for chart not found, but did not get any errors") } else if err.Error() != `chart "nginx1" version "0.1.0" not found in `+srv.URL+` repository` { t.Errorf("Expected error for chart not found, but got a different error (%v)", err) } if _, err = FindChartInRepoURL(srv.URL, "chartWithNoURL", g); err == nil { - t.Errorf("Expected error for no chart URLs available, but did not get any errors") + t.Error("Expected error for no chart URLs available, but did not get any errors") } else if err.Error() != `chart "chartWithNoURL" has no downloadable URLs` { t.Errorf("Expected error for chart not found, but got a different error (%v)", err) } diff --git a/pkg/helm/pkg/repo/v1/index.go b/pkg/helm/pkg/repo/v1/index.go index ce2d354b..5c4ed3db 100644 --- a/pkg/helm/pkg/repo/v1/index.go +++ b/pkg/helm/pkg/repo/v1/index.go @@ -81,7 +81,7 @@ func (c ChartVersions) Less(a, b int) bool { // IndexFile represents the index file in a chart repository type IndexFile struct { // This is used ONLY for validation against chartmuseum's index files and is discarded after validation. - ServerInfo map[string]interface{} `json:"serverInfo,omitempty"` + ServerInfo map[string]any `json:"serverInfo,omitempty"` APIVersion string `json:"apiVersion"` Generated time.Time `json:"generated"` Entries map[string]ChartVersions `json:"entries"` @@ -271,7 +271,7 @@ func (i *IndexFile) Merge(f *IndexFile) { type ChartVersion struct { *chart.Metadata URLs []string `json:"urls"` - Created time.Time `json:"created,omitempty"` + Created time.Time `json:"created"` Removed bool `json:"removed,omitempty"` Digest string `json:"digest,omitempty"` @@ -392,7 +392,7 @@ func loadIndex(data []byte, source string) (*IndexFile, error) { // checking its validity as JSON. If the data is valid JSON, it will use the // `encoding/json` package to unmarshal it. Otherwise, it will use the // `sigs.k8s.io/yaml` package to unmarshal the YAML data. -func jsonOrYamlUnmarshal(b []byte, i interface{}) error { +func jsonOrYamlUnmarshal(b []byte, i any) error { if json.Valid(b) { return json.Unmarshal(b, i) } diff --git a/pkg/helm/pkg/repo/v1/index_test.go b/pkg/helm/pkg/repo/v1/index_test.go index 9dedb23b..d9d9aecf 100644 --- a/pkg/helm/pkg/repo/v1/index_test.go +++ b/pkg/helm/pkg/repo/v1/index_test.go @@ -20,7 +20,7 @@ import ( "bufio" "bytes" "encoding/json" - "fmt" + "errors" "net/http" "os" "path/filepath" @@ -130,12 +130,12 @@ func TestIndexFile(t *testing.T) { cv, err = i.Get("setter", "0.1.9+alpha") if err != nil || cv.Version != "0.1.9+alpha" { - t.Errorf("Expected version: 0.1.9+alpha") + t.Error("Expected version: 0.1.9+alpha") } cv, err = i.Get("setter", "0.1.8") if err != nil || cv.Version != "0.1.8" { - t.Errorf("Expected version: 0.1.8") + t.Error("Expected version: 0.1.8") } } @@ -174,7 +174,7 @@ func TestLoadIndex(t *testing.T) { // TestLoadIndex_Duplicates is a regression to make sure that we don't non-deterministically allow duplicate packages. func TestLoadIndex_Duplicates(t *testing.T) { if _, err := loadIndex([]byte(indexWithDuplicates), "indexWithDuplicates"); err == nil { - t.Errorf("Expected an error when duplicate entries are present") + t.Error("Expected an error when duplicate entries are present") } } @@ -186,7 +186,7 @@ func TestLoadIndex_EmptyEntry(t *testing.T) { func TestLoadIndex_Empty(t *testing.T) { if _, err := loadIndex([]byte(""), "indexWithEmpty"); err == nil { - t.Errorf("Expected an error when index.yaml is empty.") + t.Error("Expected an error when index.yaml is empty.") } } @@ -360,7 +360,7 @@ func verifyLocalIndex(t *testing.T, i *IndexFile) { alpine, ok := i.Entries["alpine"] if !ok { - t.Fatalf("'alpine' section not found.") + t.Fatal("'alpine' section not found.") } if l := len(alpine); l != 1 { @@ -369,7 +369,7 @@ func verifyLocalIndex(t *testing.T, i *IndexFile) { nginx, ok := i.Entries["nginx"] if !ok || len(nginx) != 2 { - t.Fatalf("Expected 2 nginx entries") + t.Fatal("Expected 2 nginx entries") } expects := []*ChartVersion{ @@ -595,7 +595,7 @@ func TestAddFileIndexEntriesNil(t *testing.T) { {&chart.Metadata{APIVersion: "v2", Name: " ", Version: "8033-5.apinie+s.r"}, "setter-0.1.9+beta.tgz", "http://example.com/charts", "sha256:1234567890abc"}, } { if err := i.MustAdd(x.md, x.filename, x.baseURL, x.digest); err == nil { - t.Errorf("expected err to be non-nil when entries not initialized") + t.Error("expected err to be non-nil when entries not initialized") } } } @@ -610,7 +610,7 @@ func TestIgnoreSkippableChartValidationError(t *testing.T) { Input: nil, }, "generic_error": { - Input: fmt.Errorf("foo"), + Input: errors.New("foo"), }, "non_skipped_validation_error": { Input: chart.ValidationError("chart.metadata.type must be application or library"), @@ -639,7 +639,7 @@ func TestIgnoreSkippableChartValidationError(t *testing.T) { return } - if tc.Input != result { + if !errors.Is(tc.Input, result) { t.Error("expected the result equal to input") } diff --git a/pkg/helm/pkg/repo/v1/repo_test.go b/pkg/helm/pkg/repo/v1/repo_test.go index bdaa61ed..9b5c5430 100644 --- a/pkg/helm/pkg/repo/v1/repo_test.go +++ b/pkg/helm/pkg/repo/v1/repo_test.go @@ -114,11 +114,11 @@ func TestRepoFile_Get(t *testing.T) { name := "second" entry := repo.Get(name) - if entry == nil { //nolint:staticcheck + if entry == nil { t.Fatalf("Expected repo entry %q to be found", name) } - if entry.URL != "https://example.com/second" { //nolint:staticcheck + if entry.URL != "https://example.com/second" { t.Errorf("Expected repo URL to be %q but got %q", "https://example.com/second", entry.URL) } @@ -219,9 +219,9 @@ func TestWriteFile(t *testing.T) { func TestRepoNotExists(t *testing.T) { if _, err := LoadFile("/this/path/does/not/exist.yaml"); err == nil { - t.Errorf("expected err to be non-nil when path does not exist") + t.Error("expected err to be non-nil when path does not exist") } else if !strings.Contains(err.Error(), "couldn't load repositories file") { - t.Errorf("expected prompt `couldn't load repositories file`") + t.Error("expected prompt `couldn't load repositories file`") } } diff --git a/pkg/helm/pkg/repo/v1/repotest/server.go b/pkg/helm/pkg/repo/v1/repotest/server.go index f098158d..71edfb68 100644 --- a/pkg/helm/pkg/repo/v1/repotest/server.go +++ b/pkg/helm/pkg/repo/v1/repotest/server.go @@ -176,21 +176,21 @@ func NewOCIServer(t *testing.T, dir string) (*OCIServer, error) { htpasswdPath := filepath.Join(dir, testHtpasswdFileBasename) err = os.WriteFile(htpasswdPath, fmt.Appendf(nil, "%s:%s\n", testUsername, string(pwBytes)), 0o644) if err != nil { - t.Fatalf("error creating test htpasswd file") + t.Fatal("error creating test htpasswd file") } // Registry config config := &configuration.Configuration{} ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { - t.Fatalf("error finding free port for test registry") + t.Fatalf("error finding free port for test registry: %v", err) } defer ln.Close() port := ln.Addr().(*net.TCPAddr).Port config.HTTP.Addr = ln.Addr().String() config.HTTP.DrainTimeout = time.Duration(10) * time.Second - config.Storage = map[string]configuration.Parameters{"inmemory": map[string]interface{}{}} + config.Storage = map[string]configuration.Parameters{"inmemory": map[string]any{}} config.Auth = configuration.Auth{ "htpasswd": configuration.Parameters{ "realm": "localhost", @@ -238,7 +238,7 @@ func (srv *OCIServer) RunWithReturn(t *testing.T, opts ...OCIServerOpt) *OCIServ ociRegistry.ClientOptCredentialsFile(credentialsFile), ) if err != nil { - t.Fatalf("error creating registry client") + t.Fatalf("error creating registry client: %v", err) } err = registryClient.Login( @@ -250,7 +250,7 @@ func (srv *OCIServer) RunWithReturn(t *testing.T, opts ...OCIServerOpt) *OCIServ t.Fatalf("error logging into registry with good credentials: %v", err) } - ref := fmt.Sprintf("%s/u/ocitestuser/oci-dependent-chart:0.1.0", srv.RegistryURL) + ref := srv.RegistryURL + "/u/ocitestuser/oci-dependent-chart:0.1.0" err = chartutil.ExpandFile(srv.Dir, filepath.Join(srv.Dir, "oci-dependent-chart-0.1.0.tgz")) if err != nil { diff --git a/pkg/helm/pkg/storage/driver/cfgmaps.go b/pkg/helm/pkg/storage/driver/cfgmaps.go index e94eda64..bc930bc8 100644 --- a/pkg/helm/pkg/storage/driver/cfgmaps.go +++ b/pkg/helm/pkg/storage/driver/cfgmaps.go @@ -171,7 +171,7 @@ func (cfgmaps *ConfigMaps) Create(key string, rls release.Releaser) error { lbs.init() lbs.fromMap(rac.Labels()) - lbs.set("createdAt", fmt.Sprintf("%v", time.Now().Unix())) + lbs.set("createdAt", strconv.FormatInt(time.Now().Unix(), 10)) rel, err := releaserToV1Release(rls) if err != nil { @@ -209,7 +209,7 @@ func (cfgmaps *ConfigMaps) Update(key string, rel release.Releaser) error { lbs.init() lbs.fromMap(rls.Labels) - lbs.set("modifiedAt", fmt.Sprintf("%v", time.Now().Unix())) + lbs.set("modifiedAt", strconv.FormatInt(time.Now().Unix(), 10)) // create a new configmap object to hold the release obj, err := newConfigMapsObject(key, rls, lbs) diff --git a/pkg/helm/pkg/storage/driver/cfgmaps_test.go b/pkg/helm/pkg/storage/driver/cfgmaps_test.go index 4040d2d6..33b919bd 100644 --- a/pkg/helm/pkg/storage/driver/cfgmaps_test.go +++ b/pkg/helm/pkg/storage/driver/cfgmaps_test.go @@ -180,7 +180,7 @@ func TestConfigMapQuery(t *testing.T) { } _, err = cfgmaps.Query(map[string]string{"name": "notExist"}) - if err != ErrReleaseNotFound { + if !errors.Is(err, ErrReleaseNotFound) { t.Errorf("Expected {%v}, got {%v}", ErrReleaseNotFound, err) } } @@ -252,7 +252,7 @@ func TestConfigMapDelete(t *testing.T) { // perform the delete on a non-existent release _, err := cfgmaps.Delete("nonexistent") - if err != ErrReleaseNotFound { + if !errors.Is(err, ErrReleaseNotFound) { t.Fatalf("Expected ErrReleaseNotFound: got {%v}", err) } diff --git a/pkg/helm/pkg/storage/driver/memory_test.go b/pkg/helm/pkg/storage/driver/memory_test.go index 268e15a8..1dbdcce1 100644 --- a/pkg/helm/pkg/storage/driver/memory_test.go +++ b/pkg/helm/pkg/storage/driver/memory_test.go @@ -280,7 +280,7 @@ func TestMemoryDelete(t *testing.T) { } _, err = ts.Get(tt.key) if err == nil { - t.Errorf("Expected an error when asking for a deleted key") + t.Error("Expected an error when asking for a deleted key") } } diff --git a/pkg/helm/pkg/storage/driver/secrets.go b/pkg/helm/pkg/storage/driver/secrets.go index a33c0284..fc7d8f1b 100644 --- a/pkg/helm/pkg/storage/driver/secrets.go +++ b/pkg/helm/pkg/storage/driver/secrets.go @@ -171,7 +171,7 @@ func (secrets *Secrets) Create(key string, rel release.Releaser) error { lbs.init() lbs.fromMap(rls.Labels) - lbs.set("createdAt", fmt.Sprintf("%v", time.Now().Unix())) + lbs.set("createdAt", strconv.FormatInt(time.Now().Unix(), 10)) // create a new secret to hold the release obj, err := newSecretsObject(key, rls, lbs) @@ -202,7 +202,7 @@ func (secrets *Secrets) Update(key string, rel release.Releaser) error { lbs.init() lbs.fromMap(rls.Labels) - lbs.set("modifiedAt", fmt.Sprintf("%v", time.Now().Unix())) + lbs.set("modifiedAt", strconv.FormatInt(time.Now().Unix(), 10)) // create a new secret object to hold the release obj, err := newSecretsObject(key, rls, lbs) diff --git a/pkg/helm/pkg/storage/driver/secrets_test.go b/pkg/helm/pkg/storage/driver/secrets_test.go index 090c23f0..92ef233c 100644 --- a/pkg/helm/pkg/storage/driver/secrets_test.go +++ b/pkg/helm/pkg/storage/driver/secrets_test.go @@ -165,7 +165,7 @@ func TestSecretQuery(t *testing.T) { } _, err = secrets.Query(map[string]string{"name": "notExist"}) - if err != ErrReleaseNotFound { + if !errors.Is(err, ErrReleaseNotFound) { t.Errorf("Expected {%v}, got {%v}", ErrReleaseNotFound, err) } } @@ -237,7 +237,7 @@ func TestSecretDelete(t *testing.T) { // perform the delete on a non-existing release _, err := secrets.Delete("nonexistent") - if err != ErrReleaseNotFound { + if !errors.Is(err, ErrReleaseNotFound) { t.Fatalf("Expected ErrReleaseNotFound, got: {%v}", err) } diff --git a/pkg/helm/pkg/storage/driver/sql_test.go b/pkg/helm/pkg/storage/driver/sql_test.go index a7d38b77..b6a0a12a 100644 --- a/pkg/helm/pkg/storage/driver/sql_test.go +++ b/pkg/helm/pkg/storage/driver/sql_test.go @@ -15,6 +15,7 @@ package driver import ( "database/sql/driver" + "errors" "fmt" "reflect" "regexp" @@ -289,7 +290,7 @@ func TestSqlCreateAlreadyExists(t *testing.T) { mock. ExpectExec(regexp.QuoteMeta(insertQuery)). WithArgs(key, sqlReleaseDefaultType, body, rel.Name, rel.Namespace, int(rel.Version), rel.Info.Status.String(), sqlReleaseDefaultOwner, recentUnixTimestamp()). - WillReturnError(fmt.Errorf("dialect dependent SQL error")) + WillReturnError(errors.New("dialect dependent SQL error")) selectQuery := fmt.Sprintf( regexp.QuoteMeta("SELECT %s FROM %s WHERE %s = $1 AND %s = $2"), @@ -447,7 +448,7 @@ func TestSqlQuery(t *testing.T) { _, err := sqlDriver.Query(labelSetUnknown) if err == nil { t.Errorf("Expected error {%v}, got nil", ErrReleaseNotFound) - } else if err != ErrReleaseNotFound { + } else if !errors.Is(err, ErrReleaseNotFound) { t.Fatalf("failed to query for unknown smug-pigeon release: %v", err) } diff --git a/pkg/helm/pkg/storage/storage.go b/pkg/helm/pkg/storage/storage.go index cb1ecb25..e089cb81 100644 --- a/pkg/helm/pkg/storage/storage.go +++ b/pkg/helm/pkg/storage/storage.go @@ -339,12 +339,14 @@ func Init(d driver.Driver) *Storage { Driver: d, } + var h slog.Handler // Get logger from driver if it implements the LoggerSetterGetter interface if ls, ok := d.(logging.LoggerSetterGetter); ok { - ls.SetLogger(s.Logger().Handler()) + h = ls.Logger().Handler() } else { // If the driver does not implement the LoggerSetterGetter interface, set the default logger - s.SetLogger(slog.Default().Handler()) + h = slog.Default().Handler() } + s.SetLogger(h) return s } diff --git a/pkg/helm/pkg/storage/storage_test.go b/pkg/helm/pkg/storage/storage_test.go index f417b68b..23781c5c 100644 --- a/pkg/helm/pkg/storage/storage_test.go +++ b/pkg/helm/pkg/storage/storage_test.go @@ -17,8 +17,10 @@ limitations under the License. package storage // import "github.com/werf/nelm/pkg/helm/pkg/storage" import ( + "context" "errors" "fmt" + "log/slog" "reflect" "testing" @@ -206,7 +208,7 @@ func TestStorageDeployed(t *testing.T) { switch { case rls == nil: - t.Fatalf("Release is nil") + t.Fatal("Release is nil") case rel.Name != name: t.Fatalf("Expected release name %q, actual %q\n", name, rel.Name) case rel.Version != vers: @@ -249,7 +251,7 @@ func TestStorageDeployedWithCorruption(t *testing.T) { switch { case rls == nil: - t.Fatalf("Release is nil") + t.Fatal("Release is nil") case rel.Name != name: t.Fatalf("Expected release name %q, actual %q\n", name, rel.Name) case rel.Version != vers: @@ -574,8 +576,40 @@ func (test ReleaseTestData) ToRelease() *rspb.Release { } } -func assertErrNil(eh func(args ...interface{}), err error, message string) { +func assertErrNil(eh func(args ...any), err error, message string) { if err != nil { eh(fmt.Sprintf("%s: %q", message, err)) } } + +func TestStorageGetsLoggerFromDriver(t *testing.T) { + d := driver.NewMemory() + l := &mockSLogHandler{} + d.SetLogger(l) + s := Init(d) + _, _ = s.Get("doesnt-matter", 123) + if !l.Called { + t.Fatal("Expected storage to use driver's logger, but it did not") + } +} + +type mockSLogHandler struct { + Called bool +} + +func (m *mockSLogHandler) Enabled(context.Context, slog.Level) bool { + return true +} + +func (m *mockSLogHandler) Handle(context.Context, slog.Record) error { + m.Called = true + return nil +} + +func (m *mockSLogHandler) WithAttrs([]slog.Attr) slog.Handler { + return m +} + +func (m *mockSLogHandler) WithGroup(string) slog.Handler { + return m +} diff --git a/pkg/helm/pkg/strvals/literal_parser.go b/pkg/helm/pkg/strvals/literal_parser.go index d5d4c25b..96355811 100644 --- a/pkg/helm/pkg/strvals/literal_parser.go +++ b/pkg/helm/pkg/strvals/literal_parser.go @@ -26,8 +26,8 @@ import ( // ParseLiteral parses a set line interpreting the value as a literal string. // // A set line is of the form name1=value1 -func ParseLiteral(s string) (map[string]interface{}, error) { - vals := map[string]interface{}{} +func ParseLiteral(s string) (map[string]any, error) { + vals := map[string]any{} scanner := bytes.NewBufferString(s) t := newLiteralParser(scanner, vals) err := t.parse() @@ -39,7 +39,7 @@ func ParseLiteral(s string) (map[string]interface{}, error) { // // If the strval string has a key that exists in dest, it overwrites the // dest version. -func ParseLiteralInto(s string, dest map[string]interface{}) error { +func ParseLiteralInto(s string, dest map[string]any) error { scanner := bytes.NewBufferString(s) t := newLiteralParser(scanner, dest) return t.parse() @@ -54,10 +54,10 @@ func ParseLiteralInto(s string, dest map[string]interface{}) error { // where data is the final parsed data from the parses with correct types type literalParser struct { sc *bytes.Buffer - data map[string]interface{} + data map[string]any } -func newLiteralParser(sc *bytes.Buffer, data map[string]interface{}) *literalParser { +func newLiteralParser(sc *bytes.Buffer, data map[string]any) *literalParser { return &literalParser{sc: sc, data: data} } @@ -88,7 +88,7 @@ func runesUntilLiteral(in io.RuneReader, stop map[rune]bool) ([]rune, rune, erro } } -func (t *literalParser) key(data map[string]interface{}, nestedNameLevel int) (reterr error) { +func (t *literalParser) key(data map[string]any, nestedNameLevel int) (reterr error) { defer func() { if r := recover(); r != nil { reterr = fmt.Errorf("unable to parse key: %s", r) @@ -106,7 +106,7 @@ func (t *literalParser) key(data map[string]interface{}, nestedNameLevel int) (r case lastRune == '=': // found end of key: swallow the '=' and get the value value, err := t.val() - if err == nil && err != io.EOF { + if err == nil && !errors.Is(err, io.EOF) { return err } set(data, string(key), string(value)) @@ -120,9 +120,9 @@ func (t *literalParser) key(data map[string]interface{}, nestedNameLevel int) (r } // first, create or find the target map in the given data - inner := map[string]interface{}{} + inner := map[string]any{} if _, ok := data[string(key)]; ok { - inner = data[string(key)].(map[string]interface{}) + inner = data[string(key)].(map[string]any) } // recurse on sub-tree with remaining data @@ -144,9 +144,9 @@ func (t *literalParser) key(data map[string]interface{}, nestedNameLevel int) (r kk := string(key) // find or create target list - list := []interface{}{} + list := []any{} if _, ok := data[kk]; ok { - list = data[kk].([]interface{}) + list = data[kk].([]any) } // now we need to get the value after the ] @@ -169,7 +169,7 @@ func (t *literalParser) keyIndex() (int, error) { return strconv.Atoi(string(v)) } -func (t *literalParser) listItem(list []interface{}, i, nestedNameLevel int) ([]interface{}, error) { +func (t *literalParser) listItem(list []any, i, nestedNameLevel int) ([]any, error) { if i < 0 { return list, fmt.Errorf("negative %d index not allowed", i) } @@ -191,14 +191,14 @@ func (t *literalParser) listItem(list []interface{}, i, nestedNameLevel int) ([] case lastRune == '.': // we have a nested object. Send to t.key - inner := map[string]interface{}{} + inner := map[string]any{} if len(list) > i { var ok bool - inner, ok = list[i].(map[string]interface{}) + inner, ok = list[i].(map[string]any) if !ok { // We have indices out of order. Initialize empty value. - list[i] = map[string]interface{}{} - inner = list[i].(map[string]interface{}) + list[i] = map[string]any{} + inner = list[i].(map[string]any) } } @@ -215,12 +215,12 @@ func (t *literalParser) listItem(list []interface{}, i, nestedNameLevel int) ([] if err != nil { return list, fmt.Errorf("error parsing index: %w", err) } - var crtList []interface{} + var crtList []any if len(list) > i { // If nested list already exists, take the value of list to next cycle. existed := list[i] if existed != nil { - crtList = list[i].([]interface{}) + crtList = list[i].([]any) } } diff --git a/pkg/helm/pkg/strvals/literal_parser_test.go b/pkg/helm/pkg/strvals/literal_parser_test.go index 6a76458f..cc3d73d5 100644 --- a/pkg/helm/pkg/strvals/literal_parser_test.go +++ b/pkg/helm/pkg/strvals/literal_parser_test.go @@ -26,7 +26,7 @@ import ( func TestParseLiteral(t *testing.T) { cases := []struct { str string - expect map[string]interface{} + expect map[string]any err bool }{ { @@ -35,61 +35,61 @@ func TestParseLiteral(t *testing.T) { }, { str: "name=", - expect: map[string]interface{}{"name": ""}, + expect: map[string]any{"name": ""}, }, { str: "name=value", - expect: map[string]interface{}{"name": "value"}, + expect: map[string]any{"name": "value"}, err: false, }, { str: "long_int_string=1234567890", - expect: map[string]interface{}{"long_int_string": "1234567890"}, + expect: map[string]any{"long_int_string": "1234567890"}, err: false, }, { str: "boolean=true", - expect: map[string]interface{}{"boolean": "true"}, + expect: map[string]any{"boolean": "true"}, err: false, }, { str: "is_null=null", - expect: map[string]interface{}{"is_null": "null"}, + expect: map[string]any{"is_null": "null"}, err: false, }, { str: "zero=0", - expect: map[string]interface{}{"zero": "0"}, + expect: map[string]any{"zero": "0"}, err: false, }, { str: "name1=null,name2=value2", - expect: map[string]interface{}{"name1": "null,name2=value2"}, + expect: map[string]any{"name1": "null,name2=value2"}, err: false, }, { str: "name1=value,,,tail", - expect: map[string]interface{}{"name1": "value,,,tail"}, + expect: map[string]any{"name1": "value,,,tail"}, err: false, }, { str: "leading_zeros=00009", - expect: map[string]interface{}{"leading_zeros": "00009"}, + expect: map[string]any{"leading_zeros": "00009"}, err: false, }, { str: "name=one two three", - expect: map[string]interface{}{"name": "one two three"}, + expect: map[string]any{"name": "one two three"}, err: false, }, { str: "outer.inner=value", - expect: map[string]interface{}{"outer": map[string]interface{}{"inner": "value"}}, + expect: map[string]any{"outer": map[string]any{"inner": "value"}}, err: false, }, { str: "outer.middle.inner=value", - expect: map[string]interface{}{"outer": map[string]interface{}{"middle": map[string]interface{}{"inner": "value"}}}, + expect: map[string]any{"outer": map[string]any{"middle": map[string]any{"inner": "value"}}}, err: false, }, { @@ -98,7 +98,7 @@ func TestParseLiteral(t *testing.T) { }, { str: "name1.name2=", - expect: map[string]interface{}{"name1": map[string]interface{}{"name2": ""}}, + expect: map[string]any{"name1": map[string]any{"name2": ""}}, err: false, }, { @@ -111,20 +111,20 @@ func TestParseLiteral(t *testing.T) { }, { str: "name1={value1,value2}", - expect: map[string]interface{}{"name1": "{value1,value2}"}, + expect: map[string]any{"name1": "{value1,value2}"}, }, // List support { str: "list[0]=foo", - expect: map[string]interface{}{"list": []string{"foo"}}, + expect: map[string]any{"list": []string{"foo"}}, err: false, }, { str: "list[0].foo=bar", - expect: map[string]interface{}{ - "list": []interface{}{ - map[string]interface{}{"foo": "bar"}, + expect: map[string]any{ + "list": []any{ + map[string]any{"foo": "bar"}, }, }, err: false, @@ -135,7 +135,7 @@ func TestParseLiteral(t *testing.T) { }, { str: "list[3]=bar", - expect: map[string]interface{}{"list": []interface{}{nil, nil, nil, "bar"}}, + expect: map[string]any{"list": []any{nil, nil, nil, "bar"}}, err: false, }, { @@ -144,133 +144,133 @@ func TestParseLiteral(t *testing.T) { }, { str: "noval[0]", - expect: map[string]interface{}{"noval": []interface{}{}}, + expect: map[string]any{"noval": []any{}}, err: false, }, { str: "noval[0]=", - expect: map[string]interface{}{"noval": []interface{}{""}}, + expect: map[string]any{"noval": []any{""}}, err: false, }, { str: "nested[0][0]=1", - expect: map[string]interface{}{"nested": []interface{}{[]interface{}{"1"}}}, + expect: map[string]any{"nested": []any{[]any{"1"}}}, err: false, }, { str: "nested[1][1]=1", - expect: map[string]interface{}{"nested": []interface{}{nil, []interface{}{nil, "1"}}}, + expect: map[string]any{"nested": []any{nil, []any{nil, "1"}}}, err: false, }, { str: "name1.name2[0].foo=bar", - expect: map[string]interface{}{ - "name1": map[string]interface{}{ - "name2": []map[string]interface{}{{"foo": "bar"}}, + expect: map[string]any{ + "name1": map[string]any{ + "name2": []map[string]any{{"foo": "bar"}}, }, }, }, { str: "name1.name2[1].foo=bar", - expect: map[string]interface{}{ - "name1": map[string]interface{}{ - "name2": []map[string]interface{}{nil, {"foo": "bar"}}, + expect: map[string]any{ + "name1": map[string]any{ + "name2": []map[string]any{nil, {"foo": "bar"}}, }, }, }, { str: "name1.name2[1].foo=bar", - expect: map[string]interface{}{ - "name1": map[string]interface{}{ - "name2": []map[string]interface{}{nil, {"foo": "bar"}}, + expect: map[string]any{ + "name1": map[string]any{ + "name2": []map[string]any{nil, {"foo": "bar"}}, }, }, }, { str: "]={}].", - expect: map[string]interface{}{"]": "{}]."}, + expect: map[string]any{"]": "{}]."}, err: false, }, // issue test cases: , = $ ( ) { } . \ \\ { str: "name=val,val", - expect: map[string]interface{}{"name": "val,val"}, + expect: map[string]any{"name": "val,val"}, err: false, }, { str: "name=val.val", - expect: map[string]interface{}{"name": "val.val"}, + expect: map[string]any{"name": "val.val"}, err: false, }, { str: "name=val=val", - expect: map[string]interface{}{"name": "val=val"}, + expect: map[string]any{"name": "val=val"}, err: false, }, { str: "name=val$val", - expect: map[string]interface{}{"name": "val$val"}, + expect: map[string]any{"name": "val$val"}, err: false, }, { str: "name=(value", - expect: map[string]interface{}{"name": "(value"}, + expect: map[string]any{"name": "(value"}, err: false, }, { str: "name=value)", - expect: map[string]interface{}{"name": "value)"}, + expect: map[string]any{"name": "value)"}, err: false, }, { str: "name=(value)", - expect: map[string]interface{}{"name": "(value)"}, + expect: map[string]any{"name": "(value)"}, err: false, }, { str: "name={value", - expect: map[string]interface{}{"name": "{value"}, + expect: map[string]any{"name": "{value"}, err: false, }, { str: "name=value}", - expect: map[string]interface{}{"name": "value}"}, + expect: map[string]any{"name": "value}"}, err: false, }, { str: "name={value}", - expect: map[string]interface{}{"name": "{value}"}, + expect: map[string]any{"name": "{value}"}, err: false, }, { str: "name={value1,value2}", - expect: map[string]interface{}{"name": "{value1,value2}"}, + expect: map[string]any{"name": "{value1,value2}"}, err: false, }, { str: `name=val\val`, - expect: map[string]interface{}{"name": `val\val`}, + expect: map[string]any{"name": `val\val`}, err: false, }, { str: `name=val\\val`, - expect: map[string]interface{}{"name": `val\\val`}, + expect: map[string]any{"name": `val\\val`}, err: false, }, { str: `name=val\\\val`, - expect: map[string]interface{}{"name": `val\\\val`}, + expect: map[string]any{"name": `val\\\val`}, err: false, }, { str: `name={val,.?*v\0a!l)some`, - expect: map[string]interface{}{"name": `{val,.?*v\0a!l)some`}, + expect: map[string]any{"name": `{val,.?*v\0a!l)some`}, err: false, }, { str: `name=em%GT)tqUDqz,i-\h+Mbqs-!:.m\\rE=mkbM#rR}@{-k@`, - expect: map[string]interface{}{"name": `em%GT)tqUDqz,i-\h+Mbqs-!:.m\\rE=mkbM#rR}@{-k@`}, + expect: map[string]any{"name": `em%GT)tqUDqz,i-\h+Mbqs-!:.m\\rE=mkbM#rR}@{-k@`}, }, } @@ -307,20 +307,20 @@ func TestParseLiteralInto(t *testing.T) { tests := []struct { input string input2 string - got map[string]interface{} - expect map[string]interface{} + got map[string]any + expect map[string]any err bool }{ { input: "outer.inner1=value1,outer.inner3=value3,outer.inner4=4", - got: map[string]interface{}{ - "outer": map[string]interface{}{ + got: map[string]any{ + "outer": map[string]any{ "inner1": "overwrite", "inner2": "value2", }, }, - expect: map[string]interface{}{ - "outer": map[string]interface{}{ + expect: map[string]any{ + "outer": map[string]any{ "inner1": "value1,outer.inner3=value3,outer.inner4=4", "inner2": "value2", }}, @@ -329,9 +329,9 @@ func TestParseLiteralInto(t *testing.T) { { input: "listOuter[0][0].type=listValue", input2: "listOuter[0][0].status=alive", - got: map[string]interface{}{}, - expect: map[string]interface{}{ - "listOuter": [][]interface{}{{map[string]string{ + got: map[string]any{}, + expect: map[string]any{ + "listOuter": [][]any{{map[string]string{ "type": "listValue", "status": "alive", }}}, @@ -341,9 +341,9 @@ func TestParseLiteralInto(t *testing.T) { { input: "listOuter[0][0].type=listValue", input2: "listOuter[1][0].status=alive", - got: map[string]interface{}{}, - expect: map[string]interface{}{ - "listOuter": [][]interface{}{ + got: map[string]any{}, + expect: map[string]any{ + "listOuter": [][]any{ { map[string]string{"type": "listValue"}, }, @@ -357,17 +357,17 @@ func TestParseLiteralInto(t *testing.T) { { input: "listOuter[0][1][0].type=listValue", input2: "listOuter[0][0][1].status=alive", - got: map[string]interface{}{ - "listOuter": []interface{}{ - []interface{}{ - []interface{}{ + got: map[string]any{ + "listOuter": []any{ + []any{ + []any{ map[string]string{"exited": "old"}, }, }, }, }, - expect: map[string]interface{}{ - "listOuter": [][][]interface{}{ + expect: map[string]any{ + "listOuter": [][][]any{ { { map[string]string{"exited": "old"}, @@ -429,13 +429,13 @@ func TestParseLiteralNestedLevels(t *testing.T) { tests := []struct { str string - expect map[string]interface{} + expect map[string]any err bool errStr string }{ { "outer.middle.inner=value", - map[string]interface{}{"outer": map[string]interface{}{"middle": map[string]interface{}{"inner": "value"}}}, + map[string]any{"outer": map[string]any{"middle": map[string]any{"inner": "value"}}}, false, "", }, diff --git a/pkg/helm/pkg/strvals/parser.go b/pkg/helm/pkg/strvals/parser.go index 8eb761dc..cecaa245 100644 --- a/pkg/helm/pkg/strvals/parser.go +++ b/pkg/helm/pkg/strvals/parser.go @@ -52,8 +52,8 @@ func ToYAML(s string) (string, error) { // Parse parses a set line. // // A set line is of the form name1=value1,name2=value2 -func Parse(s string) (map[string]interface{}, error) { - vals := map[string]interface{}{} +func Parse(s string) (map[string]any, error) { + vals := map[string]any{} scanner := bytes.NewBufferString(s) t := newParser(scanner, vals, false) err := t.parse() @@ -63,8 +63,8 @@ func Parse(s string) (map[string]interface{}, error) { // ParseString parses a set line and forces a string value. // // A set line is of the form name1=value1,name2=value2 -func ParseString(s string) (map[string]interface{}, error) { - vals := map[string]interface{}{} +func ParseString(s string) (map[string]any, error) { + vals := map[string]any{} scanner := bytes.NewBufferString(s) t := newParser(scanner, vals, true) err := t.parse() @@ -75,7 +75,7 @@ func ParseString(s string) (map[string]interface{}, error) { // // If the strval string has a key that exists in dest, it overwrites the // dest version. -func ParseInto(s string, dest map[string]interface{}) error { +func ParseInto(s string, dest map[string]any) error { scanner := bytes.NewBufferString(s) t := newParser(scanner, dest, false) return t.parse() @@ -87,8 +87,8 @@ func ParseInto(s string, dest map[string]interface{}) error { // // When the files at path1 and path2 contained "val1" and "val2" respectively, the set line is consumed as // name1=val1,name2=val2 -func ParseFile(s string, reader RunesValueReader) (map[string]interface{}, error) { - vals := map[string]interface{}{} +func ParseFile(s string, reader RunesValueReader) (map[string]any, error) { + vals := map[string]any{} scanner := bytes.NewBufferString(s) t := newFileParser(scanner, vals, reader) err := t.parse() @@ -98,7 +98,7 @@ func ParseFile(s string, reader RunesValueReader) (map[string]interface{}, error // ParseIntoString parses a strvals line and merges the result into dest. // // This method always returns a string as the value. -func ParseIntoString(s string, dest map[string]interface{}) error { +func ParseIntoString(s string, dest map[string]any) error { scanner := bytes.NewBufferString(s) t := newParser(scanner, dest, true) return t.parse() @@ -109,7 +109,7 @@ func ParseIntoString(s string, dest map[string]interface{}) error { // An empty val is treated as null. // // If a key exists in dest, the new value overwrites the dest version. -func ParseJSON(s string, dest map[string]interface{}) error { +func ParseJSON(s string, dest map[string]any) error { scanner := bytes.NewBufferString(s) t := newJSONParser(scanner, dest) return t.parse() @@ -118,7 +118,7 @@ func ParseJSON(s string, dest map[string]interface{}) error { // ParseIntoFile parses a filevals line and merges the result into dest. // // This method always returns a string as the value. -func ParseIntoFile(s string, dest map[string]interface{}, reader RunesValueReader) error { +func ParseIntoFile(s string, dest map[string]any, reader RunesValueReader) error { scanner := bytes.NewBufferString(s) t := newFileParser(scanner, dest, reader) return t.parse() @@ -126,7 +126,7 @@ func ParseIntoFile(s string, dest map[string]interface{}, reader RunesValueReade // RunesValueReader is a function that takes the given value (a slice of runes) // and returns the parsed value -type RunesValueReader func([]rune) (interface{}, error) +type RunesValueReader func([]rune) (any, error) // parser is a simple parser that takes a strvals line and parses it into a // map representation. @@ -135,23 +135,23 @@ type RunesValueReader func([]rune) (interface{}, error) // where data is the final parsed data from the parses with correct types type parser struct { sc *bytes.Buffer - data map[string]interface{} + data map[string]any reader RunesValueReader isjsonval bool } -func newParser(sc *bytes.Buffer, data map[string]interface{}, stringBool bool) *parser { - stringConverter := func(rs []rune) (interface{}, error) { +func newParser(sc *bytes.Buffer, data map[string]any, stringBool bool) *parser { + stringConverter := func(rs []rune) (any, error) { return typedVal(rs, stringBool), nil } return &parser{sc: sc, data: data, reader: stringConverter} } -func newJSONParser(sc *bytes.Buffer, data map[string]interface{}) *parser { +func newJSONParser(sc *bytes.Buffer, data map[string]any) *parser { return &parser{sc: sc, data: data, reader: nil, isjsonval: true} } -func newFileParser(sc *bytes.Buffer, data map[string]interface{}, reader RunesValueReader) *parser { +func newFileParser(sc *bytes.Buffer, data map[string]any, reader RunesValueReader) *parser { return &parser{sc: sc, data: data, reader: reader} } @@ -176,7 +176,7 @@ func runeSet(r []rune) map[rune]bool { return s } -func (t *parser) key(data map[string]interface{}, nestedNameLevel int) (reterr error) { +func (t *parser) key(data map[string]any, nestedNameLevel int) (reterr error) { defer func() { if r := recover(); r != nil { reterr = fmt.Errorf("unable to parse key: %s", r) @@ -200,9 +200,9 @@ func (t *parser) key(data map[string]interface{}, nestedNameLevel int) (reterr e } kk := string(k) // Find or create target list - list := []interface{}{} + list := []any{} if _, ok := data[kk]; ok { - list = data[kk].([]interface{}) + list = data[kk].([]any) } // Now we need to get the value after the ]. @@ -224,7 +224,7 @@ func (t *parser) key(data map[string]interface{}, nestedNameLevel int) (reterr e // Since Decode has its own buffer that consumes more characters (from underlying t.sc) than the ones actually decoded, // we invoke Decode on a separate reader built with a copy of what is left in t.sc. After Decode is executed, we // discard in t.sc the chars of the decoded json value (the number of those characters is returned by InputOffset). - var jsonval interface{} + var jsonval any dec := json.NewDecoder(strings.NewReader(t.sc.String())) if err = dec.Decode(&jsonval); err != nil { return err @@ -270,9 +270,9 @@ func (t *parser) key(data map[string]interface{}, nestedNameLevel int) (reterr e } // First, create or find the target map. - inner := map[string]interface{}{} + inner := map[string]any{} if _, ok := data[string(k)]; ok { - inner = data[string(k)].(map[string]interface{}) + inner = data[string(k)].(map[string]any) } // Recurse @@ -288,7 +288,7 @@ func (t *parser) key(data map[string]interface{}, nestedNameLevel int) (reterr e } } -func set(data map[string]interface{}, key string, val interface{}) { +func set(data map[string]any, key string, val any) { // If key is empty, don't set it. if len(key) == 0 { return @@ -296,7 +296,7 @@ func set(data map[string]interface{}, key string, val interface{}) { data[key] = val } -func setIndex(list []interface{}, index int, val interface{}) (l2 []interface{}, err error) { +func setIndex(list []any, index int, val any) (l2 []any, err error) { // There are possible index values that are out of range on a target system // causing a panic. This will catch the panic and return an error instead. // The value of the index that causes a panic varies from system to system. @@ -313,7 +313,7 @@ func setIndex(list []interface{}, index int, val interface{}) (l2 []interface{}, return list, fmt.Errorf("index of %d is greater than maximum supported index of %d", index, MaxIndex) } if len(list) <= index { - newlist := make([]interface{}, index+1) + newlist := make([]any, index+1) copy(newlist, list) list = newlist } @@ -333,7 +333,7 @@ func (t *parser) keyIndex() (int, error) { } -func (t *parser) listItem(list []interface{}, i, nestedNameLevel int) ([]interface{}, error) { +func (t *parser) listItem(list []any, i, nestedNameLevel int) ([]any, error) { if i < 0 { return list, fmt.Errorf("negative %d index not allowed", i) } @@ -357,7 +357,7 @@ func (t *parser) listItem(list []interface{}, i, nestedNameLevel int) ([]interfa // Since Decode has its own buffer that consumes more characters (from underlying t.sc) than the ones actually decoded, // we invoke Decode on a separate reader built with a copy of what is left in t.sc. After Decode is executed, we // discard in t.sc the chars of the decoded json value (the number of those characters is returned by InputOffset). - var jsonval interface{} + var jsonval any dec := json.NewDecoder(strings.NewReader(t.sc.String())) if err = dec.Decode(&jsonval); err != nil { return list, err @@ -397,12 +397,12 @@ func (t *parser) listItem(list []interface{}, i, nestedNameLevel int) ([]interfa if err != nil { return list, fmt.Errorf("error parsing index: %w", err) } - var crtList []interface{} + var crtList []any if len(list) > i { // If nested list already exists, take the value of list to next cycle. existed := list[i] if existed != nil { - crtList = list[i].([]interface{}) + crtList = list[i].([]any) } } // Now we need to get the value after the ]. @@ -413,14 +413,14 @@ func (t *parser) listItem(list []interface{}, i, nestedNameLevel int) ([]interfa return setIndex(list, i, list2) case last == '.': // We have a nested object. Send to t.key - inner := map[string]interface{}{} + inner := map[string]any{} if len(list) > i { var ok bool - inner, ok = list[i].(map[string]interface{}) + inner, ok = list[i].(map[string]any) if !ok { // We have indices out of order. Initialize empty value. - list[i] = map[string]interface{}{} - inner = list[i].(map[string]interface{}) + list[i] = map[string]any{} + inner = list[i].(map[string]any) } } @@ -463,18 +463,18 @@ func (t *parser) val() ([]rune, error) { return v, err } -func (t *parser) valList() ([]interface{}, error) { +func (t *parser) valList() ([]any, error) { r, _, e := t.sc.ReadRune() if e != nil { - return []interface{}{}, e + return []any{}, e } if r != '{' { t.sc.UnreadRune() - return []interface{}{}, ErrNotList + return []any{}, ErrNotList } - list := []interface{}{} + list := []any{} stop := runeSet([]rune{',', '}'}) for { switch rs, last, err := runesUntil(t.sc, stop); { @@ -526,7 +526,7 @@ func inMap(k rune, m map[rune]bool) bool { return ok } -func typedVal(v []rune, st bool) interface{} { +func typedVal(v []rune, st bool) any { val := string(v) if st { diff --git a/pkg/helm/pkg/strvals/parser_test.go b/pkg/helm/pkg/strvals/parser_test.go index 73403fc5..e3f8b9d8 100644 --- a/pkg/helm/pkg/strvals/parser_test.go +++ b/pkg/helm/pkg/strvals/parser_test.go @@ -26,48 +26,48 @@ import ( func TestSetIndex(t *testing.T) { tests := []struct { name string - initial []interface{} - expect []interface{} + initial []any + expect []any add int val int err bool }{ { name: "short", - initial: []interface{}{0, 1}, - expect: []interface{}{0, 1, 2}, + initial: []any{0, 1}, + expect: []any{0, 1, 2}, add: 2, val: 2, err: false, }, { name: "equal", - initial: []interface{}{0, 1}, - expect: []interface{}{0, 2}, + initial: []any{0, 1}, + expect: []any{0, 2}, add: 1, val: 2, err: false, }, { name: "long", - initial: []interface{}{0, 1, 2, 3, 4, 5}, - expect: []interface{}{0, 1, 2, 4, 4, 5}, + initial: []any{0, 1, 2, 3, 4, 5}, + expect: []any{0, 1, 2, 4, 4, 5}, add: 3, val: 4, err: false, }, { name: "negative", - initial: []interface{}{0, 1, 2, 3, 4, 5}, - expect: []interface{}{0, 1, 2, 3, 4, 5}, + initial: []any{0, 1, 2, 3, 4, 5}, + expect: []any{0, 1, 2, 3, 4, 5}, add: -1, val: 4, err: true, }, { name: "large", - initial: []interface{}{0, 1, 2, 3, 4, 5}, - expect: []interface{}{0, 1, 2, 3, 4, 5}, + initial: []any{0, 1, 2, 3, 4, 5}, + expect: []any{0, 1, 2, 3, 4, 5}, add: MaxIndex + 1, val: 4, err: true, @@ -104,53 +104,53 @@ func TestSetIndex(t *testing.T) { func TestParseSet(t *testing.T) { testsString := []struct { str string - expect map[string]interface{} + expect map[string]any err bool }{ { str: "long_int_string=1234567890", - expect: map[string]interface{}{"long_int_string": "1234567890"}, + expect: map[string]any{"long_int_string": "1234567890"}, err: false, }, { str: "boolean=true", - expect: map[string]interface{}{"boolean": "true"}, + expect: map[string]any{"boolean": "true"}, err: false, }, { str: "is_null=null", - expect: map[string]interface{}{"is_null": "null"}, + expect: map[string]any{"is_null": "null"}, err: false, }, { str: "zero=0", - expect: map[string]interface{}{"zero": "0"}, + expect: map[string]any{"zero": "0"}, err: false, }, } tests := []struct { str string - expect map[string]interface{} + expect map[string]any err bool }{ { "name1=null,f=false,t=true", - map[string]interface{}{"name1": nil, "f": false, "t": true}, + map[string]any{"name1": nil, "f": false, "t": true}, false, }, { "name1=value1", - map[string]interface{}{"name1": "value1"}, + map[string]any{"name1": "value1"}, false, }, { "name1=value1,name2=value2", - map[string]interface{}{"name1": "value1", "name2": "value2"}, + map[string]any{"name1": "value1", "name2": "value2"}, false, }, { "name1=value1,name2=value2,", - map[string]interface{}{"name1": "value1", "name2": "value2"}, + map[string]any{"name1": "value1", "name2": "value2"}, false, }, { @@ -159,27 +159,27 @@ func TestParseSet(t *testing.T) { }, { str: "name1=,name2=value2", - expect: map[string]interface{}{"name1": "", "name2": "value2"}, + expect: map[string]any{"name1": "", "name2": "value2"}, }, { str: "leading_zeros=00009", - expect: map[string]interface{}{"leading_zeros": "00009"}, + expect: map[string]any{"leading_zeros": "00009"}, }, { str: "zero_int=0", - expect: map[string]interface{}{"zero_int": 0}, + expect: map[string]any{"zero_int": 0}, }, { str: "long_int=1234567890", - expect: map[string]interface{}{"long_int": 1234567890}, + expect: map[string]any{"long_int": 1234567890}, }, { str: "boolean=true", - expect: map[string]interface{}{"boolean": true}, + expect: map[string]any{"boolean": true}, }, { str: "is_null=null", - expect: map[string]interface{}{"is_null": nil}, + expect: map[string]any{"is_null": nil}, err: false, }, { @@ -200,40 +200,40 @@ func TestParseSet(t *testing.T) { }, { "name1=one\\,two,name2=three\\,four", - map[string]interface{}{"name1": "one,two", "name2": "three,four"}, + map[string]any{"name1": "one,two", "name2": "three,four"}, false, }, { "name1=one\\=two,name2=three\\=four", - map[string]interface{}{"name1": "one=two", "name2": "three=four"}, + map[string]any{"name1": "one=two", "name2": "three=four"}, false, }, { "name1=one two three,name2=three two one", - map[string]interface{}{"name1": "one two three", "name2": "three two one"}, + map[string]any{"name1": "one two three", "name2": "three two one"}, false, }, { "outer.inner=value", - map[string]interface{}{"outer": map[string]interface{}{"inner": "value"}}, + map[string]any{"outer": map[string]any{"inner": "value"}}, false, }, { "outer.middle.inner=value", - map[string]interface{}{"outer": map[string]interface{}{"middle": map[string]interface{}{"inner": "value"}}}, + map[string]any{"outer": map[string]any{"middle": map[string]any{"inner": "value"}}}, false, }, { "outer.inner1=value,outer.inner2=value2", - map[string]interface{}{"outer": map[string]interface{}{"inner1": "value", "inner2": "value2"}}, + map[string]any{"outer": map[string]any{"inner1": "value", "inner2": "value2"}}, false, }, { "outer.inner1=value,outer.middle.inner=value", - map[string]interface{}{ - "outer": map[string]interface{}{ + map[string]any{ + "outer": map[string]any{ "inner1": "value", - "middle": map[string]interface{}{ + "middle": map[string]any{ "inner": "value", }, }, @@ -250,7 +250,7 @@ func TestParseSet(t *testing.T) { }, { str: "name1.name2=", - expect: map[string]interface{}{"name1": map[string]interface{}{"name2": ""}}, + expect: map[string]any{"name1": map[string]any{"name2": ""}}, }, { str: "name1.=name2", @@ -262,12 +262,12 @@ func TestParseSet(t *testing.T) { }, { "name1={value1,value2}", - map[string]interface{}{"name1": []string{"value1", "value2"}}, + map[string]any{"name1": []string{"value1", "value2"}}, false, }, { "name1={value1,value2},name2={value1,value2}", - map[string]interface{}{ + map[string]any{ "name1": []string{"value1", "value2"}, "name2": []string{"value1", "value2"}, }, @@ -275,12 +275,12 @@ func TestParseSet(t *testing.T) { }, { "name1={1021,902}", - map[string]interface{}{"name1": []int{1021, 902}}, + map[string]any{"name1": []int{1021, 902}}, false, }, { "name1.name2={value1,value2}", - map[string]interface{}{"name1": map[string]interface{}{"name2": []string{"value1", "value2"}}}, + map[string]any{"name1": map[string]any{"name2": []string{"value1", "value2"}}}, false, }, { @@ -290,21 +290,21 @@ func TestParseSet(t *testing.T) { // List support { str: "list[0]=foo", - expect: map[string]interface{}{"list": []string{"foo"}}, + expect: map[string]any{"list": []string{"foo"}}, }, { str: "list[0].foo=bar", - expect: map[string]interface{}{ - "list": []interface{}{ - map[string]interface{}{"foo": "bar"}, + expect: map[string]any{ + "list": []any{ + map[string]any{"foo": "bar"}, }, }, }, { str: "list[0].foo=bar,list[0].hello=world", - expect: map[string]interface{}{ - "list": []interface{}{ - map[string]interface{}{"foo": "bar", "hello": "world"}, + expect: map[string]any{ + "list": []any{ + map[string]any{"foo": "bar", "hello": "world"}, }, }, }, @@ -314,15 +314,15 @@ func TestParseSet(t *testing.T) { }, { str: "list[0]=foo,list[1]=bar", - expect: map[string]interface{}{"list": []string{"foo", "bar"}}, + expect: map[string]any{"list": []string{"foo", "bar"}}, }, { str: "list[0]=foo,list[1]=bar,", - expect: map[string]interface{}{"list": []string{"foo", "bar"}}, + expect: map[string]any{"list": []string{"foo", "bar"}}, }, { str: "list[0]=foo,list[3]=bar", - expect: map[string]interface{}{"list": []interface{}{"foo", nil, nil, "bar"}}, + expect: map[string]any{"list": []any{"foo", nil, nil, "bar"}}, }, { str: "list[0]=foo,list[-20]=bar", @@ -334,41 +334,41 @@ func TestParseSet(t *testing.T) { }, { str: "noval[0]", - expect: map[string]interface{}{"noval": []interface{}{}}, + expect: map[string]any{"noval": []any{}}, }, { str: "noval[0]=", - expect: map[string]interface{}{"noval": []interface{}{""}}, + expect: map[string]any{"noval": []any{""}}, }, { str: "nested[0][0]=1", - expect: map[string]interface{}{"nested": []interface{}{[]interface{}{1}}}, + expect: map[string]any{"nested": []any{[]any{1}}}, }, { str: "nested[1][1]=1", - expect: map[string]interface{}{"nested": []interface{}{nil, []interface{}{nil, 1}}}, + expect: map[string]any{"nested": []any{nil, []any{nil, 1}}}, }, { str: "name1.name2[0].foo=bar,name1.name2[1].foo=bar", - expect: map[string]interface{}{ - "name1": map[string]interface{}{ - "name2": []map[string]interface{}{{"foo": "bar"}, {"foo": "bar"}}, + expect: map[string]any{ + "name1": map[string]any{ + "name2": []map[string]any{{"foo": "bar"}, {"foo": "bar"}}, }, }, }, { str: "name1.name2[1].foo=bar,name1.name2[0].foo=bar", - expect: map[string]interface{}{ - "name1": map[string]interface{}{ - "name2": []map[string]interface{}{{"foo": "bar"}, {"foo": "bar"}}, + expect: map[string]any{ + "name1": map[string]any{ + "name2": []map[string]any{{"foo": "bar"}, {"foo": "bar"}}, }, }, }, { str: "name1.name2[1].foo=bar", - expect: map[string]interface{}{ - "name1": map[string]interface{}{ - "name2": []map[string]interface{}{nil, {"foo": "bar"}}, + expect: map[string]any{ + "name1": map[string]any{ + "name2": []map[string]any{nil, {"foo": "bar"}}, }, }, }, @@ -434,20 +434,20 @@ func TestParseInto(t *testing.T) { tests := []struct { input string input2 string - got map[string]interface{} - expect map[string]interface{} + got map[string]any + expect map[string]any err bool }{ { input: "outer.inner1=value1,outer.inner3=value3,outer.inner4=4", - got: map[string]interface{}{ - "outer": map[string]interface{}{ + got: map[string]any{ + "outer": map[string]any{ "inner1": "overwrite", "inner2": "value2", }, }, - expect: map[string]interface{}{ - "outer": map[string]interface{}{ + expect: map[string]any{ + "outer": map[string]any{ "inner1": "value1", "inner2": "value2", "inner3": "value3", @@ -458,9 +458,9 @@ func TestParseInto(t *testing.T) { { input: "listOuter[0][0].type=listValue", input2: "listOuter[0][0].status=alive", - got: map[string]interface{}{}, - expect: map[string]interface{}{ - "listOuter": [][]interface{}{{map[string]string{ + got: map[string]any{}, + expect: map[string]any{ + "listOuter": [][]any{{map[string]string{ "type": "listValue", "status": "alive", }}}, @@ -470,9 +470,9 @@ func TestParseInto(t *testing.T) { { input: "listOuter[0][0].type=listValue", input2: "listOuter[1][0].status=alive", - got: map[string]interface{}{}, - expect: map[string]interface{}{ - "listOuter": [][]interface{}{ + got: map[string]any{}, + expect: map[string]any{ + "listOuter": [][]any{ { map[string]string{"type": "listValue"}, }, @@ -486,17 +486,17 @@ func TestParseInto(t *testing.T) { { input: "listOuter[0][1][0].type=listValue", input2: "listOuter[0][0][1].status=alive", - got: map[string]interface{}{ - "listOuter": []interface{}{ - []interface{}{ - []interface{}{ + got: map[string]any{ + "listOuter": []any{ + []any{ + []any{ map[string]string{"exited": "old"}, }, }, }, }, - expect: map[string]interface{}{ - "listOuter": [][][]interface{}{ + expect: map[string]any{ + "listOuter": [][][]any{ { { map[string]string{"exited": "old"}, @@ -544,15 +544,15 @@ func TestParseInto(t *testing.T) { } func TestParseIntoString(t *testing.T) { - got := map[string]interface{}{ - "outer": map[string]interface{}{ + got := map[string]any{ + "outer": map[string]any{ "inner1": "overwrite", "inner2": "value2", }, } input := "outer.inner1=1,outer.inner3=3" - expect := map[string]interface{}{ - "outer": map[string]interface{}{ + expect := map[string]any{ + "outer": map[string]any{ "inner1": "1", "inner2": "value2", "inner3": "3", @@ -580,20 +580,20 @@ func TestParseIntoString(t *testing.T) { func TestParseJSON(t *testing.T) { tests := []struct { input string - got map[string]interface{} - expect map[string]interface{} + got map[string]any + expect map[string]any err bool }{ { // set json scalars values, and replace one existing key input: "outer.inner1=\"1\",outer.inner3=3,outer.inner4=true,outer.inner5=\"true\"", - got: map[string]interface{}{ - "outer": map[string]interface{}{ + got: map[string]any{ + "outer": map[string]any{ "inner1": "overwrite", "inner2": "value2", }, }, - expect: map[string]interface{}{ - "outer": map[string]interface{}{ + expect: map[string]any{ + "outer": map[string]any{ "inner1": "1", "inner2": "value2", "inner3": 3, @@ -605,43 +605,43 @@ func TestParseJSON(t *testing.T) { }, { // set json objects and arrays, and replace one existing key input: "outer.inner1={\"a\":\"1\",\"b\":2,\"c\":[1,2,3]},outer.inner3=[\"new value 1\",\"new value 2\"],outer.inner4={\"aa\":\"1\",\"bb\":2,\"cc\":[1,2,3]},outer.inner5=[{\"A\":\"1\",\"B\":2,\"C\":[1,2,3]}]", - got: map[string]interface{}{ - "outer": map[string]interface{}{ - "inner1": map[string]interface{}{ + got: map[string]any{ + "outer": map[string]any{ + "inner1": map[string]any{ "x": "overwrite", }, "inner2": "value2", - "inner3": []interface{}{ + "inner3": []any{ "overwrite", }, }, }, - expect: map[string]interface{}{ - "outer": map[string]interface{}{ - "inner1": map[string]interface{}{"a": "1", "b": 2, "c": []interface{}{1, 2, 3}}, + expect: map[string]any{ + "outer": map[string]any{ + "inner1": map[string]any{"a": "1", "b": 2, "c": []any{1, 2, 3}}, "inner2": "value2", - "inner3": []interface{}{"new value 1", "new value 2"}, - "inner4": map[string]interface{}{"aa": "1", "bb": 2, "cc": []interface{}{1, 2, 3}}, - "inner5": []interface{}{map[string]interface{}{"A": "1", "B": 2, "C": []interface{}{1, 2, 3}}}, + "inner3": []any{"new value 1", "new value 2"}, + "inner4": map[string]any{"aa": "1", "bb": 2, "cc": []any{1, 2, 3}}, + "inner5": []any{map[string]any{"A": "1", "B": 2, "C": []any{1, 2, 3}}}, }, }, err: false, }, { // null assignment, and no value assigned (equivalent to null) input: "outer.inner1=,outer.inner3={\"aa\":\"1\",\"bb\":2,\"cc\":[1,2,3]},outer.inner3.cc[1]=null", - got: map[string]interface{}{ - "outer": map[string]interface{}{ - "inner1": map[string]interface{}{ + got: map[string]any{ + "outer": map[string]any{ + "inner1": map[string]any{ "x": "overwrite", }, "inner2": "value2", }, }, - expect: map[string]interface{}{ - "outer": map[string]interface{}{ + expect: map[string]any{ + "outer": map[string]any{ "inner1": nil, "inner2": "value2", - "inner3": map[string]interface{}{"aa": "1", "bb": 2, "cc": []interface{}{1, nil, 3}}, + "inner3": map[string]any{"aa": "1", "bb": 2, "cc": []any{1, nil, 3}}, }, }, err: false, @@ -680,10 +680,10 @@ func TestParseJSON(t *testing.T) { func TestParseFile(t *testing.T) { input := "name1=path1" - expect := map[string]interface{}{ + expect := map[string]any{ "name1": "value1", } - rs2v := func(rs []rune) (interface{}, error) { + rs2v := func(rs []rune) (any, error) { v := string(rs) if v != "path1" { t.Errorf("%s: runesToVal: Expected value path1, got %s", input, v) @@ -712,12 +712,12 @@ func TestParseFile(t *testing.T) { } func TestParseIntoFile(t *testing.T) { - got := map[string]interface{}{} + got := map[string]any{} input := "name1=path1" - expect := map[string]interface{}{ + expect := map[string]any{ "name1": "value1", } - rs2v := func(rs []rune) (interface{}, error) { + rs2v := func(rs []rune) (any, error) { v := string(rs) if v != "path1" { t.Errorf("%s: runesToVal: Expected value path1, got %s", input, v) @@ -768,13 +768,13 @@ func TestParseSetNestedLevels(t *testing.T) { } tests := []struct { str string - expect map[string]interface{} + expect map[string]any err bool errStr string }{ { "outer.middle.inner=value", - map[string]interface{}{"outer": map[string]interface{}{"middle": map[string]interface{}{"inner": "value"}}}, + map[string]any{"outer": map[string]any{"middle": map[string]any{"inner": "value"}}}, false, "", }, From ae98e045c5384ec776ce4991e850e4e1f82cc001 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 10 Jun 2026 03:03:06 +0300 Subject: [PATCH 39/85] feat: native Chart v3 and Release v2 support Render and deploy v3 charts as native release/v2 objects instead of down-converting v3 to v2 in memory. chart.Accessor and release.Accessor are threaded through the render, release, action and plan layers so each layer is release-version agnostic. On-disk storage stays release/v1-shaped for full Helm v4 compatibility: the native release/v2 is converted to v1 at the storage write seam, and the plan artifact (scheme bumped to v2) reconstructs the native v2 release on --use-plan via a release-version discriminator. Signed-off-by: Ilya Drey --- pkg/action/chart_lint.go | 4 +- pkg/action/chart_render.go | 2 +- pkg/action/chart_ts_build.go | 22 +++- pkg/action/plan_artifact.go | 20 +++- pkg/action/release_get.go | 42 +++++--- pkg/action/release_history.go | 32 ++++-- pkg/action/release_install.go | 35 ++++--- pkg/action/release_list.go | 30 ++++-- pkg/action/release_plan_install.go | 8 +- pkg/action/release_rollback.go | 34 +++--- pkg/action/release_uninstall.go | 4 +- pkg/chart/chart_render.go | 113 +------------------- pkg/helm/intern/chart/v3/chart.go | 35 +++++++ pkg/helm/intern/release/v2/release.go | 3 +- pkg/helm/pkg/action/package.go | 2 +- pkg/helm/pkg/chart/common.go | 32 ++++++ pkg/helm/pkg/chart/interfaces.go | 4 + pkg/helm/pkg/release/common.go | 32 ++++++ pkg/helm/pkg/release/interfaces.go | 4 + pkg/plan/operation_config.go | 24 +++-- pkg/plan/plan_build.go | 98 ++++++++--------- pkg/plan/plan_build_test.go | 7 +- pkg/plan/plan_execute.go | 15 ++- pkg/plan/release_info.go | 27 ++--- pkg/release/history.go | 106 +++++++++++-------- pkg/release/release.go | 139 +++++++++++++++++++------ pkg/release/release_storage.go | 119 +++++++++++++++++---- pkg/release/release_storage_ai_test.go | 60 +++++++++++ pkg/release/stored_release.go | 74 +++++++++++++ pkg/release/stored_release_ai_test.go | 106 +++++++++++++++++++ pkg/ts/deno.go | 30 ++++-- pkg/ts/render.go | 79 +++++--------- 32 files changed, 916 insertions(+), 426 deletions(-) create mode 100644 pkg/release/release_storage_ai_test.go create mode 100644 pkg/release/stored_release.go create mode 100644 pkg/release/stored_release_ai_test.go diff --git a/pkg/action/chart_lint.go b/pkg/action/chart_lint.go index b67a16b7..5aa7145e 100644 --- a/pkg/action/chart_lint.go +++ b/pkg/action/chart_lint.go @@ -234,8 +234,8 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { ) if prevRelease != nil { - newRevision = prevRelease.Version + 1 - prevReleaseFailed = prevRelease.Info.Status == helmreleasestatus.StatusFailed + newRevision = prevRelease.Version() + 1 + prevReleaseFailed = prevRelease.Status() == helmreleasestatus.StatusFailed.String() } else { newRevision = 1 } diff --git a/pkg/action/chart_render.go b/pkg/action/chart_render.go index 46252e58..bfae6a55 100644 --- a/pkg/action/chart_render.go +++ b/pkg/action/chart_render.go @@ -234,7 +234,7 @@ func ChartRender(ctx context.Context, opts ChartRenderOptions) (*ChartRenderResu var newRevision int if prevRelease != nil { - newRevision = prevRelease.Version + 1 + newRevision = prevRelease.Version() + 1 } else { newRevision = 1 } diff --git a/pkg/action/chart_ts_build.go b/pkg/action/chart_ts_build.go index 26368b0d..9883fb4a 100644 --- a/pkg/action/chart_ts_build.go +++ b/pkg/action/chart_ts_build.go @@ -13,6 +13,8 @@ import ( "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/featgate" + v3chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" + helmchart "github.com/werf/nelm/pkg/helm/pkg/chart" chartcommon "github.com/werf/nelm/pkg/helm/pkg/chart/common" "github.com/werf/nelm/pkg/helm/pkg/chart/loader" v2chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" @@ -58,16 +60,26 @@ func ChartTSBuild(ctx context.Context, opts ChartTSBuildOptions) error { return fmt.Errorf("load chart: %w", err) } - chart, ok := loadedChart.(*v2chart.Chart) - if !ok { - return fmt.Errorf("unsupported chart type %T", loadedChart) + chartAccessor, err := helmchart.NewAccessor(loadedChart) + if err != nil { + return fmt.Errorf("create chart accessor: %w", err) } - if err = ts.BundleChartsRecursive(ctx, chart, absPath, true, opts.DenoBinaryPath); err != nil { + if err = ts.BundleChartsRecursive(ctx, chartAccessor, absPath, true, opts.DenoBinaryPath); err != nil { return fmt.Errorf("process chart: %w", err) } - bundles := lo.Filter(chart.Raw, func(file *chartcommon.File, _ int) bool { + var rawFiles []*chartcommon.File + switch c := loadedChart.(type) { + case *v2chart.Chart: + rawFiles = c.Raw + case *v3chart.Chart: + rawFiles = c.Raw + default: + return fmt.Errorf("unsupported chart type %T", loadedChart) + } + + bundles := lo.Filter(rawFiles, func(file *chartcommon.File, _ int) bool { return strings.Contains(file.Name, common.ChartTSBundleFile) }) diff --git a/pkg/action/plan_artifact.go b/pkg/action/plan_artifact.go index f75eda75..3ab88ce6 100644 --- a/pkg/action/plan_artifact.go +++ b/pkg/action/plan_artifact.go @@ -13,12 +13,13 @@ import ( "github.com/werf/common-go/pkg/secrets_manager" "github.com/werf/nelm/pkg/common" - helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" + helmrel "github.com/werf/nelm/pkg/helm/pkg/release" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/plan" + "github.com/werf/nelm/pkg/release" ) -const PlanArtifactSchemeVersion = "v1" +const PlanArtifactSchemeVersion = "v2" type PlanArtifact struct { APIVersion string `json:"apiVersion"` @@ -31,11 +32,20 @@ type PlanArtifact struct { Timestamp time.Time `json:"timestamp"` } +func (a *PlanArtifact) GetReleaseAccessor() (helmrel.Accessor, error) { + accessor, err := helmrel.NewAccessor(a.Data.Release.Releaser) + if err != nil { + return nil, fmt.Errorf("create release accessor: %w", err) + } + + return accessor, nil +} + type PlanArtifactData struct { Options common.ReleaseInstallRuntimeOptions `json:"options"` Changes []*plan.ResourceChange `json:"changes"` Plan *plan.Plan `json:"plan"` - Release *helmrelease.Release `json:"release"` + Release *release.StoredRelease `json:"release"` InstallableResourceInfos []*plan.InstallableResourceInfo `json:"installableResourceInfos"` ReleaseInfos []*plan.ReleaseInfo `json:"releaseInfos"` } @@ -65,6 +75,10 @@ func ReadPlanArtifact(ctx context.Context, path, secretKey, secretWorkDir string return nil, fmt.Errorf("decode plan artifact json: %w", err) } + if artifact.APIVersion != PlanArtifactSchemeVersion { + return nil, fmt.Errorf("plan artifact %s is not supported by the current version", artifact.APIVersion) + } + if artifact.DataRaw == "" { return nil, fmt.Errorf("artifact data is empty") } diff --git a/pkg/action/release_get.go b/pkg/action/release_get.go index fd53b04c..8d9afdf9 100644 --- a/pkg/action/release_get.go +++ b/pkg/action/release_get.go @@ -13,10 +13,11 @@ import ( "github.com/samber/lo" "github.com/werf/nelm/pkg/common" + helmchart "github.com/werf/nelm/pkg/helm/pkg/chart" chartcommonutil "github.com/werf/nelm/pkg/helm/pkg/chart/common/util" "github.com/werf/nelm/pkg/helm/pkg/chart/loader" + helmrel "github.com/werf/nelm/pkg/helm/pkg/release" helmreleasestatus "github.com/werf/nelm/pkg/helm/pkg/release/common" - helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/release" @@ -138,13 +139,13 @@ func ReleaseGet(ctx context.Context, releaseName, releaseNamespace string, opts } } - var rel *helmrelease.Release + var relAccessor helmrel.Accessor if opts.Revision == 0 { - rel = lo.LastOrEmpty(releases) + relAccessor = lo.LastOrEmpty(releases) } else { var revisionFound bool - rel, revisionFound = history.FindRevision(opts.Revision) + relAccessor, revisionFound = history.FindRevision(opts.Revision) if !revisionFound { return nil, &ReleaseRevisionNotFoundError{ ReleaseName: releaseName, @@ -154,35 +155,44 @@ func ReleaseGet(ctx context.Context, releaseName, releaseNamespace string, opts } } - values, err := chartcommonutil.CoalesceValues(rel.Chart, rel.Config) + chartAccessor, err := helmchart.NewAccessor(relAccessor.Chart()) + if err != nil { + return nil, fmt.Errorf("construct chart accessor: %w", err) + } + + values, err := chartcommonutil.CoalesceValues(relAccessor.Chart(), relAccessor.Config()) if err != nil { return nil, fmt.Errorf("coalesce release values: %w", err) } + chartMetadata := chartAccessor.MetadataAsMap() + chartVersion, _ := chartMetadata["Version"].(string) + chartAppVersion, _ := chartMetadata["AppVersion"].(string) + result := &ReleaseGetResultV2{ APIVersion: "v2", Chart: &ReleaseGetResultChart{ - Name: rel.Chart.Name(), - Version: rel.Chart.Metadata.Version, - AppVersion: rel.Chart.Metadata.AppVersion, + Name: chartAccessor.Name(), + Version: chartVersion, + AppVersion: chartAppVersion, }, - Notes: rel.Info.Notes, + Notes: relAccessor.Notes(), Release: &ReleaseGetResultRelease{ - Name: rel.Name, - Namespace: rel.Namespace, - Revision: rel.Version, - Status: rel.Info.Status, + Name: relAccessor.Name(), + Namespace: relAccessor.Namespace(), + Revision: relAccessor.Version(), + Status: helmreleasestatus.Status(relAccessor.Status()), DeployedAt: &ReleaseGetResultDeployedAt{ Human: time.Time{}.String(), Unix: int(time.Time{}.Unix()), }, - Annotations: rel.Info.Annotations, - StorageLabels: rel.Labels, + Annotations: relAccessor.Annotations(), + StorageLabels: relAccessor.Labels(), }, Values: values, } - resSpecs, err := release.ReleaseToResourceSpecs(ctx, rel, releaseNamespace, false) + resSpecs, err := release.ReleaseToResourceSpecs(ctx, relAccessor, releaseNamespace, false) if err != nil { return nil, fmt.Errorf("convert release to resource specs: %w", err) } diff --git a/pkg/action/release_history.go b/pkg/action/release_history.go index d46fb5b1..baf9bf77 100644 --- a/pkg/action/release_history.go +++ b/pkg/action/release_history.go @@ -15,6 +15,7 @@ import ( "github.com/jedib0t/go-pretty/v6/text" "github.com/werf/nelm/pkg/common" + helmchart "github.com/werf/nelm/pkg/helm/pkg/chart" "github.com/werf/nelm/pkg/helm/pkg/chart/loader" helmreleasestatus "github.com/werf/nelm/pkg/helm/pkg/release/common" "github.com/werf/nelm/pkg/kube" @@ -130,22 +131,31 @@ func ReleaseHistory(ctx context.Context, releaseName, releaseNamespace string, o } } - for _, release := range releases { + for _, releaseAccessor := range releases { + chartAccessor, err := helmchart.NewAccessor(releaseAccessor.Chart()) + if err != nil { + return nil, fmt.Errorf("construct chart accessor: %w", err) + } + + chartMetadata := chartAccessor.MetadataAsMap() + chartVersion, _ := chartMetadata["Version"].(string) + chartAppVersion, _ := chartMetadata["AppVersion"].(string) + result.Releases = append(result.Releases, &ReleaseHistoryResultRelease{ - Annotations: release.Info.Annotations, + Annotations: releaseAccessor.Annotations(), Chart: &ReleaseHistoryResultChart{ - Name: release.Chart.Name(), - Version: release.Chart.Metadata.Version, - AppVersion: release.Chart.Metadata.AppVersion, + Name: chartAccessor.Name(), + Version: chartVersion, + AppVersion: chartAppVersion, }, DeployedAt: &ReleaseHistoryResultDeployedAt{ - Human: release.Info.LastDeployed.String(), - Unix: int(release.Info.LastDeployed.Unix()), + Human: releaseAccessor.DeployedAt().String(), + Unix: int(releaseAccessor.DeployedAt().Unix()), }, - Name: release.Name, - Namespace: release.Namespace, - Revision: release.Version, - Status: release.Info.Status, + Name: releaseAccessor.Name(), + Namespace: releaseAccessor.Namespace(), + Revision: releaseAccessor.Version(), + Status: helmreleasestatus.Status(releaseAccessor.Status()), }) } diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index b64a85bc..8ada82b8 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -21,9 +21,10 @@ import ( "github.com/werf/kubedog/pkg/informer" "github.com/werf/nelm/pkg/chart" "github.com/werf/nelm/pkg/common" + helmchart "github.com/werf/nelm/pkg/helm/pkg/chart" "github.com/werf/nelm/pkg/helm/pkg/registry" + helmrel "github.com/werf/nelm/pkg/helm/pkg/release" helmreleasestatus "github.com/werf/nelm/pkg/helm/pkg/release/common" - helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/legacy/progrep" "github.com/werf/nelm/pkg/lock" @@ -302,12 +303,12 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re newRevision := 1 if prevRelease != nil { - newRevision = prevRelease.Version + 1 + newRevision = prevRelease.Version() + 1 } var ( installPlan *plan.Plan - newRelease *helmrelease.Release + newRelease helmrel.Accessor instResInfos []*plan.InstallableResourceInfo relInfos []*plan.ReleaseInfo ) @@ -319,11 +320,16 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re } installPlan = planArtifact.Data.Plan - newRelease = planArtifact.Data.Release + + newRelease, err = planArtifact.GetReleaseAccessor() + if err != nil { + return fmt.Errorf("get release accessor from plan artifact: %w", err) + } + instResInfos = planArtifact.Data.InstallableResourceInfos relInfos = planArtifact.Data.ReleaseInfos } else { - prevReleaseFailed := prevRelease != nil && prevRelease.Info.Status == helmreleasestatus.StatusFailed + prevReleaseFailed := prevRelease != nil && prevRelease.Status() == helmreleasestatus.StatusFailed.String() var deployType common.DeployType if prevDeployedRelease != nil { @@ -520,7 +526,7 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re APIVersion: "v3", Release: releaseName, Namespace: releaseNamespace, - Revision: newRelease.Version, + Revision: newRelease.Version(), Status: helmreleasestatus.Status("skipped"), }); err != nil { return fmt.Errorf("save release install report: %w", err) @@ -528,7 +534,7 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re } if !opts.NoShowNotes { - printNotes(ctx, newRelease.Info.Notes) + printNotes(ctx, newRelease.Notes()) } log.Default.Info(ctx, color.Style{color.Bold, color.Green}.Render(fmt.Sprintf("Skipped release %q (namespace: %q): cluster resources already as desired", releaseName, releaseNamespace))) @@ -664,7 +670,7 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re APIVersion: "v3", Release: releaseName, Namespace: releaseNamespace, - Revision: newRelease.Version, + Revision: newRelease.Version(), Status: lo.Ternary(executePlanErr == nil, helmreleasestatus.StatusDeployed, helmreleasestatus.StatusFailed), CompletedOperations: reportCompletedOps, CanceledOperations: reportCanceledOps, @@ -680,7 +686,7 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re } if !criticalErrs.HasErrors() && !opts.NoShowNotes { - printNotes(ctx, newRelease.Info.Notes) + printNotes(ctx, newRelease.Notes()) } if criticalErrs.HasErrors() { @@ -781,7 +787,7 @@ func createReleaseNamespace(ctx context.Context, clientFactory *kube.ClientFacto return nil } -func runRollbackPlan(ctx context.Context, releaseName, releaseNamespace string, failedRelease, prevDeployedRelease *helmrelease.Release, taskStore *kdutil.Concurrent[*statestore.TaskStore], logStore *kdutil.Concurrent[*logstore.LogStore], informerFactory *kdutil.Concurrent[*informer.InformerFactory], history *release.History, clientFactory *kube.ClientFactory, opts runRollbackPlanOptions) (result *runRollbackPlanResult, nonCritErrs, critErrs *util.MultiError) { +func runRollbackPlan(ctx context.Context, releaseName, releaseNamespace string, failedRelease, prevDeployedRelease helmrel.Accessor, taskStore *kdutil.Concurrent[*statestore.TaskStore], logStore *kdutil.Concurrent[*logstore.LogStore], informerFactory *kdutil.Concurrent[*informer.InformerFactory], history *release.History, clientFactory *kube.ClientFactory, opts runRollbackPlanOptions) (result *runRollbackPlanResult, nonCritErrs, critErrs *util.MultiError) { critErrs = &util.MultiError{} nonCritErrs = &util.MultiError{} @@ -817,10 +823,15 @@ func runRollbackPlan(ctx context.Context, releaseName, releaseNamespace string, return nil, nonCritErrs, critErrs.Add(fmt.Errorf("build releasable resource specs: %w", err)) } - newRelease, err := release.NewRelease(releaseName, releaseNamespace, failedRelease.Version+1, common.DeployTypeRollback, releasableResSpecs, prevDeployedRelease.Chart, prevDeployedRelease.Config, release.ReleaseOptions{ + chartAccessor, err := helmchart.NewAccessor(prevDeployedRelease.Chart()) + if err != nil { + return nil, nonCritErrs, critErrs.Add(fmt.Errorf("create chart accessor: %w", err)) + } + + newRelease, err := release.NewRelease(releaseName, releaseNamespace, failedRelease.Version()+1, common.DeployTypeRollback, releasableResSpecs, chartAccessor, prevDeployedRelease.Config(), release.ReleaseOptions{ InfoAnnotations: opts.ReleaseInfoAnnotations, Labels: opts.ReleaseLabels, - Notes: prevDeployedRelease.Info.Notes, + Notes: prevDeployedRelease.Notes(), }) if err != nil { return nil, nonCritErrs, critErrs.Add(fmt.Errorf("construct new release: %w", err)) diff --git a/pkg/action/release_list.go b/pkg/action/release_list.go index 26ef9e82..06f0e113 100644 --- a/pkg/action/release_list.go +++ b/pkg/action/release_list.go @@ -15,6 +15,7 @@ import ( "github.com/samber/lo" "github.com/werf/nelm/pkg/common" + helmchart "github.com/werf/nelm/pkg/helm/pkg/chart" "github.com/werf/nelm/pkg/helm/pkg/chart/loader" helmreleasestatus "github.com/werf/nelm/pkg/helm/pkg/release/common" "github.com/werf/nelm/pkg/kube" @@ -130,21 +131,30 @@ func ReleaseList(ctx context.Context, opts ReleaseListOptions) (*ReleaseListResu releases := history.Releases() lastRelease := lo.LastOrEmpty(releases) + chartAccessor, err := helmchart.NewAccessor(lastRelease.Chart()) + if err != nil { + return nil, fmt.Errorf("construct chart accessor: %w", err) + } + + chartMetadata := chartAccessor.MetadataAsMap() + chartVersion, _ := chartMetadata["Version"].(string) + chartAppVersion, _ := chartMetadata["AppVersion"].(string) + result.Releases = append(result.Releases, &ReleaseListResultRelease{ - Annotations: lastRelease.Info.Annotations, + Annotations: lastRelease.Annotations(), Chart: &ReleaseListResultChart{ - Name: lastRelease.Chart.Name(), - Version: lastRelease.Chart.Metadata.Version, - AppVersion: lastRelease.Chart.Metadata.AppVersion, + Name: chartAccessor.Name(), + Version: chartVersion, + AppVersion: chartAppVersion, }, DeployedAt: &ReleaseListResultDeployedAt{ - Human: lastRelease.Info.LastDeployed.String(), - Unix: int(lastRelease.Info.LastDeployed.Unix()), + Human: lastRelease.DeployedAt().String(), + Unix: int(lastRelease.DeployedAt().Unix()), }, - Name: lastRelease.Name, - Namespace: lastRelease.Namespace, - Revision: lastRelease.Version, - Status: lastRelease.Info.Status, + Name: lastRelease.Name(), + Namespace: lastRelease.Namespace(), + Revision: lastRelease.Version(), + Status: helmreleasestatus.Status(lastRelease.Status()), }) } diff --git a/pkg/action/release_plan_install.go b/pkg/action/release_plan_install.go index eebcd1af..647b48d5 100644 --- a/pkg/action/release_plan_install.go +++ b/pkg/action/release_plan_install.go @@ -242,8 +242,8 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc ) if prevRelease != nil { - newRevision = prevRelease.Version + 1 - prevReleaseFailed = prevRelease.Info.Status == helmreleasestatus.StatusFailed + newRevision = prevRelease.Version() + 1 + prevReleaseFailed = prevRelease.Status() == helmreleasestatus.StatusFailed.String() } else { newRevision = 1 } @@ -433,7 +433,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc APIVersion: PlanArtifactSchemeVersion, Data: &PlanArtifactData{ Options: opts.ReleaseInstallRuntimeOptions, - Release: newRelease, + Release: &release.StoredRelease{Releaser: newRelease.Releaser()}, Plan: installPlan, Changes: changes, InstallableResourceInfos: instResInfos, @@ -443,7 +443,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc Release: PlanArtifactRelease{ Name: releaseName, Namespace: releaseNamespace, - Revision: newRelease.Version, + Revision: newRelease.Version(), }, Timestamp: time.Now().UTC(), } diff --git a/pkg/action/release_rollback.go b/pkg/action/release_rollback.go index dd24cdc7..eab4ad73 100644 --- a/pkg/action/release_rollback.go +++ b/pkg/action/release_rollback.go @@ -16,8 +16,9 @@ import ( kdutil "github.com/werf/kubedog/pkg/dyntracker/util" "github.com/werf/kubedog/pkg/informer" "github.com/werf/nelm/pkg/common" + helmchart "github.com/werf/nelm/pkg/helm/pkg/chart" + helmrel "github.com/werf/nelm/pkg/helm/pkg/release" helmreleasestatus "github.com/werf/nelm/pkg/helm/pkg/release/common" - helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/lock" "github.com/werf/nelm/pkg/log" @@ -181,13 +182,13 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r prevRelease := lo.LastOrEmpty(releases) prevDeployedRelease := lo.LastOrEmpty(deployedReleases) - var rollbackRelease *helmrelease.Release + var rollbackRelease helmrel.Accessor if opts.Revision == 0 { if len(deployedReleases) == 0 { return fmt.Errorf("not found successfully deployed release %q (namespace: %q)", releaseName, releaseNamespace) } - if prevDeployedRelease.Version != prevRelease.Version { + if prevDeployedRelease.Version() != prevRelease.Version() { rollbackRelease = prevDeployedRelease } else { if len(deployedReleases) < 2 { @@ -199,8 +200,8 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r } else { var found bool - rollbackRelease, found = lo.Find(releases, func(rel *helmrelease.Release) bool { - return rel.Version == opts.Revision + rollbackRelease, found = lo.Find(releases, func(rel helmrel.Accessor) bool { + return rel.Version() == opts.Revision }) if !found { return fmt.Errorf("not found revision %d for release %q (namespace: %q)", opts.Revision, releaseName, releaseNamespace) @@ -213,8 +214,8 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r ) if prevRelease != nil { - newRevision = prevRelease.Version + 1 - prevReleaseFailed = prevRelease.Info.Status == helmreleasestatus.StatusFailed + newRevision = prevRelease.Version() + 1 + prevReleaseFailed = prevRelease.Status() == helmreleasestatus.StatusFailed.String() } else { newRevision = 1 } @@ -252,10 +253,15 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r return fmt.Errorf("build releasable resource specs: %w", err) } - newRelease, err := release.NewRelease(releaseName, releaseNamespace, newRevision, deployType, releasableResSpecs, rollbackRelease.Chart, rollbackRelease.Config, release.ReleaseOptions{ + chartAccessor, err := helmchart.NewAccessor(rollbackRelease.Chart()) + if err != nil { + return fmt.Errorf("create chart accessor: %w", err) + } + + newRelease, err := release.NewRelease(releaseName, releaseNamespace, newRevision, deployType, releasableResSpecs, chartAccessor, rollbackRelease.Config(), release.ReleaseOptions{ InfoAnnotations: opts.ReleaseInfoAnnotations, - Labels: lo.Assign(rollbackRelease.Labels, opts.ReleaseLabels), - Notes: rollbackRelease.Info.Notes, + Labels: lo.Assign(rollbackRelease.Labels(), opts.ReleaseLabels), + Notes: rollbackRelease.Notes(), }) if err != nil { return fmt.Errorf("construct new release: %w", err) @@ -367,7 +373,7 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r APIVersion: "v3", Release: releaseName, Namespace: releaseNamespace, - Revision: newRelease.Version, + Revision: newRelease.Version(), Status: helmreleasestatus.Status("skipped"), }); err != nil { return fmt.Errorf("save release install report: %w", err) @@ -375,7 +381,7 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r } if !opts.NoShowNotes { - printNotes(ctx, newRelease.Info.Notes) + printNotes(ctx, newRelease.Notes()) } log.Default.Info(ctx, color.Style{color.Bold, color.Green}.Render(fmt.Sprintf("Skipped rollback of release %q (namespace: %q): cluster resources already as desired", releaseName, releaseNamespace))) @@ -481,7 +487,7 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r APIVersion: "v3", Release: releaseName, Namespace: releaseNamespace, - Revision: newRelease.Version, + Revision: newRelease.Version(), Status: helmreleasestatus.StatusDeployed, CompletedOperations: reportCompletedOps, CanceledOperations: reportCanceledOps, @@ -497,7 +503,7 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r } if !criticalErrs.HasErrors() && !opts.NoShowNotes { - printNotes(ctx, newRelease.Info.Notes) + printNotes(ctx, newRelease.Notes()) } if criticalErrs.HasErrors() { diff --git a/pkg/action/release_uninstall.go b/pkg/action/release_uninstall.go index c37e1c1b..6e5122f5 100644 --- a/pkg/action/release_uninstall.go +++ b/pkg/action/release_uninstall.go @@ -200,7 +200,7 @@ func releaseUninstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, } prevRelease := lo.LastOrEmpty(releases) - prevReleaseFailed := prevRelease.Info.Status == helmreleasestatus.StatusFailed + prevReleaseFailed := prevRelease.Status() == helmreleasestatus.StatusFailed.String() deployType := common.DeployTypeUninstall log.Default.Debug(ctx, "Convert previous release to resource specs") @@ -357,7 +357,7 @@ func releaseUninstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, APIVersion: "v3", Release: releaseName, Namespace: releaseNamespace, - Revision: prevRelease.Version, + Revision: prevRelease.Version(), Status: helmreleasestatus.StatusUninstalled, CompletedOperations: reportCompletedOps, CanceledOperations: reportCanceledOps, diff --git a/pkg/chart/chart_render.go b/pkg/chart/chart_render.go index 9da106d3..8641a6d7 100644 --- a/pkg/chart/chart_render.go +++ b/pkg/chart/chart_render.go @@ -65,7 +65,7 @@ type RenderChartOptions struct { } type RenderChartResult struct { - Chart *v2chart.Chart + Chart helmchart.Accessor Notes string ReleaseConfig map[string]interface{} ResourceSpecs []*spec.ResourceSpec @@ -281,17 +281,9 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s } if featgate.FeatGateTypescript.Enabled() { - var tsChart *v2chart.Chart - if chartV2 != nil { - tsChart = chartV2 - } else { - // TODO(major): refactor to allow native v3 chart handling in TypeScript rendering - tsChart = convertV3ToV2(chartV3) - } - - log.Default.Debug(ctx, "Rendering TypeScript resources for chart %q and its dependencies", tsChart.Name()) + log.Default.Debug(ctx, "Rendering TypeScript resources for chart %q and its dependencies", chartAccessor.Name()) - jsRenderedTemplates, err := ts.RenderChart(ctx, tsChart, renderedValues, opts.IgnoreBundleJS, chartPath, opts.TempDirPath, opts.DenoBinaryPath) + jsRenderedTemplates, err := ts.RenderChart(ctx, chartAccessor, renderedValues, opts.IgnoreBundleJS, chartPath, opts.TempDirPath, opts.DenoBinaryPath) if err != nil { return nil, fmt.Errorf("render TypeScript templates for chart %q: %w", chartAccessor.Name(), err) } @@ -327,16 +319,8 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s return spec.ResourceSpecSortHandler(resources[i], resources[j]) }) - var resultChart *v2chart.Chart - if chartV2 != nil { - resultChart = chartV2 - } else { - // TODO(major): refactor to allow native v3 chart handling in nelm - resultChart = convertV3ToV2(chartV3) - } - return &RenderChartResult{ - Chart: resultChart, + Chart: chartAccessor, Notes: notes, ReleaseConfig: overrideValues, ResourceSpecs: resources, @@ -344,82 +328,6 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s }, nil } -func convertV3ToV2(src *v3chart.Chart) *v2chart.Chart { - dst := &v2chart.Chart{ - Raw: src.Raw, - Templates: src.Templates, - Values: src.Values, - Schema: src.Schema, - SchemaModTime: src.SchemaModTime, - Files: src.Files, - ModTime: src.ModTime, - RuntimeFiles: src.RuntimeFiles, - ExtraValues: src.ExtraValues, - SecretsRuntimeData: src.SecretsRuntimeData, - } - - if src.Metadata != nil { - dst.Metadata = convertV3MetadataToV2(src.Metadata) - } - - if src.Lock != nil { - dst.Lock = convertV3LockToV2(src.Lock) - } - - for _, dep := range src.Dependencies() { - dst.AddDependency(convertV3ToV2(dep)) - } - - return dst -} - -func convertV3LockToV2(src *v3chart.Lock) *v2chart.Lock { - dst := &v2chart.Lock{ - Generated: src.Generated, - Digest: src.Digest, - } - - for _, dependency := range src.Dependencies { - dst.Dependencies = append(dst.Dependencies, convertV3DependencyToV2(dependency)) - } - - return dst -} - -func convertV3MetadataToV2(src *v3chart.Metadata) *v2chart.Metadata { - dst := &v2chart.Metadata{ - Name: src.Name, - Home: src.Home, - Sources: src.Sources, - Version: src.Version, - Description: src.Description, - Keywords: src.Keywords, - Icon: src.Icon, - APIVersion: src.APIVersion, - Condition: src.Condition, - Tags: src.Tags, - AppVersion: src.AppVersion, - Deprecated: src.Deprecated, - Annotations: src.Annotations, - KubeVersion: src.KubeVersion, - Type: src.Type, - } - - for _, maintainer := range src.Maintainers { - dst.Maintainers = append(dst.Maintainers, &v2chart.Maintainer{ - Name: maintainer.Name, - Email: maintainer.Email, - URL: maintainer.URL, - }) - } - - for _, dependency := range src.Dependencies { - dst.Dependencies = append(dst.Dependencies, convertV3DependencyToV2(dependency)) - } - - return dst -} - func buildChartCapabilities(ctx context.Context, clientFactory kube.ClientFactorier, opts buildChartCapabilitiesOptions) (*chartcommon.Capabilities, error) { capabilities := &chartcommon.Capabilities{ HelmVersion: chartcommon.DefaultCapabilities.HelmVersion, @@ -512,19 +420,6 @@ func buildContextFromJSONSets(jsonSets []string) (map[string]interface{}, error) return context, nil } -func convertV3DependencyToV2(src *v3chart.Dependency) *v2chart.Dependency { - return &v2chart.Dependency{ - Name: src.Name, - Version: src.Version, - Repository: src.Repository, - Condition: src.Condition, - Tags: src.Tags, - Enabled: src.Enabled, - ImportValues: src.ImportValues, - Alias: src.Alias, - } -} - func isLocalChart(path string) bool { return filepath.IsAbs(path) || filepath.HasPrefix(path, "..") || filepath.HasPrefix(path, ".") } diff --git a/pkg/helm/intern/chart/v3/chart.go b/pkg/helm/intern/chart/v3/chart.go index 5f06f4ad..cfbe52bb 100644 --- a/pkg/helm/intern/chart/v3/chart.go +++ b/pkg/helm/intern/chart/v3/chart.go @@ -21,6 +21,8 @@ import ( "strings" "time" + "github.com/samber/lo" + "github.com/werf/nelm/pkg/helm/pkg/chart/common" ) @@ -181,3 +183,36 @@ func hasManifestExtension(fname string) bool { ext := filepath.Ext(fname) return strings.EqualFold(ext, ".yaml") || strings.EqualFold(ext, ".yml") || strings.EqualFold(ext, ".json") } + +func (ch *Chart) AddRuntimeFile(name string, data []byte) { + ch.Raw = append(ch.Raw, &common.File{Name: name, Data: data}) + + ch.RuntimeFiles = append(ch.RuntimeFiles, &common.File{Name: name, Data: data}) + if !ch.IsRoot() { + root := ch.Root() + rawName := getRootRawFileName(ch, name) + root.Raw = append(root.Raw, &common.File{Name: rawName, Data: data}) + } +} + +func (ch *Chart) RemoveRuntimeFile(name string) { + ch.Raw = lo.Reject(ch.Raw, func(f *common.File, _ int) bool { + return f.Name == name + }) + + ch.RuntimeFiles = lo.Reject(ch.RuntimeFiles, func(f *common.File, _ int) bool { + return f.Name == name + }) + + if !ch.IsRoot() { + root := ch.Root() + rawName := getRootRawFileName(ch, name) + root.Raw = lo.Reject(root.Raw, func(f *common.File, _ int) bool { + return f.Name == rawName + }) + } +} + +func getRootRawFileName(ch *Chart, name string) string { + return filepath.Join(strings.TrimPrefix(ch.ChartFullPath(), ch.Root().Name()+"/"), name) +} diff --git a/pkg/helm/intern/release/v2/release.go b/pkg/helm/intern/release/v2/release.go index 5d83f1e4..899d35eb 100644 --- a/pkg/helm/intern/release/v2/release.go +++ b/pkg/helm/intern/release/v2/release.go @@ -50,7 +50,8 @@ type Release struct { Labels map[string]string `json:"-"` // ApplyMethod stores whether server-side or client-side apply was used for the release // Unset (empty string) should be treated as the default of client-side apply - ApplyMethod string `json:"apply_method,omitempty"` // "ssa" | "csa" + ApplyMethod string `json:"apply_method,omitempty"` // "ssa" | "csa" + UnstoredManifest string `json:"unstored_manifest,omitempty"` } // SetStatus is a helper for setting the status on a release. diff --git a/pkg/helm/pkg/action/package.go b/pkg/helm/pkg/action/package.go index 365f8faa..bdc4281d 100644 --- a/pkg/helm/pkg/action/package.go +++ b/pkg/helm/pkg/action/package.go @@ -97,7 +97,7 @@ func (p *Package) Run(path string, _ map[string]any) (string, error) { } if featgate.FeatGateTypescript.Enabled() { - if err := ts.BundleChartsRecursive(context.Background(), ch, path, true, p.TypeScriptOps.DenoBinaryPath); err != nil { + if err := ts.BundleChartsRecursive(context.Background(), ac, path, true, p.TypeScriptOps.DenoBinaryPath); err != nil { return "", fmt.Errorf("unable to process TypeScript files in chart: %w", err) } } diff --git a/pkg/helm/pkg/chart/common.go b/pkg/helm/pkg/chart/common.go index 6f490654..60034d62 100644 --- a/pkg/helm/pkg/chart/common.go +++ b/pkg/helm/pkg/chart/common.go @@ -112,6 +112,22 @@ func (r *v2Accessor) Deprecated() bool { return r.chrt.Metadata.Deprecated } +func (r *v2Accessor) RuntimeFiles() []*common.File { + return r.chrt.RuntimeFiles +} + +func (r *v2Accessor) AddRuntimeFile(name string, data []byte) { + r.chrt.AddRuntimeFile(name, data) +} + +func (r *v2Accessor) RemoveRuntimeFile(name string) { + r.chrt.RemoveRuntimeFile(name) +} + +func (r *v2Accessor) Charter() Charter { + return r.chrt +} + type v3Accessor struct { chrt *v3chart.Chart } @@ -181,6 +197,22 @@ func (r *v3Accessor) Deprecated() bool { return r.chrt.Metadata.Deprecated } +func (r *v3Accessor) RuntimeFiles() []*common.File { + return r.chrt.RuntimeFiles +} + +func (r *v3Accessor) AddRuntimeFile(name string, data []byte) { + r.chrt.AddRuntimeFile(name, data) +} + +func (r *v3Accessor) RemoveRuntimeFile(name string) { + r.chrt.RemoveRuntimeFile(name) +} + +func (r *v3Accessor) Charter() Charter { + return r.chrt +} + func structToMap(obj any) (map[string]any, error) { objValue := reflect.ValueOf(obj) diff --git a/pkg/helm/pkg/chart/interfaces.go b/pkg/helm/pkg/chart/interfaces.go index e2b3dea7..d4d0010d 100644 --- a/pkg/helm/pkg/chart/interfaces.go +++ b/pkg/helm/pkg/chart/interfaces.go @@ -36,6 +36,10 @@ type Accessor interface { Values() map[string]any Schema() []byte Deprecated() bool + RuntimeFiles() []*common.File + AddRuntimeFile(name string, data []byte) + RemoveRuntimeFile(name string) + Charter() Charter } type DependencyAccessor interface { diff --git a/pkg/helm/pkg/release/common.go b/pkg/helm/pkg/release/common.go index 232b8e63..abf4d9c5 100644 --- a/pkg/helm/pkg/release/common.go +++ b/pkg/helm/pkg/release/common.go @@ -96,6 +96,10 @@ func (a *v1Accessor) Labels() map[string]string { return a.rel.Labels } +func (a *v1Accessor) Annotations() map[string]string { + return a.rel.Info.Annotations +} + func (a *v1Accessor) Chart() chart.Charter { return a.rel.Chart } @@ -112,6 +116,18 @@ func (a *v1Accessor) DeployedAt() time.Time { return a.rel.Info.LastDeployed } +func (a *v1Accessor) Config() map[string]any { + return a.rel.Config +} + +func (a *v1Accessor) UnstoredManifest() string { + return a.rel.UnstoredManifest +} + +func (a *v1Accessor) Releaser() Releaser { + return a.rel +} + type v1HookAccessor struct { hook *v1release.Hook } @@ -160,6 +176,10 @@ func (a *v2Accessor) Labels() map[string]string { return a.rel.Labels } +func (a *v2Accessor) Annotations() map[string]string { + return nil +} + func (a *v2Accessor) Chart() chart.Charter { return a.rel.Chart } @@ -176,6 +196,18 @@ func (a *v2Accessor) DeployedAt() time.Time { return a.rel.Info.LastDeployed } +func (a *v2Accessor) Config() map[string]any { + return a.rel.Config +} + +func (a *v2Accessor) UnstoredManifest() string { + return a.rel.UnstoredManifest +} + +func (a *v2Accessor) Releaser() Releaser { + return a.rel +} + type v2HookAccessor struct { hook *v2release.Hook } diff --git a/pkg/helm/pkg/release/interfaces.go b/pkg/helm/pkg/release/interfaces.go index e6c44249..53ce4687 100644 --- a/pkg/helm/pkg/release/interfaces.go +++ b/pkg/helm/pkg/release/interfaces.go @@ -34,10 +34,14 @@ type Accessor interface { Manifest() string Notes() string Labels() map[string]string + Annotations() map[string]string Chart() chart.Charter Status() string ApplyMethod() string DeployedAt() time.Time + Config() map[string]any + UnstoredManifest() string + Releaser() Releaser } type HookAccessor interface { diff --git a/pkg/plan/operation_config.go b/pkg/plan/operation_config.go index c94b59bd..4bf258b6 100644 --- a/pkg/plan/operation_config.go +++ b/pkg/plan/operation_config.go @@ -5,10 +5,12 @@ import ( "regexp" "time" + "github.com/samber/lo" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/werf/kubedog/pkg/dyntracker/statestore" - helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" + helmrel "github.com/werf/nelm/pkg/helm/pkg/release" + "github.com/werf/nelm/pkg/release" "github.com/werf/nelm/pkg/resource/spec" ) @@ -162,27 +164,35 @@ func (c *OperationConfigTrackAbsence) IDHuman() string { } type OperationConfigCreateRelease struct { - Release *helmrelease.Release `json:"release"` + Release *release.StoredRelease `json:"release"` } func (c *OperationConfigCreateRelease) ID() string { - return releaseID(c.Release.Namespace, c.Release.Name, c.Release.Version) + acc := lo.Must(helmrel.NewAccessor(c.Release.Releaser)) + + return releaseID(acc.Namespace(), acc.Name(), acc.Version()) } func (c *OperationConfigCreateRelease) IDHuman() string { - return releaseIDHuman(c.Release.Namespace, c.Release.Name, c.Release.Version) + acc := lo.Must(helmrel.NewAccessor(c.Release.Releaser)) + + return releaseIDHuman(acc.Namespace(), acc.Name(), acc.Version()) } type OperationConfigUpdateRelease struct { - Release *helmrelease.Release `json:"release"` + Release *release.StoredRelease `json:"release"` } func (c *OperationConfigUpdateRelease) ID() string { - return releaseID(c.Release.Namespace, c.Release.Name, c.Release.Version) + acc := lo.Must(helmrel.NewAccessor(c.Release.Releaser)) + + return releaseID(acc.Namespace(), acc.Name(), acc.Version()) } func (c *OperationConfigUpdateRelease) IDHuman() string { - return releaseIDHuman(c.Release.Namespace, c.Release.Name, c.Release.Version) + acc := lo.Must(helmrel.NewAccessor(c.Release.Releaser)) + + return releaseIDHuman(acc.Namespace(), acc.Name(), acc.Version()) } type OperationConfigDeleteRelease struct { diff --git a/pkg/plan/plan_build.go b/pkg/plan/plan_build.go index d77829ce..3344708b 100644 --- a/pkg/plan/plan_build.go +++ b/pkg/plan/plan_build.go @@ -6,13 +6,13 @@ import ( "fmt" "sort" - "github.com/mitchellh/copystructure" "github.com/samber/lo" "github.com/werf/nelm/pkg/common" + helmrel "github.com/werf/nelm/pkg/helm/pkg/release" helmreleasecommon "github.com/werf/nelm/pkg/helm/pkg/release/common" - helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" "github.com/werf/nelm/pkg/log" + "github.com/werf/nelm/pkg/release" "github.com/werf/nelm/pkg/resource" "github.com/werf/nelm/pkg/resource/spec" ) @@ -187,6 +187,8 @@ func addFailureReleaseOperations(failedPlan, plan *Plan, releaseInfos []*Release continue } + infoAcc := lo.Must(helmrel.NewAccessor(info.Release.Releaser)) + if _, releaseCreated := lo.Find(failedPlan.Operations(), func(op *Operation) bool { if op.Status != OperationStatusCompleted { return false @@ -194,13 +196,17 @@ func addFailureReleaseOperations(failedPlan, plan *Plan, releaseInfos []*Release switch config := op.Config.(type) { case *OperationConfigCreateRelease: - return config.Release.Namespace == info.Release.Namespace && - config.Release.Name == info.Release.Name && - config.Release.Version == info.Release.Version + configAcc := lo.Must(helmrel.NewAccessor(config.Release.Releaser)) + + return configAcc.Namespace() == infoAcc.Namespace() && + configAcc.Name() == infoAcc.Name() && + configAcc.Version() == infoAcc.Version() case *OperationConfigUpdateRelease: - return config.Release.Namespace == info.Release.Namespace && - config.Release.Name == info.Release.Name && - config.Release.Version == info.Release.Version + configAcc := lo.Must(helmrel.NewAccessor(config.Release.Releaser)) + + return configAcc.Namespace() == infoAcc.Namespace() && + configAcc.Name() == infoAcc.Name() && + configAcc.Version() == infoAcc.Version() default: return false } @@ -496,14 +502,16 @@ func resolveTrackReadinessOpInStage(ctx context.Context, plan *Plan, instInfos [ } func addDeleteReleaseOps(plan *Plan, info *ReleaseInfo) { + acc := lo.Must(helmrel.NewAccessor(info.Release.Releaser)) + deletedOp := &Operation{ Type: OperationTypeDeleteRelease, Version: OperationVersionDeleteRelease, Category: OperationCategoryRelease, Config: &OperationConfigDeleteRelease{ - ReleaseName: info.Release.Name, - ReleaseNamespace: info.Release.Namespace, - ReleaseRevision: info.Release.Version, + ReleaseName: acc.Name(), + ReleaseNamespace: acc.Namespace(), + ReleaseRevision: acc.Version(), }, } lo.Must0(plan.AddOperationChain().AddOperation(deletedOp).Stage(common.StageFinal).Do()) @@ -548,21 +556,17 @@ func addDeleteResourcesOps(plan *Plan, infos []*DeletableResourceInfo) error { } func addFailedReleaseOps(plan *Plan, info *ReleaseInfo) error { - var failedRel *helmrelease.Release - if rel, err := copystructure.Copy(info.Release); err != nil { - return fmt.Errorf("deep copy release: %w", err) - } else { - failedRel = rel.(*helmrelease.Release) + failedReleaser, err := release.CopyReleaserWithStatus(info.Release.Releaser, helmreleasecommon.StatusFailed) + if err != nil { + return fmt.Errorf("copy release with status: %w", err) } - failedRel.Info.Status = helmreleasecommon.StatusFailed - failedOp := &Operation{ Type: OperationTypeUpdateRelease, Version: OperationVersionUpdateRelease, Category: OperationCategoryRelease, Config: &OperationConfigUpdateRelease{ - Release: failedRel, + Release: &release.StoredRelease{Releaser: failedReleaser}, }, } lo.Must0(plan.AddOperationChain().AddOperation(failedOp).Stage(common.StageInit).Do()) @@ -782,40 +786,32 @@ func addMainStages(plan *Plan) error { } func addPendingAndDeployedReleaseOps(plan *Plan, info *ReleaseInfo, pendingStatus helmreleasecommon.Status) error { - var pendingRel *helmrelease.Release - if rel, err := copystructure.Copy(info.Release); err != nil { - return fmt.Errorf("deep copy release: %w", err) - } else { - pendingRel = rel.(*helmrelease.Release) + pendingReleaser, err := release.CopyReleaserWithStatus(info.Release.Releaser, pendingStatus) + if err != nil { + return fmt.Errorf("copy release with status: %w", err) } - pendingRel.Info.Status = pendingStatus - pendingOp := &Operation{ Type: OperationTypeCreateRelease, Version: OperationVersionCreateRelease, Category: OperationCategoryRelease, Config: &OperationConfigCreateRelease{ - Release: pendingRel, + Release: &release.StoredRelease{Releaser: pendingReleaser}, }, } lo.Must0(plan.AddOperationChain().AddOperation(pendingOp).Stage(common.StageInit).Do()) - var succeededRel *helmrelease.Release - if rel, err := copystructure.Copy(pendingRel); err != nil { - return fmt.Errorf("deep copy release: %w", err) - } else { - succeededRel = rel.(*helmrelease.Release) + succeededReleaser, err := release.CopyReleaserWithStatus(pendingReleaser, helmreleasecommon.StatusDeployed) + if err != nil { + return fmt.Errorf("copy release with status: %w", err) } - succeededRel.Info.Status = helmreleasecommon.StatusDeployed - succeededOp := &Operation{ Type: OperationTypeUpdateRelease, Version: OperationVersionUpdateRelease, Category: OperationCategoryRelease, Config: &OperationConfigUpdateRelease{ - Release: succeededRel, + Release: &release.StoredRelease{Releaser: succeededReleaser}, }, } lo.Must0(plan.AddOperationChain().AddOperation(succeededOp).Stage(common.StageFinal).Do()) @@ -824,21 +820,17 @@ func addPendingAndDeployedReleaseOps(plan *Plan, info *ReleaseInfo, pendingStatu } func addSupersedeReleaseOps(plan *Plan, info *ReleaseInfo) error { - var supersededRel *helmrelease.Release - if rel, err := copystructure.Copy(info.Release); err != nil { - return fmt.Errorf("deep copy release: %w", err) - } else { - supersededRel = rel.(*helmrelease.Release) + supersededReleaser, err := release.CopyReleaserWithStatus(info.Release.Releaser, helmreleasecommon.StatusSuperseded) + if err != nil { + return fmt.Errorf("copy release with status: %w", err) } - supersededRel.Info.Status = helmreleasecommon.StatusSuperseded - supersedeOp := &Operation{ Type: OperationTypeUpdateRelease, Version: OperationVersionUpdateRelease, Category: OperationCategoryRelease, Config: &OperationConfigUpdateRelease{ - Release: supersededRel, + Release: &release.StoredRelease{Releaser: supersededReleaser}, }, } lo.Must0(plan.AddOperationChain().AddOperation(supersedeOp).Stage(common.StageFinal).Do()) @@ -847,33 +839,31 @@ func addSupersedeReleaseOps(plan *Plan, info *ReleaseInfo) error { } func addUninstallReleaseOps(plan *Plan, info *ReleaseInfo) error { - var uninstallingRel *helmrelease.Release - if rel, err := copystructure.Copy(info.Release); err != nil { - return fmt.Errorf("deep copy release: %w", err) - } else { - uninstallingRel = rel.(*helmrelease.Release) + uninstallingReleaser, err := release.CopyReleaserWithStatus(info.Release.Releaser, helmreleasecommon.StatusUninstalling) + if err != nil { + return fmt.Errorf("copy release with status: %w", err) } - uninstallingRel.Info.Status = helmreleasecommon.StatusUninstalling - uninstallingOp := &Operation{ Type: OperationTypeUpdateRelease, Version: OperationVersionUpdateRelease, Category: OperationCategoryRelease, Config: &OperationConfigUpdateRelease{ - Release: uninstallingRel, + Release: &release.StoredRelease{Releaser: uninstallingReleaser}, }, } lo.Must0(plan.AddOperationChain().AddOperation(uninstallingOp).Stage(common.StageInit).Do()) + uninstallingAcc := lo.Must(helmrel.NewAccessor(uninstallingReleaser)) + uninstalledOp := &Operation{ Type: OperationTypeDeleteRelease, Version: OperationVersionDeleteRelease, Category: OperationCategoryRelease, Config: &OperationConfigDeleteRelease{ - ReleaseName: uninstallingRel.Name, - ReleaseNamespace: uninstallingRel.Namespace, - ReleaseRevision: uninstallingRel.Version, + ReleaseName: uninstallingAcc.Name(), + ReleaseNamespace: uninstallingAcc.Namespace(), + ReleaseRevision: uninstallingAcc.Version(), }, } lo.Must0(plan.AddOperationChain().AddOperation(uninstalledOp).Stage(common.StageFinal).Do()) diff --git a/pkg/plan/plan_build_test.go b/pkg/plan/plan_build_test.go index 06246a3b..c0e92f4c 100644 --- a/pkg/plan/plan_build_test.go +++ b/pkg/plan/plan_build_test.go @@ -16,6 +16,7 @@ import ( helmreleasestatus "github.com/werf/nelm/pkg/helm/pkg/release/common" helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" "github.com/werf/nelm/pkg/plan" + "github.com/werf/nelm/pkg/release" "github.com/werf/nelm/pkg/resource" "github.com/werf/nelm/pkg/resource/spec" "github.com/werf/nelm/pkg/test" @@ -227,7 +228,7 @@ func (s *BuildPlanSuite) TestBuildPlan() { }, } - updatedRelRaw, err := copystructure.Copy(releaseInfos[0].Release) + updatedRelRaw, err := copystructure.Copy(releaseInfos[0].Release.Releaser) s.Require().NoError(err) updatedRel := updatedRelRaw.(*helmrelease.Release) @@ -238,7 +239,7 @@ func (s *BuildPlanSuite) TestBuildPlan() { Version: plan.OperationVersionUpdateRelease, Category: plan.OperationCategoryRelease, Config: &plan.OperationConfigUpdateRelease{ - Release: updatedRel, + Release: &release.StoredRelease{Releaser: updatedRel}, }, } @@ -1406,7 +1407,7 @@ func TestBuildPlanSuites(t *testing.T) { func defaultReleaseInfo(releaseName, releaseNamespace string) *plan.ReleaseInfo { return &plan.ReleaseInfo{ - Release: defaultRelease(releaseName, releaseNamespace), + Release: &release.StoredRelease{Releaser: defaultRelease(releaseName, releaseNamespace)}, Must: plan.ReleaseTypeInstall, MustFailOnFailedDeploy: true, } diff --git a/pkg/plan/plan_execute.go b/pkg/plan/plan_execute.go index b89349f2..31585867 100644 --- a/pkg/plan/plan_execute.go +++ b/pkg/plan/plan_execute.go @@ -15,6 +15,7 @@ import ( kdutil "github.com/werf/kubedog/pkg/dyntracker/util" "github.com/werf/kubedog/pkg/informer" "github.com/werf/nelm/pkg/common" + helmrel "github.com/werf/nelm/pkg/helm/pkg/release" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/release" @@ -325,7 +326,12 @@ func execOpCreate(ctx context.Context, op *Operation, releaseNamespace string, c func execOpCreateRelease(ctx context.Context, op *Operation, history release.Historier) error { opConfig := op.Config.(*OperationConfigCreateRelease) - if err := history.CreateRelease(ctx, opConfig.Release); err != nil { + rel, err := helmrel.NewAccessor(opConfig.Release.Releaser) + if err != nil { + return fmt.Errorf("get release accessor: %w", err) + } + + if err := history.CreateRelease(ctx, rel); err != nil { return fmt.Errorf("create release: %w", err) } @@ -370,7 +376,12 @@ func execOpUpdate(ctx context.Context, op *Operation, releaseNamespace string, c func execOpUpdateRelease(ctx context.Context, op *Operation, history release.Historier) error { opConfig := op.Config.(*OperationConfigUpdateRelease) - if err := history.UpdateRelease(ctx, opConfig.Release); err != nil { + rel, err := helmrel.NewAccessor(opConfig.Release.Releaser) + if err != nil { + return fmt.Errorf("get release accessor: %w", err) + } + + if err := history.UpdateRelease(ctx, rel); err != nil { return fmt.Errorf("update release: %w", err) } diff --git a/pkg/plan/release_info.go b/pkg/plan/release_info.go index 27f35055..a04133df 100644 --- a/pkg/plan/release_info.go +++ b/pkg/plan/release_info.go @@ -4,8 +4,9 @@ import ( "context" "github.com/werf/nelm/pkg/common" + helmrel "github.com/werf/nelm/pkg/helm/pkg/release" helmreleasecommon "github.com/werf/nelm/pkg/helm/pkg/release/common" - helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" + "github.com/werf/nelm/pkg/release" ) const ( @@ -30,7 +31,7 @@ type ReleaseType string // Data class, which stores all info to make a decision on what to do with the release revision // in the plan. type ReleaseInfo struct { - Release *helmrelease.Release `json:"release"` + Release *release.StoredRelease `json:"release"` Must ReleaseType `json:"must"` MustFailOnFailedDeploy bool `json:"mustFailOnFailedDeploy"` @@ -40,21 +41,21 @@ type ReleaseInfo struct { // do with each release revision. Compute here as much as you can: Release shouldn't be used for // decision making (its just a JSON representation of a Helm release) and BuildPlan is complex // enough already. -func BuildReleaseInfos(ctx context.Context, deployType common.DeployType, prevReleases []*helmrelease.Release, newRel *helmrelease.Release) ([]*ReleaseInfo, error) { +func BuildReleaseInfos(ctx context.Context, deployType common.DeployType, prevReleases []helmrel.Accessor, newRel helmrel.Accessor) ([]*ReleaseInfo, error) { var infos []*ReleaseInfo switch deployType { case common.DeployTypeInitial, common.DeployTypeInstall: infos = append(infos, &ReleaseInfo{ Must: ReleaseTypeInstall, MustFailOnFailedDeploy: true, - Release: newRel, + Release: &release.StoredRelease{Releaser: newRel.Releaser()}, }) for _, rel := range prevReleases { - if rel.Info.Status == helmreleasecommon.StatusDeployed { + if rel.Status() == helmreleasecommon.StatusDeployed.String() { infos = append(infos, &ReleaseInfo{ Must: ReleaseTypeSupersede, - Release: rel, + Release: &release.StoredRelease{Releaser: rel.Releaser()}, }) } } @@ -62,14 +63,14 @@ func BuildReleaseInfos(ctx context.Context, deployType common.DeployType, prevRe infos = append(infos, &ReleaseInfo{ Must: ReleaseTypeUpgrade, MustFailOnFailedDeploy: true, - Release: newRel, + Release: &release.StoredRelease{Releaser: newRel.Releaser()}, }) for _, rel := range prevReleases { - if rel.Info.Status == helmreleasecommon.StatusDeployed { + if rel.Status() == helmreleasecommon.StatusDeployed.String() { infos = append(infos, &ReleaseInfo{ Must: ReleaseTypeSupersede, - Release: rel, + Release: &release.StoredRelease{Releaser: rel.Releaser()}, }) } } @@ -77,14 +78,14 @@ func BuildReleaseInfos(ctx context.Context, deployType common.DeployType, prevRe infos = append(infos, &ReleaseInfo{ Must: ReleaseTypeRollback, MustFailOnFailedDeploy: true, - Release: newRel, + Release: &release.StoredRelease{Releaser: newRel.Releaser()}, }) for _, rel := range prevReleases { - if rel.Info.Status == helmreleasecommon.StatusDeployed { + if rel.Status() == helmreleasecommon.StatusDeployed.String() { infos = append(infos, &ReleaseInfo{ Must: ReleaseTypeSupersede, - Release: rel, + Release: &release.StoredRelease{Releaser: rel.Releaser()}, }) } } @@ -105,7 +106,7 @@ func BuildReleaseInfos(ctx context.Context, deployType common.DeployType, prevRe infos = append(infos, &ReleaseInfo{ Must: releaseType, MustFailOnFailedDeploy: failOnFailedDeploy, - Release: prevReleases[i], + Release: &release.StoredRelease{Releaser: prevReleases[i].Releaser()}, }) } default: diff --git a/pkg/release/history.go b/pkg/release/history.go index 67a7517b..94262d68 100644 --- a/pkg/release/history.go +++ b/pkg/release/history.go @@ -5,38 +5,41 @@ import ( "errors" "fmt" "slices" + "sort" "sync" "time" "github.com/samber/lo" + v2release "github.com/werf/nelm/pkg/helm/intern/release/v2" + helmrel "github.com/werf/nelm/pkg/helm/pkg/release" helmreleasecommon "github.com/werf/nelm/pkg/helm/pkg/release/common" helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" - releaseutil "github.com/werf/nelm/pkg/helm/pkg/release/v1/util" "github.com/werf/nelm/pkg/helm/pkg/storage/driver" ) var _ Historier = (*History)(nil) type Historier interface { - Releases() []*helmrelease.Release - FindAllDeployed() []*helmrelease.Release - FindRevision(revision int) (rel *helmrelease.Release, found bool) - CreateRelease(ctx context.Context, rel *helmrelease.Release) error - UpdateRelease(ctx context.Context, rel *helmrelease.Release) error + Releases() []helmrel.Accessor + FindAllDeployed() []helmrel.Accessor + FindRevision(revision int) (helmrel.Accessor, bool) + CreateRelease(ctx context.Context, rel helmrel.Accessor) error + UpdateRelease(ctx context.Context, rel helmrel.Accessor) error DeleteRelease(ctx context.Context, name string, revision int) error } -// Wraps Helm release management for easier use. type History struct { releaseName string - releases []*helmrelease.Release + releases []helmrel.Accessor storage ReleaseStorager updateLock sync.Mutex } -func NewHistory(rels []*helmrelease.Release, releaseName string, historyStorage ReleaseStorager, opts HistoryOptions) *History { - releaseutil.SortByRevision(rels) +func NewHistory(rels []helmrel.Accessor, releaseName string, historyStorage ReleaseStorager, opts HistoryOptions) *History { + sort.SliceStable(rels, func(i, j int) bool { + return rels[i].Version() < rels[j].Version() + }) return &History{ releaseName: releaseName, @@ -45,15 +48,16 @@ func NewHistory(rels []*helmrelease.Release, releaseName string, historyStorage } } -func (h *History) CreateRelease(ctx context.Context, rel *helmrelease.Release) error { +func (h *History) CreateRelease(ctx context.Context, rel helmrel.Accessor) error { h.updateLock.Lock() defer h.updateLock.Unlock() - rel.Info.FirstDeployed = time.Now() - rel.Info.LastDeployed = rel.Info.FirstDeployed + if err := touchReleaseDeployTimes(rel.Releaser(), time.Now()); err != nil { + return fmt.Errorf("touch release deploy times: %w", err) + } if err := h.storage.Create(rel); err != nil { - return fmt.Errorf("create release %q (namespace: %q, revision: %q): %w", rel.Name, rel.Namespace, rel.Version, err) + return fmt.Errorf("create release %q (namespace: %q, revision: %d): %w", rel.Name(), rel.Namespace(), rel.Version(), err) } h.releases = append(h.releases, rel) @@ -67,11 +71,11 @@ func (h *History) DeleteRelease(ctx context.Context, name string, revision int) rel, err := h.storage.Delete(name, revision) if err != nil { - return fmt.Errorf("uninstall release %q (namespace: %q, revision: %q): %w", rel.Name, rel.Namespace, rel.Version, err) + return fmt.Errorf("uninstall release %q (revision: %d): %w", name, revision, err) } - if _, i, found := lo.FindIndexOf(h.releases, func(r *helmrelease.Release) bool { - return r.Version == rel.Version + if _, i, found := lo.FindIndexOf(h.releases, func(existing helmrel.Accessor) bool { + return existing.Version() == rel.Version() }); !found { return nil } else { @@ -81,13 +85,13 @@ func (h *History) DeleteRelease(ctx context.Context, name string, revision int) return nil } -func (h *History) FindAllDeployed() []*helmrelease.Release { - _, lastUninstalledRelIndex, lastUninstalledRelFound := lo.FindLastIndexOf(h.releases, func(r *helmrelease.Release) bool { - return r.Info.Status == helmreleasecommon.StatusUninstalled || - r.Info.Status == helmreleasecommon.StatusUninstalling +func (h *History) FindAllDeployed() []helmrel.Accessor { + _, lastUninstalledRelIndex, lastUninstalledRelFound := lo.FindLastIndexOf(h.releases, func(r helmrel.Accessor) bool { + return r.Status() == helmreleasecommon.StatusUninstalled.String() || + r.Status() == helmreleasecommon.StatusUninstalling.String() }) - var relsSinceUninstalled []*helmrelease.Release + var relsSinceUninstalled []helmrel.Accessor if lastUninstalledRelFound { if lastUninstalledRelIndex == len(h.releases)-1 { return nil @@ -98,37 +102,38 @@ func (h *History) FindAllDeployed() []*helmrelease.Release { relsSinceUninstalled = h.releases } - return lo.Filter(relsSinceUninstalled, func(r *helmrelease.Release, _ int) bool { - return r.Info.Status == helmreleasecommon.StatusDeployed || - r.Info.Status == helmreleasecommon.StatusSuperseded + return lo.Filter(relsSinceUninstalled, func(r helmrel.Accessor, _ int) bool { + return r.Status() == helmreleasecommon.StatusDeployed.String() || + r.Status() == helmreleasecommon.StatusSuperseded.String() }) } -func (h *History) FindRevision(revision int) (rel *helmrelease.Release, found bool) { - return lo.Find(h.releases, func(r *helmrelease.Release) bool { - return r.Version == revision +func (h *History) FindRevision(revision int) (helmrel.Accessor, bool) { + return lo.Find(h.releases, func(r helmrel.Accessor) bool { + return r.Version() == revision }) } -func (h *History) Releases() []*helmrelease.Release { +func (h *History) Releases() []helmrel.Accessor { return h.releases } -func (h *History) UpdateRelease(ctx context.Context, rel *helmrelease.Release) error { +func (h *History) UpdateRelease(ctx context.Context, rel helmrel.Accessor) error { h.updateLock.Lock() defer h.updateLock.Unlock() - rel.Info.FirstDeployed = time.Now() - rel.Info.LastDeployed = rel.Info.FirstDeployed + if err := touchReleaseDeployTimes(rel.Releaser(), time.Now()); err != nil { + return fmt.Errorf("touch release deploy times: %w", err) + } if err := h.storage.Update(rel); err != nil { - return fmt.Errorf("update release %q (namespace: %q, revision: %q): %w", rel.Name, rel.Namespace, rel.Version, err) + return fmt.Errorf("update release %q (namespace: %q, revision: %d): %w", rel.Name(), rel.Namespace(), rel.Version(), err) } - if _, i, found := lo.FindIndexOf(h.releases, func(r *helmrelease.Release) bool { - return r.Version == rel.Version + if _, i, found := lo.FindIndexOf(h.releases, func(existing helmrel.Accessor) bool { + return existing.Version() == rel.Version() }); !found { - return fmt.Errorf("release %q (namespace: %q, revision: %q) not found in history", rel.Name, rel.Namespace, rel.Version) + return fmt.Errorf("release %q (namespace: %q, revision: %d) not found in history", rel.Name(), rel.Namespace(), rel.Version()) } else { h.releases[i] = rel } @@ -138,24 +143,23 @@ func (h *History) UpdateRelease(ctx context.Context, rel *helmrelease.Release) e type HistoryOptions struct{} -// Builds histories for multiple different releases. func BuildHistories(historyStorage ReleaseStorager, opts HistoryOptions) ([]*History, error) { rels, err := historyStorage.Query(map[string]string{"owner": "helm"}) if err != nil && !errors.Is(err, driver.ErrReleaseNotFound) { return nil, fmt.Errorf("query releases: %w", err) } - releasesByNamespace := map[string]map[string][]*helmrelease.Release{} + releasesByNamespace := map[string]map[string][]helmrel.Accessor{} for _, rel := range rels { - if releasesByNamespace[rel.Namespace] == nil { - releasesByNamespace[rel.Namespace] = map[string][]*helmrelease.Release{} + if releasesByNamespace[rel.Namespace()] == nil { + releasesByNamespace[rel.Namespace()] = map[string][]helmrel.Accessor{} } - if releasesByNamespace[rel.Namespace][rel.Name] == nil { - releasesByNamespace[rel.Namespace][rel.Name] = []*helmrelease.Release{} + if releasesByNamespace[rel.Namespace()][rel.Name()] == nil { + releasesByNamespace[rel.Namespace()][rel.Name()] = []helmrel.Accessor{} } - releasesByNamespace[rel.Namespace][rel.Name] = append(releasesByNamespace[rel.Namespace][rel.Name], rel) + releasesByNamespace[rel.Namespace()][rel.Name()] = append(releasesByNamespace[rel.Namespace()][rel.Name()], rel) } var histories []*History @@ -175,7 +179,6 @@ func BuildHistories(historyStorage ReleaseStorager, opts HistoryOptions) ([]*His return histories, nil } -// Builds history for a specific release. func BuildHistory(releaseName string, historyStorage ReleaseStorager, opts HistoryOptions) (*History, error) { rels, err := historyStorage.Query(map[string]string{"name": releaseName, "owner": "helm"}) if err != nil && !errors.Is(err, driver.ErrReleaseNotFound) { @@ -189,3 +192,18 @@ func BuildHistory(releaseName string, historyStorage ReleaseStorager, opts Histo opts, ), nil } + +func touchReleaseDeployTimes(releaser helmrel.Releaser, t time.Time) error { + switch r := releaser.(type) { + case *helmrelease.Release: + r.Info.FirstDeployed = t + r.Info.LastDeployed = t + case *v2release.Release: + r.Info.FirstDeployed = t + r.Info.LastDeployed = t + default: + return fmt.Errorf("unexpected release type: %T", releaser) + } + + return nil +} diff --git a/pkg/release/release.go b/pkg/release/release.go index b31e3a95..02bdd840 100644 --- a/pkg/release/release.go +++ b/pkg/release/release.go @@ -11,13 +11,18 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" + "github.com/samber/lo" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/yaml" "github.com/werf/nelm/pkg/common" + v3chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" + v2release "github.com/werf/nelm/pkg/helm/intern/release/v2" + chart "github.com/werf/nelm/pkg/helm/pkg/chart" helmchart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" chartv2util "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" + helmrel "github.com/werf/nelm/pkg/helm/pkg/release" helmreleasecommon "github.com/werf/nelm/pkg/helm/pkg/release/common" helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" releaseutil "github.com/werf/nelm/pkg/helm/pkg/release/v1/util" @@ -33,7 +38,7 @@ type ReleaseOptions struct { // Check if the new Release is up-to-date compared to the old Release. It doesn't check any // resources of the release in the cluster, just compares Release objects. -func IsReleaseUpToDate(oldRel, newRel *helmrelease.Release) (bool, error) { +func IsReleaseUpToDate(oldRel, newRel helmrel.Accessor) (bool, error) { if oldRel == nil { return false, nil } @@ -42,15 +47,20 @@ func IsReleaseUpToDate(oldRel, newRel *helmrelease.Release) (bool, error) { cmpopts.EquateEmpty(), } - if oldRel.Info.Status != helmreleasecommon.StatusDeployed || - oldRel.Info.Notes != newRel.Info.Notes || - !cmp.Equal(oldRel.Config, newRel.Config, cmpOpts) { + if oldRel.Status() != helmreleasecommon.StatusDeployed.String() || + oldRel.Notes() != newRel.Notes() || + !cmp.Equal(oldRel.Config(), newRel.Config(), cmpOpts) { return false, nil } oldHookResourcesHash := fnv.New32a() - for _, oldHook := range oldRel.Hooks { - obj, _, err := scheme.Codecs.UniversalDecoder().Decode([]byte(oldHook.Manifest), nil, &unstructured.Unstructured{}) + for _, oldHook := range oldRel.Hooks() { + hookAcc, err := helmrel.NewHookAccessor(oldHook) + if err != nil { + return false, fmt.Errorf("get old hook accessor: %w", err) + } + + obj, _, err := scheme.Codecs.UniversalDecoder().Decode([]byte(hookAcc.Manifest()), nil, &unstructured.Unstructured{}) if err != nil { return false, fmt.Errorf("decode old hook: %w", err) } @@ -63,8 +73,13 @@ func IsReleaseUpToDate(oldRel, newRel *helmrelease.Release) (bool, error) { } newHookResourcesHash := fnv.New32a() - for _, newHook := range newRel.Hooks { - obj, _, err := scheme.Codecs.UniversalDecoder().Decode([]byte(newHook.Manifest), nil, &unstructured.Unstructured{}) + for _, newHook := range newRel.Hooks() { + hookAcc, err := helmrel.NewHookAccessor(newHook) + if err != nil { + return false, fmt.Errorf("get new hook accessor: %w", err) + } + + obj, _, err := scheme.Codecs.UniversalDecoder().Decode([]byte(hookAcc.Manifest()), nil, &unstructured.Unstructured{}) if err != nil { return false, fmt.Errorf("decode new hook: %w", err) } @@ -80,7 +95,7 @@ func IsReleaseUpToDate(oldRel, newRel *helmrelease.Release) (bool, error) { return false, nil } - oldRelManifests := util.SplitManifests(oldRel.Manifest) + oldRelManifests := util.SplitManifests(oldRel.Manifest()) oldRegularResourcesHash := fnv.New32a() for _, manifest := range oldRelManifests { @@ -96,7 +111,7 @@ func IsReleaseUpToDate(oldRel, newRel *helmrelease.Release) (bool, error) { } } - newRelManifests := util.SplitManifests(newRel.Manifest) + newRelManifests := util.SplitManifests(newRel.Manifest()) newRegularResourcesHash := fnv.New32a() for _, manifest := range newRelManifests { @@ -120,7 +135,7 @@ func IsReleaseUpToDate(oldRel, newRel *helmrelease.Release) (bool, error) { } // Construct Helm release. -func NewRelease(name, namespace string, revision int, deployType common.DeployType, resources []*spec.ResourceSpec, chart *helmchart.Chart, releaseConfig map[string]interface{}, opts ReleaseOptions) (*helmrelease.Release, error) { +func NewRelease(name, namespace string, revision int, deployType common.DeployType, resources []*spec.ResourceSpec, chrt chart.Accessor, releaseConfig map[string]interface{}, opts ReleaseOptions) (helmrel.Accessor, error) { if err := chartv2util.ValidateReleaseName(name); err != nil { return nil, fmt.Errorf("release name %q is not valid: %w", name, err) } @@ -185,28 +200,59 @@ func NewRelease(name, namespace string, revision int, deployType common.DeployTy opts.Notes = strings.TrimRightFunc(opts.Notes, unicode.IsSpace) - return &helmrelease.Release{ - Name: name, - Info: &helmrelease.Info{ - Status: status, - Notes: opts.Notes, - Annotations: opts.InfoAnnotations, - }, - Chart: chart, - Config: releaseConfig, - Manifest: strings.Join(regularResources, "\n---\n"), - Hooks: hookResources, - Version: revision, - Namespace: namespace, - Labels: opts.Labels, - UnstoredManifest: strings.Join(unstoredResources, "\n---\n"), - }, nil + var releaser helmrel.Releaser + switch chartObj := chrt.Charter().(type) { + case *helmchart.Chart: + releaser = &helmrelease.Release{ + Name: name, + Info: &helmrelease.Info{ + Status: status, + Notes: opts.Notes, + Annotations: opts.InfoAnnotations, + }, + Chart: chartObj, + Config: releaseConfig, + Manifest: strings.Join(regularResources, "\n---\n"), + Hooks: hookResources, + Version: revision, + Namespace: namespace, + Labels: opts.Labels, + UnstoredManifest: strings.Join(unstoredResources, "\n---\n"), + } + case *v3chart.Chart: + releaser = &v2release.Release{ + Name: name, + Info: &v2release.Info{ + Status: status, + Notes: opts.Notes, + }, + Chart: chartObj, + Config: releaseConfig, + Manifest: strings.Join(regularResources, "\n---\n"), + Hooks: lo.Map(hookResources, func(h *helmrelease.Hook, _ int) *v2release.Hook { + return v1HookToV2Hook(h) + }), + Version: revision, + Namespace: namespace, + Labels: opts.Labels, + UnstoredManifest: strings.Join(unstoredResources, "\n---\n"), + } + default: + return nil, fmt.Errorf("unexpected chart type: %T", chrt.Charter()) + } + + acc, err := helmrel.NewAccessor(releaser) + if err != nil { + return nil, fmt.Errorf("wrap release: %w", err) + } + + return acc, nil } // Constructs ResourceSpecs from a Release object. -func ReleaseToResourceSpecs(ctx context.Context, rel *helmrelease.Release, releaseNamespace string, noCleanNullFields bool) ([]*spec.ResourceSpec, error) { +func ReleaseToResourceSpecs(ctx context.Context, rel helmrel.Accessor, releaseNamespace string, noCleanNullFields bool) ([]*spec.ResourceSpec, error) { var resources []*spec.ResourceSpec - for _, manifest := range util.SplitManifests(rel.UnstoredManifest) { + for _, manifest := range util.SplitManifests(rel.UnstoredManifest()) { if res, err := spec.NewResourceSpecFromManifest(ctx, manifest, releaseNamespace, spec.ResourceSpecOptions{ StoreAs: common.StoreAsNone, LegacyNoCleanNullFields: noCleanNullFields, @@ -218,7 +264,7 @@ func ReleaseToResourceSpecs(ctx context.Context, rel *helmrelease.Release, relea } } - for _, manifest := range util.SplitManifests(rel.Manifest) { + for _, manifest := range util.SplitManifests(rel.Manifest()) { if res, err := spec.NewResourceSpecFromManifest(ctx, manifest, releaseNamespace, spec.ResourceSpecOptions{ StoreAs: common.StoreAsRegular, LegacyNoCleanNullFields: noCleanNullFields, @@ -230,8 +276,13 @@ func ReleaseToResourceSpecs(ctx context.Context, rel *helmrelease.Release, relea } } - for _, hook := range rel.Hooks { - if res, err := spec.NewResourceSpecFromManifest(ctx, hook.Manifest, releaseNamespace, spec.ResourceSpecOptions{ + for _, hook := range rel.Hooks() { + hookAcc, err := helmrel.NewHookAccessor(hook) + if err != nil { + return nil, fmt.Errorf("get hook accessor: %w", err) + } + + if res, err := spec.NewResourceSpecFromManifest(ctx, hookAcc.Manifest(), releaseNamespace, spec.ResourceSpecOptions{ StoreAs: common.StoreAsHook, LegacyNoCleanNullFields: noCleanNullFields, DropInvalidAnnotationsAndLabels: true, @@ -270,6 +321,30 @@ func resourceSpecToManifest(name, namespace string, revision int, res *spec.Reso return manifest, nil } +func v1HookToV2Hook(h *helmrelease.Hook) *v2release.Hook { + return &v2release.Hook{ + Name: h.Name, + Kind: h.Kind, + Path: h.Path, + Manifest: h.Manifest, + Events: lo.Map(h.Events, func(e helmrelease.HookEvent, _ int) v2release.HookEvent { + return v2release.HookEvent(e) + }), + LastRun: v2release.HookExecution{ + StartedAt: h.LastRun.StartedAt, + CompletedAt: h.LastRun.CompletedAt, + Phase: v2release.HookPhase(h.LastRun.Phase), + }, + Weight: h.Weight, + DeletePolicies: lo.Map(h.DeletePolicies, func(p helmrelease.HookDeletePolicy, _ int) v2release.HookDeletePolicy { + return v2release.HookDeletePolicy(p) + }), + OutputLogPolicies: lo.Map(h.OutputLogPolicies, func(p helmrelease.HookOutputLogPolicy, _ int) v2release.HookOutputLogPolicy { + return v2release.HookOutputLogPolicy(p) + }), + } +} + func writeUnstructHash(unstruct *unstructured.Unstructured, hash hash.Hash32) error { if b, err := unstruct.MarshalJSON(); err != nil { return fmt.Errorf("unmarshal resource: %w", err) diff --git a/pkg/release/release_storage.go b/pkg/release/release_storage.go index 3b1782b4..3500a8ba 100644 --- a/pkg/release/release_storage.go +++ b/pkg/release/release_storage.go @@ -2,66 +2,81 @@ package release import ( "context" + "encoding/json" "fmt" + "github.com/mitchellh/copystructure" "k8s.io/client-go/kubernetes" "github.com/werf/nelm/pkg/common" + v2release "github.com/werf/nelm/pkg/helm/intern/release/v2" + helmrel "github.com/werf/nelm/pkg/helm/pkg/release" + helmreleasecommon "github.com/werf/nelm/pkg/helm/pkg/release/common" helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" helmstorage "github.com/werf/nelm/pkg/helm/pkg/storage" helmdriver "github.com/werf/nelm/pkg/helm/pkg/storage/driver" "github.com/werf/nelm/pkg/kube" ) +const ( + ReleaseVersionV1 = "v1" + ReleaseVersionV2 = "v2" +) + var _ ReleaseStorager = (*storageAdapter)(nil) type ReleaseStorager interface { - Create(rls *helmrelease.Release) error - Update(rls *helmrelease.Release) error - Delete(name string, version int) (*helmrelease.Release, error) - Query(labels map[string]string) ([]*helmrelease.Release, error) + Create(rls helmrel.Accessor) error + Update(rls helmrel.Accessor) error + Delete(name string, version int) (helmrel.Accessor, error) + Query(labels map[string]string) ([]helmrel.Accessor, error) } type storageAdapter struct { storage *helmstorage.Storage } -func (a *storageAdapter) Create(rls *helmrelease.Release) error { - if err := a.storage.Create(rls); err != nil { +func (a *storageAdapter) Create(rls helmrel.Accessor) error { + releaser, err := ReleaserToV1Release(rls.Releaser()) + if err != nil { + return fmt.Errorf("prepare release for storage: %w", err) + } + + if err := a.storage.Create(releaser); err != nil { return fmt.Errorf("create release: %w", err) } return nil } -func (a *storageAdapter) Delete(name string, version int) (*helmrelease.Release, error) { +func (a *storageAdapter) Delete(name string, version int) (helmrel.Accessor, error) { rel, err := a.storage.Delete(name, version) if err != nil { return nil, fmt.Errorf("delete release: %w", err) } - r, ok := rel.(*helmrelease.Release) - if !ok { - return nil, fmt.Errorf("unexpected release type: %T", rel) + acc, err := helmrel.NewAccessor(rel) + if err != nil { + return nil, fmt.Errorf("wrap release: %w", err) } - return r, nil + return acc, nil } -func (a *storageAdapter) Query(labels map[string]string) ([]*helmrelease.Release, error) { +func (a *storageAdapter) Query(labels map[string]string) ([]helmrel.Accessor, error) { releasers, err := a.storage.Query(labels) if err != nil { return nil, fmt.Errorf("query releases: %w", err) } - result := make([]*helmrelease.Release, 0, len(releasers)) + result := make([]helmrel.Accessor, 0, len(releasers)) for _, rel := range releasers { - r, ok := rel.(*helmrelease.Release) - if !ok { - return nil, fmt.Errorf("unexpected release type: %T", rel) + acc, err := helmrel.NewAccessor(rel) + if err != nil { + return nil, fmt.Errorf("wrap release: %w", err) } - result = append(result, r) + result = append(result, acc) } return result, nil @@ -71,8 +86,13 @@ func (a *storageAdapter) Storage() *helmstorage.Storage { return a.storage } -func (a *storageAdapter) Update(rls *helmrelease.Release) error { - if err := a.storage.Update(rls); err != nil { +func (a *storageAdapter) Update(rls helmrel.Accessor) error { + releaser, err := ReleaserToV1Release(rls.Releaser()) + if err != nil { + return fmt.Errorf("prepare release for storage: %w", err) + } + + if err := a.storage.Update(releaser); err != nil { return fmt.Errorf("update release: %w", err) } @@ -84,6 +104,24 @@ type ReleaseStorageOptions struct { SQLConnection string } +func CopyReleaserWithStatus(releaser helmrel.Releaser, status helmreleasecommon.Status) (helmrel.Releaser, error) { + copied, err := copystructure.Copy(releaser) + if err != nil { + return nil, fmt.Errorf("deep copy release: %w", err) + } + + switch r := copied.(type) { + case *helmrelease.Release: + r.Info.Status = status + return r, nil + case *v2release.Release: + r.Info.Status = status + return r, nil + default: + return nil, fmt.Errorf("unexpected release type: %T", copied) + } +} + func NewReleaseStorage(ctx context.Context, namespace, storageDriver string, clientFactory kube.ClientFactorier, opts ReleaseStorageOptions) (ReleaseStorager, error) { var storage *helmstorage.Storage @@ -123,3 +161,46 @@ func NewReleaseStorage(ctx context.Context, namespace, storageDriver string, cli return &storageAdapter{storage: storage}, nil } + +func ReleaserToV1Release(releaser helmrel.Releaser) (*helmrelease.Release, error) { + switch r := releaser.(type) { + case *helmrelease.Release: + return r, nil + case *v2release.Release: + v1rel, err := v2ReleaseToV1Release(r) + if err != nil { + return nil, fmt.Errorf("convert v2 release to v1 release: %w", err) + } + + return v1rel, nil + default: + return nil, fmt.Errorf("unexpected release type: %T", releaser) + } +} + +func ReleaserVersion(releaser helmrel.Releaser) string { + switch releaser.(type) { + case *helmrelease.Release: + return ReleaseVersionV1 + case *v2release.Release: + return ReleaseVersionV2 + default: + panic(fmt.Sprintf("unexpected release type: %T", releaser)) + } +} + +func v2ReleaseToV1Release(rel *v2release.Release) (*helmrelease.Release, error) { + data, err := json.Marshal(rel) + if err != nil { + return nil, fmt.Errorf("marshal v2 release: %w", err) + } + + v1rel := &helmrelease.Release{} + if err := json.Unmarshal(data, v1rel); err != nil { + return nil, fmt.Errorf("unmarshal into v1 release: %w", err) + } + + v1rel.Labels = rel.Labels + + return v1rel, nil +} diff --git a/pkg/release/release_storage_ai_test.go b/pkg/release/release_storage_ai_test.go new file mode 100644 index 00000000..3e7e989f --- /dev/null +++ b/pkg/release/release_storage_ai_test.go @@ -0,0 +1,60 @@ +//go:build ai_tests + +package release + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v2release "github.com/werf/nelm/pkg/helm/intern/release/v2" + helmreleasecommon "github.com/werf/nelm/pkg/helm/pkg/release/common" + helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" +) + +func TestAI_CopyReleaserWithStatus_UnexpectedTypeFails(t *testing.T) { + _, err := CopyReleaserWithStatus("not-a-release", helmreleasecommon.StatusFailed) + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected release type") +} + +func TestAI_CopyReleaserWithStatus_V1PreservesDescriptionAndOriginal(t *testing.T) { + original := &helmrelease.Release{ + Name: "myrelease", + Info: &helmrelease.Info{ + Status: helmreleasecommon.StatusDeployed, + Description: "original description", + }, + } + + copied, err := CopyReleaserWithStatus(original, helmreleasecommon.StatusFailed) + require.NoError(t, err) + + copiedRel, ok := copied.(*helmrelease.Release) + require.True(t, ok) + assert.Equal(t, helmreleasecommon.StatusFailed, copiedRel.Info.Status) + assert.Equal(t, "original description", copiedRel.Info.Description) + + assert.Equal(t, helmreleasecommon.StatusDeployed, original.Info.Status, "original must not be mutated") +} + +func TestAI_CopyReleaserWithStatus_V2PreservesDescriptionAndOriginal(t *testing.T) { + original := &v2release.Release{ + Name: "myrelease", + Info: &v2release.Info{ + Status: helmreleasecommon.StatusDeployed, + Description: "original description", + }, + } + + copied, err := CopyReleaserWithStatus(original, helmreleasecommon.StatusFailed) + require.NoError(t, err) + + copiedRel, ok := copied.(*v2release.Release) + require.True(t, ok) + assert.Equal(t, helmreleasecommon.StatusFailed, copiedRel.Info.Status) + assert.Equal(t, "original description", copiedRel.Info.Description) + + assert.Equal(t, helmreleasecommon.StatusDeployed, original.Info.Status, "original must not be mutated") +} diff --git a/pkg/release/stored_release.go b/pkg/release/stored_release.go new file mode 100644 index 00000000..0f83db79 --- /dev/null +++ b/pkg/release/stored_release.go @@ -0,0 +1,74 @@ +package release + +import ( + "encoding/json" + "fmt" + + v2release "github.com/werf/nelm/pkg/helm/intern/release/v2" + helmrel "github.com/werf/nelm/pkg/helm/pkg/release" + helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" +) + +type StoredRelease struct { + Releaser helmrel.Releaser +} + +func (s *StoredRelease) MarshalJSON() ([]byte, error) { + if s == nil || s.Releaser == nil { + return []byte("null"), nil + } + + payload, err := json.Marshal(s.Releaser) + if err != nil { + return nil, fmt.Errorf("marshal release payload: %w", err) + } + + data, err := json.Marshal(storedReleaseEnvelope{ + Release: payload, + Version: ReleaserVersion(s.Releaser), + }) + if err != nil { + return nil, fmt.Errorf("marshal stored release envelope: %w", err) + } + + return data, nil +} + +func (s *StoredRelease) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + s.Releaser = nil + + return nil + } + + var env storedReleaseEnvelope + if err := json.Unmarshal(data, &env); err != nil { + return fmt.Errorf("unmarshal stored release envelope: %w", err) + } + + switch env.Version { + case ReleaseVersionV1: + rel := &helmrelease.Release{} + if err := json.Unmarshal(env.Release, rel); err != nil { + return fmt.Errorf("unmarshal v1 release: %w", err) + } + + s.Releaser = rel + case ReleaseVersionV2: + rel := &v2release.Release{} + if err := json.Unmarshal(env.Release, rel); err != nil { + return fmt.Errorf("unmarshal v2 release: %w", err) + } + + s.Releaser = rel + default: + return fmt.Errorf("unknown release version %q", env.Version) + } + + return nil +} + +type storedReleaseEnvelope struct { + Version string `json:"version"` + Release json.RawMessage `json:"release"` +} diff --git a/pkg/release/stored_release_ai_test.go b/pkg/release/stored_release_ai_test.go new file mode 100644 index 00000000..15d22dc9 --- /dev/null +++ b/pkg/release/stored_release_ai_test.go @@ -0,0 +1,106 @@ +//go:build ai_tests + +package release + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v3chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" + v2release "github.com/werf/nelm/pkg/helm/intern/release/v2" + v2chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + helmreleasecommon "github.com/werf/nelm/pkg/helm/pkg/release/common" + helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" +) + +func TestAI_StoredRelease_NilRoundTrip(t *testing.T) { + data, err := json.Marshal(&StoredRelease{}) + require.NoError(t, err) + assert.Equal(t, "null", string(data)) + + var restored StoredRelease + require.NoError(t, json.Unmarshal([]byte("null"), &restored)) + assert.Nil(t, restored.Releaser) +} + +func TestAI_StoredRelease_RoundTripV1PreservesNativeType(t *testing.T) { + original := &helmrelease.Release{ + Name: "myrelease", + Info: &helmrelease.Info{ + Status: helmreleasecommon.StatusDeployed, + }, + Chart: &v2chart.Chart{ + Metadata: &v2chart.Metadata{ + Name: "mychart", + Version: "1.2.3", + APIVersion: v2chart.APIVersionV2, + }, + }, + Version: 3, + Namespace: "myns", + } + + data, err := json.Marshal(&StoredRelease{Releaser: original}) + require.NoError(t, err) + assert.Contains(t, string(data), `"version":"v1"`) + + var restored StoredRelease + require.NoError(t, json.Unmarshal(data, &restored)) + + rel, ok := restored.Releaser.(*helmrelease.Release) + require.True(t, ok, "v1 release must round-trip as *helmrelease.Release, got %T", restored.Releaser) + assert.Equal(t, "myrelease", rel.Name) + assert.Equal(t, 3, rel.Version) + require.NotNil(t, rel.Chart) + require.NotNil(t, rel.Chart.Metadata) + assert.Equal(t, "mychart", rel.Chart.Metadata.Name) +} + +func TestAI_StoredRelease_RoundTripV2PreservesNativeType(t *testing.T) { + original := &v2release.Release{ + Name: "myrelease", + Info: &v2release.Info{ + Status: helmreleasecommon.StatusDeployed, + Description: "release deployed", + }, + Chart: &v3chart.Chart{ + Metadata: &v3chart.Metadata{ + Name: "mychart", + Version: "1.2.3", + APIVersion: v3chart.APIVersionV3, + }, + }, + Version: 7, + Namespace: "myns", + } + + data, err := json.Marshal(&StoredRelease{Releaser: original}) + require.NoError(t, err) + assert.Contains(t, string(data), `"version":"v2"`) + + var restored StoredRelease + require.NoError(t, json.Unmarshal(data, &restored)) + + rel, ok := restored.Releaser.(*v2release.Release) + require.True(t, ok, "v2 release must round-trip as *v2release.Release, got %T", restored.Releaser) + assert.Equal(t, "myrelease", rel.Name) + assert.Equal(t, 7, rel.Version) + assert.Equal(t, "myns", rel.Namespace) + assert.Equal(t, helmreleasecommon.StatusDeployed, rel.Info.Status) + + require.NotNil(t, rel.Chart) + require.NotNil(t, rel.Chart.Metadata) + assert.Equal(t, "mychart", rel.Chart.Metadata.Name) + assert.Equal(t, "1.2.3", rel.Chart.Metadata.Version) + assert.Equal(t, v3chart.APIVersionV3, rel.Chart.Metadata.APIVersion) +} + +func TestAI_StoredRelease_UnknownVersionFails(t *testing.T) { + var restored StoredRelease + err := json.Unmarshal([]byte(`{"version":"v9","release":{}}`), &restored) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown release version") +} diff --git a/pkg/ts/deno.go b/pkg/ts/deno.go index c64ea9de..4a209894 100644 --- a/pkg/ts/deno.go +++ b/pkg/ts/deno.go @@ -15,14 +15,14 @@ import ( "github.com/samber/lo" "github.com/werf/nelm/pkg/common" + helmchart "github.com/werf/nelm/pkg/helm/pkg/chart" chartcommon "github.com/werf/nelm/pkg/helm/pkg/chart/common" - v2chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" "github.com/werf/nelm/pkg/log" ) var chartTSEntryPoints = [...]string{common.ChartTSEntryPointTS, common.ChartTSEntryPointJS} -func BundleChartsRecursive(ctx context.Context, chart *v2chart.Chart, path string, rebuildBundle bool, binaryPath string) error { +func BundleChartsRecursive(ctx context.Context, chart helmchart.Accessor, path string, rebuildBundle bool, binaryPath string) error { if !hasTSFiles(chart) { return nil } @@ -57,8 +57,8 @@ func RunDenoInstall(ctx context.Context, chartPath, binaryPath string) error { return nil } -func bundleChartsRecursive(ctx context.Context, chart *v2chart.Chart, path string, rebuildBundle bool, denoBin string) error { - entrypoint, bundle := getEntrypointAndBundle(chart.RuntimeFiles) +func bundleChartsRecursive(ctx context.Context, chart helmchart.Accessor, path string, rebuildBundle bool, denoBin string) error { + entrypoint, bundle := getEntrypointAndBundle(chart.RuntimeFiles()) if entrypoint != "" { if bundle == nil || rebuildBundle { @@ -78,7 +78,12 @@ func bundleChartsRecursive(ctx context.Context, chart *v2chart.Chart, path strin } for _, dep := range chart.Dependencies() { - depPath := filepath.Join(path, "charts", dep.Name()) + depAcc, err := helmchart.NewAccessor(dep) + if err != nil { + return fmt.Errorf("create accessor for dependency: %w", err) + } + + depPath := filepath.Join(path, "charts", depAcc.Name()) if _, err := os.Stat(depPath); err != nil { // Subchart loaded from .tgz or missing on disk — skip, @@ -86,8 +91,8 @@ func bundleChartsRecursive(ctx context.Context, chart *v2chart.Chart, path strin continue } - if err := bundleChartsRecursive(ctx, dep, depPath, rebuildBundle, denoBin); err != nil { - return fmt.Errorf("process dependency %q: %w", dep.Name(), err) + if err := bundleChartsRecursive(ctx, depAcc, depPath, rebuildBundle, denoBin); err != nil { + return fmt.Errorf("process dependency %q: %w", depAcc.Name(), err) } } @@ -111,14 +116,19 @@ func getEntrypointAndBundle(files []*chartcommon.File) (string, *chartcommon.Fil return entrypoint, bundleFile } -func hasTSFiles(chart *v2chart.Chart) bool { - entrypoint := findEntrypointInFiles(chart.RuntimeFiles) +func hasTSFiles(chart helmchart.Accessor) bool { + entrypoint := findEntrypointInFiles(chart.RuntimeFiles()) if entrypoint != "" { return true } for _, dep := range chart.Dependencies() { - if hasTSFiles(dep) { + depAcc, err := helmchart.NewAccessor(dep) + if err != nil { + continue + } + + if hasTSFiles(depAcc) { return true } } diff --git a/pkg/ts/render.go b/pkg/ts/render.go index d875ce91..8309a525 100644 --- a/pkg/ts/render.go +++ b/pkg/ts/render.go @@ -12,12 +12,12 @@ import ( "sigs.k8s.io/yaml" "github.com/werf/nelm/pkg/common" + helmchart "github.com/werf/nelm/pkg/helm/pkg/chart" chartcommon "github.com/werf/nelm/pkg/helm/pkg/chart/common" - helmchart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" "github.com/werf/nelm/pkg/log" ) -func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues chartcommon.Values, rebuildBundle bool, chartPath, tempDirPath, denoBinaryPath string) (map[string]string, error) { +func RenderChart(ctx context.Context, chart helmchart.Accessor, renderedValues chartcommon.Values, rebuildBundle bool, chartPath, tempDirPath, denoBinaryPath string) (map[string]string, error) { if !hasTSFiles(chart) { return map[string]string{}, nil } @@ -39,11 +39,11 @@ func RenderChart(ctx context.Context, chart *helmchart.Chart, renderedValues cha return allRendered, nil } -func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values chartcommon.Values, pathPrefix, chartPath, tempDirPath, denoBin string) (map[string]string, error) { +func renderChartRecursive(ctx context.Context, chart helmchart.Accessor, values chartcommon.Values, pathPrefix, chartPath, tempDirPath, denoBin string) (map[string]string, error) { log.Default.Debug(ctx, "Rendering TypeScript for chart %q (path prefix: %s)", chart.Name(), pathPrefix) results := make(map[string]string) - entrypoint, bundle := getEntrypointAndBundle(chart.RuntimeFiles) + entrypoint, bundle := getEntrypointAndBundle(chart.RuntimeFiles()) if bundle != nil { content, err := renderChart(ctx, bundle, chart, values, tempDirPath, denoBin) @@ -59,19 +59,24 @@ func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values ch } for _, dep := range chart.Dependencies() { - log.Default.Debug(ctx, "Processing dependency %q for chart %q", dep.Name(), chart.Name()) + depAcc, err := helmchart.NewAccessor(dep) + if err != nil { + return nil, fmt.Errorf("create accessor for dependency: %w", err) + } + + log.Default.Debug(ctx, "Processing dependency %q for chart %q", depAcc.Name(), chart.Name()) depResults, err := renderChartRecursive( ctx, - dep, - scopeValuesForSubchart(values, dep.Name(), dep), - path.Join(pathPrefix, "charts", dep.Name()), - filepath.Join(chartPath, "charts", dep.Name()), + depAcc, + scopeValuesForSubchart(values, depAcc.Name(), depAcc), + path.Join(pathPrefix, "charts", depAcc.Name()), + filepath.Join(chartPath, "charts", depAcc.Name()), tempDirPath, denoBin, ) if err != nil { - return nil, fmt.Errorf("render dependency %q: %w", dep.Name(), err) + return nil, fmt.Errorf("render dependency %q: %w", depAcc.Name(), err) } results = lo.Assign(results, depResults) @@ -80,7 +85,7 @@ func renderChartRecursive(ctx context.Context, chart *helmchart.Chart, values ch return results, nil } -func renderChart(ctx context.Context, bundle *chartcommon.File, chart *helmchart.Chart, renderedValues chartcommon.Values, tempDirPath, denoBin string) (string, error) { +func renderChart(ctx context.Context, bundle *chartcommon.File, chart helmchart.Accessor, renderedValues chartcommon.Values, tempDirPath, denoBin string) (string, error) { renderDir := filepath.Join(tempDirPath, "typescript-render", chart.ChartFullPath()) if err := os.MkdirAll(renderDir, 0o755); err != nil { return "", fmt.Errorf("create temp dir for render context: %w", err) @@ -102,7 +107,7 @@ func renderChart(ctx context.Context, bundle *chartcommon.File, chart *helmchart return strings.TrimSpace(string(resultBytes)), nil } -func scopeValuesForSubchart(parentValues chartcommon.Values, subchartName string, subchart *helmchart.Chart) chartcommon.Values { +func scopeValuesForSubchart(parentValues chartcommon.Values, subchartName string, subchart helmchart.Accessor) chartcommon.Values { scoped := chartcommon.Values{ "Chart": buildChartMetadata(subchart), "Values": map[string]any{}, @@ -130,8 +135,8 @@ func scopeValuesForSubchart(parentValues chartcommon.Values, subchartName string } } - files := make(map[string]any, len(subchart.Files)) - for _, f := range subchart.Files { + files := make(map[string]any, len(subchart.Files())) + for _, f := range subchart.Files() { files[f.Name] = f.Data } @@ -140,7 +145,7 @@ func scopeValuesForSubchart(parentValues chartcommon.Values, subchartName string return scoped } -func writeInputRenderContext(renderedValues chartcommon.Values, chart *helmchart.Chart, renderDir string) error { +func writeInputRenderContext(renderedValues chartcommon.Values, chart helmchart.Accessor, renderDir string) error { renderContext := renderedValues.AsMap() if valuesInterface, ok := renderContext["Values"]; ok { @@ -151,8 +156,8 @@ func writeInputRenderContext(renderedValues chartcommon.Values, chart *helmchart renderContext["Chart"] = buildChartMetadata(chart) - files := make(map[string]any, len(chart.Files)) - for _, file := range chart.Files { + files := make(map[string]any, len(chart.Files())) + for _, file := range chart.Files() { files[file.Name] = file.Data } @@ -170,41 +175,13 @@ func writeInputRenderContext(renderedValues chartcommon.Values, chart *helmchart return nil } -func buildChartMetadata(chart *helmchart.Chart) map[string]any { - metadata := map[string]any{ - "Name": chart.Name(), - "Version": "", - } - - if chart.Metadata == nil { - return metadata - } - - m := chart.Metadata - metadata["Version"] = m.Version - metadata["AppVersion"] = m.AppVersion - metadata["Description"] = m.Description - metadata["Keywords"] = m.Keywords - metadata["Home"] = m.Home - metadata["Sources"] = m.Sources - metadata["Icon"] = m.Icon - metadata["APIVersion"] = m.APIVersion - metadata["Condition"] = m.Condition - metadata["Tags"] = m.Tags - metadata["Type"] = m.Type - metadata["Annotations"] = m.Annotations - - if m.Maintainers != nil { - maintainers := make([]map[string]any, len(m.Maintainers)) - for i, maint := range m.Maintainers { - maintainers[i] = map[string]any{ - "Name": maint.Name, - "Email": maint.Email, - "URL": maint.URL, - } +func buildChartMetadata(chart helmchart.Accessor) map[string]any { + metadata := chart.MetadataAsMap() + if metadata == nil { + return map[string]any{ + "Name": chart.Name(), + "Version": "", } - - metadata["Maintainers"] = maintainers } return metadata From 8f935db93024007ff23939319c0868833f796743 Mon Sep 17 00:00:00 2001 From: Ilya Lesikov Date: Tue, 16 Jun 2026 18:38:18 +0300 Subject: [PATCH 40/85] refactor: refactor chart v3 and release v2 support Signed-off-by: Ilya Lesikov --- pkg/action/chart_ts_build.go | 14 +--- pkg/action/plan_artifact.go | 12 +-- pkg/action/release_install.go | 7 +- pkg/action/release_plan_install.go | 2 +- .../intern/release/v2/util/manifest_sorter.go | 16 ++++ pkg/helm/pkg/chart/common.go | 8 ++ pkg/helm/pkg/chart/interfaces.go | 1 + pkg/helm/pkg/release/common.go | 45 +++++++++++ pkg/helm/pkg/release/interfaces.go | 5 ++ pkg/plan/operation_config.go | 22 ++---- pkg/plan/plan_build.go | 75 ++++++++++--------- pkg/plan/plan_build_test.go | 10 ++- pkg/plan/plan_execute.go | 15 +--- pkg/plan/release_info.go | 16 ++-- pkg/release/history.go | 29 ++----- pkg/release/history_ai_test.go | 47 ++++++++++++ pkg/release/release.go | 62 ++++++--------- pkg/release/release_storage.go | 22 +----- pkg/release/release_storage_ai_test.go | 27 ++++--- ...stored_release.go => versioned_release.go} | 40 ++++++---- ...i_test.go => versioned_release_ai_test.go} | 39 ++++++---- 21 files changed, 288 insertions(+), 226 deletions(-) create mode 100644 pkg/release/history_ai_test.go rename pkg/release/{stored_release.go => versioned_release.go} (57%) rename pkg/release/{stored_release_ai_test.go => versioned_release_ai_test.go} (70%) diff --git a/pkg/action/chart_ts_build.go b/pkg/action/chart_ts_build.go index 9883fb4a..e9403f9d 100644 --- a/pkg/action/chart_ts_build.go +++ b/pkg/action/chart_ts_build.go @@ -13,11 +13,9 @@ import ( "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/featgate" - v3chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" helmchart "github.com/werf/nelm/pkg/helm/pkg/chart" chartcommon "github.com/werf/nelm/pkg/helm/pkg/chart/common" "github.com/werf/nelm/pkg/helm/pkg/chart/loader" - v2chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/ts" ) @@ -69,17 +67,7 @@ func ChartTSBuild(ctx context.Context, opts ChartTSBuildOptions) error { return fmt.Errorf("process chart: %w", err) } - var rawFiles []*chartcommon.File - switch c := loadedChart.(type) { - case *v2chart.Chart: - rawFiles = c.Raw - case *v3chart.Chart: - rawFiles = c.Raw - default: - return fmt.Errorf("unsupported chart type %T", loadedChart) - } - - bundles := lo.Filter(rawFiles, func(file *chartcommon.File, _ int) bool { + bundles := lo.Filter(chartAccessor.RawFiles(), func(file *chartcommon.File, _ int) bool { return strings.Contains(file.Name, common.ChartTSBundleFile) }) diff --git a/pkg/action/plan_artifact.go b/pkg/action/plan_artifact.go index 3ab88ce6..4c49ec74 100644 --- a/pkg/action/plan_artifact.go +++ b/pkg/action/plan_artifact.go @@ -13,7 +13,6 @@ import ( "github.com/werf/common-go/pkg/secrets_manager" "github.com/werf/nelm/pkg/common" - helmrel "github.com/werf/nelm/pkg/helm/pkg/release" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/plan" "github.com/werf/nelm/pkg/release" @@ -32,20 +31,11 @@ type PlanArtifact struct { Timestamp time.Time `json:"timestamp"` } -func (a *PlanArtifact) GetReleaseAccessor() (helmrel.Accessor, error) { - accessor, err := helmrel.NewAccessor(a.Data.Release.Releaser) - if err != nil { - return nil, fmt.Errorf("create release accessor: %w", err) - } - - return accessor, nil -} - type PlanArtifactData struct { Options common.ReleaseInstallRuntimeOptions `json:"options"` Changes []*plan.ResourceChange `json:"changes"` Plan *plan.Plan `json:"plan"` - Release *release.StoredRelease `json:"release"` + Release *release.VersionedRelease `json:"release"` InstallableResourceInfos []*plan.InstallableResourceInfo `json:"installableResourceInfos"` ReleaseInfos []*plan.ReleaseInfo `json:"releaseInfos"` } diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index 8ada82b8..657fab78 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -321,11 +321,12 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re installPlan = planArtifact.Data.Plan - newRelease, err = planArtifact.GetReleaseAccessor() - if err != nil { - return fmt.Errorf("get release accessor from plan artifact: %w", err) + if planArtifact.Data.Release == nil || planArtifact.Data.Release.Accessor == nil { + return fmt.Errorf("plan artifact has no release") } + newRelease = planArtifact.Data.Release.Accessor + instResInfos = planArtifact.Data.InstallableResourceInfos relInfos = planArtifact.Data.ReleaseInfos } else { diff --git a/pkg/action/release_plan_install.go b/pkg/action/release_plan_install.go index 647b48d5..2b111caa 100644 --- a/pkg/action/release_plan_install.go +++ b/pkg/action/release_plan_install.go @@ -433,7 +433,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc APIVersion: PlanArtifactSchemeVersion, Data: &PlanArtifactData{ Options: opts.ReleaseInstallRuntimeOptions, - Release: &release.StoredRelease{Releaser: newRelease.Releaser()}, + Release: &release.VersionedRelease{Accessor: newRelease}, Plan: installPlan, Changes: changes, InstallableResourceInfos: instResInfos, diff --git a/pkg/helm/intern/release/v2/util/manifest_sorter.go b/pkg/helm/intern/release/v2/util/manifest_sorter.go index 9d40911a..db04cf36 100644 --- a/pkg/helm/intern/release/v2/util/manifest_sorter.go +++ b/pkg/helm/intern/release/v2/util/manifest_sorter.go @@ -242,3 +242,19 @@ func operateAnnotationValues(entry SimpleHead, annotation string, operate func(p } } } + +func HookManifestToHook(manifest, filePath string) (*v2.Hook, error) { + manifestFile := &manifestFile{ + entries: map[string]string{ + "0": manifest, + }, + path: filePath, + } + + res := &result{} + if err := manifestFile.sort(res); err != nil { + return nil, fmt.Errorf("sort hook manifest file: %w", err) + } + + return res.hooks[0], nil +} diff --git a/pkg/helm/pkg/chart/common.go b/pkg/helm/pkg/chart/common.go index 60034d62..4530d146 100644 --- a/pkg/helm/pkg/chart/common.go +++ b/pkg/helm/pkg/chart/common.go @@ -72,6 +72,10 @@ func (r *v2Accessor) Files() []*common.File { return r.chrt.Files } +func (r *v2Accessor) RawFiles() []*common.File { + return r.chrt.Raw +} + func (r *v2Accessor) Templates() []*common.File { return r.chrt.Templates } @@ -157,6 +161,10 @@ func (r *v3Accessor) Files() []*common.File { return r.chrt.Files } +func (r *v3Accessor) RawFiles() []*common.File { + return r.chrt.Raw +} + func (r *v3Accessor) Templates() []*common.File { return r.chrt.Templates } diff --git a/pkg/helm/pkg/chart/interfaces.go b/pkg/helm/pkg/chart/interfaces.go index d4d0010d..8ca53fd9 100644 --- a/pkg/helm/pkg/chart/interfaces.go +++ b/pkg/helm/pkg/chart/interfaces.go @@ -28,6 +28,7 @@ type Accessor interface { IsRoot() bool MetadataAsMap() map[string]any Files() []*common.File + RawFiles() []*common.File Templates() []*common.File ChartFullPath() string IsLibraryChart() bool diff --git a/pkg/helm/pkg/release/common.go b/pkg/helm/pkg/release/common.go index abf4d9c5..8b3c953c 100644 --- a/pkg/helm/pkg/release/common.go +++ b/pkg/helm/pkg/release/common.go @@ -21,8 +21,11 @@ import ( "fmt" "time" + "github.com/mitchellh/copystructure" + v2release "github.com/werf/nelm/pkg/helm/intern/release/v2" "github.com/werf/nelm/pkg/helm/pkg/chart" + "github.com/werf/nelm/pkg/helm/pkg/release/common" v1release "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) @@ -128,6 +131,27 @@ func (a *v1Accessor) Releaser() Releaser { return a.rel } +func (a *v1Accessor) SetStatus(status common.Status) { + a.rel.Info.Status = status +} + +func (a *v1Accessor) SetFirstDeployed(t time.Time) { + a.rel.Info.FirstDeployed = t +} + +func (a *v1Accessor) SetLastDeployed(t time.Time) { + a.rel.Info.LastDeployed = t +} + +func (a *v1Accessor) Copy() (Accessor, error) { + copied, err := copystructure.Copy(a.rel) + if err != nil { + return nil, fmt.Errorf("deep copy release: %w", err) + } + + return NewAccessor(copied) +} + type v1HookAccessor struct { hook *v1release.Hook } @@ -208,6 +232,27 @@ func (a *v2Accessor) Releaser() Releaser { return a.rel } +func (a *v2Accessor) SetStatus(status common.Status) { + a.rel.Info.Status = status +} + +func (a *v2Accessor) SetFirstDeployed(t time.Time) { + a.rel.Info.FirstDeployed = t +} + +func (a *v2Accessor) SetLastDeployed(t time.Time) { + a.rel.Info.LastDeployed = t +} + +func (a *v2Accessor) Copy() (Accessor, error) { + copied, err := copystructure.Copy(a.rel) + if err != nil { + return nil, fmt.Errorf("deep copy release: %w", err) + } + + return NewAccessor(copied) +} + type v2HookAccessor struct { hook *v2release.Hook } diff --git a/pkg/helm/pkg/release/interfaces.go b/pkg/helm/pkg/release/interfaces.go index 53ce4687..5ce99519 100644 --- a/pkg/helm/pkg/release/interfaces.go +++ b/pkg/helm/pkg/release/interfaces.go @@ -20,6 +20,7 @@ import ( "time" "github.com/werf/nelm/pkg/helm/pkg/chart" + "github.com/werf/nelm/pkg/helm/pkg/release/common" ) type Releaser any @@ -42,6 +43,10 @@ type Accessor interface { Config() map[string]any UnstoredManifest() string Releaser() Releaser + SetStatus(status common.Status) + SetFirstDeployed(t time.Time) + SetLastDeployed(t time.Time) + Copy() (Accessor, error) } type HookAccessor interface { diff --git a/pkg/plan/operation_config.go b/pkg/plan/operation_config.go index 4bf258b6..64d62b06 100644 --- a/pkg/plan/operation_config.go +++ b/pkg/plan/operation_config.go @@ -5,11 +5,9 @@ import ( "regexp" "time" - "github.com/samber/lo" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/werf/kubedog/pkg/dyntracker/statestore" - helmrel "github.com/werf/nelm/pkg/helm/pkg/release" "github.com/werf/nelm/pkg/release" "github.com/werf/nelm/pkg/resource/spec" ) @@ -164,35 +162,27 @@ func (c *OperationConfigTrackAbsence) IDHuman() string { } type OperationConfigCreateRelease struct { - Release *release.StoredRelease `json:"release"` + Release *release.VersionedRelease `json:"release"` } func (c *OperationConfigCreateRelease) ID() string { - acc := lo.Must(helmrel.NewAccessor(c.Release.Releaser)) - - return releaseID(acc.Namespace(), acc.Name(), acc.Version()) + return releaseID(c.Release.Accessor.Namespace(), c.Release.Accessor.Name(), c.Release.Accessor.Version()) } func (c *OperationConfigCreateRelease) IDHuman() string { - acc := lo.Must(helmrel.NewAccessor(c.Release.Releaser)) - - return releaseIDHuman(acc.Namespace(), acc.Name(), acc.Version()) + return releaseIDHuman(c.Release.Accessor.Namespace(), c.Release.Accessor.Name(), c.Release.Accessor.Version()) } type OperationConfigUpdateRelease struct { - Release *release.StoredRelease `json:"release"` + Release *release.VersionedRelease `json:"release"` } func (c *OperationConfigUpdateRelease) ID() string { - acc := lo.Must(helmrel.NewAccessor(c.Release.Releaser)) - - return releaseID(acc.Namespace(), acc.Name(), acc.Version()) + return releaseID(c.Release.Accessor.Namespace(), c.Release.Accessor.Name(), c.Release.Accessor.Version()) } func (c *OperationConfigUpdateRelease) IDHuman() string { - acc := lo.Must(helmrel.NewAccessor(c.Release.Releaser)) - - return releaseIDHuman(acc.Namespace(), acc.Name(), acc.Version()) + return releaseIDHuman(c.Release.Accessor.Namespace(), c.Release.Accessor.Name(), c.Release.Accessor.Version()) } type OperationConfigDeleteRelease struct { diff --git a/pkg/plan/plan_build.go b/pkg/plan/plan_build.go index 3344708b..bd0f6195 100644 --- a/pkg/plan/plan_build.go +++ b/pkg/plan/plan_build.go @@ -9,7 +9,6 @@ import ( "github.com/samber/lo" "github.com/werf/nelm/pkg/common" - helmrel "github.com/werf/nelm/pkg/helm/pkg/release" helmreleasecommon "github.com/werf/nelm/pkg/helm/pkg/release/common" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/release" @@ -187,7 +186,7 @@ func addFailureReleaseOperations(failedPlan, plan *Plan, releaseInfos []*Release continue } - infoAcc := lo.Must(helmrel.NewAccessor(info.Release.Releaser)) + infoRel := info.Release.Accessor if _, releaseCreated := lo.Find(failedPlan.Operations(), func(op *Operation) bool { if op.Status != OperationStatusCompleted { @@ -196,17 +195,17 @@ func addFailureReleaseOperations(failedPlan, plan *Plan, releaseInfos []*Release switch config := op.Config.(type) { case *OperationConfigCreateRelease: - configAcc := lo.Must(helmrel.NewAccessor(config.Release.Releaser)) + configRel := config.Release.Accessor - return configAcc.Namespace() == infoAcc.Namespace() && - configAcc.Name() == infoAcc.Name() && - configAcc.Version() == infoAcc.Version() + return configRel.Namespace() == infoRel.Namespace() && + configRel.Name() == infoRel.Name() && + configRel.Version() == infoRel.Version() case *OperationConfigUpdateRelease: - configAcc := lo.Must(helmrel.NewAccessor(config.Release.Releaser)) + configRel := config.Release.Accessor - return configAcc.Namespace() == infoAcc.Namespace() && - configAcc.Name() == infoAcc.Name() && - configAcc.Version() == infoAcc.Version() + return configRel.Namespace() == infoRel.Namespace() && + configRel.Name() == infoRel.Name() && + configRel.Version() == infoRel.Version() default: return false } @@ -502,16 +501,16 @@ func resolveTrackReadinessOpInStage(ctx context.Context, plan *Plan, instInfos [ } func addDeleteReleaseOps(plan *Plan, info *ReleaseInfo) { - acc := lo.Must(helmrel.NewAccessor(info.Release.Releaser)) + rel := info.Release.Accessor deletedOp := &Operation{ Type: OperationTypeDeleteRelease, Version: OperationVersionDeleteRelease, Category: OperationCategoryRelease, Config: &OperationConfigDeleteRelease{ - ReleaseName: acc.Name(), - ReleaseNamespace: acc.Namespace(), - ReleaseRevision: acc.Version(), + ReleaseName: rel.Name(), + ReleaseNamespace: rel.Namespace(), + ReleaseRevision: rel.Version(), }, } lo.Must0(plan.AddOperationChain().AddOperation(deletedOp).Stage(common.StageFinal).Do()) @@ -556,17 +555,19 @@ func addDeleteResourcesOps(plan *Plan, infos []*DeletableResourceInfo) error { } func addFailedReleaseOps(plan *Plan, info *ReleaseInfo) error { - failedReleaser, err := release.CopyReleaserWithStatus(info.Release.Releaser, helmreleasecommon.StatusFailed) + failedRel, err := info.Release.Accessor.Copy() if err != nil { - return fmt.Errorf("copy release with status: %w", err) + return fmt.Errorf("copy release: %w", err) } + failedRel.SetStatus(helmreleasecommon.StatusFailed) + failedOp := &Operation{ Type: OperationTypeUpdateRelease, Version: OperationVersionUpdateRelease, Category: OperationCategoryRelease, Config: &OperationConfigUpdateRelease{ - Release: &release.StoredRelease{Releaser: failedReleaser}, + Release: &release.VersionedRelease{Accessor: failedRel}, }, } lo.Must0(plan.AddOperationChain().AddOperation(failedOp).Stage(common.StageInit).Do()) @@ -786,32 +787,36 @@ func addMainStages(plan *Plan) error { } func addPendingAndDeployedReleaseOps(plan *Plan, info *ReleaseInfo, pendingStatus helmreleasecommon.Status) error { - pendingReleaser, err := release.CopyReleaserWithStatus(info.Release.Releaser, pendingStatus) + pendingRel, err := info.Release.Accessor.Copy() if err != nil { - return fmt.Errorf("copy release with status: %w", err) + return fmt.Errorf("copy release: %w", err) } + pendingRel.SetStatus(pendingStatus) + pendingOp := &Operation{ Type: OperationTypeCreateRelease, Version: OperationVersionCreateRelease, Category: OperationCategoryRelease, Config: &OperationConfigCreateRelease{ - Release: &release.StoredRelease{Releaser: pendingReleaser}, + Release: &release.VersionedRelease{Accessor: pendingRel}, }, } lo.Must0(plan.AddOperationChain().AddOperation(pendingOp).Stage(common.StageInit).Do()) - succeededReleaser, err := release.CopyReleaserWithStatus(pendingReleaser, helmreleasecommon.StatusDeployed) + succeededRel, err := pendingRel.Copy() if err != nil { - return fmt.Errorf("copy release with status: %w", err) + return fmt.Errorf("copy release: %w", err) } + succeededRel.SetStatus(helmreleasecommon.StatusDeployed) + succeededOp := &Operation{ Type: OperationTypeUpdateRelease, Version: OperationVersionUpdateRelease, Category: OperationCategoryRelease, Config: &OperationConfigUpdateRelease{ - Release: &release.StoredRelease{Releaser: succeededReleaser}, + Release: &release.VersionedRelease{Accessor: succeededRel}, }, } lo.Must0(plan.AddOperationChain().AddOperation(succeededOp).Stage(common.StageFinal).Do()) @@ -820,17 +825,19 @@ func addPendingAndDeployedReleaseOps(plan *Plan, info *ReleaseInfo, pendingStatu } func addSupersedeReleaseOps(plan *Plan, info *ReleaseInfo) error { - supersededReleaser, err := release.CopyReleaserWithStatus(info.Release.Releaser, helmreleasecommon.StatusSuperseded) + supersededRel, err := info.Release.Accessor.Copy() if err != nil { - return fmt.Errorf("copy release with status: %w", err) + return fmt.Errorf("copy release: %w", err) } + supersededRel.SetStatus(helmreleasecommon.StatusSuperseded) + supersedeOp := &Operation{ Type: OperationTypeUpdateRelease, Version: OperationVersionUpdateRelease, Category: OperationCategoryRelease, Config: &OperationConfigUpdateRelease{ - Release: &release.StoredRelease{Releaser: supersededReleaser}, + Release: &release.VersionedRelease{Accessor: supersededRel}, }, } lo.Must0(plan.AddOperationChain().AddOperation(supersedeOp).Stage(common.StageFinal).Do()) @@ -839,31 +846,31 @@ func addSupersedeReleaseOps(plan *Plan, info *ReleaseInfo) error { } func addUninstallReleaseOps(plan *Plan, info *ReleaseInfo) error { - uninstallingReleaser, err := release.CopyReleaserWithStatus(info.Release.Releaser, helmreleasecommon.StatusUninstalling) + uninstallingRel, err := info.Release.Accessor.Copy() if err != nil { - return fmt.Errorf("copy release with status: %w", err) + return fmt.Errorf("copy release: %w", err) } + uninstallingRel.SetStatus(helmreleasecommon.StatusUninstalling) + uninstallingOp := &Operation{ Type: OperationTypeUpdateRelease, Version: OperationVersionUpdateRelease, Category: OperationCategoryRelease, Config: &OperationConfigUpdateRelease{ - Release: &release.StoredRelease{Releaser: uninstallingReleaser}, + Release: &release.VersionedRelease{Accessor: uninstallingRel}, }, } lo.Must0(plan.AddOperationChain().AddOperation(uninstallingOp).Stage(common.StageInit).Do()) - uninstallingAcc := lo.Must(helmrel.NewAccessor(uninstallingReleaser)) - uninstalledOp := &Operation{ Type: OperationTypeDeleteRelease, Version: OperationVersionDeleteRelease, Category: OperationCategoryRelease, Config: &OperationConfigDeleteRelease{ - ReleaseName: uninstallingAcc.Name(), - ReleaseNamespace: uninstallingAcc.Namespace(), - ReleaseRevision: uninstallingAcc.Version(), + ReleaseName: uninstallingRel.Name(), + ReleaseNamespace: uninstallingRel.Namespace(), + ReleaseRevision: uninstallingRel.Version(), }, } lo.Must0(plan.AddOperationChain().AddOperation(uninstalledOp).Stage(common.StageFinal).Do()) diff --git a/pkg/plan/plan_build_test.go b/pkg/plan/plan_build_test.go index c0e92f4c..2afbfb77 100644 --- a/pkg/plan/plan_build_test.go +++ b/pkg/plan/plan_build_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/werf/nelm/pkg/common" + helmrel "github.com/werf/nelm/pkg/helm/pkg/release" helmreleasestatus "github.com/werf/nelm/pkg/helm/pkg/release/common" helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" "github.com/werf/nelm/pkg/plan" @@ -37,6 +38,9 @@ func (s *BuildPlanSuite) SetupSuite() { s.cmpOpts = cmp.Options{ cmpopts.EquateEmpty(), test.IgnoreEdgeOption(), + cmp.Comparer(func(a, b helmrel.Accessor) bool { + return cmp.Equal(a.Releaser(), b.Releaser()) + }), cmpopts.SortSlices(func(a, b *plan.Operation) bool { return a.ID() < b.ID() }), @@ -228,7 +232,7 @@ func (s *BuildPlanSuite) TestBuildPlan() { }, } - updatedRelRaw, err := copystructure.Copy(releaseInfos[0].Release.Releaser) + updatedRelRaw, err := copystructure.Copy(releaseInfos[0].Release.Accessor.Releaser()) s.Require().NoError(err) updatedRel := updatedRelRaw.(*helmrelease.Release) @@ -239,7 +243,7 @@ func (s *BuildPlanSuite) TestBuildPlan() { Version: plan.OperationVersionUpdateRelease, Category: plan.OperationCategoryRelease, Config: &plan.OperationConfigUpdateRelease{ - Release: &release.StoredRelease{Releaser: updatedRel}, + Release: &release.VersionedRelease{Accessor: lo.Must(helmrel.NewAccessor(updatedRel))}, }, } @@ -1407,7 +1411,7 @@ func TestBuildPlanSuites(t *testing.T) { func defaultReleaseInfo(releaseName, releaseNamespace string) *plan.ReleaseInfo { return &plan.ReleaseInfo{ - Release: &release.StoredRelease{Releaser: defaultRelease(releaseName, releaseNamespace)}, + Release: &release.VersionedRelease{Accessor: lo.Must(helmrel.NewAccessor(defaultRelease(releaseName, releaseNamespace)))}, Must: plan.ReleaseTypeInstall, MustFailOnFailedDeploy: true, } diff --git a/pkg/plan/plan_execute.go b/pkg/plan/plan_execute.go index 31585867..2b983345 100644 --- a/pkg/plan/plan_execute.go +++ b/pkg/plan/plan_execute.go @@ -15,7 +15,6 @@ import ( kdutil "github.com/werf/kubedog/pkg/dyntracker/util" "github.com/werf/kubedog/pkg/informer" "github.com/werf/nelm/pkg/common" - helmrel "github.com/werf/nelm/pkg/helm/pkg/release" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/release" @@ -326,12 +325,7 @@ func execOpCreate(ctx context.Context, op *Operation, releaseNamespace string, c func execOpCreateRelease(ctx context.Context, op *Operation, history release.Historier) error { opConfig := op.Config.(*OperationConfigCreateRelease) - rel, err := helmrel.NewAccessor(opConfig.Release.Releaser) - if err != nil { - return fmt.Errorf("get release accessor: %w", err) - } - - if err := history.CreateRelease(ctx, rel); err != nil { + if err := history.CreateRelease(ctx, opConfig.Release.Accessor); err != nil { return fmt.Errorf("create release: %w", err) } @@ -376,12 +370,7 @@ func execOpUpdate(ctx context.Context, op *Operation, releaseNamespace string, c func execOpUpdateRelease(ctx context.Context, op *Operation, history release.Historier) error { opConfig := op.Config.(*OperationConfigUpdateRelease) - rel, err := helmrel.NewAccessor(opConfig.Release.Releaser) - if err != nil { - return fmt.Errorf("get release accessor: %w", err) - } - - if err := history.UpdateRelease(ctx, rel); err != nil { + if err := history.UpdateRelease(ctx, opConfig.Release.Accessor); err != nil { return fmt.Errorf("update release: %w", err) } diff --git a/pkg/plan/release_info.go b/pkg/plan/release_info.go index a04133df..99608316 100644 --- a/pkg/plan/release_info.go +++ b/pkg/plan/release_info.go @@ -31,7 +31,7 @@ type ReleaseType string // Data class, which stores all info to make a decision on what to do with the release revision // in the plan. type ReleaseInfo struct { - Release *release.StoredRelease `json:"release"` + Release *release.VersionedRelease `json:"release"` Must ReleaseType `json:"must"` MustFailOnFailedDeploy bool `json:"mustFailOnFailedDeploy"` @@ -48,14 +48,14 @@ func BuildReleaseInfos(ctx context.Context, deployType common.DeployType, prevRe infos = append(infos, &ReleaseInfo{ Must: ReleaseTypeInstall, MustFailOnFailedDeploy: true, - Release: &release.StoredRelease{Releaser: newRel.Releaser()}, + Release: &release.VersionedRelease{Accessor: newRel}, }) for _, rel := range prevReleases { if rel.Status() == helmreleasecommon.StatusDeployed.String() { infos = append(infos, &ReleaseInfo{ Must: ReleaseTypeSupersede, - Release: &release.StoredRelease{Releaser: rel.Releaser()}, + Release: &release.VersionedRelease{Accessor: rel}, }) } } @@ -63,14 +63,14 @@ func BuildReleaseInfos(ctx context.Context, deployType common.DeployType, prevRe infos = append(infos, &ReleaseInfo{ Must: ReleaseTypeUpgrade, MustFailOnFailedDeploy: true, - Release: &release.StoredRelease{Releaser: newRel.Releaser()}, + Release: &release.VersionedRelease{Accessor: newRel}, }) for _, rel := range prevReleases { if rel.Status() == helmreleasecommon.StatusDeployed.String() { infos = append(infos, &ReleaseInfo{ Must: ReleaseTypeSupersede, - Release: &release.StoredRelease{Releaser: rel.Releaser()}, + Release: &release.VersionedRelease{Accessor: rel}, }) } } @@ -78,14 +78,14 @@ func BuildReleaseInfos(ctx context.Context, deployType common.DeployType, prevRe infos = append(infos, &ReleaseInfo{ Must: ReleaseTypeRollback, MustFailOnFailedDeploy: true, - Release: &release.StoredRelease{Releaser: newRel.Releaser()}, + Release: &release.VersionedRelease{Accessor: newRel}, }) for _, rel := range prevReleases { if rel.Status() == helmreleasecommon.StatusDeployed.String() { infos = append(infos, &ReleaseInfo{ Must: ReleaseTypeSupersede, - Release: &release.StoredRelease{Releaser: rel.Releaser()}, + Release: &release.VersionedRelease{Accessor: rel}, }) } } @@ -106,7 +106,7 @@ func BuildReleaseInfos(ctx context.Context, deployType common.DeployType, prevRe infos = append(infos, &ReleaseInfo{ Must: releaseType, MustFailOnFailedDeploy: failOnFailedDeploy, - Release: &release.StoredRelease{Releaser: prevReleases[i].Releaser()}, + Release: &release.VersionedRelease{Accessor: prevReleases[i]}, }) } default: diff --git a/pkg/release/history.go b/pkg/release/history.go index 94262d68..0cf762eb 100644 --- a/pkg/release/history.go +++ b/pkg/release/history.go @@ -11,10 +11,8 @@ import ( "github.com/samber/lo" - v2release "github.com/werf/nelm/pkg/helm/intern/release/v2" helmrel "github.com/werf/nelm/pkg/helm/pkg/release" helmreleasecommon "github.com/werf/nelm/pkg/helm/pkg/release/common" - helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" "github.com/werf/nelm/pkg/helm/pkg/storage/driver" ) @@ -52,9 +50,9 @@ func (h *History) CreateRelease(ctx context.Context, rel helmrel.Accessor) error h.updateLock.Lock() defer h.updateLock.Unlock() - if err := touchReleaseDeployTimes(rel.Releaser(), time.Now()); err != nil { - return fmt.Errorf("touch release deploy times: %w", err) - } + now := time.Now() + rel.SetFirstDeployed(now) + rel.SetLastDeployed(now) if err := h.storage.Create(rel); err != nil { return fmt.Errorf("create release %q (namespace: %q, revision: %d): %w", rel.Name(), rel.Namespace(), rel.Version(), err) @@ -122,9 +120,9 @@ func (h *History) UpdateRelease(ctx context.Context, rel helmrel.Accessor) error h.updateLock.Lock() defer h.updateLock.Unlock() - if err := touchReleaseDeployTimes(rel.Releaser(), time.Now()); err != nil { - return fmt.Errorf("touch release deploy times: %w", err) - } + now := time.Now() + rel.SetFirstDeployed(now) + rel.SetLastDeployed(now) if err := h.storage.Update(rel); err != nil { return fmt.Errorf("update release %q (namespace: %q, revision: %d): %w", rel.Name(), rel.Namespace(), rel.Version(), err) @@ -192,18 +190,3 @@ func BuildHistory(releaseName string, historyStorage ReleaseStorager, opts Histo opts, ), nil } - -func touchReleaseDeployTimes(releaser helmrel.Releaser, t time.Time) error { - switch r := releaser.(type) { - case *helmrelease.Release: - r.Info.FirstDeployed = t - r.Info.LastDeployed = t - case *v2release.Release: - r.Info.FirstDeployed = t - r.Info.LastDeployed = t - default: - return fmt.Errorf("unexpected release type: %T", releaser) - } - - return nil -} diff --git a/pkg/release/history_ai_test.go b/pkg/release/history_ai_test.go new file mode 100644 index 00000000..ee751593 --- /dev/null +++ b/pkg/release/history_ai_test.go @@ -0,0 +1,47 @@ +//go:build ai_tests + +package release + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + helmrel "github.com/werf/nelm/pkg/helm/pkg/release" +) + +type stubStorager struct { + deleteErr error + deleteRel helmrel.Accessor +} + +func (s *stubStorager) Create(rls helmrel.Accessor) error { + return nil +} + +func (s *stubStorager) Delete(name string, version int) (helmrel.Accessor, error) { + return s.deleteRel, s.deleteErr +} + +func (s *stubStorager) Query(labels map[string]string) ([]helmrel.Accessor, error) { + return nil, nil +} + +func (s *stubStorager) Update(rls helmrel.Accessor) error { + return nil +} + +func TestAI_DeleteRelease_ErrorIncludesNameAndRevision(t *testing.T) { + history := NewHistory(nil, "myrelease", &stubStorager{ + deleteErr: errors.New("kube delete failed"), + deleteRel: nil, + }, HistoryOptions{}) + + delErr := history.DeleteRelease(context.Background(), "myrelease", 3) + require.Error(t, delErr) + assert.Contains(t, delErr.Error(), `"myrelease"`) + assert.Contains(t, delErr.Error(), "revision: 3") +} diff --git a/pkg/release/release.go b/pkg/release/release.go index 02bdd840..43672ed3 100644 --- a/pkg/release/release.go +++ b/pkg/release/release.go @@ -11,7 +11,6 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" - "github.com/samber/lo" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/yaml" @@ -19,6 +18,7 @@ import ( "github.com/werf/nelm/pkg/common" v3chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" v2release "github.com/werf/nelm/pkg/helm/intern/release/v2" + v2releaseutil "github.com/werf/nelm/pkg/helm/intern/release/v2/util" chart "github.com/werf/nelm/pkg/helm/pkg/chart" helmchart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" chartv2util "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" @@ -159,10 +159,13 @@ func NewRelease(name, namespace string, revision int, deployType common.DeployTy return spec.ResourceSpecSortHandler(resources[i], resources[j]) }) + _, isV3 := chrt.Charter().(*v3chart.Chart) + var ( unstoredResources []string regularResources []string - hookResources []*helmrelease.Hook + v1HookResources []*helmrelease.Hook + v2HookResources []*v2release.Hook ) for _, res := range resources { @@ -173,12 +176,21 @@ func NewRelease(name, namespace string, revision int, deployType common.DeployTy return nil, fmt.Errorf("convert resource spec to manifest: %w", err) } - hook, err := releaseutil.HookManifestToHook(manifest, res.FilePath) - if err != nil { - return nil, fmt.Errorf("convert hook manifest to hook: %w", err) - } + if isV3 { + hook, err := v2releaseutil.HookManifestToHook(manifest, res.FilePath) + if err != nil { + return nil, fmt.Errorf("convert hook manifest to hook: %w", err) + } - hookResources = append(hookResources, hook) + v2HookResources = append(v2HookResources, hook) + } else { + hook, err := releaseutil.HookManifestToHook(manifest, res.FilePath) + if err != nil { + return nil, fmt.Errorf("convert hook manifest to hook: %w", err) + } + + v1HookResources = append(v1HookResources, hook) + } case common.StoreAsRegular: manifest, err := resourceSpecToManifest(name, namespace, revision, res) if err != nil { @@ -213,7 +225,7 @@ func NewRelease(name, namespace string, revision int, deployType common.DeployTy Chart: chartObj, Config: releaseConfig, Manifest: strings.Join(regularResources, "\n---\n"), - Hooks: hookResources, + Hooks: v1HookResources, Version: revision, Namespace: namespace, Labels: opts.Labels, @@ -226,12 +238,10 @@ func NewRelease(name, namespace string, revision int, deployType common.DeployTy Status: status, Notes: opts.Notes, }, - Chart: chartObj, - Config: releaseConfig, - Manifest: strings.Join(regularResources, "\n---\n"), - Hooks: lo.Map(hookResources, func(h *helmrelease.Hook, _ int) *v2release.Hook { - return v1HookToV2Hook(h) - }), + Chart: chartObj, + Config: releaseConfig, + Manifest: strings.Join(regularResources, "\n---\n"), + Hooks: v2HookResources, Version: revision, Namespace: namespace, Labels: opts.Labels, @@ -321,30 +331,6 @@ func resourceSpecToManifest(name, namespace string, revision int, res *spec.Reso return manifest, nil } -func v1HookToV2Hook(h *helmrelease.Hook) *v2release.Hook { - return &v2release.Hook{ - Name: h.Name, - Kind: h.Kind, - Path: h.Path, - Manifest: h.Manifest, - Events: lo.Map(h.Events, func(e helmrelease.HookEvent, _ int) v2release.HookEvent { - return v2release.HookEvent(e) - }), - LastRun: v2release.HookExecution{ - StartedAt: h.LastRun.StartedAt, - CompletedAt: h.LastRun.CompletedAt, - Phase: v2release.HookPhase(h.LastRun.Phase), - }, - Weight: h.Weight, - DeletePolicies: lo.Map(h.DeletePolicies, func(p helmrelease.HookDeletePolicy, _ int) v2release.HookDeletePolicy { - return v2release.HookDeletePolicy(p) - }), - OutputLogPolicies: lo.Map(h.OutputLogPolicies, func(p helmrelease.HookOutputLogPolicy, _ int) v2release.HookOutputLogPolicy { - return v2release.HookOutputLogPolicy(p) - }), - } -} - func writeUnstructHash(unstruct *unstructured.Unstructured, hash hash.Hash32) error { if b, err := unstruct.MarshalJSON(); err != nil { return fmt.Errorf("unmarshal resource: %w", err) diff --git a/pkg/release/release_storage.go b/pkg/release/release_storage.go index 3500a8ba..cb67a0e9 100644 --- a/pkg/release/release_storage.go +++ b/pkg/release/release_storage.go @@ -5,13 +5,11 @@ import ( "encoding/json" "fmt" - "github.com/mitchellh/copystructure" "k8s.io/client-go/kubernetes" "github.com/werf/nelm/pkg/common" v2release "github.com/werf/nelm/pkg/helm/intern/release/v2" helmrel "github.com/werf/nelm/pkg/helm/pkg/release" - helmreleasecommon "github.com/werf/nelm/pkg/helm/pkg/release/common" helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" helmstorage "github.com/werf/nelm/pkg/helm/pkg/storage" helmdriver "github.com/werf/nelm/pkg/helm/pkg/storage/driver" @@ -37,6 +35,7 @@ type storageAdapter struct { } func (a *storageAdapter) Create(rls helmrel.Accessor) error { + // XXX: must convert to v1 for now, since support of v2 releases in storage drivers doesn't yet exist releaser, err := ReleaserToV1Release(rls.Releaser()) if err != nil { return fmt.Errorf("prepare release for storage: %w", err) @@ -87,6 +86,7 @@ func (a *storageAdapter) Storage() *helmstorage.Storage { } func (a *storageAdapter) Update(rls helmrel.Accessor) error { + // XXX: must convert to v1 for now, since support of v2 releases in storage drivers doesn't yet exist releaser, err := ReleaserToV1Release(rls.Releaser()) if err != nil { return fmt.Errorf("prepare release for storage: %w", err) @@ -104,24 +104,6 @@ type ReleaseStorageOptions struct { SQLConnection string } -func CopyReleaserWithStatus(releaser helmrel.Releaser, status helmreleasecommon.Status) (helmrel.Releaser, error) { - copied, err := copystructure.Copy(releaser) - if err != nil { - return nil, fmt.Errorf("deep copy release: %w", err) - } - - switch r := copied.(type) { - case *helmrelease.Release: - r.Info.Status = status - return r, nil - case *v2release.Release: - r.Info.Status = status - return r, nil - default: - return nil, fmt.Errorf("unexpected release type: %T", copied) - } -} - func NewReleaseStorage(ctx context.Context, namespace, storageDriver string, clientFactory kube.ClientFactorier, opts ReleaseStorageOptions) (ReleaseStorager, error) { var storage *helmstorage.Storage diff --git a/pkg/release/release_storage_ai_test.go b/pkg/release/release_storage_ai_test.go index 3e7e989f..93bd662c 100644 --- a/pkg/release/release_storage_ai_test.go +++ b/pkg/release/release_storage_ai_test.go @@ -9,17 +9,12 @@ import ( "github.com/stretchr/testify/require" v2release "github.com/werf/nelm/pkg/helm/intern/release/v2" + helmrel "github.com/werf/nelm/pkg/helm/pkg/release" helmreleasecommon "github.com/werf/nelm/pkg/helm/pkg/release/common" helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) -func TestAI_CopyReleaserWithStatus_UnexpectedTypeFails(t *testing.T) { - _, err := CopyReleaserWithStatus("not-a-release", helmreleasecommon.StatusFailed) - require.Error(t, err) - assert.Contains(t, err.Error(), "unexpected release type") -} - -func TestAI_CopyReleaserWithStatus_V1PreservesDescriptionAndOriginal(t *testing.T) { +func TestAI_AccessorCopyAndSetStatus_V1PreservesDescriptionAndOriginal(t *testing.T) { original := &helmrelease.Release{ Name: "myrelease", Info: &helmrelease.Info{ @@ -28,10 +23,14 @@ func TestAI_CopyReleaserWithStatus_V1PreservesDescriptionAndOriginal(t *testing. }, } - copied, err := CopyReleaserWithStatus(original, helmreleasecommon.StatusFailed) + acc, err := helmrel.NewAccessor(original) + require.NoError(t, err) + + copied, err := acc.Copy() require.NoError(t, err) + copied.SetStatus(helmreleasecommon.StatusFailed) - copiedRel, ok := copied.(*helmrelease.Release) + copiedRel, ok := copied.Releaser().(*helmrelease.Release) require.True(t, ok) assert.Equal(t, helmreleasecommon.StatusFailed, copiedRel.Info.Status) assert.Equal(t, "original description", copiedRel.Info.Description) @@ -39,7 +38,7 @@ func TestAI_CopyReleaserWithStatus_V1PreservesDescriptionAndOriginal(t *testing. assert.Equal(t, helmreleasecommon.StatusDeployed, original.Info.Status, "original must not be mutated") } -func TestAI_CopyReleaserWithStatus_V2PreservesDescriptionAndOriginal(t *testing.T) { +func TestAI_AccessorCopyAndSetStatus_V2PreservesDescriptionAndOriginal(t *testing.T) { original := &v2release.Release{ Name: "myrelease", Info: &v2release.Info{ @@ -48,10 +47,14 @@ func TestAI_CopyReleaserWithStatus_V2PreservesDescriptionAndOriginal(t *testing. }, } - copied, err := CopyReleaserWithStatus(original, helmreleasecommon.StatusFailed) + acc, err := helmrel.NewAccessor(original) + require.NoError(t, err) + + copied, err := acc.Copy() require.NoError(t, err) + copied.SetStatus(helmreleasecommon.StatusFailed) - copiedRel, ok := copied.(*v2release.Release) + copiedRel, ok := copied.Releaser().(*v2release.Release) require.True(t, ok) assert.Equal(t, helmreleasecommon.StatusFailed, copiedRel.Info.Status) assert.Equal(t, "original description", copiedRel.Info.Description) diff --git a/pkg/release/stored_release.go b/pkg/release/versioned_release.go similarity index 57% rename from pkg/release/stored_release.go rename to pkg/release/versioned_release.go index 0f83db79..9f1b5df4 100644 --- a/pkg/release/stored_release.go +++ b/pkg/release/versioned_release.go @@ -9,43 +9,46 @@ import ( helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) -type StoredRelease struct { - Releaser helmrel.Releaser +type VersionedRelease struct { + Accessor helmrel.Accessor } -func (s *StoredRelease) MarshalJSON() ([]byte, error) { - if s == nil || s.Releaser == nil { +func (r *VersionedRelease) MarshalJSON() ([]byte, error) { + if r == nil || r.Accessor == nil { return []byte("null"), nil } - payload, err := json.Marshal(s.Releaser) + releaser := r.Accessor.Releaser() + + payload, err := json.Marshal(releaser) if err != nil { return nil, fmt.Errorf("marshal release payload: %w", err) } - data, err := json.Marshal(storedReleaseEnvelope{ + data, err := json.Marshal(versionedReleaseEnvelope{ Release: payload, - Version: ReleaserVersion(s.Releaser), + Version: ReleaserVersion(releaser), }) if err != nil { - return nil, fmt.Errorf("marshal stored release envelope: %w", err) + return nil, fmt.Errorf("marshal versioned release envelope: %w", err) } return data, nil } -func (s *StoredRelease) UnmarshalJSON(data []byte) error { +func (r *VersionedRelease) UnmarshalJSON(data []byte) error { if string(data) == "null" { - s.Releaser = nil + r.Accessor = nil return nil } - var env storedReleaseEnvelope + var env versionedReleaseEnvelope if err := json.Unmarshal(data, &env); err != nil { - return fmt.Errorf("unmarshal stored release envelope: %w", err) + return fmt.Errorf("unmarshal versioned release envelope: %w", err) } + var releaser helmrel.Releaser switch env.Version { case ReleaseVersionV1: rel := &helmrelease.Release{} @@ -53,22 +56,29 @@ func (s *StoredRelease) UnmarshalJSON(data []byte) error { return fmt.Errorf("unmarshal v1 release: %w", err) } - s.Releaser = rel + releaser = rel case ReleaseVersionV2: rel := &v2release.Release{} if err := json.Unmarshal(env.Release, rel); err != nil { return fmt.Errorf("unmarshal v2 release: %w", err) } - s.Releaser = rel + releaser = rel default: return fmt.Errorf("unknown release version %q", env.Version) } + acc, err := helmrel.NewAccessor(releaser) + if err != nil { + return fmt.Errorf("wrap release: %w", err) + } + + r.Accessor = acc + return nil } -type storedReleaseEnvelope struct { +type versionedReleaseEnvelope struct { Version string `json:"version"` Release json.RawMessage `json:"release"` } diff --git a/pkg/release/stored_release_ai_test.go b/pkg/release/versioned_release_ai_test.go similarity index 70% rename from pkg/release/stored_release_ai_test.go rename to pkg/release/versioned_release_ai_test.go index 15d22dc9..fbe29709 100644 --- a/pkg/release/stored_release_ai_test.go +++ b/pkg/release/versioned_release_ai_test.go @@ -12,21 +12,22 @@ import ( v3chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" v2release "github.com/werf/nelm/pkg/helm/intern/release/v2" v2chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + helmrel "github.com/werf/nelm/pkg/helm/pkg/release" helmreleasecommon "github.com/werf/nelm/pkg/helm/pkg/release/common" helmrelease "github.com/werf/nelm/pkg/helm/pkg/release/v1" ) -func TestAI_StoredRelease_NilRoundTrip(t *testing.T) { - data, err := json.Marshal(&StoredRelease{}) +func TestAI_VersionedRelease_NilRoundTrip(t *testing.T) { + data, err := json.Marshal(&VersionedRelease{}) require.NoError(t, err) assert.Equal(t, "null", string(data)) - var restored StoredRelease + var restored VersionedRelease require.NoError(t, json.Unmarshal([]byte("null"), &restored)) - assert.Nil(t, restored.Releaser) + assert.Nil(t, restored.Accessor) } -func TestAI_StoredRelease_RoundTripV1PreservesNativeType(t *testing.T) { +func TestAI_VersionedRelease_RoundTripV1PreservesNativeType(t *testing.T) { original := &helmrelease.Release{ Name: "myrelease", Info: &helmrelease.Info{ @@ -43,15 +44,18 @@ func TestAI_StoredRelease_RoundTripV1PreservesNativeType(t *testing.T) { Namespace: "myns", } - data, err := json.Marshal(&StoredRelease{Releaser: original}) + acc, err := helmrel.NewAccessor(original) + require.NoError(t, err) + + data, err := json.Marshal(&VersionedRelease{Accessor: acc}) require.NoError(t, err) assert.Contains(t, string(data), `"version":"v1"`) - var restored StoredRelease + var restored VersionedRelease require.NoError(t, json.Unmarshal(data, &restored)) - rel, ok := restored.Releaser.(*helmrelease.Release) - require.True(t, ok, "v1 release must round-trip as *helmrelease.Release, got %T", restored.Releaser) + rel, ok := restored.Accessor.Releaser().(*helmrelease.Release) + require.True(t, ok, "v1 release must round-trip as *helmrelease.Release, got %T", restored.Accessor.Releaser()) assert.Equal(t, "myrelease", rel.Name) assert.Equal(t, 3, rel.Version) require.NotNil(t, rel.Chart) @@ -59,7 +63,7 @@ func TestAI_StoredRelease_RoundTripV1PreservesNativeType(t *testing.T) { assert.Equal(t, "mychart", rel.Chart.Metadata.Name) } -func TestAI_StoredRelease_RoundTripV2PreservesNativeType(t *testing.T) { +func TestAI_VersionedRelease_RoundTripV2PreservesNativeType(t *testing.T) { original := &v2release.Release{ Name: "myrelease", Info: &v2release.Info{ @@ -77,15 +81,18 @@ func TestAI_StoredRelease_RoundTripV2PreservesNativeType(t *testing.T) { Namespace: "myns", } - data, err := json.Marshal(&StoredRelease{Releaser: original}) + acc, err := helmrel.NewAccessor(original) + require.NoError(t, err) + + data, err := json.Marshal(&VersionedRelease{Accessor: acc}) require.NoError(t, err) assert.Contains(t, string(data), `"version":"v2"`) - var restored StoredRelease + var restored VersionedRelease require.NoError(t, json.Unmarshal(data, &restored)) - rel, ok := restored.Releaser.(*v2release.Release) - require.True(t, ok, "v2 release must round-trip as *v2release.Release, got %T", restored.Releaser) + rel, ok := restored.Accessor.Releaser().(*v2release.Release) + require.True(t, ok, "v2 release must round-trip as *v2release.Release, got %T", restored.Accessor.Releaser()) assert.Equal(t, "myrelease", rel.Name) assert.Equal(t, 7, rel.Version) assert.Equal(t, "myns", rel.Namespace) @@ -98,8 +105,8 @@ func TestAI_StoredRelease_RoundTripV2PreservesNativeType(t *testing.T) { assert.Equal(t, v3chart.APIVersionV3, rel.Chart.Metadata.APIVersion) } -func TestAI_StoredRelease_UnknownVersionFails(t *testing.T) { - var restored StoredRelease +func TestAI_VersionedRelease_UnknownVersionFails(t *testing.T) { + var restored VersionedRelease err := json.Unmarshal([]byte(`{"version":"v9","release":{}}`), &restored) require.Error(t, err) assert.Contains(t, err.Error(), "unknown release version") From ec0af5b0ee1382045f09b68b762e8813a427f37b Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Wed, 17 Jun 2026 15:55:36 +0300 Subject: [PATCH 41/85] feat: move repo commands from root to chart repo (#640) Signed-off-by: Dmitry Mordvinov --- README.md | 10 +++---- cmd/nelm/chart.go | 1 + cmd/nelm/chart_repo.go | 28 +++++++++++++++++++ cmd/nelm/{repo_add.go => chart_repo_add.go} | 2 +- .../{repo_login.go => chart_repo_login.go} | 2 +- .../{repo_logout.go => chart_repo_logout.go} | 2 +- .../{repo_remove.go => chart_repo_remove.go} | 2 +- .../{repo_update.go => chart_repo_update.go} | 2 +- cmd/nelm/repo.go | 28 ------------------- cmd/nelm/root.go | 1 - 10 files changed, 39 insertions(+), 39 deletions(-) create mode 100644 cmd/nelm/chart_repo.go rename cmd/nelm/{repo_add.go => chart_repo_add.go} (88%) rename cmd/nelm/{repo_login.go => chart_repo_login.go} (88%) rename cmd/nelm/{repo_logout.go => chart_repo_logout.go} (88%) rename cmd/nelm/{repo_remove.go => chart_repo_remove.go} (88%) rename cmd/nelm/{repo_update.go => chart_repo_update.go} (88%) delete mode 100644 cmd/nelm/repo.go diff --git a/README.md b/README.md index 23d4394f..93b455b2 100644 --- a/README.md +++ b/README.md @@ -172,11 +172,11 @@ Dependency commands: chart dependency update Update Chart.lock and download chart dependencies. Repo commands: - repo add Set up a new chart repository. - repo remove Remove a chart repository. - repo update Update info about available charts for all chart repositories. - repo login Log in to an OCI registry with charts. - repo logout Log out from an OCI registry with charts. + chart repo add Set up a new chart repository. + chart repo remove Remove a chart repository. + chart repo update Update info about available charts for all chart repositories. + chart repo login Log in to an OCI registry with charts. + chart repo logout Log out from an OCI registry with charts. Other commands: completion bash Generate the autocompletion script for bash diff --git a/cmd/nelm/chart.go b/cmd/nelm/chart.go index 58786f4a..d8c1bc52 100644 --- a/cmd/nelm/chart.go +++ b/cmd/nelm/chart.go @@ -19,6 +19,7 @@ func newChartCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra. ) cmd.AddCommand(newChartRenderCommand(ctx, afterAllCommandsBuiltFuncs)) + cmd.AddCommand(newChartRepoCommand(ctx, afterAllCommandsBuiltFuncs)) cmd.AddCommand(newChartDependencyCommand(ctx, afterAllCommandsBuiltFuncs)) cmd.AddCommand(newChartDownloadCommand(ctx, afterAllCommandsBuiltFuncs)) cmd.AddCommand(newChartUploadCommand(ctx, afterAllCommandsBuiltFuncs)) diff --git a/cmd/nelm/chart_repo.go b/cmd/nelm/chart_repo.go new file mode 100644 index 00000000..da745267 --- /dev/null +++ b/cmd/nelm/chart_repo.go @@ -0,0 +1,28 @@ +package main + +import ( + "context" + + "github.com/spf13/cobra" + + "github.com/werf/common-go/pkg/cli" +) + +func newChartRepoCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra.Command]func(cmd *cobra.Command) error) *cobra.Command { + cmd := cli.NewGroupCommand( + ctx, + "repo", + "Manage chart repositories.", + "Manage chart repositories.", + repoCmdGroup, + cli.GroupCommandOptions{}, + ) + + cmd.AddCommand(newChartRepoAddCommand(ctx, afterAllCommandsBuiltFuncs)) + cmd.AddCommand(newChartRepoRemoveCommand(ctx, afterAllCommandsBuiltFuncs)) + cmd.AddCommand(newChartRepoUpdateCommand(ctx, afterAllCommandsBuiltFuncs)) + cmd.AddCommand(newChartRepoLoginCommand(ctx, afterAllCommandsBuiltFuncs)) + cmd.AddCommand(newChartRepoLogoutCommand(ctx, afterAllCommandsBuiltFuncs)) + + return cmd +} diff --git a/cmd/nelm/repo_add.go b/cmd/nelm/chart_repo_add.go similarity index 88% rename from cmd/nelm/repo_add.go rename to cmd/nelm/chart_repo_add.go index a9cdef8c..7fb65e60 100644 --- a/cmd/nelm/repo_add.go +++ b/cmd/nelm/chart_repo_add.go @@ -14,7 +14,7 @@ import ( "github.com/werf/nelm/pkg/log" ) -func newRepoAddCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra.Command]func(cmd *cobra.Command) error) *cobra.Command { +func newChartRepoAddCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra.Command]func(cmd *cobra.Command) error) *cobra.Command { repoCmd := lo.Must(lo.Find(helmRootCmd.Commands(), func(c *cobra.Command) bool { return strings.HasPrefix(c.Use, "repo") })) diff --git a/cmd/nelm/repo_login.go b/cmd/nelm/chart_repo_login.go similarity index 88% rename from cmd/nelm/repo_login.go rename to cmd/nelm/chart_repo_login.go index 67d25d38..7c67eaf0 100644 --- a/cmd/nelm/repo_login.go +++ b/cmd/nelm/chart_repo_login.go @@ -14,7 +14,7 @@ import ( "github.com/werf/nelm/pkg/log" ) -func newRepoLoginCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra.Command]func(cmd *cobra.Command) error) *cobra.Command { +func newChartRepoLoginCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra.Command]func(cmd *cobra.Command) error) *cobra.Command { registryCmd := lo.Must(lo.Find(helmRootCmd.Commands(), func(c *cobra.Command) bool { return strings.HasPrefix(c.Use, "registry") })) diff --git a/cmd/nelm/repo_logout.go b/cmd/nelm/chart_repo_logout.go similarity index 88% rename from cmd/nelm/repo_logout.go rename to cmd/nelm/chart_repo_logout.go index ced85ccc..4007ae16 100644 --- a/cmd/nelm/repo_logout.go +++ b/cmd/nelm/chart_repo_logout.go @@ -14,7 +14,7 @@ import ( "github.com/werf/nelm/pkg/log" ) -func newRepoLogoutCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra.Command]func(cmd *cobra.Command) error) *cobra.Command { +func newChartRepoLogoutCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra.Command]func(cmd *cobra.Command) error) *cobra.Command { registryCmd := lo.Must(lo.Find(helmRootCmd.Commands(), func(c *cobra.Command) bool { return strings.HasPrefix(c.Use, "registry") })) diff --git a/cmd/nelm/repo_remove.go b/cmd/nelm/chart_repo_remove.go similarity index 88% rename from cmd/nelm/repo_remove.go rename to cmd/nelm/chart_repo_remove.go index 025141e4..817fbd28 100644 --- a/cmd/nelm/repo_remove.go +++ b/cmd/nelm/chart_repo_remove.go @@ -14,7 +14,7 @@ import ( "github.com/werf/nelm/pkg/log" ) -func newRepoRemoveCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra.Command]func(cmd *cobra.Command) error) *cobra.Command { +func newChartRepoRemoveCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra.Command]func(cmd *cobra.Command) error) *cobra.Command { repoCmd := lo.Must(lo.Find(helmRootCmd.Commands(), func(c *cobra.Command) bool { return strings.HasPrefix(c.Use, "repo") })) diff --git a/cmd/nelm/repo_update.go b/cmd/nelm/chart_repo_update.go similarity index 88% rename from cmd/nelm/repo_update.go rename to cmd/nelm/chart_repo_update.go index f878644e..ac6eda33 100644 --- a/cmd/nelm/repo_update.go +++ b/cmd/nelm/chart_repo_update.go @@ -14,7 +14,7 @@ import ( "github.com/werf/nelm/pkg/log" ) -func newRepoUpdateCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra.Command]func(cmd *cobra.Command) error) *cobra.Command { +func newChartRepoUpdateCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra.Command]func(cmd *cobra.Command) error) *cobra.Command { repoCmd := lo.Must(lo.Find(helmRootCmd.Commands(), func(c *cobra.Command) bool { return strings.HasPrefix(c.Use, "repo") })) diff --git a/cmd/nelm/repo.go b/cmd/nelm/repo.go deleted file mode 100644 index a5ad95d7..00000000 --- a/cmd/nelm/repo.go +++ /dev/null @@ -1,28 +0,0 @@ -package main - -import ( - "context" - - "github.com/spf13/cobra" - - "github.com/werf/common-go/pkg/cli" -) - -func newRepoCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra.Command]func(cmd *cobra.Command) error) *cobra.Command { - cmd := cli.NewGroupCommand( - ctx, - "repo", - "Manage chart repositories.", - "Manage chart repositories.", - repoCmdGroup, - cli.GroupCommandOptions{}, - ) - - cmd.AddCommand(newRepoAddCommand(ctx, afterAllCommandsBuiltFuncs)) - cmd.AddCommand(newRepoRemoveCommand(ctx, afterAllCommandsBuiltFuncs)) - cmd.AddCommand(newRepoUpdateCommand(ctx, afterAllCommandsBuiltFuncs)) - cmd.AddCommand(newRepoLoginCommand(ctx, afterAllCommandsBuiltFuncs)) - cmd.AddCommand(newRepoLogoutCommand(ctx, afterAllCommandsBuiltFuncs)) - - return cmd -} diff --git a/cmd/nelm/root.go b/cmd/nelm/root.go index e8267f30..ecdffbd6 100644 --- a/cmd/nelm/root.go +++ b/cmd/nelm/root.go @@ -26,7 +26,6 @@ func NewRootCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobra.C cmd.AddCommand(newReleaseCommand(ctx, afterAllCommandsBuiltFuncs)) cmd.AddCommand(newChartCommand(ctx, afterAllCommandsBuiltFuncs)) - cmd.AddCommand(newRepoCommand(ctx, afterAllCommandsBuiltFuncs)) cmd.AddCommand(newVersionCommand(ctx, afterAllCommandsBuiltFuncs)) cmd.AddCommand(newGenerateReferenceCommand(ctx, afterAllCommandsBuiltFuncs)) From c22d5fd3c5dda5e2d387d63a9164ba50b231519d Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Wed, 17 Jun 2026 16:11:44 +0300 Subject: [PATCH 42/85] docs: enhance README with external dependency handling details for annotations (#639) Signed-off-by: Dmitry Mordvinov --- README.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 93b455b2..04f94109 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,8 @@ Generally, the migration from Helm to Nelm should be as simple as changing Helm The resource deployment subsystem of Helm is rewritten from scratch in Nelm. During the deployment, Nelm builds the Directed Acyclic Graph (DAG) of all operations we want to perform in the cluster to do the release, then the DAG is executed. The DAG allowed us to implement advanced resource ordering capabilities, such as: * The `werf.io/weight` annotation: similar to `helm.sh/hook-weight`, but also works for non-hook resources. Resources with the same weight deployed in parallel. -* The `werf.io/deploy-dependency-` annotation: do not deploy the annotated resource until the dependency is present or ready. This is the most powerful and effective way to enforce deployment order in Nelm. +* The `werf.io/deploy-dependency-` annotation: do not deploy the annotated resource until the dependency is present or ready. Works for both release resources and external cluster resources (e.g. resources created by a third-party operator). This is the most powerful and effective way to enforce deployment order in Nelm. +* The `werf.io/delete-dependency-` annotation: do not delete the annotated resource until the dependency is absent. Works for both release resources and external cluster resources. * Helm Hooks and their weights are supported, too. ![ordering](resources/images/graph.png) @@ -362,30 +363,36 @@ Default: ### `werf.io/deploy-dependency-` annotation -The resource will deploy only after all of its dependencies are satisfied. It waits until the specified resource is just `present` or is also `ready`. It serves as a more powerful alternative to hooks and `werf.io/weight`. You can only point to resources in the release. This annotation has higher priority than `werf.io/weight` and `helm.sh/hook-weight`. This annotation has no effect if the resource on which we depend upon is outside the stage (pre, main, post, ...) of the resource with the annotation. +The resource will deploy only after all of its dependencies are satisfied. It waits until the specified resource is just `present` or is also `ready`. It serves as a more powerful alternative to hooks and `werf.io/weight`. This annotation has higher priority than `werf.io/weight` and `helm.sh/hook-weight`. This annotation has no effect on internal (release) dependencies if the resource on which we depend upon is outside the stage (pre, main, post, ...) of the resource with the annotation. + +If `external=true` or `external=auto` and the dependency is not found in the release, then the dependency is treated as external to the release: `name`, `kind` and `version` must be specified. Example: ```yaml werf.io/deploy-dependency-db: state=ready,kind=StatefulSet,name=postgres werf.io/deploy-dependency-app: state=present,kind=Deployment,group=apps,version=v1,name=app,namespace=app +werf.io/deploy-dependency-secret: state=ready,kind=Secret,version=v1,name=my-vault-secret,external=true ``` Format: ``` -werf.io/deploy-dependency-: state=ready|present[,name=][,namespace=][,kind=][,group=][,version=] +werf.io/deploy-dependency-: state=ready|present[,name=][,namespace=][,kind=][,group=][,version=][,external=auto|true|false] ``` ### `werf.io/delete-dependency-` annotation -The resource will be deleted only after all of its dependencies are satisfied. It waits until the specified resource is `absent`. You can only point to resources in the release. This annotation has no effect if the resource on which we depend upon is outside the stage (pre, main, post, ...) of the resource with the annotation. +The resource will be deleted only after all of its dependencies are satisfied. It waits until the specified resource is `absent`. This annotation has no effect on internal (release) dependencies if the resource on which we depend upon is outside the stage (pre, main, post, ...) of the resource with the annotation. + +If `external=true` or `external=auto` and the dependency is not found in the release, then the dependency is treated as external to the release: `name`, `kind` and `version` must be specified. Example: ```yaml werf.io/delete-dependency-db: state=absent,kind=StatefulSet,name=postgres werf.io/delete-dependency-app: state=absent,kind=Deployment,group=apps,version=v1,name=app,namespace=app +werf.io/delete-dependency-secret: state=absent,kind=Secret,version=v1,name=my-vault-secret,external=true ``` Format: ``` -werf.io/delete-dependency-: state=absent[,name=][,namespace=][,kind=][,group=][,version=] +werf.io/delete-dependency-: state=absent[,name=][,namespace=][,kind=][,group=][,version=][,external=auto|true|false] ``` From 89f4783bb3dc1d2813da5925eef3a3b393796032 Mon Sep 17 00:00:00 2001 From: Ilya Lesikov Date: Wed, 8 Jul 2026 13:01:56 +0300 Subject: [PATCH 43/85] chore: add method to update release storage labels Signed-off-by: Ilya Lesikov --- pkg/helm/pkg/storage/driver/cfgmaps.go | 25 +++++++ pkg/helm/pkg/storage/driver/driver.go | 10 +++ pkg/helm/pkg/storage/driver/memory.go | 31 +++++++++ pkg/helm/pkg/storage/driver/memory_test.go | 29 ++++++++ pkg/helm/pkg/storage/driver/secrets.go | 25 +++++++ pkg/helm/pkg/storage/driver/secrets_test.go | 34 ++++++++++ pkg/helm/pkg/storage/driver/sql.go | 73 +++++++++++++++++++++ pkg/helm/pkg/storage/driver/sql_test.go | 55 ++++++++++++++++ pkg/helm/pkg/storage/storage.go | 8 +++ pkg/helm/pkg/storage/storage_test.go | 3 + pkg/release/release_storage.go | 9 +++ 11 files changed, 302 insertions(+) diff --git a/pkg/helm/pkg/storage/driver/cfgmaps.go b/pkg/helm/pkg/storage/driver/cfgmaps.go index bc930bc8..cb323dfb 100644 --- a/pkg/helm/pkg/storage/driver/cfgmaps.go +++ b/pkg/helm/pkg/storage/driver/cfgmaps.go @@ -230,6 +230,31 @@ func (cfgmaps *ConfigMaps) Update(key string, rel release.Releaser) error { return nil } +// UpdateLabels merges the given custom labels into the ConfigMap holding the +// release named by key without creating a new revision. System labels in the +// map are ignored to avoid corrupting release metadata. +func (cfgmaps *ConfigMaps) UpdateLabels(key string, lbls map[string]string) error { + obj, err := cfgmaps.impl.Get(context.Background(), key, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + return ErrReleaseNotFound + } + return fmt.Errorf("update labels: failed to get %q: %w", key, err) + } + + if obj.Labels == nil { + obj.Labels = map[string]string{} + } + for k, v := range filterSystemLabels(lbls) { + obj.Labels[k] = v + } + + if _, err := cfgmaps.impl.Update(context.Background(), obj, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("update labels: failed to update %q: %w", key, err) + } + return nil +} + // Delete deletes the ConfigMap holding the release named by key. func (cfgmaps *ConfigMaps) Delete(key string) (rls release.Releaser, err error) { // fetch the release to check existence diff --git a/pkg/helm/pkg/storage/driver/driver.go b/pkg/helm/pkg/storage/driver/driver.go index 0dc8a0ac..41171105 100644 --- a/pkg/helm/pkg/storage/driver/driver.go +++ b/pkg/helm/pkg/storage/driver/driver.go @@ -92,6 +92,15 @@ type Queryor interface { Query(labels map[string]string) ([]release.Releaser, error) } +// LabelUpdator is the interface that wraps the UpdateLabels method. +// +// UpdateLabels merges the given custom labels into the stored release named by +// key without creating a new revision, or returns ErrReleaseNotFound if the +// release does not exist. Existing labels not present in the map are preserved. +type LabelUpdator interface { + UpdateLabels(key string, labels map[string]string) error +} + // Driver is the interface composed of Creator, Updator, Deletor, and Queryor // interfaces. It defines the behavior for storing, updating, deleted, // and retrieving Helm releases from some underlying storage mechanism, @@ -101,6 +110,7 @@ type Driver interface { Updator Deletor Queryor + LabelUpdator Name() string } diff --git a/pkg/helm/pkg/storage/driver/memory.go b/pkg/helm/pkg/storage/driver/memory.go index 586771e1..a2927603 100644 --- a/pkg/helm/pkg/storage/driver/memory.go +++ b/pkg/helm/pkg/storage/driver/memory.go @@ -210,6 +210,37 @@ func (mem *Memory) Update(key string, rel release.Releaser) error { return ErrReleaseNotFound } +// UpdateLabels merges the given custom labels into the stored release named by +// key without creating a new revision. Returns ErrReleaseNotFound if the +// release does not exist. +func (mem *Memory) UpdateLabels(key string, lbls map[string]string) error { + defer unlock(mem.wlock()) + + keyWithoutPrefix := strings.TrimPrefix(key, "sh.helm.release.v1.") + elems := strings.Split(keyWithoutPrefix, ".v") + if len(elems) != 2 { + return ErrInvalidKey + } + name := elems[0] + + recs, ok := mem.cache[mem.namespace][name] + if !ok { + return ErrReleaseNotFound + } + r := recs.Get(key) + if r == nil { + return ErrReleaseNotFound + } + + if r.rls.Labels == nil { + r.rls.Labels = map[string]string{} + } + for k, v := range filterSystemLabels(lbls) { + r.rls.Labels[k] = v + } + return nil +} + // Delete deletes a release or returns ErrReleaseNotFound. func (mem *Memory) Delete(key string) (release.Releaser, error) { defer unlock(mem.wlock()) diff --git a/pkg/helm/pkg/storage/driver/memory_test.go b/pkg/helm/pkg/storage/driver/memory_test.go index 1dbdcce1..d7073c60 100644 --- a/pkg/helm/pkg/storage/driver/memory_test.go +++ b/pkg/helm/pkg/storage/driver/memory_test.go @@ -238,6 +238,35 @@ func TestMemoryUpdate(t *testing.T) { } } +func TestMemoryUpdateLabels(t *testing.T) { + ts := tsFixtureMemory(t) + ts.SetNamespace("default") + + key := testKey("rls-a", 4) + if err := ts.UpdateLabels(key, map[string]string{"owned-by": "operator", "key1": "changed"}); err != nil { + t.Fatalf("Failed to update labels: %s\n", err) + } + + r, err := ts.Get(key) + if err != nil { + t.Fatalf("Failed to get: %s\n", err) + } + rls := r.(*rspb.Release) + if rls.Labels["owned-by"] != "operator" { + t.Fatalf("Expected merged label owned-by=operator, got %q\n", rls.Labels["owned-by"]) + } + if rls.Labels["key1"] != "changed" { + t.Fatalf("Expected overwritten label key1=changed, got %q\n", rls.Labels["key1"]) + } + if rls.Labels["key2"] != "val2" { + t.Fatalf("Expected preserved label key2=val2, got %q\n", rls.Labels["key2"]) + } + + if err := ts.UpdateLabels(testKey("rls-missing", 1), map[string]string{"owned-by": "operator"}); err != ErrReleaseNotFound { + t.Fatalf("Expected ErrReleaseNotFound, got %v\n", err) + } +} + func TestMemoryDelete(t *testing.T) { var tests = []struct { desc string diff --git a/pkg/helm/pkg/storage/driver/secrets.go b/pkg/helm/pkg/storage/driver/secrets.go index fc7d8f1b..46201b16 100644 --- a/pkg/helm/pkg/storage/driver/secrets.go +++ b/pkg/helm/pkg/storage/driver/secrets.go @@ -217,6 +217,31 @@ func (secrets *Secrets) Update(key string, rel release.Releaser) error { return nil } +// UpdateLabels merges the given custom labels into the Secret holding the +// release named by key without creating a new revision. System labels in the +// map are ignored to avoid corrupting release metadata. +func (secrets *Secrets) UpdateLabels(key string, lbls map[string]string) error { + obj, err := secrets.impl.Get(context.Background(), key, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + return ErrReleaseNotFound + } + return fmt.Errorf("update labels: failed to get %q: %w", key, err) + } + + if obj.Labels == nil { + obj.Labels = map[string]string{} + } + for k, v := range filterSystemLabels(lbls) { + obj.Labels[k] = v + } + + if _, err := secrets.impl.Update(context.Background(), obj, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("update labels: failed to update %q: %w", key, err) + } + return nil +} + // Delete deletes the Secret holding the release named by key. func (secrets *Secrets) Delete(key string) (rls release.Releaser, err error) { // fetch the release to check existence diff --git a/pkg/helm/pkg/storage/driver/secrets_test.go b/pkg/helm/pkg/storage/driver/secrets_test.go index 92ef233c..a9fd3179 100644 --- a/pkg/helm/pkg/storage/driver/secrets_test.go +++ b/pkg/helm/pkg/storage/driver/secrets_test.go @@ -226,6 +226,40 @@ func TestSecretUpdate(t *testing.T) { } } +func TestSecretUpdateLabels(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) + + secrets := newTestFixtureSecrets(t, []*rspb.Release{rel}...) + + if err := secrets.UpdateLabels(key, map[string]string{"owned-by": "operator", "key1": "changed"}); err != nil { + t.Fatalf("Failed to update labels: %s", err) + } + + goti, err := secrets.Get(key) + if err != nil { + t.Fatalf("Failed to get release with key %q: %s", key, err) + } + got := convertReleaserToV1(t, goti) + + if got.Labels["owned-by"] != "operator" { + t.Errorf("Expected merged label owned-by=operator, got %q", got.Labels["owned-by"]) + } + if got.Labels["key1"] != "changed" { + t.Errorf("Expected overwritten label key1=changed, got %q", got.Labels["key1"]) + } + if got.Labels["key2"] != "val2" { + t.Errorf("Expected preserved label key2=val2, got %q", got.Labels["key2"]) + } + + if err := secrets.UpdateLabels("nonexistent", map[string]string{"owned-by": "operator"}); !errors.Is(err, ErrReleaseNotFound) { + t.Fatalf("Expected ErrReleaseNotFound, got %v", err) + } +} + func TestSecretDelete(t *testing.T) { vers := 1 name := "smug-pigeon" diff --git a/pkg/helm/pkg/storage/driver/sql.go b/pkg/helm/pkg/storage/driver/sql.go index 5e73a756..68b522cf 100644 --- a/pkg/helm/pkg/storage/driver/sql.go +++ b/pkg/helm/pkg/storage/driver/sql.go @@ -618,6 +618,79 @@ func (s *SQL) Update(key string, rel release.Releaser) error { return nil } +// UpdateLabels merges the given custom labels into the release named by key +// without creating a new revision, by upserting rows in the custom labels +// table. System labels in the map are ignored to avoid corrupting release +// metadata. Returns ErrReleaseNotFound if the release does not exist. +func (s *SQL) UpdateLabels(key string, lbls map[string]string) error { + selectQuery, selectArgs, err := s.statementBuilder. + Select(sqlReleaseTableKeyColumn). + From(sqlReleaseTableName). + Where(sq.Eq{sqlReleaseTableKeyColumn: key}). + Where(sq.Eq{sqlReleaseTableNamespaceColumn: s.namespace}). + ToSql() + if err != nil { + s.Logger().Debug("failed to build select query", slog.Any("error", err)) + return err + } + + transaction, err := s.db.Beginx() + if err != nil { + s.Logger().Debug("failed to start SQL transaction", slog.Any("error", err)) + return fmt.Errorf("error beginning transaction: %v", err) + } + + var existingKey string + if err := transaction.Get(&existingKey, selectQuery, selectArgs...); err != nil { + transaction.Rollback() + return ErrReleaseNotFound + } + + for k, v := range filterSystemLabels(lbls) { + deleteQuery, deleteArgs, err := s.statementBuilder. + Delete(sqlCustomLabelsTableName). + Where(sq.Eq{ + sqlCustomLabelsTableReleaseKeyColumn: key, + sqlCustomLabelsTableReleaseNamespaceColumn: s.namespace, + sqlCustomLabelsTableKeyColumn: k, + }). + ToSql() + if err != nil { + transaction.Rollback() + s.Logger().Debug("failed to build delete labels query", slog.Any("error", err)) + return err + } + if _, err := transaction.Exec(deleteQuery, deleteArgs...); err != nil { + transaction.Rollback() + s.Logger().Debug("failed to delete existing label", slog.Any("error", err)) + return err + } + + insertQuery, insertArgs, err := s.statementBuilder. + Insert(sqlCustomLabelsTableName). + Columns( + sqlCustomLabelsTableReleaseKeyColumn, + sqlCustomLabelsTableReleaseNamespaceColumn, + sqlCustomLabelsTableKeyColumn, + sqlCustomLabelsTableValueColumn, + ). + Values(key, s.namespace, k, v). + ToSql() + if err != nil { + transaction.Rollback() + s.Logger().Debug("failed to build insert labels query", slog.Any("error", err)) + return err + } + if _, err := transaction.Exec(insertQuery, insertArgs...); err != nil { + transaction.Rollback() + s.Logger().Debug("failed to insert label", slog.Any("error", err)) + return err + } + } + + return transaction.Commit() +} + // Delete deletes a release or returns ErrReleaseNotFound. func (s *SQL) Delete(key string) (release.Releaser, error) { transaction, err := s.db.Beginx() diff --git a/pkg/helm/pkg/storage/driver/sql_test.go b/pkg/helm/pkg/storage/driver/sql_test.go index b6a0a12a..22991ef5 100644 --- a/pkg/helm/pkg/storage/driver/sql_test.go +++ b/pkg/helm/pkg/storage/driver/sql_test.go @@ -359,6 +359,61 @@ func TestSqlUpdate(t *testing.T) { } } +func TestSqlUpdateLabels(t *testing.T) { + vers := 1 + name := "smug-pigeon" + namespace := "default" + key := testKey(name, vers) + + sqlDriver, mock := newTestFixtureSQL(t) + + selectQuery := fmt.Sprintf( + "SELECT %s FROM %s WHERE %s = $1 AND %s = $2", + sqlReleaseTableKeyColumn, + sqlReleaseTableName, + sqlReleaseTableKeyColumn, + sqlReleaseTableNamespaceColumn, + ) + deleteQuery := fmt.Sprintf( + "DELETE FROM %s WHERE %s = $1 AND %s = $2 AND %s = $3", + sqlCustomLabelsTableName, + sqlCustomLabelsTableKeyColumn, + sqlCustomLabelsTableReleaseKeyColumn, + sqlCustomLabelsTableReleaseNamespaceColumn, + ) + insertQuery := fmt.Sprintf( + "INSERT INTO %s (%s,%s,%s,%s) VALUES ($1,$2,$3,$4)", + sqlCustomLabelsTableName, + sqlCustomLabelsTableReleaseKeyColumn, + sqlCustomLabelsTableReleaseNamespaceColumn, + sqlCustomLabelsTableKeyColumn, + sqlCustomLabelsTableValueColumn, + ) + + mock.ExpectBegin() + mock. + ExpectQuery(regexp.QuoteMeta(selectQuery)). + WithArgs(key, namespace). + WillReturnRows(sqlmock.NewRows([]string{sqlReleaseTableKeyColumn}).AddRow(key)) + mock. + ExpectExec(regexp.QuoteMeta(deleteQuery)). + WithArgs("owned-by", key, namespace). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock. + ExpectExec(regexp.QuoteMeta(insertQuery)). + WithArgs(key, namespace, "owned-by", "operator"). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + if err := sqlDriver.UpdateLabels(key, map[string]string{"owned-by": "operator"}); err != nil { + t.Fatalf("failed to update labels with key %s: %v", key, err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sql expectations weren't met: %v", err) + } +} + func TestSqlQuery(t *testing.T) { // Reflect actual use cases in ../storage.go labelSetUnknown := map[string]string{ diff --git a/pkg/helm/pkg/storage/storage.go b/pkg/helm/pkg/storage/storage.go index e089cb81..7410a4a2 100644 --- a/pkg/helm/pkg/storage/storage.go +++ b/pkg/helm/pkg/storage/storage.go @@ -89,6 +89,14 @@ func (s *Storage) Update(rls release.Releaser) error { return s.Driver.Update(makeKey(rac.Name(), rac.Version()), rls) } +// UpdateLabels merges the given custom labels into the stored release identified +// by name and version without creating a new revision. An error is returned if +// the storage backend fails or the release does not exist. +func (s *Storage) UpdateLabels(name string, version int, labels map[string]string) error { + s.Logger().Debug("updating release labels", "key", makeKey(name, version)) + return s.Driver.UpdateLabels(makeKey(name, version), labels) +} + // Delete deletes the release from storage. An error is returned if // the storage backend fails to delete the release or if the release // does not exist. diff --git a/pkg/helm/pkg/storage/storage_test.go b/pkg/helm/pkg/storage/storage_test.go index 23781c5c..2efd6389 100644 --- a/pkg/helm/pkg/storage/storage_test.go +++ b/pkg/helm/pkg/storage/storage_test.go @@ -307,6 +307,9 @@ func (d *MaxHistoryMockDriver) Create(key string, rls release.Releaser) error { func (d *MaxHistoryMockDriver) Update(key string, rls release.Releaser) error { return d.Driver.Update(key, rls) } +func (d *MaxHistoryMockDriver) UpdateLabels(key string, labels map[string]string) error { + return d.Driver.UpdateLabels(key, labels) +} func (d *MaxHistoryMockDriver) Delete(_ string) (release.Releaser, error) { return nil, errMaxHistoryMockDriverSomethingHappened } diff --git a/pkg/release/release_storage.go b/pkg/release/release_storage.go index cb67a0e9..7951e1ed 100644 --- a/pkg/release/release_storage.go +++ b/pkg/release/release_storage.go @@ -26,6 +26,7 @@ var _ ReleaseStorager = (*storageAdapter)(nil) type ReleaseStorager interface { Create(rls helmrel.Accessor) error Update(rls helmrel.Accessor) error + UpdateLabels(name string, version int, labels map[string]string) error Delete(name string, version int) (helmrel.Accessor, error) Query(labels map[string]string) ([]helmrel.Accessor, error) } @@ -99,6 +100,14 @@ func (a *storageAdapter) Update(rls helmrel.Accessor) error { return nil } +func (a *storageAdapter) UpdateLabels(name string, version int, labels map[string]string) error { + if err := a.storage.UpdateLabels(name, version, labels); err != nil { + return fmt.Errorf("update release labels: %w", err) + } + + return nil +} + type ReleaseStorageOptions struct { HistoryLimit int SQLConnection string From 4acd2902631bdd17c261d45b57e896f00b595c19 Mon Sep 17 00:00:00 2001 From: Ilya Lesikov Date: Wed, 8 Jul 2026 17:19:39 +0300 Subject: [PATCH 44/85] chore: reformat Signed-off-by: Ilya Lesikov --- README.md | 3 +- docs/reference.md | 1005 ++++++++++++----- .../issue-7233/charts/alpine-0.1.0.tgz | Bin 1166 -> 1166 bytes 3 files changed, 702 insertions(+), 306 deletions(-) diff --git a/README.md b/README.md index 04f94109..9b053651 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Nelm is production-ready: as the werf deployment engine, it was battle-tested ac - [Advanced resource lifecycle capabilities](#advanced-resource-lifecycle-capabilities) - [Resource state tracking](#resource-state-tracking) - [Printing logs and events during deploy](#printing-logs-and-events-during-deploy) - - [Release planning and two-stage deployment support](#release-planning-and-two-stage-deployment-workflow-support) + - [Release planning and two-stage deployment workflow support](#release-planning-and-two-stage-deployment-workflow-support) - [Encrypted values and encrypted files](#encrypted-values-and-encrypted-files) - [Improved CRD management](#improved-crd-management) - [Usage](#usage) @@ -45,7 +45,6 @@ Nelm is production-ready: as the werf deployment engine, it was battle-tested ac - [`werf.io/weight` annotation](#werfioweight-annotation) - [`werf.io/deploy-dependency-` annotation](#werfiodeploy-dependency-id-annotation) - [`werf.io/delete-dependency-` annotation](#werfiodelete-dependency-id-annotation) - - [`werf.io/ownership` annotation](#werfioownership-annotation) - [`werf.io/deploy-on` annotation](#werfiodeploy-on-annotation) - [`werf.io/delete-policy` annotation](#werfiodelete-policy-annotation) diff --git a/docs/reference.md b/docs/reference.md index f8ac59c9..858b5987 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -6,16 +6,16 @@ - [Chart commands](#chart-commands) - [Secret commands](#secret-commands) - [Dependency commands](#dependency-commands) - - [TypeScript commands](#typescript-commands) - [Repo commands](#repo-commands) + - [TypeScript commands](#typescript-commands) - [Other commands](#other-commands) - [Commands](#commands) - [release install](#release-install) - [release rollback](#release-rollback) - [release plan install](#release-plan-install) - [release uninstall](#release-uninstall) - - [release list](#release-list) - [release history](#release-history) + - [release list](#release-list) - [release get](#release-get) - [release plan show](#release-plan-show) - [chart lint](#chart-lint) @@ -33,26 +33,20 @@ - [chart secret file decrypt](#chart-secret-file-decrypt) - [chart dependency download](#chart-dependency-download) - [chart dependency update](#chart-dependency-update) + - [chart repo add](#chart-repo-add) + - [chart repo remove](#chart-repo-remove) + - [chart repo update](#chart-repo-update) + - [chart repo login](#chart-repo-login) + - [chart repo logout](#chart-repo-logout) - [chart ts init](#chart-ts-init) - [chart ts build](#chart-ts-build) - - [repo add](#repo-add) - - [repo remove](#repo-remove) - - [repo update](#repo-update) - - [repo login](#repo-login) - - [repo logout](#repo-logout) - [completion bash](#completion-bash) - [completion fish](#completion-fish) - [completion powershell](#completion-powershell) - [completion zsh](#completion-zsh) - [version](#version) - [Feature Gates](#feature-gates) - - [NELM_FEAT_REMOTE_CHARTS](#nelm_feat_remote_charts) - [NELM_FEAT_PERIODIC_STACK_TRACES](#nelm_feat_periodic_stack_traces) - - [NELM_FEAT_FIELD_SENSITIVE](#nelm_feat_field_sensitive) - - [NELM_FEAT_PREVIEW_V2](#nelm_feat_preview_v2) - - [NELM_FEAT_CLEAN_NULL_FIELDS](#nelm_feat_clean_null_fields) - - [NELM_FEAT_MORE_DETAILED_EXIT_CODE_FOR_PLAN](#nelm_feat_more_detailed_exit_code_for_plan) - - [NELM_FEAT_RESOURCE_VALIDATION](#nelm_feat_resource_validation) - [NELM_FEAT_TYPESCRIPT](#nelm_feat_typescript) @@ -65,8 +59,8 @@ - [`nelm release rollback`](#release-rollback) — Rollback to a previously deployed release\. - [`nelm release plan install`](#release-plan-install) — Plan a release install to Kubernetes\. - [`nelm release uninstall`](#release-uninstall) — Uninstall a Helm Release from Kubernetes\. -- [`nelm release list`](#release-list) — List all deployed releases\. - [`nelm release history`](#release-history) — Show release history\. +- [`nelm release list`](#release-list) — List all deployed releases\. - [`nelm release get`](#release-get) — Get information about a deployed release\. - [`nelm release plan show`](#release-plan-show) — Show plan artifact planned changes\. @@ -94,19 +88,19 @@ - [`nelm chart dependency download`](#chart-dependency-download) — Download chart dependencies from Chart\.lock\. - [`nelm chart dependency update`](#chart-dependency-update) — Update Chart\.lock and download chart dependencies\. +### Repo commands + +- [`nelm chart repo add`](#chart-repo-add) — Set up a new chart repository\. +- [`nelm chart repo remove`](#chart-repo-remove) — Remove a chart repository\. +- [`nelm chart repo update`](#chart-repo-update) — Update info about available charts for all chart repositories\. +- [`nelm chart repo login`](#chart-repo-login) — Log in to an OCI registry with charts\. +- [`nelm chart repo logout`](#chart-repo-logout) — Log out from an OCI registry with charts\. + ### TypeScript commands - [`nelm chart ts init`](#chart-ts-init) — Initialize the files needed to render manifests using TypeScript\. - [`nelm chart ts build`](#chart-ts-build) — Build TypeScript chart\. -### Repo commands - -- [`nelm repo add`](#repo-add) — Set up a new chart repository\. -- [`nelm repo remove`](#repo-remove) — Remove a chart repository\. -- [`nelm repo update`](#repo-update) — Update info about available charts for all chart repositories\. -- [`nelm repo login`](#repo-login) — Log in to an OCI registry with charts\. -- [`nelm repo logout`](#repo-logout) — Log out from an OCI registry with charts\. - ### Other commands - [`nelm completion bash`](#completion-bash) — Generate the autocompletion script for bash @@ -124,7 +118,7 @@ Deploy a chart to Kubernetes\. **Usage:** ```shell -nelm release install [options...] -n namespace -r release [chart-dir] +nelm release install [options...] -n namespace -r release [chart-dir|chart-repo-name/chart-name|chart-archive|chart-archive-url] ``` **Options:** @@ -133,7 +127,11 @@ nelm release install [options...] -n namespace -r release [chart-dir] Automatically rollback the release on failure\. Var: \$NELM\_RELEASE\_INSTALL\_AUTO\_ROLLBACK -- `--delete-propagation` (default: `"Foreground"`) +- `--chart-version` (default: `""`) + + Choose a remote chart version, otherwise the latest version is used\. Vars: \$NELM\_CHART\_VERSION, \$NELM\_RELEASE\_INSTALL\_CHART\_VERSION + +- `--delete-propagation` (default: `"Background"`) Default delete propagation strategy\. Vars: \$NELM\_DELETE\_PROPAGATION, \$NELM\_RELEASE\_INSTALL\_DELETE\_PROPAGATION @@ -232,10 +230,6 @@ nelm release install [options...] -n namespace -r release [chart-dir] Set new keys in the global context \(\$\), where the key is the value path and the value is JSON\. This is meant to be generated inside the program, so use \-\-set\-json instead, unless you know what you are doing\. Vars: \$NELM\_SET\_ROOT\_JSON, \$NELM\_RELEASE\_INSTALL\_SET\_ROOT\_JSON -- `--set-runtime-json` (default: `[]`) - - Set new keys in \$\.Runtime, where the key is the value path and the value is JSON\. This is meant to be generated inside the program, so use \-\-set\-json instead, unless you know what you are doing\. Vars: \$NELM\_SET\_RUNTIME\_JSON, \$NELM\_RELEASE\_INSTALL\_SET\_RUNTIME\_JSON - - `--set-string` (default: `[]`) Set new values, where the key is the value path and the value is the value\. The value will always become a string\. Vars: \$NELM\_SET\_STRING, \$NELM\_RELEASE\_INSTALL\_SET\_STRING @@ -264,6 +258,41 @@ nelm release install [options...] -n namespace -r release [chart-dir] Secret values files paths\. Vars: \$NELM\_SECRET\_VALUES, \$NELM\_RELEASE\_INSTALL\_SECRET\_VALUES +**Resource validation options:** + +- `--local-resource-validation` (default: `false`) + + Do not use external json schema sources\. Vars: \$NELM\_LOCAL\_RESOURCE\_VALIDATION, \$NELM\_RELEASE\_INSTALL\_LOCAL\_RESOURCE\_VALIDATION + +- `--no-resource-validation` (default: `false`) + + Disable resource validation\. Vars: \$NELM\_NO\_RESOURCE\_VALIDATION, \$NELM\_RELEASE\_INSTALL\_NO\_RESOURCE\_VALIDATION + +- `--no-values-schema-validation` (default: `false`) + + Disable values validation against JSON schema\. Vars: \$NELM\_NO\_VALUES\_SCHEMA\_VALIDATION, \$NELM\_RELEASE\_INSTALL\_NO\_VALUES\_SCHEMA\_VALIDATION + +- `--resource-validation-cache-lifetime` (default: `48h0m0s`) + + How long local schema cache will be valid\. Vars: \$NELM\_RESOURCE\_VALIDATION\_CACHE\_LIFETIME, \$NELM\_RELEASE\_INSTALL\_RESOURCE\_VALIDATION\_CACHE\_LIFETIME + +- `--resource-validation-extra-schema` (default: `[]`) + + Extra json schema sources to validate resources \(preferred over default sources\)\. Must be a valid go template defining a http\(s\) URL, or an absolute path on local file system\. Vars: \$NELM\_RESOURCE\_VALIDATION\_EXTRA\_SCHEMA\_\*, \$NELM\_RELEASE\_INSTALL\_RESOURCE\_VALIDATION\_EXTRA\_SCHEMA\_\* + +- `--resource-validation-kube-version` (default: `"1.35.0"`) + + Kubernetes schemas version to use during resource validation\. Vars: \$NELM\_RESOURCE\_VALIDATION\_KUBE\_VERSION, \$NELM\_RELEASE\_INSTALL\_RESOURCE\_VALIDATION\_KUBE\_VERSION + +- `--resource-validation-schema` (default: `[https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/{{ .NormalizedKubernetesVersion }}-standalone{{ .StrictSuffix }}/{{ .ResourceKind }}{{ .KindSuffix }}.json,https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json]`) + + Default json schema sources to validate resources\. Must be a valid go template defining a http\(s\) URL, or an absolute path on local file system\. Vars: \$NELM\_RESOURCE\_VALIDATION\_SCHEMA\_\*, \$NELM\_RELEASE\_INSTALL\_RESOURCE\_VALIDATION\_SCHEMA\_\* + +- `--resource-validation-skip` (default: `[]`) + + Skip resource validation for resources with specified attributes\. Format: key1=value1,key2=value2\. Supported keys: group, version, kind, name, namespace\. Example: kind=Deployment,name=my\-app\. Vars: \$NELM\_RESOURCE\_VALIDATION\_SKIP, \$NELM\_RELEASE\_INSTALL\_RESOURCE\_VALIDATION\_SKIP + + **Patch options:** - `--annotations` (default: `{}`) @@ -363,6 +392,10 @@ nelm release install [options...] -n namespace -r release [chart-dir] Set URL of chart repo to be used to look for chart\. Vars: \$NELM\_CHART\_REPO\_URL, \$NELM\_RELEASE\_INSTALL\_CHART\_REPO\_URL +- `--docker-config` (default: `"~/.docker"`) + + Docker config directory path\. Vars: \$DOCKER\_CONFIG, \$NELM\_DOCKER\_CONFIG, \$NELM\_RELEASE\_INSTALL\_DOCKER\_CONFIG + - `--insecure-chart-repos` (default: `false`) Allow insecure HTTP connections to chart repository\. Vars: \$NELM\_INSECURE\_CHART\_REPOS, \$NELM\_RELEASE\_INSTALL\_INSECURE\_CHART\_REPOS @@ -375,7 +408,7 @@ nelm release install [options...] -n namespace -r release [chart-dir] Don't verify TLS certificates of chart repository\. Vars: \$NELM\_NO\_VERIFY\_CHART\_REPOS\_TLS, \$NELM\_RELEASE\_INSTALL\_NO\_VERIFY\_CHART\_REPOS\_TLS -- `--oci-chart-repos-creds` (default: `"~/.docker/config.json"`) +- `--oci-chart-repos-creds` (default: `""`) Credentials to access OCI chart repositories\. Vars: \$NELM\_OCI\_CHART\_REPOS\_CREDS, \$NELM\_RELEASE\_INSTALL\_OCI\_CHART\_REPOS\_CREDS @@ -537,7 +570,7 @@ nelm release rollback [options...] -n namespace -r release [revision] **Options:** -- `--delete-propagation` (default: `"Foreground"`) +- `--delete-propagation` (default: `"Background"`) Default delete propagation strategy\. Vars: \$NELM\_DELETE\_PROPAGATION, \$NELM\_RELEASE\_ROLLBACK\_DELETE\_PROPAGATION @@ -582,6 +615,41 @@ nelm release rollback [options...] -n namespace -r release [revision] Fail if not finished in time\. Vars: \$NELM\_TIMEOUT, \$NELM\_RELEASE\_ROLLBACK\_TIMEOUT +**Resource validation options:** + +- `--local-resource-validation` (default: `false`) + + Do not use external json schema sources\. Vars: \$NELM\_LOCAL\_RESOURCE\_VALIDATION, \$NELM\_RELEASE\_ROLLBACK\_LOCAL\_RESOURCE\_VALIDATION + +- `--no-resource-validation` (default: `false`) + + Disable resource validation\. Vars: \$NELM\_NO\_RESOURCE\_VALIDATION, \$NELM\_RELEASE\_ROLLBACK\_NO\_RESOURCE\_VALIDATION + +- `--no-values-schema-validation` (default: `false`) + + Disable values validation against JSON schema\. Vars: \$NELM\_NO\_VALUES\_SCHEMA\_VALIDATION, \$NELM\_RELEASE\_ROLLBACK\_NO\_VALUES\_SCHEMA\_VALIDATION + +- `--resource-validation-cache-lifetime` (default: `48h0m0s`) + + How long local schema cache will be valid\. Vars: \$NELM\_RESOURCE\_VALIDATION\_CACHE\_LIFETIME, \$NELM\_RELEASE\_ROLLBACK\_RESOURCE\_VALIDATION\_CACHE\_LIFETIME + +- `--resource-validation-extra-schema` (default: `[]`) + + Extra json schema sources to validate resources \(preferred over default sources\)\. Must be a valid go template defining a http\(s\) URL, or an absolute path on local file system\. Vars: \$NELM\_RESOURCE\_VALIDATION\_EXTRA\_SCHEMA\_\*, \$NELM\_RELEASE\_ROLLBACK\_RESOURCE\_VALIDATION\_EXTRA\_SCHEMA\_\* + +- `--resource-validation-kube-version` (default: `"1.35.0"`) + + Kubernetes schemas version to use during resource validation\. Vars: \$NELM\_RESOURCE\_VALIDATION\_KUBE\_VERSION, \$NELM\_RELEASE\_ROLLBACK\_RESOURCE\_VALIDATION\_KUBE\_VERSION + +- `--resource-validation-schema` (default: `[https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/{{ .NormalizedKubernetesVersion }}-standalone{{ .StrictSuffix }}/{{ .ResourceKind }}{{ .KindSuffix }}.json,https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json]`) + + Default json schema sources to validate resources\. Must be a valid go template defining a http\(s\) URL, or an absolute path on local file system\. Vars: \$NELM\_RESOURCE\_VALIDATION\_SCHEMA\_\*, \$NELM\_RELEASE\_ROLLBACK\_RESOURCE\_VALIDATION\_SCHEMA\_\* + +- `--resource-validation-skip` (default: `[]`) + + Skip resource validation for resources with specified attributes\. Format: key1=value1,key2=value2\. Supported keys: group, version, kind, name, namespace\. Example: kind=Deployment,name=my\-app\. Vars: \$NELM\_RESOURCE\_VALIDATION\_SKIP, \$NELM\_RELEASE\_ROLLBACK\_RESOURCE\_VALIDATION\_SKIP + + **Patch options:** - `--runtime-annotations` (default: `{}`) @@ -776,12 +844,16 @@ Plan a release install to Kubernetes\. **Usage:** ```shell -nelm release plan install [options...] -n namespace -r release [chart-dir] +nelm release plan install [options...] -n namespace -r release [chart-dir|chart-repo-name/chart-name|chart-archive|chart-archive-url] ``` **Options:** -- `--delete-propagation` (default: `"Foreground"`) +- `--chart-version` (default: `""`) + + Choose a remote chart version, otherwise the latest version is used\. Vars: \$NELM\_CHART\_VERSION, \$NELM\_RELEASE\_PLAN\_INSTALL\_CHART\_VERSION + +- `--delete-propagation` (default: `"Background"`) Default delete propagation strategy\. Vars: \$NELM\_DELETE\_PROPAGATION, \$NELM\_RELEASE\_PLAN\_INSTALL\_DELETE\_PROPAGATION @@ -791,7 +863,7 @@ nelm release plan install [options...] -n namespace -r release [chart-dir] - `--exit-code` (default: `false`) - Return exit code 0 if no changes, 1 if error, 2 if any changes planned\. Var: \$NELM\_RELEASE\_PLAN\_INSTALL\_EXIT\_CODE + Return exit code 0 if no changes, 1 if error, 2 if resource changes planned, 3 if no resource changes planned, but release still should be installed\. Var: \$NELM\_RELEASE\_PLAN\_INSTALL\_EXIT\_CODE - `--force-adoption` (default: `false`) @@ -884,10 +956,6 @@ nelm release plan install [options...] -n namespace -r release [chart-dir] Set new keys in the global context \(\$\), where the key is the value path and the value is JSON\. This is meant to be generated inside the program, so use \-\-set\-json instead, unless you know what you are doing\. Vars: \$NELM\_SET\_ROOT\_JSON, \$NELM\_RELEASE\_PLAN\_INSTALL\_SET\_ROOT\_JSON -- `--set-runtime-json` (default: `[]`) - - Set new keys in \$\.Runtime, where the key is the value path and the value is JSON\. This is meant to be generated inside the program, so use \-\-set\-json instead, unless you know what you are doing\. Vars: \$NELM\_SET\_RUNTIME\_JSON, \$NELM\_RELEASE\_PLAN\_INSTALL\_SET\_RUNTIME\_JSON - - `--set-string` (default: `[]`) Set new values, where the key is the value path and the value is the value\. The value will always become a string\. Vars: \$NELM\_SET\_STRING, \$NELM\_RELEASE\_PLAN\_INSTALL\_SET\_STRING @@ -916,6 +984,41 @@ nelm release plan install [options...] -n namespace -r release [chart-dir] Secret values files paths\. Vars: \$NELM\_SECRET\_VALUES, \$NELM\_RELEASE\_PLAN\_INSTALL\_SECRET\_VALUES +**Resource validation options:** + +- `--local-resource-validation` (default: `false`) + + Do not use external json schema sources\. Vars: \$NELM\_LOCAL\_RESOURCE\_VALIDATION, \$NELM\_RELEASE\_PLAN\_INSTALL\_LOCAL\_RESOURCE\_VALIDATION + +- `--no-resource-validation` (default: `false`) + + Disable resource validation\. Vars: \$NELM\_NO\_RESOURCE\_VALIDATION, \$NELM\_RELEASE\_PLAN\_INSTALL\_NO\_RESOURCE\_VALIDATION + +- `--no-values-schema-validation` (default: `false`) + + Disable values validation against JSON schema\. Vars: \$NELM\_NO\_VALUES\_SCHEMA\_VALIDATION, \$NELM\_RELEASE\_PLAN\_INSTALL\_NO\_VALUES\_SCHEMA\_VALIDATION + +- `--resource-validation-cache-lifetime` (default: `48h0m0s`) + + How long local schema cache will be valid\. Vars: \$NELM\_RESOURCE\_VALIDATION\_CACHE\_LIFETIME, \$NELM\_RELEASE\_PLAN\_INSTALL\_RESOURCE\_VALIDATION\_CACHE\_LIFETIME + +- `--resource-validation-extra-schema` (default: `[]`) + + Extra json schema sources to validate resources \(preferred over default sources\)\. Must be a valid go template defining a http\(s\) URL, or an absolute path on local file system\. Vars: \$NELM\_RESOURCE\_VALIDATION\_EXTRA\_SCHEMA\_\*, \$NELM\_RELEASE\_PLAN\_INSTALL\_RESOURCE\_VALIDATION\_EXTRA\_SCHEMA\_\* + +- `--resource-validation-kube-version` (default: `"1.35.0"`) + + Kubernetes schemas version to use during resource validation\. Vars: \$NELM\_RESOURCE\_VALIDATION\_KUBE\_VERSION, \$NELM\_RELEASE\_PLAN\_INSTALL\_RESOURCE\_VALIDATION\_KUBE\_VERSION + +- `--resource-validation-schema` (default: `[https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/{{ .NormalizedKubernetesVersion }}-standalone{{ .StrictSuffix }}/{{ .ResourceKind }}{{ .KindSuffix }}.json,https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json]`) + + Default json schema sources to validate resources\. Must be a valid go template defining a http\(s\) URL, or an absolute path on local file system\. Vars: \$NELM\_RESOURCE\_VALIDATION\_SCHEMA\_\*, \$NELM\_RELEASE\_PLAN\_INSTALL\_RESOURCE\_VALIDATION\_SCHEMA\_\* + +- `--resource-validation-skip` (default: `[]`) + + Skip resource validation for resources with specified attributes\. Format: key1=value1,key2=value2\. Supported keys: group, version, kind, name, namespace\. Example: kind=Deployment,name=my\-app\. Vars: \$NELM\_RESOURCE\_VALIDATION\_SKIP, \$NELM\_RELEASE\_PLAN\_INSTALL\_RESOURCE\_VALIDATION\_SKIP + + **Patch options:** - `--annotations` (default: `{}`) @@ -991,6 +1094,10 @@ nelm release plan install [options...] -n namespace -r release [chart-dir] Set URL of chart repo to be used to look for chart\. Vars: \$NELM\_CHART\_REPO\_URL, \$NELM\_RELEASE\_PLAN\_INSTALL\_CHART\_REPO\_URL +- `--docker-config` (default: `"~/.docker"`) + + Docker config directory path\. Vars: \$DOCKER\_CONFIG, \$NELM\_DOCKER\_CONFIG, \$NELM\_RELEASE\_PLAN\_INSTALL\_DOCKER\_CONFIG + - `--insecure-chart-repos` (default: `false`) Allow insecure HTTP connections to chart repository\. Vars: \$NELM\_INSECURE\_CHART\_REPOS, \$NELM\_RELEASE\_PLAN\_INSTALL\_INSECURE\_CHART\_REPOS @@ -1003,7 +1110,7 @@ nelm release plan install [options...] -n namespace -r release [chart-dir] Don't verify TLS certificates of chart repository\. Vars: \$NELM\_NO\_VERIFY\_CHART\_REPOS\_TLS, \$NELM\_RELEASE\_PLAN\_INSTALL\_NO\_VERIFY\_CHART\_REPOS\_TLS -- `--oci-chart-repos-creds` (default: `"~/.docker/config.json"`) +- `--oci-chart-repos-creds` (default: `""`) Credentials to access OCI chart repositories\. Vars: \$NELM\_OCI\_CHART\_REPOS\_CREDS, \$NELM\_RELEASE\_PLAN\_INSTALL\_OCI\_CHART\_REPOS\_CREDS @@ -1165,7 +1272,7 @@ nelm release uninstall [options...] -n namespace -r release Delete the release namespace\. Var: \$NELM\_RELEASE\_UNINSTALL\_DELETE\_NAMESPACE -- `--delete-propagation` (default: `"Foreground"`) +- `--delete-propagation` (default: `"Background"`) Default delete propagation strategy\. Vars: \$NELM\_DELETE\_PROPAGATION, \$NELM\_RELEASE\_UNINSTALL\_DELETE\_PROPAGATION @@ -1370,154 +1477,158 @@ nelm release uninstall [options...] -n namespace -r release The directory for temporary files\. By default, create a new directory in the default system directory for temporary files\. Var: \$NELM\_TEMP\_DIR -### release list +### release history -List all deployed releases\. +Show release history\. **Usage:** ```shell -nelm release list [options...] [-n namespace] +nelm release history [options...] -n namespace -r release ``` **Options:** - `-n`, `--namespace` (default: `""`) - The release namespace\. Query all namespaces if not specified\. Vars: \$NELM\_NAMESPACE, \$NELM\_RELEASE\_LIST\_NAMESPACE + The release namespace\. Resources with no namespace will be deployed here\. Vars: \$NELM\_NAMESPACE, \$NELM\_RELEASE\_HISTORY\_NAMESPACE + +- `-r`, `--release` (default: `""`) + + The release name\. Must be unique within the release namespace\. Vars: \$NELM\_RELEASE, \$NELM\_RELEASE\_HISTORY\_RELEASE + +- `--revisions-limit` (default: `0`) + + Maximum number of revisions to show\. 0 means no limit\. Vars: \$NELM\_REVISIONS\_LIMIT, \$NELM\_RELEASE\_HISTORY\_REVISIONS\_LIMIT **Kubernetes connection options:** - `--kube-api-server` (default: `""`) - Kubernetes API server address\. Vars: \$NELM\_KUBE\_API\_SERVER, \$NELM\_RELEASE\_LIST\_KUBE\_API\_SERVER + Kubernetes API server address\. Vars: \$NELM\_KUBE\_API\_SERVER, \$NELM\_RELEASE\_HISTORY\_KUBE\_API\_SERVER - `--kube-api-server-tls-name` (default: `""`) - Server name for Kubernetes API TLS validation, if different from the hostname of Kubernetes API server\. Vars: \$NELM\_KUBE\_API\_SERVER\_TLS\_NAME, \$NELM\_RELEASE\_LIST\_KUBE\_API\_SERVER\_TLS\_NAME + Server name for Kubernetes API TLS validation, if different from the hostname of Kubernetes API server\. Vars: \$NELM\_KUBE\_API\_SERVER\_TLS\_NAME, \$NELM\_RELEASE\_HISTORY\_KUBE\_API\_SERVER\_TLS\_NAME - `--kube-auth-password` (default: `""`) - Basic auth password for Kubernetes API\. Vars: \$NELM\_KUBE\_AUTH\_PASSWORD, \$NELM\_RELEASE\_LIST\_KUBE\_AUTH\_PASSWORD + Basic auth password for Kubernetes API\. Vars: \$NELM\_KUBE\_AUTH\_PASSWORD, \$NELM\_RELEASE\_HISTORY\_KUBE\_AUTH\_PASSWORD - `--kube-auth-provider` (default: `""`) - Auth provider name for authentication in Kubernetes\. Vars: \$NELM\_KUBE\_AUTH\_PROVIDER, \$NELM\_RELEASE\_LIST\_KUBE\_AUTH\_PROVIDER + Auth provider name for authentication in Kubernetes\. Vars: \$NELM\_KUBE\_AUTH\_PROVIDER, \$NELM\_RELEASE\_HISTORY\_KUBE\_AUTH\_PROVIDER - `--kube-auth-provider-config` (default: `{}`) - Auth provider config for authentication in Kubernetes API\. Vars: \$NELM\_KUBE\_AUTH\_PROVIDER\_CONFIG, \$NELM\_RELEASE\_LIST\_KUBE\_AUTH\_PROVIDER\_CONFIG + Auth provider config for authentication in Kubernetes API\. Vars: \$NELM\_KUBE\_AUTH\_PROVIDER\_CONFIG, \$NELM\_RELEASE\_HISTORY\_KUBE\_AUTH\_PROVIDER\_CONFIG - `--kube-auth-username` (default: `""`) - Basic auth username for Kubernetes API\. Vars: \$NELM\_KUBE\_AUTH\_USERNAME, \$NELM\_RELEASE\_LIST\_KUBE\_AUTH\_USERNAME + Basic auth username for Kubernetes API\. Vars: \$NELM\_KUBE\_AUTH\_USERNAME, \$NELM\_RELEASE\_HISTORY\_KUBE\_AUTH\_USERNAME - `--kube-ca` (default: `""`) - Path to Kubernetes API server TLS CA file\. Vars: \$NELM\_KUBE\_CA, \$NELM\_RELEASE\_LIST\_KUBE\_CA + Path to Kubernetes API server TLS CA file\. Vars: \$NELM\_KUBE\_CA, \$NELM\_RELEASE\_HISTORY\_KUBE\_CA - `--kube-ca-data` (default: `""`) - Pass Kubernetes API server TLS CA data\. Vars: \$NELM\_KUBE\_CA\_DATA, \$NELM\_RELEASE\_LIST\_KUBE\_CA\_DATA + Pass Kubernetes API server TLS CA data\. Vars: \$NELM\_KUBE\_CA\_DATA, \$NELM\_RELEASE\_HISTORY\_KUBE\_CA\_DATA - `--kube-cert` (default: `""`) - Path to PEM\-encoded TLS client cert for connecting to Kubernetes API\. Vars: \$NELM\_KUBE\_CERT, \$NELM\_RELEASE\_LIST\_KUBE\_CERT + Path to PEM\-encoded TLS client cert for connecting to Kubernetes API\. Vars: \$NELM\_KUBE\_CERT, \$NELM\_RELEASE\_HISTORY\_KUBE\_CERT - `--kube-cert-data` (default: `""`) - Pass PEM\-encoded TLS client cert for connecting to Kubernetes API\. Vars: \$NELM\_KUBE\_CERT\_DATA, \$NELM\_RELEASE\_LIST\_KUBE\_CERT\_DATA + Pass PEM\-encoded TLS client cert for connecting to Kubernetes API\. Vars: \$NELM\_KUBE\_CERT\_DATA, \$NELM\_RELEASE\_HISTORY\_KUBE\_CERT\_DATA - `--kube-config` (default: `[]`) - Kubeconfig path\(s\)\. If multiple specified, their contents are merged\. Vars: \$KUBECONFIG, \$NELM\_KUBE\_CONFIG\_\*, \$NELM\_RELEASE\_LIST\_KUBE\_CONFIG\_\* + Kubeconfig path\(s\)\. If multiple specified, their contents are merged\. Vars: \$KUBECONFIG, \$NELM\_KUBE\_CONFIG\_\*, \$NELM\_RELEASE\_HISTORY\_KUBE\_CONFIG\_\* - `--kube-config-base64` (default: `""`) - Pass Kubeconfig file content encoded as base64\. Vars: \$NELM\_KUBE\_CONFIG\_BASE\_64, \$NELM\_RELEASE\_LIST\_KUBE\_CONFIG\_BASE\_64 + Pass Kubeconfig file content encoded as base64\. Vars: \$NELM\_KUBE\_CONFIG\_BASE\_64, \$NELM\_RELEASE\_HISTORY\_KUBE\_CONFIG\_BASE\_64 - `--kube-context` (default: `""`) - Use specified Kubeconfig context\. Vars: \$NELM\_KUBE\_CONTEXT, \$NELM\_RELEASE\_LIST\_KUBE\_CONTEXT + Use specified Kubeconfig context\. Vars: \$NELM\_KUBE\_CONTEXT, \$NELM\_RELEASE\_HISTORY\_KUBE\_CONTEXT - `--kube-context-cluster` (default: `""`) - Use cluster from Kubeconfig for current context\. Vars: \$NELM\_KUBE\_CONTEXT\_CLUSTER, \$NELM\_RELEASE\_LIST\_KUBE\_CONTEXT\_CLUSTER + Use cluster from Kubeconfig for current context\. Vars: \$NELM\_KUBE\_CONTEXT\_CLUSTER, \$NELM\_RELEASE\_HISTORY\_KUBE\_CONTEXT\_CLUSTER - `--kube-context-user` (default: `""`) - Use user from Kubeconfig for current context\. Vars: \$NELM\_KUBE\_CONTEXT\_USER, \$NELM\_RELEASE\_LIST\_KUBE\_CONTEXT\_USER + Use user from Kubeconfig for current context\. Vars: \$NELM\_KUBE\_CONTEXT\_USER, \$NELM\_RELEASE\_HISTORY\_KUBE\_CONTEXT\_USER - `--kube-impersonate-group` (default: `[]`) - Sets Impersonate\-Group headers when authenticating in Kubernetes\. Vars: \$NELM\_KUBE\_IMPERSONATE\_GROUP, \$NELM\_RELEASE\_LIST\_KUBE\_IMPERSONATE\_GROUP + Sets Impersonate\-Group headers when authenticating in Kubernetes\. Vars: \$NELM\_KUBE\_IMPERSONATE\_GROUP, \$NELM\_RELEASE\_HISTORY\_KUBE\_IMPERSONATE\_GROUP - `--kube-impersonate-uid` (default: `""`) - Sets Impersonate\-Uid header when authenticating in Kubernetes\. Vars: \$NELM\_KUBE\_IMPERSONATE\_UID, \$NELM\_RELEASE\_LIST\_KUBE\_IMPERSONATE\_UID + Sets Impersonate\-Uid header when authenticating in Kubernetes\. Vars: \$NELM\_KUBE\_IMPERSONATE\_UID, \$NELM\_RELEASE\_HISTORY\_KUBE\_IMPERSONATE\_UID - `--kube-impersonate-user` (default: `""`) - Sets Impersonate\-User header when authenticating in Kubernetes\. Vars: \$NELM\_KUBE\_IMPERSONATE\_USER, \$NELM\_RELEASE\_LIST\_KUBE\_IMPERSONATE\_USER + Sets Impersonate\-User header when authenticating in Kubernetes\. Vars: \$NELM\_KUBE\_IMPERSONATE\_USER, \$NELM\_RELEASE\_HISTORY\_KUBE\_IMPERSONATE\_USER - `--kube-key` (default: `""`) - Path to PEM\-encoded TLS client key for connecting to Kubernetes API\. Vars: \$NELM\_KUBE\_KEY, \$NELM\_RELEASE\_LIST\_KUBE\_KEY + Path to PEM\-encoded TLS client key for connecting to Kubernetes API\. Vars: \$NELM\_KUBE\_KEY, \$NELM\_RELEASE\_HISTORY\_KUBE\_KEY - `--kube-key-data` (default: `""`) - Pass PEM\-encoded TLS client key for connecting to Kubernetes API\. Vars: \$NELM\_KUBE\_KEY\_DATA, \$NELM\_RELEASE\_LIST\_KUBE\_KEY\_DATA + Pass PEM\-encoded TLS client key for connecting to Kubernetes API\. Vars: \$NELM\_KUBE\_KEY\_DATA, \$NELM\_RELEASE\_HISTORY\_KUBE\_KEY\_DATA - `--kube-proxy-url` (default: `""`) - Proxy URL to use for proxying all requests to Kubernetes API\. Vars: \$NELM\_KUBE\_PROXY\_URL, \$NELM\_RELEASE\_LIST\_KUBE\_PROXY\_URL + Proxy URL to use for proxying all requests to Kubernetes API\. Vars: \$NELM\_KUBE\_PROXY\_URL, \$NELM\_RELEASE\_HISTORY\_KUBE\_PROXY\_URL - `--kube-request-timeout` (default: `0s`) - Timeout for all requests to Kubernetes API\. Vars: \$NELM\_KUBE\_REQUEST\_TIMEOUT, \$NELM\_RELEASE\_LIST\_KUBE\_REQUEST\_TIMEOUT + Timeout for all requests to Kubernetes API\. Vars: \$NELM\_KUBE\_REQUEST\_TIMEOUT, \$NELM\_RELEASE\_HISTORY\_KUBE\_REQUEST\_TIMEOUT - `--kube-token` (default: `""`) - Bearer token for authentication in Kubernetes\. Vars: \$NELM\_KUBE\_TOKEN, \$NELM\_RELEASE\_LIST\_KUBE\_TOKEN + Bearer token for authentication in Kubernetes\. Vars: \$NELM\_KUBE\_TOKEN, \$NELM\_RELEASE\_HISTORY\_KUBE\_TOKEN - `--kube-token-path` (default: `""`) - Path to file with bearer token for authentication in Kubernetes\. Vars: \$NELM\_KUBE\_TOKEN\_PATH, \$NELM\_RELEASE\_LIST\_KUBE\_TOKEN\_PATH + Path to file with bearer token for authentication in Kubernetes\. Vars: \$NELM\_KUBE\_TOKEN\_PATH, \$NELM\_RELEASE\_HISTORY\_KUBE\_TOKEN\_PATH - `--no-verify-kube-tls` (default: `false`) - Don't verify TLS certificates of Kubernetes API\. Vars: \$NELM\_NO\_VERIFY\_KUBE\_TLS, \$NELM\_RELEASE\_LIST\_NO\_VERIFY\_KUBE\_TLS + Don't verify TLS certificates of Kubernetes API\. Vars: \$NELM\_NO\_VERIFY\_KUBE\_TLS, \$NELM\_RELEASE\_HISTORY\_NO\_VERIFY\_KUBE\_TLS **Performance options:** - `--kube-burst-limit` (default: `100`) - Burst limit for requests to Kubernetes\. Vars: \$NELM\_KUBE\_BURST\_LIMIT, \$NELM\_RELEASE\_LIST\_KUBE\_BURST\_LIMIT + Burst limit for requests to Kubernetes\. Vars: \$NELM\_KUBE\_BURST\_LIMIT, \$NELM\_RELEASE\_HISTORY\_KUBE\_BURST\_LIMIT - `--kube-qps-limit` (default: `30`) - Queries Per Second limit for requests to Kubernetes\. Vars: \$NELM\_KUBE\_QPS\_LIMIT, \$NELM\_RELEASE\_LIST\_KUBE\_QPS\_LIMIT - -- `--network-parallelism` (default: `30`) - - Limit of network\-related tasks to run in parallel\. Vars: \$NELM\_NETWORK\_PARALLELISM, \$NELM\_RELEASE\_LIST\_NETWORK\_PARALLELISM + Queries Per Second limit for requests to Kubernetes\. Vars: \$NELM\_KUBE\_QPS\_LIMIT, \$NELM\_RELEASE\_HISTORY\_KUBE\_QPS\_LIMIT **Other options:** - `--color-mode` (default: `"auto"`) - Color mode for logs\. Allowed: auto, off, on\. Vars: \$NELM\_COLOR\_MODE, \$NELM\_RELEASE\_LIST\_COLOR\_MODE + Color mode for logs\. Allowed: auto, off, on\. Vars: \$NELM\_COLOR\_MODE, \$NELM\_RELEASE\_HISTORY\_COLOR\_MODE - `--log-level` (default: `"error"`) - Set log level\. Allowed: silent, error, warning, info, debug, trace\. Vars: \$NELM\_LOG\_LEVEL, \$NELM\_RELEASE\_LIST\_LOG\_LEVEL + Set log level\. Allowed: silent, error, warning, info, debug, trace\. Vars: \$NELM\_LOG\_LEVEL, \$NELM\_RELEASE\_HISTORY\_LOG\_LEVEL - `--output-format` (default: `"table"`) - Result output format\. Vars: \$NELM\_OUTPUT\_FORMAT, \$NELM\_RELEASE\_LIST\_OUTPUT\_FORMAT + Result output format\. Vars: \$NELM\_OUTPUT\_FORMAT, \$NELM\_RELEASE\_HISTORY\_OUTPUT\_FORMAT - `--release-storage` (default: `""`) @@ -1532,163 +1643,226 @@ nelm release list [options...] [-n namespace] The directory for temporary files\. By default, create a new directory in the default system directory for temporary files\. Var: \$NELM\_TEMP\_DIR -### release history - - -History prints historical revisions for a given release\. - -A default maximum of 256 revisions will be returned\. Setting '\-\-max' -configures the maximum length of the revision list returned\. - -The historical release set is printed as a formatted table, e\.g: - - $ helm history angry-bird - REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION - 1 Mon Oct 3 10:15:13 2016 superseded alpine-0.1.0 1.0 Initial install - 2 Mon Oct 3 10:15:13 2016 superseded alpine-0.1.0 1.0 Upgraded successfully - 3 Mon Oct 3 10:15:13 2016 superseded alpine-0.1.0 1.0 Rolled back to 2 - 4 Mon Oct 3 10:15:13 2016 deployed alpine-0.1.0 1.0 Upgraded successfully +### release list +List all deployed releases\. **Usage:** ```shell -nelm release history RELEASE_NAME [flags] +nelm release list [options...] [-n namespace] ``` -**Other options:** +**Options:** -- `--burst-limit` (default: `100`) +- `-n`, `--namespace` (default: `""`) - client\-side default throttling limit + The release namespace\. Query all namespaces if not specified\. Vars: \$NELM\_NAMESPACE, \$NELM\_RELEASE\_LIST\_NAMESPACE -- `--debug` (default: `false`) - enable verbose output +**Kubernetes connection options:** -- `--kube-apiserver` (default: `""`) +- `--kube-api-server` (default: `""`) - the address and the port for the Kubernetes API server + Kubernetes API server address\. Vars: \$NELM\_KUBE\_API\_SERVER, \$NELM\_RELEASE\_LIST\_KUBE\_API\_SERVER -- `--kube-as-group` (default: `[]`) +- `--kube-api-server-tls-name` (default: `""`) - group to impersonate for the operation, this flag can be repeated to specify multiple groups\. + Server name for Kubernetes API TLS validation, if different from the hostname of Kubernetes API server\. Vars: \$NELM\_KUBE\_API\_SERVER\_TLS\_NAME, \$NELM\_RELEASE\_LIST\_KUBE\_API\_SERVER\_TLS\_NAME -- `--kube-as-user` (default: `""`) +- `--kube-auth-password` (default: `""`) - username to impersonate for the operation + Basic auth password for Kubernetes API\. Vars: \$NELM\_KUBE\_AUTH\_PASSWORD, \$NELM\_RELEASE\_LIST\_KUBE\_AUTH\_PASSWORD -- `--kube-ca-file` (default: `""`) +- `--kube-auth-provider` (default: `""`) - the certificate authority file for the Kubernetes API server connection + Auth provider name for authentication in Kubernetes\. Vars: \$NELM\_KUBE\_AUTH\_PROVIDER, \$NELM\_RELEASE\_LIST\_KUBE\_AUTH\_PROVIDER -- `--kube-context` (default: `""`) +- `--kube-auth-provider-config` (default: `{}`) - name of the kubeconfig context to use + Auth provider config for authentication in Kubernetes API\. Vars: \$NELM\_KUBE\_AUTH\_PROVIDER\_CONFIG, \$NELM\_RELEASE\_LIST\_KUBE\_AUTH\_PROVIDER\_CONFIG -- `--kube-insecure-skip-tls-verify` (default: `false`) +- `--kube-auth-username` (default: `""`) - if true, the Kubernetes API server's certificate will not be checked for validity\. This will make your HTTPS connections insecure + Basic auth username for Kubernetes API\. Vars: \$NELM\_KUBE\_AUTH\_USERNAME, \$NELM\_RELEASE\_LIST\_KUBE\_AUTH\_USERNAME -- `--kube-tls-server-name` (default: `""`) +- `--kube-ca` (default: `""`) - server name to use for Kubernetes API server certificate validation\. If it is not provided, the hostname used to contact the server is used + Path to Kubernetes API server TLS CA file\. Vars: \$NELM\_KUBE\_CA, \$NELM\_RELEASE\_LIST\_KUBE\_CA -- `--kube-token` (default: `""`) +- `--kube-ca-data` (default: `""`) - bearer token used for authentication + Pass Kubernetes API server TLS CA data\. Vars: \$NELM\_KUBE\_CA\_DATA, \$NELM\_RELEASE\_LIST\_KUBE\_CA\_DATA -- `--kubeconfig` (default: `""`) +- `--kube-cert` (default: `""`) - path to the kubeconfig file + Path to PEM\-encoded TLS client cert for connecting to Kubernetes API\. Vars: \$NELM\_KUBE\_CERT, \$NELM\_RELEASE\_LIST\_KUBE\_CERT -- `--max` (default: `256`) +- `--kube-cert-data` (default: `""`) - maximum number of revision to include in history + Pass PEM\-encoded TLS client cert for connecting to Kubernetes API\. Vars: \$NELM\_KUBE\_CERT\_DATA, \$NELM\_RELEASE\_LIST\_KUBE\_CERT\_DATA -- `-n`, `--namespace` (default: `""`) +- `--kube-config` (default: `[]`) - namespace scope for this request + Kubeconfig path\(s\)\. If multiple specified, their contents are merged\. Vars: \$KUBECONFIG, \$NELM\_KUBE\_CONFIG\_\*, \$NELM\_RELEASE\_LIST\_KUBE\_CONFIG\_\* -- `-o`, `--output` (default: `table`) +- `--kube-config-base64` (default: `""`) - prints the output in the specified format\. Allowed values: table, json, yaml + Pass Kubeconfig file content encoded as base64\. Vars: \$NELM\_KUBE\_CONFIG\_BASE\_64, \$NELM\_RELEASE\_LIST\_KUBE\_CONFIG\_BASE\_64 -- `--qps` (default: `0`) +- `--kube-context` (default: `""`) - queries per second used when communicating with the Kubernetes API, not including bursting + Use specified Kubeconfig context\. Vars: \$NELM\_KUBE\_CONTEXT, \$NELM\_RELEASE\_LIST\_KUBE\_CONTEXT -- `--registry-config` (default: `"~/.config/helm/registry/config.json"`) +- `--kube-context-cluster` (default: `""`) - path to the registry config file + Use cluster from Kubeconfig for current context\. Vars: \$NELM\_KUBE\_CONTEXT\_CLUSTER, \$NELM\_RELEASE\_LIST\_KUBE\_CONTEXT\_CLUSTER -- `--repository-cache` (default: `"~/.cache/helm/repository"`) +- `--kube-context-user` (default: `""`) - path to the file containing cached repository indexes + Use user from Kubeconfig for current context\. Vars: \$NELM\_KUBE\_CONTEXT\_USER, \$NELM\_RELEASE\_LIST\_KUBE\_CONTEXT\_USER -- `--repository-config` (default: `"~/.config/helm/repositories.yaml"`) +- `--kube-impersonate-group` (default: `[]`) - path to the file containing repository names and URLs + Sets Impersonate\-Group headers when authenticating in Kubernetes\. Vars: \$NELM\_KUBE\_IMPERSONATE\_GROUP, \$NELM\_RELEASE\_LIST\_KUBE\_IMPERSONATE\_GROUP +- `--kube-impersonate-uid` (default: `""`) -### release get + Sets Impersonate\-Uid header when authenticating in Kubernetes\. Vars: \$NELM\_KUBE\_IMPERSONATE\_UID, \$NELM\_RELEASE\_LIST\_KUBE\_IMPERSONATE\_UID -Get information about a deployed release\. +- `--kube-impersonate-user` (default: `""`) -**Usage:** + Sets Impersonate\-User header when authenticating in Kubernetes\. Vars: \$NELM\_KUBE\_IMPERSONATE\_USER, \$NELM\_RELEASE\_LIST\_KUBE\_IMPERSONATE\_USER -```shell -nelm release get [options...] -n namespace -r release [revision] -``` +- `--kube-key` (default: `""`) -**Options:** + Path to PEM\-encoded TLS client key for connecting to Kubernetes API\. Vars: \$NELM\_KUBE\_KEY, \$NELM\_RELEASE\_LIST\_KUBE\_KEY -- `-n`, `--namespace` (default: `""`) +- `--kube-key-data` (default: `""`) - The release namespace\. Resources with no namespace will be deployed here\. Vars: \$NELM\_NAMESPACE, \$NELM\_RELEASE\_GET\_NAMESPACE + Pass PEM\-encoded TLS client key for connecting to Kubernetes API\. Vars: \$NELM\_KUBE\_KEY\_DATA, \$NELM\_RELEASE\_LIST\_KUBE\_KEY\_DATA -- `--print-values` (default: `false`) +- `--kube-proxy-url` (default: `""`) - Print Values of the last Helm release\. Var: \$NELM\_RELEASE\_GET\_PRINT\_VALUES + Proxy URL to use for proxying all requests to Kubernetes API\. Vars: \$NELM\_KUBE\_PROXY\_URL, \$NELM\_RELEASE\_LIST\_KUBE\_PROXY\_URL -- `-r`, `--release` (default: `""`) +- `--kube-request-timeout` (default: `0s`) - The release name\. Must be unique within the release namespace\. Vars: \$NELM\_RELEASE, \$NELM\_RELEASE\_GET\_RELEASE + Timeout for all requests to Kubernetes API\. Vars: \$NELM\_KUBE\_REQUEST\_TIMEOUT, \$NELM\_RELEASE\_LIST\_KUBE\_REQUEST\_TIMEOUT +- `--kube-token` (default: `""`) -**Kubernetes connection options:** + Bearer token for authentication in Kubernetes\. Vars: \$NELM\_KUBE\_TOKEN, \$NELM\_RELEASE\_LIST\_KUBE\_TOKEN -- `--kube-api-server` (default: `""`) +- `--kube-token-path` (default: `""`) - Kubernetes API server address\. Vars: \$NELM\_KUBE\_API\_SERVER, \$NELM\_RELEASE\_GET\_KUBE\_API\_SERVER + Path to file with bearer token for authentication in Kubernetes\. Vars: \$NELM\_KUBE\_TOKEN\_PATH, \$NELM\_RELEASE\_LIST\_KUBE\_TOKEN\_PATH -- `--kube-api-server-tls-name` (default: `""`) +- `--no-verify-kube-tls` (default: `false`) - Server name for Kubernetes API TLS validation, if different from the hostname of Kubernetes API server\. Vars: \$NELM\_KUBE\_API\_SERVER\_TLS\_NAME, \$NELM\_RELEASE\_GET\_KUBE\_API\_SERVER\_TLS\_NAME + Don't verify TLS certificates of Kubernetes API\. Vars: \$NELM\_NO\_VERIFY\_KUBE\_TLS, \$NELM\_RELEASE\_LIST\_NO\_VERIFY\_KUBE\_TLS -- `--kube-auth-password` (default: `""`) - Basic auth password for Kubernetes API\. Vars: \$NELM\_KUBE\_AUTH\_PASSWORD, \$NELM\_RELEASE\_GET\_KUBE\_AUTH\_PASSWORD +**Performance options:** -- `--kube-auth-provider` (default: `""`) +- `--kube-burst-limit` (default: `100`) - Auth provider name for authentication in Kubernetes\. Vars: \$NELM\_KUBE\_AUTH\_PROVIDER, \$NELM\_RELEASE\_GET\_KUBE\_AUTH\_PROVIDER + Burst limit for requests to Kubernetes\. Vars: \$NELM\_KUBE\_BURST\_LIMIT, \$NELM\_RELEASE\_LIST\_KUBE\_BURST\_LIMIT -- `--kube-auth-provider-config` (default: `{}`) +- `--kube-qps-limit` (default: `30`) - Auth provider config for authentication in Kubernetes API\. Vars: \$NELM\_KUBE\_AUTH\_PROVIDER\_CONFIG, \$NELM\_RELEASE\_GET\_KUBE\_AUTH\_PROVIDER\_CONFIG + Queries Per Second limit for requests to Kubernetes\. Vars: \$NELM\_KUBE\_QPS\_LIMIT, \$NELM\_RELEASE\_LIST\_KUBE\_QPS\_LIMIT -- `--kube-auth-username` (default: `""`) +- `--network-parallelism` (default: `30`) - Basic auth username for Kubernetes API\. Vars: \$NELM\_KUBE\_AUTH\_USERNAME, \$NELM\_RELEASE\_GET\_KUBE\_AUTH\_USERNAME + Limit of network\-related tasks to run in parallel\. Vars: \$NELM\_NETWORK\_PARALLELISM, \$NELM\_RELEASE\_LIST\_NETWORK\_PARALLELISM -- `--kube-ca` (default: `""`) - Path to Kubernetes API server TLS CA file\. Vars: \$NELM\_KUBE\_CA, \$NELM\_RELEASE\_GET\_KUBE\_CA +**Other options:** -- `--kube-ca-data` (default: `""`) +- `--color-mode` (default: `"auto"`) - Pass Kubernetes API server TLS CA data\. Vars: \$NELM\_KUBE\_CA\_DATA, \$NELM\_RELEASE\_GET\_KUBE\_CA\_DATA + Color mode for logs\. Allowed: auto, off, on\. Vars: \$NELM\_COLOR\_MODE, \$NELM\_RELEASE\_LIST\_COLOR\_MODE + +- `--log-level` (default: `"error"`) + + Set log level\. Allowed: silent, error, warning, info, debug, trace\. Vars: \$NELM\_LOG\_LEVEL, \$NELM\_RELEASE\_LIST\_LOG\_LEVEL + +- `--output-format` (default: `"table"`) + + Result output format\. Vars: \$NELM\_OUTPUT\_FORMAT, \$NELM\_RELEASE\_LIST\_OUTPUT\_FORMAT + +- `--release-storage` (default: `""`) + + How releases should be stored\. Var: \$NELM\_RELEASE\_STORAGE + +- `--release-storage-sql-connection` (default: `""`) + + SQL connection string for MySQL release storage driver\. Var: \$NELM\_RELEASE\_STORAGE\_SQL\_CONNECTION + +- `--temp-dir` (default: `""`) + + The directory for temporary files\. By default, create a new directory in the default system directory for temporary files\. Var: \$NELM\_TEMP\_DIR + + +### release get + +Get information about a deployed release\. + +**Usage:** + +```shell +nelm release get [options...] -n namespace -r release [revision] +``` + +**Options:** + +- `-n`, `--namespace` (default: `""`) + + The release namespace\. Resources with no namespace will be deployed here\. Vars: \$NELM\_NAMESPACE, \$NELM\_RELEASE\_GET\_NAMESPACE + +- `--print-values` (default: `false`) + + Print Values of the last Helm release\. Var: \$NELM\_RELEASE\_GET\_PRINT\_VALUES + +- `-r`, `--release` (default: `""`) + + The release name\. Must be unique within the release namespace\. Vars: \$NELM\_RELEASE, \$NELM\_RELEASE\_GET\_RELEASE + + +**Kubernetes connection options:** + +- `--kube-api-server` (default: `""`) + + Kubernetes API server address\. Vars: \$NELM\_KUBE\_API\_SERVER, \$NELM\_RELEASE\_GET\_KUBE\_API\_SERVER + +- `--kube-api-server-tls-name` (default: `""`) + + Server name for Kubernetes API TLS validation, if different from the hostname of Kubernetes API server\. Vars: \$NELM\_KUBE\_API\_SERVER\_TLS\_NAME, \$NELM\_RELEASE\_GET\_KUBE\_API\_SERVER\_TLS\_NAME + +- `--kube-auth-password` (default: `""`) + + Basic auth password for Kubernetes API\. Vars: \$NELM\_KUBE\_AUTH\_PASSWORD, \$NELM\_RELEASE\_GET\_KUBE\_AUTH\_PASSWORD + +- `--kube-auth-provider` (default: `""`) + + Auth provider name for authentication in Kubernetes\. Vars: \$NELM\_KUBE\_AUTH\_PROVIDER, \$NELM\_RELEASE\_GET\_KUBE\_AUTH\_PROVIDER + +- `--kube-auth-provider-config` (default: `{}`) + + Auth provider config for authentication in Kubernetes API\. Vars: \$NELM\_KUBE\_AUTH\_PROVIDER\_CONFIG, \$NELM\_RELEASE\_GET\_KUBE\_AUTH\_PROVIDER\_CONFIG + +- `--kube-auth-username` (default: `""`) + + Basic auth username for Kubernetes API\. Vars: \$NELM\_KUBE\_AUTH\_USERNAME, \$NELM\_RELEASE\_GET\_KUBE\_AUTH\_USERNAME + +- `--kube-ca` (default: `""`) + + Path to Kubernetes API server TLS CA file\. Vars: \$NELM\_KUBE\_CA, \$NELM\_RELEASE\_GET\_KUBE\_CA + +- `--kube-ca-data` (default: `""`) + + Pass Kubernetes API server TLS CA data\. Vars: \$NELM\_KUBE\_CA\_DATA, \$NELM\_RELEASE\_GET\_KUBE\_CA\_DATA - `--kube-cert` (default: `""`) @@ -1856,12 +2030,16 @@ Lint a chart\. **Usage:** ```shell -nelm chart lint [options...] [chart-dir] +nelm chart lint [options...] [chart-dir|chart-repo-name/chart-name|chart-archive|chart-archive-url] ``` **Options:** -- `--delete-propagation` (default: `"Foreground"`) +- `--chart-version` (default: `""`) + + Choose a remote chart version, otherwise the latest version is used\. Vars: \$NELM\_CHART\_VERSION, \$NELM\_CHART\_LINT\_CHART\_VERSION + +- `--delete-propagation` (default: `"Background"`) Default delete propagation strategy\. Vars: \$NELM\_DELETE\_PROPAGATION, \$NELM\_CHART\_LINT\_DELETE\_PROPAGATION @@ -1873,7 +2051,7 @@ nelm chart lint [options...] [chart-dir] Always adopt resources, even if they belong to a different Helm release\. Vars: \$NELM\_FORCE\_ADOPTION, \$NELM\_CHART\_LINT\_FORCE\_ADOPTION -- `--kube-version` (default: `"1.20.0"`) +- `--kube-version` (default: `"1.36.0"`) Kubernetes version stub for non\-remote mode\. Var: \$NELM\_CHART\_LINT\_KUBE\_VERSION @@ -1932,10 +2110,6 @@ nelm chart lint [options...] [chart-dir] Set new keys in the global context \(\$\), where the key is the value path and the value is JSON\. This is meant to be generated inside the program, so use \-\-set\-json instead, unless you know what you are doing\. Vars: \$NELM\_SET\_ROOT\_JSON, \$NELM\_CHART\_LINT\_SET\_ROOT\_JSON -- `--set-runtime-json` (default: `[]`) - - Set new keys in \$\.Runtime, where the key is the value path and the value is JSON\. This is meant to be generated inside the program, so use \-\-set\-json instead, unless you know what you are doing\. Vars: \$NELM\_SET\_RUNTIME\_JSON, \$NELM\_CHART\_LINT\_SET\_RUNTIME\_JSON - - `--set-string` (default: `[]`) Set new values, where the key is the value path and the value is the value\. The value will always become a string\. Vars: \$NELM\_SET\_STRING, \$NELM\_CHART\_LINT\_SET\_STRING @@ -1964,6 +2138,41 @@ nelm chart lint [options...] [chart-dir] Secret values files paths\. Vars: \$NELM\_SECRET\_VALUES, \$NELM\_CHART\_LINT\_SECRET\_VALUES +**Resource validation options:** + +- `--local-resource-validation` (default: `false`) + + Do not use external json schema sources\. Vars: \$NELM\_LOCAL\_RESOURCE\_VALIDATION, \$NELM\_CHART\_LINT\_LOCAL\_RESOURCE\_VALIDATION + +- `--no-resource-validation` (default: `false`) + + Disable resource validation\. Vars: \$NELM\_NO\_RESOURCE\_VALIDATION, \$NELM\_CHART\_LINT\_NO\_RESOURCE\_VALIDATION + +- `--no-values-schema-validation` (default: `false`) + + Disable values validation against JSON schema\. Vars: \$NELM\_NO\_VALUES\_SCHEMA\_VALIDATION, \$NELM\_CHART\_LINT\_NO\_VALUES\_SCHEMA\_VALIDATION + +- `--resource-validation-cache-lifetime` (default: `48h0m0s`) + + How long local schema cache will be valid\. Vars: \$NELM\_RESOURCE\_VALIDATION\_CACHE\_LIFETIME, \$NELM\_CHART\_LINT\_RESOURCE\_VALIDATION\_CACHE\_LIFETIME + +- `--resource-validation-extra-schema` (default: `[]`) + + Extra json schema sources to validate resources \(preferred over default sources\)\. Must be a valid go template defining a http\(s\) URL, or an absolute path on local file system\. Vars: \$NELM\_RESOURCE\_VALIDATION\_EXTRA\_SCHEMA\_\*, \$NELM\_CHART\_LINT\_RESOURCE\_VALIDATION\_EXTRA\_SCHEMA\_\* + +- `--resource-validation-kube-version` (default: `"1.35.0"`) + + Kubernetes schemas version to use during resource validation\. Vars: \$NELM\_RESOURCE\_VALIDATION\_KUBE\_VERSION, \$NELM\_CHART\_LINT\_RESOURCE\_VALIDATION\_KUBE\_VERSION + +- `--resource-validation-schema` (default: `[https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/{{ .NormalizedKubernetesVersion }}-standalone{{ .StrictSuffix }}/{{ .ResourceKind }}{{ .KindSuffix }}.json,https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json]`) + + Default json schema sources to validate resources\. Must be a valid go template defining a http\(s\) URL, or an absolute path on local file system\. Vars: \$NELM\_RESOURCE\_VALIDATION\_SCHEMA\_\*, \$NELM\_CHART\_LINT\_RESOURCE\_VALIDATION\_SCHEMA\_\* + +- `--resource-validation-skip` (default: `[]`) + + Skip resource validation for resources with specified attributes\. Format: key1=value1,key2=value2\. Supported keys: group, version, kind, name, namespace\. Example: kind=Deployment,name=my\-app\. Vars: \$NELM\_RESOURCE\_VALIDATION\_SKIP, \$NELM\_CHART\_LINT\_RESOURCE\_VALIDATION\_SKIP + + **Patch options:** - `--annotations` (default: `{}`) @@ -2039,6 +2248,10 @@ nelm chart lint [options...] [chart-dir] Set URL of chart repo to be used to look for chart\. Vars: \$NELM\_CHART\_REPO\_URL, \$NELM\_CHART\_LINT\_CHART\_REPO\_URL +- `--docker-config` (default: `"~/.docker"`) + + Docker config directory path\. Vars: \$DOCKER\_CONFIG, \$NELM\_DOCKER\_CONFIG, \$NELM\_CHART\_LINT\_DOCKER\_CONFIG + - `--insecure-chart-repos` (default: `false`) Allow insecure HTTP connections to chart repository\. Vars: \$NELM\_INSECURE\_CHART\_REPOS, \$NELM\_CHART\_LINT\_INSECURE\_CHART\_REPOS @@ -2051,7 +2264,7 @@ nelm chart lint [options...] [chart-dir] Don't verify TLS certificates of chart repository\. Vars: \$NELM\_NO\_VERIFY\_CHART\_REPOS\_TLS, \$NELM\_CHART\_LINT\_NO\_VERIFY\_CHART\_REPOS\_TLS -- `--oci-chart-repos-creds` (default: `"~/.docker/config.json"`) +- `--oci-chart-repos-creds` (default: `""`) Credentials to access OCI chart repositories\. Vars: \$NELM\_OCI\_CHART\_REPOS\_CREDS, \$NELM\_CHART\_LINT\_OCI\_CHART\_REPOS\_CREDS @@ -2204,16 +2417,20 @@ Render a chart\. **Usage:** ```shell -nelm chart render [options...] [chart-dir] +nelm chart render [options...] [chart-dir|chart-repo-name/chart-name|chart-archive|chart-archive-url] ``` **Options:** +- `--chart-version` (default: `""`) + + Choose a remote chart version, otherwise the latest version is used\. Vars: \$NELM\_CHART\_VERSION, \$NELM\_CHART\_RENDER\_CHART\_VERSION + - `--extra-apiversions` (default: `[]`) Extra Kubernetes API versions passed to \$\.Capabilities\.APIVersions\. Vars: \$NELM\_EXTRA\_APIVERSIONS\_\*, \$NELM\_CHART\_RENDER\_EXTRA\_APIVERSIONS\_\* -- `--kube-version` (default: `"1.20.0"`) +- `--kube-version` (default: `"1.36.0"`) Kubernetes version stub for non\-remote mode\. Var: \$NELM\_CHART\_RENDER\_KUBE\_VERSION @@ -2280,10 +2497,6 @@ nelm chart render [options...] [chart-dir] Set new keys in the global context \(\$\), where the key is the value path and the value is JSON\. This is meant to be generated inside the program, so use \-\-set\-json instead, unless you know what you are doing\. Vars: \$NELM\_SET\_ROOT\_JSON, \$NELM\_CHART\_RENDER\_SET\_ROOT\_JSON -- `--set-runtime-json` (default: `[]`) - - Set new keys in \$\.Runtime, where the key is the value path and the value is JSON\. This is meant to be generated inside the program, so use \-\-set\-json instead, unless you know what you are doing\. Vars: \$NELM\_SET\_RUNTIME\_JSON, \$NELM\_CHART\_RENDER\_SET\_RUNTIME\_JSON - - `--set-string` (default: `[]`) Set new values, where the key is the value path and the value is the value\. The value will always become a string\. Vars: \$NELM\_SET\_STRING, \$NELM\_CHART\_RENDER\_SET\_STRING @@ -2376,6 +2589,10 @@ nelm chart render [options...] [chart-dir] Set URL of chart repo to be used to look for chart\. Vars: \$NELM\_CHART\_REPO\_URL, \$NELM\_CHART\_RENDER\_CHART\_REPO\_URL +- `--docker-config` (default: `"~/.docker"`) + + Docker config directory path\. Vars: \$DOCKER\_CONFIG, \$NELM\_DOCKER\_CONFIG, \$NELM\_CHART\_RENDER\_DOCKER\_CONFIG + - `--insecure-chart-repos` (default: `false`) Allow insecure HTTP connections to chart repository\. Vars: \$NELM\_INSECURE\_CHART\_REPOS, \$NELM\_CHART\_RENDER\_INSECURE\_CHART\_REPOS @@ -2388,7 +2605,7 @@ nelm chart render [options...] [chart-dir] Don't verify TLS certificates of chart repository\. Vars: \$NELM\_NO\_VERIFY\_CHART\_REPOS\_TLS, \$NELM\_CHART\_RENDER\_NO\_VERIFY\_CHART\_REPOS\_TLS -- `--oci-chart-repos-creds` (default: `"~/.docker/config.json"`) +- `--oci-chart-repos-creds` (default: `""`) Credentials to access OCI chart repositories\. Vars: \$NELM\_OCI\_CHART\_REPOS\_CREDS, \$NELM\_CHART\_RENDER\_OCI\_CHART\_REPOS\_CREDS @@ -2571,6 +2788,18 @@ nelm chart download [chart URL | repo/chartname] [...] [flags] identify HTTPS client using this SSL certificate file +- `--color` (default: `"auto"`) + + use colored output \(never, auto, always\) + +- `--colour` (default: `"auto"`) + + use colored output \(never, auto, always\) + +- `--content-cache` (default: `"~/.cache/helm/content"`) + + path to the directory containing cached content \(e\.g\. charts\) + - `--debug` (default: `false`) enable verbose output @@ -2665,7 +2894,7 @@ nelm chart download [chart URL | repo/chartname] [...] [flags] - `--repository-cache` (default: `"~/.cache/helm/repository"`) - path to the file containing cached repository indexes + path to the directory containing cached repository indexes - `--repository-config` (default: `"~/.config/helm/repositories.yaml"`) @@ -2721,6 +2950,18 @@ nelm chart upload [archive] [remote] [flags] identify registry client using this SSL certificate file +- `--color` (default: `"auto"`) + + use colored output \(never, auto, always\) + +- `--colour` (default: `"auto"`) + + use colored output \(never, auto, always\) + +- `--content-cache` (default: `"~/.cache/helm/content"`) + + path to the directory containing cached content \(e\.g\. charts\) + - `--debug` (default: `false`) enable verbose output @@ -2773,6 +3014,10 @@ nelm chart upload [archive] [remote] [flags] namespace scope for this request +- `--password` (default: `""`) + + chart repository password where to locate the requested chart + - `--plain-http` (default: `false`) use insecure HTTP connections for the chart upload @@ -2787,12 +3032,16 @@ nelm chart upload [archive] [remote] [flags] - `--repository-cache` (default: `"~/.cache/helm/repository"`) - path to the file containing cached repository indexes + path to the directory containing cached repository indexes - `--repository-config` (default: `"~/.config/helm/repositories.yaml"`) path to the file containing repository names and URLs +- `--username` (default: `""`) + + chart repository username where to locate the requested chart + ### chart pack @@ -2835,6 +3084,26 @@ nelm chart pack [CHART_PATH] [...] [flags] client\-side default throttling limit +- `--ca-file` (default: `""`) + + verify certificates of HTTPS\-enabled servers using this CA bundle + +- `--cert-file` (default: `""`) + + identify HTTPS client using this SSL certificate file + +- `--color` (default: `"auto"`) + + use colored output \(never, auto, always\) + +- `--colour` (default: `"auto"`) + + use colored output \(never, auto, always\) + +- `--content-cache` (default: `"~/.cache/helm/content"`) + + path to the directory containing cached content \(e\.g\. charts\) + - `--debug` (default: `false`) enable verbose output @@ -2847,10 +3116,18 @@ nelm chart pack [CHART_PATH] [...] [flags] location to write the chart\. +- `--insecure-skip-tls-verify` (default: `false`) + + skip tls certificate checks for the chart download + - `--key` (default: `""`) name of the key to use when signing\. Used if \-\-sign is true +- `--key-file` (default: `""`) + + identify HTTPS client using this SSL key file + - `--keyring` (default: `"~/.gnupg/pubring.gpg"`) location of a public keyring @@ -2899,6 +3176,14 @@ nelm chart pack [CHART_PATH] [...] [flags] location of a file which contains the passphrase for the signing key\. Use "\-" in order to read from stdin\. +- `--password` (default: `""`) + + chart repository password where to locate the requested chart + +- `--plain-http` (default: `false`) + + use insecure HTTP connections for the chart download + - `--qps` (default: `0`) queries per second used when communicating with the Kubernetes API, not including bursting @@ -2909,7 +3194,7 @@ nelm chart pack [CHART_PATH] [...] [flags] - `--repository-cache` (default: `"~/.cache/helm/repository"`) - path to the file containing cached repository indexes + path to the directory containing cached repository indexes - `--repository-config` (default: `"~/.config/helm/repositories.yaml"`) @@ -2919,6 +3204,10 @@ nelm chart pack [CHART_PATH] [...] [flags] use a PGP private key to sign this package +- `--username` (default: `""`) + + chart repository username where to locate the requested chart + - `--version` (default: `""`) set the version on the chart to this semver version @@ -3213,10 +3502,38 @@ nelm chart dependency download CHART [flags] client\-side default throttling limit +- `--ca-file` (default: `""`) + + verify certificates of HTTPS\-enabled servers using this CA bundle + +- `--cert-file` (default: `""`) + + identify HTTPS client using this SSL certificate file + +- `--color` (default: `"auto"`) + + use colored output \(never, auto, always\) + +- `--colour` (default: `"auto"`) + + use colored output \(never, auto, always\) + +- `--content-cache` (default: `"~/.cache/helm/content"`) + + path to the directory containing cached content \(e\.g\. charts\) + - `--debug` (default: `false`) enable verbose output +- `--insecure-skip-tls-verify` (default: `false`) + + skip tls certificate checks for the chart download + +- `--key-file` (default: `""`) + + identify HTTPS client using this SSL key file + - `--keyring` (default: `"~/.gnupg/pubring.gpg"`) keyring containing public keys @@ -3261,6 +3578,14 @@ nelm chart dependency download CHART [flags] namespace scope for this request +- `--password` (default: `""`) + + chart repository password where to locate the requested chart + +- `--plain-http` (default: `false`) + + use insecure HTTP connections for the chart download + - `--qps` (default: `0`) queries per second used when communicating with the Kubernetes API, not including bursting @@ -3271,7 +3596,7 @@ nelm chart dependency download CHART [flags] - `--repository-cache` (default: `"~/.cache/helm/repository"`) - path to the file containing cached repository indexes + path to the directory containing cached repository indexes - `--repository-config` (default: `"~/.config/helm/repositories.yaml"`) @@ -3281,6 +3606,10 @@ nelm chart dependency download CHART [flags] do not refresh the local repository cache +- `--username` (default: `""`) + + chart repository username where to locate the requested chart + - `--verify` (default: `false`) verify the packages against signatures @@ -3315,10 +3644,38 @@ nelm chart dependency update CHART [flags] client\-side default throttling limit +- `--ca-file` (default: `""`) + + verify certificates of HTTPS\-enabled servers using this CA bundle + +- `--cert-file` (default: `""`) + + identify HTTPS client using this SSL certificate file + +- `--color` (default: `"auto"`) + + use colored output \(never, auto, always\) + +- `--colour` (default: `"auto"`) + + use colored output \(never, auto, always\) + +- `--content-cache` (default: `"~/.cache/helm/content"`) + + path to the directory containing cached content \(e\.g\. charts\) + - `--debug` (default: `false`) enable verbose output +- `--insecure-skip-tls-verify` (default: `false`) + + skip tls certificate checks for the chart download + +- `--key-file` (default: `""`) + + identify HTTPS client using this SSL key file + - `--keyring` (default: `"~/.gnupg/pubring.gpg"`) keyring containing public keys @@ -3363,6 +3720,14 @@ nelm chart dependency update CHART [flags] namespace scope for this request +- `--password` (default: `""`) + + chart repository password where to locate the requested chart + +- `--plain-http` (default: `false`) + + use insecure HTTP connections for the chart download + - `--qps` (default: `0`) queries per second used when communicating with the Kubernetes API, not including bursting @@ -3373,7 +3738,7 @@ nelm chart dependency update CHART [flags] - `--repository-cache` (default: `"~/.cache/helm/repository"`) - path to the file containing cached repository indexes + path to the directory containing cached repository indexes - `--repository-config` (default: `"~/.config/helm/repositories.yaml"`) @@ -3383,83 +3748,23 @@ nelm chart dependency update CHART [flags] do not refresh the local repository cache -- `--verify` (default: `false`) - - verify the packages against signatures - - -### chart ts init - -Initialize the files needed to render manifests using TypeScript\. If PATH is not specified, uses the current directory\. - -**Usage:** - -```shell -nelm chart ts init [PATH] -``` - -**TypeScript options:** - -- `--deno-binary-path` (default: `""`) - - Path to the Deno binary to use instead of auto\-downloading\. Vars: \$NELM\_DENO\_BINARY\_PATH, \$NELM\_CHART\_TS\_INIT\_DENO\_BINARY\_PATH - - -**Other options:** - -- `--color-mode` (default: `"auto"`) - - Color mode for logs\. Allowed: auto, off, on\. Vars: \$NELM\_COLOR\_MODE, \$NELM\_CHART\_TS\_INIT\_COLOR\_MODE - -- `--log-level` (default: `"info"`) - - Set log level\. Allowed: silent, error, warning, info, debug, trace\. Vars: \$NELM\_LOG\_LEVEL, \$NELM\_CHART\_TS\_INIT\_LOG\_LEVEL - -- `--temp-dir` (default: `""`) - - The directory for temporary files\. By default, create a new directory in the default system directory for temporary files\. Var: \$NELM\_TEMP\_DIR - - -### chart ts build - -Build TypeScript chart in the specified directory\. If PATH is not specified, uses the current directory\. - -**Usage:** - -```shell -nelm chart ts build [PATH] -``` - -**TypeScript options:** - -- `--deno-binary-path` (default: `""`) - - Path to the Deno binary to use instead of auto\-downloading\. Vars: \$NELM\_DENO\_BINARY\_PATH, \$NELM\_CHART\_TS\_BUILD\_DENO\_BINARY\_PATH - - -**Other options:** - -- `--color-mode` (default: `"auto"`) - - Color mode for logs\. Allowed: auto, off, on\. Vars: \$NELM\_COLOR\_MODE, \$NELM\_CHART\_TS\_BUILD\_COLOR\_MODE - -- `--log-level` (default: `"info"`) +- `--username` (default: `""`) - Set log level\. Allowed: silent, error, warning, info, debug, trace\. Vars: \$NELM\_LOG\_LEVEL, \$NELM\_CHART\_TS\_BUILD\_LOG\_LEVEL + chart repository username where to locate the requested chart -- `--temp-dir` (default: `""`) +- `--verify` (default: `false`) - The directory for temporary files\. By default, create a new directory in the default system directory for temporary files\. Var: \$NELM\_TEMP\_DIR + verify the packages against signatures -### repo add +### chart repo add Set up a new chart repository\. **Usage:** ```shell -nelm repo add [NAME] [URL] [flags] +nelm chart repo add [NAME] [URL] [flags] ``` **Other options:** @@ -3480,6 +3785,18 @@ nelm repo add [NAME] [URL] [flags] identify HTTPS client using this SSL certificate file +- `--color` (default: `"auto"`) + + use colored output \(never, auto, always\) + +- `--colour` (default: `"auto"`) + + use colored output \(never, auto, always\) + +- `--content-cache` (default: `"~/.cache/helm/content"`) + + path to the directory containing cached content \(e\.g\. charts\) + - `--debug` (default: `false`) enable verbose output @@ -3536,10 +3853,6 @@ nelm repo add [NAME] [URL] [flags] namespace scope for this request -- `--no-update` (default: `false`) - - Ignored\. Formerly, it would disabled forced updates\. It is deprecated by force\-update\. - - `--pass-credentials` (default: `false`) pass credentials to all domains @@ -3562,25 +3875,29 @@ nelm repo add [NAME] [URL] [flags] - `--repository-cache` (default: `"~/.cache/helm/repository"`) - path to the file containing cached repository indexes + path to the directory containing cached repository indexes - `--repository-config` (default: `"~/.config/helm/repositories.yaml"`) path to the file containing repository names and URLs +- `--timeout` (default: `2m0s`) + + time to wait for the index file download to complete + - `--username` (default: `""`) chart repository username -### repo remove +### chart repo remove Remove a chart repository\. **Usage:** ```shell -nelm repo remove [REPO1 [REPO2 ...]] [flags] +nelm chart repo remove [REPO1 [REPO2 ...]] [flags] ``` **Other options:** @@ -3589,6 +3906,18 @@ nelm repo remove [REPO1 [REPO2 ...]] [flags] client\-side default throttling limit +- `--color` (default: `"auto"`) + + use colored output \(never, auto, always\) + +- `--colour` (default: `"auto"`) + + use colored output \(never, auto, always\) + +- `--content-cache` (default: `"~/.cache/helm/content"`) + + path to the directory containing cached content \(e\.g\. charts\) + - `--debug` (default: `false`) enable verbose output @@ -3643,21 +3972,21 @@ nelm repo remove [REPO1 [REPO2 ...]] [flags] - `--repository-cache` (default: `"~/.cache/helm/repository"`) - path to the file containing cached repository indexes + path to the directory containing cached repository indexes - `--repository-config` (default: `"~/.config/helm/repositories.yaml"`) path to the file containing repository names and URLs -### repo update +### chart repo update Update info about available charts for all chart repositories\. **Usage:** ```shell -nelm repo update [REPO1 [REPO2 ...]] [flags] +nelm chart repo update [REPO1 [REPO2 ...]] [flags] ``` **Other options:** @@ -3666,13 +3995,21 @@ nelm repo update [REPO1 [REPO2 ...]] [flags] client\-side default throttling limit -- `--debug` (default: `false`) +- `--color` (default: `"auto"`) - enable verbose output + use colored output \(never, auto, always\) -- `--fail-on-repo-update-fail` (default: `false`) +- `--colour` (default: `"auto"`) - update fails if any of the repository updates fail + use colored output \(never, auto, always\) + +- `--content-cache` (default: `"~/.cache/helm/content"`) + + path to the directory containing cached content \(e\.g\. charts\) + +- `--debug` (default: `false`) + + enable verbose output - `--kube-apiserver` (default: `""`) @@ -3724,21 +4061,25 @@ nelm repo update [REPO1 [REPO2 ...]] [flags] - `--repository-cache` (default: `"~/.cache/helm/repository"`) - path to the file containing cached repository indexes + path to the directory containing cached repository indexes - `--repository-config` (default: `"~/.config/helm/repositories.yaml"`) path to the file containing repository names and URLs +- `--timeout` (default: `2m0s`) -### repo login + time to wait for the index file download to complete + + +### chart repo login Log in to an OCI registry with charts\. **Usage:** ```shell -nelm repo login [host] [flags] +nelm chart repo login [host] [flags] ``` **Other options:** @@ -3755,6 +4096,18 @@ nelm repo login [host] [flags] identify registry client using this SSL certificate file +- `--color` (default: `"auto"`) + + use colored output \(never, auto, always\) + +- `--colour` (default: `"auto"`) + + use colored output \(never, auto, always\) + +- `--content-cache` (default: `"~/.cache/helm/content"`) + + path to the directory containing cached content \(e\.g\. charts\) + - `--debug` (default: `false`) enable verbose output @@ -3815,6 +4168,10 @@ nelm repo login [host] [flags] read password or identity token from stdin +- `--plain-http` (default: `false`) + + use insecure HTTP connections for the chart upload + - `--qps` (default: `0`) queries per second used when communicating with the Kubernetes API, not including bursting @@ -3825,7 +4182,7 @@ nelm repo login [host] [flags] - `--repository-cache` (default: `"~/.cache/helm/repository"`) - path to the file containing cached repository indexes + path to the directory containing cached repository indexes - `--repository-config` (default: `"~/.config/helm/repositories.yaml"`) @@ -3836,14 +4193,14 @@ nelm repo login [host] [flags] registry username -### repo logout +### chart repo logout Log out from an OCI registry with charts\. **Usage:** ```shell -nelm repo logout [host] [flags] +nelm chart repo logout [host] [flags] ``` **Other options:** @@ -3852,6 +4209,18 @@ nelm repo logout [host] [flags] client\-side default throttling limit +- `--color` (default: `"auto"`) + + use colored output \(never, auto, always\) + +- `--colour` (default: `"auto"`) + + use colored output \(never, auto, always\) + +- `--content-cache` (default: `"~/.cache/helm/content"`) + + path to the directory containing cached content \(e\.g\. charts\) + - `--debug` (default: `false`) enable verbose output @@ -3906,13 +4275,77 @@ nelm repo logout [host] [flags] - `--repository-cache` (default: `"~/.cache/helm/repository"`) - path to the file containing cached repository indexes + path to the directory containing cached repository indexes - `--repository-config` (default: `"~/.config/helm/repositories.yaml"`) path to the file containing repository names and URLs +### chart ts init + +Initialize the files needed to render manifests using TypeScript\. If PATH is not specified, uses the current directory\. + +**Usage:** + +```shell +nelm chart ts init [PATH] +``` + +**TypeScript options:** + +- `--deno-binary-path` (default: `""`) + + Path to the Deno binary to use instead of auto\-downloading\. Vars: \$NELM\_DENO\_BINARY\_PATH, \$NELM\_CHART\_TS\_INIT\_DENO\_BINARY\_PATH + + +**Other options:** + +- `--color-mode` (default: `"auto"`) + + Color mode for logs\. Allowed: auto, off, on\. Vars: \$NELM\_COLOR\_MODE, \$NELM\_CHART\_TS\_INIT\_COLOR\_MODE + +- `--log-level` (default: `"info"`) + + Set log level\. Allowed: silent, error, warning, info, debug, trace\. Vars: \$NELM\_LOG\_LEVEL, \$NELM\_CHART\_TS\_INIT\_LOG\_LEVEL + +- `--temp-dir` (default: `""`) + + The directory for temporary files\. By default, create a new directory in the default system directory for temporary files\. Var: \$NELM\_TEMP\_DIR + + +### chart ts build + +Build TypeScript chart in the specified directory\. If PATH is not specified, uses the current directory\. + +**Usage:** + +```shell +nelm chart ts build [PATH] +``` + +**TypeScript options:** + +- `--deno-binary-path` (default: `""`) + + Path to the Deno binary to use instead of auto\-downloading\. Vars: \$NELM\_DENO\_BINARY\_PATH, \$NELM\_CHART\_TS\_BUILD\_DENO\_BINARY\_PATH + + +**Other options:** + +- `--color-mode` (default: `"auto"`) + + Color mode for logs\. Allowed: auto, off, on\. Vars: \$NELM\_COLOR\_MODE, \$NELM\_CHART\_TS\_BUILD\_COLOR\_MODE + +- `--log-level` (default: `"info"`) + + Set log level\. Allowed: silent, error, warning, info, debug, trace\. Vars: \$NELM\_LOG\_LEVEL, \$NELM\_CHART\_TS\_BUILD\_LOG\_LEVEL + +- `--temp-dir` (default: `""`) + + The directory for temporary files\. By default, create a new directory in the default system directory for temporary files\. Var: \$NELM\_TEMP\_DIR + + ### completion bash Generate the autocompletion script for the bash shell\. @@ -4075,48 +4508,12 @@ nelm version [options...] Feature gates are experimental features that can be enabled via environment variables. -### NELM_FEAT_REMOTE_CHARTS - -**Default:** `false` - -Allow not only local, but also remote charts as an argument to cli commands\. Also adds the "\-\-chart\-version" option - ### NELM_FEAT_PERIODIC_STACK_TRACES **Default:** `false` Print stack traces periodically to help with debugging deadlocks and other issues -### NELM_FEAT_FIELD_SENSITIVE - -**Default:** `false` - -Enable JSONPath\-based selective sensitive field redaction - -### NELM_FEAT_PREVIEW_V2 - -**Default:** `false` - -Activate all feature gates that will be enabled by default in Nelm v2 - -### NELM_FEAT_CLEAN_NULL_FIELDS - -**Default:** `false` - -Enable cleaning of null fields from resource manifests for better Helm chart compatibility - -### NELM_FEAT_MORE_DETAILED_EXIT_CODE_FOR_PLAN - -**Default:** `false` - -Make the "plan" command with the flag "\-\-exit\-code" return an exit code 3 instead of 2 when no resource changes, but still must install the release - -### NELM_FEAT_RESOURCE_VALIDATION - -**Default:** `false` - -Validate chart resources against specific Kubernetes resources' schemas - ### NELM_FEAT_TYPESCRIPT **Default:** `false` diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/charts/alpine-0.1.0.tgz b/pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/charts/alpine-0.1.0.tgz index 21e50abe68ecea525af6daacd6ce4880658ff4be..449601875da94f5cf0b71f870968f86a515afa14 100644 GIT binary patch delta 1023 zcmV#6Gs`DRP)mMp|?~DwFQJF8lJ+n4<7iI%jynFl|{KKXb!oxrI`WyjmuyD*XJgXw&M#l7CUcqL+5ozfcVvDH{*+=g?8%HXP(*C4)foqp?LN%IyA$G#4rm85=rLCXh z;gZXt5kzJDI)1|#i+}DrnkS{H|2OZh-+uQlDY8@S@m&8uyS(c5|BUtb|Fg)e<`O@L z>wh-K{hve;a&8peRMICuxfbMZ_%4Awy!9thp8*SxL9n>*K^Ks5O{rCS+olSa*EXcLt&&ccw#-4zZ4MZvs&b#y%2hZl$dWcK z_=5}X0#;nmJ@BJ!YkWd?d2-R|h8NH(nRrCekE((cCHUef1lWg;2&M;f+X>0=VDU>I z`#~E1)1B4$%ZdI1JbVA2Ogj9Zj@Y>8|2f2xv91P({{TJ=1|baoJ~7(M>7?PmYX(n6 t|3AV1?)`sqHD*2k&mqVBcVW=ezn5Nm>E*v5zXt#S|NpW5nymmJ005Os^zQ%w delta 1023 zcmV}on2U$F}|8c%2A3t%VE<&jjzqP}2%d9SW<{}92Fe_|L|y%m2%(%bx$|kPn`g#dgpz^QHCa<-F4H-%?Quv}A^1U{I>;t7U^{_&=UZ z#~uF9rjwrkXOT|*zvVKU!$*}x1zF6{;yeNfwL9MJk{c3)h7y-H_WQwZmwXD9BMih5 zfWRdQsiQ4kC z5~%%N4oZ_Ll{9=Q$l)b^(Ni|e;el(uC%xe*!DsljQh%0Uzkk{lTpEiqr5I8rHzZxB z1qI%c0d9fNY`sr70k(QKfK~wt0cr&{C-~-o>I?`C7T6pu)T9YgO=z9-j5j%~>N@00 zrnWjB8S0L_YvtpmhZb(PgCtxtt`AM-L+5bw*lMgYZo@f4WpLdn*fy5_LkP@(d>@wK zz1b!NCVxQaa=Z~oVH=r}QWvp}9wt|{fn3#2hsrq58(x%x0zwjI)m#V=O5KK_gA$FQ zY`|rh<>68kf--j?7PbAT1W&gKyk9vZxdEvxtW+hl*KWWAy6xO?X0*ae*k(r$w%fIX z@dw(Fjv+Y$-tQeIf)RY3Td@UIT3+zqD1+t{ntxdFXoYf7aR@mSV7AiYop;X>E_{a0 z#{Srj=FI;{yT-{KB_nX#TI7Upx|7Vd`%_V*g z*MDt}`#*^y01M z;Jy|$KLZvXgJ5yrgDxQBno_IuwoMf-uWd+gTP2+?ZJC3b+Z-@TRpmaZm8)=AkR@$e z@CO&%1+2KBd*Dae*7$_(^5mk^4KJWoGI)uiA5{e@O7O)|2(S+w5lj!}wiA-!!Qz)b z_JcJ1r#q|hmlOR3c=rB3nRNI+9kE%@|8s~XV_gjn{{eg$3_=+EePXni(@DdB*9@MD t{(pl1-TVLKYCP%re-1h3zYBw&{=M|lOE3Qg`8@yt|NkY6VG#fz004V``C Date: Thu, 9 Jul 2026 15:22:35 +0300 Subject: [PATCH 45/85] fix: resolve issues after conflicts Signed-off-by: Dmitry Mordvinov --- pkg/common/common.go | 5 +++-- pkg/helm/pkg/provenance/sign_test.go | 9 ++++----- pkg/resource/dependency.go | 14 +++++++------- pkg/resource/resource.go | 13 +++++-------- pkg/resource/resource_policy_test.go | 6 +----- pkg/resource/resource_test.go | 14 +++++++------- 6 files changed, 27 insertions(+), 34 deletions(-) diff --git a/pkg/common/common.go b/pkg/common/common.go index 30c9c5da..e7c6343e 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -118,8 +118,9 @@ const ( DefaultFieldManager = "helm" DefaultLocalKubeVersion = "1.36.0" DefaultLogColorMode = log.LogColorModeAuto - DefaultMapperNoMatchRetryInterval = 500 * time.Millisecond - DefaultMapperNoMatchRetryTimeout = 10 * time.SecondDefaultNetworkParallelism = 30 + DefaultMapperNoMatchRetryInterval = 500 * time.Millisecond + DefaultMapperNoMatchRetryTimeout = 10 * time.Second + DefaultNetworkParallelism = 30 DefaultProgressPrintInterval = 5 * time.Second DefaultQPSLimit = 30 DefaultReleaseHistoryLimit = 10 diff --git a/pkg/helm/pkg/provenance/sign_test.go b/pkg/helm/pkg/provenance/sign_test.go index daccfa90..a06cd3b0 100644 --- a/pkg/helm/pkg/provenance/sign_test.go +++ b/pkg/helm/pkg/provenance/sign_test.go @@ -32,7 +32,6 @@ import ( "sigs.k8s.io/yaml" "github.com/werf/nelm/pkg/helm/pkg/chart/v2/loader" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" ) const ( @@ -112,7 +111,7 @@ func TestMessageBlock(t *testing.T) { t.Fatal(err) } - out, err := messageBlock(archiveData, filepath.Base(testChartfile, helmopts.HelmOptions{}), metadataBytes) + out, err := messageBlock(archiveData, filepath.Base(testChartfile), metadataBytes) if err != nil { t.Fatal(err) } @@ -263,7 +262,7 @@ func TestClearSign(t *testing.T) { t.Fatal(err) } - sig, err := signer.ClearSign(archiveData, filepath.Base(testChartfile, helmopts.HelmOptions{}), metadataBytes) + sig, err := signer.ClearSign(archiveData, filepath.Base(testChartfile), metadataBytes) if err != nil { t.Fatal(err) } @@ -352,7 +351,7 @@ func TestClearSignError(t *testing.T) { t.Fatal(err) } - sig, err := signer.ClearSign(archiveData, filepath.Base(testChartfile, helmopts.HelmOptions{}), metadataBytes) + sig, err := signer.ClearSign(archiveData, filepath.Base(testChartfile), metadataBytes) if err == nil { t.Fatal("didn't get an error from ClearSign but expected one") } @@ -369,7 +368,7 @@ func TestVerify(t *testing.T) { } // Read the chart file data - archiveData, err := os.ReadFile(testChartfile, helmopts.HelmOptions{}) + archiveData, err := os.ReadFile(testChartfile) if err != nil { t.Fatal(err) } diff --git a/pkg/resource/dependency.go b/pkg/resource/dependency.go index a911e228..8397d6c9 100644 --- a/pkg/resource/dependency.go +++ b/pkg/resource/dependency.go @@ -223,7 +223,7 @@ func parseContainer(unstruct *unstructured.Unstructured, container interface{}) return dependencies, len(dependencies) > 0 } -func parseBindingDependenciesForWorkload(pod interface{}, otherUnstructs []*unstructured.Unstructured) (dependencies []*InternalDependency, found bool) { +func parseBindingDependenciesForWorkload(pod interface{}, otherUnstructs []*unstructured.Unstructured) (dependencies []*Dependency, found bool) { serviceAccountName, _ := nestedStringNotEmpty(pod, "spec", "serviceAccountName") if serviceAccountName == "" { serviceAccountName, _ = nestedStringNotEmpty(pod, "spec", "serviceAccount") @@ -256,7 +256,7 @@ func parseBindingDependenciesForWorkload(pod interface{}, otherUnstructs []*unst continue } - dep := &InternalDependency{ + dep := &Dependency{ ResourceMatcher: newExactResourceMatcher(bindingUnstruct), ResourceState: common.ResourceStatePresent, } @@ -269,7 +269,7 @@ func parseBindingDependenciesForWorkload(pod interface{}, otherUnstructs []*unst return dependencies, len(dependencies) > 0 } -func parseBindingSubjects(unstruct *unstructured.Unstructured) (dependencies []*InternalDependency, found bool) { +func parseBindingSubjects(unstruct *unstructured.Unstructured) (dependencies []*Dependency, found bool) { subjects, _ := nestedSlice(unstruct.Object, "subjects") for _, subj := range subjects { kind, found := nestedString(subj, "kind") @@ -282,7 +282,7 @@ func parseBindingSubjects(unstruct *unstructured.Unstructured) (dependencies []* continue } - dep := &InternalDependency{ + dep := &Dependency{ ResourceMatcher: &spec.ResourceMatcher{ Names: []string{serviceAccountName}, Namespaces: []string{unstruct.GetNamespace()}, @@ -597,7 +597,7 @@ func parseRuntimeClassName(pod interface{}) (dep *Dependency, found bool) { return dep, true } -func parseScaleTargetRef(unstruct *unstructured.Unstructured) (dep *InternalDependency, found bool) { +func parseScaleTargetRef(unstruct *unstructured.Unstructured) (dep *Dependency, found bool) { name, found := nestedStringNotEmpty(unstruct.Object, "spec", "scaleTargetRef", "name") if !found { return nil, false @@ -618,7 +618,7 @@ func parseScaleTargetRef(unstruct *unstructured.Unstructured) (dep *InternalDepe return nil, false } - dep = &InternalDependency{ + dep = &Dependency{ ResourceMatcher: &spec.ResourceMatcher{ Names: []string{name}, Namespaces: []string{unstruct.GetNamespace()}, @@ -738,7 +738,7 @@ func parseServiceAccountDependencies(unstruct *unstructured.Unstructured, otherU continue } - dep := &InternalDependency{ + dep := &Dependency{ ResourceMatcher: newExactResourceMatcher(otherUnstruct), ResourceState: common.ResourceStateAbsent, } diff --git a/pkg/resource/resource.go b/pkg/resource/resource.go index 146dd20c..627ad846 100644 --- a/pkg/resource/resource.go +++ b/pkg/resource/resource.go @@ -52,8 +52,8 @@ type InstallableResource struct { // Construct an InstallableResource from a ResourceSpec. Must never contact the cluster, because // this is called even when no cluster access allowed. -func NewInstallableResource(ctx context.Context, res *spec.ResourceSpec, otherResourceSpecs []*spec.ResourceSpec, otherResSpecs []*spec.ResourceSpec, releaseNamespace string, opts InstallableResourceOptions) (*InstallableResource, error) { - otherResourceMetaList := lo.Map(otherResourceSpecs, func(resSpec *spec.ResourceSpec, _ int) *spec.ResourceMeta { +func NewInstallableResource(ctx context.Context, res *spec.ResourceSpec, otherResSpecs []*spec.ResourceSpec, releaseNamespace string, opts InstallableResourceOptions) (*InstallableResource, error) { + otherResourceMetaList := lo.Map(otherResSpecs, func(resSpec *spec.ResourceSpec, _ int) *spec.ResourceMeta { return resSpec.ResourceMeta }) if err := validateHook(res.ResourceMeta); err != nil { @@ -159,7 +159,6 @@ type DeletableResource struct { AutoInternalDependencies []*Dependency DeletePropagation metav1.DeletionPropagation - KeepOnDelete bool ManualDependencies []*Dependency Ownership common.Ownership ResourcePolicies []common.ResourcePolicy @@ -205,7 +204,6 @@ func NewDeletableResource(resourceSpec *spec.ResourceSpec, otherResourceSpecs [] ResourceMeta: resourceSpec.ResourceMeta, AutoInternalDependencies: internalDeleteDependencies(resourceSpec.Unstruct, unstructList), DeletePropagation: delPropagation, - KeepOnDelete: keep, ManualDependencies: manualDeleteDependencies(resourceSpec.ResourceMeta, otherResourceMetaList), Ownership: owner, ResourcePolicies: policies, @@ -239,7 +237,7 @@ func BuildResources(ctx context.Context, deployType common.DeployType, releaseNa var prevRelInstResources []*InstallableResource for _, resSpec := range prevRelResSpecs { - installableResource, err := NewInstallableResource(ctx, resSpec, lo.Without(prevRelResSpecs, resSpec), lo.Without(prevRelResSpecs, resSpec), releaseNamespace, InstallableResourceOptions{ + installableResource, err := NewInstallableResource(ctx, resSpec, lo.Without(prevRelResSpecs, resSpec), releaseNamespace, InstallableResourceOptions{ DefaultDeletePropagation: opts.DefaultDeletePropagation, NoPodLogs: opts.NoPodLogs, }) @@ -252,7 +250,7 @@ func BuildResources(ctx context.Context, deployType common.DeployType, releaseNa var newRelInstResources []*InstallableResource for _, resSpec := range newRelResSpecs { - installableResource, err := NewInstallableResource(ctx, resSpec, lo.Without(newRelResSpecs, resSpec), lo.Without(newRelResSpecs, resSpec), releaseNamespace, InstallableResourceOptions{ + installableResource, err := NewInstallableResource(ctx, resSpec, lo.Without(newRelResSpecs, resSpec), releaseNamespace, InstallableResourceOptions{ DefaultDeletePropagation: opts.DefaultDeletePropagation, NoPodLogs: opts.NoPodLogs, }) @@ -352,9 +350,8 @@ func BuildResources(ctx context.Context, deployType common.DeployType, releaseNa var instResources []*InstallableResource for _, resSpec := range patchedResSpecs { - instRes, err := NewInstallableResource(ctx, resSpec, lo.Without(patchedResSpecs, resSpec), newRelResSpecs, releaseNamespace, InstallableResourceOptions{ + instRes, err := NewInstallableResource(ctx, resSpec, lo.Without(patchedResSpecs, resSpec), releaseNamespace, InstallableResourceOptions{ DefaultDeletePropagation: opts.DefaultDeletePropagation, - NoPodLogs: opts.NoPodLogs, NoPodLogs: opts.NoPodLogs, }) if err != nil { diff --git a/pkg/resource/resource_policy_test.go b/pkg/resource/resource_policy_test.go index 0d18a913..507bcbf3 100644 --- a/pkg/resource/resource_policy_test.go +++ b/pkg/resource/resource_policy_test.go @@ -10,7 +10,6 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "github.com/werf/nelm/pkg/common" - "github.com/werf/nelm/pkg/kube/fake" "github.com/werf/nelm/pkg/resource" "github.com/werf/nelm/pkg/resource/spec" ) @@ -170,10 +169,7 @@ func chartInstallableResource(t *testing.T, annotations map[string]string) *reso resSpec.SetAnnotations(annotations) } - clientFactory, err := fake.NewClientFactory(context.Background()) - require.NoError(t, err) - - localRes, err := resource.NewInstallableResource(resSpec, nil, resourcePolicyTestNamespace, clientFactory, resource.InstallableResourceOptions{}) + localRes, err := resource.NewInstallableResource(context.Background(), resSpec, nil, resourcePolicyTestNamespace, resource.InstallableResourceOptions{}) require.NoError(t, err) return localRes diff --git a/pkg/resource/resource_test.go b/pkg/resource/resource_test.go index 932e495e..51354c63 100644 --- a/pkg/resource/resource_test.go +++ b/pkg/resource/resource_test.go @@ -500,7 +500,7 @@ func (s *InstallableResourceSuite) TestNewInstallableResourceForDependencies() { { expect: func(resSpec *spec.ResourceSpec) *resource.InstallableResource { res := defaultInstallableResource(resSpec) - res.AutoInternalDependencies = []*resource.InternalDependency{ + res.AutoInternalDependencies = []*resource.Dependency{ { ResourceMatcher: &spec.ResourceMatcher{ Names: []string{"test-cr"}, @@ -550,7 +550,7 @@ func (s *InstallableResourceSuite) TestNewInstallableResourceForDependencies() { { expect: func(resSpec *spec.ResourceSpec) *resource.InstallableResource { res := defaultDeploymentInstallableResource(resSpec) - res.AutoInternalDependencies = []*resource.InternalDependency{ + res.AutoInternalDependencies = []*resource.Dependency{ { ResourceMatcher: &spec.ResourceMatcher{ Names: []string{"my-sa"}, @@ -611,7 +611,7 @@ func (s *InstallableResourceSuite) TestNewInstallableResourceForDependencies() { { expect: func(resSpec *spec.ResourceSpec) *resource.InstallableResource { res := defaultDeploymentInstallableResource(resSpec) - res.AutoInternalDependencies = []*resource.InternalDependency{ + res.AutoInternalDependencies = []*resource.Dependency{ { ResourceMatcher: &spec.ResourceMatcher{ Names: []string{"my-sa"}, @@ -706,7 +706,7 @@ func (s *InstallableResourceSuite) TestNewInstallableResourceForDependencies() { { expect: func(resSpec *spec.ResourceSpec) *resource.InstallableResource { res := defaultInstallableResource(resSpec) - res.AutoInternalDependencies = []*resource.InternalDependency{ + res.AutoInternalDependencies = []*resource.Dependency{ { ResourceMatcher: &spec.ResourceMatcher{ Names: []string{"test-statefulset"}, @@ -743,7 +743,7 @@ func (s *InstallableResourceSuite) TestNewInstallableResourceForDependencies() { { expect: func(resSpec *spec.ResourceSpec) *resource.InstallableResource { res := defaultInstallableResource(resSpec) - res.AutoInternalDependencies = []*resource.InternalDependency{ + res.AutoInternalDependencies = []*resource.Dependency{ { ResourceMatcher: &spec.ResourceMatcher{ Names: []string{"test-deployment"}, @@ -800,7 +800,7 @@ func (s *InstallableResourceSuite) TestNewInstallableResourceForDependencies() { { expect: func(resSpec *spec.ResourceSpec) *resource.InstallableResource { res := defaultInstallableResource(resSpec) - res.AutoInternalDependencies = []*resource.InternalDependency{ + res.AutoInternalDependencies = []*resource.Dependency{ { ResourceMatcher: &spec.ResourceMatcher{ Names: []string{"test-rollout"}, @@ -1424,7 +1424,7 @@ func (s *DeletableResourceSuite) TestNewDeletableResourceForAutoDependencies() { { expectFunc: func(resSpec *spec.ResourceSpec) *resource.DeletableResource { res := defaultDeletableResource(resSpec.ResourceMeta) - res.AutoInternalDependencies = []*resource.InternalDependency{ + res.AutoInternalDependencies = []*resource.Dependency{ { ResourceMatcher: &spec.ResourceMatcher{ Names: []string{"test-crb"}, From ee64bf7b1ad298ac26481caae2611e949a63ac6a Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Thu, 9 Jul 2026 15:45:59 +0300 Subject: [PATCH 46/85] chore: bump kubedog Signed-off-by: Dmitry Mordvinov --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 93c3e13a..e4d0c18c 100644 --- a/go.mod +++ b/go.mod @@ -57,7 +57,7 @@ require ( github.com/tidwall/sjson v1.2.5 github.com/wI2L/jsondiff v0.7.0 github.com/werf/common-go v0.0.0-20260428201303-b0dcadceca5c - github.com/werf/kubedog v0.13.1-0.20260521123751-aabed6e8f8ac + github.com/werf/kubedog v0.13.1-0.20260709123314-5d578345082f github.com/werf/lockgate v0.1.1 github.com/werf/logboek v0.6.1 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e diff --git a/go.sum b/go.sum index efa5b736..6fdb5e71 100644 --- a/go.sum +++ b/go.sum @@ -410,6 +410,8 @@ github.com/werf/kubedog v0.13.1-0.20260320165832-7d97aaf7aab9 h1:N+XKTPiXT5pf5lx github.com/werf/kubedog v0.13.1-0.20260320165832-7d97aaf7aab9/go.mod h1:93L6aIdpj7iIhL30Obkv7bWgUyTeuxas1ijtzjmyb4Q= github.com/werf/kubedog v0.13.1-0.20260521123751-aabed6e8f8ac h1:iQlaWzQdWkl/Ad6SZ0a0Un3WQNPz7OmzJ3RBFdj7ICk= github.com/werf/kubedog v0.13.1-0.20260521123751-aabed6e8f8ac/go.mod h1:tU8RyC5qXvJMPT2LmaNnVINWvwDHao0Ca3Bw9P8JC3E= +github.com/werf/kubedog v0.13.1-0.20260709123314-5d578345082f h1:A3dA97W1cJwUP9CegXunkiJ6gAhek7hyDXP89P7P8r0= +github.com/werf/kubedog v0.13.1-0.20260709123314-5d578345082f/go.mod h1:tU8RyC5qXvJMPT2LmaNnVINWvwDHao0Ca3Bw9P8JC3E= github.com/werf/lockgate v0.1.1 h1:S400JFYjtWfE4i4LY9FA8zx0fMdfui9DPrBiTciCrx4= github.com/werf/lockgate v0.1.1/go.mod h1:0yIFSLq9ausy6ejNxF5uUBf/Ib6daMAfXuCaTMZJzIE= github.com/werf/logboek v0.6.1 h1:oEe6FkmlKg0z0n80oZjLplj6sXcBeLleCkjfOOZEL2g= From 9241ea8a51d07fbe6c8b6f41c470752eda8e2a15 Mon Sep 17 00:00:00 2001 From: Ilya Lesikov Date: Fri, 10 Jul 2026 15:29:24 +0300 Subject: [PATCH 47/85] feat: add --diff-patches and patches.yaml support Signed-off-by: Ilya Lesikov --- cmd/nelm/common_flags.go | 19 + cmd/nelm/release_install.go | 4 + cmd/nelm/release_plan_install.go | 4 + cmd/nelm/release_rollback.go | 4 + cmd/nelm/release_uninstall.go | 4 + docs/reference.md | 35 ++ go.mod | 2 + go.sum | 4 + pkg/action/common.go | 22 + pkg/action/release_install.go | 12 + pkg/action/release_plan_install.go | 6 + pkg/action/release_rollback.go | 12 + pkg/action/release_uninstall.go | 19 + pkg/common/options.go | 11 + .../issue-7233/charts/alpine-0.1.0.tgz | Bin 1166 -> 1166 bytes pkg/plan/resource_info.go | 37 +- pkg/plan/resource_info_test.go | 51 +- pkg/resource/spec/diff_patch.go | 353 +++++++++++++ pkg/resource/spec/diff_patch_test.go | 490 ++++++++++++++++++ pkg/resource/spec/export_test.go | 15 + pkg/resource/spec/resource_match.go | 190 ++++--- 21 files changed, 1228 insertions(+), 66 deletions(-) create mode 100644 pkg/resource/spec/diff_patch.go create mode 100644 pkg/resource/spec/diff_patch_test.go create mode 100644 pkg/resource/spec/export_test.go diff --git a/cmd/nelm/common_flags.go b/cmd/nelm/common_flags.go index 5766b6ca..3e4b66d9 100644 --- a/cmd/nelm/common_flags.go +++ b/cmd/nelm/common_flags.go @@ -317,6 +317,25 @@ func AddKubeConnectionFlags(cmd *cobra.Command, cfg *common.KubeConnectionOption return nil } +func AddPatchesFlags(cmd *cobra.Command, patchesFiles *[]string, defaultPatchesDisable *bool) error { + if err := cli.AddFlag(cmd, patchesFiles, "patches", []string{}, "Additional patches files (diff patches for drift detection)", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: patchFlagGroup, + Type: cli.FlagTypeFile, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + + if err := cli.AddFlag(cmd, defaultPatchesDisable, "no-default-patches", false, "Ignore patches.yaml of the top-level chart and subcharts", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: patchFlagGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + + return nil +} + func AddResourceValidationFlags(cmd *cobra.Command, cfg *common.ResourceValidationOptions) error { if err := cli.AddFlag(cmd, &cfg.NoResourceValidation, "no-resource-validation", false, "Disable resource validation", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, diff --git a/cmd/nelm/release_install.go b/cmd/nelm/release_install.go index 0a5a7b25..cc1a375d 100644 --- a/cmd/nelm/release_install.go +++ b/cmd/nelm/release_install.go @@ -77,6 +77,10 @@ func newReleaseInstallCommand(ctx context.Context, afterAllCommandsBuiltFuncs ma return fmt.Errorf("add secret values flags: %w", err) } + if err := AddPatchesFlags(cmd, &cfg.PatchesFiles, &cfg.DefaultPatchesDisable); err != nil { + return fmt.Errorf("add patches flags: %w", err) + } + if err := AddTrackingFlags(cmd, &cfg.TrackingOptions); err != nil { return fmt.Errorf("add tracking flags: %w", err) } diff --git a/cmd/nelm/release_plan_install.go b/cmd/nelm/release_plan_install.go index 87576e55..7bf9a9ab 100644 --- a/cmd/nelm/release_plan_install.go +++ b/cmd/nelm/release_plan_install.go @@ -77,6 +77,10 @@ func newReleasePlanInstallCommand(ctx context.Context, afterAllCommandsBuiltFunc return fmt.Errorf("add secret values flags: %w", err) } + if err := AddPatchesFlags(cmd, &cfg.PatchesFiles, &cfg.DefaultPatchesDisable); err != nil { + return fmt.Errorf("add patches flags: %w", err) + } + if err := cli.AddFlag(cmd, &cfg.ChartAppVersion, "app-version", "", "Set appVersion of Chart.yaml", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: patchFlagGroup, diff --git a/cmd/nelm/release_rollback.go b/cmd/nelm/release_rollback.go index 10cd7f59..15097342 100644 --- a/cmd/nelm/release_rollback.go +++ b/cmd/nelm/release_rollback.go @@ -65,6 +65,10 @@ func newReleaseRollbackCommand(ctx context.Context, afterAllCommandsBuiltFuncs m return fmt.Errorf("add tracking flags: %w", err) } + if err := AddPatchesFlags(cmd, &cfg.PatchesFiles, &cfg.DefaultPatchesDisable); err != nil { + return fmt.Errorf("add patches flags: %w", err) + } + if err := AddResourceValidationFlags(cmd, &cfg.ResourceValidationOptions); err != nil { return fmt.Errorf("add resource validation flags: %w", err) } diff --git a/cmd/nelm/release_uninstall.go b/cmd/nelm/release_uninstall.go index 35200cfa..b75e9eea 100644 --- a/cmd/nelm/release_uninstall.go +++ b/cmd/nelm/release_uninstall.go @@ -53,6 +53,10 @@ func newReleaseUninstallCommand(ctx context.Context, afterAllCommandsBuiltFuncs return fmt.Errorf("add tracking flags: %w", err) } + if err := AddPatchesFlags(cmd, &cfg.PatchesFiles, &cfg.DefaultPatchesDisable); err != nil { + return fmt.Errorf("add patches flags: %w", err) + } + // TODO: restrict allowed values if err := cli.AddFlag(cmd, &cfg.DefaultDeletePropagation, "delete-propagation", string(common.DefaultDeletePropagation), "Default delete propagation strategy", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, diff --git a/docs/reference.md b/docs/reference.md index 858b5987..37a9fc5a 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -307,6 +307,14 @@ nelm release install [options...] -n namespace -r release [chart-dir|chart-repo- Add labels to all resources\. Vars: \$NELM\_LABELS\_\*, \$NELM\_RELEASE\_INSTALL\_LABELS\_\* +- `--no-default-patches` (default: `false`) + + Ignore patches\.yaml of the top\-level chart and subcharts\. Vars: \$NELM\_NO\_DEFAULT\_PATCHES, \$NELM\_RELEASE\_INSTALL\_NO\_DEFAULT\_PATCHES + +- `--patches` (default: `[]`) + + Additional patches files \(diff patches for drift detection\)\. Vars: \$NELM\_PATCHES, \$NELM\_RELEASE\_INSTALL\_PATCHES + - `--runtime-annotations` (default: `{}`) Add annotations which will not trigger resource updates to all resources\. Vars: \$NELM\_RUNTIME\_ANNOTATIONS\_\*, \$NELM\_RELEASE\_INSTALL\_RUNTIME\_ANNOTATIONS\_\* @@ -652,6 +660,14 @@ nelm release rollback [options...] -n namespace -r release [revision] **Patch options:** +- `--no-default-patches` (default: `false`) + + Ignore patches\.yaml of the top\-level chart and subcharts\. Vars: \$NELM\_NO\_DEFAULT\_PATCHES, \$NELM\_RELEASE\_ROLLBACK\_NO\_DEFAULT\_PATCHES + +- `--patches` (default: `[]`) + + Additional patches files \(diff patches for drift detection\)\. Vars: \$NELM\_PATCHES, \$NELM\_RELEASE\_ROLLBACK\_PATCHES + - `--runtime-annotations` (default: `{}`) Add annotations which will not trigger resource updates to all resources\. Vars: \$NELM\_RUNTIME\_ANNOTATIONS\_\*, \$NELM\_RELEASE\_ROLLBACK\_RUNTIME\_ANNOTATIONS\_\* @@ -1033,6 +1049,14 @@ nelm release plan install [options...] -n namespace -r release [chart-dir|chart- Add labels to all resources\. Vars: \$NELM\_LABELS\_\*, \$NELM\_RELEASE\_PLAN\_INSTALL\_LABELS\_\* +- `--no-default-patches` (default: `false`) + + Ignore patches\.yaml of the top\-level chart and subcharts\. Vars: \$NELM\_NO\_DEFAULT\_PATCHES, \$NELM\_RELEASE\_PLAN\_INSTALL\_NO\_DEFAULT\_PATCHES + +- `--patches` (default: `[]`) + + Additional patches files \(diff patches for drift detection\)\. Vars: \$NELM\_PATCHES, \$NELM\_RELEASE\_PLAN\_INSTALL\_PATCHES + - `--runtime-annotations` (default: `{}`) Add annotations which will not trigger resource updates to all resources\. Vars: \$NELM\_RUNTIME\_ANNOTATIONS\_\*, \$NELM\_RELEASE\_PLAN\_INSTALL\_RUNTIME\_ANNOTATIONS\_\* @@ -1301,6 +1325,17 @@ nelm release uninstall [options...] -n namespace -r release Fail if not finished in time\. Vars: \$NELM\_TIMEOUT, \$NELM\_RELEASE\_UNINSTALL\_TIMEOUT +**Patch options:** + +- `--no-default-patches` (default: `false`) + + Ignore patches\.yaml of the top\-level chart and subcharts\. Vars: \$NELM\_NO\_DEFAULT\_PATCHES, \$NELM\_RELEASE\_UNINSTALL\_NO\_DEFAULT\_PATCHES + +- `--patches` (default: `[]`) + + Additional patches files \(diff patches for drift detection\)\. Vars: \$NELM\_PATCHES, \$NELM\_RELEASE\_UNINSTALL\_PATCHES + + **Progress options:** - `--no-final-tracking` (default: `false`) diff --git a/go.mod b/go.mod index e4d0c18c..f98a4594 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ require ( github.com/gosimple/slug v1.15.0 github.com/gosuri/uitable v0.0.4 github.com/hofstadter-io/cinful v1.0.0 + github.com/itchyny/gojq v0.12.19 github.com/jedib0t/go-pretty/v6 v6.7.8 github.com/jellydator/ttlcache/v3 v3.4.0 github.com/jmoiron/sqlx v1.4.0 @@ -139,6 +140,7 @@ require ( github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/itchyny/timefmt-go v0.1.8 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/cpuid/v2 v2.2.5 // indirect diff --git a/go.sum b/go.sum index 6fdb5e71..986f4d00 100644 --- a/go.sum +++ b/go.sum @@ -234,6 +234,10 @@ github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/gojq v0.12.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs= +github.com/itchyny/gojq v0.12.19/go.mod h1:5galtVPDywX8SPSOrqjGxkBeDhSxEW1gSxoy7tn1iZY= +github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo791OI= +github.com/itchyny/timefmt-go v0.1.8/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= github.com/jedib0t/go-pretty/v6 v6.7.8 h1:BVYrDy5DPBA3Qn9ICT+PokP9cvCv1KaHv2i+Hc8sr5o= github.com/jedib0t/go-pretty/v6 v6.7.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= diff --git a/pkg/action/common.go b/pkg/action/common.go index 2a9d585f..bb079cbb 100644 --- a/pkg/action/common.go +++ b/pkg/action/common.go @@ -21,11 +21,13 @@ import ( kdutil "github.com/werf/kubedog/pkg/dyntracker/util" "github.com/werf/kubedog/pkg/informer" "github.com/werf/nelm/pkg/common" + helmchart "github.com/werf/nelm/pkg/helm/pkg/chart" helmreleasestatus "github.com/werf/nelm/pkg/helm/pkg/release/common" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/plan" "github.com/werf/nelm/pkg/release" + "github.com/werf/nelm/pkg/resource/spec" "github.com/werf/nelm/pkg/util" ) @@ -162,6 +164,26 @@ func printReport(ctx context.Context, report *ReleaseReportV3) { } } +func resolveDiffPatches(chart helmchart.Accessor, defaultDisable bool, patchesFiles []string) ([]spec.DiffPatch, error) { + var patches []spec.DiffPatch + + if !defaultDisable { + chartPatches, err := spec.CollectChartPatches(chart) + if err != nil { + return nil, fmt.Errorf("collect chart patches: %w", err) + } + + patches = append(patches, chartPatches...) + } + + filePatches, err := spec.LoadPatchesFiles(patchesFiles) + if err != nil { + return nil, fmt.Errorf("load patches files: %w", err) + } + + return append(patches, filePatches...), nil +} + func runFailurePlan(ctx context.Context, releaseNamespace string, failedPlan *plan.Plan, installableInfos []*plan.InstallableResourceInfo, releaseInfos []*plan.ReleaseInfo, taskStore *kdutil.Concurrent[*statestore.TaskStore], logStore *kdutil.Concurrent[*logstore.LogStore], informerFactory *kdutil.Concurrent[*informer.InformerFactory], history *release.History, clientFactory *kube.ClientFactory, opts runFailureInstallPlanOptions) (result *runFailurePlanResult, nonCritErrs, critErrs *util.MultiError) { critErrs = &util.MultiError{} nonCritErrs = &util.MultiError{} diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index 1e619a8f..7c38f1ff 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -465,7 +465,13 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re } } + diffPatches, err := resolveDiffPatches(renderChartResult.Chart, opts.DefaultPatchesDisable, opts.PatchesFiles) + if err != nil { + return fmt.Errorf("resolve diff patches: %w", err) + } + instResInfos, delResInfos, err = plan.BuildResourceInfos(ctx, deployType, releaseName, releaseNamespace, instResources, delResources, prevReleaseFailed, clientFactory, plan.BuildResourceInfosOptions{ + DiffPatches: diffPatches, NetworkParallelism: opts.NetworkParallelism, NoRemoveManualChanges: opts.NoRemoveManualChanges, LastDeployedOrLastRelResourceSpecs: lastDeployedOrLastRelResSpecs, @@ -881,7 +887,13 @@ func runRollbackPlan(ctx context.Context, releaseName, releaseNamespace string, return nil, nonCritErrs, critErrs.Add(fmt.Errorf("convert last deployed or last release to resource specs: %w", err)) } + diffPatches, err := resolveDiffPatches(chartAccessor, opts.DefaultPatchesDisable, opts.PatchesFiles) + if err != nil { + return nil, nonCritErrs, critErrs.Add(fmt.Errorf("resolve diff patches: %w", err)) + } + instResInfos, delResInfos, err := plan.BuildResourceInfos(ctx, common.DeployTypeRollback, releaseName, releaseNamespace, instResources, delResources, true, clientFactory, plan.BuildResourceInfosOptions{ + DiffPatches: diffPatches, NetworkParallelism: opts.NetworkParallelism, NoRemoveManualChanges: opts.NoRemoveManualChanges, LastDeployedOrLastRelResourceSpecs: lastDeployedOrLastRelResSpecs, diff --git a/pkg/action/release_plan_install.go b/pkg/action/release_plan_install.go index 425dd88a..7cb3e7dd 100644 --- a/pkg/action/release_plan_install.go +++ b/pkg/action/release_plan_install.go @@ -361,7 +361,13 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc } } + diffPatches, err := resolveDiffPatches(renderChartResult.Chart, opts.DefaultPatchesDisable, opts.PatchesFiles) + if err != nil { + return nil, fmt.Errorf("resolve diff patches: %w", err) + } + instResInfos, delResInfos, err := plan.BuildResourceInfos(ctx, deployType, releaseName, releaseNamespace, instResources, delResources, prevReleaseFailed, clientFactory, plan.BuildResourceInfosOptions{ + DiffPatches: diffPatches, NetworkParallelism: opts.NetworkParallelism, NoRemoveManualChanges: opts.NoRemoveManualChanges, LastDeployedOrLastRelResourceSpecs: lastDeployedOrLastRelResSpecs, diff --git a/pkg/action/release_rollback.go b/pkg/action/release_rollback.go index 3e3d99db..8d41255f 100644 --- a/pkg/action/release_rollback.go +++ b/pkg/action/release_rollback.go @@ -39,6 +39,9 @@ type ReleaseRollbackOptions struct { // DefaultDeletePropagation sets the deletion propagation policy for resource deletions. DefaultDeletePropagation string + // DefaultPatchesDisable, when true, ignores chart-shipped patches.yaml files + // (from the top-level chart and subcharts of the rolled-back revision). + DefaultPatchesDisable bool // ExtraRuntimeAnnotations are additional annotations to add to resources at runtime during rollback. // These are added during resource creation/update but not stored in the release. ExtraRuntimeAnnotations map[string]string @@ -57,6 +60,9 @@ type ReleaseRollbackOptions struct { // NoShowNotes, when true, suppresses printing of NOTES.txt after successful rollback. // NOTES.txt typically contains usage instructions and next steps. NoShowNotes bool + // PatchesFiles are paths to additional patches files (diff patches for drift + // detection) applied on top of chart-shipped ones during the rollback plan. + PatchesFiles []string // ReleaseHistoryLimit sets the maximum number of release revisions to keep in storage. // When exceeded, the oldest revisions are deleted. Defaults to DefaultReleaseHistoryLimit if not set or <= 0. // Note: Only release metadata is deleted; actual Kubernetes resources are not affected. @@ -312,7 +318,13 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r } } + diffPatches, err := resolveDiffPatches(chartAccessor, opts.DefaultPatchesDisable, opts.PatchesFiles) + if err != nil { + return fmt.Errorf("resolve diff patches: %w", err) + } + instResInfos, delResInfos, err := plan.BuildResourceInfos(ctx, deployType, releaseName, releaseNamespace, instResources, delResources, prevReleaseFailed, clientFactory, plan.BuildResourceInfosOptions{ + DiffPatches: diffPatches, NetworkParallelism: opts.NetworkParallelism, NoRemoveManualChanges: opts.NoRemoveManualChanges, LastDeployedOrLastRelResourceSpecs: lastDeployedOrLastRelResSpecs, diff --git a/pkg/action/release_uninstall.go b/pkg/action/release_uninstall.go index 8f1b88f1..f3219b52 100644 --- a/pkg/action/release_uninstall.go +++ b/pkg/action/release_uninstall.go @@ -18,6 +18,7 @@ import ( kdutil "github.com/werf/kubedog/pkg/dyntracker/util" "github.com/werf/kubedog/pkg/informer" "github.com/werf/nelm/pkg/common" + helmchart "github.com/werf/nelm/pkg/helm/pkg/chart" helmreleasestatus "github.com/werf/nelm/pkg/helm/pkg/release/common" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/legacy/progrep" @@ -39,6 +40,10 @@ type ReleaseUninstallOptions struct { // DefaultDeletePropagation sets the deletion propagation policy for resource deletions. DefaultDeletePropagation string + // DefaultPatchesDisable, when true, ignores chart-shipped patches.yaml files + // (from the top-level chart and subcharts of the uninstalled release). Diff + // patches only affect drift detection of surviving pre-delete hooks. + DefaultPatchesDisable bool // DeleteReleaseNamespace, when true, deletes the release namespace after uninstalling the release. // WARNING: This will delete the entire namespace including resources not managed by this release. DeleteReleaseNamespace bool @@ -53,6 +58,9 @@ type ReleaseUninstallOptions struct { // NoRemoveManualChanges, when true, preserves fields manually added to resources in the cluster // that are not present in the chart manifests. By default, such fields are removed during deletion. NoRemoveManualChanges bool + // PatchesFiles are paths to additional patches files (diff patches for drift + // detection) applied on top of chart-shipped ones during the uninstall plan. + PatchesFiles []string // ReleaseHistoryLimit sets the maximum number of release revisions to keep in storage. // Defaults to DefaultReleaseHistoryLimit if not set or <= 0. // After uninstall, only the uninstall record itself is kept. @@ -231,7 +239,18 @@ func releaseUninstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, log.Default.Debug(ctx, "Build resource infos") + uninstallChart, err := helmchart.NewAccessor(prevRelease.Chart()) + if err != nil { + return fmt.Errorf("access chart of previous release: %w", err) + } + + diffPatches, err := resolveDiffPatches(uninstallChart, opts.DefaultPatchesDisable, opts.PatchesFiles) + if err != nil { + return fmt.Errorf("resolve diff patches: %w", err) + } + instResInfos, delResInfos, err := plan.BuildResourceInfos(ctx, deployType, releaseName, releaseNamespace, instResources, delResources, prevReleaseFailed, clientFactory, plan.BuildResourceInfosOptions{ + DiffPatches: diffPatches, NetworkParallelism: opts.NetworkParallelism, NoRemoveManualChanges: opts.NoRemoveManualChanges, LastDeployedOrLastRelResourceSpecs: prevRelResSpecs, diff --git a/pkg/common/options.go b/pkg/common/options.go index d2b84c2b..44921e41 100644 --- a/pkg/common/options.go +++ b/pkg/common/options.go @@ -243,6 +243,17 @@ type ReleaseInstallRuntimeOptions struct { // DefaultDeletePropagation sets the deletion propagation policy for resource deletions. DefaultDeletePropagation string `json:"defaultDeletePropagation"` + // PatchesFiles are paths to patches files (same format as a chart-shipped + // patches.yaml) whose diff patch rules are applied on top of chart-shipped + // ones. Diff patches affect ONLY drift detection: each matching rule's jq + // transform is applied identically to the live and the dry-apply object before + // comparison, so normalized-away fields never produce a diff. They never change + // what is rendered or applied. These rules are UNSCOPED (they may match any + // resource), unlike chart-shipped rules which are scoped to their chart subtree. + PatchesFiles []string `json:"patchesFiles"` + // DefaultPatchesDisable, when true, ignores chart-shipped patches.yaml files + // (from the top-level chart and subcharts). + DefaultPatchesDisable bool `json:"defaultPatchesDisable"` // ExtraAnnotations are additional Kubernetes annotations to add to all chart resources. // These are added during chart rendering, before resources are stored in the release. ExtraAnnotations map[string]string `json:"extraAnnotations"` diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/charts/alpine-0.1.0.tgz b/pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/charts/alpine-0.1.0.tgz index 449601875da94f5cf0b71f870968f86a515afa14..4dc00b7db2c0234ac26c94dea4eda5f3bb9cef83 100644 GIT binary patch delta 1019 zcmVT>v|QE{~)#7WD=D%X@W=`-cdY{1a)zm7K$7 z6k%B&UgJsfCXO;Psph5iLvN`RY6}QUG(3fCA3X3am(?AVDvNSe&>V7WOEVu1a}q^j za{r7Zx=$h8BBLsul9@+?!{jx$d9_SZRrvW~(WcdfC8LBzFMsW zzWJaG5Y~J{5>nr^!h72+UR45CN&{vEmj>!wL5clwwGg+>msg78H0( z2Dk-6v-LjV1la1`09pko1gI6*oZy=Ssxu%oSYUIsP?IJ|HKBFRGv4H|s_T$1ncC`j zWT-pxu9c6M9$L8F4w7)qxIQ$Q51qr!W2>>sxDDqFmBDqRVB1*w47U?N?pV@dYD|*269z99V+8IZ+KA(3J6JVVfO2*lyPj#vf=y zI)>y3c)xd;2uARAZp9Wyt01M;Jy|$ zKLZvXgJ5yrgDxQBno_IuwoMf-uWd+gTP2+?ZJC3b+Z-@TRpmaZm8)=AkR@$e@CO&% z1+2KBd*Dae*7$_(^5mk^4KJWoGKr!eRdfX@O7O)|2(S+w5lj!}wiA-!!Qz)b_JcJ1 zr#q|hmlOR3c=rB3nRNI+9kFrG|8s~XV_gjn{{eg$3_=+EePXni(@DdB*9@MD{(pl1 p-TVLKYRr26pF@uM@4}#`e=oiC(#wBAeh&Zu|NoD#qfY=J003Py>fitX delta 1019 zcmV#6Gs`DRP)mMp|?~DwFQJF8lJ+n4<7iI%jynFl|{KKXb!oxrI`WyjmuyD*XJgXw&M#l2O8VvDH{*+=g?8%HXP(*C4)foqp?LN%IyA$G#4rm85=rLCXh;gZXt z5kzJDI)1|#i+}DrnkS{H|2OZh-+uQlDY8@S@m&8uyS(c5|BUtb|Fg)e<`O@L>o&*z zpMOLVa&8peRMICuxfbMZ_%4Awy!9th zp8*SxL9n>*K^Ks5O{rCS+olSa*EXcLt&&ccw#-4zZ4MZvs&b#y%2hZl$dWcK_=5}X z0#;nmJ@BJ!YkWd?d2-R|h8NH(nMBc#s&s-BCHUef1lWg;2&M;f+X>0=VDU>I`#~E1 z)1B4$%ZdI1JbVA2Ogj9Zj@Y>8|2f2xv91P({{TJ=1|baoJ~7(M>7?PmYX(n6|3AV1 p?)`sqHD*2k&mqVBcVW=ezn5Nm>E*v5zXt#S|NpW5nymmJ0010^@(BO{ diff --git a/pkg/plan/resource_info.go b/pkg/plan/resource_info.go index d0a1d0d3..7a832933 100644 --- a/pkg/plan/resource_info.go +++ b/pkg/plan/resource_info.go @@ -76,6 +76,7 @@ type DeletableResourceInfo struct { } type BuildResourceInfosOptions struct { + DiffPatches []spec.DiffPatch ExtraRuntimeAnnotations map[string]string ExtraRuntimeLabels map[string]string LastDeployedOrLastRelResourceSpecs []*spec.ResourceSpec @@ -88,6 +89,11 @@ type BuildResourceInfosOptions struct { // more info, and here we actually decide what to do with each resource. Initially all this logic // was in BuildPlan, but it became way too complex, so we extracted it here. func BuildResourceInfos(ctx context.Context, deployType common.DeployType, releaseName, releaseNamespace string, instResources []*resource.InstallableResource, delResources []*resource.DeletableResource, prevReleaseFailed bool, clientFactory kube.ClientFactorier, opts BuildResourceInfosOptions) (instResourceInfos []*InstallableResourceInfo, delResourceInfos []*DeletableResourceInfo, err error) { + diffPatches, err := spec.CompileDiffPatches(opts.DiffPatches) + if err != nil { + return nil, nil, fmt.Errorf("compile diff patches: %w", err) + } + totalResourcesCount := len(instResources) + len(delResources) routines := lo.Max([]int{len(instResources) / lo.Max([]int{totalResourcesCount, 1}) * opts.NetworkParallelism, 1}) @@ -95,7 +101,7 @@ func BuildResourceInfos(ctx context.Context, deployType common.DeployType, relea instResourcesPool := pool.NewWithResults[[]*InstallableResourceInfo]().WithContext(ctx).WithMaxGoroutines(routines).WithCancelOnError().WithFirstError() for _, res := range instResources { instResourcesPool.Go(func(ctx context.Context) ([]*InstallableResourceInfo, error) { - infos, err := buildInstallableResourceInfo(ctx, res, deployType, releaseNamespace, prevReleaseFailed, opts.NoRemoveManualChanges, clientFactory, opts) + infos, err := buildInstallableResourceInfo(ctx, res, deployType, releaseNamespace, prevReleaseFailed, opts.NoRemoveManualChanges, clientFactory, opts, diffPatches) if err != nil { return nil, fmt.Errorf("build installable resource info: %w", err) } @@ -156,7 +162,7 @@ func ResourceInstallTypeSortHandler(type1, type2 ResourceInstallType) bool { } // TODO(major): keep annotation should probably forbid resource recreations -func buildInstallableResourceInfo(ctx context.Context, localRes *resource.InstallableResource, deployType common.DeployType, releaseNamespace string, prevRelFailed, noRemoveManualChanges bool, clientFactory kube.ClientFactorier, opts BuildResourceInfosOptions) ([]*InstallableResourceInfo, error) { +func buildInstallableResourceInfo(ctx context.Context, localRes *resource.InstallableResource, deployType common.DeployType, releaseNamespace string, prevRelFailed, noRemoveManualChanges bool, clientFactory kube.ClientFactorier, opts BuildResourceInfosOptions, diffPatches []*spec.CompiledDiffPatch) ([]*InstallableResourceInfo, error) { var stages []common.Stage switch deployType { case common.DeployTypeInitial, common.DeployTypeInstall: @@ -206,7 +212,7 @@ func buildInstallableResourceInfo(ctx context.Context, localRes *resource.Instal }) } - installType, skippedByPolicy, err := resourceInstallType(ctx, localRes, getObj, dryApplyObj, dryApplyErr, opts.ExtraRuntimeAnnotations, opts.ExtraRuntimeLabels, resourcePolicies) + installType, skippedByPolicy, err := resourceInstallType(ctx, localRes, getObj, dryApplyObj, dryApplyErr, opts.ExtraRuntimeAnnotations, opts.ExtraRuntimeLabels, resourcePolicies, diffPatches) if err != nil { return nil, fmt.Errorf("determine install type for resource %q: %w", localRes.IDHuman(), err) } @@ -795,7 +801,7 @@ func removeUndesirableManagers(managedFields []v1.ManagedFieldsEntry, oursEntry return newManagedFields, newOursEntry, changed } -func resourceInstallType(ctx context.Context, localRes *resource.InstallableResource, getObj, dryApplyObj *unstructured.Unstructured, dryApplyErr error, extraRuntimeAnnotations, extraRuntimeLabels map[string]string, resourcePolicies []common.ResourcePolicy) (installType ResourceInstallType, skippedByPolicy bool, err error) { +func resourceInstallType(ctx context.Context, localRes *resource.InstallableResource, getObj, dryApplyObj *unstructured.Unstructured, dryApplyErr error, extraRuntimeAnnotations, extraRuntimeLabels map[string]string, resourcePolicies []common.ResourcePolicy, diffPatches []*spec.CompiledDiffPatch) (installType ResourceInstallType, skippedByPolicy bool, err error) { skipCreate := lo.Contains(resourcePolicies, common.ResourcePolicySkipCreate) skipUpdate := lo.Contains(resourcePolicies, common.ResourcePolicySkipUpdate) skipRecreate := lo.Contains(resourcePolicies, common.ResourcePolicySkipRecreate) @@ -830,7 +836,26 @@ func resourceInstallType(ctx context.Context, localRes *resource.InstallableReso return ResourceInstallTypeApply, false, nil } - diffableGetObj := spec.CleanUnstruct(getObj, spec.CleanUnstructOptions{ + patchedGetObj := getObj + + patchedDryApplyObj := dryApplyObj + if len(diffPatches) > 0 { + // getObj is non-nil here and carries the resource's true namespace, which is + // empty only for genuinely cluster-scoped resources (unlike the blanked + // ResourceMeta.Namespace), so it is the authoritative value for the + // namespace selector dimension. + namespace := getObj.GetNamespace() + + if patchedGetObj, err = spec.ApplyDiffPatches(diffPatches, localRes.ResourceMeta, namespace, getObj); err != nil { + return "", false, fmt.Errorf("apply diff patches to live version of resource %q: %w", localRes.IDHuman(), err) + } + + if patchedDryApplyObj, err = spec.ApplyDiffPatches(diffPatches, localRes.ResourceMeta, namespace, dryApplyObj); err != nil { + return "", false, fmt.Errorf("apply diff patches to dry-apply version of resource %q: %w", localRes.IDHuman(), err) + } + } + + diffableGetObj := spec.CleanUnstruct(patchedGetObj, spec.CleanUnstructOptions{ CleanHelmShAnnos: true, CleanWerfIoAnnos: true, CleanRuntimeData: true, @@ -838,7 +863,7 @@ func resourceInstallType(ctx context.Context, localRes *resource.InstallableReso CleanLabels: extraRuntimeLabels, }) - diffableDryApplyObj := spec.CleanUnstruct(dryApplyObj, spec.CleanUnstructOptions{ + diffableDryApplyObj := spec.CleanUnstruct(patchedDryApplyObj, spec.CleanUnstructOptions{ CleanHelmShAnnos: true, CleanWerfIoAnnos: true, CleanRuntimeData: true, diff --git a/pkg/plan/resource_info_test.go b/pkg/plan/resource_info_test.go index 76a6696b..6a62e372 100644 --- a/pkg/plan/resource_info_test.go +++ b/pkg/plan/resource_info_test.go @@ -547,6 +547,55 @@ func (s *ResourceInfoSuite) TestBuildResourceInfos() { } } +func (s *ResourceInfoSuite) TestBuildResourceInfosDiffPatches() { + s.Run(`outdated resource yields update without a diff patch`, func() { + _, err := s.clientFactory.KubeClient().Create(context.Background(), defaultResourceSpec(s.releaseName, s.releaseNamespace), kube.KubeClientCreateOptions{ + DefaultNamespace: s.releaseNamespace, + }) + s.Require().NoError(err) + + localRes := updatedInstallableResource(&s.Suite, s.releaseName, s.releaseNamespace) + + instResInfos, _, err := plan.BuildResourceInfos(context.Background(), common.DeployTypeInitial, s.releaseName, s.releaseNamespace, []*resource.InstallableResource{localRes}, nil, false, s.clientFactory, plan.BuildResourceInfosOptions{ + NetworkParallelism: 10, + }) + s.Require().NoError(err) + s.Require().Len(instResInfos, 1) + s.Require().Equal(plan.ResourceInstallTypeUpdate, instResInfos[0].MustInstall) + }) + + s.Run(`matching diff patch cancels the drift`, func() { + _, err := s.clientFactory.KubeClient().Create(context.Background(), defaultResourceSpec(s.releaseName, s.releaseNamespace), kube.KubeClientCreateOptions{ + DefaultNamespace: s.releaseNamespace, + }) + s.Require().NoError(err) + + localRes := updatedInstallableResource(&s.Suite, s.releaseName, s.releaseNamespace) + + instResInfos, _, err := plan.BuildResourceInfos(context.Background(), common.DeployTypeInitial, s.releaseName, s.releaseNamespace, []*resource.InstallableResource{localRes}, nil, false, s.clientFactory, plan.BuildResourceInfosOptions{ + NetworkParallelism: 10, + DiffPatches: []spec.DiffPatch{ + {Patch: `del(.data.key2)`}, + }, + }) + s.Require().NoError(err) + s.Require().Len(instResInfos, 1) + s.Require().Equal(plan.ResourceInstallTypeNone, instResInfos[0].MustInstall) + }) + + s.Run(`invalid diff patch fails closed`, func() { + localRes := defaultInstallableResource(s.releaseName, s.releaseNamespace) + + _, _, err := plan.BuildResourceInfos(context.Background(), common.DeployTypeInitial, s.releaseName, s.releaseNamespace, []*resource.InstallableResource{localRes}, nil, false, s.clientFactory, plan.BuildResourceInfosOptions{ + NetworkParallelism: 10, + DiffPatches: []spec.DiffPatch{ + {Patch: `del(.data.key2`}, + }, + }) + s.Require().Error(err) + }) +} + type buildInstallableResourceInfoTestCase struct { expect func(*resource.InstallableResource) []*plan.InstallableResourceInfo input func() (localRes *resource.InstallableResource, deployType common.DeployType, prevRelFailed bool) @@ -696,7 +745,7 @@ func runBuildInstallableResourceInfoTest(tc buildInstallableResourceInfoTestCase localRes, deployType, prevRelFailed := tc.input() - resInfos, err := plan.BuildInstallableResourceInfo(context.Background(), localRes, deployType, s.releaseNamespace, prevRelFailed, true, s.clientFactory, plan.BuildResourceInfosOptions{}) + resInfos, err := plan.BuildInstallableResourceInfo(context.Background(), localRes, deployType, s.releaseNamespace, prevRelFailed, true, s.clientFactory, plan.BuildResourceInfosOptions{}, nil) s.Require().NoError(err) expectResInfos := tc.expect(localRes) diff --git a/pkg/resource/spec/diff_patch.go b/pkg/resource/spec/diff_patch.go new file mode 100644 index 00000000..3f4924a7 --- /dev/null +++ b/pkg/resource/spec/diff_patch.go @@ -0,0 +1,353 @@ +package spec + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/itchyny/gojq" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/yaml" + + helmchart "github.com/werf/nelm/pkg/helm/pkg/chart" +) + +const ( + DiffPatchTypeJQ DiffPatchType = "jq" + + // patchesFileName is the conventional name of a chart-shipped patches file. + patchesFileName = "patches.yaml" +) + +// DiffPatchType is the transform kind of a diff patch. +type DiffPatchType string + +// PatchesFile is the on-disk format of a patches file. Only diffPatches is +// supported today; the top-level key leaves room for future siblings. +type PatchesFile struct { + DiffPatches []DiffPatch `json:"diffPatches,omitempty"` +} + +// DiffPatch is a diff-time normalization rule. It affects ONLY drift detection: +// the transform is applied identically to the live and the dry-apply object +// before they are compared, so normalized-away fields never produce a diff. +type DiffPatch struct { + // Match chooses which resources this rule applies to. An empty matcher + // matches every resource. + Match ResourceMatcher `json:"match,omitempty"` + // Type is the transform kind. Only "jq" is supported; empty defaults to "jq". + Type DiffPatchType `json:"type,omitempty"` + // Patch is the jq program: it receives the whole raw resource object and must + // return exactly one object. + Patch string `json:"patch,omitempty"` + // ChartScope, when set, constrains this rule to resources originating in the + // given chart subtree (a FilePath prefix). Set internally for chart-shipped + // rules; never serialized or set by users. + ChartScope string `json:"-"` +} + +// CompiledDiffPatch is a DiffPatch with its jq program compiled once, ready to +// match and transform many resources. +type CompiledDiffPatch struct { + chartScope string + code *gojq.Code + matcher ResourceMatcher +} + +// Match reports whether the rule matches the resource. namespace is the +// resource's true namespace (empty only for cluster-scoped resources), passed in +// because ResourceMeta.Namespace is blanked for release-namespace resources. +func (c *CompiledDiffPatch) Match(resMeta *ResourceMeta, namespace string) bool { + if !resourceInChartScope(c.chartScope, resMeta.FilePath) { + return false + } + + // Match against the resource's true namespace, not the blanked meta value. + scoped := *resMeta + scoped.Namespace = namespace + + return c.matcher.Match(&scoped) +} + +// transform runs the compiled jq program over a deep copy of the object and +// returns the single object output. Zero, multiple, or non-object output is an +// error, and a jq panic is recovered into an error; the input is never mutated. +func (c *CompiledDiffPatch) transform(unstruct *unstructured.Unstructured) (result *unstructured.Unstructured, err error) { + // Unstructured stores integers as int64, which gojq rejects; round-trip + // through JSON with UseNumber so numbers reach gojq as json.Number. + input, err := toJQInput(unstruct.Object) + if err != nil { + return nil, fmt.Errorf("normalize resource for jq: %w", err) + } + + defer func() { + if r := recover(); r != nil { + result = nil + err = fmt.Errorf("jq program panicked: %v", r) + } + }() + + iter := c.code.Run(input) + + first, ok := iter.Next() + if !ok { + return nil, fmt.Errorf("jq program produced no output, want exactly one object") + } + + if err, ok := first.(error); ok { + return nil, fmt.Errorf("run jq program: %w", err) + } + + if _, ok := iter.Next(); ok { + return nil, fmt.Errorf("jq program produced more than one output, want exactly one object") + } + + if _, ok := first.(map[string]interface{}); !ok { + return nil, fmt.Errorf("jq program output is %T, want an object", first) + } + + obj, err := fromJQOutput(first) + if err != nil { + return nil, fmt.Errorf("convert jq output for resource: %w", err) + } + + return &unstructured.Unstructured{Object: obj}, nil +} + +// ApplyDiffPatches runs every rule whose matcher matches the resource, threading +// each transform's output into the next, and returns a transformed deep copy; the +// input is never mutated. namespace is the resource's true namespace. +func ApplyDiffPatches(patches []*CompiledDiffPatch, resMeta *ResourceMeta, namespace string, unstruct *unstructured.Unstructured) (*unstructured.Unstructured, error) { + result := unstruct + transformed := false + + for i, patch := range patches { + if !patch.Match(resMeta, namespace) { + continue + } + + out, err := patch.transform(result) + if err != nil { + return nil, fmt.Errorf("apply diff patch #%d: %w", i+1, err) + } + + result = out + transformed = true + } + + if !transformed { + return unstruct.DeepCopy(), nil + } + + return result, nil +} + +// CollectChartPatches returns every chart-shipped patches.yaml rule in the chart +// tree, ordered leaf-first and each constrained to its own chart subtree. +func CollectChartPatches(chart helmchart.Accessor) ([]DiffPatch, error) { + if chart == nil { + return nil, nil + } + + chartPath := chart.ChartFullPath() + + var patches []DiffPatch + + for _, dep := range chart.Dependencies() { + depAccessor, err := helmchart.NewAccessor(dep) + if err != nil { + return nil, fmt.Errorf("access subchart of %q: %w", chartPath, err) + } + + depPatches, err := CollectChartPatches(depAccessor) + if err != nil { + return nil, err + } + + patches = append(patches, depPatches...) + } + + var own []DiffPatch + for _, f := range chart.Files() { + if f.Name != patchesFileName { + continue + } + + ownPatches, err := parsePatchesFile(f.Data) + if err != nil { + return nil, fmt.Errorf("read %s of chart %q: %w", patchesFileName, chartPath, err) + } + + own = ownPatches + + break + } + + for i := range own { + own[i].ChartScope = chartPath + } + + return append(patches, own...), nil +} + +// CompileDiffPatches compiles diff patch rules, returning a plan error on the +// first invalid regexp, unsupported type, empty patch body, or invalid jq +// program. +func CompileDiffPatches(patches []DiffPatch) ([]*CompiledDiffPatch, error) { + if len(patches) == 0 { + return nil, nil + } + + compiled := make([]*CompiledDiffPatch, 0, len(patches)) + for i, patch := range patches { + c, err := compileDiffPatch(patch) + if err != nil { + return nil, fmt.Errorf("compile diff patch #%d: %w", i+1, err) + } + + compiled = append(compiled, c) + } + + return compiled, nil +} + +// LoadPatchesFiles reads and parses the given patches file paths, returning their +// rules concatenated in order. +func LoadPatchesFiles(paths []string) ([]DiffPatch, error) { + var patches []DiffPatch + for _, path := range paths { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read patches file %q: %w", path, err) + } + + filePatches, err := parsePatchesFile(data) + if err != nil { + return nil, fmt.Errorf("patches file %q: %w", path, err) + } + + patches = append(patches, filePatches...) + } + + return patches, nil +} + +func fromJQOutput(value interface{}) (map[string]interface{}, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("marshal: %w", err) + } + + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + + var decoded interface{} + if err := dec.Decode(&decoded); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + + normalized, ok := normalizeNumbers(decoded).(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("jq program output is not an object") + } + + return normalized, nil +} + +func compileDiffPatch(patch DiffPatch) (*CompiledDiffPatch, error) { + patchType := patch.Type + if patchType == "" { + patchType = DiffPatchTypeJQ + } + + if patchType != DiffPatchTypeJQ { + return nil, fmt.Errorf("unsupported diff patch type %q, only %q is supported", patch.Type, DiffPatchTypeJQ) + } + + if strings.TrimSpace(patch.Patch) == "" { + return nil, fmt.Errorf("diff patch program is empty") + } + + if err := patch.Match.Validate(); err != nil { + return nil, fmt.Errorf("invalid matcher: %w", err) + } + + query, err := gojq.Parse(patch.Patch) + if err != nil { + return nil, fmt.Errorf("parse jq program: %w", err) + } + + code, err := gojq.Compile(query) + if err != nil { + return nil, fmt.Errorf("compile jq program: %w", err) + } + + return &CompiledDiffPatch{chartScope: patch.ChartScope, code: code, matcher: patch.Match}, nil +} + +func normalizeNumbers(value interface{}) interface{} { + switch v := value.(type) { + case map[string]interface{}: + for key, elem := range v { + v[key] = normalizeNumbers(elem) + } + + return v + case []interface{}: + for i, elem := range v { + v[i] = normalizeNumbers(elem) + } + + return v + case json.Number: + if i, err := v.Int64(); err == nil { + return i + } + + if f, err := v.Float64(); err == nil { + return f + } + + return v.String() + default: + return value + } +} + +// parsePatchesFile parses a patches file into its diff patch rules. Unknown +// top-level keys are rejected so typos and unsupported kinds fail loudly. +func parsePatchesFile(data []byte) ([]DiffPatch, error) { + var file PatchesFile + if err := yaml.UnmarshalStrict(data, &file); err != nil { + return nil, fmt.Errorf("parse patches file: %w", err) + } + + return file.DiffPatches, nil +} + +func resourceInChartScope(chartPath, filePath string) bool { + if chartPath == "" { + return true + } + + return filePath == chartPath || strings.HasPrefix(filePath, chartPath+"/") +} + +func toJQInput(obj map[string]interface{}) (interface{}, error) { + data, err := json.Marshal(obj) + if err != nil { + return nil, fmt.Errorf("marshal: %w", err) + } + + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + + var out interface{} + if err := dec.Decode(&out); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + + return out, nil +} diff --git a/pkg/resource/spec/diff_patch_test.go b/pkg/resource/spec/diff_patch_test.go new file mode 100644 index 00000000..d6545768 --- /dev/null +++ b/pkg/resource/spec/diff_patch_test.go @@ -0,0 +1,490 @@ +package spec_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + helmchart "github.com/werf/nelm/pkg/helm/pkg/chart" + chartcommon "github.com/werf/nelm/pkg/helm/pkg/chart/common" + v2chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/resource/spec" +) + +func TestApplyDiffPatches(t *testing.T) { + obj := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{"name": "web"}, + "spec": map[string]interface{}{"replicas": int64(3)}, + }} + meta := metaFor("Deployment", "apps", "v1", "web", "", "myapp/templates/web.yaml", nil, nil) + + t.Run("no rules returns unchanged deep copy", func(t *testing.T) { + out, err := spec.ApplyDiffPatches(nil, meta, "prod", obj) + require.NoError(t, err) + require.Equal(t, obj.Object, out.Object) + require.NotSame(t, &obj.Object, &out.Object) + }) + + t.Run("non-matching rule leaves object unchanged", func(t *testing.T) { + c, err := spec.CompileDiffPatch(spec.DiffPatch{ + Match: spec.ResourceMatcher{Names: []string{"other"}}, + Patch: "del(.spec.replicas)", + }) + require.NoError(t, err) + + out, err := spec.ApplyDiffPatches([]*spec.CompiledDiffPatch{c}, meta, "prod", obj) + require.NoError(t, err) + require.Equal(t, int64(3), out.Object["spec"].(map[string]interface{})["replicas"]) + }) + + t.Run("matching rules chain in order", func(t *testing.T) { + c1, err := spec.CompileDiffPatch(spec.DiffPatch{Patch: "del(.spec.replicas)"}) + require.NoError(t, err) + c2, err := spec.CompileDiffPatch(spec.DiffPatch{Patch: `.metadata.name = "patched"`}) + require.NoError(t, err) + + out, err := spec.ApplyDiffPatches([]*spec.CompiledDiffPatch{c1, c2}, meta, "prod", obj) + require.NoError(t, err) + + _, hasReplicas := out.Object["spec"].(map[string]interface{})["replicas"] + require.False(t, hasReplicas) + require.Equal(t, "patched", out.Object["metadata"].(map[string]interface{})["name"]) + }) +} + +func TestCollectChartPatches_NilChart(t *testing.T) { + patches, err := spec.CollectChartPatches(nil) + require.NoError(t, err) + require.Empty(t, patches) +} + +func TestCollectChartPatches_ScopingAndOrder(t *testing.T) { + // Tree: app (root) -> [cache subchart] + cache := chartWithPatches("cache", "diffPatches:\n- patch: del(.cacheField)\n") + app := chartWithPatches("app", "diffPatches:\n- patch: del(.appField)\n", cache) + + accessor, err := helmchart.NewAccessor(app) + require.NoError(t, err) + + patches, err := spec.CollectChartPatches(accessor) + require.NoError(t, err) + require.Len(t, patches, 2) + + // Leaf-first ordering: subchart (cache) rule before parent (app) rule. + require.Equal(t, "del(.cacheField)", patches[0].Patch) + require.Equal(t, "del(.appField)", patches[1].Patch) + + // Scoping: subchart rule constrained to its subtree; parent rule to the root. + require.Equal(t, "app/charts/cache", patches[0].ChartScope) + require.Equal(t, "app", patches[1].ChartScope) +} + +func TestCompileDiffPatch_DefaultsType(t *testing.T) { + c, err := spec.CompileDiffPatch(spec.DiffPatch{Patch: "."}) + require.NoError(t, err) + require.NotNil(t, c) +} + +func TestCompileDiffPatch_FailsClosed(t *testing.T) { + tests := []struct { + name string + patch spec.DiffPatch + }{ + { + name: "invalid jq program", + patch: spec.DiffPatch{Patch: "del(.spec.replicas"}, + }, + { + name: "empty patch body", + patch: spec.DiffPatch{Patch: " "}, + }, + { + name: "unsupported type", + patch: spec.DiffPatch{Type: "jsonPointer", Patch: "."}, + }, + { + name: "invalid regexp in selector", + patch: spec.DiffPatch{ + Match: spec.ResourceMatcher{Names: []string{"/(/"}}, + Patch: ".", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := spec.CompileDiffPatch(tt.patch) + require.Error(t, err) + }) + } +} + +func TestCompiledDiffPatch_ChartScope(t *testing.T) { + scope := "app/charts/cache" + tests := []struct { + name string + filePath string + want bool + }{ + {name: "resource in the scoped subtree matches", filePath: "app/charts/cache/templates/redis.yaml", want: true}, + {name: "resource in a nested sub-subchart matches", filePath: "app/charts/cache/charts/inner/templates/x.yaml", want: true}, + {name: "parent resource does not match", filePath: "app/templates/web.yaml", want: false}, + {name: "sibling subchart resource does not match", filePath: "app/charts/postgres/templates/db.yaml", want: false}, + {name: "prefix-collision sibling does not match", filePath: "app/charts/cache-extra/templates/x.yaml", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, err := spec.CompileDiffPatch(spec.DiffPatch{ChartScope: scope, Patch: "."}) + require.NoError(t, err) + + meta := metaFor("Deployment", "apps", "v1", "web", "", tt.filePath, nil, nil) + require.Equal(t, tt.want, c.Match(meta, "")) + }) + } +} + +func TestCompiledDiffPatch_Match(t *testing.T) { + tests := []struct { + name string + selector spec.ResourceMatcher + meta *spec.ResourceMeta + namespace string + want bool + }{ + { + name: "empty selector matches all", + selector: spec.ResourceMatcher{}, + meta: metaFor("Deployment", "apps", "v1", "web", "", "", nil, nil), + want: true, + }, + { + name: "exact name match", + selector: spec.ResourceMatcher{Names: []string{"web"}}, + meta: metaFor("Deployment", "apps", "v1", "web", "", "", nil, nil), + want: true, + }, + { + name: "name mismatch", + selector: spec.ResourceMatcher{Names: []string{"web"}}, + meta: metaFor("Deployment", "apps", "v1", "api", "", "", nil, nil), + want: false, + }, + { + name: "name is case-sensitive", + selector: spec.ResourceMatcher{Names: []string{"Web"}}, + meta: metaFor("Deployment", "apps", "v1", "web", "", "", nil, nil), + want: false, + }, + { + name: "kind is case-insensitive", + selector: spec.ResourceMatcher{Kinds: []string{"deployment"}}, + meta: metaFor("Deployment", "apps", "v1", "web", "", "", nil, nil), + want: true, + }, + { + name: "regexp name match", + selector: spec.ResourceMatcher{Names: []string{"/web-.*/"}}, + meta: metaFor("Deployment", "apps", "v1", "web-123", "", "", nil, nil), + want: true, + }, + { + name: "regexp is anchored full match", + selector: spec.ResourceMatcher{Names: []string{"/web/"}}, + meta: metaFor("Deployment", "apps", "v1", "web-123", "", "", nil, nil), + want: false, + }, + { + name: "literal dot does not over-match", + selector: spec.ResourceMatcher{Names: []string{"my.app"}}, + meta: metaFor("Deployment", "apps", "v1", "myXapp", "", "", nil, nil), + want: false, + }, + { + name: "fields AND together - kind matches but group does not", + selector: spec.ResourceMatcher{Kinds: []string{"Deployment"}, Groups: []string{"batch"}}, + meta: metaFor("Deployment", "apps", "v1", "web", "", "", nil, nil), + want: false, + }, + { + name: "list values OR together", + selector: spec.ResourceMatcher{Names: []string{"api", "web"}}, + meta: metaFor("Deployment", "apps", "v1", "web", "", "", nil, nil), + want: true, + }, + { + name: "labels all must match", + selector: spec.ResourceMatcher{Labels: map[string]string{"tier": "backend", "team": "pay"}}, + meta: metaFor("Deployment", "apps", "v1", "web", "", "", map[string]string{"tier": "backend", "team": "pay"}, nil), + want: true, + }, + { + name: "labels mismatch when one missing", + selector: spec.ResourceMatcher{Labels: map[string]string{"tier": "backend", "team": "pay"}}, + meta: metaFor("Deployment", "apps", "v1", "web", "", "", map[string]string{"tier": "backend"}, nil), + want: false, + }, + { + name: "empty-value label selector does not match a missing key", + selector: spec.ResourceMatcher{Labels: map[string]string{"tier": ""}}, + meta: metaFor("Deployment", "apps", "v1", "web", "", "", map[string]string{"other": "x"}, nil), + want: false, + }, + { + name: "empty-value label selector matches an explicit empty value", + selector: spec.ResourceMatcher{Labels: map[string]string{"tier": ""}}, + meta: metaFor("Deployment", "apps", "v1", "web", "", "", map[string]string{"tier": ""}, nil), + want: true, + }, + { + name: "annotations match", + selector: spec.ResourceMatcher{Annotations: map[string]string{"team": "payments"}}, + meta: metaFor("Deployment", "apps", "v1", "web", "", "", nil, map[string]string{"team": "payments"}), + want: true, + }, + { + name: "namespace matches release namespace via true object namespace", + selector: spec.ResourceMatcher{Namespaces: []string{"prod"}}, + meta: metaFor("Deployment", "apps", "v1", "web", "", "", nil, nil), + namespace: "prod", + want: true, + }, + { + name: "namespace explicit match", + selector: spec.ResourceMatcher{Namespaces: []string{"other"}}, + meta: metaFor("Deployment", "apps", "v1", "web", "other", "", nil, nil), + namespace: "other", + want: true, + }, + { + name: "namespace-scoped selector does not match cluster-scoped resource", + selector: spec.ResourceMatcher{Namespaces: []string{"prod"}}, + meta: metaFor("ClusterRole", "rbac.authorization.k8s.io", "v1", "viewer", "", "", nil, nil), + namespace: "", + want: false, + }, + { + name: "chart top-level match", + selector: spec.ResourceMatcher{Charts: []string{"myapp"}}, + meta: metaFor("Deployment", "apps", "v1", "web", "", "myapp/templates/web.yaml", nil, nil), + want: true, + }, + { + name: "chart subchart alias match", + selector: spec.ResourceMatcher{Charts: []string{"cache"}}, + meta: metaFor("Deployment", "apps", "v1", "web", "", "myapp/charts/cache/templates/redis.yaml", nil, nil), + want: true, + }, + { + name: "chart selector does not match upstream name when aliased", + selector: spec.ResourceMatcher{Charts: []string{"redis"}}, + meta: metaFor("Deployment", "apps", "v1", "web", "", "myapp/charts/cache/templates/redis.yaml", nil, nil), + want: false, + }, + { + name: "parent chart does not match subchart-originated resource", + selector: spec.ResourceMatcher{Charts: []string{"myapp"}}, + meta: metaFor("Deployment", "apps", "v1", "web", "", "myapp/charts/cache/templates/redis.yaml", nil, nil), + want: false, + }, + { + name: "full chart-path matches the subchart", + selector: spec.ResourceMatcher{Charts: []string{"myapp/charts/cache"}}, + meta: metaFor("Deployment", "apps", "v1", "web", "", "myapp/charts/cache/templates/redis.yaml", nil, nil), + want: true, + }, + { + name: "full chart-path disambiguates identically-named subcharts (match)", + selector: spec.ResourceMatcher{Charts: []string{"parent/charts/child1/charts/mychart"}}, + meta: metaFor("ConfigMap", "", "v1", "cm", "", "parent/charts/child1/charts/mychart/templates/cm.yaml", nil, nil), + want: true, + }, + { + name: "full chart-path disambiguates identically-named subcharts (no match on the other)", + selector: spec.ResourceMatcher{Charts: []string{"parent/charts/child1/charts/mychart"}}, + meta: metaFor("ConfigMap", "", "v1", "cm", "", "parent/charts/child2/charts/subchild3/charts/mychart/templates/cm.yaml", nil, nil), + want: false, + }, + { + name: "bare alias still matches both identically-named subcharts", + selector: spec.ResourceMatcher{Charts: []string{"mychart"}}, + meta: metaFor("ConfigMap", "", "v1", "cm", "", "parent/charts/child2/charts/subchild3/charts/mychart/templates/cm.yaml", nil, nil), + want: true, + }, + { + name: "regex over the chart-path targets one subtree", + selector: spec.ResourceMatcher{Charts: []string{"/parent/charts/child2/.*/mychart/"}}, + meta: metaFor("ConfigMap", "", "v1", "cm", "", "parent/charts/child2/charts/subchild3/charts/mychart/templates/cm.yaml", nil, nil), + want: true, + }, + { + name: "standalone CRD path matches leading chart segment", + selector: spec.ResourceMatcher{Charts: []string{"myapp"}}, + meta: metaFor("CustomResourceDefinition", "apiextensions.k8s.io", "v1", "foos", "", "myapp/crds/foo.yaml", nil, nil), + want: true, + }, + { + name: "empty FilePath never matches chart rule", + selector: spec.ResourceMatcher{Charts: []string{"myapp"}}, + meta: metaFor("Deployment", "apps", "v1", "web", "", "", nil, nil), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, err := spec.CompileDiffPatch(spec.DiffPatch{Match: tt.selector, Patch: "."}) + require.NoError(t, err) + require.Equal(t, tt.want, c.Match(tt.meta, tt.namespace)) + }) + } +} + +func TestCompiledDiffPatch_Transform(t *testing.T) { + obj := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "spec": map[string]interface{}{ + "replicas": int64(3), + "selector": map[string]interface{}{"matchLabels": map[string]interface{}{"app": "web"}}, + }, + }} + + t.Run("happy path removes field without mutating input", func(t *testing.T) { + c, err := spec.CompileDiffPatch(spec.DiffPatch{Patch: "del(.spec.replicas)"}) + require.NoError(t, err) + + out, err := c.Transform(obj) + require.NoError(t, err) + + outSpec := out.Object["spec"].(map[string]interface{}) + _, hasReplicas := outSpec["replicas"] + require.False(t, hasReplicas) + + inSpec := obj.Object["spec"].(map[string]interface{}) + require.Equal(t, int64(3), inSpec["replicas"]) + }) + + // Kubernetes unstructured objects store integers as int64, which gojq rejects + // unless the input is number-normalized. These programs would silently + // mis-compare or panic without that normalization. + numeric := []struct { + name string + program string + want interface{} + }{ + {name: "comparison", program: `if .spec.replicas == 3 then .spec.replicas = 0 else . end`, want: int64(0)}, + {name: "greater-than", program: `if .spec.replicas > 2 then .spec.replicas = 0 else . end`, want: int64(0)}, + {name: "arithmetic", program: `.spec.replicas += 1`, want: int64(4)}, + {name: "assignment-with-arithmetic", program: `.spec.replicas = (.spec.replicas + 1)`, want: int64(4)}, + {name: "walk-zeroes-numbers", program: `walk(if type == "number" then 0 else . end)`, want: int64(0)}, + } + + for _, tt := range numeric { + t.Run("numeric "+tt.name, func(t *testing.T) { + c, err := spec.CompileDiffPatch(spec.DiffPatch{Patch: tt.program}) + require.NoError(t, err) + + out, err := c.Transform(obj) + require.NoError(t, err) + require.Equal(t, tt.want, out.Object["spec"].(map[string]interface{})["replicas"]) + }) + } + + failures := []struct { + name string + program string + }{ + {name: "no output", program: "empty"}, + {name: "multiple outputs", program: ".spec, .kind"}, + {name: "scalar output", program: ".kind"}, + {name: "array output", program: "[.]"}, + {name: "null output", program: ".missing"}, + } + + for _, tt := range failures { + t.Run("rejects "+tt.name, func(t *testing.T) { + c, err := spec.CompileDiffPatch(spec.DiffPatch{Patch: tt.program}) + require.NoError(t, err) + + _, err = c.Transform(obj) + require.Error(t, err) + }) + } +} + +func TestLoadPatchesFiles(t *testing.T) { + dir := t.TempDir() + a := filepath.Join(dir, "a.yaml") + b := filepath.Join(dir, "b.yaml") + + require.NoError(t, os.WriteFile(a, []byte("diffPatches:\n- patch: del(.a)\n"), 0o644)) + require.NoError(t, os.WriteFile(b, []byte("diffPatches:\n- patch: del(.b)\n"), 0o644)) + + patches, err := spec.LoadPatchesFiles([]string{a, b}) + require.NoError(t, err) + require.Len(t, patches, 2) + require.Equal(t, "del(.a)", patches[0].Patch) + require.Equal(t, "del(.b)", patches[1].Patch) + + _, err = spec.LoadPatchesFiles([]string{filepath.Join(dir, "missing.yaml")}) + require.Error(t, err) +} + +func TestParsePatchesFile(t *testing.T) { + t.Run("parses diffPatches", func(t *testing.T) { + data := []byte(` +diffPatches: +- match: + kinds: [Deployment] + patch: del(.spec.replicas) +- patch: del(.data.foo) +`) + patches, err := spec.ParsePatchesFile(data) + require.NoError(t, err) + require.Len(t, patches, 2) + require.Equal(t, []string{"Deployment"}, patches[0].Match.Kinds) + require.Equal(t, "del(.spec.replicas)", patches[0].Patch) + }) + + t.Run("rejects unknown top-level key", func(t *testing.T) { + _, err := spec.ParsePatchesFile([]byte("bogusKey: []\n")) + require.Error(t, err) + }) + + t.Run("empty file yields no patches", func(t *testing.T) { + patches, err := spec.ParsePatchesFile([]byte("{}\n")) + require.NoError(t, err) + require.Empty(t, patches) + }) +} + +func chartWithPatches(name, patchesYAML string, deps ...*v2chart.Chart) *v2chart.Chart { + ch := &v2chart.Chart{Metadata: &v2chart.Metadata{Name: name}} + if patchesYAML != "" { + ch.Files = []*chartcommon.File{{Name: "patches.yaml", Data: []byte(patchesYAML)}} + } + + if len(deps) > 0 { + ch.AddDependency(deps...) + } + + return ch +} + +func metaFor(kind, group, version, name, namespace, filePath string, labels, annotations map[string]string) *spec.ResourceMeta { + return &spec.ResourceMeta{ + Name: name, + Namespace: namespace, + GroupVersionKind: schema.GroupVersionKind{Group: group, Version: version, Kind: kind}, + FilePath: filePath, + Labels: labels, + Annotations: annotations, + } +} diff --git a/pkg/resource/spec/export_test.go b/pkg/resource/spec/export_test.go new file mode 100644 index 00000000..4e5853f8 --- /dev/null +++ b/pkg/resource/spec/export_test.go @@ -0,0 +1,15 @@ +package spec + +import "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + +var ( + // CompileDiffPatch and ParsePatchesFile expose unexported symbols to the + // external spec_test package. + CompileDiffPatch = compileDiffPatch + ParsePatchesFile = parsePatchesFile +) + +// Transform exposes the unexported transform method to the external spec_test package. +func (c *CompiledDiffPatch) Transform(unstruct *unstructured.Unstructured) (*unstructured.Unstructured, error) { + return c.transform(unstruct) +} diff --git a/pkg/resource/spec/resource_match.go b/pkg/resource/spec/resource_match.go index 21cd3a7c..80c46753 100644 --- a/pkg/resource/spec/resource_match.go +++ b/pkg/resource/spec/resource_match.go @@ -1,94 +1,166 @@ package spec -import "strings" +import ( + "fmt" + "regexp" + "strings" + "sync" +) +var +// once regardless of how many resources it is matched against. +regexpCache sync.Map // string -> *regexp.Regexp + +// ResourceMatcher matches resources by metadata. Fields AND together; values +// within a field OR together; an empty field matches everything. String values +// use the /regex/ convention: a bare value is an exact match (case-insensitive +// for groups/versions/kinds, case-sensitive for names/namespaces/charts), a +// value wrapped in slashes is an anchored regexp. Labels and annotations are +// exact key=value and must all match. Charts matches a resource's originating +// (sub)chart alias or its full chart-path. type ResourceMatcher struct { - Names []string `json:"names,omitempty"` - Namespaces []string `json:"namespaces,omitempty"` - Groups []string `json:"groups,omitempty"` - Versions []string `json:"versions,omitempty"` - Kinds []string `json:"kinds,omitempty"` + Names []string `json:"names,omitempty"` + Namespaces []string `json:"namespaces,omitempty"` + Groups []string `json:"groups,omitempty"` + Versions []string `json:"versions,omitempty"` + Kinds []string `json:"kinds,omitempty"` + Charts []string `json:"charts,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` } func (s *ResourceMatcher) Match(resMeta *ResourceMeta) bool { - var nameMatch bool - if len(s.Names) == 0 { - nameMatch = true - } else { - for _, name := range s.Names { - if resMeta.Name == name { - nameMatch = true - break + return matchStrings(s.Kinds, resMeta.GroupVersionKind.Kind, true) && + matchStrings(s.Names, resMeta.Name, false) && + matchStrings(s.Namespaces, resMeta.Namespace, false) && + matchStrings(s.Groups, resMeta.GroupVersionKind.Group, true) && + matchStrings(s.Versions, resMeta.GroupVersionKind.Version, true) && + matchCharts(s.Charts, resMeta.FilePath) && + matchKeyValues(s.Labels, resMeta.Labels) && + matchKeyValues(s.Annotations, resMeta.Annotations) +} + +// Validate compiles every /regex/ value and returns the first error, so callers +// that need fail-closed behavior can reject a bad matcher before matching. +func (s *ResourceMatcher) Validate() error { + for _, group := range [][]string{s.Names, s.Namespaces, s.Groups, s.Versions, s.Kinds, s.Charts} { + for _, value := range group { + if isRegexPattern(value) { + if _, err := compileMatchString(value); err != nil { + return err + } } } } - if !nameMatch { + return nil +} + +func matchCharts(patterns []string, filePath string) bool { + if len(patterns) == 0 { + return true + } + + charts := chartSegments(filePath) + if len(charts) == 0 { return false } - var namespaceMatch bool - if len(s.Namespaces) == 0 { - namespaceMatch = true - } else { - for _, namespace := range s.Namespaces { - if resMeta.Namespace == namespace { - namespaceMatch = true - break + for _, pattern := range patterns { + for _, chart := range charts { + if matchString(pattern, chart, false) { + return true } } } - if !namespaceMatch { - return false + return false +} + +func matchStrings(patterns []string, value string, caseInsensitive bool) bool { + if len(patterns) == 0 { + return true } - var groupMatch bool - if len(s.Groups) == 0 { - groupMatch = true - } else { - for _, group := range s.Groups { - if strings.EqualFold(resMeta.GroupVersionKind.Group, group) { - groupMatch = true - break - } + for _, pattern := range patterns { + if matchString(pattern, value, caseInsensitive) { + return true } } - if !groupMatch { - return false - } + return false +} - var versionMatch bool - if len(s.Versions) == 0 { - versionMatch = true - } else { - for _, version := range s.Versions { - if strings.EqualFold(resMeta.GroupVersionKind.Version, version) { - versionMatch = true - break - } +func matchString(pattern, value string, caseInsensitive bool) bool { + if isRegexPattern(pattern) { + re, err := compileMatchString(pattern) + if err != nil { + return false } + + return re.MatchString(value) } - if !versionMatch { - return false + if caseInsensitive { + return strings.EqualFold(value, pattern) } - var kindMatch bool - if len(s.Kinds) == 0 { - kindMatch = true - } else { - for _, kind := range s.Kinds { - if strings.EqualFold(resMeta.GroupVersionKind.Kind, kind) { - kindMatch = true - break - } + return value == pattern +} + +// chartSegments returns the chart match candidates for a rendered FilePath: for +// each "templates/" boundary, both the chart alias (the preceding segment) and +// the full chart-path. Standalone-CRD paths (no "templates/") fall back to the +// leading segment. Uses chart aliases, not upstream names. +func chartSegments(filePath string) []string { + if filePath == "" { + return nil + } + + segments := strings.Split(filePath, "/") + + var charts []string + for i, seg := range segments { + if seg == "templates" && i > 0 { + charts = append(charts, segments[i-1]) + charts = append(charts, strings.Join(segments[:i], "/")) } } - if !kindMatch { - return false + if len(charts) == 0 { + charts = append(charts, segments[0]) + } + + return charts +} + +// compileMatchString compiles a /…/ regexp value (anchored), caching the result. +// The caller must ensure value is a regexp pattern (see isRegexPattern). +func compileMatchString(value string) (*regexp.Regexp, error) { + if cached, ok := regexpCache.Load(value); ok { + return cached.(*regexp.Regexp), nil + } + + re, err := regexp.Compile(`\A(?:` + value[1:len(value)-1] + `)\z`) + if err != nil { + return nil, fmt.Errorf("compile regexp %q: %w", value, err) + } + + regexpCache.Store(value, re) + + return re, nil +} + +// isRegexPattern reports whether a matcher value uses the /…/ regexp convention. +func isRegexPattern(value string) bool { + return len(value) >= 2 && strings.HasPrefix(value, "/") && strings.HasSuffix(value, "/") +} + +func matchKeyValues(want, have map[string]string) bool { + for k, v := range want { + if got, ok := have[k]; !ok || got != v { + return false + } } return true From deda7bcc0183d278d6d1150c9e1f32f16d3aa1fa Mon Sep 17 00:00:00 2001 From: Ilya Lesikov Date: Wed, 15 Jul 2026 00:21:59 +0300 Subject: [PATCH 48/85] chore: fix tests Signed-off-by: Ilya Lesikov --- go.mod | 2 -- go.sum | 6 ----- .../helm-registry-test/authtest.htpasswd | 1 + .../helm-registry-test/registry/config.json | 7 ++++++ pkg/resource/spec/patch_ai_test.go | 22 +++++++++---------- 5 files changed, 19 insertions(+), 19 deletions(-) create mode 100644 pkg/helm/pkg/registry/helm-registry-test/authtest.htpasswd create mode 100644 pkg/helm/pkg/registry/helm-registry-test/registry/config.json diff --git a/go.mod b/go.mod index f98a4594..38e2f112 100644 --- a/go.mod +++ b/go.mod @@ -128,7 +128,6 @@ require ( github.com/go-openapi/swag/yamlutils v0.25.5 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.1 // indirect - github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect github.com/gorilla/handlers v1.5.2 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/gosimple/unidecode v1.0.1 // indirect @@ -208,7 +207,6 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect golang.org/x/mod v0.34.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect diff --git a/go.sum b/go.sum index 986f4d00..7be33003 100644 --- a/go.sum +++ b/go.sum @@ -406,14 +406,8 @@ github.com/wI2L/jsondiff v0.7.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ= github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM= github.com/werf/3p-cobra v0.0.0-20260403075225-552c82797324 h1:aqEM5aboMpBfsILjaxxRKhGFv9rGtNcd5YzMUDyVX+U= github.com/werf/3p-cobra v0.0.0-20260403075225-552c82797324/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/werf/common-go v0.0.0-20260212174520-adf7d95a1579 h1:LojMRgEoMNrUnfsbDG5GT4M5HXC9LAYH+5DWBY3p4uU= -github.com/werf/common-go v0.0.0-20260212174520-adf7d95a1579/go.mod h1:MXS0JR9zut+oR9oEM8PEkdXXoEbKDILTmWopt0z1eZs= github.com/werf/common-go v0.0.0-20260428201303-b0dcadceca5c h1:4/qKB2licflIlCZGX4U1o4Ij/oraYyuNc2cWFiutZ10= github.com/werf/common-go v0.0.0-20260428201303-b0dcadceca5c/go.mod h1:DlN/hD9tXLxYgdAMkulQdzHkiaPrvs3MzgPZvjluea4= -github.com/werf/kubedog v0.13.1-0.20260320165832-7d97aaf7aab9 h1:N+XKTPiXT5pf5lxThhaQQPARLUpZTlYJeMNoNtn+540= -github.com/werf/kubedog v0.13.1-0.20260320165832-7d97aaf7aab9/go.mod h1:93L6aIdpj7iIhL30Obkv7bWgUyTeuxas1ijtzjmyb4Q= -github.com/werf/kubedog v0.13.1-0.20260521123751-aabed6e8f8ac h1:iQlaWzQdWkl/Ad6SZ0a0Un3WQNPz7OmzJ3RBFdj7ICk= -github.com/werf/kubedog v0.13.1-0.20260521123751-aabed6e8f8ac/go.mod h1:tU8RyC5qXvJMPT2LmaNnVINWvwDHao0Ca3Bw9P8JC3E= github.com/werf/kubedog v0.13.1-0.20260709123314-5d578345082f h1:A3dA97W1cJwUP9CegXunkiJ6gAhek7hyDXP89P7P8r0= github.com/werf/kubedog v0.13.1-0.20260709123314-5d578345082f/go.mod h1:tU8RyC5qXvJMPT2LmaNnVINWvwDHao0Ca3Bw9P8JC3E= github.com/werf/lockgate v0.1.1 h1:S400JFYjtWfE4i4LY9FA8zx0fMdfui9DPrBiTciCrx4= diff --git a/pkg/helm/pkg/registry/helm-registry-test/authtest.htpasswd b/pkg/helm/pkg/registry/helm-registry-test/authtest.htpasswd new file mode 100644 index 00000000..a2f3e61d --- /dev/null +++ b/pkg/helm/pkg/registry/helm-registry-test/authtest.htpasswd @@ -0,0 +1 @@ +myuser:$2a$10$O9hViliAUmuA5UGt2hLyPOdM47J1ThPtdXVKyU0NC7tKs9RxaXBHi diff --git a/pkg/helm/pkg/registry/helm-registry-test/registry/config.json b/pkg/helm/pkg/registry/helm-registry-test/registry/config.json new file mode 100644 index 00000000..8edb16cf --- /dev/null +++ b/pkg/helm/pkg/registry/helm-registry-test/registry/config.json @@ -0,0 +1,7 @@ +{ + "auths": { + "helm-test-registry:42459": { + "auth": "bXl1c2VyOm15cGFzcw==" + } + } +} \ No newline at end of file diff --git a/pkg/resource/spec/patch_ai_test.go b/pkg/resource/spec/patch_ai_test.go index 9fd29145..9bd36e6c 100644 --- a/pkg/resource/spec/patch_ai_test.go +++ b/pkg/resource/spec/patch_ai_test.go @@ -9,14 +9,14 @@ import ( "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "github.com/werf/kubedog/pkg/trackers/rollout/multitrack" + "github.com/werf/kubedog/pkg/dyntracker/statestore" "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/resource/spec" ) func TestAI_LegacyOnlyTrackJobsPatcher_Patch(t *testing.T) { - defaultFailMode := string(multitrack.IgnoreAndContinueDeployProcess) - defaultTrackTermination := string(multitrack.NonBlocking) + defaultFailMode := string(statestore.IgnoreAndContinueDeployProcess) + defaultTrackTermination := string(statestore.NonBlocking) tests := []struct { name string @@ -34,32 +34,32 @@ func TestAI_LegacyOnlyTrackJobsPatcher_Patch(t *testing.T) { { name: "preserves both user-set values", input: map[string]string{ - common.AnnotationKeyHumanFailMode: string(multitrack.FailWholeDeployProcessImmediately), - common.AnnotationKeyHumanTrackTerminationMode: string(multitrack.WaitUntilResourceReady), + common.AnnotationKeyHumanFailMode: string(statestore.FailWholeDeployProcessImmediately), + common.AnnotationKeyHumanTrackTerminationMode: string(statestore.WaitUntilResourceReady), }, expected: map[string]string{ - common.AnnotationKeyHumanFailMode: string(multitrack.FailWholeDeployProcessImmediately), - common.AnnotationKeyHumanTrackTerminationMode: string(multitrack.WaitUntilResourceReady), + common.AnnotationKeyHumanFailMode: string(statestore.FailWholeDeployProcessImmediately), + common.AnnotationKeyHumanTrackTerminationMode: string(statestore.WaitUntilResourceReady), }, }, { name: "preserves fail-mode override and injects track-termination default", input: map[string]string{ - common.AnnotationKeyHumanFailMode: string(multitrack.FailWholeDeployProcessImmediately), + common.AnnotationKeyHumanFailMode: string(statestore.FailWholeDeployProcessImmediately), }, expected: map[string]string{ - common.AnnotationKeyHumanFailMode: string(multitrack.FailWholeDeployProcessImmediately), + common.AnnotationKeyHumanFailMode: string(statestore.FailWholeDeployProcessImmediately), common.AnnotationKeyHumanTrackTerminationMode: defaultTrackTermination, }, }, { name: "preserves track-termination override and injects fail-mode default", input: map[string]string{ - common.AnnotationKeyHumanTrackTerminationMode: string(multitrack.WaitUntilResourceReady), + common.AnnotationKeyHumanTrackTerminationMode: string(statestore.WaitUntilResourceReady), }, expected: map[string]string{ common.AnnotationKeyHumanFailMode: defaultFailMode, - common.AnnotationKeyHumanTrackTerminationMode: string(multitrack.WaitUntilResourceReady), + common.AnnotationKeyHumanTrackTerminationMode: string(statestore.WaitUntilResourceReady), }, }, { From 5c9ccecdb898e5a9a0fba4aaf6c40291f44e9672 Mon Sep 17 00:00:00 2001 From: Ilya Lesikov Date: Wed, 15 Jul 2026 00:23:02 +0300 Subject: [PATCH 49/85] chore: remove leaked test files Signed-off-by: Ilya Lesikov --- pkg/helm/pkg/registry/helm-registry-test/authtest.htpasswd | 1 - .../pkg/registry/helm-registry-test/registry/config.json | 7 ------- 2 files changed, 8 deletions(-) delete mode 100644 pkg/helm/pkg/registry/helm-registry-test/authtest.htpasswd delete mode 100644 pkg/helm/pkg/registry/helm-registry-test/registry/config.json diff --git a/pkg/helm/pkg/registry/helm-registry-test/authtest.htpasswd b/pkg/helm/pkg/registry/helm-registry-test/authtest.htpasswd deleted file mode 100644 index a2f3e61d..00000000 --- a/pkg/helm/pkg/registry/helm-registry-test/authtest.htpasswd +++ /dev/null @@ -1 +0,0 @@ -myuser:$2a$10$O9hViliAUmuA5UGt2hLyPOdM47J1ThPtdXVKyU0NC7tKs9RxaXBHi diff --git a/pkg/helm/pkg/registry/helm-registry-test/registry/config.json b/pkg/helm/pkg/registry/helm-registry-test/registry/config.json deleted file mode 100644 index 8edb16cf..00000000 --- a/pkg/helm/pkg/registry/helm-registry-test/registry/config.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "auths": { - "helm-test-registry:42459": { - "auth": "bXl1c2VyOm15cGFzcw==" - } - } -} \ No newline at end of file From 8f85359dc1d45dacb3b4cb0b3e4d7de807d5966e Mon Sep 17 00:00:00 2001 From: Ilya Lesikov Date: Tue, 21 Jul 2026 13:53:24 +0300 Subject: [PATCH 50/85] fix: cleanup TODOs for major (#663) Signed-off-by: Ilya Lesikov --- Taskfile.dist.yaml | 5 -- cmd/nelm/chart_pack.go | 1 - cmd/nelm/release.go | 2 - pkg/action/chart_lint.go | 1 - pkg/action/chart_render.go | 1 - pkg/chart/chart_render.go | 1 - pkg/chart/chart_render_ai_test.go | 4 -- pkg/common/common.go | 5 +- .../issue-7233/charts/alpine-0.1.0.tgz | Bin 1166 -> 1166 bytes pkg/kube/factory.go | 32 +++++-------- pkg/kube/fake/factory.go | 4 -- pkg/kube/legacy_client_getter.go | 44 ------------------ pkg/plan/resource_info.go | 1 - pkg/resource/kubeconform.go | 1 - pkg/resource/sensitive.go | 2 +- 15 files changed, 15 insertions(+), 89 deletions(-) delete mode 100644 pkg/kube/legacy_client_getter.go diff --git a/Taskfile.dist.yaml b/Taskfile.dist.yaml index 9806fede..2c61493b 100644 --- a/Taskfile.dist.yaml +++ b/Taskfile.dist.yaml @@ -287,11 +287,6 @@ tasks: generate:reference: desc: "Generate CLI reference documentation with TOC." - env: - # TODO(major): not supported anymore - HELM_CONFIG_HOME: "~/.config/helm" - HELM_CACHE_HOME: "~/.cache/helm" - HELM_DATA_HOME: "~/.local/share/helm" cmds: - go run {{.package}} generate-reference - task: generate:doctoc diff --git a/cmd/nelm/chart_pack.go b/cmd/nelm/chart_pack.go index a0feb3b2..61239c60 100644 --- a/cmd/nelm/chart_pack.go +++ b/cmd/nelm/chart_pack.go @@ -34,7 +34,6 @@ func newChartPackCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co cmd.RunE = func(cmd *cobra.Command, args []string) error { helmSettings := helmcmd.Settings - // TODO(major): should we do it like that everywhere, setting the context? ctx = action.SetupLogging(cmd.Context(), lo.Ternary(helmSettings.Debug, log.DebugLevel, log.InfoLevel), action.SetupLoggingOptions{}) ctx = ts.NewContextWithTSOptions(ctx, opts) cmd.SetContext(ctx) diff --git a/cmd/nelm/release.go b/cmd/nelm/release.go index 74a5f58a..222c9a47 100644 --- a/cmd/nelm/release.go +++ b/cmd/nelm/release.go @@ -26,7 +26,5 @@ func newReleaseCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*cobr cmd.AddCommand(newReleaseGetCommand(ctx, afterAllCommandsBuiltFuncs)) cmd.AddCommand(newPlanCommand(ctx, afterAllCommandsBuiltFuncs)) - // TODO(major): in v1 don't fail on unknown env var, maybe warning (which can be disabled)? - return cmd } diff --git a/pkg/action/chart_lint.go b/pkg/action/chart_lint.go index 4a59484a..10500010 100644 --- a/pkg/action/chart_lint.go +++ b/pkg/action/chart_lint.go @@ -424,7 +424,6 @@ func applyChartLintOptionsDefaults(opts ChartLintOptions, currentDir, homeDir st } if opts.LocalKubeVersion == "" { - // TODO(major): update default local version opts.LocalKubeVersion = common.DefaultLocalKubeVersion } diff --git a/pkg/action/chart_render.go b/pkg/action/chart_render.go index bfae6a55..094c5211 100644 --- a/pkg/action/chart_render.go +++ b/pkg/action/chart_render.go @@ -409,7 +409,6 @@ func applyChartRenderOptionsDefaults(opts ChartRenderOptions, currentDir, homeDi } if opts.LocalKubeVersion == "" { - // TODO(major): update default local version opts.LocalKubeVersion = common.DefaultLocalKubeVersion } diff --git a/pkg/chart/chart_render.go b/pkg/chart/chart_render.go index 342ef63e..3c5b6609 100644 --- a/pkg/chart/chart_render.go +++ b/pkg/chart/chart_render.go @@ -456,7 +456,6 @@ func renderedTemplatesToResourceSpecs(ctx context.Context, renderedTemplates map if err := yaml.UnmarshalWithOptions( []byte(manifest), &head, - // TODO(major): remove yaml.AllowDuplicateMapKey(), ); err != nil { return nil, fmt.Errorf("parse YAML resource #%d for %q: %w", idx+1, filePath, err) diff --git a/pkg/chart/chart_render_ai_test.go b/pkg/chart/chart_render_ai_test.go index 96ee9cc7..b8390989 100644 --- a/pkg/chart/chart_render_ai_test.go +++ b/pkg/chart/chart_render_ai_test.go @@ -49,10 +49,6 @@ func (f *chartCapabilitiesClientFactory) KubeConfig() *kube.KubeConfig { panic("not implemented") } -func (f *chartCapabilitiesClientFactory) LegacyClientGetter() *kube.LegacyClientGetter { - panic("not implemented") -} - func (f *chartCapabilitiesClientFactory) Mapper() apimeta.ResettableRESTMapper { panic("not implemented") } diff --git a/pkg/common/common.go b/pkg/common/common.go index e7c6343e..c2eac4be 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -106,9 +106,8 @@ const ( // ChartTSOutputFile is the name of the output file with rendered manifests from the Deno app. ChartTSOutputFile = "output.yaml" // ChartTSSourceDir is the directory containing TypeScript sources in a Helm chart. - ChartTSSourceDir = "ts/" - DefaultBurstLimit = 100 - // TODO(major): switch to if-possible + ChartTSSourceDir = "ts/" + DefaultBurstLimit = 100 DefaultChartProvenanceStrategy = "never" DefaultDeletePropagation = metav1.DeletePropagationBackground DefaultDependencyExternal = DependencyExternalAuto diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/charts/alpine-0.1.0.tgz b/pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/charts/alpine-0.1.0.tgz index 4dc00b7db2c0234ac26c94dea4eda5f3bb9cef83..fa8d1a7ac72209301dd03de5f315c64c3803e608 100644 GIT binary patch delta 868 zcmV-q1DpJg362SnpAD1;9?U$D`5I-6dRtciay*7N@y^1;)x*bW+IzO+8QoL3tD zTPjL{mdr2=3`&)KwQTSV|HqT*xWoV1blUU(EYhj}w_Ij(_^7g|Ad4AVoJRnmcE`J2 zazld9P~y_Yem~gll24&>gn>8$5V#~^+%kYy@Iy`zV_7D*)snO%OD5rJSfIo;WrO85 z4hw&P8>kFrU=>)6=`GYbPjj$Ji3Te3%?D+Gu;v?*kou+--rHvJsuHkL8ZaxkG*IUX zYLwPN8X~B3v<;LFW*9&yQCpr?0=3`EL1{9jl7=q@IlRO#ddh}5JaEnTq&GYz_zb^R z$`b7NPrHIkV^O9QL#pJ4r0cYxz*{oFEf9a2t@jBhz*g@D&?-P7K&`;$1m7G`odKc2 z0-K|SnlwSG39WOU@g|2=U59+h)Kgt$e)n(8BF@kc4Z-^`Xgp=p1eyTa8u5 zZ8&GB46Yjm+s4v=2!R=p@53^@H`|231PEP@H{vL4BU4i9BDT@P*M8Rvg_ z!;4Z-KuE%@nhOC!soM~AP@*xE4Y&-mJY0%GQ05NAqP8EE;ORDj_bX>4Hz1XTm8xX+ z+6{O>x1BrAj8<3)+wADUcDr^k{y-bjF(gO8`@O?NFoLghE4H9Y%M1P+Wzd{L6DuCA zP%bJCA%_CYR$9FC?m5DR&(PV}AKQP?ocSMV*EqSOWCU(oi(HcSd1BBcm>Rhy?N5pq zxOOQgRHOM9Vkewxs)_<#+WILTF1Z{UK~%=C<2Q`4_~*W(c~YACfAjA8?RW2zB0I$% z&-MSa%d2kx&qn?I|19#Vxx~-my3KL_CsBl)8$~yj^vO@I1-To(OCS$#{Yifm`CLeM z=-1Y0U`>*=uQioh=(AbFQCQ?2+}EP!XTZW^5G?L{&;?{%Q)-powyDD9wGHWQtEAJV zEpt$Fn*&Cvs@x~Faup5>vZPH5{@{YUfE5>X5BwA~D~LNYv9{L(+ievpR$bZ0gGa-zQg&))whlMer z#$kT}a08X046FjHF};O4=V=aBDbYY>zWJaG5Y~J{5>nr^!h72+UR45CN&{vEmj>!w zL5>s zxDDqFmBDqRVB1*w4VVfO2*lyPj#vf=yI)>y3c)xd;2uARAZp9W>ei*-DFd-aSXS@EJNA`(uAQnlt|+?HVU{l#IY_YmrORK2Hpq1XClor2R?p z0@p6(glaVZLhOW7O;u5#OItt1!zGtPBZ$iQb^L}g7XRFLG*3!X|8L%1zy0oAQe>yt z^P{>ra28$mc@3 zL%+6018b6`eXXh7LZ8hVj>01M;Jy|$KLZvXgJ5yrgDxQBno_IuwoMf-uWd+gTP2+? zZJC3b+Z-@TRpmaZm8)=AkR@$e@CO&%1+2KBd*Dae*7$_(^5mk^4KJWoGKr!eRRt+Z z@WoLGun!#(Ob_O^6O!S<;+H=@_JcJ1r#q|hmlOR3c=rB3nRNI+9kFrG|8s~XV_gjn u{{eg$3_=+EePXni(@DdB*9@MD{(pl1-TVLKYRr>v1Q-a9ucJ=@AOHaMTcAS# diff --git a/pkg/kube/factory.go b/pkg/kube/factory.go index 507e43a1..d78bab1b 100644 --- a/pkg/kube/factory.go +++ b/pkg/kube/factory.go @@ -28,20 +28,18 @@ type ClientFactorier interface { Dynamic() dynamic.Interface Discovery() discovery.CachedDiscoveryInterface Mapper() meta.ResettableRESTMapper - LegacyClientGetter() *LegacyClientGetter KubeConfig() *KubeConfig } // Constructs all Kubernetes clients you may possibly need and makes it easy to pass them all // around. type ClientFactory struct { - discoveryClient discovery.CachedDiscoveryInterface - dynamicClient dynamic.Interface - kubeClient KubeClienter - kubeConfig *KubeConfig - legacyClientGetter *LegacyClientGetter - mapper meta.ResettableRESTMapper - staticClient kubernetes.Interface + discoveryClient discovery.CachedDiscoveryInterface + dynamicClient dynamic.Interface + kubeClient KubeClienter + kubeConfig *KubeConfig + mapper meta.ResettableRESTMapper + staticClient kubernetes.Interface } func NewClientFactory(ctx context.Context, kubeConfig *KubeConfig) (*ClientFactory, error) { @@ -71,16 +69,14 @@ func NewClientFactory(ctx context.Context, kubeConfig *KubeConfig) (*ClientFacto mapper := reflect.ValueOf(NewKubeMapper(ctx, discoveryClient)).Interface().(meta.ResettableRESTMapper) kubeClient := NewKubeClient(staticClient, dynamicClient, discoveryClient, mapper) - legacyClientGetter := NewLegacyClientGetter(discoveryClient, mapper, kubeConfig.RestConfig, kubeConfig.LegacyClientConfig) clientFactory := &ClientFactory{ - discoveryClient: discoveryClient, - dynamicClient: dynamicClient, - kubeClient: kubeClient, - kubeConfig: kubeConfig, - legacyClientGetter: legacyClientGetter, - mapper: mapper, - staticClient: staticClient, + discoveryClient: discoveryClient, + dynamicClient: dynamicClient, + kubeClient: kubeClient, + kubeConfig: kubeConfig, + mapper: mapper, + staticClient: staticClient, } return clientFactory, nil @@ -102,10 +98,6 @@ func (f *ClientFactory) KubeConfig() *KubeConfig { return f.kubeConfig } -func (f *ClientFactory) LegacyClientGetter() *LegacyClientGetter { - return f.legacyClientGetter -} - func (f *ClientFactory) Mapper() meta.ResettableRESTMapper { return f.mapper } diff --git a/pkg/kube/fake/factory.go b/pkg/kube/fake/factory.go index 928d1802..cebfae6c 100644 --- a/pkg/kube/fake/factory.go +++ b/pkg/kube/fake/factory.go @@ -70,10 +70,6 @@ func (f *ClientFactory) KubeConfig() *kube.KubeConfig { panic("not implemented yet") } -func (f *ClientFactory) LegacyClientGetter() *kube.LegacyClientGetter { - panic("not implemented yet") -} - func (f *ClientFactory) Mapper() meta.ResettableRESTMapper { return f.mapper } diff --git a/pkg/kube/legacy_client_getter.go b/pkg/kube/legacy_client_getter.go deleted file mode 100644 index 7d591019..00000000 --- a/pkg/kube/legacy_client_getter.go +++ /dev/null @@ -1,44 +0,0 @@ -package kube - -import ( - "k8s.io/apimachinery/pkg/api/meta" - "k8s.io/cli-runtime/pkg/genericclioptions" - "k8s.io/client-go/discovery" - "k8s.io/client-go/rest" - "k8s.io/client-go/tools/clientcmd" -) - -var _ genericclioptions.RESTClientGetter = (*LegacyClientGetter)(nil) - -type LegacyClientGetter struct { - discoveryClient discovery.CachedDiscoveryInterface - legacyClientConfig clientcmd.ClientConfig - mapper meta.ResettableRESTMapper - restConfig *rest.Config -} - -// TODO(major): get rid -func NewLegacyClientGetter(discoveryClient discovery.CachedDiscoveryInterface, mapper meta.ResettableRESTMapper, restConfig *rest.Config, legacyClientConfig clientcmd.ClientConfig) *LegacyClientGetter { - return &LegacyClientGetter{ - discoveryClient: discoveryClient, - legacyClientConfig: legacyClientConfig, - mapper: mapper, - restConfig: restConfig, - } -} - -func (g *LegacyClientGetter) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) { - return g.discoveryClient, nil -} - -func (g *LegacyClientGetter) ToRESTConfig() (*rest.Config, error) { - return g.restConfig, nil -} - -func (g *LegacyClientGetter) ToRESTMapper() (meta.RESTMapper, error) { - return g.mapper, nil -} - -func (g *LegacyClientGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig { - return g.legacyClientConfig -} diff --git a/pkg/plan/resource_info.go b/pkg/plan/resource_info.go index 7a832933..b925c4ad 100644 --- a/pkg/plan/resource_info.go +++ b/pkg/plan/resource_info.go @@ -161,7 +161,6 @@ func ResourceInstallTypeSortHandler(type1, type2 ResourceInstallType) bool { return type1I < type2I } -// TODO(major): keep annotation should probably forbid resource recreations func buildInstallableResourceInfo(ctx context.Context, localRes *resource.InstallableResource, deployType common.DeployType, releaseNamespace string, prevRelFailed, noRemoveManualChanges bool, clientFactory kube.ClientFactorier, opts BuildResourceInfosOptions, diffPatches []*spec.CompiledDiffPatch) ([]*InstallableResourceInfo, error) { var stages []common.Stage switch deployType { diff --git a/pkg/resource/kubeconform.go b/pkg/resource/kubeconform.go index 3d289e5c..f172eadb 100644 --- a/pkg/resource/kubeconform.go +++ b/pkg/resource/kubeconform.go @@ -72,7 +72,6 @@ func (kc *kubeConformValidator) Validate(ctx context.Context, resourceSpec *spec } validatorLoop: - // TODO(major): if possible, we should use only a single yaml marshaller and a single json marshaller everywhere for _, schemaValidator := range validators { validationErrs := &util.MultiError{} diff --git a/pkg/resource/sensitive.go b/pkg/resource/sensitive.go index a08e5b00..3b6f7b83 100644 --- a/pkg/resource/sensitive.go +++ b/pkg/resource/sensitive.go @@ -96,7 +96,7 @@ func RedactSensitiveData(unstruct *unstructured.Unstructured, sensitivePaths []s func redactSensitiveData(unstruct *unstructured.Unstructured, sensitivePaths []string) *unstructured.Unstructured { for _, pathExpr := range sensitivePaths { - // TODO(major): should we remove this? + // TODO: should we remove this? if pathExpr == HideAll { return &unstructured.Unstructured{Object: map[string]interface{}{ "apiVersion": unstruct.GetAPIVersion(), From fec11597241b54945e32bd9a924b0945ee379c47 Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:42:14 +0300 Subject: [PATCH 51/85] fix: register go-client auth plugins (#670) Signed-off-by: Ilya Drey --- pkg/kube/client_static.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/kube/client_static.go b/pkg/kube/client_static.go index be635c40..dfdc8862 100644 --- a/pkg/kube/client_static.go +++ b/pkg/kube/client_static.go @@ -4,6 +4,7 @@ import ( "fmt" "k8s.io/client-go/kubernetes" + _ "k8s.io/client-go/plugin/pkg/client/auth" ) func NewStaticKubeClientFromKubeConfig(kubeConfig *KubeConfig) (*kubernetes.Clientset, error) { From 0d2b4e06f3a6d2cc5196a3ae3a02c4ce0e24ea90 Mon Sep 17 00:00:00 2001 From: Aleksei Igrychev Date: Tue, 28 Jul 2026 09:34:00 +0100 Subject: [PATCH 52/85] feat: port --lookup-resources and --no-create-namespace from main (#671) * feat: add --lookup-resources flag for offline lookup in render/lint commands (#657) Signed-off-by: Dmitry Mordvinov (cherry picked from commit a984242ee37ecc17dfa711450102955ed571a907) * fix: create release namespace under strict RBAC, `--no-create-namespace` flag (#662) Signed-off-by: Dmitry Mordvinov (cherry picked from commit 3d389efe5cbc46a514af521f9072075f538acf68) --------- Signed-off-by: Dmitry Mordvinov Co-authored-by: Dmitry Mordvinov --- cmd/nelm/chart_lint.go | 8 + cmd/nelm/chart_render.go | 8 + cmd/nelm/release_install.go | 7 + docs/reference.md | 12 + pkg/action/chart_lint.go | 9 + pkg/action/chart_render.go | 9 + pkg/action/release_install.go | 64 +++-- pkg/action/release_install_ai_test.go | 251 ++++++++++++++++++ pkg/chart/chart_render.go | 90 ++++++- pkg/chart/lookup.go | 102 +++++++ pkg/chart/lookup_ai_test.go | 177 ++++++++++++ .../parse_local_lookup_resources_ai_test.go | 163 ++++++++++++ pkg/common/common.go | 1 + pkg/helm/pkg/engine/engine.go | 6 + pkg/lock/lock_manager.go | 7 +- pkg/util/manifest.go | 26 ++ 16 files changed, 919 insertions(+), 21 deletions(-) create mode 100644 pkg/action/release_install_ai_test.go create mode 100644 pkg/chart/lookup.go create mode 100644 pkg/chart/lookup_ai_test.go create mode 100644 pkg/chart/parse_local_lookup_resources_ai_test.go diff --git a/cmd/nelm/chart_lint.go b/cmd/nelm/chart_lint.go index bfd4a71f..b7de43b0 100644 --- a/cmd/nelm/chart_lint.go +++ b/cmd/nelm/chart_lint.go @@ -162,6 +162,14 @@ func newChartLintCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co return fmt.Errorf("add flag: %w", err) } + if err := cli.AddFlag(cmd, &cfg.LocalLookupResourcesPaths, "lookup-resources", nil, "Manifest files used as a cluster stub for the lookup template function in non-remote mode. Multi-document and kind:List supported. Namespaced resources must set metadata.namespace, otherwise namespace-scoped lookups ignore the requested namespace", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalMultiEnvVarRegexes, + Group: mainFlagGroup, + Type: cli.FlagTypeFile, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + if err := cli.AddFlag(cmd, &cfg.LocalKubeVersion, "kube-version", common.DefaultLocalKubeVersion, "Kubernetes version stub for non-remote mode", cli.AddFlagOptions{ Group: mainFlagGroup, }); err != nil { diff --git a/cmd/nelm/chart_render.go b/cmd/nelm/chart_render.go index a9e2e38c..3c23adad 100644 --- a/cmd/nelm/chart_render.go +++ b/cmd/nelm/chart_render.go @@ -139,6 +139,14 @@ func newChartRenderCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[* return fmt.Errorf("add flag: %w", err) } + if err := cli.AddFlag(cmd, &cfg.LocalLookupResourcesPaths, "lookup-resources", nil, "Manifest files used as a cluster stub for the lookup template function in non-remote mode. Multi-document and kind:List supported. Namespaced resources must set metadata.namespace, otherwise namespace-scoped lookups ignore the requested namespace", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalMultiEnvVarRegexes, + Group: mainFlagGroup, + Type: cli.FlagTypeFile, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + if err := cli.AddFlag(cmd, &cfg.LocalKubeVersion, "kube-version", common.DefaultLocalKubeVersion, "Kubernetes version stub for non-remote mode", cli.AddFlagOptions{ Group: mainFlagGroup, }); err != nil { diff --git a/cmd/nelm/release_install.go b/cmd/nelm/release_install.go index cc1a375d..12ef8359 100644 --- a/cmd/nelm/release_install.go +++ b/cmd/nelm/release_install.go @@ -206,6 +206,13 @@ func newReleaseInstallCommand(ctx context.Context, afterAllCommandsBuiltFuncs ma return fmt.Errorf("add flag: %w", err) } + if err := cli.AddFlag(cmd, &cfg.NoCreateNamespace, "no-create-namespace", false, "Don't create the release namespace", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: mainFlagGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + if err := cli.AddFlag(cmd, &cfg.NoShowNotes, "no-notes", false, "Don't show release notes at the end of the release", cli.AddFlagOptions{ Group: mainFlagGroup, }); err != nil { diff --git a/docs/reference.md b/docs/reference.md index 37a9fc5a..223ece8a 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -143,6 +143,10 @@ nelm release install [options...] -n namespace -r release [chart-dir|chart-repo- The release namespace\. Resources with no namespace will be deployed here\. Vars: \$NELM\_NAMESPACE, \$NELM\_RELEASE\_INSTALL\_NAMESPACE +- `--no-create-namespace` (default: `false`) + + Don't create the release namespace\. Vars: \$NELM\_NO\_CREATE\_NAMESPACE, \$NELM\_RELEASE\_INSTALL\_NO\_CREATE\_NAMESPACE + - `--no-install-crds` (default: `false`) Don't install CRDs from "crds/" directories of installed charts\. Var: \$NELM\_RELEASE\_INSTALL\_NO\_INSTALL\_CRDS @@ -2090,6 +2094,10 @@ nelm chart lint [options...] [chart-dir|chart-repo-name/chart-name|chart-archive Kubernetes version stub for non\-remote mode\. Var: \$NELM\_CHART\_LINT\_KUBE\_VERSION +- `--lookup-resources` (default: `[]`) + + Manifest files used as a cluster stub for the lookup template function in non\-remote mode\. Multi\-document and kind:List supported\. Namespaced resources must set metadata\.namespace, otherwise namespace\-scoped lookups ignore the requested namespace\. Vars: \$NELM\_LOOKUP\_RESOURCES\_\*, \$NELM\_CHART\_LINT\_LOOKUP\_RESOURCES\_\* + - `-n`, `--namespace` (default: `"stub-namespace"`) The release namespace\. Resources with no namespace will be deployed here\. Vars: \$NELM\_NAMESPACE, \$NELM\_CHART\_LINT\_NAMESPACE @@ -2469,6 +2477,10 @@ nelm chart render [options...] [chart-dir|chart-repo-name/chart-name|chart-archi Kubernetes version stub for non\-remote mode\. Var: \$NELM\_CHART\_RENDER\_KUBE\_VERSION +- `--lookup-resources` (default: `[]`) + + Manifest files used as a cluster stub for the lookup template function in non\-remote mode\. Multi\-document and kind:List supported\. Namespaced resources must set metadata\.namespace, otherwise namespace\-scoped lookups ignore the requested namespace\. Vars: \$NELM\_LOOKUP\_RESOURCES\_\*, \$NELM\_CHART\_RENDER\_LOOKUP\_RESOURCES\_\* + - `-n`, `--namespace` (default: `"stub-namespace"`) The release namespace\. Resources with no namespace will be deployed here\. Vars: \$NELM\_NAMESPACE, \$NELM\_CHART\_RENDER\_NAMESPACE diff --git a/pkg/action/chart_lint.go b/pkg/action/chart_lint.go index 10500010..3ac422e1 100644 --- a/pkg/action/chart_lint.go +++ b/pkg/action/chart_lint.go @@ -96,6 +96,14 @@ type ChartLintOptions struct { // LocalKubeVersion specifies the Kubernetes version to use for linting when not connected to a cluster. // Format: "major.minor.patch" (e.g., "1.28.0"). Defaults to DefaultLocalKubeVersion if not set. LocalKubeVersion string + // LocalLookupResourcesPaths are paths to YAML or JSON manifest files whose resources the "lookup" + // template function resolves against in local mode (Remote=false), instead of a live cluster. + // Each file may hold multiple documents separated by "---" and/or a kind: List (or typed *List) + // wrapper whose items are expanded; a bare top-level JSON array is not supported. Duplicate + // resources are rejected. Ignored if Remote is true. Namespace scope is inferred from + // whether a resource sets metadata.namespace; namespaced resources must set it, otherwise + // namespace-scoped lookups ignore the requested namespace and match the resource regardless. + LocalLookupResourcesPaths []string // NetworkParallelism limits the number of concurrent network-related operations (API calls, resource fetches). // Defaults to DefaultNetworkParallelism if not set or <= 0. NetworkParallelism int @@ -266,6 +274,7 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { TempDirPath: opts.TempDirPath, IgnoreBundleJS: opts.IgnoreBundleJS, DenoBinaryPath: opts.DenoBinaryPath, + LocalLookupResourcesPaths: opts.LocalLookupResourcesPaths, } log.Default.Debug(ctx, "Render chart") diff --git a/pkg/action/chart_render.go b/pkg/action/chart_render.go index 094c5211..947adb4a 100644 --- a/pkg/action/chart_render.go +++ b/pkg/action/chart_render.go @@ -89,6 +89,14 @@ type ChartRenderOptions struct { // LocalKubeVersion specifies the Kubernetes version to use for template rendering when not connected to a cluster. // Format: "major.minor.patch" (e.g., "1.28.0"). Defaults to DefaultLocalKubeVersion if not set. LocalKubeVersion string + // LocalLookupResourcesPaths are paths to YAML or JSON manifest files whose resources the "lookup" + // template function resolves against in local mode (Remote=false), instead of a live cluster. + // Each file may hold multiple documents separated by "---" and/or a kind: List (or typed *List) + // wrapper whose items are expanded; a bare top-level JSON array is not supported. Duplicate + // resources are rejected. Ignored if Remote is true. Namespace scope is inferred from + // whether a resource sets metadata.namespace; namespaced resources must set it, otherwise + // namespace-scoped lookups ignore the requested namespace and match the resource regardless. + LocalLookupResourcesPaths []string // NetworkParallelism limits the number of concurrent network-related operations (API calls, resource fetches). // Defaults to DefaultNetworkParallelism if not set or <= 0. NetworkParallelism int @@ -264,6 +272,7 @@ func ChartRender(ctx context.Context, opts ChartRenderOptions) (*ChartRenderResu TempDirPath: opts.TempDirPath, IgnoreBundleJS: opts.IgnoreBundleJS, DenoBinaryPath: opts.DenoBinaryPath, + LocalLookupResourcesPaths: opts.LocalLookupResourcesPaths, } log.Default.Debug(ctx, "Render chart") diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index 7c38f1ff..6d2a6420 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -108,6 +108,8 @@ type ReleaseInstallOptions struct { // NetworkParallelism limits the number of concurrent network-related operations (API calls, resource fetches). // Defaults to DefaultNetworkParallelism if not set or <= 0. NetworkParallelism int + // NoCreateNamespace, when true, skips creating the release namespace entirely. + NoCreateNamespace bool // NoShowNotes, when true, suppresses printing of NOTES.txt after successful installation. // NOTES.txt typically contains usage instructions and next steps. NoShowNotes bool @@ -275,8 +277,10 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re lockManager = m } - if err := createReleaseNamespace(ctx, clientFactory, releaseNamespace); err != nil { - return fmt.Errorf("create release namespace: %w", err) + if !opts.NoCreateNamespace { + if err := createReleaseNamespace(ctx, clientFactory, releaseNamespace); err != nil { + return fmt.Errorf("create release namespace: %w", err) + } } log.Default.Info(ctx, color.Style{color.Bold, color.Green}.Render("Start release")+" %q (namespace: %q)", releaseName, releaseNamespace) @@ -766,8 +770,19 @@ func applyReleaseInstallOptionsDefaults(opts ReleaseInstallOptions, currentDir, return opts, nil } -func createReleaseNamespace(ctx context.Context, clientFactory *kube.ClientFactory, releaseNamespace string) error { - unstruct := &unstructured.Unstructured{ +func createReleaseNamespace(ctx context.Context, clientFactory kube.ClientFactorier, releaseNamespace string) error { + cmUnstruct := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": common.LockConfigMapName, + "namespace": releaseNamespace, + }, + }, + } + + nsUnstruct := &unstructured.Unstructured{ Object: map[string]interface{}{ "apiVersion": "v1", "kind": "Namespace", @@ -777,21 +792,38 @@ func createReleaseNamespace(ctx context.Context, clientFactory *kube.ClientFacto }, } - resSpec := spec.NewResourceSpec(unstruct, releaseNamespace, spec.ResourceSpecOptions{}) + cmResSpec := spec.NewResourceSpec(cmUnstruct, releaseNamespace, spec.ResourceSpecOptions{}) + nsResSpec := spec.NewResourceSpec(nsUnstruct, releaseNamespace, spec.ResourceSpecOptions{}) + + _, cmApplyErr := clientFactory.KubeClient().Apply(ctx, cmResSpec, kube.KubeClientApplyOptions{ + DefaultNamespace: releaseNamespace, + DryRun: true, + }) + if cmApplyErr == nil { + return nil + } + + if !errors.IsForbidden(cmApplyErr) && !errors.IsNotFound(cmApplyErr) { + return fmt.Errorf("dry-run apply release synchronization configmap: %w", cmApplyErr) + } - if _, err := clientFactory.KubeClient().Get(ctx, resSpec.ResourceMeta, kube.KubeClientGetOptions{ - TryCache: true, - }); err != nil { - if kube.IsNotFoundErr(err) { - log.Default.Debug(ctx, "Create release namespace %q", releaseNamespace) + if _, nsApplyErr := clientFactory.KubeClient().Apply(ctx, nsResSpec, kube.KubeClientApplyOptions{ + DefaultNamespace: releaseNamespace, + DryRun: true, + }); nsApplyErr != nil { + if errors.IsForbidden(nsApplyErr) || errors.IsNotFound(nsApplyErr) { + allErr := &util.MultiError{} - if _, err := clientFactory.KubeClient().Create(ctx, resSpec, kube.KubeClientCreateOptions{}); err != nil { - return fmt.Errorf("create release namespace: %w", err) - } - } else if errors.IsForbidden(err) { - } else { - return fmt.Errorf("get release namespace: %w", err) + return fmt.Errorf("can't apply ConfigMap for locking, and can't apply release namespace (in case ConfigMap apply error caused by non-existent namespace): %w", allErr.Add(cmApplyErr, nsApplyErr)) } + + return fmt.Errorf("dry-run apply release namespace: %w", nsApplyErr) + } + + log.Default.Debug(ctx, "Ensure release namespace %q", releaseNamespace) + + if _, err := clientFactory.KubeClient().Create(ctx, nsResSpec, kube.KubeClientCreateOptions{}); err != nil { + return fmt.Errorf("create release namespace: %w", err) } return nil diff --git a/pkg/action/release_install_ai_test.go b/pkg/action/release_install_ai_test.go new file mode 100644 index 00000000..cc3df1d3 --- /dev/null +++ b/pkg/action/release_install_ai_test.go @@ -0,0 +1,251 @@ +//go:build ai_tests + +package action + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/version" + "k8s.io/client-go/discovery" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + + "github.com/werf/nelm/pkg/kube" + "github.com/werf/nelm/pkg/resource/spec" +) + +var ( + _ kube.ClientFactorier = (*createNamespaceClientFactory)(nil) + _ kube.KubeClienter = (*createNamespaceKubeClient)(nil) +) + +type createNamespaceClientFactory struct { + kubeClient kube.KubeClienter +} + +func (f *createNamespaceClientFactory) Discovery() discovery.CachedDiscoveryInterface { + panic("not implemented") +} + +func (f *createNamespaceClientFactory) Dynamic() dynamic.Interface { + panic("not implemented") +} + +func (f *createNamespaceClientFactory) KubeClient() kube.KubeClienter { + return f.kubeClient +} + +func (f *createNamespaceClientFactory) KubeConfig() *kube.KubeConfig { + panic("not implemented") +} + +func (f *createNamespaceClientFactory) LegacyClientGetter() *kube.LegacyClientGetter { + panic("not implemented") +} + +func (f *createNamespaceClientFactory) Mapper() apimeta.ResettableRESTMapper { + panic("not implemented") +} + +func (f *createNamespaceClientFactory) Static() kubernetes.Interface { + panic("not implemented") +} + +type createNamespaceCall struct { + dryRun bool + kind string +} + +type createNamespaceKubeClient struct { + calls []createNamespaceCall + cmApplyErr error + nsApplyErr error + nsCreateErr error +} + +func (c *createNamespaceKubeClient) Apply(ctx context.Context, resSpec *spec.ResourceSpec, opts kube.KubeClientApplyOptions) (*unstructured.Unstructured, error) { + kind := resSpec.GroupVersionKind.Kind + c.calls = append(c.calls, createNamespaceCall{dryRun: opts.DryRun, kind: kind}) + + switch kind { + case "ConfigMap": + return nil, c.cmApplyErr + case "Namespace": + return nil, c.nsApplyErr + default: + panic("unexpected apply kind: " + kind) + } +} + +func (c *createNamespaceKubeClient) Create(ctx context.Context, resSpec *spec.ResourceSpec, opts kube.KubeClientCreateOptions) (*unstructured.Unstructured, error) { + c.calls = append(c.calls, createNamespaceCall{dryRun: false, kind: resSpec.GroupVersionKind.Kind}) + + return nil, c.nsCreateErr +} + +func (c *createNamespaceKubeClient) Delete(ctx context.Context, meta *spec.ResourceMeta, opts kube.KubeClientDeleteOptions) error { + panic("not implemented") +} + +func (c *createNamespaceKubeClient) GVKToGVR(ctx context.Context, gvk schema.GroupVersionKind) (schema.GroupVersionResource, bool, error) { + panic("not implemented") +} + +func (c *createNamespaceKubeClient) Get(ctx context.Context, meta *spec.ResourceMeta, opts kube.KubeClientGetOptions) (*unstructured.Unstructured, error) { + panic("not implemented") +} + +func (c *createNamespaceKubeClient) MergePatch(ctx context.Context, meta *spec.ResourceMeta, patch []byte, opts kube.KubeClientMergePatchOptions) (*unstructured.Unstructured, error) { + panic("not implemented") +} + +func (c *createNamespaceKubeClient) Namespaced(ctx context.Context, gvk schema.GroupVersionKind) (bool, error) { + panic("not implemented") +} + +func (c *createNamespaceKubeClient) ResetAndRetryOnUnknownGVR(ctx context.Context, fn func() error) error { + panic("not implemented") +} + +func (c *createNamespaceKubeClient) ResetDiscoveryCache(ctx context.Context) error { + panic("not implemented") +} + +func (c *createNamespaceKubeClient) ServerVersion(ctx context.Context) (*version.Info, error) { + panic("not implemented") +} + +func TestAI_CreateReleaseNamespaceBothProbesForbiddenAggregates(t *testing.T) { + cmErr := newForbiddenErr("configmaps", "werf-synchronization") + nsErr := newForbiddenErr("namespaces", "my-namespace") + kubeClient := &createNamespaceKubeClient{cmApplyErr: cmErr, nsApplyErr: nsErr} + clientFactory := &createNamespaceClientFactory{kubeClient: kubeClient} + + err := createReleaseNamespace(context.Background(), clientFactory, "my-namespace") + require.Error(t, err) + require.ErrorIs(t, err, cmErr) + require.ErrorIs(t, err, nsErr) + assert.Contains(t, err.Error(), "can't apply ConfigMap for locking, and can't apply release namespace") + + require.Len(t, kubeClient.calls, 2) + assert.Equal(t, createNamespaceCall{dryRun: true, kind: "ConfigMap"}, kubeClient.calls[0]) + assert.Equal(t, createNamespaceCall{dryRun: true, kind: "Namespace"}, kubeClient.calls[1]) +} + +func TestAI_CreateReleaseNamespaceConfigMapForbiddenNamespaceNotFoundAggregates(t *testing.T) { + cmErr := newForbiddenErr("configmaps", "werf-synchronization") + nsErr := newNotFoundErr("namespaces", "my-namespace") + kubeClient := &createNamespaceKubeClient{cmApplyErr: cmErr, nsApplyErr: nsErr} + clientFactory := &createNamespaceClientFactory{kubeClient: kubeClient} + + err := createReleaseNamespace(context.Background(), clientFactory, "my-namespace") + require.Error(t, err) + require.ErrorIs(t, err, cmErr) + require.ErrorIs(t, err, nsErr) + + require.Len(t, kubeClient.calls, 2) +} + +func TestAI_CreateReleaseNamespaceConfigMapForbiddenThenNamespaceCreated(t *testing.T) { + kubeClient := &createNamespaceKubeClient{ + cmApplyErr: newForbiddenErr("configmaps", "werf-synchronization"), + } + clientFactory := &createNamespaceClientFactory{kubeClient: kubeClient} + + err := createReleaseNamespace(context.Background(), clientFactory, "my-namespace") + require.NoError(t, err) + + require.Len(t, kubeClient.calls, 3) + assert.Equal(t, createNamespaceCall{dryRun: true, kind: "ConfigMap"}, kubeClient.calls[0]) + assert.Equal(t, createNamespaceCall{dryRun: true, kind: "Namespace"}, kubeClient.calls[1]) + assert.Equal(t, createNamespaceCall{dryRun: false, kind: "Namespace"}, kubeClient.calls[2]) +} + +func TestAI_CreateReleaseNamespaceConfigMapNotFoundThenNamespaceCreated(t *testing.T) { + kubeClient := &createNamespaceKubeClient{ + cmApplyErr: newNotFoundErr("configmaps", "werf-synchronization"), + } + clientFactory := &createNamespaceClientFactory{kubeClient: kubeClient} + + err := createReleaseNamespace(context.Background(), clientFactory, "my-namespace") + require.NoError(t, err) + + require.Len(t, kubeClient.calls, 3) + assert.Equal(t, createNamespaceCall{dryRun: true, kind: "ConfigMap"}, kubeClient.calls[0]) + assert.Equal(t, createNamespaceCall{dryRun: true, kind: "Namespace"}, kubeClient.calls[1]) + assert.Equal(t, createNamespaceCall{dryRun: false, kind: "Namespace"}, kubeClient.calls[2]) +} + +func TestAI_CreateReleaseNamespaceConfigMapOtherErrorPropagates(t *testing.T) { + cmErr := errors.New("connection refused") + kubeClient := &createNamespaceKubeClient{cmApplyErr: cmErr} + clientFactory := &createNamespaceClientFactory{kubeClient: kubeClient} + + err := createReleaseNamespace(context.Background(), clientFactory, "my-namespace") + require.Error(t, err) + require.ErrorIs(t, err, cmErr) + + require.Len(t, kubeClient.calls, 1) + assert.Equal(t, "ConfigMap", kubeClient.calls[0].kind) +} + +func TestAI_CreateReleaseNamespaceConfigMapProbeSucceeds(t *testing.T) { + kubeClient := &createNamespaceKubeClient{} + clientFactory := &createNamespaceClientFactory{kubeClient: kubeClient} + + err := createReleaseNamespace(context.Background(), clientFactory, "my-namespace") + require.NoError(t, err) + + require.Len(t, kubeClient.calls, 1) + assert.Equal(t, "ConfigMap", kubeClient.calls[0].kind) + assert.True(t, kubeClient.calls[0].dryRun) +} + +func TestAI_CreateReleaseNamespaceNamespaceProbeOtherErrorPropagates(t *testing.T) { + nsErr := errors.New("connection refused") + kubeClient := &createNamespaceKubeClient{ + cmApplyErr: newForbiddenErr("configmaps", "werf-synchronization"), + nsApplyErr: nsErr, + } + clientFactory := &createNamespaceClientFactory{kubeClient: kubeClient} + + err := createReleaseNamespace(context.Background(), clientFactory, "my-namespace") + require.Error(t, err) + require.ErrorIs(t, err, nsErr) + assert.Contains(t, err.Error(), "dry-run apply release namespace") + + require.Len(t, kubeClient.calls, 2) +} + +func TestAI_CreateReleaseNamespaceRealCreateFailurePropagates(t *testing.T) { + createErr := errors.New("quota exceeded") + kubeClient := &createNamespaceKubeClient{ + cmApplyErr: newForbiddenErr("configmaps", "werf-synchronization"), + nsCreateErr: createErr, + } + clientFactory := &createNamespaceClientFactory{kubeClient: kubeClient} + + err := createReleaseNamespace(context.Background(), clientFactory, "my-namespace") + require.Error(t, err) + require.ErrorIs(t, err, createErr) + assert.Contains(t, err.Error(), "create release namespace") + + require.Len(t, kubeClient.calls, 3) + assert.Equal(t, createNamespaceCall{dryRun: false, kind: "Namespace"}, kubeClient.calls[2]) +} + +func newForbiddenErr(resource, name string) error { + return apierrors.NewForbidden(schema.GroupResource{Resource: resource}, name, errors.New("forbidden")) +} + +func newNotFoundErr(resource, name string) error { + return apierrors.NewNotFound(schema.GroupResource{Resource: resource}, name) +} diff --git a/pkg/chart/chart_render.go b/pkg/chart/chart_render.go index 3c5b6609..c917ee5e 100644 --- a/pkg/chart/chart_render.go +++ b/pkg/chart/chart_render.go @@ -14,7 +14,10 @@ import ( "github.com/goccy/go-yaml" "github.com/samber/lo" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/discovery" + "k8s.io/client-go/kubernetes/scheme" "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/featgate" @@ -56,6 +59,7 @@ type RenderChartOptions struct { HelmOptions common.HelmOptions IgnoreBundleJS bool LocalKubeVersion string + LocalLookupResourcesPaths []string NoStandaloneCRDs bool NoValuesSchemaValidation bool Remote bool @@ -233,7 +237,15 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s if opts.Remote && clientFactory.KubeClient() != nil { engine = lo.ToPtr(helmengine.New(clientFactory.KubeConfig().RestConfig)) } else { - engine = lo.ToPtr(helmengine.Engine{}) + engine = &helmengine.Engine{} + if len(opts.LocalLookupResourcesPaths) > 0 { + localLookupResources, err := parseLocalLookupResources(opts.LocalLookupResourcesPaths) + if err != nil { + return nil, fmt.Errorf("parse local lookup resources: %w", err) + } + + engine.SetClientProvider(newLocalClientProvider(localLookupResources)) + } } engine.EnableDNS = opts.TemplatesAllowDNS @@ -328,6 +340,61 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s }, nil } +func parseLocalLookupResources(paths []string) ([]*unstructured.Unstructured, error) { + var resources []*unstructured.Unstructured + + seen := make(map[string]bool) + + for _, filePath := range paths { + content, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("read file %q: %w", filePath, err) + } + + for i, manifest := range util.SplitManifestsKeepingEmpty(string(content)) { + if manifest == "" { + continue + } + + obj, _, err := scheme.Codecs.UniversalDecoder().Decode([]byte(manifest), nil, &unstructured.Unstructured{}) + if err != nil { + return nil, fmt.Errorf("decode resource #%d for %q: %w", i+1, filePath, err) + } + + unstruct := obj.(*unstructured.Unstructured) + + if unstruct.IsList() { + item := 0 + + if err := unstruct.EachListItem(func(o runtime.Object) error { + res, err := collectLocalLookupResource(o.(*unstructured.Unstructured), seen) + if err != nil { + return err + } + + item++ + resources = append(resources, res) + + return nil + }); err != nil { + return nil, fmt.Errorf("collect resource #%d for %q (item %d): %w", i+1, filePath, item+1, err) + } + + continue + } + + res, err := collectLocalLookupResource(unstruct, seen) + if err != nil { + return nil, fmt.Errorf("collect resource #%d for %q: %w", i+1, filePath, err) + } + + resources = append(resources, res) + } + } + + return resources, nil +} + func buildChartCapabilities(ctx context.Context, clientFactory kube.ClientFactorier, opts buildChartCapabilitiesOptions) (*chartcommon.Capabilities, error) { capabilities := &chartcommon.Capabilities{ HelmVersion: chartcommon.DefaultCapabilities.HelmVersion, @@ -422,6 +489,27 @@ func buildContextFromJSONSets(jsonSets []string) (map[string]interface{}, error) return context, nil } +func collectLocalLookupResource(unstruct *unstructured.Unstructured, seen map[string]bool) (*unstructured.Unstructured, error) { + if unstruct.GetAPIVersion() == "" { + return nil, fmt.Errorf("apiVersion is missing") + } + + if unstruct.GetName() == "" { + return nil, fmt.Errorf("name is missing") + } + + gvk := unstruct.GroupVersionKind() + id := spec.IDWithVersion(unstruct.GetName(), unstruct.GetNamespace(), gvk.Group, gvk.Version, gvk.Kind) + + if seen[id] { + return nil, fmt.Errorf("duplicate resource %s", spec.IDHuman(unstruct.GetName(), unstruct.GetNamespace(), gvk.Group, gvk.Kind)) + } + + seen[id] = true + + return unstruct, nil +} + func isLocalChart(path string) bool { return filepath.IsAbs(path) || filepath.HasPrefix(path, "..") || filepath.HasPrefix(path, ".") } diff --git a/pkg/chart/lookup.go b/pkg/chart/lookup.go new file mode 100644 index 00000000..fee26557 --- /dev/null +++ b/pkg/chart/lookup.go @@ -0,0 +1,102 @@ +package chart + +import ( + "context" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/dynamic/fake" + + "github.com/werf/nelm/pkg/helm/pkg/engine" +) + +var ( + _ engine.ClientProvider = (*LocalClientProvider)(nil) + _ dynamic.NamespaceableResourceInterface = (*emptyListResource)(nil) +) + +type LocalClientProvider struct { + client *fake.FakeDynamicClient + namespaced map[schema.GroupVersionKind]bool + registered map[schema.GroupVersionKind]bool +} + +// newLocalClientProvider returns an engine.ClientProvider that resolves lookup calls against the +// provided in-memory set of Kubernetes objects instead of a live cluster. An empty set makes +// every lookup return an empty result, matching the offline stub behavior. +func newLocalClientProvider(objects []*unstructured.Unstructured) *LocalClientProvider { + runtimeObjects := make([]runtime.Object, 0, len(objects)) + + registered := make(map[schema.GroupVersionKind]bool) + + namespaced := make(map[schema.GroupVersionKind]bool) + for _, obj := range objects { + runtimeObjects = append(runtimeObjects, obj) + if obj.GetNamespace() != "" { + namespaced[obj.GroupVersionKind()] = true + } + + registered[obj.GroupVersionKind()] = true + } + + return &LocalClientProvider{ + client: fake.NewSimpleDynamicClient(runtime.NewScheme(), runtimeObjects...), + namespaced: namespaced, + registered: registered, + } +} + +func (p *LocalClientProvider) GetClientFor(apiVersion, kind string) (dynamic.NamespaceableResourceInterface, bool, error) { + gvk := schema.FromAPIVersionAndKind(apiVersion, kind) + gvr, _ := meta.UnsafeGuessKindToResource(gvk) + + if !p.registered[gvk] { + return &emptyListResource{ + NamespaceableResourceInterface: p.client.Resource(gvr), + listGVK: gvk.GroupVersion().WithKind(gvk.Kind + "List"), + }, p.namespaced[gvk], nil + } + + return p.client.Resource(gvr), p.namespaced[gvk], nil +} + +// emptyListResource wraps a fake dynamic resource interface for a kind with no registered objects, +// so that List returns an empty list (the fake client would otherwise panic for an unregistered +// list kind), matching the offline stub semantics where an absent kind yields an empty result. +type emptyListResource struct { + dynamic.NamespaceableResourceInterface + + listGVK schema.GroupVersionKind +} + +func (r *emptyListResource) List(_ context.Context, _ metav1.ListOptions) (*unstructured.UnstructuredList, error) { + return listEmpty(r.listGVK) +} + +func (r *emptyListResource) Namespace(namespace string) dynamic.ResourceInterface { + return &emptyListNamespacedResource{ + ResourceInterface: r.NamespaceableResourceInterface.Namespace(namespace), + listGVK: r.listGVK, + } +} + +type emptyListNamespacedResource struct { + dynamic.ResourceInterface + + listGVK schema.GroupVersionKind +} + +func (r *emptyListNamespacedResource) List(_ context.Context, _ metav1.ListOptions) (*unstructured.UnstructuredList, error) { + return listEmpty(r.listGVK) +} + +func listEmpty(listGVK schema.GroupVersionKind) (*unstructured.UnstructuredList, error) { + list := &unstructured.UnstructuredList{} + list.SetGroupVersionKind(listGVK) + + return list, nil +} diff --git a/pkg/chart/lookup_ai_test.go b/pkg/chart/lookup_ai_test.go new file mode 100644 index 00000000..c4251ec0 --- /dev/null +++ b/pkg/chart/lookup_ai_test.go @@ -0,0 +1,177 @@ +//go:build ai_tests + +package chart + +import ( + "path" + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + helmchart "github.com/werf/nelm/pkg/helm/pkg/chart" + "github.com/werf/nelm/pkg/helm/pkg/chartutil" + "github.com/werf/nelm/pkg/helm/pkg/engine" + "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" +) + +func TestAI_LocalClientProviderEmpty(t *testing.T) { + provider := newLocalClientProvider(nil) + + c := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "moby", Version: "1.2.3"}, + Templates: []*helmchart.File{ + {Name: "templates/empty", Data: []byte(`{{ (lookup "v1" "Pod" "default" "pod1") }}`)}, + }, + Values: map[string]any{}, + } + + vals, err := chartutil.CoalesceValues(c, map[string]any{"Values": map[string]any{}}) + require.NoError(t, err) + + out, err := engine.RenderWithClientProvider(c, vals, provider, helmopts.HelmOptions{}) + require.NoError(t, err) + require.Equal(t, "map[]", out["moby/templates/empty"]) +} + +func TestAI_LocalClientProviderLookup(t *testing.T) { + provider := newLocalClientProvider([]*unstructured.Unstructured{ + makeUnstructured("v1", "Namespace", "default", ""), + makeUnstructured("v1", "Pod", "pod1", "default"), + makeUnstructured("v1", "Pod", "pod2", "ns1"), + makeUnstructured("v1", "Pod", "pod3", "ns1"), + }) + + templates := map[string]string{ + "cluster-single": `{{ (lookup "v1" "Namespace" "" "default").metadata.name }}`, + "namespaced-get": `{{ (lookup "v1" "Pod" "default" "pod1").metadata.name }}`, + "namespaced-list": `{{ (lookup "v1" "Pod" "ns1" "").items | len }}`, + "all-ns-list": `{{ (lookup "v1" "Pod" "" "").items | len }}`, + "missing-get": `{{ (lookup "v1" "Pod" "" "absent") }}`, + } + expected := map[string]string{ + "cluster-single": "default", + "namespaced-get": "pod1", + "namespaced-list": "2", + "all-ns-list": "3", + "missing-get": "map[]", + } + + c := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "moby", Version: "1.2.3"}, + Values: map[string]any{}, + } + + for name, tpl := range templates { + c.Templates = append(c.Templates, &helmchart.File{ + Name: path.Join("templates", name), + Data: []byte(tpl), + }) + } + + vals, err := chartutil.CoalesceValues(c, map[string]any{"Values": map[string]any{}}) + require.NoError(t, err) + + out, err := engine.RenderWithClientProvider(c, vals, provider, helmopts.HelmOptions{}) + require.NoError(t, err) + + for name, want := range expected { + t.Run(name, func(t *testing.T) { + require.Equal(t, want, out[path.Join("moby/templates", name)]) + }) + } +} + +func TestAI_LocalClientProviderNamespaceIsolation(t *testing.T) { + provider := newLocalClientProvider([]*unstructured.Unstructured{ + makeUnstructured("v1", "Pod", "pod1", "default"), + }) + + templates := map[string]string{ + "same-ns-get": `{{ (lookup "v1" "Pod" "default" "pod1").metadata.name }}`, + "other-ns-get": `{{ (lookup "v1" "Pod" "other" "pod1") }}`, + } + expected := map[string]string{ + "same-ns-get": "pod1", + "other-ns-get": "map[]", + } + + c := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "moby", Version: "1.2.3"}, + Values: map[string]any{}, + } + + for name, tpl := range templates { + c.Templates = append(c.Templates, &helmchart.File{ + Name: path.Join("templates", name), + Data: []byte(tpl), + }) + } + + vals, err := chartutil.CoalesceValues(c, map[string]any{"Values": map[string]any{}}) + require.NoError(t, err) + + out, err := engine.RenderWithClientProvider(c, vals, provider, helmopts.HelmOptions{}) + require.NoError(t, err) + + for name, want := range expected { + t.Run(name, func(t *testing.T) { + require.Equal(t, want, out[path.Join("moby/templates", name)]) + }) + } +} + +func TestAI_LocalClientProviderUnstubbedListEmptyProvider(t *testing.T) { + provider := newLocalClientProvider(nil) + + c := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "moby", Version: "1.2.3"}, + Templates: []*helmchart.File{ + {Name: "templates/list", Data: []byte(`{{ (lookup "v1" "Pod" "" "").items | len }}`)}, + }, + Values: map[string]any{}, + } + + vals, err := chartutil.CoalesceValues(c, map[string]any{"Values": map[string]any{}}) + require.NoError(t, err) + + out, err := engine.RenderWithClientProvider(c, vals, provider, helmopts.HelmOptions{}) + require.NoError(t, err) + require.Equal(t, "0", out["moby/templates/list"]) +} + +func TestAI_LocalClientProviderUnstubbedListOtherKind(t *testing.T) { + provider := newLocalClientProvider([]*unstructured.Unstructured{ + makeUnstructured("v1", "Pod", "pod1", "default"), + }) + + c := &helmchart.Chart{ + Metadata: &helmchart.Metadata{Name: "moby", Version: "1.2.3"}, + Templates: []*helmchart.File{ + {Name: "templates/list", Data: []byte(`{{ (lookup "v1" "ConfigMap" "" "").items | len }}`)}, + }, + Values: map[string]any{}, + } + + vals, err := chartutil.CoalesceValues(c, map[string]any{"Values": map[string]any{}}) + require.NoError(t, err) + + out, err := engine.RenderWithClientProvider(c, vals, provider, helmopts.HelmOptions{}) + require.NoError(t, err) + require.Equal(t, "0", out["moby/templates/list"]) +} + +func makeUnstructured(apiVersion, kind, name, namespace string) *unstructured.Unstructured { + obj := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": apiVersion, + "kind": kind, + "metadata": map[string]interface{}{ + "name": name, + }, + }} + if namespace != "" { + obj.Object["metadata"].(map[string]interface{})["namespace"] = namespace + } + + return obj +} diff --git a/pkg/chart/parse_local_lookup_resources_ai_test.go b/pkg/chart/parse_local_lookup_resources_ai_test.go new file mode 100644 index 00000000..d8b75146 --- /dev/null +++ b/pkg/chart/parse_local_lookup_resources_ai_test.go @@ -0,0 +1,163 @@ +//go:build ai_tests + +package chart + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAI_ParseLocalLookupResourcesDuplicateAfterExpansion(t *testing.T) { + path := writeLocalLookupFile(t, ` +apiVersion: v1 +kind: Pod +metadata: + name: pod1 + namespace: default +--- +apiVersion: v1 +kind: List +items: + - apiVersion: v1 + kind: Pod + metadata: + name: pod1 + namespace: default +`) + + _, err := parseLocalLookupResources([]string{path}) + require.Error(t, err) + require.Contains(t, err.Error(), "duplicate resource") + require.Contains(t, err.Error(), "item 1") +} + +func TestAI_ParseLocalLookupResourcesListExpansion(t *testing.T) { + path := writeLocalLookupFile(t, ` +apiVersion: v1 +kind: List +items: + - apiVersion: v1 + kind: Pod + metadata: + name: pod1 + namespace: default + - apiVersion: v1 + kind: Pod + metadata: + name: pod2 + namespace: default +`) + + resources, err := parseLocalLookupResources([]string{path}) + require.NoError(t, err) + require.Len(t, resources, 2) + require.Equal(t, "pod1", resources[0].GetName()) + require.Equal(t, "pod2", resources[1].GetName()) + for _, r := range resources { + require.Equal(t, "Pod", r.GetKind()) + } +} + +func TestAI_ParseLocalLookupResourcesListItemMissingAPIVersion(t *testing.T) { + path := writeLocalLookupFile(t, ` +apiVersion: v1 +kind: List +items: + - kind: Pod + metadata: + name: pod1 + namespace: default +`) + + _, err := parseLocalLookupResources([]string{path}) + require.Error(t, err) + require.Contains(t, err.Error(), "apiVersion is missing") + require.Contains(t, err.Error(), "item 1") +} + +func TestAI_ParseLocalLookupResourcesMultiDoc(t *testing.T) { + path := writeLocalLookupFile(t, ` +apiVersion: v1 +kind: Pod +metadata: + name: pod1 + namespace: default +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 + namespace: default +`) + + resources, err := parseLocalLookupResources([]string{path}) + require.NoError(t, err) + require.Len(t, resources, 2) + require.Equal(t, "Pod", resources[0].GetKind()) + require.Equal(t, "ConfigMap", resources[1].GetKind()) +} + +func TestAI_ParseLocalLookupResourcesTopLevelDuplicate(t *testing.T) { + path := writeLocalLookupFile(t, ` +apiVersion: v1 +kind: Pod +metadata: + name: pod1 + namespace: default +--- +apiVersion: v1 +kind: Pod +metadata: + name: pod1 + namespace: default +`) + + _, err := parseLocalLookupResources([]string{path}) + require.Error(t, err) + require.Contains(t, err.Error(), "duplicate resource") +} + +func TestAI_ParseLocalLookupResourcesTopLevelMissingAPIVersion(t *testing.T) { + path := writeLocalLookupFile(t, ` +kind: Pod +metadata: + name: pod1 + namespace: default +`) + + _, err := parseLocalLookupResources([]string{path}) + require.Error(t, err) + require.Contains(t, err.Error(), "apiVersion is missing") +} + +func TestAI_ParseLocalLookupResourcesTypedListExpansion(t *testing.T) { + path := writeLocalLookupFile(t, ` +apiVersion: v1 +kind: PodList +items: + - apiVersion: v1 + kind: Pod + metadata: + name: pod1 + namespace: default +`) + + resources, err := parseLocalLookupResources([]string{path}) + require.NoError(t, err) + require.Len(t, resources, 1) + require.Equal(t, "Pod", resources[0].GetKind()) + require.Equal(t, "pod1", resources[0].GetName()) +} + +func writeLocalLookupFile(t *testing.T, content string) string { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, "resources.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + return path +} diff --git a/pkg/common/common.go b/pkg/common/common.go index c2eac4be..65e78dd2 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -127,6 +127,7 @@ const ( DefaultResourceValidationKubeVersion = "1.35.0" DefaultWebhookRetryTimeout = 4 * time.Minute KubectlEditFieldManager = "kubectl-edit" + LockConfigMapName = "werf-synchronization" OldFieldManagerPrefix = "werf" OutputFormatJSON = "json" OutputFormatTable = "table" diff --git a/pkg/helm/pkg/engine/engine.go b/pkg/helm/pkg/engine/engine.go index 4261ca6a..26501623 100644 --- a/pkg/helm/pkg/engine/engine.go +++ b/pkg/helm/pkg/engine/engine.go @@ -70,6 +70,12 @@ func New(config *rest.Config) Engine { } } +// SetClientProvider sets the ClientProvider used to resolve lookup calls. It allows resolving +// lookups against a custom source, such as an in-memory set of objects in offline mode. +func (e *Engine) SetClientProvider(clientProvider ClientProvider) { + e.clientProvider = &clientProvider +} + // Render takes a chart, optional values, and value overrides, and attempts to render the Go templates. // // Render can be called repeatedly on the same engine. diff --git a/pkg/lock/lock_manager.go b/pkg/lock/lock_manager.go index 9ef508af..870e7bc3 100644 --- a/pkg/lock/lock_manager.go +++ b/pkg/lock/lock_manager.go @@ -10,6 +10,7 @@ import ( "github.com/werf/common-go/pkg/locker_with_retry" "github.com/werf/lockgate" "github.com/werf/lockgate/pkg/distributed_locker" + "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/resource/spec" @@ -22,16 +23,14 @@ type LockManager struct { } func NewLockManager(ctx context.Context, namespace string, createNamespace bool, clientFactory kube.ClientFactorier) (*LockManager, error) { - configMapName := "werf-synchronization" - locker := distributed_locker.NewKubernetesLocker( clientFactory.Dynamic(), schema.GroupVersionResource{ Group: "", Version: "v1", Resource: "configmaps", - }, configMapName, namespace, + }, common.LockConfigMapName, namespace, ) - cmLocker := NewConfigMapLocker(configMapName, namespace, namespace, locker, clientFactory, ConfigMapLockerOptions{CreateNamespace: createNamespace}) + cmLocker := NewConfigMapLocker(common.LockConfigMapName, namespace, namespace, locker, clientFactory, ConfigMapLockerOptions{CreateNamespace: createNamespace}) lockerWithRetry := locker_with_retry.NewLockerWithRetry(ctx, cmLocker, locker_with_retry.LockerWithRetryOptions{ MaxAcquireAttempts: 10, MaxReleaseAttempts: 10, diff --git a/pkg/util/manifest.go b/pkg/util/manifest.go index 748f3682..633c6453 100644 --- a/pkg/util/manifest.go +++ b/pkg/util/manifest.go @@ -44,3 +44,29 @@ func SplitManifests(bigFile string) []string { return result } + +func SplitManifestsKeepingEmpty(bigFile string) []string { + const sep = "\n---" + + var result []string + for _, d := range strings.SplitAfter(bigFile, sep) { + d = strings.TrimSuffix(d, sep) + + hasContent := false + for _, line := range strings.Split(d, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed != "" && trimmed != "---" && !strings.HasPrefix(trimmed, "#") { + hasContent = true + break + } + } + + if hasContent { + result = append(result, strings.TrimSpace(d)+"\n") + } else { + result = append(result, "") + } + } + + return result +} From f6ce79b3fd4cf716cf06d7de16f3073acf603182 Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:07:50 +0300 Subject: [PATCH 53/85] feat: embed kubeconform validation schemas into the binary (#673) Signed-off-by: Ilya Drey --- .../_check_validation_schemas_upstream.yml | 43 ++ .github/workflows/test_daily.yml | 4 + Taskfile.dist.yaml | 26 + cmd/nelm/common_flags.go | 19 +- docs/reference.md | 48 +- pkg/common/common.go | 58 +- pkg/common/options.go | 10 +- pkg/resource/helpers_ai_test.go | 28 +- pkg/resource/kubeconform.go | 339 +++++---- pkg/resource/kubeconform_ai_test.go | 55 +- pkg/resource/kubeconform_cache_ai_test.go | 388 ++++++++++ pkg/resource/kubeconform_embedded_ai_test.go | 354 +++++++++ pkg/resource/resource.go | 1 + pkg/resource/schemas/data/README.md | 61 ++ pkg/resource/schemas/data/crds.tar.gz | Bin 0 -> 2082359 bytes pkg/resource/schemas/data/index.json | 32 + pkg/resource/schemas/data/kubernetes.tar.gz | Bin 0 -> 179246 bytes pkg/resource/schemas/embed.go | 231 ++++++ pkg/resource/schemas/extract.go | 343 +++++++++ pkg/resource/schemas/extract_ai_test.go | 280 +++++++ pkg/resource/schemas/schemas_ai_test.go | 291 +++++++ pkg/resource/validate.go | 22 +- pkg/resource/validate_ai_test.go | 56 +- scripts/schemagen/download.go | 453 +++++++++++ scripts/schemagen/main.go | 720 ++++++++++++++++++ scripts/schemagen/size_ai_test.go | 87 +++ scripts/schemagen/strip.go | 101 +++ scripts/schemagen/strip_ai_test.go | 118 +++ 28 files changed, 3887 insertions(+), 281 deletions(-) create mode 100644 .github/workflows/_check_validation_schemas_upstream.yml create mode 100644 pkg/resource/kubeconform_cache_ai_test.go create mode 100644 pkg/resource/kubeconform_embedded_ai_test.go create mode 100644 pkg/resource/schemas/data/README.md create mode 100644 pkg/resource/schemas/data/crds.tar.gz create mode 100644 pkg/resource/schemas/data/index.json create mode 100644 pkg/resource/schemas/data/kubernetes.tar.gz create mode 100644 pkg/resource/schemas/embed.go create mode 100644 pkg/resource/schemas/extract.go create mode 100644 pkg/resource/schemas/extract_ai_test.go create mode 100644 pkg/resource/schemas/schemas_ai_test.go create mode 100644 scripts/schemagen/download.go create mode 100644 scripts/schemagen/main.go create mode 100644 scripts/schemagen/size_ai_test.go create mode 100644 scripts/schemagen/strip.go create mode 100644 scripts/schemagen/strip_ai_test.go diff --git a/.github/workflows/_check_validation_schemas_upstream.yml b/.github/workflows/_check_validation_schemas_upstream.yml new file mode 100644 index 00000000..5ab6ec23 --- /dev/null +++ b/.github/workflows/_check_validation_schemas_upstream.yml @@ -0,0 +1,43 @@ +name: xxxxx(internal) + +on: + workflow_call: + inputs: + forceSkip: + default: false + type: string + +defaults: + run: + shell: bash + +env: + DEBIAN_FRONTEND: "noninteractive" + +jobs: + _: + if: inputs.forceSkip == 'false' + runs-on: ubuntu-22.04 + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + cache: false + go-version-file: go.mod + + - name: Install Task + uses: go-task/setup-task@v2 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + # Fails when upstream changed schemas the committed archives were generated from, which is the + # only signal that "task generate:validation-schemas:force" is due: the generator itself never + # goes looking, so that builds stay offline and reproducible. + - name: Check embedded validation schemas against upstream + run: task -p check:validation-schemas-upstream + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test_daily.yml b/.github/workflows/test_daily.yml index 2e599b87..ff0bdc0a 100644 --- a/.github/workflows/test_daily.yml +++ b/.github/workflows/test_daily.yml @@ -11,6 +11,9 @@ jobs: lint: uses: ./.github/workflows/_lint.yml + check_validation_schemas_upstream: + uses: ./.github/workflows/_check_validation_schemas_upstream.yml + unit: uses: ./.github/workflows/_test_unit.yml with: @@ -26,6 +29,7 @@ jobs: if: always() needs: - lint + - check_validation_schemas_upstream - coverage_report uses: werf/common-ci/.github/workflows/notification.yml@main secrets: diff --git a/Taskfile.dist.yaml b/Taskfile.dist.yaml index 2c61493b..fa1d2a8d 100644 --- a/Taskfile.dist.yaml +++ b/Taskfile.dist.yaml @@ -14,6 +14,8 @@ vars: arch: '{{ternary "arm64" ARCH (eq ARCH "arm")}}' package: "github.com/werf/nelm/cmd/nelm" + kubeconformValidationSchemasUpstreamKubeVersion: "1.36.3" + tags: "" ldFlags: "-s -w -X github.com/werf/nelm/pkg/common.Brand={{.brand}} -X github.com/werf/nelm/pkg/common.Version={{.version}}" @@ -282,9 +284,21 @@ tasks: desc: 'Run all generators. Important vars: "paths".' run: once cmds: + - task: generate:validation-schemas - task: generate:reference - task: generate:doctoc + generate:validation-schemas: + desc: 'Download and pack the JSON schemas embedded into the binary. Fetches only what the committed archives do not cover; use the force variant to pull upstream changes. Commit the changed archives. Important vars: "kubeconformValidationSchemasUpstreamKubeVersion".' + run: once + cmds: + - go run ./scripts/schemagen -kube-version {{.kubeconformValidationSchemasUpstreamKubeVersion}} {{.CLI_ARGS}} + + generate:validation-schemas:force: + desc: 'Rebuild every embedded JSON schema archive from scratch, even the up to date ones. Commit the changed archives. Important vars: "kubeconformValidationSchemasUpstreamKubeVersion".' + cmds: + - go run ./scripts/schemagen -kube-version {{.kubeconformValidationSchemasUpstreamKubeVersion}} -force {{.CLI_ARGS}} + generate:reference: desc: "Generate CLI reference documentation with TOC." cmds: @@ -307,6 +321,18 @@ tasks: - lint:wormatter - lint:prettier - lint:golangci-lint + - lint:validation-schemas + + lint:validation-schemas: + desc: 'Check that the committed embedded JSON schema archives match their index and the pinned Kubernetes version. Runs offline. Important vars: "kubeconformValidationSchemasUpstreamKubeVersion".' + cmds: + - go run ./scripts/schemagen -kube-version {{.kubeconformValidationSchemasUpstreamKubeVersion}} -verify {{.CLI_ARGS}} + + check:validation-schemas-upstream: + desc: "Report whether upstream has schemas the committed archives do not. Needs network, so it is not part of lint: run it on a schedule." + cmds: + # Takes the repositories, refs and versions to compare from the index, so it needs no vars. + - go run ./scripts/schemagen -check-upstream {{.CLI_ARGS}} lint:golangci-lint: desc: 'Lint with golangci-lint. Important vars: "paths".' diff --git a/cmd/nelm/common_flags.go b/cmd/nelm/common_flags.go index 3e4b66d9..d4020616 100644 --- a/cmd/nelm/common_flags.go +++ b/cmd/nelm/common_flags.go @@ -351,14 +351,7 @@ func AddResourceValidationFlags(cmd *cobra.Command, cfg *common.ResourceValidati return fmt.Errorf("add flag: %w", err) } - if err := cli.AddFlag(cmd, &cfg.LocalResourceValidation, "local-resource-validation", false, "Do not use external json schema sources", cli.AddFlagOptions{ - GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, - Group: resourceValidationGroup, - }); err != nil { - return fmt.Errorf("add flag: %w", err) - } - - if err := cli.AddFlag(cmd, &cfg.ValidationKubeVersion, "resource-validation-kube-version", common.DefaultResourceValidationKubeVersion, "Kubernetes schemas version to use during resource validation", cli.AddFlagOptions{ + if err := cli.AddFlag(cmd, &cfg.LocalResourceValidation, "local-resource-validation", false, "Do not use external json schema sources, validate against the json schemas embedded into the binary instead", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: resourceValidationGroup, }); err != nil { @@ -373,15 +366,7 @@ func AddResourceValidationFlags(cmd *cobra.Command, cfg *common.ResourceValidati return fmt.Errorf("add flag: %w", err) } - if err := cli.AddFlag(cmd, &cfg.ValidationSchemas, "resource-validation-schema", common.DefaultResourceValidationSchema, "Default json schema sources to validate resources. Must be a valid go template defining a http(s) URL, or an absolute path on local file system", cli.AddFlagOptions{ - GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalMultiEnvVarRegexes, - Group: resourceValidationGroup, - NoSplitOnCommas: true, - }); err != nil { - return fmt.Errorf("add flag: %w", err) - } - - if err := cli.AddFlag(cmd, &cfg.ValidationExtraSchemas, "resource-validation-extra-schema", []string{}, "Extra json schema sources to validate resources (preferred over default sources). Must be a valid go template defining a http(s) URL, or an absolute path on local file system", cli.AddFlagOptions{ + if err := cli.AddFlag(cmd, &cfg.ValidationExtraSchemas, "resource-validation-extra-schema", []string{}, "Extra json schema sources to validate resources, preferred over the json schemas embedded into the binary. Which source served a resource kind is cached for --resource-validation-cache-lifetime, so a schema that only appears in these sources later takes over once that entry expires, whereas changing this option resets the cache at once. Must be a valid go template defining a http(s) URL, or an absolute path on local file system. Example: https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/{{ .NormalizedKubernetesVersion }}-standalone{{ .StrictSuffix }}/{{ .ResourceKind }}{{ .KindSuffix }}.json", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalMultiEnvVarRegexes, Group: resourceValidationGroup, NoSplitOnCommas: true, diff --git a/docs/reference.md b/docs/reference.md index 223ece8a..e871b25c 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -266,7 +266,7 @@ nelm release install [options...] -n namespace -r release [chart-dir|chart-repo- - `--local-resource-validation` (default: `false`) - Do not use external json schema sources\. Vars: \$NELM\_LOCAL\_RESOURCE\_VALIDATION, \$NELM\_RELEASE\_INSTALL\_LOCAL\_RESOURCE\_VALIDATION + Do not use external json schema sources, validate against the json schemas embedded into the binary instead\. Vars: \$NELM\_LOCAL\_RESOURCE\_VALIDATION, \$NELM\_RELEASE\_INSTALL\_LOCAL\_RESOURCE\_VALIDATION - `--no-resource-validation` (default: `false`) @@ -282,15 +282,7 @@ nelm release install [options...] -n namespace -r release [chart-dir|chart-repo- - `--resource-validation-extra-schema` (default: `[]`) - Extra json schema sources to validate resources \(preferred over default sources\)\. Must be a valid go template defining a http\(s\) URL, or an absolute path on local file system\. Vars: \$NELM\_RESOURCE\_VALIDATION\_EXTRA\_SCHEMA\_\*, \$NELM\_RELEASE\_INSTALL\_RESOURCE\_VALIDATION\_EXTRA\_SCHEMA\_\* - -- `--resource-validation-kube-version` (default: `"1.35.0"`) - - Kubernetes schemas version to use during resource validation\. Vars: \$NELM\_RESOURCE\_VALIDATION\_KUBE\_VERSION, \$NELM\_RELEASE\_INSTALL\_RESOURCE\_VALIDATION\_KUBE\_VERSION - -- `--resource-validation-schema` (default: `[https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/{{ .NormalizedKubernetesVersion }}-standalone{{ .StrictSuffix }}/{{ .ResourceKind }}{{ .KindSuffix }}.json,https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json]`) - - Default json schema sources to validate resources\. Must be a valid go template defining a http\(s\) URL, or an absolute path on local file system\. Vars: \$NELM\_RESOURCE\_VALIDATION\_SCHEMA\_\*, \$NELM\_RELEASE\_INSTALL\_RESOURCE\_VALIDATION\_SCHEMA\_\* + Extra json schema sources to validate resources, preferred over the json schemas embedded into the binary\. Which source served a resource kind is cached for \-\-resource\-validation\-cache\-lifetime, so a schema that only appears in these sources later takes over once that entry expires, whereas changing this option resets the cache at once\. Must be a valid go template defining a http\(s\) URL, or an absolute path on local file system\. Example: https://raw\.githubusercontent\.com/yannh/kubernetes\-json\-schema/master/\{\{ \.NormalizedKubernetesVersion \}\}\-standalone\{\{ \.StrictSuffix \}\}/\{\{ \.ResourceKind \}\}\{\{ \.KindSuffix \}\}\.json\. Vars: \$NELM\_RESOURCE\_VALIDATION\_EXTRA\_SCHEMA\_\*, \$NELM\_RELEASE\_INSTALL\_RESOURCE\_VALIDATION\_EXTRA\_SCHEMA\_\* - `--resource-validation-skip` (default: `[]`) @@ -631,7 +623,7 @@ nelm release rollback [options...] -n namespace -r release [revision] - `--local-resource-validation` (default: `false`) - Do not use external json schema sources\. Vars: \$NELM\_LOCAL\_RESOURCE\_VALIDATION, \$NELM\_RELEASE\_ROLLBACK\_LOCAL\_RESOURCE\_VALIDATION + Do not use external json schema sources, validate against the json schemas embedded into the binary instead\. Vars: \$NELM\_LOCAL\_RESOURCE\_VALIDATION, \$NELM\_RELEASE\_ROLLBACK\_LOCAL\_RESOURCE\_VALIDATION - `--no-resource-validation` (default: `false`) @@ -647,15 +639,7 @@ nelm release rollback [options...] -n namespace -r release [revision] - `--resource-validation-extra-schema` (default: `[]`) - Extra json schema sources to validate resources \(preferred over default sources\)\. Must be a valid go template defining a http\(s\) URL, or an absolute path on local file system\. Vars: \$NELM\_RESOURCE\_VALIDATION\_EXTRA\_SCHEMA\_\*, \$NELM\_RELEASE\_ROLLBACK\_RESOURCE\_VALIDATION\_EXTRA\_SCHEMA\_\* - -- `--resource-validation-kube-version` (default: `"1.35.0"`) - - Kubernetes schemas version to use during resource validation\. Vars: \$NELM\_RESOURCE\_VALIDATION\_KUBE\_VERSION, \$NELM\_RELEASE\_ROLLBACK\_RESOURCE\_VALIDATION\_KUBE\_VERSION - -- `--resource-validation-schema` (default: `[https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/{{ .NormalizedKubernetesVersion }}-standalone{{ .StrictSuffix }}/{{ .ResourceKind }}{{ .KindSuffix }}.json,https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json]`) - - Default json schema sources to validate resources\. Must be a valid go template defining a http\(s\) URL, or an absolute path on local file system\. Vars: \$NELM\_RESOURCE\_VALIDATION\_SCHEMA\_\*, \$NELM\_RELEASE\_ROLLBACK\_RESOURCE\_VALIDATION\_SCHEMA\_\* + Extra json schema sources to validate resources, preferred over the json schemas embedded into the binary\. Which source served a resource kind is cached for \-\-resource\-validation\-cache\-lifetime, so a schema that only appears in these sources later takes over once that entry expires, whereas changing this option resets the cache at once\. Must be a valid go template defining a http\(s\) URL, or an absolute path on local file system\. Example: https://raw\.githubusercontent\.com/yannh/kubernetes\-json\-schema/master/\{\{ \.NormalizedKubernetesVersion \}\}\-standalone\{\{ \.StrictSuffix \}\}/\{\{ \.ResourceKind \}\}\{\{ \.KindSuffix \}\}\.json\. Vars: \$NELM\_RESOURCE\_VALIDATION\_EXTRA\_SCHEMA\_\*, \$NELM\_RELEASE\_ROLLBACK\_RESOURCE\_VALIDATION\_EXTRA\_SCHEMA\_\* - `--resource-validation-skip` (default: `[]`) @@ -1008,7 +992,7 @@ nelm release plan install [options...] -n namespace -r release [chart-dir|chart- - `--local-resource-validation` (default: `false`) - Do not use external json schema sources\. Vars: \$NELM\_LOCAL\_RESOURCE\_VALIDATION, \$NELM\_RELEASE\_PLAN\_INSTALL\_LOCAL\_RESOURCE\_VALIDATION + Do not use external json schema sources, validate against the json schemas embedded into the binary instead\. Vars: \$NELM\_LOCAL\_RESOURCE\_VALIDATION, \$NELM\_RELEASE\_PLAN\_INSTALL\_LOCAL\_RESOURCE\_VALIDATION - `--no-resource-validation` (default: `false`) @@ -1024,15 +1008,7 @@ nelm release plan install [options...] -n namespace -r release [chart-dir|chart- - `--resource-validation-extra-schema` (default: `[]`) - Extra json schema sources to validate resources \(preferred over default sources\)\. Must be a valid go template defining a http\(s\) URL, or an absolute path on local file system\. Vars: \$NELM\_RESOURCE\_VALIDATION\_EXTRA\_SCHEMA\_\*, \$NELM\_RELEASE\_PLAN\_INSTALL\_RESOURCE\_VALIDATION\_EXTRA\_SCHEMA\_\* - -- `--resource-validation-kube-version` (default: `"1.35.0"`) - - Kubernetes schemas version to use during resource validation\. Vars: \$NELM\_RESOURCE\_VALIDATION\_KUBE\_VERSION, \$NELM\_RELEASE\_PLAN\_INSTALL\_RESOURCE\_VALIDATION\_KUBE\_VERSION - -- `--resource-validation-schema` (default: `[https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/{{ .NormalizedKubernetesVersion }}-standalone{{ .StrictSuffix }}/{{ .ResourceKind }}{{ .KindSuffix }}.json,https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json]`) - - Default json schema sources to validate resources\. Must be a valid go template defining a http\(s\) URL, or an absolute path on local file system\. Vars: \$NELM\_RESOURCE\_VALIDATION\_SCHEMA\_\*, \$NELM\_RELEASE\_PLAN\_INSTALL\_RESOURCE\_VALIDATION\_SCHEMA\_\* + Extra json schema sources to validate resources, preferred over the json schemas embedded into the binary\. Which source served a resource kind is cached for \-\-resource\-validation\-cache\-lifetime, so a schema that only appears in these sources later takes over once that entry expires, whereas changing this option resets the cache at once\. Must be a valid go template defining a http\(s\) URL, or an absolute path on local file system\. Example: https://raw\.githubusercontent\.com/yannh/kubernetes\-json\-schema/master/\{\{ \.NormalizedKubernetesVersion \}\}\-standalone\{\{ \.StrictSuffix \}\}/\{\{ \.ResourceKind \}\}\{\{ \.KindSuffix \}\}\.json\. Vars: \$NELM\_RESOURCE\_VALIDATION\_EXTRA\_SCHEMA\_\*, \$NELM\_RELEASE\_PLAN\_INSTALL\_RESOURCE\_VALIDATION\_EXTRA\_SCHEMA\_\* - `--resource-validation-skip` (default: `[]`) @@ -2185,7 +2161,7 @@ nelm chart lint [options...] [chart-dir|chart-repo-name/chart-name|chart-archive - `--local-resource-validation` (default: `false`) - Do not use external json schema sources\. Vars: \$NELM\_LOCAL\_RESOURCE\_VALIDATION, \$NELM\_CHART\_LINT\_LOCAL\_RESOURCE\_VALIDATION + Do not use external json schema sources, validate against the json schemas embedded into the binary instead\. Vars: \$NELM\_LOCAL\_RESOURCE\_VALIDATION, \$NELM\_CHART\_LINT\_LOCAL\_RESOURCE\_VALIDATION - `--no-resource-validation` (default: `false`) @@ -2201,15 +2177,7 @@ nelm chart lint [options...] [chart-dir|chart-repo-name/chart-name|chart-archive - `--resource-validation-extra-schema` (default: `[]`) - Extra json schema sources to validate resources \(preferred over default sources\)\. Must be a valid go template defining a http\(s\) URL, or an absolute path on local file system\. Vars: \$NELM\_RESOURCE\_VALIDATION\_EXTRA\_SCHEMA\_\*, \$NELM\_CHART\_LINT\_RESOURCE\_VALIDATION\_EXTRA\_SCHEMA\_\* - -- `--resource-validation-kube-version` (default: `"1.35.0"`) - - Kubernetes schemas version to use during resource validation\. Vars: \$NELM\_RESOURCE\_VALIDATION\_KUBE\_VERSION, \$NELM\_CHART\_LINT\_RESOURCE\_VALIDATION\_KUBE\_VERSION - -- `--resource-validation-schema` (default: `[https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/{{ .NormalizedKubernetesVersion }}-standalone{{ .StrictSuffix }}/{{ .ResourceKind }}{{ .KindSuffix }}.json,https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json]`) - - Default json schema sources to validate resources\. Must be a valid go template defining a http\(s\) URL, or an absolute path on local file system\. Vars: \$NELM\_RESOURCE\_VALIDATION\_SCHEMA\_\*, \$NELM\_CHART\_LINT\_RESOURCE\_VALIDATION\_SCHEMA\_\* + Extra json schema sources to validate resources, preferred over the json schemas embedded into the binary\. Which source served a resource kind is cached for \-\-resource\-validation\-cache\-lifetime, so a schema that only appears in these sources later takes over once that entry expires, whereas changing this option resets the cache at once\. Must be a valid go template defining a http\(s\) URL, or an absolute path on local file system\. Example: https://raw\.githubusercontent\.com/yannh/kubernetes\-json\-schema/master/\{\{ \.NormalizedKubernetesVersion \}\}\-standalone\{\{ \.StrictSuffix \}\}/\{\{ \.ResourceKind \}\}\{\{ \.KindSuffix \}\}\.json\. Vars: \$NELM\_RESOURCE\_VALIDATION\_EXTRA\_SCHEMA\_\*, \$NELM\_CHART\_LINT\_RESOURCE\_VALIDATION\_EXTRA\_SCHEMA\_\* - `--resource-validation-skip` (default: `[]`) diff --git a/pkg/common/common.go b/pkg/common/common.go index 65e78dd2..154aacc4 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -93,8 +93,16 @@ const ( StoreAsHook StoreAs = "hook" StoreAsRegular StoreAs = "regular" + // CacheDirAPIResourceJSONSchemas is where the JSON schemas downloaded from the schema sources + // configured by the user are cached. Unchanged on purpose: moving it would orphan the schemas + // every existing installation has already downloaded, with nothing to clean them up afterwards. CacheDirAPIResourceJSONSchemas = CacheSubdirNelm + "/api-resource-json-schemas" - CacheSubdirNelm = "nelm" + // CacheDirEmbeddedAPIResourceJSONSchemas is where the JSON schemas embedded into the binary are + // unpacked to, so that they are validated against from disk instead of from memory. It nests under + // the directory above, which holds nothing but hash named per source directories, so a new name + // cannot collide with one of those. + CacheDirEmbeddedAPIResourceJSONSchemas = CacheDirAPIResourceJSONSchemas + "/embedded" + CacheSubdirNelm = "nelm" // ChartTSBundleFile is the path to the bundle in a Helm chart. ChartTSBundleFile = ChartTSSourceDir + "dist/bundle.js" // ChartTSEntryPointJS is the JavaScript entry point path. @@ -123,28 +131,26 @@ const ( DefaultProgressPrintInterval = 5 * time.Second DefaultQPSLimit = 30 DefaultReleaseHistoryLimit = 10 - // DefaultResourceValidationKubeVersion Kubernetes version to use during resource validation by kubeconform - DefaultResourceValidationKubeVersion = "1.35.0" - DefaultWebhookRetryTimeout = 4 * time.Minute - KubectlEditFieldManager = "kubectl-edit" - LockConfigMapName = "werf-synchronization" - OldFieldManagerPrefix = "werf" - OutputFormatJSON = "json" - OutputFormatTable = "table" - OutputFormatYAML = "yaml" - ReleaseStorageDriverConfigMap = "configmap" - ReleaseStorageDriverConfigMaps = "configmaps" - ReleaseStorageDriverDefault = "" - ReleaseStorageDriverMemory = "memory" - ReleaseStorageDriverSQL = "sql" - ReleaseStorageDriverSecret = "secret" - ReleaseStorageDriverSecrets = "secrets" - StageEndSuffix = "end" - StagePrefix = "stage" - StageStartSuffix = "start" - StubReleaseName = "stub-release" - StubReleaseNamespace = "stub-namespace" - TSDefaultRenderContextType = TSGenericRenderContextType + DefaultWebhookRetryTimeout = 4 * time.Minute + KubectlEditFieldManager = "kubectl-edit" + LockConfigMapName = "werf-synchronization" + OldFieldManagerPrefix = "werf" + OutputFormatJSON = "json" + OutputFormatTable = "table" + OutputFormatYAML = "yaml" + ReleaseStorageDriverConfigMap = "configmap" + ReleaseStorageDriverConfigMaps = "configmaps" + ReleaseStorageDriverDefault = "" + ReleaseStorageDriverMemory = "memory" + ReleaseStorageDriverSQL = "sql" + ReleaseStorageDriverSecret = "secret" + ReleaseStorageDriverSecrets = "secrets" + StageEndSuffix = "end" + StagePrefix = "stage" + StageStartSuffix = "start" + StubReleaseName = "stub-release" + StubReleaseNamespace = "stub-namespace" + TSDefaultRenderContextType = TSGenericRenderContextType // TSGenericRenderContextType is the TypeScript render context type name for nelm charts. TSGenericRenderContextType = "RenderContext" // TSWerfRenderContextType is the TypeScript render context type name for werf charts. @@ -240,11 +246,7 @@ var ( SprigFuncs = sprig.TxtFuncMap() DefaultDockerConfig = filepath.Join(userHomeDir(), ".docker") DefaultPlanArtifactLifetime = 2 * time.Hour - DefaultResourceValidationSchema = []string{ - "https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/{{ .NormalizedKubernetesVersion }}-standalone{{ .StrictSuffix }}/{{ .ResourceKind }}{{ .KindSuffix }}.json", - "https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json", - } - DefaultResourceValidationCacheLifetime = 48 * time.Hour + DefaultResourceValidationCacheLifetime = 48 * time.Hour ) // Type of the current operation. diff --git a/pkg/common/options.go b/pkg/common/options.go index 44921e41..964115a0 100644 --- a/pkg/common/options.go +++ b/pkg/common/options.go @@ -222,17 +222,15 @@ type ResourceValidationOptions struct { NoResourceValidation bool `json:"noResourceValidation"` // NoValuesSchemaValidation disables values validation against json schema. NoValuesSchemaValidation bool `json:"noValuesSchemaValidation"` - // LocalResourceValidation Disable KubeConform resource validation. + // LocalResourceValidation validates by using kubeconform embedded schemas and client-go codec only. LocalResourceValidation bool `json:"localResourceValidation"` - // ValidationKubeVersion sets specific Kubernetes version and respective schemas to use on resource validation. - ValidationKubeVersion string `json:"validationKubeVersion"` // ValidationSkip Do not validate resources with specific attributes. ValidationSkip []string `json:"validationSkip"` // ValidationSchemaCacheLifetime how long the schema cache should be valid. ValidationSchemaCacheLifetime time.Duration `json:"validationSchemaCacheLifetime"` - // ValidationSchemas default schema sources to validate Kubernetes resources. - ValidationSchemas []string `json:"validationSchemas"` - // ValidationExtraSchemas extra schema sources to validate Kubernetes resources (preferred). + // ValidationExtraSchemas are additional schema sources to validate Kubernetes resources + // against. They are preferred over the json schemas embedded into the binary, which are used + // for whatever these sources have no schema for. ValidationExtraSchemas []string `json:"validationExtraSchemas"` } diff --git a/pkg/resource/helpers_ai_test.go b/pkg/resource/helpers_ai_test.go index 1c88241a..3c6bbd0e 100644 --- a/pkg/resource/helpers_ai_test.go +++ b/pkg/resource/helpers_ai_test.go @@ -18,12 +18,12 @@ import ( "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/resource" + "github.com/werf/nelm/pkg/resource/schemas" "github.com/werf/nelm/pkg/resource/spec" ) const ( schemaURLTemplate = "/{{ .NormalizedKubernetesVersion }}-standalone{{ .StrictSuffix }}/{{ .ResourceKind }}{{ .KindSuffix }}.json" - testKubeVersion = "1.30.0" testReleaseNamespace = "test-namespace" ) @@ -32,7 +32,7 @@ func setupDefaultSchemaServer(t *testing.T) string { t.Helper() setupTestEnvironment(t) - schemas := getDefaultSchemas(t, testKubeVersion) + schemas := getDefaultSchemas(t, testKubeVersion(t)) server := setupSchemaServer(t, schemas) return server.URL + schemaURLTemplate @@ -109,11 +109,10 @@ func makeInstallableResource(t *testing.T, obj map[string]interface{}, releaseNa return instRes } -func makeValidationOptions(kubeVersion string, schemaURLs []string) common.ResourceValidationOptions { +func makeValidationOptions(schemaURLs []string) common.ResourceValidationOptions { return common.ResourceValidationOptions{ - ValidationKubeVersion: kubeVersion, ValidationSchemaCacheLifetime: 1 * time.Hour, - ValidationSchemas: schemaURLs, + ValidationExtraSchemas: schemaURLs, } } @@ -148,6 +147,25 @@ func setupLocalSchemaDir(t *testing.T, schemas map[string]string) string { return schemaDir } +// setupTestEnvironment redirects the cache directory, which holds both the downloaded schemas and +// the unpacked embedded ones, at a temporary directory. Without it, tests read and write the real +// user cache, which makes cache assertions depend on machine state. Note that the vendored helmpath +// only honors XDG_CACHE_HOME, not HELM_CACHE_HOME. func setupTestEnvironment(t *testing.T) { t.Helper() + + t.Setenv("XDG_CACHE_HOME", t.TempDir()) +} + +// testKubeVersion is the version the "{{ .NormalizedKubernetesVersion }}" placeholder of a configured +// schema source resolves to. It is not configurable: it comes from the schemas embedded into the +// binary, and test servers have to lay their schemas out under it, or nothing ever finds them. +func testKubeVersion(t *testing.T) string { + t.Helper() + + kubeVersion, err := schemas.KubeVersion() + require.NoError(t, err) + require.NotEmpty(t, kubeVersion, "the embedded schemas record no Kubernetes version") + + return kubeVersion } diff --git a/pkg/resource/kubeconform.go b/pkg/resource/kubeconform.go index f172eadb..33e69f77 100644 --- a/pkg/resource/kubeconform.go +++ b/pkg/resource/kubeconform.go @@ -6,12 +6,11 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" - "errors" "fmt" - "net/http" "net/url" "os" "path/filepath" + "slices" "strings" "text/template" "time" @@ -26,6 +25,7 @@ import ( "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/helm/pkg/helmpath" "github.com/werf/nelm/pkg/log" + "github.com/werf/nelm/pkg/resource/schemas" "github.com/werf/nelm/pkg/resource/spec" "github.com/werf/nelm/pkg/util" ) @@ -36,20 +36,44 @@ const ( kubeConformCacheMetadataFilename = "metadata.json" ) -var ErrResourceValidationSourceSanityCheck = errors.New("resource validation source sanity check") - type kubeConformValidator struct { - kubeVersion string + cacheSubDirName string + embeddedSources map[string]*schemas.Source schemaCacheLifetime time.Duration schemaSources []string validators []*kubeConformInstance } -func newKubeConformValidator(kubeVersion string, schemaCacheLifetime time.Duration, schemaSource []string) (*kubeConformValidator, error) { +func newKubeConformValidator(schemaCacheLifetime time.Duration, schemaSources []string, embeddedSchemasOnly bool) (*kubeConformValidator, error) { + kubernetesSource, err := schemas.KubernetesSource() + if err != nil { + return nil, fmt.Errorf("get embedded Kubernetes schemas: %w", err) + } + + crdsSource, err := schemas.CRDsSource() + if err != nil { + return nil, fmt.Errorf("get embedded CRD schemas: %w", err) + } + + if embeddedSchemasOnly { + schemaSources = nil + } + + cacheSubDirName := getHash(strings.Join(schemaSources, "-")) + + sources := slices.Clone(schemaSources) + embeddedByTemplate := make(map[string]*schemas.Source, 2) + + for _, embeddedSource := range []*schemas.Source{kubernetesSource, crdsSource} { + sources = append(sources, embeddedSource.Template) + embeddedByTemplate[embeddedSource.Template] = embeddedSource + } + return &kubeConformValidator{ - kubeVersion: strings.TrimLeft(kubeVersion, "v"), + cacheSubDirName: cacheSubDirName, + embeddedSources: embeddedByTemplate, schemaCacheLifetime: schemaCacheLifetime, - schemaSources: schemaSource, + schemaSources: sources, }, nil } @@ -146,21 +170,20 @@ func (kc *kubeConformValidator) getValidatorInstances(ctx context.Context) ([]*k return kc.validators, nil } - if err := kc.validateSchemasSources(ctx); err != nil { - return nil, fmt.Errorf("validate schema sources: %w", err) + // Not configurable: it is what the embedded schemas were generated for. kubeconform resolves + // "{{ .NormalizedKubernetesVersion }}" with it, and it keys the cache entries. + kubeVersion, err := schemas.KubeVersion() + if err != nil { + return nil, fmt.Errorf("get Kubernetes version of the embedded schemas: %w", err) } - // Generate top level directory name based on source combination, to avoid invalid cache hit on - // source combination change. - sourcesSubDirName := getHash(strings.Join(kc.schemaSources, "-")) - for _, source := range kc.schemaSources { - cacheDir, err := createKubeConformCacheDir(sourcesSubDirName, source) + cacheDir, err := createKubeConformCacheDir(kc.cacheSubDirName, source) if err != nil { return nil, fmt.Errorf("get schema cache dir: %w", err) } - validationInstance, err := newKubeConformInstance(ctx, source, cacheDir, kc.kubeVersion, kc.schemaCacheLifetime) + validationInstance, err := newKubeConformInstance(ctx, source, cacheDir, kubeVersion, kc.schemaCacheLifetime, kc.embeddedSources[source]) if err != nil { return nil, fmt.Errorf("get generic validator: %w", err) } @@ -175,56 +198,9 @@ func (kc *kubeConformValidator) getValidatorInstances(ctx context.Context) ([]*k return kc.validators, nil } -// validateSchemasSources validates source against Kubernetes version based on assumption that -// every Kubernetes version has Deployment.apps/v1 schema. If at least one source statisfy to condition, the complete -// source list considered to be valid, as it allows to validate native resources. -func (kc *kubeConformValidator) validateSchemasSources(ctx context.Context) error { - httpClient := util.NewRestyClient(ctx) - - for _, schemaSource := range kc.schemaSources { - patchedSource, err := patchKubeConformSchemaSource(schemaSource, "deployment", - "apps", "v1", false, kc.kubeVersion) - if err != nil { - return fmt.Errorf("%w: patch schema source %s: %w", ErrResourceValidationSourceSanityCheck, schemaSource, err) - } - - if isLocalFSSource(patchedSource) { - if _, err := os.Stat(patchedSource); err != nil { - if !os.IsNotExist(err) { - return fmt.Errorf("%w: open test schema for Deployment/apps/v1 for kube version %s: %w", ErrResourceValidationSourceSanityCheck, kc.kubeVersion, err) - } - - log.Default.Debug(ctx, "Test schema for Deployment/apps/v1 for kube version %s not found at %s: %w", kc.kubeVersion, patchedSource, err) - - continue - } - - return nil - } - - response, err := httpClient.R().SetContext(ctx).Head(patchedSource) - if err != nil { - return fmt.Errorf("%w: cannot get test schema for deployment/apps/v1 for kube version %s at %s: %w", - ErrResourceValidationSourceSanityCheck, kc.kubeVersion, patchedSource, err) - } - - switch response.StatusCode() { - case http.StatusOK: - return nil - case http.StatusNotFound: - log.Default.Debug(ctx, "Test schema for Deployment/apps/v1 for kube version %s not found at %s not found", kc.kubeVersion, patchedSource) - - continue - default: - return fmt.Errorf("%w: got unexpected status code %d", ErrResourceValidationSourceSanityCheck, response.StatusCode()) - } - } - - return fmt.Errorf("%w: unable to get deployment/apps/v1 for kube version %s in any schema sources", ErrResourceValidationSourceSanityCheck, kc.kubeVersion) -} - type kubeConformCacheEntry struct { - Created time.Time `json:"created"` + Created time.Time `json:"created"` + SchemaFile string `json:"schemaFile,omitempty"` } type kubeConformCacheMetadata struct { @@ -232,17 +208,25 @@ type kubeConformCacheMetadata struct { Entries map[string]kubeConformCacheEntry `json:"entries"` } +func newKubeConformCacheMetadata() *kubeConformCacheMetadata { + return &kubeConformCacheMetadata{ + APIVersion: kubeConformCacheMetadataAPIVersion, + Entries: make(map[string]kubeConformCacheEntry), + } +} + type kubeConformInstance struct { - cacheDir string - cacheLifetime time.Duration - fileLock *flock.Flock - kubeVersion string - metadata kubeConformCacheMetadata - source string - validator validator.Validator + cacheDir string + cacheLifetime time.Duration + embeddedSource *schemas.Source + fileLock *flock.Flock + kubeVersion string + metadata kubeConformCacheMetadata + source string + validator validator.Validator } -func newKubeConformInstance(ctx context.Context, source, cacheDir, kubeVersion string, cacheLifetime time.Duration) (*kubeConformInstance, error) { +func newKubeConformInstance(ctx context.Context, source, cacheDir, kubeVersion string, cacheLifetime time.Duration, embeddedSource *schemas.Source) (*kubeConformInstance, error) { validatorOpts := validator.Opts{ Strict: false, IgnoreMissingSchemas: false, @@ -267,12 +251,13 @@ func newKubeConformInstance(ctx context.Context, source, cacheDir, kubeVersion s lockFilePath := filepath.Join(cacheDir, kubeConformCacheLockFilename) v := &kubeConformInstance{ - cacheDir: cacheDir, - cacheLifetime: cacheLifetime, - fileLock: flock.New(lockFilePath), - kubeVersion: kubeVersion, - source: source, - validator: validatorInstance, + cacheDir: cacheDir, + cacheLifetime: cacheLifetime, + embeddedSource: embeddedSource, + fileLock: flock.New(lockFilePath), + kubeVersion: kubeVersion, + source: source, + validator: validatorInstance, } if err := v.fileLock.Lock(); err != nil { @@ -285,22 +270,7 @@ func newKubeConformInstance(ctx context.Context, source, cacheDir, kubeVersion s } }() - metadataFilePath := filepath.Join(cacheDir, kubeConformCacheMetadataFilename) - - if _, err := os.Stat(metadataFilePath); os.IsNotExist(err) { - v.metadata = kubeConformCacheMetadata{ - APIVersion: kubeConformCacheMetadataAPIVersion, - Entries: make(map[string]kubeConformCacheEntry), - } - - if err := writeKubeConformCacheMetadata(metadataFilePath, v.metadata); err != nil { - return nil, fmt.Errorf("write kube conform cache metadata: %w", err) - } - - return v, nil - } - - metadata, err := readKubeConformMetadata(metadataFilePath) + metadata, err := readKubeConformMetadata(ctx, v.metadataFilePath()) if err != nil { return nil, fmt.Errorf("read kube conform metadata: %w", err) } @@ -321,15 +291,21 @@ func (v *kubeConformInstance) AddCacheEntry(ctx context.Context, gvk schema.Grou } }() - metadata, err := readKubeConformMetadata(v.metadataFilePath()) + metadata, err := readKubeConformMetadata(ctx, v.metadataFilePath()) if err != nil { return fmt.Errorf("load metadata from %s: %w", v.metadataFilePath(), err) } v.metadata = *metadata + schemaFile, err := v.schemaCacheFileName(gvk) + if err != nil { + return fmt.Errorf("get schema cache file name for %s: %w", gvk, err) + } + v.metadata.Entries[getKubeConformEntryHash(v.kubeVersion, gvk)] = kubeConformCacheEntry{ - Created: time.Now().UTC(), + Created: time.Now().UTC(), + SchemaFile: schemaFile, } if err := writeKubeConformCacheMetadata(v.metadataFilePath(), v.metadata); err != nil { @@ -350,7 +326,7 @@ func (v *kubeConformInstance) FindCachedEntry(ctx context.Context, gvk schema.Gr } }() - metadata, err := readKubeConformMetadata(v.metadataFilePath()) + metadata, err := readKubeConformMetadata(ctx, v.metadataFilePath()) if err != nil { return false, fmt.Errorf("load metadata from %s: %w", v.metadataFilePath(), err) } @@ -378,7 +354,7 @@ func (v *kubeConformInstance) InvalidateCacheEntries(ctx context.Context) error } }() - metadata, err := readKubeConformMetadata(v.metadataFilePath()) + metadata, err := readKubeConformMetadata(ctx, v.metadataFilePath()) if err != nil { return fmt.Errorf("refresh metadata from %s: %w", v.metadataFilePath(), err) } @@ -392,15 +368,22 @@ func (v *kubeConformInstance) InvalidateCacheEntries(ctx context.Context) error continue } - entryFilePath := filepath.Join(v.cacheDir, hash) - - if !isLocalFSSource(v.source) { - if err := os.Remove(entryFilePath); err != nil { - log.Default.Warn(ctx, "Cannot remove schema cache entry %s: %s", entryFilePath, err) + // Dropping the file is what makes the lifetime mean anything: kubeconform never overwrites a + // schema it has cached, so while the file is there it is reused no matter how old. + if entry.SchemaFile != "" { + schemaFilePath := filepath.Join(v.cacheDir, entry.SchemaFile) + + if err := os.Remove(schemaFilePath); err != nil { + if os.IsNotExist(err) { + // Normal: another process may have evicted it, or this source never had the schema. + log.Default.Debug(ctx, "Cached schema %s is already gone, nothing to evict", schemaFilePath) + } else { + log.Default.Warn(ctx, "Cannot remove cached schema %s: %s", schemaFilePath, err) + } } } - log.Default.Debug(ctx, "Invalidating schema validator cache entry %s", entryFilePath) + log.Default.Debug(ctx, "Invalidating schema validator cache entry %s", hash) delete(v.metadata.Entries, hash) changed = true @@ -416,6 +399,10 @@ func (v *kubeConformInstance) InvalidateCacheEntries(ctx context.Context) error } func (v *kubeConformInstance) ValidateResource(ctx context.Context, res resource.Resource) (*validator.Result, error) { + if err := v.ensureReady(ctx); err != nil { + return nil, err + } + if err := v.fileLock.Lock(); err != nil { return nil, fmt.Errorf("acquire lock on schema validator %s: %w", v.lockFilePath(), err) } @@ -429,6 +416,19 @@ func (v *kubeConformInstance) ValidateResource(ctx context.Context, res resource return lo.ToPtr(v.validator.ValidateResource(res)), nil } +// ensureReady unpacks the embedded bundle right before it is first read. A no-op for other sources. +func (v *kubeConformInstance) ensureReady(ctx context.Context) error { + if v.embeddedSource == nil { + return nil + } + + if err := v.embeddedSource.EnsureExtracted(ctx); err != nil { + return fmt.Errorf("unpack embedded schemas: %w", err) + } + + return nil +} + func (v *kubeConformInstance) lockFilePath() string { return filepath.Join(v.cacheDir, kubeConformCacheLockFilename) } @@ -437,6 +437,21 @@ func (v *kubeConformInstance) metadataFilePath() string { return filepath.Join(v.cacheDir, kubeConformCacheMetadataFilename) } +// schemaCacheFileName returns the file kubeconform caches this resource's schema in, which is the hash +// of the schema location. Local sources are read in place and never cached, so they get no name. +func (v *kubeConformInstance) schemaCacheFileName(gvk schema.GroupVersionKind) (string, error) { + if isLocalFSSource(v.source) { + return "", nil + } + + schemaLocation, err := patchKubeConformSchemaSource(v.source, gvk, false, v.kubeVersion) + if err != nil { + return "", fmt.Errorf("patch schema source %s: %w", v.source, err) + } + + return getHash(schemaLocation), nil +} + func createKubeConformCacheDir(subDir, source string) (string, error) { sourceHash := getHash(source) @@ -474,10 +489,26 @@ func getKubeConformEntryHash(kubeVersion string, gvk schema.GroupVersionKind) st return getHash(fmt.Sprintf("%s-%s-%s", gvk.Kind, gvk.GroupVersion(), kubeVersion)) } -func patchKubeConformSchemaSource(source, kind, group, apiVersion string, strict bool, kubeVersion string) (string, error) { - kindSuffix := "-" + group + "-" + apiVersion - if group == "" { - kindSuffix = "-" + kubeVersion +func getHash(s string) string { + digest := sha256.Sum256([]byte(s)) + + return hex.EncodeToString(digest[:]) +} + +func isLocalFSSource(source string) bool { + return !strings.HasPrefix(source, "https://") && !strings.HasPrefix(source, "http://") +} + +// patchKubeConformSchemaSource renders a source template into the schema location for a resource. It +// mirrors the unexported schemaPath of kubeconform's registry, byte for byte. +func patchKubeConformSchemaSource(source string, gvk schema.GroupVersionKind, strict bool, kubeVersion string) (string, error) { + // Recombined the way kubeconform sees it, as a raw apiVersion: "apps/v1", or "v1" for core. + groupParts := strings.Split(gvk.GroupVersion().String(), "/") + versionParts := strings.Split(groupParts[0], ".") + + kindSuffix := "-" + strings.ToLower(versionParts[0]) + if len(groupParts) > 1 { + kindSuffix += "-" + strings.ToLower(groupParts[1]) } params := struct { @@ -488,10 +519,10 @@ func patchKubeConformSchemaSource(source, kind, group, apiVersion string, strict StrictSuffix string KindSuffix string }{ - Group: group, + Group: groupParts[0], NormalizedKubernetesVersion: kubeVersion, - ResourceAPIVersion: apiVersion, - ResourceKind: kind, + ResourceAPIVersion: groupParts[len(groupParts)-1], + ResourceKind: strings.ToLower(gvk.Kind), KindSuffix: kindSuffix, } @@ -503,9 +534,11 @@ func patchKubeConformSchemaSource(source, kind, group, apiVersion string, strict params.StrictSuffix = "-strict" } - if isLocalFSSource(source) && !strings.HasSuffix(source, ".json") { - // This local path adjustments match the default kubeconform logic. - source = strings.TrimRight(source, "/") + "/{{ .NormalizedKubernetesVersion }}-standalone{{ .StrictSuffix }}/{{ .ResourceKind }}{{ .KindSuffix }}.json" + // kubeconform appends its own layout to any source not already pointing at a file, remote and local + // alike. Skipping that here would make the cache entry name a file that does not exist, and the + // expiry then drops nothing. Mirrored verbatim, trailing slash and all. + if !strings.HasSuffix(source, "json") { + source += "/{{ .NormalizedKubernetesVersion }}-standalone{{ .StrictSuffix }}/{{ .ResourceKind }}{{ .KindSuffix }}.json" } tmpl, err := template.New("tpl").Parse(source) @@ -522,50 +555,76 @@ func patchKubeConformSchemaSource(source, kind, group, apiVersion string, strict return buf.String(), nil } -func getHash(s string) string { - digest := sha256.Sum256([]byte(s)) - - return hex.EncodeToString(digest[:]) -} +// readKubeConformMetadata falls back to an empty metadata whenever the file cannot be made sense of: +// it is a pure optimization, so a broken one must cost a few extra lookups, not fail the run. +func readKubeConformMetadata(ctx context.Context, path string) (*kubeConformCacheMetadata, error) { + metadataBytes, err := os.ReadFile(path) + if err != nil { + // Anything but a missing file points at the environment, not at the cache contents. + if !os.IsNotExist(err) { + return nil, fmt.Errorf("read %s: %w", path, err) + } -func isLocalFSSource(source string) bool { - return !strings.HasPrefix(source, "https://") && !strings.HasPrefix(source, "http://") -} + return newKubeConformCacheMetadata(), nil + } -func readKubeConformMetadata(path string) (*kubeConformCacheMetadata, error) { var metadata kubeConformCacheMetadata - metadataFile, err := os.OpenFile(path, os.O_RDONLY, 0o644) - if err != nil { - return nil, fmt.Errorf("open %s: %w", path, err) - } + if err := json.Unmarshal(metadataBytes, &metadata); err != nil { + log.Default.Warn(ctx, "Resetting unreadable schema cache metadata %s: %s", path, err) - defer metadataFile.Close() + return newKubeConformCacheMetadata(), nil + } - decoder := json.NewDecoder(metadataFile) + if metadata.APIVersion != kubeConformCacheMetadataAPIVersion { + log.Default.Warn(ctx, "Resetting schema cache metadata %s written in unsupported format %q", path, metadata.APIVersion) - if err := decoder.Decode(&metadata); err != nil { - return nil, fmt.Errorf("decode persisted metadata %s: %w", path, err) + return newKubeConformCacheMetadata(), nil } - if metadata.APIVersion != kubeConformCacheMetadataAPIVersion { - return nil, fmt.Errorf("invalid metadata API version %q found in %s", metadata.APIVersion, path) + // A metadata file with no entries at all decodes into a nil map, which is not writable. + if metadata.Entries == nil { + metadata.Entries = make(map[string]kubeConformCacheEntry) } return &metadata, nil } +// writeKubeConformCacheMetadata writes in full and moves into place, so an interrupted write leaves +// the previous metadata intact instead of a truncated file for the next run to choke on. func writeKubeConformCacheMetadata(path string, metadata kubeConformCacheMetadata) error { - metadataFile, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) + metadataBytes, err := json.Marshal(metadata) if err != nil { - return fmt.Errorf("open %s: %w", path, err) + return fmt.Errorf("encode metadata for %s: %w", path, err) } - defer metadataFile.Close() + tmpFile, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-*") + if err != nil { + return fmt.Errorf("create temp file for %s: %w", path, err) + } + + tmpPath := tmpFile.Name() + + defer func() { + _ = os.Remove(tmpPath) + }() + + if _, err := tmpFile.Write(metadataBytes); err != nil { + tmpFile.Close() + + return fmt.Errorf("write %s: %w", tmpPath, err) + } + + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("close %s: %w", tmpPath, err) + } + + if err := os.Chmod(tmpPath, 0o644); err != nil { + return fmt.Errorf("chmod %s: %w", tmpPath, err) + } - encoder := json.NewEncoder(metadataFile) - if err := encoder.Encode(metadata); err != nil { - return fmt.Errorf("update %s: %w", path, err) + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("move %s to %s: %w", tmpPath, path, err) } return nil diff --git a/pkg/resource/kubeconform_ai_test.go b/pkg/resource/kubeconform_ai_test.go index fec84de1..42cd23e5 100644 --- a/pkg/resource/kubeconform_ai_test.go +++ b/pkg/resource/kubeconform_ai_test.go @@ -48,7 +48,7 @@ func TestAI_KubeConformValidator(t *testing.T) { }, testReleaseNamespace) ctx := context.Background() - opts := makeValidationOptions(testKubeVersion, []string{schemaURL}) + opts := makeValidationOptions([]string{schemaURL}) err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{deployment}, opts) assert.NoError(t, err) @@ -70,7 +70,7 @@ func TestAI_KubeConformValidator(t *testing.T) { }, testReleaseNamespace) ctx := context.Background() - opts := makeValidationOptions(testKubeVersion, []string{schemaURL}) + opts := makeValidationOptions([]string{schemaURL}) err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{configMap}, opts) assert.NoError(t, err) @@ -99,7 +99,7 @@ func TestAI_KubeConformValidator(t *testing.T) { }, testReleaseNamespace) ctx := context.Background() - opts := makeValidationOptions(testKubeVersion, []string{schemaURL}) + opts := makeValidationOptions([]string{schemaURL}) err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{service}, opts) assert.NoError(t, err) @@ -125,7 +125,7 @@ func TestAI_KubeConformValidator(t *testing.T) { }, testReleaseNamespace) ctx := context.Background() - opts := makeValidationOptions(testKubeVersion, []string{schemaURL}) + opts := makeValidationOptions([]string{schemaURL}) err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{deployment}, opts) assert.NoError(t, err) @@ -145,7 +145,7 @@ func TestAI_KubeConformValidator(t *testing.T) { }, testReleaseNamespace) ctx := context.Background() - opts := makeValidationOptions(testKubeVersion, []string{schemaURL}) + opts := makeValidationOptions([]string{schemaURL}) err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{deployment}, opts) assertValidationError(t, err, "spec") @@ -167,7 +167,7 @@ func TestAI_KubeConformValidator(t *testing.T) { }, testReleaseNamespace) ctx := context.Background() - opts := makeValidationOptions(testKubeVersion, []string{schemaURL}) + opts := makeValidationOptions([]string{schemaURL}) err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{deployment}, opts) assertValidationError(t, err, "replicas") @@ -193,7 +193,7 @@ func TestAI_KubeConformValidator(t *testing.T) { }, testReleaseNamespace) ctx := context.Background() - opts := makeValidationOptions(testKubeVersion, []string{schemaURL}) + opts := makeValidationOptions([]string{schemaURL}) err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{service}, opts) assertValidationError(t, err, "port") @@ -214,7 +214,7 @@ func TestAI_KubeConformValidator(t *testing.T) { }, testReleaseNamespace) ctx := context.Background() - opts := makeValidationOptions(testKubeVersion, []string{schemaURL}) + opts := makeValidationOptions([]string{schemaURL}) err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{configMap}, opts) assertValidationError(t, err, "data") @@ -240,7 +240,7 @@ func TestAI_KubeConformValidator(t *testing.T) { }, testReleaseNamespace) ctx := context.Background() - opts := makeValidationOptions(testKubeVersion, []string{schemaURL}) + opts := makeValidationOptions([]string{schemaURL}) err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{deployment1, deployment2}, opts) assertValidationError(t, err, "invalid-deployment-1") @@ -252,7 +252,7 @@ func TestAI_KubeConformValidator(t *testing.T) { t.Run("local_filesystem_source_works", func(t *testing.T) { setupTestEnvironment(t) - schemas := getDefaultSchemas(t, testKubeVersion) + schemas := getDefaultSchemas(t, testKubeVersion(t)) schemaDir := setupLocalSchemaDir(t, schemas) deployment := makeInstallableResource(t, map[string]interface{}{ @@ -268,7 +268,7 @@ func TestAI_KubeConformValidator(t *testing.T) { }, testReleaseNamespace) ctx := context.Background() - opts := makeValidationOptions(testKubeVersion, []string{schemaDir}) + opts := makeValidationOptions([]string{schemaDir}) err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{deployment}, opts) assert.NoError(t, err) @@ -290,7 +290,7 @@ func TestAI_KubeConformValidator(t *testing.T) { }, testReleaseNamespace) ctx := context.Background() - opts := makeValidationOptions(testKubeVersion, []string{schemaURL}) + opts := makeValidationOptions([]string{schemaURL}) err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{deployment}, opts) assert.NoError(t, err) @@ -299,14 +299,14 @@ func TestAI_KubeConformValidator(t *testing.T) { t.Run("fallback_to_second_source", func(t *testing.T) { setupTestEnvironment(t) - version := "v" + testKubeVersion + version := "v" + testKubeVersion(t) deploymentOnlySchemas := map[string]string{ version + "-standalone/deployment-apps-v1.json": loadSchema(t, "deployment"), } server1 := setupSchemaServer(t, deploymentOnlySchemas) schemaURL1 := server1.URL + schemaURLTemplate - allSchemas := getDefaultSchemas(t, testKubeVersion) + allSchemas := getDefaultSchemas(t, testKubeVersion(t)) server2 := setupSchemaServer(t, allSchemas) schemaURL2 := server2.URL + schemaURLTemplate @@ -322,18 +322,21 @@ func TestAI_KubeConformValidator(t *testing.T) { }, testReleaseNamespace) ctx := context.Background() - opts := makeValidationOptions(testKubeVersion, []string{schemaURL1, schemaURL2}) + opts := makeValidationOptions([]string{schemaURL1, schemaURL2}) err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{configMap}, opts) assert.NoError(t, err) }) - t.Run("sanity_check_fails_no_deployment_schema", func(t *testing.T) { + t.Run("configured_source_with_only_some_schemas_is_accepted", func(t *testing.T) { setupTestEnvironment(t) - version := "v" + testKubeVersion + // A source that has no Deployment schema used to be rejected up front as probably mistyped. + // It is not any more: the embedded Kubernetes schemas cover native resources, so a source + // that only adds a schema here and there is a legitimate setup. + version := "v" + testKubeVersion(t) configMapOnlySchemas := map[string]string{ - version + "-standalone/configmap-" + testKubeVersion + ".json": loadSchema(t, "configmap"), + version + "-standalone/configmap-" + testKubeVersion(t) + ".json": loadSchema(t, "configmap"), } server := setupSchemaServer(t, configMapOnlySchemas) schemaURL := server.URL + schemaURLTemplate @@ -350,10 +353,10 @@ func TestAI_KubeConformValidator(t *testing.T) { }, testReleaseNamespace) ctx := context.Background() - opts := makeValidationOptions(testKubeVersion, []string{schemaURL}) + opts := makeValidationOptions([]string{schemaURL}) err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{configMap}, opts) - assertValidationError(t, err, "sanity check") + assert.NoError(t, err) }) t.Run("resource_without_schema_skipped", func(t *testing.T) { @@ -371,7 +374,7 @@ func TestAI_KubeConformValidator(t *testing.T) { }, testReleaseNamespace) ctx := context.Background() - opts := makeValidationOptions(testKubeVersion, []string{schemaURL}) + opts := makeValidationOptions([]string{schemaURL}) err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{crd}, opts) assert.NoError(t, err) @@ -382,7 +385,7 @@ func TestAI_KubeConformValidator(t *testing.T) { t.Run("second_validation_uses_cache", func(t *testing.T) { setupTestEnvironment(t) - schemas := getDefaultSchemas(t, testKubeVersion) + schemas := getDefaultSchemas(t, testKubeVersion(t)) server, requestCount := setupSchemaServerWithCounter(t, schemas) schemaURL := server.URL + schemaURLTemplate @@ -411,7 +414,7 @@ func TestAI_KubeConformValidator(t *testing.T) { }, testReleaseNamespace) ctx := context.Background() - opts := makeValidationOptions(testKubeVersion, []string{schemaURL}) + opts := makeValidationOptions([]string{schemaURL}) err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{deployment1}, opts) assert.NoError(t, err) @@ -430,7 +433,7 @@ func TestAI_KubeConformValidator(t *testing.T) { schemaURL := setupDefaultSchemaServer(t) ctx := context.Background() - opts := makeValidationOptions(testKubeVersion, []string{schemaURL}) + opts := makeValidationOptions([]string{schemaURL}) err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{}, opts) assert.NoError(t, err) @@ -451,7 +454,7 @@ func TestAI_KubeConformValidator(t *testing.T) { }, testReleaseNamespace) ctx := context.Background() - opts := makeValidationOptions(testKubeVersion, []string{schemaURL}) + opts := makeValidationOptions([]string{schemaURL}) err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{configMap}, opts) assert.NoError(t, err) @@ -500,7 +503,7 @@ func TestAI_KubeConformValidator(t *testing.T) { }, testReleaseNamespace) ctx := context.Background() - opts := makeValidationOptions(testKubeVersion, []string{schemaURL}) + opts := makeValidationOptions([]string{schemaURL}) err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{deployment, configMap, service}, opts) assert.NoError(t, err) diff --git a/pkg/resource/kubeconform_cache_ai_test.go b/pkg/resource/kubeconform_cache_ai_test.go new file mode 100644 index 00000000..d4dc3e61 --- /dev/null +++ b/pkg/resource/kubeconform_cache_ai_test.go @@ -0,0 +1,388 @@ +//go:build ai_tests + +package resource_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/werf/nelm/pkg/common" + "github.com/werf/nelm/pkg/resource" +) + +var sha256HexPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + +type testCacheMetadata struct { + APIVersion string `json:"apiVersion"` + Entries map[string]struct { + Created time.Time `json:"created"` + SchemaFile string `json:"schemaFile"` + } `json:"entries"` +} + +func TestAI_SchemaCacheEntryOfPreviousNelmVersion(t *testing.T) { + cacheHome := setupTestEnvironmentWithCacheHome(t) + + server := setupSchemaServer(t, kubeConformNamedSchemas(t, testKubeVersion(t))) + + ctx := context.Background() + deployment := makeInstallableResource(t, validDeploymentObject(), testReleaseNamespace) + opts := makeValidationOptions([]string{server.URL + schemaURLTemplate}) + + require.NoError(t, resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{deployment}, opts)) + + cacheDir := findRemoteSchemaCacheDir(t, cacheHome) + + // Older nelm versions did not record the schema file name. Such an entry must still expire + // cleanly instead of failing the run, even though its schema cannot be dropped from disk. + metadataPath := filepath.Join(cacheDir, "metadata.json") + metadataBytes, err := os.ReadFile(metadataPath) + require.NoError(t, err) + + var raw map[string]any + require.NoError(t, json.Unmarshal(metadataBytes, &raw)) + + for _, entry := range raw["entries"].(map[string]any) { + delete(entry.(map[string]any), "schemaFile") + } + + strippedBytes, err := json.Marshal(raw) + require.NoError(t, err) + require.NoError(t, os.WriteFile(metadataPath, strippedBytes, 0o644)) + + expiringOpts := makeValidationOptions([]string{server.URL + schemaURLTemplate}) + expiringOpts.ValidationSchemaCacheLifetime = time.Nanosecond + + require.NoError(t, resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{deployment}, expiringOpts)) + + // The entry is rewritten by the current version, so from now on it can be evicted properly. + for _, entry := range readTestCacheMetadata(t, cacheDir).Entries { + assert.NotEmpty(t, entry.SchemaFile) + } +} + +func TestAI_SchemaCacheInvalidation(t *testing.T) { + t.Run("records the file kubeconform cached each schema in", func(t *testing.T) { + cacheHome := setupTestEnvironmentWithCacheHome(t) + + server := setupSchemaServer(t, kubeConformNamedSchemas(t, testKubeVersion(t))) + + ctx := context.Background() + opts := makeValidationOptions([]string{server.URL + schemaURLTemplate}) + + require.NoError(t, resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{ + makeInstallableResource(t, validDeploymentObject(), testReleaseNamespace), + makeInstallableResource(t, validConfigMapObject(), testReleaseNamespace), + }, opts)) + + cacheDir := findRemoteSchemaCacheDir(t, cacheHome) + metadata := readTestCacheMetadata(t, cacheDir) + + require.Len(t, metadata.Entries, 2, "one entry per validated GroupVersionKind") + + // This is the whole point: the recorded name must be the name kubeconform actually used, + // otherwise expiry deletes nothing. Deriving it wrongly would leave these files unmatched. + for hash, entry := range metadata.Entries { + require.NotEmpty(t, entry.SchemaFile, "entry %s recorded no schema file", hash) + assert.FileExists(t, filepath.Join(cacheDir, entry.SchemaFile)) + } + + onDisk := cachedSchemaFiles(t, cacheDir) + require.Len(t, onDisk, 2, "kubeconform should have cached both schemas") + + recorded := make([]string, 0, len(metadata.Entries)) + for _, entry := range metadata.Entries { + recorded = append(recorded, entry.SchemaFile) + } + + assert.ElementsMatch(t, onDisk, recorded, "every cached schema must be accounted for in the metadata") + }) + + t.Run("records the file for a source that has no path template", func(t *testing.T) { + cacheHome := setupTestEnvironmentWithCacheHome(t) + + // kubeconform appends its own "-standalone/.json" layout to any source + // that does not already point at a file, so this bare URL ends up serving the same schemas. The + // name of the cached file has to be derived from the location kubeconform actually built, not + // from the source as configured, or the entry expiry has nothing to delete. + server := setupSchemaServer(t, kubeConformNamedSchemas(t, testKubeVersion(t))) + + ctx := context.Background() + opts := makeValidationOptions([]string{server.URL}) + + require.NoError(t, resource.ValidateLocal(ctx, testReleaseNamespace, + []*resource.InstallableResource{makeInstallableResource(t, validDeploymentObject(), testReleaseNamespace)}, opts)) + + cacheDir := findRemoteSchemaCacheDir(t, cacheHome) + metadata := readTestCacheMetadata(t, cacheDir) + + require.Len(t, metadata.Entries, 1) + + for hash, entry := range metadata.Entries { + require.NotEmpty(t, entry.SchemaFile, "entry %s recorded no schema file", hash) + assert.FileExists(t, filepath.Join(cacheDir, entry.SchemaFile)) + } + + assert.ElementsMatch(t, cachedSchemaFiles(t, cacheDir), + []string{firstSchemaFile(t, metadata)}, "the cached schema must be the one the entry names") + }) + + t.Run("expired entries drop the cached schema and it is downloaded again", func(t *testing.T) { + cacheHome := setupTestEnvironmentWithCacheHome(t) + + server, downloadCount := setupSchemaServerCountingDownloads(t, kubeConformNamedSchemas(t, testKubeVersion(t))) + + ctx := context.Background() + deployment := makeInstallableResource(t, validDeploymentObject(), testReleaseNamespace) + + opts := makeValidationOptions([]string{server.URL + schemaURLTemplate}) + require.NoError(t, resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{deployment}, opts)) + + cacheDir := findRemoteSchemaCacheDir(t, cacheHome) + require.Len(t, cachedSchemaFiles(t, cacheDir), 1) + require.Equal(t, 1, *downloadCount) + + // Everything is stale now, so the cached schema must be dropped and fetched anew. + expiringOpts := makeValidationOptions([]string{server.URL + schemaURLTemplate}) + expiringOpts.ValidationSchemaCacheLifetime = time.Nanosecond + + require.NoError(t, resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{deployment}, expiringOpts)) + + assert.Equal(t, 2, *downloadCount, "the expired schema must have been downloaded again") + assert.Len(t, cachedSchemaFiles(t, cacheDir), 1, "the re-downloaded schema must be cached again") + }) + + t.Run("live entries keep the cached schema and skip downloading", func(t *testing.T) { + cacheHome := setupTestEnvironmentWithCacheHome(t) + + server, downloadCount := setupSchemaServerCountingDownloads(t, kubeConformNamedSchemas(t, testKubeVersion(t))) + + ctx := context.Background() + deployment := makeInstallableResource(t, validDeploymentObject(), testReleaseNamespace) + opts := makeValidationOptions([]string{server.URL + schemaURLTemplate}) + + require.NoError(t, resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{deployment}, opts)) + + cacheDir := findRemoteSchemaCacheDir(t, cacheHome) + cachedBefore := cachedSchemaFiles(t, cacheDir) + + require.NoError(t, resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{deployment}, opts)) + + assert.ElementsMatch(t, cachedBefore, cachedSchemaFiles(t, cacheDir), "a live entry must not be evicted") + assert.Equal(t, 1, *downloadCount, "a live entry must not be downloaded again") + }) +} + +func TestAI_SchemaCacheMetadataRecovery(t *testing.T) { + // The metadata is only an optimization, so none of these may fail a run. Before, each of them + // made validation impossible until the cache was wiped by hand. + for _, tt := range []struct { + content string + name string + }{ + {content: "", name: "empty file"}, + {content: `{"apiVersion":"v1","entries":{"abc":{"created":`, name: "truncated mid-write"}, + {content: "not json at all", name: "not json"}, + {content: `{"apiVersion":"v99","entries":{}}`, name: "unsupported format version"}, + {content: `{"apiVersion":"v1"}`, name: "no entries key at all"}, + {content: `{}`, name: "empty object"}, + } { + t.Run(tt.name, func(t *testing.T) { + cacheHome := setupTestEnvironmentWithCacheHome(t) + + server := setupSchemaServer(t, kubeConformNamedSchemas(t, testKubeVersion(t))) + + ctx := context.Background() + deployment := makeInstallableResource(t, validDeploymentObject(), testReleaseNamespace) + opts := makeValidationOptions([]string{server.URL + schemaURLTemplate}) + + require.NoError(t, resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{deployment}, opts)) + + cacheDir := findRemoteSchemaCacheDir(t, cacheHome) + metadataPath := filepath.Join(cacheDir, "metadata.json") + require.NoError(t, os.WriteFile(metadataPath, []byte(tt.content), 0o644)) + + require.NoError(t, resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{deployment}, opts), + "a broken cache must not fail validation") + + // The broken file is replaced by a usable one, so the cache works again from now on. + metadata := readTestCacheMetadata(t, cacheDir) + assert.Equal(t, "v1", metadata.APIVersion) + assert.Len(t, metadata.Entries, 1) + }) + } +} + +func TestAI_SchemaCacheMetadataWrittenAtomically(t *testing.T) { + cacheHome := setupTestEnvironmentWithCacheHome(t) + + server := setupSchemaServer(t, kubeConformNamedSchemas(t, testKubeVersion(t))) + + ctx := context.Background() + deployment := makeInstallableResource(t, validDeploymentObject(), testReleaseNamespace) + opts := makeValidationOptions([]string{server.URL + schemaURLTemplate}) + + require.NoError(t, resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{deployment}, opts)) + + // No staging file may be left behind next to the metadata it was written through. + entries, err := os.ReadDir(findRemoteSchemaCacheDir(t, cacheHome)) + require.NoError(t, err) + + for _, entry := range entries { + assert.NotContains(t, entry.Name(), ".tmp-", "temp file %s was not cleaned up", entry.Name()) + } +} + +// cachedSchemaFiles returns the schemas kubeconform has cached in dir, skipping nelm's own metadata +// and lock files. +func cachedSchemaFiles(t *testing.T, dir string) []string { + t.Helper() + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + + var names []string + + for _, entry := range entries { + if !entry.IsDir() && isCachedSchemaName(entry.Name()) { + names = append(names, entry.Name()) + } + } + + return names +} + +// findRemoteSchemaCacheDir locates the cache dir of the only remote schema source in play. +func findRemoteSchemaCacheDir(t *testing.T, cacheHome string) string { + t.Helper() + + root := filepath.Join(cacheHome, "helm", filepath.FromSlash(common.CacheDirAPIResourceJSONSchemas)) + embeddedRoot := filepath.Join(cacheHome, "helm", filepath.FromSlash(common.CacheDirEmbeddedAPIResourceJSONSchemas)) + + var found []string + + require.NoError(t, filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + + // The unpacked embedded bundles live under this root too, and are none of this helper's + // business. + if path == embeddedRoot { + return filepath.SkipDir + } + + // Local file system sources, the embedded bundles among them, get a "local-" prefixed dir + // and never cache anything into it. The remaining level is the per-source-set hash dir. + if entry.IsDir() && entry.Name() != filepath.Base(root) && + !isCachedSchemaName(entry.Name()) && !strings.HasPrefix(entry.Name(), "local-") { + found = append(found, path) + } + + return nil + })) + + require.Len(t, found, 1, "expected exactly one remote source cache dir, got %v", found) + + return found[0] +} + +func firstSchemaFile(t *testing.T, metadata testCacheMetadata) string { + t.Helper() + + for _, entry := range metadata.Entries { + return entry.SchemaFile + } + + t.Fatal("metadata has no entries") + + return "" +} + +// isCachedSchemaName reports whether name is a sha256 hash, which is how kubeconform names the +// files it caches downloaded schemas in, and how nelm names the per-source cache directories. It +// tells both apart from nelm's own metadata.json and lock. +func isCachedSchemaName(name string) bool { + return sha256HexPattern.MatchString(name) +} + +// kubeConformNamedSchemas serves the test schemas under the names kubeconform actually requests. +func kubeConformNamedSchemas(t *testing.T, kubeVersion string) map[string]string { + t.Helper() + + prefix := "v" + kubeVersion + "-standalone/" + + return map[string]string{ + prefix + "deployment-apps-v1.json": loadSchema(t, "deployment"), + prefix + "configmap-v1.json": loadSchema(t, "configmap"), + } +} + +func readTestCacheMetadata(t *testing.T, dir string) testCacheMetadata { + t.Helper() + + metadataBytes, err := os.ReadFile(filepath.Join(dir, "metadata.json")) + require.NoError(t, err) + + var metadata testCacheMetadata + + require.NoError(t, json.Unmarshal(metadataBytes, &metadata)) + + return metadata +} + +// setupSchemaServerCountingDownloads counts only the GETs that actually fetch a schema, so that a +// request of any other kind cannot be mistaken for a cache miss. +func setupSchemaServerCountingDownloads(t *testing.T, schemas map[string]string) (*httptest.Server, *int) { + t.Helper() + + downloadCount := new(int) + baseHandler := newSchemaHandler(schemas) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + *downloadCount++ + } + + baseHandler.ServeHTTP(w, r) + })) + + t.Cleanup(server.Close) + + return server, downloadCount +} + +// setupTestEnvironmentWithCacheHome is setupTestEnvironment that also hands back the cache home, so +// that tests can look into what ended up on disk. +func setupTestEnvironmentWithCacheHome(t *testing.T) string { + t.Helper() + + cacheHome := t.TempDir() + t.Setenv("XDG_CACHE_HOME", cacheHome) + + return cacheHome +} + +func validConfigMapObject() map[string]interface{} { + return map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test-configmap", + }, + "data": map[string]interface{}{ + "key": "value", + }, + } +} diff --git a/pkg/resource/kubeconform_embedded_ai_test.go b/pkg/resource/kubeconform_embedded_ai_test.go new file mode 100644 index 00000000..313b0924 --- /dev/null +++ b/pkg/resource/kubeconform_embedded_ai_test.go @@ -0,0 +1,354 @@ +//go:build ai_tests + +package resource_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/werf/nelm/pkg/common" + "github.com/werf/nelm/pkg/resource" + "github.com/werf/nelm/pkg/resource/schemas" +) + +func TestAI_EmbeddedSchemasFallback(t *testing.T) { + t.Run("falls_back_to_embedded_when_configured_source_has_no_schema", func(t *testing.T) { + setupTestEnvironment(t) + + // The server answers 404 for everything, so only the embedded schemas can validate this. + server := setupSchemaServer(t, map[string]string{}) + + validDeployment := makeInstallableResource(t, validDeploymentObject(), testReleaseNamespace) + + ctx := context.Background() + opts := makeValidationOptions([]string{server.URL + schemaURLTemplate}) + + err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{validDeployment}, opts) + assert.NoError(t, err) + }) + + t.Run("embedded_fallback_reports_invalid_resources", func(t *testing.T) { + setupTestEnvironment(t) + + server := setupSchemaServer(t, map[string]string{}) + + object := validDeploymentObject() + object["spec"].(map[string]interface{})["replicas"] = "should-be-integer" + invalidDeployment := makeInstallableResource(t, object, testReleaseNamespace) + + ctx := context.Background() + opts := makeValidationOptions([]string{server.URL + schemaURLTemplate}) + + err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{invalidDeployment}, opts) + assertValidationError(t, err, "/spec/replicas") + }) + + t.Run("configured_source_takes_precedence_over_embedded", func(t *testing.T) { + setupTestEnvironment(t) + + // An empty schema accepts anything. The resource below omits spec.selector, which the real + // Deployment schema requires and the codec check does not look at, so it can only pass if + // the configured source is consulted instead of the embedded schemas. + server := setupSchemaServer(t, map[string]string{ + "v" + testKubeVersion(t) + "-standalone/deployment-apps-v1.json": "{}", + }) + + object := validDeploymentObject() + delete(object["spec"].(map[string]interface{}), "selector") + invalidDeployment := makeInstallableResource(t, object, testReleaseNamespace) + + ctx := context.Background() + + // Sanity check: the embedded schemas do reject this resource. + embeddedOnlyOpts := makeValidationOptions(nil) + embeddedOnlyOpts.LocalResourceValidation = true + + err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{invalidDeployment}, embeddedOnlyOpts) + assertValidationError(t, err, "selector") + + opts := makeValidationOptions([]string{server.URL + schemaURLTemplate}) + + err = resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{invalidDeployment}, opts) + assert.NoError(t, err) + }) + + t.Run("resource_without_any_schema_still_passes", func(t *testing.T) { + setupTestEnvironment(t) + + server := setupSchemaServer(t, map[string]string{}) + + customResource := makeInstallableResource(t, map[string]interface{}{ + "apiVersion": "custom.example.com/v1", + "kind": "MyCustomResource", + "metadata": map[string]interface{}{ + "name": "test-custom-resource", + }, + "spec": map[string]interface{}{ + "anyField": "anyValue", + }, + }, testReleaseNamespace) + + ctx := context.Background() + opts := makeValidationOptions([]string{server.URL + schemaURLTemplate}) + + err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{customResource}, opts) + assert.NoError(t, err) + }) + + t.Run("custom_resources_validate_against_the_embedded_crd_catalog", func(t *testing.T) { + setupTestEnvironment(t) + + ctx := context.Background() + opts := makeValidationOptions(nil) + + // Prometheus is in the CRDs catalog, and spec.replicas is an integer there. + valid := makeInstallableResource(t, prometheusObject(int64(2)), testReleaseNamespace) + require.NoError(t, resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{valid}, opts)) + + invalid := makeInstallableResource(t, prometheusObject("should-be-integer"), testReleaseNamespace) + + err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{invalid}, opts) + assertValidationError(t, err, "/spec/replicas") + }) + + t.Run("crd_catalog_is_consulted_next_to_the_configured_sources", func(t *testing.T) { + setupTestEnvironment(t) + + // The native schemas come from the configured source here, but it has nothing for custom + // resources, so the embedded catalog must still be reached for the Prometheus below. + server := setupSchemaServer(t, kubeConformNamedSchemas(t, testKubeVersion(t))) + + ctx := context.Background() + opts := makeValidationOptions([]string{server.URL + schemaURLTemplate}) + + invalid := makeInstallableResource(t, prometheusObject("should-be-integer"), testReleaseNamespace) + + err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{invalid}, opts) + assertValidationError(t, err, "/spec/replicas") + }) + + t.Run("configured_sources_are_only_requested_for_the_resources_being_validated", func(t *testing.T) { + setupTestEnvironment(t) + + // The embedded Kubernetes schemas can always serve native resources, so nothing has to be + // learned about the configured sources up front. They used to be probed for a Deployment schema + // on every run, which cost a request that could not change the outcome. + server, requestCount := setupSchemaServerWithCounter(t, map[string]string{}) + + ctx := context.Background() + opts := makeValidationOptions([]string{server.URL + schemaURLTemplate}) + + configMap := makeInstallableResource(t, validConfigMapObject(), testReleaseNamespace) + require.NoError(t, resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{configMap}, opts)) + + // Exactly one request, the schema lookup for the ConfigMap, and no probe on top of it. + assert.Equal(t, 1, *requestCount, "the configured sources must not be probed") + }) + + t.Run("unreachable_configured_source_still_fails_the_run", func(t *testing.T) { + setupTestEnvironment(t) + + // Falling back to the embedded schemas on a broken configured source would hide the mistake, + // so an unreachable source is deliberately fatal even though the embedded schemas could have + // validated this resource. + validDeployment := makeInstallableResource(t, validDeploymentObject(), testReleaseNamespace) + + ctx := context.Background() + opts := makeValidationOptions([]string{"http://127.0.0.1:1/{{ .ResourceKind }}{{ .KindSuffix }}.json"}) + + err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{validDeployment}, opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed downloading schema") + }) +} + +func TestAI_EmbeddedSchemasNotUnpackedWhenNotNeeded(t *testing.T) { + setupTestEnvironment(t) + + // The configured source has every schema this resource needs, so the embedded bundle must not + // be unpacked at all. + schemas := getDefaultSchemas(t, testKubeVersion(t)) + server := setupSchemaServer(t, schemas) + + validDeployment := makeInstallableResource(t, validDeploymentObject(), testReleaseNamespace) + + ctx := context.Background() + opts := makeValidationOptions([]string{server.URL + schemaURLTemplate}) + + require.NoError(t, resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{validDeployment}, opts)) + + assert.False(t, embeddedSchemasUnpacked(t), "embedded schemas must stay packed while the configured sources suffice") +} + +func TestAI_EmbeddedSchemasOnly(t *testing.T) { + t.Run("ignores_configured_sources", func(t *testing.T) { + setupTestEnvironment(t) + + validDeployment := makeInstallableResource(t, validDeploymentObject(), testReleaseNamespace) + + ctx := context.Background() + + // Nothing is listening on this port: if the configured sources were consulted at all, looking + // the Deployment schema up would fail the run. + opts := makeValidationOptions([]string{"http://127.0.0.1:1/{{ .ResourceKind }}{{ .KindSuffix }}.json"}) + opts.LocalResourceValidation = true + + err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{validDeployment}, opts) + assert.NoError(t, err) + }) + + t.Run("validates_invalid_resources", func(t *testing.T) { + setupTestEnvironment(t) + + object := validDeploymentObject() + object["spec"].(map[string]interface{})["template"] = "should-be-object" + invalidDeployment := makeInstallableResource(t, object, testReleaseNamespace) + + ctx := context.Background() + opts := common.ResourceValidationOptions{LocalResourceValidation: true} + + err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{invalidDeployment}, opts) + assertValidationError(t, err, "/spec/template") + }) + + t.Run("collects_the_schemas_of_several_kube_versions", func(t *testing.T) { + embedded, err := schemas.KubeVersions() + require.NoError(t, err) + require.Len(t, embedded, 5, "five Kubernetes minor versions are expected to be collected") + assert.Equal(t, embedded[0], testKubeVersion(t), + "the newest collected version must be the one resources are validated against") + }) + + t.Run("validates_resources_of_api_versions_dropped_by_newer_kube_versions", func(t *testing.T) { + setupTestEnvironment(t) + + // PodCertificateRequest certificates.k8s.io/v1alpha1 exists in Kubernetes 1.34 alone, so its + // schema can only come from an older collected version and only while 1.34 is one of them. + // Replace it with another resource of a dropped API version once the window moves past 1.34. + // + // The path in the error is what tells the two validators apart: the schema reports + // "/spec/maxExpirationSeconds", the client-go codec would report "spec.maxExpirationSeconds". + ctx := context.Background() + opts := common.ResourceValidationOptions{LocalResourceValidation: true} + + valid := makeInstallableResource(t, podCertificateRequestObject(int64(3600)), testReleaseNamespace) + require.NoError(t, resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{valid}, opts)) + + invalid := makeInstallableResource(t, podCertificateRequestObject("should-be-integer"), testReleaseNamespace) + + err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{invalid}, opts) + assertValidationError(t, err, "/spec/maxExpirationSeconds") + }) + + t.Run("unpacks_schemas_only_once", func(t *testing.T) { + setupTestEnvironment(t) + + validDeployment := makeInstallableResource(t, validDeploymentObject(), testReleaseNamespace) + + ctx := context.Background() + opts := common.ResourceValidationOptions{LocalResourceValidation: true} + + for range 2 { + err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{validDeployment}, opts) + require.NoError(t, err) + } + }) +} + +func embeddedSchemasUnpacked(t *testing.T) bool { + t.Helper() + + cacheHome := os.Getenv("XDG_CACHE_HOME") + require.NotEmpty(t, cacheHome, "setupTestEnvironment must redirect the cache dir") + + entries, err := os.ReadDir(filepath.Join(cacheHome, "helm", common.CacheDirEmbeddedAPIResourceJSONSchemas)) + if errors.Is(err, os.ErrNotExist) { + return false + } + + require.NoError(t, err) + + for _, entry := range entries { + if entry.IsDir() { + return true + } + } + + return false +} + +// podCertificateRequestObject builds a resource of an API version that only the older collected +// Kubernetes versions have a schema for. +func podCertificateRequestObject(maxExpirationSeconds interface{}) map[string]interface{} { + return map[string]interface{}{ + "apiVersion": "certificates.k8s.io/v1alpha1", + "kind": "PodCertificateRequest", + "metadata": map[string]interface{}{ + "name": "test-pod-certificate-request", + }, + "spec": map[string]interface{}{ + "maxExpirationSeconds": maxExpirationSeconds, + "nodeName": "test-node", + "nodeUID": "11111111-1111-1111-1111-111111111111", + "pkixPublicKey": "dGVzdC1wdWJsaWMta2V5", + "podName": "test-pod", + "podUID": "22222222-2222-2222-2222-222222222222", + "proofOfPossession": "dGVzdC1wcm9vZg==", + "serviceAccountName": "test-service-account", + "serviceAccountUID": "33333333-3333-3333-3333-333333333333", + "signerName": "example.com/test-signer", + }, + } +} + +func prometheusObject(replicas interface{}) map[string]interface{} { + return map[string]interface{}{ + "apiVersion": "monitoring.coreos.com/v1", + "kind": "Prometheus", + "metadata": map[string]interface{}{ + "name": "test-prometheus", + }, + "spec": map[string]interface{}{ + "replicas": replicas, + }, + } +} + +func validDeploymentObject() map[string]interface{} { + return map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": "test-deployment", + }, + "spec": map[string]interface{}{ + "replicas": int64(1), + "selector": map[string]interface{}{ + "matchLabels": map[string]interface{}{ + "app": "test", + }, + }, + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "app": "test", + }, + }, + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "name": "app", + "image": "nginx:latest", + }, + }, + }, + }, + }, + } +} diff --git a/pkg/resource/resource.go b/pkg/resource/resource.go index 627ad846..95f3eab6 100644 --- a/pkg/resource/resource.go +++ b/pkg/resource/resource.go @@ -56,6 +56,7 @@ func NewInstallableResource(ctx context.Context, res *spec.ResourceSpec, otherRe otherResourceMetaList := lo.Map(otherResSpecs, func(resSpec *spec.ResourceSpec, _ int) *spec.ResourceMeta { return resSpec.ResourceMeta }) + if err := validateHook(res.ResourceMeta); err != nil { return nil, fmt.Errorf("validate hook configuration: %w", err) } diff --git a/pkg/resource/schemas/data/README.md b/pkg/resource/schemas/data/README.md new file mode 100644 index 00000000..6ac3f1c7 --- /dev/null +++ b/pkg/resource/schemas/data/README.md @@ -0,0 +1,61 @@ +# Embedded JSON schemas + +These archives are embedded into the nelm binary by `../embed.go` and hold the JSON schemas nelm +validates resources against without network access: + +- `kubernetes.tar.gz` — the Kubernetes API schemas of several minor versions merged into one flat + set. The newest is the `kubeconformValidationSchemasUpstreamKubeVersion` variable of the Taskfile + exactly as pinned, since that is the version nelm validates against; the minors right before it are + taken at the latest patch upstream has, which is decided from the upstream listing at generation + time rather than computed. The newest version contributes all of its schemas, and every older one + only adds the schemas that no newer version has any more, so resources of API versions removed + along the way stay validatable while everything else is validated against its newest schema. +- `crds.tar.gz` — a catalog of CRD schemas. +- `index.json` — what each archive contains, and its digest. + +They are committed on purpose. `go:embed` resolves at compile time from the files present in the +module as distributed, and the module cache is read-only, so generating them at build time would +leave anyone importing nelm as a library, or installing it with `go install`, without schemas. It +also keeps builds offline and makes a nelm commit fully determine the binary it produces. + +## Regenerating + +```shell +task generate:validation-schemas # after bumping the pinned Kubernetes version +task generate:validation-schemas:force # to pull whatever upstream has now +``` + +The plain task only regenerates what the committed archives do not already cover, and it decides that +without any network access, by comparing the index against the repository, ref, pinned version and +number of minors it was asked for. It therefore cannot notice that a ref has moved or that a new patch +was released: refreshing the CRD catalog, or picking up schema fixes for an unchanged Kubernetes +version, needs the `:force` variant. + +`task lint:validation-schemas` is offline for the same reason, so it takes the collected patches from +the index as recorded and only checks the pin against the Taskfile: the newest version and the window +of minors around it. + +That the network stays out of it is deliberate — builds are offline and a nelm commit determines the +binary it produces — so staleness is reported separately instead: + +```shell +task check:validation-schemas-upstream +``` + +Two requests, no downloads, and it runs daily in CI. For the CRD catalog it compares the commit, since +the whole repository is taken. For the Kubernetes schemas it lists the repository and fails when a +collected older minor has a newer patch upstream, or when the git tree of a collected version changed. +The commit is deliberately not compared there: that repository's ref moves whenever any version is +touched, while the directories we take are frozen snapshots, so it would cry stale on every unrelated +release. A newer patch of the pinned version is only reported, since bumping the pin is a decision. + +Commit the changed archives along with `index.json`. `task lint:validation-schemas` checks that the +two agree, and runs offline. + +Both tasks also fail when the archives add up to more than `-max-bundles-size`, 5 MiB by default: +every byte of them is carried by each nelm binary and by each module importing nelm, so the size is a +budget rather than whatever upstream happens to produce. Collecting fewer Kubernetes minor versions is +the usual way back under it; raising the flag is the other, when the budget is meant to grow. + +This directory is not documentation the binary carries: `embed.go` names the archives explicitly, so +this file is not embedded. diff --git a/pkg/resource/schemas/data/crds.tar.gz b/pkg/resource/schemas/data/crds.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..2d3ab07630228c678040c28b378b07f2f7e5f5d6 GIT binary patch literal 2082359 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

c{w;2@^4$Ua9RJ@@~Pqp9~{B=O; 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�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

SVa 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ʶA$F(O4W8 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

wI7PMZSbp~HVT~Ls z@EOeY=M?Mo6t!pNqX4Pj2djsEAF?EP#6&4YF_8D9$Goay<75`880eK)uKt18Au#ok zUmDuoKq3VSC>+`GIO5w{Z=@EG&Vx>0-)dql($I-3YYiyFj?VUqjJL1}Ty(Qryge

N&rdd8y8V=9pq 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

Wdpd8aRW5z}1B`VNc~SWQcdA?P+L#z%)eF(9zeU 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®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�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�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 z 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

0RGz5KkUJ!2BiQ;60N52egu~!8Ee* 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ȞOGmu3(DD83=R!p2UYLZV5 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

  • fk2Yq1r4Kp6AHi(@@5>YNNh0wm~f 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%0DcNfBwWE~H+mILYn{@WV&K-hxN!<^o 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

    g58N^hHpY|2+Iu9|g1Ce3YuTDfA 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 z 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

    {F(I5+IdM*K&>-mF(MKB})Ce+UyEkg9b85 zP#6-4P#84vzeGns1R9xGBnSyvNCY~c0O=okMJ2>>Hqv$@0=UKWdHMYDYD7od^zwW+ z+|(?Ss5;U|>0pOHzCa|m(JMv1OCmG&Q=4r`zv%b}MfcNN_41B?k!fKMy6&z6 zd0+PkekKybe;$LyQ5smfG?$3S;}*FPvvC*Q&n+nhFRY%OdRNDb754zW8g>A{x5n-M zwf33z%^NVeo4L$?et&5UW@MZ8n&0x+gh6;2@czDw!PWAL&|v36b7|B)4Z&`_{44eT zd;uSo=%-~C&q6EvH-3cQSRI7WoG;H8_#Te41^MC8X}Tdn4SRv{YYP&xiSTJP zkQa2PD|;;iP{Iu>{Cn4VMScT)s1r89qYM`vk9*V)E%k*_oYVM|uu7A{_ban2v+KVL zm%XD7K4zt2s@KDdx)0iXJOf7q+e0{3Ss{+X40yw#m+sfPi^AtznMsJ3Gdp{}k@`Qp zvMinb32y01ii$EJg4)-Y(myaltV$1**0yA27sE7pa%+*uw1N7C^cO1Cf5jbA7bWN9 z&GBP|^lSGn1(yeu_j&?j3mE!e`1t^XUaz_(l@8de3)8*xpGMnMoqU&B+`Ls_iYg*9 zymv$z(Y~!1sz40YXp50wQ|7Tsnnu@mU&HF=KQy`yZc=67$VF?=D>`pVxF#K?6aCpk z4xR~T!&E!^7txu8)Q^HG+Z0ij8EARKYhg;gtRPWZIC3ktXV+#x{3F@5=-w)9$~;a=y(;S@ZpdsdZFdZ zMRbuL^}S@9BMvQ#tkoY#*y;m2lZv}ftz2L0!H4;QB=!yed9BB#snJMu_Wi`H*pbD8 z$QV`1#q@-a4%<9wBpFXy3D>=e6E%$)7=6Zmch)mfG1>4{b*{!3Omj!5PbsJ8rVw=o=dL?^Hr)W9;;0CSJ zruX)!$gJ8I{IwchzyjU-^A0bu;r_>Xs3v}fcYsIHL&V29SQcIU%ovMrB&}3)Tzn23 zo+M(Cyt&e%^t1`P=F?32Te?;XCWX1_P_dzvK*?`p@$NbV1~E8q@zHGr=o6)Y~oMH6{64_6q5#IPi-m7`h8kq6* za2BFAZ|hX2e$>{XHcr_UnfyT1Q@DA3ziq()KJw%0$Vl_UUb@2#vNRsA+yy)<;e*lH zdewfzJ3y^%u*%P~Ef$Gw6dF%A*ddUD15Y&~f_r!yeUL(ak%a*(Y55_SgXXInTID!l zPJZD-KsL800%MD|U(jkAdrO>z*rduga5-gv+*|^{aB`#;9ty7Z=%b|>jQI5^+D1l= zP+m=N;%XQAjXu~c6Wh?~xdzaNrGIrlO6>2;5Wo@j5I? zwLAPQ61jQqD$~Sj3#VWw9kTxJv1xNmw98mz@*(MKqxXu833XTVmhPu6NtRVd+i0Nc zvIA}Eqwpg_#;}gv!@9p^e`iwP$%e#%Ro(qV@T5gV8dHb6J*%t!1ME1#!^g>KYYv_! z+wi${<#Ey(?y|*~yAU0?$_{6~Wp;E3h}Xm0`L|o4gwZc@!ZJV1^^ynEDl7RKlv~P+ zj18N~+4(wU&pJAtTP!N{tx~RXEm1nFyGk2$#|7PJ}d*b&mHLqluY$k7a|a|GDF!WuJOp zj*@((s*_M=DT-4mxxqU`jux4b`0{svK&A{6AVp?D@mPr#k-7k{=}c2YYC*M`j==T< zd>EgVzdfeBJ%L~xL;ZM)y@%hDRuK`S!-_zpCeq{wo0D%3{4I4nfHj9+7V!QyF0C!{ zTQy z1f+8SCV6vSh(@*&oaPoVlwHjo1&|&o!MM44`6NbJOUwtD&cq%KoBEwr-_I^&@&f3e zl%g@StfLf-_Z=??vK~Wdxnd@-ldR=qBs7DJ99KNdhLZ_Z>Kt2lLMX@+T=;JRNlliJZDKH|I-DY+IU;k0Bz0KJ z6EP+|P;dLkbI39%oBd?=O=Yh!Y>$gi1d3a@lEfmSEseV6LRffD$9@{LC6}k4tZ*!E zC|R*9F}*ZJw3Zf&%*%+BBAW`9?0#3NoSd$^-W)GdRD5lO?ozv5;kaJ1;6qX!{L?g1 zUSf(y8S#6%Anb6|t!OS@N}RgWr}-iA5BgKd!U%>Eb5mn9UguWs?T(eKPE;V)5B)(;Jr-n{oSJBYN}+?%$MWeXdz| z2iwvSCf?&B=Vp)gOitR*)Rxz+Vq87rRf<1q!wIqD8PF6Y#G>^bUYst$P@4%l_)G95vS|S(T3A#_v*z2mu``L{ zB_MqyCWZv}A2^=`9%ms2RVn$M1o_8G#4VXi>CKZSQ1}c5YRnEQ#1oA5)TE2x{2Fm) zo3~Ih}ZKge_-%ON{+Nb zX>sGQ@NY~+FNPse8S#aZ1pgN{F+|HH?rNAf=`+sUfSSvIKi_~O3sq~>Vo4s*DouiJ9&+lJmv7m!wPeX+&<4;_2_GDXUn@vJ5}9@-6~87rf7Nv_AY@<8okh=SOn7 zy;R-d2F^@_!)y@B}wnqxCa>_HUrsD>Yi1~ z9yD3mdH!A<9<8d0)PmMupNksjSMk!?Bj2xGBl%sZpQy*y8YKfz^yjV0x97nehArbk zsnpPhc!b~<*|par#-S@PDjs_)*XmvH>f7bfAU2F$DCoZx{w`e()8kxTFH*Ou09yAW zd4q)*T)Zwe`nAvqIfz8SAJ55rQ<&^b%zcg(y5%z)a7-TZD7tNi9I~tX!h>p?=wh+$ z8qMeviM~dR8H2*#-jdiL{REMl{Td)tHdJly*T5!1Z$Fami(YTRyCc|00A|c3*5W(c zA+Ja@O#Iau`(6yYV-R#}L?JQ3lkYTs|7vz!Yc!WPZ&JDS z;{KYp`^nw6!Z9y%FE2c~kLfFo0 zr$6!qS3!cL4suc`i}G#m>IVhK?FmI4rSEs}1@^qeA0?!)#r&a;PuRgq7EIZitX2j%ka=*n(F`4J)D*;lx0kFx6sGtf*9Z+4jt_R&2d$cv&OO z*yPjPu*_#zqt7)^t_mdQv@6iMZAN(6~2h zK#MCH+Hs8QkberHt<+3B96;K-{hT^6{beB=gu$9=ub7B}X4Hj4xy}XWVBI*LZ5MpC zMYXIrgmw}HRMa2L=N9M3wi~n7d_%XNzpX-J9ZNbNQ%)a)KesJ9qCZ`L;UQvZ{b|wN z3*PvLpsSx-WaFr-KTH4+Ks^j&SuLbBsvrT-z(TNx(8er>EQdTpFNfTMQGxyoOEfAY zY92JEpRnWQY`@X|qbR&TYAAva*WwtwgiAt4qvdB!KpAZxnS7Fdj!DJFNL+k9QKmAY z>1TLh^G99tD9EPxH{Tm+SaYN$JLR%7~@%mUYIH_6$t+41fSTSK&Yyq@F>6 z4Xn?Gp#BtVx4_9lP>t_yn1rIy?-@N>;07wf%?D^$;h}{{pWzJZfqvEn^dT^h55LH4y{T!X*Vq-p&>E^`-f}{@2B;2x(UXZ-{ z%;S+fKfn8`LPa6a=w~qqTnO!N+*3i%+6_}KT{&MRQK+}aRQVY0jhKg+DOb?TCYS=B zSHES39pu4shOKJL?ze}Oqg)NOrRj%f1sMJ8_98QAFmSP%5+SXF(GFBqHG@Yad)(ax zfemmCV&H41TsgS(@3%w3CTY`f&Ye2iDhodJ8a>_gY#zT-M7G<~$F%O&TwkY9$cv>~ zc$_ZBdX=Q!5JmNO)Qm+ZYVRa>BiC^1sAlR?xP4b14&xKE7KAInPxNv+7>k_{p7d zr{(kZ&r1~*4`yY#=BLe^xNJ1IVnyk*MQDDM#f}`R+K`;k-nJqCB9by`z+%$oVK<@6Qn-HE>9*d~5qLNorv1T& zOtJCC8Migr8b8*`oZVGRHbGhf_kBydilnX_Gllo`ZGO?2m$Tj`W_4L(mTH;3I@w|Z z*NL%KOY1Lni$vh#{Bq*M`NNtiVBU$pjk#uQpk)y%C%a9RsC=+`b1+)oexA$zYNGi%Dd*_KLo2&U0)DUWX>H>Mv!R9BQK9E^ctTlaABM_qdnt zL6t7=bYi{VN3odWUe4UB%@RmA+Woa_>8ADei0Y4i_F>^5y(Kd_@=~|23M>M5AcU8fH^^n^TxA`mYX42&rfF?jhxi@-uweT?G%kXSzCLhV;6tHyBeGJ? zR`QBzIIV9aO1{vmpR-9CsoRbqa;B+?~ZV%k>Kq%>`m$tv%slz zB$53uu_tor!itf(KTxeA+jv@ww1jZv?tP?{e|Wnh)K=eYY1vrL43&5UZyTSpc|t*t zSA-4O3xttaiuJz-KiTAfvO_hNP%nbKz54#$7J0V#4Xel?B}=jH|08!}+ZQ_a&X8Mz z0#bMYqXNZG1OsbvPE#{+*QA;~)>3+kfjr}V(egD4$c%Dzl`n%jZ7?gBHF^QT53b}< zHuO&@P=e^e3pNx>IJmLaPdkn5CbNz%-q%Qc@fd$5bQjR@s)4! zfKr~?bUqcSrMclw=saXEropamuD6G&&xNnRl~Qjes!9^H zB)aLP9G~1xZ1$;gx&Wu`utU|_<*V4_ja`ZDC4O6FtQ@6)$h8PpEHwvHm;Qf*Zy%R= zSqwc-KNk*B_w}EoIhFUO?Hp-q`v(W%eVO>l%cxY7j#L8C{?x%`)9XfA^i}CrfnQlN zqEEfcL6~P|Tnu-@^itxvk%`4u)!MrDHM|%zZ`(LEX(zIq4=$@Fh-vxSYF(NnWbhuN ztjv4)!wS7MF4xTwt31<=n2u?{qq0JDvZ&Gz)PMchJv_=efbEP@^~?FwqEv6wNMHyRH@u*}1H zP4hp-cn3I7_|chqAuWyLofF-Y)NL8HR-!ZU1iJ$|vXq5`RXDkY88_(}QT1CGz`QfC z>!!qN#ad3$-af83eN9CA6a&9un?H=dYgJrf~V>P1Pl{|)Q#ZhuG^ zsYUz>g;Ir}Y=#Cm1?-6bDr7%RQO&(3HVQkMx`6{r*5zj=;9C^-=bUer@eASn6*~Q9 z|ET56H(-nGb{2SmaM?I2`KW^*#bJ%%wVh$1LWL}nq$!*5R&^)wB6X0|Wgbfh3Q{YE zpk-*s9@C_Dnxq`H^92db#Wk(C^jH-$Gl#~VBrrBt8rrDxwZ>I7(=4XO-*(C3 zc;3b&ejOa^Ae7lV_}bNSYRxm0crJTT5@uUM)+nq^r4-lBAu3W0jGKz@HVz z&w!;E$Vo(YXJb|pk%+YEe2IV1A{tLU_f8`l*dY686kk- z`e>zG--K4b*)=m(Hf|MB zLb#@|)@qDYcHCnfM&PwC{za#e6p7WNZqLk{Bsia2U3UDZz*Y_w-ySIZan++n?Mj$g zNl~EaY#mH7mva5p7A0vM0j$!pm8YjL6OnZyS*+-J29$TJf2CZN<{51@^|*=6Qfzp{ zvbmtHh?J)#YT?-HgS=A7CR>UFTElUT2Hx3MSZjD~3f|p9w9y@OVZB4jTpUuX*W}3O z%NEriKMrW(#8hGk6z6Pt)gIm`1H;^zqMF^7H=K6IwKr{){@T<;6}k1$`_mc~ti?59 z<#H&W>5fN(5e@5Ms=3+0oXd2}t8`*Dl@-5k zsJ$)uG$c}R>$*7lm+Bt&<6HPKLA#YK(D$oBxiG0nHQo=0qQ&H6cD8?vKGc>>?#}I`40mo0$zP-MG!|O%mIB{5+w*P^*_Olu;3vV$mKO zynC%y>ymGkX9Zle+Nlx<{tE`3Lcuo4ElP*oXbUBi+|CUvBT0^{E`>VtanZl;KBa10 zsO^IrP|+M>*}u0~DUn&wf#xQ;a%F-$yTuc3+1;2A+3G4TmB_**J!hyXf8=2JbIV1- zNz@fE93mV2>RQzb(Gk;V(r7|7TTH3ikK6PqCmBa<-7&;-{cO``3n#`N3Fy6x)GwxV&GG`W)%SZ_Gk zNw>(X6n<;Aj=IvWTtaBo5Pmg(@^Er(wYpixJ_HX|ieAP($!zjy;to%eSp2DHr(bt{ zi;6B_CE`{08~Il4{NWB}*!yK(_giwlni$hJ@-?%n`~olFmy@SRo_N@rHd>iDT zKpDLqsaCDeym@rDrbARSCUX0S{G+(4&2w;LbZS}Mbk~=f>d808@YcjQO93oW)Tk^Lbd{U>ed?;Zo!b>+w)|;3 zbmQpER;tcw@^o`#RN(AQiS+pv#9{*>tC9UP&?uw)@&efXvEFKl+wqFMmuf@yjo|eN z0h20{#H9`Y7AgscPAl`T)~K`)LRx?hxD|xVr}p4#C|uKycRt5AN>4-Q7ZPcXtTx zF8glCYv+9DeBZi1?w`A6tvx-%boZuqS69{3PxSzce%^i3$d6yv2oZdMk0AK`Ip@A! z{BIwB;fa4#90zG&N{O3eBR z-;@6vuZ{SATcZM!pR89_hRcVYH#?M9))uO9d<7dXZ#wGG<5JwJq*^r|3B#TlDPo|U z?2oK^owvFtOLRIZXQ1$?^Ai$T(i@RG1LedVh=w3G{Dp>On*T;aB1v*K5*HSZDokSI z?}i;PbR($Vzak@rEY>2p$A{7OX}kwX(Zor_J@+o@R|fOGLlnE+mlouKOV~Z^9bdGq zdm~>KgmwrE4ZTnq3B0nm!0APEZoGNOY$ZXH8rc|IN4FmABOvp1HB;#8eo&x(0K{5i zz>jG=BvO3^RYN^%xmxL;k9~s-nzwcm}xTxm!9~<+ZNz_?ifW!ARlRSrk>`z&ux&Nwr5*j z=6;7Kf=-|$A~eiunne5XWSxfp<=iE(81AfXH=~z)fbGr(K6L+xXoZJ~7#qJ%=U!+VOzG5b(;6<~#w&ecOiUJhxO+7na4zY8m=4jVM?WQd zIXoU59yeU)%jAg47J;$_ed+MOi4skmhsl2SHO<~&N5`h+Pf|EYRva6sFPL#q*I>7s zC(eQ6d6%ZHq?j0U}*&HUh{LP#1tA2&%YWv*PFAg3JEJpIEvnUK^JD!ZiinC|rBA#}}$ z$^lr8VyIRLnveVnL`eXhw~9Ib?EUmPDte{kxOlCde}5F;-rn;v<_@rxW=}~i3>sa2 ziAE^$PcBXobJ{Xsy`PoJ_A1R9JJ*+&QT(9r*ee z)Xnd^D(-vz$r7c3wKH)-~lmk&u0kZ-OvPlsb%0lLNoesRnLvT&6CQ)}f@W&7>f+pcZ-t(0}5^*D%0FNRF zx)jX4i44B{1~jL&_vZ5L8CvC^F2$e@F5{Op-f&p2GJOR7n-XAIlLHwIArV~{N%o35 zS-KGrW92g&X-zpQo4~-*SdjmLI~_KmN<`MWHlZhDmp!x;tZR%Pb9&$3bq)q-XwThR zeHhSI*vhSJ=0A27Xzvk-jnI{; zOG-Qm-5=%P$IQC&cP2NKW_>Wre1=868k6YX)0TRy4Zlt0S0>d`cSowTbDi=!WZU=^ zL`zzwMad5~dH#XSHcaW0;56L?^9!8Bj_?X6HGkp(SCG>9!Gd;hq!7)ulKv>y2*EFB z6FKARdacSPFZ~1bW1ecj;XoC?4te6)p0q#u{6#s6ibT%ItV3t@6BnI?LBKVPh!d#0?(gN8KR=o$8E*)>}dwa;?fU?Z{Ax4TXX6C-|5=?_9koNmTa zZWZ%J>zs?8Ct*-Yj`%fsuFnD|@S9{|;4>ZMAx%)zEM^9+2^1IV!msWwa*h-( zp(YjNcN|$YcE*_f>G_e~Y(MUd5^Atx-W=!(6!i$Q3Aay$OIrK;_jIn5^!TJ-A997nj`u1TSZ+o((P&Q zy#4q#>5R%5aU^DRvto&JQ+H35!0tjVL1>RvOc?fqG0vzyfmHO#v9!fM0H{BZC|rp& zh5%ADPgiA2n48vX0}Y`i*$TeFk5A7Jy)();M|3w=J6UePEZ!>C#a0nfqba|v+f1Z| z^}payy#Ig$km|aPj%$m_7Z%fCp+rfS%mLkx z#8CQ_^auNbQ+HuZYSMRLFg7>Jz%Y5PJcd{%g1VKfaHv#iSi1LT<4(L{b5wMHIW|v^ z7@7hDv*5+;B2pGK)Eoe(@FaKhrOsb^w(9G{^R% zd|?MNqrBM6^d*k-D_LzqfvMU=gY=eQjFk>u3>!RtDr0jJJAvM0k_oSDNZ)>)8Y;C<(6Fl*@ zY%%!G;W#^M9K)v<5**9AMLs+9<}-mXWcy6#Vv)7xEB`{e+?#1@L%N@~{e2!B zhrkW3?k{7yot=KM6gC=qo%zE?^?y-K1zPY1D$|9lIZ*3FP~Imzj-*6 zq~YzENrWtfQ_0nNbhO%F`q^I4t#U0x5d2;H?EEcHVtkiFx`?&zAtFbUsa&uDqP% z6E5|Utl7h}km{IxR9j?PUe&WJKK>DrwN!N5TE}?P`Oh>I1^-(1~x}{%JLM20`u`O4NI$SND{=3gs`yf+qF6}!u)fn zwk0ask9>r8iqdOilISJHOKp&}7s}Zm@8gc}Uo2E93f3vk&IpCESeJM|l#+ z7nhqjO{gm5DQ$6RXH&3v0hWSQhXs!%O8CG2us~UMiM88a+|dG1AS_9IgB@QjASfLJ zO$ipUB7%HNwSUp+5pnDt~@HDn_S zTHYb1>S;kW3}mY0KPC00Sd!c$`W z4rcTvRGWhSrn0Pssc|xAggQZFQ!vO%dFC-X>=v27BwDici1TKGwoVhkVLpfs$2wHJ z<5tO1GL^huewc@tFcjL?kdd?Ln>Rl4-+5&uGhjtd7qVHVeNs10`Zj#eq;PgmyH+6a zf#HZv1%~C0%yQokzh_w%bKb7o| z(iKVOa0U?s_?F%BukKBU{(;BgEUdT-Fa9eYmr(eM$L(}TtCmuEa$7rD#M)8OoXc%) z&5X7Z-`RHbtUU?#_4nGG^o53AO&BSQ*~bnO4Yoj64WXy|-dX59%@%sMlk>k#Zn}3J z@kq1=lFzLmud0iuRtLhFzZ12z5>4~M_~3`(w}$b!4fg^!h*UkwR3O>2X|&E*(K}z$ zn}CX`i<@<}JJMB5Xbo*wU^JHuy1I8rT%E*PxmRRnj`qd-=ADHDNn8yLq);W} z=D<}wG;8L{)PeuCRxV+WF@f6rT*-*H0$WF~grI}~TtG)?!T6h%UV5%F@R*RApCDyx zjn2aQb`(p)O>v9P@FqYSsTz}+Y=g0J+b5CTJ&9kE$0(KnKWYJOBBK;4N!CKBbfLw! zU3JXSJ9Y<+$(#3MAUl9IkZ*ZU7NdgDRQlNXn`PS=hNCu-I%Ge!go;oVY_xJ@$pRd1 zXT-hQodV0cl|eg-j4ESKXPHu zVqN86&a@)%wRLxJ{U1HZ>}jTDT=kt`AyN)TYfiWGMu>R^b<2A{0oT3ExIir5&M=nf zt91jDP!MvqL8L8e@Ib0L4r#I5jWu)yP)e!Wjw8;K2iKlgJ-HZITk#1iQda$KF8VlbKPc_r~ zUZR_VD}TE~nw<;CO`+l18hKX-nk>x2cDKaGkpYmn&(|vE9BzEVdjQ|XC;S8K>!-C6 z+X>LZvpI*YOG3FP%ek;sjqR_}nX0ARI^SmHa?_=5iQ7m1-m(sLD>b;XaOalyPE%KF z!nw$^@0HGyq&t_M9THa`vJr|26_UDfU``?=`Ht)q`Imk2VRkH&I1kVhXA z_+)Jz*F0GEf~IuTJgfPt4-Q|%$jO9!5MW<_ex5nHCHpBeTKYM=315;-+@i7_i^A5` z?yUyjUPYgXyQ0+z=c2h);N4+uTJf9_#fX)}v$$cW6$-)^sKv70tr1MxM;o+j?$-LmL6`9A zZJP(ina+#?+OjAa2Yu9`hM3=3gH?Z{sg~Zc!+(K*ei46+T^%5!u8W?_Yz%()!rX^d z0a8PHY1|XVDr5KP8u=tQ{pORd&vq3^Qe|_;#F*Nk%!L-*(4$K7QQA{=fJDA?8`ZqP z-@!U}*Pp?9gFi6GW>v1`(N?t5)NT$)<1c!raBg)OYP`Mt7gt0OS|!q8>o-@#@z=Y5 z#ORW5!B(Q(cj?qMZsvK+bQ0Z)SD~bFkDu@)PIg`x`ERa(jC{<{S4Ms<7_l+~t@~6X z!9e`KQS#wlDEa&nt21p%t4f#b)35R3O--8g8f%v{FkV!rKM#QO-qz5a)^>^l6f~Ue zS!wJZr8Q@1Jt^kjDKuxPmd_szNmTE~x#gfEJRC<;%e2&%HI|GYbEDmi?6`ia!Zjtm zvbh=D$ur{b&npju%TRLPC>(*X8HcYU15n7ArZvt?yigVpZ29NOtroWRpAN)p@4t?+ z_FIdSd*{!z{DZK7CyuHMt%WpNZxuSlL=-M#GCk2mKbDF({JrWE=-s)>67x1;M(gS^ z{>>QAbvXm}*w%9vYoo3-a{UV}Uk^yjPetSB9_PK~MH~!cd5-hiCgzB6#Ez9U{F9o` z6th4Hy15SNuMqh$q5;L4F#~&tdTxQF_?LYr*zOL&BKPYp88jN17&R5vIvQTO~gn78|WPMKoIVd2^CRN#xrk zbK#=_eI~kPdz^--(ahpHF)pXGYtWa>s!HPw^x%ay(%IuqbDO^G%{h3v#_O%1SkZTw zfoY~rT6aPixE$S9TWbU7q|YA-=A;R!Arr3WNdyaSOPj;}!gHBv&9njh6(zH=3 z{LOID330<`T}i64l_;j;lbpfDVvZBp3Mw4YggV!><06$$hByqCew4o{60Or&tcstc z(Hi>#OGshM2wOs-nSp~h; z`>FB@Z!O#T5}Z)gXk*fnNK%%u8Twrq_(B30Ld%ENKMpMX)g{?jKzMUI!iG}0|1%TM=U%?e}Q zEq#-IgONq+UYLF)cIcc&sp<2D{V0%&)q(0Q_#|?3*hTe^z_g>VJem$n5vHulVP6_E zYDx~zs5;wdE64C?OF~>&amq>BwQ3vuI85qGFI;blONm>Sf0ALj3KxDUsP-Th4NK*L zWGyZospVFlEb#);?R;$6By|0fp2I_Ae(6|qr2C=IFrxfwlTAK}+LZ4&DU#E;w| z8>6cBw6@Yn za9W8!QA77qp>8VLGf8muobU3HVw{_!I=F%m1OVU6^ov&I%cWg=aQ6xo?m-is^Mj(R z1L{gk%swErRGHWJ^6-&Bl;VQdqC-2Ca*sXnxK$TyIe7TdCB2}g{k-%u{aa7Gi5?*( z5m6DJ(9T?P=W(xxuP_mQtSXFj&E^(~ofyva@Sw4t08=JKML=HDz4(A zAC;ZSkSA#Nk6%R<2$-CxQFc3T0ik*N-p%QanL*tLcF8;blqm&i@7J_yw@us>je5&E z!}5py4c_NdYMp-ON8M%-25!RRSNFqddSPeBB<-LdfE&D|P&9}wDXqy`^BtwI1;y(F zL^d$8jvFRtVQNh2Kh@?5-N}BwM75Q8Hm7GynCz`&XC}L9TZ&(-u(E30k70##z0*?C z2+g{5%}{O8(LY%p5FZ*V*IG(-XC?ulm8$O)6s;D@mMPwA^B>Sxx6h$rmLw&87qxYEL}5d=6QY+Dh3Jo(TIpa@J? zMDMeeFo4WnCD+#Y_RH-fS6->*wN(JZEXKPSgI@Hzd|T$p-Id5k&NYKmFQToj&3fl8 z85h$OvlR(8cm*)LF?76B`V(*8JG)`ynSQBNR6=-}74a_Cz;(G>I!sfY&EP1l|5|6b8ta09{`?#Xh*|B-KuP_&V!WwEvwn z6rs=rk^V?(`cJBa=pxCd55Mz)AC!sdvV#A2qQrS=>U;W}28)$8P_Z1<_WEN8#LnXQL9SdE{hfY{HU;cb=Q!?#;H+*xXjt8OczFK>u^gsuSY3)*nZm|r- ztI)w0e;2KF`jXIk6nE}2qQI!ZRu&}`US-J}Hg!VjY`Q*g@BA^pe(UG48tAh%$SGoy z9=LPR&DPlF6(8Xa0!>mXGqg-U(dV{Nhr^4?Q+gK(W_GrGPSGGhm%lfOKNgK>7w)V} zUlNL>io*im_BWX0X{cRe!JqAwZhTdy%>i_^=L$nPUU;cUhW6ApgWBQ5l^u=RvQ868 z(X*E=?FfCxM;iOQoBZR)56c+T7x86*iYu&oPa`Qd3~-2rgBY7&*RJgC zlOUsN=be|J{EVFs)0hP*z3XGHvlbJ^S>+cLQz^Ku3?S1WO5i1sf@~nu6*-kTpi1EK z2#!1jUj&yCXK41F;33p{>VxP%f#`q04V}3 zfcg)Gg#aH5Pv(_Vvf45HU17JS2|6U=FZsyKK~i)f%EDo49Zv?NUeAULgOwM=GbZBV zTW8NM;;;+)U8_+feb13kX#%;u%{-N`O*}m~4>NmCN_b@X-UQ4~S|7)}iTTQ@0u^f79$8~cvcb%7;mq+Sph&X(gAnefkWL^qO+*>9^DT<6c>e<(6hjFwc zbq>)|Ptta7cm9buoSTdv!!~BBK2!N>2qY3yZ+G?K1UUMQ%9d0S?zljz1n2cCotF*1{3<=K@44=3yEh#heHjB4Blw`W%dz)|l;&T__t z;D>3@W`Q>8FSIexjrT1ao&{K{0>{s+7t6;PJj)a^6QQ#$I?b0qVofl6)8VYtkRx^D z3eD&2+SH5n-2tnXRAKBPqM>{~H`?wXsy6^W56@GJO2A2HC+;Oc6tH?^{0)Q!JQ3Sx z6YAykGH5!7Kun;}i%3HQ-#eD5q8EW5K$M`>k-cVOyh+6VuY<+}ijl=9Y27OcTTldq z#IILOWQ>4Vkc^E(F^n$rtB{Nb`dj1<9}p-AMj{_jQ20027ogw48S!hzud#i+L7qWx zq1U`aK>#4XIKY0H>Vq5O+P5o7PhH{;JHN94dCZ-km}PdK1$bQsp#k=#4H!2xLmZrr{as)1em5eTR3 z9g9A0U@gFc6RQ(z$0CSLPCQ01%v6;@Yj!-w=Qkb1735j3%r=mffWOn&Y#@P%8c+{LB-jIj0j~6oeq#|*v24tA z5~;2w#T4^?;E=-s8&V#j5V1o8sIc+~K&l0fAX}`Lfn}yOv?GsN`MW2PJ<7j(^Rf8N zt^3Wa>(K0s|N5Fe18n0(*I(QCpM_;Pnw=L7-GV0ucMz>bU^4){2@Siwi!kSC7ztEM z!N-s4eb~gOG7E(977rnM5WaSPjwCoa{z3ig7~S$vGk;ORHZt|uweQkiMhM`4d28)# zdTv_cH!l$S(Yah_41ZS4JIXJ8)$zoMXSK}dZ`Fjbvxi$84B&cuL96f}do77+s>$7- z=S9KQ`uJWNA=NgqP|Vg+y&+bxlyC^Sw07x9rQ6p2ZF!xeL)TZ}+D^=TW4uYj1+915 zs3ObWeAj>rdu{0RrH8>iEnZFYMR~(459h#hnuf$(Uvs0-rI`jiAwdh*y;Qv|;gSWm z;zE*3yEAZxj$rZDG1d}Gab<^FziwP zuq&JMy}!pYedrnOSI(ysK*|GzLI(t09mtBa1*q0C;O6wIvpDa$-A1SVJwW#ou&nX( z&5Oc-a$3KhUWULByq<|*7I&4)JEdMX(Jbx?*$!@fsrRoWO5l@!Bf2dZE;Mt|^2GAQ zYUT38dM2+re4LO3Dgr@7;_^uZ;s^yyMA4pfdJatT?bHu(-xr0`69fSVd(pu)c0xUF zkVzq2tBe<|BYaW6`ZuRYK3=3gUYJl7Wea<=M?T|(D4gyQ&?dJnbK#)KCR)GYxz~#zj4v z1qU$hKizl6J6{aF*G+l~pw9s*BAy0SgL|=Y`oNS}@PYuKRYaIN0_xrYMqb9dryqZ) z7qB;Zgx^yCjiUc8_5WrbUoB_WgL#jKM1*E?DzTAISwUTv810%@C5ixm=?H4}4%xCg zhxFXVyM+|;h;jWy0>#L??|Fo9Vskl74RSfnAk6c?frlC~dQt!>$*YX&S9>JE>d~?E z^EYM22UFYq<7v32&4phKienee$II5cix`n9g4SS-Fk2Ka1#5lxc}8f%baIQ;ex3e} zPg0bPQ=?@03A#arzAT6=)9zrNSBk&@c#l_F~zuXgBG7}{|HA#=*qznL?9Eo-u;C`+5EbKI~xH^!lUO{D9Iwkxu-K@ z9HS)OzUkf8J>k}dd)u4*q!TB=lKB*Cqsws2yf>IcE={|crkBl4X;)X=;fQeDxd#;-$s@^{sD&neg`+`!B?Ow^s+q)}Apmk826C_dwxh`+U$-7y%v)sb@M)#9c!flzNt%{E zrvyodu#{I}GDxZIc!y{`Zf>8uZ*N+)9NgIJvcdSV=3+Adz&{7_{4m^^GCO#C;GL3-P{9~J< zdN5!zCh8wa+Ym3+Kv%>xJ!__c8fWGNJpHM0&;=gCnRX5+(7!bflv)hy>4uz~<3Xg#_YwFv;Of+1w$ND5T4$=82r!Y@&c^~%0P`@1(mw%<|* zekrzpN*(M!I;4O2ssYFO7#GBR*5-Brk)Tc43jsKlJ`X)&kB_}IxLUiyCBxz2eVb2b z;pjEpU)xDVBcF&Lc)TSdoSh9Y9GpYgu1hD63OFTX0lY@{jo&i?@7+A=n_2EyR5}kc zdJ3CW8GeOZ=Cn%)jh-|)H>cUC{zK!y8XNtOjJxr^xyBcGQo56eR@0?zDGJ1->Ua@< zhZ^A=Qp#zlrPY$moIx@Pn$bA+Dqe5k`lhdQ010UY?b{|sIa(BK99W$-XgS3A7(1Jh+QXQs0%?hV0Te9X-BS4Z(>%)fY^1;(NVV6`P` z8(5V#WjZ)Y&AjM*IsZ4>@{4lq4AN|_W(u08XJ#oK>2tcEtN+~jn(nJ%O=;0dOw-0Yp3 z-{n#V6bRwy&E#M&vygtOnm>7_Y4rsOi-f2%Y`7(*_~p3Ls6 zIXXIk$BmnzN-TiI6m@bc;zjOFeP+0w(35V4OR>@ukO_WrymCyi56FII*a!HjfPR>h z=z=N*GsCFJ7AgkOfFz?PhJcRclHW-zmr#12jT-+N8ST)Z#t9aK0Qda>?)-5FtNdjd z)`><9XkQY6_9asp+d-3EAh09H~;-;&4<1v5t!aGz|M*ZY5ctP)l} zm&1xQ5d2EZBJr5Luk|UO1$bV4uZ_5GF93wzw`VE~qOwOBSbAP{%~LN9Iar}gUnmSJDyw?m`!=D*A5wZ$v%zvZL8)kh-! zqzn9p**Ndh5$aa{=|@Jlo)@CN)fCq`Y+CkWuht0lj{poR#xm@C!W19tk) zj$gX|@A7#S+3Sx%4-V|?FZmc*lh~uZ-v8_42D$*itkQ)EX0 zWe&_5n{f0VoCa7OwXJyasREpB%CBIryz3jE%e%2LcD5e1#y_)3$9JCJ>T)};2HMw` zel;dWe4kU{mM9(zOQ_*l73_Z2nW5 z_PKtg#)%l90Pyr>DhS{9c04n0+=7>*HeNXs#4Q>z+e3ycqXeWU@fozY)mJ3WBm#Q_RXKVBWo#B$&dQ}6fZ5Lq68EfH6E-^ohe#P4SW ziJ1+fEO{JL@Ya)9^2^2>bn#41upX3m;exhCo3(v9Y|7NeAlbluVzJqPI|`{Wr>fS; zd=FkxP-$o*w3?^<1|6bK=MbaQy3y%poR7o zIf60)T2$q^4oYA;7T-dbzMjF)FVrW^6F;gbbIPJ9D6=3tS{Bk>`9$A4fG)N525 z7Qa~znFaf`B6y=>R;i>pphu2tnnsQ)|4}CcH4o%IStoMQ*qu+ z8t+2{qk;;LseRH2x#`H91+$KrRQ6h~L55+qEl=)-q?tkDaW&@`m!xAEO#%0d7ESH) zAED9Qtg^-xT~#+-#iLej@wWxh{Md8-MItERv!zh|0|Ha!<8c9EFf z{Sb9j} znym-@@+L~C1V*W3W7(9lLr0B*vj5SKtd!o&^QpPq?I3DkR=;%;RNxytlFvbpiHK2O ztqb{cenc3`mKssVMt`bh4K>-=v64o{;*m&1&!4Dm`9;>H9*i|jsA3Wgi@;tllFP)$ zQeFsd5+HO^Nif{M$$KAgY15a(s~)8jfqfznaq)Sd`RR>ZI+o44QHFGDzlcO2`Q(Q^ znq9Wc6@t6nqWG`+2qi)vwjw&=6{wi*ET+NzGPG4uyx32N|{ zn`;gMXh^>w8TnFmNUIr1+Le?-T~Y20+{m8|Tg2x0<8EFzxK#nSYSk!bYv$9g?3Sa~ zO3rBTf#OZ0(kRB&R#4OEhgJEjB?VT3G!tRSb#yEE;O7q?S9DtzLw5le0;ApC(amX( zEF_wSdzws7sjfPD*-5;vr`{NRcMmta0OiMDr?HR4-LrK%v$wn4sfYoIYvXo9!abuZ z%u<}=JBlb-IdJqz#>z5c6y81NgVfUwBFcV^8oZo)+)}~`rDa=C{d>j`n*5s|E3aW> zeAB;Mh%DbIk+G|)8xB%S41V=WUmm4oqtaRVio1{E`7-78-vsdq!8heB6R;WGZ0l-- zaF3eRYLtF?dirHkJe~a1+`PEpc|rX|_v~4Ibw7PzI;uHJu!d$PJXJk2Fq&+$gm(9+ zO?PYG{nauGCZOR*+z)ny=wiDvBM6)z0|T1LU!uTDMIEUVc`r|lT& zDW)VwhsRGmw?fBNVVfflsN>(%Y5W_2aJSuAb?}Q;U&A5 zQMXd_WpMmqQkpSd>%Y_mVE}VM=?DeUf!Helf(3d3`>!`S&(<8Eem^2#u5lNM5Wr(3 z@|KeCXYr;MwR$mJc+A#93zo3~a>y?nO?>Qv+W1nP5>Z+BR`N~!jp_$a7Iw5T6;VGp3-6U*h2=%mY*>%D;X-<@DS@+`o-h_)Wl+L^Bk;Sx;?ZD7c)e`~M zJ=qY}Dd3W8uEhH=9sXG&;NqjQsk?q2)9j29KVj z9Cnf(D~2ojnD*9q9c9%IlZx?EH&^RNH+)LV%mGN0ix{*bh>hdRHy0z{xI?rh&3=IZmQ(qVggyJFw-e^MYZv zXS-ZQkv4+&fy%8WI8*M|l=WM!re4yWuSj)nCmap zBqGBDD8nR|L;JCK>qGbod5Kc9bd$&^Vj^!*)(KfiN(#{m_I0i=t8PFQjx{_-=D+8f z>M&Sd93E(CK4N}?M7ab{3ZqF9$s=>2L(o9~)Eu`@u~gvGh%ywm<_d04nuuGO4K%Gw zZ+67=VAnU_lkfmNnE6hBFnaVChw#N6WaKb-bq8NO(U3p=(BGb@o;Acv6R*`7Is9H) z)th^5m8raADTo(;YwxwI&)nU?&^ux;kDLi5;U>`ap{x(dV9d?7;}T3K!)J5c6kbfM z`?4#g(af8s>Wn?xQ=W4RoQ&`Oa4dY#D%uTkvlCg~&9y0TJAQ`~AzPj0_c??;Km?FW zb;7W6)N=v2%h+Al?_ZXE#*Qkjyev)h8sTMN9G6b#y=@Oke|&nl08o25xJW)vuUl%C zTRz(3%R&oFpPKEJWNnzzB$20@*%tIu#BC84)w3Xb3Wn7a8$ep4~$b*%McGiXm_F<;B4m*kF^&tX{$Ggdm? zMLYDFWzm@uO(o0d$9IPN44-ep`g&1l?Z~rnBzHpFJ+?d0wTDKDgpYU61d>(-nn`^; z21X$}eS|d}vF9dnuEJUzc_$-H5nlI_PHnZ$&-K7S>)<5mWU}1*xzYZ~ey1-~0N`=A zv76CW=lq;-=n-0;;+5eQR-QuOdRE>-FNY1FXl-l<6g4*BU-sc&boD!*-d`2ZN-h5| zH?&s>P+ZOwn4QjO6bTL`A-iM8AfD9a{}F0f_$K%`nIm&O9FU4esj)>9iB(dE;`3#K zq~*!MOeJzD#(hiXt|U{-TohWY&QrG^rFG&0Z&b?pT}oMk>BF5wrLtMY2fcuVenk5P zBJpBr!wo=`=5Q=U<7MYwPerhKp<)b=RNC*0QYcH^4P zkt^@^+oOB|fCFG<`C+%mrk@O8eEdx-$+}OlT90fCxtUc6z&+ zOULSO+vvx_MM*SC_@*wlC0}sc*}CY}mK?0xgrbkW9=eM>F?j}WwXb+fbLxR_^I(XX zK{_I@lS%OvsJz*1BS8YbP*DngnQ6Uzxa;#*@ne1kCdTfcHs#kVc#p}@k#xIlH;F;c zd8HmMDmdGHytehV^@Q4-clu(C$04+nvh zGx^JYZH?c}sTHa`tKAuCSlTIY?=sqs$yU8&z1_~-$qqtfUCu5TqEd$%V4c2)xnWBV zk&pN_#wD=ee(&bpdol@29Mxmqug%{V>?dlk5x!lB%X#nAVP03{H|;ZsG>9ddT!oKc zZ@fMnQ$N6K>^92Ws>LJn{T*dM6IO9H;gR8`u)_^?YpB{f5&@t&*n4|QDdH(E2+-

    H+LHPmHHZT{JNlJ+DodpeW|{0&4AuEC}YeQt`nu-F0Wm8zThQIm&Z5C}R3BCH^ z?WLJV&cLG4USv&xf=6J{Ir%SqN#cSS>kF$A zEDPVUA?4}1^dBgCHLy@sm8EXAXE2jiE#(<4=X0kj=pz;~_d4^_XklYYyOHEBSPIAd zyy4mU>RLSH7O{j6wIFfAXx}~GUzfl!YiGZat!;cQmX^6{>THkOQ*u|m7NuU()3^M= zW82!Yf2y*E+(wXNbsM`Z=lZUHQE*Q^vDdnI$%TUBR$wS_rr~4}Kvmwn-&)nV76gw9Ds2jUk8WC$6Xf+f{QD91#;*Srrn3QSGSa+!kH>@9c z?}Up|YCaE;rW;1nQ|6|3z!cSE5nqgD|)WSE#9LSdFI4(%=E`-QTD)V7DR?6+apy8 z5;nWQLLI;$sPv?sQtHAY_>WYe6V(&d&!~9A)%sK#5v?++RSfDS=u-(=3t2x^I3@Wn z@Y0V}F~hp~9a5%p*_ooYBITK&Pp;m{2~Km~oi+Jz9|Q{8f)^s>UL%D~l_gYRVP*5Q z+$gti${Rayp5@r^v_+F*?h}`I0*@zgjfXxD@j`aB$9L6rqQ*0lA(J@mL{iY|+_-6Z zXgWfK1qCXI&jQ&v6OR2W)SCd!(%Ev|QQ)}%P_ujsa6z4ko1(|vx&R>)(HK4}_WM zZ9l;0vF|EkYj0}oWJvW}$`N%nw&Wehl@(dKYjJbnUx&q)g7z*wQtkdMb$@t%yd6of zsB;oMAL$q6qv9ebp&p>d+c@x%Bbqj^PfN{6C8RENr2GYgvw*wK5N5JD2zZ*}mjaBW zqf8GD9xQ=HJ6>=xf~(uK?21ZM?tgQmmt&SG?mlYO!6O3c2YX-aOig%0nGf3-^LvVztY$^mT}&Ho}t1_gdEUmx(t$M zU{&=YgN#O(P$c@`8wvhWC_yVh174^JGQva&h&=nK`7HS@b?JeAfF+}fhu^}5f^)(? zWTu7eqw6cB6OM-6Q_K|iC4h8u_|mY6mR*l8ULgigc^Qm-{1wTP71vYtL@fHGA*5%T zj2HV&_2k9F(;E^JZ4D9j)uE8^(;)HTNGLbT^+FCj+N((BNal_L@Eje~9D410@sJ>J zH-ukonX^7R;MCABg$mgUSsxwYSaifsBDly_g0a5`q5nIoF-tY(NSn8`bH){ezcsZE zv$ZZJG8jY`%me0>T(ELlsFIun!i-%4R{}S4-p51}bU2=9zUo1dp;rYn@;0wh43$?<|Ba75EYdP3!#)pnp*$E|jhQ5Fj(FUKnz>o?9REZ?-mUEwQ}&qe^uwHsr_{Epmq7tFZL5r5Da>4tI@8o z`^lA%OGca<9ELI0#j6RFSkK>t?c7Siy0GDCQg+ zqEyyKKl_;P>GIZmN38lx$l~Gy_g9YOusZR!4`e88RPx5Pi;V|n^o1tJdNq7JgX;jAzrkZ*m zbvV1KR{;lNtWIzd%nGiCPX6mlA=J^&5e#^N|E7VMJLK0pdHhJtNX@sjncO~gJ<+}% z-Flt$U$g`AKpG(%YaPurhYdFBzw#k6Y6{AO2!Re^GA{OeJ`By~Po$xw^!iqqUR}VWk#uiK-jNnwxy>M%Jvl5iqIX?o z?R@iL31!2MF(}@dZCC}{YjArFfAcbSsTTS&=-c2%%lBFQ0l~xpRmVAwrpB(sL7~9# z0?8JYnCZe;CE#p>lr7@oR&6WdE_8b%a%s%3(eRb%4f!P%TL{;^R#BB9ijL!U^N+D` zg&$)=%Frp%yi007ZwgvU8x9~Wa0%i~SwXw<6N;MSl-Xz-XFwjjN~ zO(d~7s1tTPJQUnHf)n-@<`yOt+y}TD859E1|A(({46dx})=ko}jgGC3?R3ZNBptJ3 z+wR!5ZQHhO+qUmczwi0(J?B*2npG=ft>nkbuG(YFF&@mEsH#7qV0z!U#ruC4`Qntj zTZ@#q3}g)-TGfkeIJl9De4AwumF@N(@b-MB6qk3F6eLJ^2xh-cr6OB1Y5Fc|n_P9l z*`D3Y6Z->OD6udD#`kOmWeG_#<1H$@q_%}WMDN05p((3@v__qqEId~vaT0A>M^2=_ zX?Ju8|MIeS#E26kRlc>)nAIXcUL8sbcE&ya2UJL$!}9Y|%E8+4Gw?4Y;*~-d=;=TT zVV%bFA1ZzlteL@fg(wusVyPoc)zQ(ChSOyhhEVtMLB#PLR=8d8d51OMyPz_1?+y>c zQKlS%ERDLL3eI|riS&hI0-o#IZo*G*D?Zj=3=Qu)00`A6+Khi!))m#+`u?&?9;$(;H#MQ;+FoWQG?mFSNchXf%e>#x6UPcMDqO~k?%_})x#!DEN8oty3 zf!L>K^BiunK3Q+)<7>Y`QBRV1A=xzePYrGjh7Ne-+$5=62RNp2@Nw|T%biG1qA=I8 zb!&Yc>reQ2DRPg0PvhB<40fAM5^C^+7(M8O)oZ&-#~|!0W;$--9@>p+iE(PZ*QA3- zG$d-|BJzJs4a7>>?q%C*IRd1u`&W%c(u0F1(7P=fom1U_q6h5n?~ zWhT8`Q@6g|D*g419wxTU?g%MM)2JNiv&2T`@<)RZLVO0FdoI9D)t}$UM4o}Kz*V35 z&94jOvr2!%8f1e-KV@9hSBCyfhHCyKXw!N85g+(ROh0=)nIjm@5&&ZkeJog@kuih&9cBVN@(Ee})NP ztuBfsiuHTeOO`H3Hi7nySg04c4gB`&q$z&|aNL*A;O`4o5Q;-Xux7&rvJ!|x|9!z9 zv8abE|LX-qG*`q11^>_iw}U<+XD%LRE+Qj=Fk|P!<-$#x@z<37F_4HfQ+nq|nydnz z1n0*FG6y;a`V8iw=AnShAyc~x!hk?Q5Ft~40JhxPm5A(MnQ#)|*Kj^yEwF&x0s_b? z^Yb9fas=XtB0s$tfrmjUP%6R}Sb{3s0EnCLMUN+jl zw@g9E)iF5K2Xn|-YCNp{_-(MLy>X~%`mwO2b+7ipoA|c3u;v-ie|fbf`?{i?DQtIf z*VS!V;jyOmcy_mL;qhIZ6|omIcv-XhCadk?nV@w-r~volW3J+b>BFk}zMA+sYNPPEe8eB%UCz;umHnasF zOS0>^v*WlA$el8CgLS!;fzBa00GR&hEvolQK`yYE;e1wPs%7>X)9LE+;YEJw*54Bx zNVjI@_cHh87_$eXg!&-?hIDPdDrojW2C_>#6mTF$YMJI<^1c0{>iVS@xU?N z?=Gp$fI=5|^k|yCdur?QAavcCbI=QG>+0{US9w~P?e3st#l6`ofGae{1G%E`MPf|Y ziMN342m%i_v9jLL<-(R!WHiqNzSs4&imA_Ac!Tdn-p;=izkM~z&Z3YL-JeQ}K?+du z`v!KbsI8u^+|MDcIWPrBT9OS)U_16~F2k)|#Lt(%xCr*^TWijo!0Qu8?v-$!Vy~%d zkM3m(h{%&_mJA)six&g34umWbciR$sb{oN~E73C}K8^YhL|^3R*spy!?z9R^Rge|U z*BUxT0~9)knZ-U*Vt5wcc3kDx6<6&+SPGfI#O*pn&dWiukopCXobV?e3xEhUe~>~_ zj-z2SNJC_?zH?2~e~z2m#b@Nh5(N-`hSL_GqxQc?)_To#(_&zv{;vBo*Ujl{qf8TW zj2lgSY|Bv|WeSP-@TPOZayl}1HnM6Qkqbsb(~0Qs9Y6>xfO!Hc1_Lwz+yE2_1rD>x z{%7Z(?Qvu+Pz$UCxDVJ9cnj>`R?aWi?1!JRf1fOu|-E*E!k~6@vJZ+$=M08 z?)$T#Q^@>@chLcbWsOXsYR8U~)yb7;6;?gllKShC2=JGsX#)7EC4C;m3kzzv)sOv~C(uh`L`0oI@+ojA+-=EVic6I|nMlHQ;wVS>(_glA`Z_Bo)>R5OfMUrEGGNGegI2|+?4-7XewqV(!wjG{^ywcd+{b$h4UoB zdoH`@C}>}p%wF$JF2=elI1ESr3qD(>Rx(HPi9jReY^T5JU7KI`u-n=Vz#=|2bv^JL z8V68aqUp-gJRMUyG(J+FF6_42oAO(jr4zHDnOCIG$=;-3nA8*-cE@9}3T zYd$J3=ccb93w-WtV(o9K@3-3zgS6C~TYVE9f~;$~gbP}ZpJO~#$0S|4>jD);yuDHX zBl@2%p3`(=`e2HTE$k7nMn7UZ*rOybBN_8YmtFiPz}@7CPx6BH`z6RcgF5<98o8F; zpAzDA@(gQL%jUb@J#HK3OvXOe*48}JJR&?dc${iFUQV4)2ssHR{R<7t;xbq^8p*Gp zTzQ2$4YdlVgU(e}!x*A6I#R}}D)Xtn;UyYHh=ZkjAjWAbd0|kVfqh8={$8vIS2EXb#hrT@7lPlxH)E(>s)E(2NF8aWzk5ek?~ z@xzWQ>6Z>wy7-U_!2~uM&09u~R^t*{qYnn`cwns3#Z$B&Hjn^essa&=GxjzLsor)l zQfPG4pMel-BVshH$-l!su`*dfngv$jD$U=8w6+ezhlU((iqVJ_=LXk!aU@&noV_w^ zG{&nznvvy=Dx^NmDkEkjk@7+O0aBy&x546Oyrvv#441WaL6hQ&`)Ls>=KX+6m@1*Y%kUiKzBDwZCpSAQx89`j8h?JbQ+kFUF1Fk^ex(v1VQnpb^&2O)sW zUGBd0iC2!V7?KAxJzNJ8d^t|HAmP)-vKez@KX%DHf08%OAJg-+AB4K0 zHG2FKqtkp%9ol~u8B9D}RE3xtFu(=B)f7O}3oRm9LNFa^`Nff6%3f~LHUBJBxCsCz zit>z>aPg*u@xxMX8L;yvoL>cU)a3A}jK3jVsfbmuVViqM^f!++wbJ;-hX`LB(VZ9^ zxsb?)DDXN=DM?Zp^!Js+2nCJ&xO(@9V?XPyu~!*xU~+Y78hzVkS;+B2z01w=q(F~Z zo;S9MKo(NiC9RCN9ZE$}`)UeZQ`$F}5s@+*yarEcUDEV*dp4Lj-?Ch;LnkjQhJ?PS zZP+<>h+J#^Ha22F-op#S@0L_PLbXoNLL^AAwJSk(v zG|fB8B&dg+4qs`&wS!`%0a?}6E3r;s-|5Hz<5Ma}t~gu^e(8`vzu1kb%KFTL63wuB`t*?@ds<1IXmC*`RP4WheJe835&p`)dMTlOn5KgJ z!i|DJ=d>UR*&4H%SA$H*(snmoesn9#A`Wk!@QZ@)rbNdQBDwxf)`XR&E+pGf^WY0; z9MtQblLa!rUZ1rjB6b1NDDmN#@p-68LAqtXShjoK(Oz}uh!)-{b{Ob*zW8SA6I*ig z1KkN)gsRB?06LA`ZUF%w*b`-CV^oIsE9$>@gFnA}Kwtl=XX7JPLJMU{@cDX9yz`hr zw7Co|I1zqJ)UqF&oMi{FUGPAbo4nYlweCP@XZUE5c-hK-5~6)dk-#0w)7dzzmD}$Y z`&Mi$YXuVVRo+Wj7F#Gt$x1{33~7It;^X&PpiNWbala6ZgpfE}>6}DSGLvn+ zm6?)ieTf~PQ-vqc`U#8mb9v$qM5E+hBm)P$kn;!rnQ+-rhBT7+I8W7PAy~!Cr*Jvm zvca*xR3=Htz2TOlZltGgjeBPbi>zb9w>np~pgB0I^L`_s_Ro&($?|+sY2EiI2x)pw|wyv5!Q_*2Z|hxs2FUh1>q0 zw0-eR0ni#OR%;2vSs5?xE_sBQlK2v!=%B}V`R}7{$$W;l1MTyX5$sH82|U<*gFDEkgMqk@zz^nTNJMch*rMMwE$?V?&lxp%WE1Rv1r_ zx(OfgT>0E&lUOn1qKl-{M|1F@X3W?bFK??sBzX_4m#@?xM6qirD?jC23M6}_*2tQx z_x4T98fKBZn4#P>db>Ars24HEBLjiQR*lIf$cB4AI>7~sX#)gvudAjc;oYMfY(=ig zT8fF=4!-cfTqLzwVwGX|1yhqY^a|KAm8enckO(!NMk#DD>1b8SWZ4iBz zA=_0R-{RS}W=BJm=a&Kg4hH%+$iM_;`MNDFdE-#u8K5Xs)MNp5@WXDNC9~>JKgd@Y zqi_G;-~l2Gj1gw=8$r#VQIP)?EVJt=mWfxN_!#JirILdX>(7)N?bvdBcsYBpHanqdWtmh}3z{#UEXKo)MvD2a@NRmBC>^m| zRv3JuI(4><(bPxdUzt~}C;W%5ZD`eMv|Y%?#iiqf#aLS!_g5xf9U)KuQp2F@GXzR` zYuO@Al&L1WU5Z2fRI3#0O=4YQ-B0cL{RNB0l0$e=@wL*^3+c2a`-%$m!=RH@#XI!S zLZUA&`eaOHdibOm9%4$1WUiheBqRRiC8hMW@`P%=5M(v1NjWiC&S@QUxl~Ie@;z1* z`=zEz#R(uWG)5#lf>+YTcU00LsuW&RB<*#I0X^Kra{Dy%Ip^8ZFSYN_EP1Svd)@_+ z8Ic(g4zd7JkS}1!_gdalG&;$bWY7O|iHu#ChOPq-V zya!1ka_(nCBKuUZdmea{5)MFs@`nDj<6?(|rmCaVfXsSq$cS%>Wz%r7StGxLKj-fPd%4#H$nM_r+dibB~VaK1`U%BBgRH=ct`=%u3yw-GA79YFR!;u$`}^o=OWEj}jinc~4)>#u9G-kx zor=f~2l4m1nWo{QEth}aVxZq8%@o;vl|svSjw-pDUBs$;whp3d#y`XZa$VOb;{yPR zX6bqN+?ks!5d+?wn$L~j&P{tisHLp!?XY_YVQkfIN>80^L<~&3X5DXX$%wUFCx*#! zC^s?6jL8updBntwE_~>R;m>4d=N<#G)1hi*0hfflD)4Nnl?RR-i(|_Yy_f`?h3qPI z5<)5N_M^x-<+6$MhnVwoE&pyUD&gqd#edoXj>)wti-@n)FV}Q5LS_G>FCGBe;Ix*DO+DFl}SR_EIl;|X!i!JWjlAj z37=!nvEDy3=SE;>9g-gxZA)o*5_ccxriUM6GarTAaLclj;oc6YwKTEU zsLoc{aS#VGStaoLT2uiZ=MNkx=sm;!b{>4+H zF~MY1Hi%~&f!?r(;CieooA`V-Z`QdnglVuxdZOe4oLJLC^QqGH&XkHleASy8Q9*u^ zrTAPZA?)yT&+^SD5A41*#lHe-gC9@ZqP;#g<;(P0U1t;(HipaVw6B11#Xsh-OisQU z(X0Hfjs;rrHn)XYWF@C6TG5U`|DfD%QYBQHh|;Vb{aJ)Xd;0T#?j2GwpNSkZw&g@@iWb?PX{#b^o`c|g<%P&E z3%?%(xfb@`3ya{5d2f)tJW}HDxZF9vUawj_#*&}1HCCTm_#P3Ehr8t_ahS7dxG{HSuRd@-nC$@sW30B{fR)zUr~I9S5r`&&HQtg-Lv_L>kg?6Y@+R6UT96SW(g zQYpfy&||V`(fpCxoqJ-8Q|2;s=z6Ov5nTkC3UGV-tP{W7`^fFXzT%V2{ab#1dM~~?sZNJw% zrK3)jM4JksLbkUIUwwDMP)yWiaw-D!;2tklHT5dtbFUb{>t-Fz6be&>5`Ip%&wgh#$;le;y%QaFwx0g? zl1*;}syk@TJNR2 z*y@6}Kxdt>d={nH1S@jI;R`*5%&s#i6ZP4)<1M44ut|J(AjU(Y68-xYG$}12JJ2Mv zG1&rRtNR2aHwo!BRqH?E()i)dOSbQFV!Kl9y2jL#H^l-Ik=1-;?~7vFN^WwQodq zL4{Z@sN~=rCZW6FrJ+19u%osx3O_u~JIXcM5|He;IkZNbzN)=f-O%Pl_f>u^)Fi;l zg~YN%C*=*TWa?RnvN%3gErgs$*OvCb)!#Fp?lIvma3SnzUGDFRA6)>5o!UVnmW&#w zSqh9V$JupHcV1_nH%TSM(tJ&!G&*@Vt4tl78%}$+t^MolH&*ZBuLH!zgMT!Wh9s$% zM27sdrqSw$+V6;lsxy?HUp-L+2S>mzhNSIiQ}%KG>#}oRCSD0w42L^%7hV{#|V1>k}b+29$7{ z5=uP={;+<37Y#9k0uMRPs#)SS-*&9lxhi!6obE6>MM&_rf>p)=`g>@j7r9Xf=^=kC zy@n~&4Dj53&899F_zS~GDq~ktqR=zY;g`&bH(>W|s097{8paU{TiUoHPB@6*M*)Mw zrZNvw6nDH;6$FWl;F0mE6@-L@1Bsv2sgYW<=wN`OmoqPyO~5Q22!GpPzyx}0m#fh( z>Sp)02qHX4DG2%QUFsc`wEX}iO!}(<2@`)18 z)v|=tZ;u86n$u;(iH1~lW%vv#+__t^z;Eyz+UtF}(bv5wGTQ5F@I~y|sb7=vr{E(T zak_004lpVmBN7$A>&3RitsYzSyi72MpGMuk@e;yel@W6=Aqt2$VuerSxpM1_pBnuP zU49REEX`d=31jUb)m__HU*AuxiJw}jCh}>w)3&k5BTEeB{wA%QieO+ZZGhW5S|5lN z!Z!0yYoBY>9?f%@UzWnWRVcvqor|Z|A$T9xcT^D)3()g}&ON_T^?{Mul_um7VAd2Q zbm>TH+VWVFu;ANoRPv{h!@UVqp)vQF?}3bsuUA>Q4}GhapYoD=pcpcK*&ZeGCT3)n zNUx-I)5|mtfD?YvhcVBT!G!jUqe89EvTH)x-WoY-iWXL7m!q&QsX~;tB3B9q_@7|k zdzmQ%h-D1!wsF^GN+pMqAWW+zqwS-G^cwrwGAr|FCLsZumJ~rQ_4W=Df5_sM*-+Wm zgNf#pt}RyKw9{nFd;!LrKqngyV_q-Hc);* z8S#&JmolTBlin11rvX2|J_2oL^aW z)(P$Mx!iBPQPogLU`~Iezj&nkp-jKMw`Z!2=4%8m!fhqBkP^a|9WqoWzaB*w`sTOG zTQL}zfF|mul{p~+3`c=&1Thj4ND=Ln4O6@yT{s9rVr=6Ur_zbc7zJl~>eOKYks94g z{B}Eg#(L?ggWmNZK674kSD7Wm>9z_fdXN#*--6LLXqb9>>A$&NIEOFY-p^oYoVQPy zvr_HodWH=@CL$Jweh@45Te6x-ClU-~bg0bGx?-g47%)ilK(#p(GDyM}s|H0GC$E7k z+HOfoMkthaw>13Af|5ELX7SMw-p3xwa60k0ZT-IhWN=EzPY4(2G&OU%h8&QC(cPh^z~}3R~6Lm z^N04;UXKobta_k7K5_e*;nR{I9s+WDEvHdc}aH&_*Qzf@+i$-zmBvL*7&D>NOUK1`fmdgr9<+6UfkA~V3w12PB%e&F1@V})Uk%5)YoXw?DQHeShOSl`j zVoBrNt}JXIA~%hD7`hl#?`|iY%8Me^FO9YeIUoqE&C4IQyF?QB0eiHiBbpQhp=>#@F&914f(pe4k8B}q0Ub*n$RDAM8s!slkCjxJuYc(RxMeJM{lLR%>-yG_Fv@s=dXbxvtCEfDyIr zf24bm@6}XtX73t4U@|{d)84%~gOLRQ03&QFc>%pUXplINEYo){At=1VOKreB43yG> z=WOJNWI`A@q4*U=L=$jYIY&E_JXANaSS2$V>S%;e9FLt-U0gXmZ*lHV@ZMr|XvmMi z7ZodsjKazRX6&huz8OXx_Cu8oT8__2*&h6yu|NvxuTwG}vy8*MO_aI)%T-gu6H3hr z>hOm2Na3|bK_~Opdy@fXZkq%frq*Rw+e_2+`Zf&v=9lzfL7n|z@d&7WKjiEw<`X#sf&-2*$w^M)6Ke-%1Ax_ zFyA-`ZKiB*Q1z;{*->SV=kTW5R;1B7Fgh+*$9W`_wOHcHJ03 zOQ>4HV-?dq!!fq=Ah-8+2+<=|pu{&`lp1h(9(&HM^uopgYsP&QF8ZDb?2(_FqnOzP z|6mx63mI)kK_cL{(XiK0LI%=pKoTs<#lZ9$FrBke-H2rSBP1I)@uc8@-VM=F=FHpV zA*;wCV>z}^;n8i_Ox35LvY2`BdbBJ)xbV@Y&ooETE{Tjs8o=p{swrR+$VMDPl(^(> zuM}|Zh>^0XOQjjEK9&+Lk;C7u91wAnGYwE5uyuSxmp9k4EozvLr7(0t7)S zkg(L0WnSbsX444B&SbH_-iz)Q7fyKmsKMJpld^Bd3*l=XtHR_tgl%^GWiVarDDGmf z#=7LAI1#26vR17YCzfYI(FiMBk z3b}C=nygl2am}LQ^drH{LxjGiL2 z)f_Tx$l+H5czolOp(Fvna?aSO^!L>ZTe8Bq{8{wu3fg;*S2(|5Zc8wk&Q(+o)?h?} z6YY~tn@d`#2uWf+;|Tx=W1NJKwPSM`6t2TX-FCWrx^@7-VnklHOQ+0mwA9_&jhL}7 zmJ`l?0uawucL$q9SGrG)&iPTyf$*T8CWH0&Ag=mO!EaX-glovZU**i;rYm`Kpx+4| zeKrx6X+346l6^!GiO`I=+g0} z=GBK&@T@JaX$H;&EcH>=2(mxdeCjolElEGRQIO1UL2X(53PlMr0QhX)h;mYCS-Wcr zh(AD?Gtp?5#J9eZvMW-KXGO;Q)Z*msm6FnkOd-uk1|vvf7xl|-#fY0)`&VXH+vcMb zcDXm{koA!@G!s$*t!?R&>KI#bXh{-o(dhOpZr312@bYZg`7VQLG1S zyK`{=I4S9axMqp0;1RlJxhyj0&QSF)0PB%GZIi( z!S#cisXc4XN{gCEz|C+a;pz6R)51M^lJo76t%On!!@*5Vp{XfE&UdhR`3M|HuxavP z@x(2CBBi+LDo4PidxebXD&H{lDRQd$#zQesCL12{%S>A+{U!*_@vB2R9BfRg8{roL zggkml&|CKXR`3u`kV=m8m1w zDBV0}t2sqameao6F_1@liEyp4q{ZY5)+J7{#}Aje%NqG^=z|trIw-0@eB3b)s_8(o4HtP_uy!0&S9LXwq}Q%LW|VQ7tn4`AFVS zsn5R*8|I9JJso2@T!%K4o^GEcOAmyv=%oecUYph6Ge=N73a`As(iL7^lbE&|3G|U4 z?S^zgPzrYMWnL(h3v5qo(i{1MK3GYtT~5usfHi;?in? ze9MJ=%YWa3paRMK5z)OBKlM5u4KyYFG7?SmjaLlZKYhgqV{-&p3m!n30%}hsvCgAv z3p+@;LMjPm%BF>BeN~xgpAyA0FE*PIO_WMVFMcvJ4Rar749sROs>6G82X~wci7gj-cDhpfS~U*^3L9dsROd`VON0q z&;KOND<|htq;D}q7WCH81i{o7a_lNBr(DyAAjO@*#Bg|zW?d@ZnMD-!{ar%X%60S9 zs|I5Hox%Fvb|P&sjvE_zbpJQ2R)J<)UYjZ>OK~FB0Xe9Pz7R(7b{w+Qe6~eT!y>gr zWAH3yZa)DFcoBnI;@2C5V$ICszF!qCNbNFzhpWLU#d@M|6bek@{9$-i?*e9D=L$h& zl|Qr{E9#U#w&vd7X^;8LY~LWr{7VNUmB3otCsN+Y1EqYS`Dcyr*Y8ZKhI)bhR53n& zQ5kk-nR(R~iX%bi;})&z#r*21?hVAuZhj&i)Ft}%wMC9;*q~z0WDm_B!7-$cRbwmv zJJr28QAG(Rb#Svhrf}mrcZlb5y(8<$8XmcNqVKN*{y96oVFGkx4bT2FTsRR$-@eFop~bw5IdmoU3eB4!cUu!TufsNPBszg|b#dY#T3YbOJ=sG2{c<9V zQXqr?x_R0+qKn{cj6_5=Ho`*2dCDjvh)-BrE)?5I36X1YvKy$%@x2t}_wOi#JRr>C zBCD2QlOYk-VdNgkps*pLGsrWzyw)nJ$3e8H9FE93B(j-U z9UyS)ECk>-phCNw$Dg6puHQCc17dBHJY97um!oT2OdIR<@+Gj!?$*u*{=h<82ACL{i3Mno#1# zT{Nyp_#WxHEVF&2umHDx5?Ddp9{$L=eX@u{^iL32A%Nax8w3SBu{YnpFyho*ibQQ{ zg?n{#Z4oxLo{Wo)#!=7k9>M&6)Iuv}AKQ*6({(s(p=%f&-5gkTnKaf&%zHxC)+SqR zn*_6x=U=J7kqPsg#x}%(^ge_rGBz^5(LZ!7>{Mc_Jo0xRRW#AhXqylv0pL_U3xmY! zJHz3}ZgRUt=e~2h@AD@*n8k@6y1X{u!R4F0uqv9Laiwc%H$5n_{VEL`oZC_BL-6&y z;7V&(Pt>(!le*D#>dB&&TyVZWtWz1#JK`$Quc+m0cJ{Dy#-L zA>L;3EdR~GTgr@zb~O`KcaY7hDPFuYVH}QSJTj5xT*4&k;^@0jY8jl0oE9t^k&V9~ zqzO}IqaRy>%Qam*LQ*=v>+x+o-uQgVw>YXQXsioyQ34TY(+*+Qq&p*{3p?_-rjxG5 zp7LRWqGEPof3Jm_b~P_ExCpx*#|@$>Y>4#47QY|zw%e=R)vuxH<*moJlz21ErWzpE z{T|c_F$}7u$M#4VJdf1;%*g`LHWJ7HH?&NTJebi2hUQs|75@L zgX!CrD=yG}8PdQP8=_6(jG>exO~v2fZfhKkJ(4gkZ1(&RD;zu{8{;{4{<+rJkUb)( z&`Dn>UO{fmiP|ESKuwi(fl?<;h9)xK*#PTHhuF^l2(ks^OUEz&Z-CeyDa8M%^8;uL z2E43k@>&LZ84d7HIe87Ub1nX^)heO4U6}uv=~+uA9n9$L|0z~Nm~+^fyg|cphO9qw z91fcccix~5={i|Mc1g~BTMy4PZ5Y01R3w)ZJL5&P?#?;jGHpiDBjSsfXnkx*!Kt1v z7BN#E>3^3bu-`Qj0n{rNZa*d?yREvhN-jQAZb=g`zo2}~(2X$%i zvoYRSSnn04xwVna6Z>fMHa6HgMhoXS$tTsPC?k(17RC|lYXvAs<6!ISk05-R!hj-t*SN$eq^ z5Nm3lu_R|r>uYYZc$l&%mDk=F^(h2DYSqVQUdNaFZRpdlx$IR-SeV?65IIH3x0gw5 z7WiZ|o>T)uNyyKq)Z!!6+P=e&nR=LJ|s4RE&k56BfeQCg2m{v4!QJ_qlg#=ob*K77x(>h=U7^C?USVt^!H0| zyGIw#Ef4OGM3(pc3meD=#abswJFo{Z>fhXT>F7)BbF$&}i6xrrvUy1j{NMrP##8LM zqT4h+0rHA;E{QXDIqmf25ueh%ds?iM@>541N}UU-C+nSQ!g*EnskdU)>CqCcUqrD+ zTX5fA^98Y1wwf5yR*m+nZK9xEQ0@)u6eB2$>UWQaru?96HSSoC^cKS8xA)h6VC#5) zjcvJVstd?KF{dR*Tc?YOO7}HT*Ey7CEam{at!oN4nUQay&GF{3Nw8SgJZs~4WSJ;G z6td}e{|T0cJ~06v0e_zW9)bFQX`{ASp2v3Tg^-pXb=F1Rl`0qA=KzL)cQVf~YdgZr{i`2BfB-W@Zdx0J__A-ZBCq*0Vh{vNtd}`7;4q5p` z)`DRt!AP$;erle0p&i4b@Yy8TUvMRtOUZm&AjHDO3D*1E#CV_LqCDa@Enz&QX=1lk z+_Uyq2?^N?tTM$C`^padH}g7GTTcX4*O!=$?TrW33e7WK*L9bBpbUappb1rKKGSyV zpSw2?MCtD^3d{L7mWg=|!GEn1LcAVi=$~ebOj;gZBuD2xi!cTq*|pb6@7$zeHAu&P zkFCj7Vc9p+6QOd(S&%v!dNGg9B9Zvpk%6@o-|e`X-Z7fe#h18aXsp+oQA#L?q}Co2 zmA~C7!-XIiWw4j9a+RB4))iGZ^>G_OYg`G2@;zX8xQ}i5s5l*+?8A=lP4IoZyo{sf zUe9EBwuiRA0X*U`Hpo9-E->gko{vrjW*!m_1{O~H9x8|~Ew(@0KaQw*+G#yA0KfiN zcqRGWeJWZP)4IKCLBHbl0aYlhq%2Okki8oGia6kyH9J#s3T5WeIa^wlkgZ%JIckk< zu$~>C*Fum1U_l7F*wukVf6Uq#ek~gOgJ+XnH)nt+hUZkvLs(B|dtVgBNX6@>nBLJb zUw`{aDvf&aZN>-!4Tt-Pdwf3=V3837O)Xp{7jR&~hQA7mQI7ot{I2>^sh4u**68gu z*!?>7U((f999D!b8x&p-U;i&%f4EVUJc;%+$$QDVFn*T*Il>lkLC4b$}9|md0^pK!E zm0AB;e#9R982>ws`c|8RK(dWuT1S!`9=KwyJn*3VIY+9FS~K3oR4T~E|KYa-U26*U z=iFmR%kp-dVp`Eh0gAR~1t(<62fn9`u1~CFo${zmUY!B8W3G+D6v^M-sK_{D4Xsk* zlTlhMMO|lXD{SX(XygSEKEdaw9V&*U6!9Io;RP>1LCLp_VF>7c9=Eq|gmRXg z7TV1`O_;F{&T3+MRx90c`W=2US?5!&(-Gz-RgW&P1w1~tMSlo71;B`}S_g2-H9jRZ zh5$cOa;d^qDcwacWjCP9;-TJ(vDLb?<}H1WDlSudLma9IWHSRUvMF*ZmDT;BiAIN_&`6VIpmy^ zvH`&tkBmqV`+4mNP@;OH{)1^AJO)FDT5`4jjii|w(BwSaHq;9N^{1kjxcwUc2R1|c zEfl2Y{^bu3>3!_tFJ3`I|L9Ziz(rCmykta8bb%%_L4+xTJKUXo8;67FHA-@_)dT=L zgJmp160eCXK@!tldhP@2P*Ry-C*kF%(_vclI(fEKz%=AX4&2qN+X)dT-!b(`i}ChT zYET!4f@Jd|r*467850?3{kU44J?lpNMXKT5f%o>{L<%7-iu}n#2}!tAW_?0lXFdR> zBkC8`vgN4~6LKHNv7z{)E#AL!Ool0x{sA zJkdbT?f0@M%R8!cvbd@2wM?l7Y0!Q!CTHazClDmnj=pu!GuR^ROK)c9G@n(K=AFP< ze1P`^C2Sj03HiV{fj`VT%NOKx5~w=cRstlrEKRJ|oB+Y57_Sj+NH)QwPOXShX`7nj z>F(acqZvpW+}r(oWh^FNmv$`ts$oixPr!i>5x_ppzt1nfYt7?HZXTSmyA=`ru>pcW zCASwv6(R4u=pY&=Qq2ADUG6Zk0$$OzXxzkI`ma3(B=ehf>g~xzVvC{1N*TPbLnz_B z&#UEsXX`Q|_C5N~;&aJzTu_`Y`E*Sb{xr*5Nz{B{sP_Ml?h;+Q zdp=DH-N*$sz{LP{Bm04TvQ`(>R+8L$c`qX#KrSoH=*Q6iP}6<8k|lL#th7ZAAyS_; z?4q%(=&t|Q8*g{VJHZUVfhwTWTAu;%NtrSMe9}&_fQDeK&LJT3FR7;kelch}-SL?% zI?Wr7K{}`q1g)vG&6BB3!onFBAUvMzVK&^hKsPYfNKQ?PR zxJ0uuFgVHHgXq(xyDf#hP-r6os|_<+zy7(Sy)07vAg1p*H);@pE7AhaZG0a{VQXor zI5_caHiWi1lBk{2^i>5C8J>HLu)Yo@ke z`vXU5)qp2<9PULk4(b!Mj?~I9+RKvpGtYevVCtrFZ?-N%d)*l-dZ*RcO_E$Tf(i{H zsn}Gjv^O+Wx=LalC2=!%L0AsD@q2~4wc``Pn8KQg#YZP{N@ILbb9N0P>`3q^KII+! z8r_sr`Pjxc012O@@l2Xrr+hMx_6l{oR_xO{X;5B`hr?BIossRns!fIsR`7Ddj~R=y zErr=aYqtALJ6!BXdKlrPpR97yZR7zMs=_)?b^e%&_7gD^z-ZP2Wtu$fbH6N+QZv?c z%`6Vx{k>Un0Y{Nkl--b^FLE9p%xotB3uF~T6uWR6DMVPw-Ka*n(BgKV4VEXP5u|_H zp$X2(WQYE|45OopU_IwDB3g?IOM)u^hb|MQ;ti?EHgy6S!k6$tdmyE5AlKKCxa#Fz zOs9;hWiVMrrKYc6xJboA7KBsMBh~(8a8e+?|I6T%75B0_WUWFMytyA9DD5kCXRKju zh=pxx2`nlTPlyWgLZrrYu~sJv1D|heS522_^{n`6hA%4wemg8_pgP1WPWu{NUMU6t z3r#E@PD4pW+sLb&=6Zp&#sOJU)L_^CotI&`PRRfrT4}$zJ~mT-5nR=OS_We9=itOZ zOh%9qb!_rbL-8ide+ZgFVC7z6COSdi*9HDA%h=WojFB<9ubm0y{$sFM(0>=;6E1d< zW^Y`g-}CIH>{zW+46huEEn`Gd(5cwuohb7*3-u)QIj&te+0F_F!odKZXaJc9LN0PL z_weP832J?Ko^!juIZb_kn&8uFe|)1}dO{y>{(qc{n8`yvor^lYjv*%=y%1l)tK}6c zQ-;wOf|fj5qPf(Cz)lBANqsCa7AhaFdeg)aKK~Dv zf(op{gj0%cXRhhbzyR5h9+BsXBVBv0>X#|Rqu{H)7xM?}7gl#wWW8JvF#RxWv6F%n z@mm0GY~*aJ%0fFaTAZmEJxr@6EM1|+c-e?^q?YxncP~6bEOk!&c*iKqiByJ{_A@^QW{X`PlMHL$l8` zz@1bld0%~PN3nb4fMKeAgFLsZ$i8v{oM?XnH$&pVsW%&L|LI5bqu7Ulo8t7zm~hHQ zbd`lIt)(sc5Z@C++ss+<%>K%4R7GOwumC!`6~DXY>;V`q(qUk_h5P#Pa?>Xoa@&Q&v7paAixu?m~Ty*ZNJ@4|s{Q+%^mLxuV6-_BHo~ zG!iu}@I04;-1^{>(Z8cXV@MK7x=5hN#OTGm#gHYVV+y&TFSFLaLSJhC$OPJmQ2Rqy zP1~&5s(h@R%(rN<$t(F%VAeUmh?^&H`)A*V9ikvK4^2H$|65euIyX&tL130oQjk5; zFp5t1*i@fnE4`(-0_Cn}`R>}Ix)?F;kP_7czR~mEtyTVU)LpNrub}iF`04%z0&{LW zyU;s_&^u9pZnuCD@s1}!)(glr==t!|!B#Rle}rocmGm4?rEMhrJSi3JtxRM3+?dUV$M5BZr~2 zOt}&@GbOl~0HQMQ!%Gv2nD~YH=9AL95Jd}HLq_hcbW&hSv()S6$PU;QZ$Ug{KGgO* z5&AoxMpwU;zaXs9zBCiq4zbo91S_VTJqHOCaW4djjA!|3`0 zZzJb4A!nM}(pIzut28K6=%uK!WPRR$lO`-=HE%VccCZ)GYe76|xr$q7|0PXe0gtBW zCn3Dp4OAu;=`#?@r8p^RopDB(q|t-uLBjttFZlUxO zFm8R5U=_WnW?uSZG;{AhUYPP@mrCj}`V+|y3D51tkAqhUvwwyCKa9O&bY$HYHX1vf zj@hwot2;@@w(X>2+a24e7#-W_*tTt3mAZM)Irn_y+&|wPv&O2Y*4RJxuByGKo;fF= z2ZGiJgUMzh@rA9+9}wKpSRVgZD!y6EDGuO!5@$+`$PChAs{DD zDtx)dzS&m6N$--9EiLB$r>N6fVt*to4xcuk;D*|Vaz`=aYxKa|^}lN^YD17JO&_-{~4^o=cz2pkE9d081esEDJB7G{xu zD=JpQbVpbh%l$<<)~6u;_geAxkwx2I={{jq?0Gy1>n01Jnx&P>;N85MwE?D(?($9c zuw0ek>Qyq*yOpu-7mVaeJ@rUev(-PRzE_Q$6-q1Jy=K)l1p(10_mC}bPg|lFk&?)|J4wJHk_4t&eIxhd40x||FI2G;FNSy9OsEGG2 zc}uD7RYc=7mH1lo>G+6?W-8nHh{vTZK1Wqk{H~m4MC3)ecs{P2RHZ}e%%9Mf0$vjw#ThBW~S>l2Bt8iJXWPOFHFz=*u=VR zifaz(VA!`Mu|xFG9$x#C(`SgEW+qQE>=6vsa1{H#~;IDf)xM*8g()sCn~FPbTUrydOz0`AEEmd4h&tvH(6_`qqVBv-UHQUia@-_Q zM{wLViXZ|>Vkm&fq}zCcmt8&F?kyBQI^WwoRvb5OIaUN!c1;n7KqoFpB19qtkoPUl zuT-!q?$+d#FB*-v5#C^OsjAB4n)|^xu#Oh=pTZy^YL9N(lvp}Tf8iDcPBs(je~ATV zIL%prz5hzhzMI2Hs)+T%Y_#)&mS&R*w=t?DMvG{`*f)B6$^7Ioc(-Ghb%NJ=`;&)- z+YYsu?`BOCyIqnL<4u>5fmzhU;s3KN2K~>+;g@r09(?cVcO~r`sxHzDoJjFp7(}a@ z2F0KOPyjcIp+Z5GcJ61}N%wR=ZSm{EUZq_`b!J}zN!?KnvK*u!TCS&)*^vHE`k9$+jRshbHw@6+r^~Tc|teLajc084>(P8Y8MS^Z|Z6N!K zB)~7{{tM4^tz>6v@{taeA(W(T>3B#Zr3nn2PwA847#&utO|Qyn5h1x!TyzJv-)9`E zu}fw5jj&MXrCy1<1)(9Ui>p5y=f0QxqRTp74c1a^4~db_5mXY4sF+IJ9C!hZB-REk zH#qqf@<+QLg|_(&rW5(B$_e^7Ev8f5_5Uhy!_(&m^*Jc+<-E8i7~X3U<)85L3cjYK zWwmOXrC2BWOkBP=ghQv@$u3fQeMk*WE#Ok)O`Cpok7{GfNKTg3yJ^Sr8Ebs`qMQ%N z^HQME`DH)}gcgDpV#^s_lK{YvNN5pVRFb`S%X%~Js<@&}ze&0YbDVSmQ_;dVtJI6oV~5wz9FcBsJ^WTHiN7w!kbWCYnx=tCaOP3u&lkj9{++lwAGi!FUO~o8 zuD>C4W`BLQ5DqV7z5HpQW0USgtF-EVTtxpqoVF~&8F$TpQ`@6q)fK1M%@KZJKEl8aTs+^kQ_am>!VDsQPVr()Q7=7>SB_cma5EHriRaH+Gv$7{|7%Y4&xn6Uvn9l8E5h7nJ>!oCi zYA)b?cPp5ck?-J2HH;zpw^$hi)YCw=OT{D;BQbAt3wc&Z)r)g+QRcn?y)fak}yE9&Z z;jsJDtisl1d1)X)NsMbmEzk?Xo@l!ZcfEV5Uat@Kp{Sn8{A0B-KJq=qv(|u=5dQ`( zmV!qaDJ%l-osfa%hK!`dA_xhtCZR`al}c^4swP73Yfx!02pz7O1y~XVa#|GV)HncT z>1G9h?($!Rw9w7H_3LxB*K>J^WB%G4(PX#iRHi_!98H}pL6X38JqNK&Kd#hGxPDjN zu#oNsU>}#`Yb+<*K{( zn~I$8_fzbBvh53QyB6te*~bY-N?uu@V)3NxQ71zvo%X6eoZFqh;*I*xmJky~ z5#b5HE`tc`jz5Q|Ijx4zaF=yk*Pvd~EF~zqTw$7MCmY`-p^Mb?${0-I5!`rYGXxA4 zt9v&q%~_kml_dqrg99K0p}iye>OttvlmQ`DZ{P!upn;Qt5i3xv-c>&08_|pYFlg2O z1N%iFsljg{DGL46ft{M_Lkmz!h{#oL*xiq7HY5&!qar)i8?wtSX{F_aztzz{fF0Zm zg2n4E^zJR({)sRp^>>>BIcwV9H)?#w8%cnGTDQ^!Pw~OO+WI z@A6xNpzaF;boVyxeMY3!6ndLH5a! z->5{VZVQ~y3yi;bKwFUW#`D;gkP;GEvYuj_Sz5AMf%1km0^viWDCpr#=r3Fcl_~`6 zPOh(07L=Gt6nj+z;TZ8*WILF?P;?~2VHr$C_vIvS(-4bH{}2xKN3uo5W&ODrfB=pO zLF-Ii`nBha1|}F^4KbMOIs39Ah1c-GpW3;pF*>)`D19o}c8}-@8MD1{iX3R7*XC}5 zZ$!F3LG2w;A9p99iSY0H`&?yG3UDr-6R2N@@}z)S6-7Z>KJdE5Gqjc^#6=_l44qTD zMQWaA2-m|HD=EDNsm%anQ*g2XCwKkM2)J;Y$-y;6! zNHa0pCT@o5uE)m$bGi}bE@VMPdVVJcvtFv{o30sdQH@{xWrE@miOsfDVGV>U1bh7b z!2@1D$15Yj;e2J@R^yg${l4XIAxk0j`Zfc?=F!+HTLL}5ZGAjGR)ZXzb?W2lR8gx2 zq&eL#AQl0MF-z78maaYKCtq;5=dBJ1#Avqre9y= zDGL4^i?Pp)Teg(p*#*(&v%OR_o%9~)vKzwpT?bG^L4!-XH#6*n3n88-*+Dj9rb^88X~$j4bX|%2T~6(|P-41sDNadcSN zTz?P|u10l8;&Hb9($QCx69zW z|0~4l*k1*G|2#Vs5!gOBVoyY*0-vG5a+Vk{)ZShQQMljsUSD?toCwr~2D|%vqUa|y z@qi6z`IVX@$bvFa;J!@^DB11&A#W}Lu`7X&2Q})Fy_%gK;rbxp+<0v%@?{43VwC=p z9P9`Lq0vu_YJ`??YO5QPrC@!2OCo&8X}`sL!SbG%M{9f2U95@*QYb-jmD$UgvV(V2Y#Tp2GaFzp&`L;p_Og|MTA(H8EExH=nk%zidR$rvt z#rD(-fJ8*PSnd0Ka<-H?n}|fU@8cWGlSH>Rz=JWHrynX3>tID* zmIg=r80h^3$(G<5*s^5iFXVSgm84wss}N^;kTHzvCy|nYX@JOt+2J#_W4_Z3m3)-o ztRxy(+R`ZSeP?XuW~Tb{(fxVbZ*Fb+whDZDN%k)fw|k%a69-3UZl4Xa;6IhD@1In3 zDQ@hHapbet(7YuxpUOrlf(HyZ6J9**!X7$xxe zw|a>iru}JpoQ(WluIzVQ%iTJQJD`NK&z^Bir5}f*e@bASbcuQ`!C{|`_)m$`Nchfl z?o<)k1I&j~&S5*rJ=WVR(x>iSsZ{oz7H#=_@wBZj6l__3)wU{ZbHwneOC^|auW|D> z`%DeTMYimb?zbZ$(CbjOQ=6_yS`=+zl}blJjD(P6^}SMErNn+D4*qJ#D4uCXm41ZBv~3S88A}eF+L5=iBIEm+O*xf zorgEZWo#*yZ>wEbmbN$htHB0mCKM7&7ixYMU1}Gr_{JnXHKp+Wv;G^cXIYh*-j6bT z131({LzS|=+;64?w$ffY{@hdX*8xo8WSUGQJYB;=JtnZJZHD%`qD`(va8({( ztsrF$U0c4Cq&c!)A6G}U@6M?FvFr~iBuZ29iPn9sOdW6Dbi4grm4o^U+#@U{?rs|WL32gsO!HpxPR zKxST_kI)dni=O*sC+(~A2zz13_gQ*q6`1d)amZ>gH0zlTlTN)G9RS!J>TQ?*2|+{N$s>Y{8MCJvPDYtE?(GZ^fh6TPx$`CKXu zL^R+V@&Rw}bCe$_{T^^ZcyZ?c4H(%|B_-Sj)2Wg)#nYVG=N>3!DOm12$s=USx2v2u zYo~temh#x#u^-N1)NPJK_7zLxV&LDSLLE&|7KE=FqBwe&Ite;S>ZHIe5jVTv;;q;M zpd^}NMwV+)`zpFhdaFY=HL%_elyMAQ&8P{yia4fvbix916 z578QL^ESh>&Bt(NBvL6Z)={v=0TTwvzJn*}$Oyj)3Uz2Ea4LU({c$=dEwQm|N(*9S z_K$9mse%0aMby2E!8LdG$7%%0-?t16^=#Q)s$W6H5m``H^!I#{;l;&9NA>k+a&;#a zn)W<4)6KM#cATLJi3LhfCMJ)O9;oH-h+H0p3A}kKHb3l^^LCznGY=n@=#j=ZLIfisYz|Gmd0hhW$9uF-N3cL6~ps3fQSd z41(yl3B?%#Of0M#K?5I)m^SGN#d;@ZNFmyIlO-na*um&rwLmR+D+=F?J0Y^_V#B8) z)(`Wi^Yf1OUeI}#?tKr)d*poziEK>aD;cMwIcJJkb~^^FbhNVg?1D_H>GU`%I_(u~ zzenzRr#WSevJSqyPT6|qLHz^wwG#TDMPB>LXJfZAi+%#9yc`Z&6NwsU?RbH$O=Iaan6n6)(kC9R ze}VSfEL0VDclaS?TVisKJm_{^>44{#7uOx`Xk0b~PBkV%)0(N(di?&}@zk6Rzsmhk=5) zEM@Mkzq{2<28dL5AFT6B9KCgac?d~K+N6oc@5PxXpFg8YUr^Umg|ho$@8e*mnrJ|R z;frs?Q<2X5->LVVY;0QDgDpy5B_E%MSj*<$2i=s&@OxCIguFtRrZG$(EiHbJv7h!_ zR`=Y}i$iCN@?mTwpDC!NWo~I8S5Vh+2i-1%uExqmhFfmQ>TQ68{*}G|X;{xNn#hz- zAZf$GDaEjofgfwM>G6PaUN^vG(Rl0z$XDp-DZg(a^028A9bxhb!-7# z@N{&}+mTi&tNN2(S!DC$TI8gxSg=n=9_SxcYYMq*U$C^&J}#tx7;+VLMvl5nTUD>w z+)uBO1_vJ$5)KWHb@;2Ec~9ewgcMS9K<`}0G|L@o5|vwg`&|rbGIFnbRkEh=P4aW( zqUQTkhm)&g7amBU^_cYIEn%byDI4293Q+I zW3w>|134^;$?dIFJ~o6zwqmvy zTJNDM?)#5*)!4O&hTilrIU7FF+KGkd*EE~1`Y+?kbF^yjRR+bF3k%Jm5d8FL3fmXD1NwLc zMaXQnua}t0tWhEFD0zJOkKRKicHXGf&31DNd18T6&j)6J+}ZSxWe5lQePbzpft*da zCt&b^SjewMca)_*KtlbjrjTIUJ>iIou4j(>xTbikzFG@Y1#DOCh79q)%ofKEK%^&Z zjYQk~r`eLtJCxatm$;Eycab7j{c}$uY$fJv68m+U$fj7AmDowprNHs#=VI$}mCif> z1qv@4PK!cx)VvVe@ra5|s0r(Iy$o|~py}*z*Z}YW{$(sA=>20X=zTAjNH&g2O`MqZ z9g2660XQ4aRMh1f2tn7>LquoXMOh1u5>ho zV=0J$*>32YX+N>&(1#ZnP*Cls45o+QA3qYX0-D=gbXW2gZuv2hE2v(oikgp@Kejs71{<+p2CDk5oDR_tw7YZ4cS(ToGy`M7x=Vh5*L{26_H zGKK&ky$_!@Z_k^u7R;AYep2Jd)9K*cE6Bm|DUJoUv}W%(`0i^d%e5rd{SZP-)zfG2 z10X1zo<^BpX7+2XwOuiWd6%ikh`k=9E(=7xHH~9V{-D-4pB!R2P@gZzI83dHhf z$46I+k9^UlJ9(#t9S>ZZ+`08VYm`dNiH-JR>@0&uf1}*f$Ke$4ge^-&EK3^*KSb+M z*x!WfV^gQk`?^435$%JV)*!`MP=TAHt7q*hsK9GR1QeUP4k{SfCG{odK4?2`>~V!$ zL$=|n+4u)kg0CU#aM^G4xIivlh}my^X>r_-S^5EP3*SI7og%Ca@d=7jETzN54`?Qi zp!Z;-0N>0%x$k*WK3a6XtAR`Sm4AFI9&Lv(YKV&9AlsP(JWx%##k^k}I8qsx!gM;4 z1&|g_BPn22$btA>u8@$(l!!7Zj)`K)ZB;OnwXR0A$f|!Nql&Pc=wUwAVq@uTN7ME> zhz4}&;q9=OdqQNUJPbA9aB>a& z1HlnsEDrM3h#domTjfP3Ere6QZ?l|E+M|^-fRkDXe3W>e!AXkszB&J<%)z0^IP{&R z&|{{2pvg6TpSpZGp#|MlzJ?=WvQ`Q{hQ~{#npxN_#y#e>@&#vb_%1 zOnNgjuGJVG0H3P2*v=m3EKyW=sfyDnEcEvfmq<6ZB@OffM0e?3iJDhD9xlvNy)xInX&!7Ag}?33h82dQEh& z!i#D$w{X2luvH|BMV1b(D$Fo}+VEp%!Mk)NI)!n}OC0d}L#`W*r@YuV&U+lo37U@L zZzZ`peV}&ay>4vGZ*<#MTr$HCSV`4!n*3ja-@v_DMgBAzf3wH=DmEpr4pFv3T|V}^ zDk&hXRr#@vPg%;{Fx2$Wz^p0o)6(E5t{WiFO>;CDt{p!^wPa~dh)J7u^d#e-x}7~P zIxs0a4n}@6=2$SfNpK7MyRvF}smd94c{|~k$kn~&lln31aR+!B^6P#Ryqn?^G28eF zr>$xFpv~&ve&y?x`0)rlm#+q)(xvC4>-mA2HNH>i?_V}^MW%Y?3%L^5!` zjZev=k713M4M>-cu~sA>)k|#9>@Eh6SEik3`Dkq>@dY~JHOrk?zx_HFo6odZ-K219 zHg@)ve0uj5J{YrmNod!(?JSnk7e7676>HHRtK|Pu7ygxdj@%NSLtF717n>^+gIP^3 zRN!73>NIq!!>cf!%0|`( zqoL_uold6gX{R~@_xFPw9bw;^uu+N-^0}C5JQJ;Y5X9(p@Z60~=K}LlCU|-;D1I}U zx-12nR}%5CTwVu8-4B?m3hFc*)E-(_zVsLM?k{9?j7syZ^bR4XJn#NeK>9z@8c#e_ z5KI;Lmy&jHq-U%evW@j{CB4A+z|G;*^?X&H(r4aAm z8IBySr${4HdH!T9zl#Tim^N)LiC7v<^#4JXk}62&QhBYwMb!$`^NHsxr?VdUy%#uT z)#cL;wD7o*k@s%0Ni$YqDN29to(M^Q`~9p4US6gRwcaa$4p<(9jaCy>!3*K=B+Lg)R7#(Ajfbj^`5uM zd^EO}l{i2tQ#AWX9Qk*`tOml*)Ya_QMrq6TVh;V;EGua;AA^!$dUybDK9#m)DMOhm zD<2QX`*EBAri!9hQ?06Hp%o($Hc>0Qz|~Lp&srWMLVP9jQ}_ARMD?~$G5~|6B;XuA z_;HolZgWrzR!C{>CMIN*35dRNNhP%o6B^LCI>GcaIZM~H8gJ!qJ%sp$lVcKFv1)0+ z4V>fFU-;Kv4~wfl+f&{0&baR%_l&x^Kyvn__Zi3u2usgxwZFJ0nc@_tbrb-f@; zbLJDARe6mX;#l@g>d)g6bFwtpwmMWswaP>>NWT4wtjA#wtgs#y)jxZs)W?G>Vz@N% zEs>F(67zVhzWOB<`N-IHih0QvpfLw`|7gdM(NFbOHrs z7I4`@P9tX+q*tdp$kghpA}2E{iyby|hNpSjxm4zE+jtekR7T~>`u3zU#n=}a?4R-| zY~%^`=pWydLdu5a3w16pjq$1PKlAuxE5RPlqFZ!f?j}X_-#H0nAy^Fv$D9#sEa(le z6GGiYUj?|dV1`hSSbp^53J!=D4E z0>!zQS`N69otMk(#l?hJ2?c%NPchu@j)M=m(BgUvpyvQfV?Pu%S*TPMhfsa($p+#y z#RK?vzB0LXV5O>mJD^?bQ)$_a|0E9m@l52s#q^*mllCwfd6;`&dT7hDF2P-Jd2O0> zjEw0JGp-6(pyA1s_y%LYwcYjCC+H34sWSAfxM2**OvU>cQ6CL4y)QlWIq9Cdjm6eK zOL@Wuv2%Wm>8>aI`UgO7MnZ{$nh;C(7l9=QES*#0-YI>q$$Kq{x)2?I+bZ|#eklex zE~@cDRbYfqIEkeO@=nk5`u*}-N$wB8!T_Dd_(nk-N|d_wzSc`Iqk1G`K}rTi6$vQ= zPdTZ@{6|g9&vJ!=2nlnWiFQ3EtRdCE#d3dyN`iM|ql9pfMDRorWYMm^Q4i!|1Sug& zvHTbKgMTIYPBSRiD+f`8v&>)PVQ|-T_?-r(2q($tSm58H5b>Xl{{f+&mydjjzuqj6 zhk?$M|Ef#Fg)ncd5c`zrNiY)skKg3SBi6kADC?xDkVu`?54L&Wmw&u%{->ao6=3h= zV=9P~{%h2GxyAX=rrdWNcMa@|Ql2dP+%{cMHBuMr^XPwp@VA~iD1BT$cs_VrTI}q%6d-W`4w4!B7`QJ*&mkR)UD=7oFZRcCvL{Q)GoWNHYARyW{KRwOsI@%*1~;8_W&7d zVYO$+PgJM|<2Lw3#RLq4sdkvxS)|&(IVoA!YLE43i7B6CLLk+jgKt=4E4pB5`DrB!iV2;$ql#JWf{VRlj~x?M z_s^<1h!Qr+*-uFF#fbS?G9H753%#gXW)o^)1arh;=IPu7Zh<2C*~=&eQXDVLjb=hQ zVrzP|ENcumnAp2ZOUIH1qN2<3xMe@RZX$Vnk&D@Al5gDGCnxZOFYU4&tT=xXHmroDc^s z!DakfifNrJfAb~=(t;f)3Ty_al5-c2>fLDr{)R9np1Iy8r!{kT6-WCZ4XKV+TpPmUfa2t+j1eH(j+iI znp^-4q%ZR8FFK}W#qmePPd3!7nuK`hs4Vw&8r~CEw?z5zuyiE_esBgS2Y>3l1S^jR zU>Y;_JznDJ_RzC=={*KJ$fD~(LBU8IL8@YKtz5FZ16dv@EI)UgA1xrFi}m_)2>q7r zYa%!o_KNIO;;{I4-jNVJybYtQ_5&@WMKp1|d3f77fMYGPUnW_?L<_HchWOPb|I-w^ zn-+Eb%-V(NXj5owY3g^{g7eu<*D?=7irIK;(IUlIs50yo(-LXJP$< z=j|$7+6Lmg556-%4iQUI4SxRt>S z9i%>g9$0N84bn-UO@d$)!MPL$_MUxOo%m}O3p}HUK$p?zXEYL-)y!2(sFvtfo_3i- z@=Fkw8G8tjk4QN}|1?9qoP2AiW=1+7F2g;=>S$%>SyX?s-jp=&CH@PBlWLjlea=8lYX=}7Mo^iHzlBMZBF5U6e;f}Ob5&(I zq<%ZdCv&qXm6g(yprh3NL2j5|UTR6LGOv)?l(&>_kqe|Q@H-cjB4v2ibm`@LMvp0u zzs0M&8=gXbiq46%a$uPj%H2oNvE6`H%++bG1gIdVRL&{IdZLeAJ>Lf#ycb8eRA2|| z0dAZ#D?I62?^O)Ru$-$6GT158+K7SFiODu7(bC{e_=z{&8M9uxT5sj5QC@p( zCZ}_d@I3*B&;e-;{k`nyNM4|(M=u@!2}t%WD#YF1GD~7@fA1?-gZl?r*>!{D){0&&Q6GeSWg@y`Ra} z5{1k!otcqyD_fT_(Rjsp{f0`UboovmyL5c4XEYZ9;>9Q5lu;(Oq0qi*IboE}pOPuQ zYHugZTW9)ZA2ZqISGy&#ncgHf|3H^zY8)_)r@~oPB83>$9TfizVzC*SwIBNN*RJPX zv^!v>vnOBZ2h|(dOJH54=xH)#@KQn5{()bR0Yx!)r-*H&EWdl^&CQufl z!dW3EM)&WMeX}9mCtiL;*o=JW)XdX8;M?l0=AtPhmvmb2VmUf)*nQD#Cl-FC=VQ-3 zc;Zk8uJ4MD!?^Pg&0d%nW2^zp0eE?@-ShA1n)j!bWaOALe(&8yqukeU-2mrs>?p~n zi|(ZDhn$%cXWpZdk{dt1?8#HVsY$shvu-NCD;Lj=#O{vMcT+^6yjjtYqPb_ya;9b~ zA&{~78QpRT!;v^_00w$O>^vo`tVBm5x!HZiRGNfCJJ>Oe3&KwT;$ED|e!B>=WcYob0s=Kl; zGyUiVFp{@nyiZ}~+1NNG&?(XbV0+`Qr3AVr4u983*rwqpbj>3aI|3yJX)g?2PMD%k z+*1VDx($<4wc1Jq5MrR!Tee5@wzTB5P3#u4Z;c|<$>Tf1!woVhn(dao$g+^j@~jmk z(SxQA!X`*ttK8V`EbA`h)B)<}Cpg#|a+|`p;jBa^z8(~ek(5enHW{Qm7)O(XbPty= zS@jI>`>%ezcZnF)Z<6TU)%l>MGZ*<(VE5(A$_dYVcj`F^nPw)hfw6M`9~zAh6~xaC zH=sV!!3*Rre;n$?5PU0;#%-7UVt!-1*rd`#0r>DB)TQkA>=&iq{c+~EkC0?#OJN<% zma{-Ta_j`)_bK$ozzw15jJyLmWnojsvSn`&r4k%BngDpTbB(F{5?1Ncsa*Lw=bn3C zh*DUOecQe7w)Q8k+_s9koj2c)fHzxLT~qIOjFKL7H9=+$t<|hihr1%|sp3Q8ThEZ# zvHV=aob^d{PIChKa+hxmxIG(g-JOztZ@!NuOQ7G_8Z+?K-Jg91r@8#%+qX99^-eD~ zo~l9dJddwJQpviLR>MZ=C+9@kZ|Dnop)c})N#sP~1BetFU zIJ2Gn;1l5M)~E+fw$*_2>VU8=&@PQAr>8hM2x{E?nC`U8IM*tV)5C04mil-!&>d4X8)V(#G~e&>k-@EMHB7Oa%)=%5 zo!U+9Y;@kxT8`;g*>-Tg1AxyASaj?;609E<>l-|*K zKI|KaVh>bvX1HVrroEmM4?zc|Hr#dUhxsLmOfybNC=&~eb`fNby>Yks=kx1!$pz^< zzx&B>m*j-+3z?8<8}de3)63hI6CwOh{4~1bDyj_MGb*-9TxT5g{t?b=m^d3dVr&+@ zWI&g^EaiYQEaJDs?{yMiH>06WtJJ=#YggNQ*j%PIF4!vm@L<=K zlXYNg(R)7#qe}qNW%wsR;+idcb*M%basC-XEF|kQiMZMP8*)z@xw#t2#^F@=*GoBh z!pA@17+K!vD5O-`G%2wJ-KlnZhX(-Y{fx-Y==Q`BdHzZtwD&-i9#Q&#E&`?hp*BsE^SqW$ zaW>H|+|(CxIj{- zx4%-D5G{DcQcq+N@$c~8H^w#1m*u@?=o@gLr6|>RtaFL*9q)$A={>yu@uoa7Kb#=` z>Rf*_DtY&8#|`??qNC%{USE8oUj1 zE{>R12x_6N4##4{**WesE2r99K7-U9>JApngsX^OCmZ5YTEc6|G?FdTCmxG09{F(z zXtgBCixLy~CZgI^ny485$gKQ4Dx?5sb3>>c>llPzWCK_Jt~a@4nF*7o4}AGf0YzQg z3V{yQaERJ;fWsHD;tM`P8M^2Pea7i%XxAGsG~d$wBBmuKu4l!tLOkD4Hqcfmo-i`< z7F^}NA1m6H%C*$qdFV!fY}@tVUwR3b--ahwNeOf$BlFEIQ6FEGFJVLkdfx>C<%C!P zCnCVm);XN0GI`T{LHa|M_|hx>-uLQx6#o25C+XE+u$SB_W&d?y+Po6{ffifa^?2+V zwpHon+J`|^6wPb+ZVpPN2io8jFdE1;cm@xG$->h=5)=#JlUeb?*r=Bnc;8$9G}j}5 zAGl#m;-61jqNHB;x?YbJ9`{vu`!=b3rg6q?bx4ekXG-*USGFSLvz|}?Sc*Prg6Exn zTuq}-2613uX>$GnBan^tdo%k{xH!n-cr3eLLMz)^1@6!ccL4i2AZjN`wRwaiu;X)@ zp(SyJ7wMQrtPC<4;=i1j3az8(@o2}P%MdAL%VddltVr2T~^4iQGJG^geOje`TB`cdiVqX_%URIg$FKhE|?*0r?3WJ<-T(th=nMl^+C^;h@I5ytlfP2 zF-_1>632C#3ssFf)1RJ99{K;xlW`YhnePFnMnDuvFNtvXJ*PtFPf+I_pf55K?SlY z!NG#qLz7E(e*#&O8*I(JyTk+zWt=)MlbxjW$}=((c%G_?<_9`>jCCw`J-7&WQoj=q z1iQOJ!4~8R^4+)A&^OJBS`CU{y>_dZ;Eofue)zX9w!xsv^0T~#qcOdxH5x?jQzHMS z4%6&>f3_f)D*ygEPtDSeKwTyGz8gM7F)+In;=zYp=$kKk3Qn%JN0&+A$_lhtYbs79*{cythJ(=;&Fb1 z_Bj;h`(|7DLPx$yxytg_hgCL$%u>4J)*5Xf(Y&@eLQ#ID$QJ7jmpDSsb@l4Whr%l8 zB;ToN5me^n8S#O_)CLOL4%c!i4p**Q|8qBc*Kb^`cyzu+6iiSfk+M14qs>{ymJ!Ds z+a^>ye3Cwy2yw`DMW}w$ainb`j&sX4ZNMVVMW0K!$Id2uCv+ey&CI867y zhkp&qs60YQ9dXzk~7 zLAzu;qKtATiWTX4AoLwHG;Cvm`FWgQmVQ?Ct&pd{Gick-9|Yb5r4n_$k1C|EH z3~o?o`ytLp9=%tvTxQ{*sGfoynb~1gtgMaG|f>`Jb4tddfm!5FE4XBMhm<> z*%!azL31$gZJ9)6!0u`|6lRtECH#`X7?6;%=Hz-(Pra7U`2E% zd|c(^0J17}yGy91K}^~px`ChTB|1wq^xeOU7L|!Ik11MVHXVxKrX= zS%5j2CTkJkv<(|LHSCMk*{kVn^A&H)6C=u(Q#Aa?$mqml5iI%Aev=eNex-#bb?4~+ z!`NE@)e&^zf=F-+!Gc?G4-f(bcPBUmhv4qPE(EvW!6CQ@cXvW?cXxL#aJjQL|Nq|G zw_CflTVG9ASKpbcnYlgZ>;C$j)2|i|SKGG~C06Eb5c3ZCj^?gA68IaFg9(^_k0TT0 zWIbeesRVVm<{k-m+7K`$I9x8bEf+HH^ODGqQcykyC&uS8L@D+4!-7e$ZK=8Kwd{9!K+vQPvNGy%xHV*x^3&EGxgAt$Azrb-hTynN5ucy`vO|Jgl z9nHUxa>MRvySO^={6vhI#v~{X8dZ zs-CKjrzI3=x>p$OeB@awcz)O-!u#xVI=haM@5?x?_g<^0t+I-MKcWq9Z8$crA!40F zYB<4ioTHiB&Us>yvyO1EYOui51NxhAI)~iffA?dTt3{1j68_D<=w{y;nhonxB+&ln zZ#QZ43f;Yfy}|_{9m@~CjUyMB&6ypfRqBNL$_hrTw?RgSf!2_LYNx&X&Z`$o%MDE& z7ZjZQ814=d!fmIc?(0CY!*ANw=3o6RStB7PL*%DFz9{K(5}C8osYM6Bonou{X2i9E zWV1D;+1OL9S*`iAPc5!r$1p?B@E(6JRIQOvyLpORyL4A~EinfvUG1iLbBENH$^yBQ zXVgsfPHb*NsSL{eVM`0c*t|+NV0k`eu;Fgzsg_0TJF}BFm6RK*;ysDteQmw});F4Z zf;D!SOX7~SNbv8Ds`ut9vz5ypq4Eq!wKlVz(dF!bd}=3pA=71JwtWM{;pAbsQung^ z`(K&f+cQ&XZL9&_04JumSrORm0|+Vez;;xF)<^0Dx;kX+lcKY~yNE3?ue8=sCy@Gf zEG%FK`zG(NOAgi1zbJ$@lKO(B2IN-bR^QP>37w8lR5m=npc2#o3)M1l8_#7w4;@Gs zAD+D&m=x;L*#c!{XM1!52tfI-;KU`&MSOlcZDs{lqJUUOuZQS6dU+1pLaM;cn)}ST zL9RfLWl zHJ8Z9{tWl%;e~K^(?fvbb0k)p#C%|>jg~Urimc$yerT!R@Sd)nK#uc1UXUf8On+K7 zNp!78F9-INTYWZMZBPz>dK_BvX!hOp@GV#&PEi)BA!EtfDF0{LY*`-zA<3gqdtN(e`5bg<+>2F zJPc6bEwvGn>eHoEtKDN;>asffd4um)UIEr(mBN`~p7>=i4elI&;-VXKE)+9d`Jjd^ zZVDTlkHct0p%Re--`hqwtxw{xZ;n714s9e@uh!+}3Ya9K`JX)IZR@S5i5KLUNG-19 zxD(x)9K|%uM&0<+waDnkRF2ADlf+P`0|w#u2j|egOwZ+YeGNp0^;MsVk8u{ObB8o|_}SgSFk>ambla;5ws8reH0=R_#OX)^u2K2`?%4#T^%JAG2FRi;P^~7fqHAVk~Tyw zQ0AlG-~J8M1M#671Bu<0OA+PyJ~cuF@qRkIkkRHUzBmMj zUr)x~-xCo>qlpM)lehdenZXKak2merx80nkv-$K|vh|+4W7MGHpz5o0ldSW%8@uoU z-_`N{Ra5Erp!A)1&!NZG$&ATYb@J>T|D1ctVd1SfA{c(OrFr@X`ufbyW7aKoXlLrN z0(y)7uSMvL4~GqkU0TevkZofoo)=SHT$_#lLcVqqbJ9}FEDX|O>1~+g@7gDwW|~%= zE*het7OziRY|3?i5Lo)1aB?*@lM&`5N>`j-#z&$4GMyZ>j{ztS+!TmMJdz*w2@h3T!`id%TSQC3xeHm;cDzo z2;OtNmG@YhMZZ~>3D4{N1qit$v}%LD?~kDAP;v@abo_vJp?*#b^)Wk?8McbyU(Lv%8+go2OAszz|}M=V9P5yG6$%! z88tDMskQm|tZb$3?bbA;WQjQWKi^su5?g#T+JMPTVa;}fjyG*@7s6G(BXfs2bSe88-iKt{h-&tQVcjYO$^DAa$UInCF!H>FH;80gQ{)Xj7c(r5sEUPtV$V#G9XOw8m&~^p;J6aXM#3{PM_;7c<|PZ! zI-?hz3n=r1FZ;05HLky;B!Koxp9q7!2jtVi3rqeXD2N2bfOs-oZls4^t9r2~7m3=> zZMP4}&@gf}VqMA=0DBDzEr!2O6XI|2n9bT6ZPrqx4)8Oi*%Oq)i>&iQ(;K0YoIeWO zy?bTfPm z^&u+*Ofm7jvc|>I@gz6MMoW^sZ>(V9M{IAkG8V5+U^RjX5(0n>#VWgpUUeO`(8n-s ze!T+;5LuQ(F6ta`z#^AGRyTmBJPk@S_c665Bts~zCm*ItQ<{|Xj8@jOQV4X%n&)kl zW5ekXtHvOK;S27+a>YD~1S8(y)LPQxCjTc!q8-RIC5nqL#@OQgmeqOmO^_G1I|=$0 z{@c+(RBJZ8eA(?~88(JNMnanR;lorkK{@iU&LW?~q}hcl=?p1e;J+}a*au}k!~ua| zoF|cA-Msq#_|3?lkiG04(Df(A@|Lk@wh2AZ4$|LjRAc2!Ec&0>by>03oO#%-gha~s zEyrS;oHfMC&gNM ztX{GToAkG1u$Rj(t7M4iu0W`>(N5FUg5+2o&2Pdb?}9wX*;`p4If-@3C=kj^kVh1N zX!m-NimV}W@mB|z1ZFjLMo z^<+G#hwd#K3C$b?w9^EI6r}g*bNkOu=%{=}!S~#rwdhbHSgbft34-oCaJ22#0H{A8 z+))MsXQ)savM}a5cda<@v+=ar{R@6#uh}tAUa?|WI(oO|*;bZy=71HrCKDFAR#(M> z9%mC_C1E1Gb|*p23-x(8iLmtG^#1kGf2Edy8*ju%{8tFg1C_|H^!O;*5HT7w&phaG zXt2V0>WQsO0sxg(#4@=if4E<7feU?ow}mUvB`6TMK!tXtRPIC^L<~8ya&aX8`MW4- z_W9ZIg(DHXU|wIbFJEb_PW0DCSrYLUBWkyh^$pnn@}*l$67asQlKEGJCD#zMAb!Mg z%C_f}FW(gD2qo`6yUH0K;C~W<1K^>Eg%}W&s8?W|OUg}=ZrHT=KaCKS|2C}jfwF^u z86+s;zm_ESOG?%^{|~4AyUvaTn39Z86{x3vDXxI7{P|;T2^(!oL5VJN49b9ex#q`f zCsBM?m}3O{=w(*UoziFP`p0}?ve!}~8Om>M$sv*v3Wf6eKeF6E{)kO&)$>X_;ePWc zfhF&c&pB#J>mYS5g$Q%buWLCmA^$)0k~3B;cV_Ncd2jwlkW52>KyCrh(2jBvnS>(H zu+mAGN8kG3%o}LNNnqQoU_smwpJ)AC@}=xl%nI&52nbq$3r7=^rJ+xvya<=B7PeTqEnFn&63e!*DF{w)<9QX7f0?C!w%HyP$Q`rYjf;9JnoC-Awv;L!%A16 zwygC5W}s<7>P4g<g7FVY&a_~2ii)SUi^T%<9{7TBO_{C{GixI17; z!YP2(ar3*(vu*6~=9Cj@UC&O&shaLDg!J1w1r59~k{bMewbu$g6Ghu1@X5mJk!YPv zP|qsY^`pwGE)`rr41#h+*=^a4+4!9pe<9;lGqMvFDc!fJ7BP*LHoa9EPlrNIuTYq| zNQzg8h&@{vzmM8~v*Iw+Xi!qZ6R${6QP(){&y%9_EZA@lum)v{5(RVx2_b<{px7VK zA>?)ieyWc194&>3cg%^WdhD1ZI!;Q+^FWYgv*zJ0Y%D#{?zXK2jZJ<3I$V zs}Q!T?#oc!%pBw*QTYPlKNce#KrDH!N)2g@RL$-9X2CPwm`oA%5(7g=-7_f|yC1O@ z<)#kh0NoDs9`!)NAziAu`LlIjRtR`G=bK)1%aP<)m_g-FA(g;us)~ZUhTj!Tg2;12 z4{8G6xsbe8?y%6L`g<&5c&qgcc={9gxB__`K)2WKLx%ZZ#{|F`% zMlf#zoww0Onq2hr1`)*ye9qWWc&E9)9pWU&_LTLzm%6;es7K>c9Br5=>M&qooo$UL z3R+bd(lS~sQnhUFy^d9yubx{9dTk~C>xdXZX&cT%{9>qZN}yXEp@hcH|Y z^E~|;Yo;W)2DPo-%n-mHd07Hb*%WCD1SG}fl*3c11bOdHY#6B53T0K1#;L!qBeK1J8qzL6}+RJHDjIeU5Ws_hGweweHa`j-*MV{SzK% zD-+^)c=LIq0ZJSp6`SZXQ7E7+fCl+OiNbyYOrK;|>;ndw?g6kVR_l}xv;T5e!73(+yx zG4D^ZNv zd5qto;Rw5d)oGhI^^{pS44wH|4Aspc=R#cr!s>?11b>tEu=mNjU1w4QV zMSE3HsN+|3@R3vI76V4O^D3;GBX$RYeSb`w=+SLBKm}M%fVwILW$Y z|L^HdU!-x|#pUc{@UBE32hZkY1m0p?#esWB{nNkM_;WQrls2FtE8>VBJF2}Qi4WGC*U^e*Xt zE1^-SM7ZoU`jXAzPPkh%-#uO}<66m_U4xQP3nlp0fLvE^f9INY0R9oQ37jGWVP4r@ zkij=bhH2;)DL#6_TXwRV740eJ##Gj ziUY3*?!U1G70>%8dl!_7xTJw;L;Y%f-*_kr3R-;As`2#jzu*ba?Oh3M3i3m8 zL|Mo7AZjP~64ZJA$C}0$rLBN?3u53fInFfL};Z1PS=hXfhzY?_pU=vK@iqGn^f1W%Jkpswtlt3>UKw$o`0* z17YjHzAXb3YdIAy1F0GN<6qA;4A($X`e&aDf#i6;-%nlMT?Qk<6$sSARRRdLDCQRf z=8RecfcYvYD0bWX;U`l7-5rQS`9JSy=A8IaWD_J92nh^fI;cU!S_RiaK@Gw13SG;UTi6-LaZd?&O1;Zz0ugpjPq;D7(u`*x% z!-_gJd$JUSW<%;%szyZrq^^|UB(0Zz=g7f@Jns%$hOVtfO{`;H4rTXHW^kq)s&^Bv zpQbkw*)1R|U$!cvonpz@P~pb~r*eegx_ib3LPE4`rOO<~QeoSV8i zs7l8T_bmtkT1N_w{VdX$w;VSR#7bd)#S=tdBk>RMmMF7iCH%)7jxFoY_qh#hea52I z0mkI4-!%gO#@v$m=va+D!p#Bjg!M(=JHAsj++hTPMMXh^n>tkQ)dcFeu{kku6(rvVK-+m(RN7vW@gUOEmhIBr9h04 z`-G9jJE^z6G-8vIgaya<(|2jq79=8r)e2gVHyA7$!&CNm!k@~s-5{gFbkoFuD1L_? zm%M_|)#x2%YO(9mJ-?-&x`yF+ze*=RhX$1Wnix1PrLiXnFT`q2Wo8QYmx+FMb@u8M zDOU+D6A3G`drG=~!eIr6$baBbMW=C<2Hjzj&(d0o20td|3Ellb+R+qJ6F63#>b$bwTTjZz1wOS&TaA2!;FGKVJ zDDpOmGT%VcYY?`Z%kQoJ0KY^-Xs&APv-k1movH6E12SCimBjK$F@O2EaDIOK{2NiC zFr>?QWu#DI@Tky4$$a1MsJ|)+e7ww?Deu?Y-Vy+{OK;knaR{d%RmQljzX%UVr_ion zFx$%=$?0AF7j||1*=K5+wadtNIR@YV*O|8Z<5>uqrT|leU^EtZ#Ie>*%Sd70DoDfN zMUt4HUs^x->1V5=noNd=KT^vlmZL)S5kF1v@>IkKPqb_?-QWi%eurA5eM^X49Nn@y zMwagw0gN%QE=buFW>h7l94W~9l5_K#+!#wqh7{UaJtR->u zZx>}5pWuq1%K2o#LNLv89B)lM`hanj>O(*%X@cIh(eO+yHGHONK(!QX^~9Y;V&v{T zl3gWF>=)iikG$}4`k*GPOzS1O-F3Z!tLtZ-hc`2d|hLQ^cb zL-*N3kv8^0$?5g5TXl z=hX_%yQ~_W#za!t2j*#N5T#FA-NkAFi=Ir6-?2*zR&yKf#&y8AktJ86uaDBJ@q~&( zJ8=0)yE1%RW#lQJ(syZGG>v}`Yh|@q-u0$)2Nctl*54JVy=CH5qhd8FFh=Q>4U9*i z{pu=>o;vcnz?eB7W4O)}p3CQxL)(wZx-Z{3M7c=3dVET_6U7cxxJ*03@6NS|-nv5g zG&CG&p)33s*_l-XAeFXofCk!ja%H619sdyhNwiMJ8FlWs5yj5zUg0J4&a*X00Vjom z-S2CP@K2HFotYt6aNaBf-#5S817~N$`!c7c{N2;J+zYhesoIe!JBa=S=?`YIIRiWG z$Q-PD(4F>cxZak@D;n!i#3WeOO=acrd02jyeBcD;J$cf0a*nyTp0M@Q+Y$Qcc7c(# z{ncOL#*-TDB+ro-SFBUN)aD%RamkY6B*17}48b{##DMd@fWx$0ugKlDDBV@g`J3DC%v28F>sixdcM1bB!f>Lf^dzSbjDaYzPt9k(`_Ti15jX{wQft9w|&TYZ=*{In6e% z=)Qu}CXf!^kj5)w(Nw7^wZUw3^V&O77U@QtmH8S2Oq5a;FUAL8Hwuxn0T2SSUy;U| zxaz=Fj$u>V$De~BWdTc}f6A{|E*gG%z;m=XUO*|F`k8HRM9Z1R%H6-oIddVW^ODx{ zF)L;7{F?lPw!E+1Qpa+wt!7OMhK1RNo!N#YcZ^@1%(KM_+r-vk&s5*<;CvJroM+4* z6l6jly%%epZZ{82Hcs>MQSQkXJ*7_DBp91o6A6EP{xi2SWSkHvhfKeuum_TQUQ0*R zN&q(abf7gnN|VFcrl=V5@zCvqhM%Pg9(z zxL?fCpExbau<&7DE3fxI6GuOtuwxrsb^HC^Qy}Sw+63cV;3tG{udczYU?W&?lX@Gv z6&iG*?Xcnwbesoexa{(lkNuYo?4+XLep!|~WNE|o{bfnU#p~~enBH6v$o{RVR{I!S z6TCy;2fKExxl~!#l(k*6_dOU*rgu;)tV_=l%M#0Bq!Pou^<^xOSJ zI_Ilseq+MQd8bq`BJK}1nUBm{K;n6T&2%Lx9OED$NYoKU2I<0pP=bLdYjTxODxdt# zB3cR+KRnXwGQu1Xxb2@KRIb}my!Nb)p^J9HNKnZJ4Z0|?$9XqU(po; zy_=jmjKy9>932nZ1}S^T2z0N&9Ayk2F#qXu&G($70f2G*|3$LF zGV(A4MRIDQ2=8C`^5<=}(l^>xe}7C~;i_w6Y&gFN<)8UV@qC*g-%?|lc-^p_Ue#b?zvcT;Z$ZkRfeS7uAdK%2GlpA#Y&>o0&Km(n9}xGL*uMb zd};-DlI3Y8Vw+ZKabeFJvPED^lr7WJ1LU=BJ&gz6o>#woE+- zL#ySCT$}H@(a~!iTidZR=wRBz}th2XjeZyT1fBoog^4AiB&bj^*}>{K^vg z+Df=3g2vWG{80A>aCK%K010$N;r$ayk7dE;bG<8zp-u6lypZUQYwpe8eFj4xY?P;H z5?o$#Io-M9+{9Zt^W+t{Vkn>2aek^n-y01lNjA}4zK+C;#1o|WSFRlHF)35ciI(y8 zVgArQp!jT@zx83idysFb@I52a?xbak4E;VM4Qe=*-N)~$KL8YsCq>vYw*@$-!=cHi zW)NuqhyuldP;w$0V}gy?o{a|P=HXo6cDw0qv0X+hiJoCX>KL(H~$I zVa?ud&8n#8>w^uo2t4*~^P+%B>9(pPsHD5DI_| zC2wSf2!cHq`A|+b0+o_SfM8h{5~l*gQIpc@BSK!*XVI&>+F^ZehD>qI`rYbuL&P%X3~- z;ciuHd&ctK^f)hKZ*p~#wb?a)m`!KZQ49A)9OcMzFK+e--KiW?(|yrg(rY zAELh0RZc~6dka|QpnZW~Hwti@fZ23N@elfUzbC*=zIi>)EY`-Rz_6}zDa9b6!<60su|%8~p`3KZXD} zK-bSDE<)jsbn124J4sH89&ilO&Rr_WypMVA3%eJz=RX58*^Y0&Nekd%=n<1c{1qgM z;#t;cOh;~3>D^eL0k;TO4R4GHVL$=UKE038`hs^Zll%lCVp#hp)cJcLz*ZYnDxn+t z>ipD(orLqVRpkf4ZYid^1UkWZu=$*pkm0+8?k^!tt!uUSSjP0I{^c^2jsJ`nLXk{d)ji5GGl3_HvvwPxf|nnw)=?mZo@>k2+4(o5npo--D1)Y!}E_CzVA7t>p!o_Cf!+&Y=~y)c$>(uNwB6Z=)zT~l9aY~Z^{{b66?7vN}TY4@_VdyK`+lmPr<3bh~p?;N0_>+PyV_)y=mM z{?{^8KSaqM(11i|^*6$i8E`s<0Y7I!!d)MaD)&LzISBtgcm=xF8YLja=yPFV>i% z?s;b^btAVlR^919O~sj-<-S~8{r&!jW1d3KvQ2f%{(WKo*)$WrhoU+@M9dmX&VKr6 zESUFDh;~cS$)IFM>^SFrO08OaVPi}|f0qAimk&@#n8PRnf<8jCIjxhNJFu~SQFRCy zW!`H&i)6X6k8c8fKvjNJ^qWqCt!PYK^Y8$XOW#LeaqS<50R+7$?+cC+m^4S7#n#6m zsvM9RU{z}d!J)&SOTUV@nMG)S3-F)avyW?E=A2uj+;s&4vugU;3g7)(?M@;g%_5FD zG2kXDqYnOvJMEkM8lJ>EnPOO4T+TBx{h1)$_||wGWtF#by%MqgWftAdKFMA9c@lh* zr83lO{m1o9{FH~cfaS$X`pV!`{d2Km%Y9y(_W4gdhP1N_(-C_Hq4mu6pNXZ*6`trc z)M&J?H{pJnfBgWwuw=iJN05z6Ag83*=;xY%Tc=%r_~zjpbCfsEjr}pdH&g9v#e@zy zRMAV^cKn+H(E}K9V_NDS{^6QnNS^-6=gm-?teHuUa&I{Q{aWqN$N3`*w%d{XBpctO ziPAb^b(jN>YToxh7m_n|SSWQ|^p*NRsIysRW@F}^oVvRjGhCZ3dxPJAjH14?LGij- z3q5Oju9{l9`gT&`+*Tp$BTn$SaA>PFxPCu~DN00rK>COM(JVBB<=ogrh1@4?5oN&1 zZ)E#V5xt}DiR4V+e&Ydem0(V?oD|koQg$uatn(5D1J}(bYCZ5>MB1m7s4StW^q2w4 z81Z$hq&hq+FM8VEU_a(ab*OA%*?n|uvcJ?&1lMyx^NXq}J!0MF?6>-T`A6j+1`=_$ zzB0!GBB>dH&(>9M?9JUygcW3E6esb96t$&Y&-z*9Y5XVzlnZf9d`mq2bNr0^Lf)~a6q{3bwJoKO zOxNj-wI1``jD&B)ey?WcMVgqHQRBJFNfcQb>tCPd+ADA}8X)5DajhIhEZ>oo7LKeL zOgpW9-Ab`+ceg_AyBnF##!w29{5X0s&haLeXoYmY%JeX?X#u}h{fO%m4f4lqAF~qJo}4b`4oy`h+<9LZ97!5l zU9)Ct{f=dL??26K9r0zX-U`za#*r+QXS&8BO5rs1I8+`-;}V3Xh3w4JmLfdK&4}Pf zFfeer+7t9&G}Xyy3O0T+G&}iu(2TDj=`h})d`P@!{yC%XN%14bL_+#nte(0jMJLny znKLY%ipA)+Pe0i+b6N%V41e<`V_$T?mT^j7UT2k_&B*RN%UqylVTQL+c9FzxR2Pw* zy4A}e&T9KE_kpngU1auUjmD@V&5*U}sCq~f1z%%Sk4>d{6Q^@n8R@P@yt%4o_Cf5T4M9IiV&5?_Q;ouXa)U_1wZkH0f*tW)6i3sI`|fs-H~_}ncrkB z*Mh^zv;;94JZe+2QvWKq`SMbqc%>){5gCA)c?0I}u@=fB!>wZaDpl}?O)F~lQS^0=g zjs>viAi)I*GcIph(P?Lw9+5nVtE-cK3^*p$*oa$NEA1p8|12dXD!9@X5q(HJm6-dE z+3jn_Xt2O8GuA|BYu+Y*;Y=X)JXw*7DW|*D-+;yyX{WR|D~Zx=H`2-3??nB4p|o)_AGL9It13nhgCeZi}phe<2@_X@jV?94F`JP7uK7-UVIG*fv)UWpgwnbv0$7lBchcC{e}a}>=CUpXaDm? z;%nvGo{6v=ql1Hu7L1ggA;`hz4$8@=`jmwDU24r7vV`8-?$>(-h@MfY`+pW|SWL*+ zvLgD>l*lTh>XOAzHdVX3f|Na`0T+h@++ffp(E7G~`Hf=1<7T=L^Fs71__?PJ%rjemSso1KH{9P0 z!o(Xtog@~GFo~3U;Y4`sG;#Sg>glei$SDmGbL}>un#=ZikoG!kv2k`y3*x*t(_~G> znH?=mZ(ZeXlbI53No@|pWGB~eea=>>xB;%=Y#z`zVYjd}^>^%L!;&K&2m%)V(98$# z`pCZTh7U&csv_$q8471)9?e*`U-7fA?VSrO52|gK(E?JQL4poYZ!l=$d7|+cFzkU{ z+oTWIC*+VQZDp1d+=GUE3EM`r)SOD9`d5g`Iv4z4h*@1V~%;QT>DlFgKcv1{(B z3wpH=y@(RN2WhC=o8W`U;AlMERe zVzqNS&7)d|%yia^@t^lTJ!z+*Rv!XUAd^DIMu1}qt8pT*AhJFR2>E!tf-rzV!$40~ zpM%3dp_mMOJI3z9r7PcK7qfPc{XUR=XJoer{4nhOcmG3>91bxQeevethw?t!=v1qv zUU_cA>z_np?=760Q=PcBOnZTqOMsb`pwgfR_T~-;=%Igav?o3TC;-l*$VVTQze`^% zubm4JYy~pULw8WvDfB-zd#Q{7Dd8?@-NYJgg8LU{A7g3V6+fLLropuC^nfdz_Q33*`vR zGL%^#4Z3UD0)$NLFk;iri9_920^A zqIafePm+ounHWBHiGI3kO$=8$@o~qA#3q@TH{QMM>^amsYx5f3K~Euddo5ZV8*#mb z#=1MPG6x+zD}V`&xUwTIa0Xtw8G}qrU9l_z4E6vmy>cO^yo<8t8fh{{bNQ3=xt&{W zl@DiLR+sr0YJJ&B=@q(8{ol>`R#M)et$UK6LeQYXXfy{g-GVSjen4w zMvsk6-P>hosI$v4A{lul(F&Yt_F=HmSvq2~?UK1*^MjmRItiWQ3Pm;qjm@Yv38gr> zXfq4DkQA(6)Nv6Y){oU^vZgn9wZ%eM6Hm^MK)JOeB^u8jU}!%N`;ws7B{%@U^bEY? zw37x+%xB*PKGwGD*RlV7nMD+s3zKBs$16KDX&J=1v8}hiVLX`9Q`lp@&}!ejZ`5-h z{AMG_!9vk|`V&`2 z)kc?gVpP6i*G81GzEyrBg%9WxKH_kC3Ho#VJG-T1LEeasfW|GWAJ0 z4hf3T81%o3-`jS4zScT%Dc7miscz~pUcl>6&}K1`MJa9i9n`*?C%e19K0hMXcJHN; zXh{$YiYP@dplTVL0L2bTyirU9U({Jj*my8Q9UB3emz1H5J78!N^qglzAKM@{J6d zO4ZTS^qFEjI`uQ+-1Rt0)76(l%UL#Vv@9wO@pTz+9yfvzby$Fj93c?YrX02l+vP@f zBakvgY40ve(Nto}k>{SJTFdNpR*R9au`3j@vh9^TaeO_=Wbl!H&JWSjl%7He!OdAU zvu!3@@q7vIeDSJe?f^I9ugh0c^ZxIwdsGLe${JoS&i*NgIcW*^a<3xUdI5_7 zHy3~>c*e*da-{%Viu?v%hJeQ$quLFB=n;p=%f>YbfX=9hK*4)w&mzCs4UOt@U<4tX zXASKFp069`nCxav`fi)_Kys+mrNR)JYIDKFQzw-=z)H1 zo~!i`d~9dn$G%9;RGZQ|dPkj4Q{BbU5uxhzf}l$;ZVTWnKQ6Jsg+q*2Yt8pw9D2VfumM6&?wFX2jLEY>sgN z@)U~vhdujmYN=5iBj^|GE=#rS><&+qB_TO4C(Z+%*LhVOq@vC26Kx)?G@|lNOmjFjHf%;GUpS z+kh{{@npb^c->X7-n-|v?+YIe4ig6oHo`IWuZwBinKmz-EUhlaazajB9IIMAhd~lz z1@@t+rrG@KChyt?jm9>knp`!9rItfvZ@3Pmj*vgFppbI@+@Hq`CT}6{!!d73=`s9? zVKszX!rOVzyT29L>85I2bszU6Z4Z99w};&c=Y}`Z#&nKHM_B9WT}5qWi7kr&DwD z67RlOGk7F=LgnFTgmhCqjvUYHkXLeR(oawUPLK1NlC$y>LgL1yMk4X_xi=?TRe7-bEgh_PDzdS_;S*Y4@N zs&&Y$2&Sr(fZy?ODaPvjdC;!98>uqtYB|$dt4$`Jk=H(=GQyK;+t%a-Js)eVG{pop zRtYM8XW|{3U>mO!(x@-{dqRGA@a#Rb4o_ghn&!kE`}A6f@S$qe{#zwM2>(R?C^NmwvL#JsE)Dq%CIh{kjJ#E*+}atJaVGr( zYHjK%f4#uDe7{FbYU$CIYC)yP`0Bvu8pJ5-DCU^^0_RWsuaiAt`4daRdt)Tt1+U+Lm{E9ZKOA`xGc?XVWmCOFmUD`X5*cKisw%r#AR48jA=LM-smVy{cZb074TXVbu$ai*v#E<{o}MwqIS)% zMuLr&k&e2r(qrVc1SNFhW8^mHR1768ee3Mg=ZmyG6lnJzHBA_>@8bJ9?8jTu|NVQQ zKlwbj`qk%;oMG*>d&H{%z88v*8M&EJ{GYe}3_KcOL&85(c!-xjDJC}t)!tpV8G0!V zAI~bseQD!}(=6PFS;v}J*RDTfL+u9La&IX22z~j#kIi&^s#^)|Nrx?-_j`oX!=Y{P z`*Uk@6HX>l#YJ#xyU4GcyC=Le|BBS@Q3_TXZJo7wiYYVup`4(%@%R=-5(E z-dzGGjl{_u@EJZD-B?3oD-WIMUz6Xf?vlxge_*?LV#o|P zu1qUXUaAg{KBG|f*rnNmh`SJ)yXp%=gD3uo7f%#%mtlLO9OUCn>ob;>a{ffocK9$X zFy+vbw&eb$*|R(N!_lO>KfeYm^}GZfGV@Q>npgZ{18+PAPMOygHDM`O?54G_72d6Rc3yT}N;Ui6_y#a+939R}t5 zUwFoJMQ@jPV!c;-O!{9%B`^v$8_*)*p~JA;8(bTQEa_fltG@FSUXCh1d{?;6rOW^AM(uc%=chkitJ8@4(|^YL5Z_A3O&Rl2_Y&dJ)9Nq^*z#h5$Y$J*vDjtdE&sj@i#6S8q>9-UX2jqBg$ER4f#{+@|D z>RB=fF1oEWD@8{DR%24!@yqK2$?NA;Nx>RNg02*HYKGZov}ekuMa_qJOQ6Z(AhbIh zI{A(6CoHi~adehL!u02jopTpK6@`S6*wN9{a&hlur?C^vm44O`6KI@Z(1qRh$S#I+ zW;1&8e%4TVha=ECCsa9V>Sq~}-TFS>vS+$3(-U?1w~a)7+dUwlqYhg8pE$94 zsNRTrF+6=}WJ*I%sCX-ZF5$Rr^RBlg(}?kdS>}%;Tk_Y+qfhi3?^D(VQDqfFqwDtO z*eLKDSE;Zyv$IwP8Y2GUV4Q8>u@JYXL?rsB22&y#>C0QMb1L77laef56ufcHOOYy} zSbpEO8H9f{X6x`Mcc7t1;W>Ltj`wG5s5y7_&-%KkWzvd_n2R1aEqp(9m3 zSo{yJZ3lVpS5Ys*zzX&UzrSN|u5L>jibEn)$K9L6$Ys(qnMwAOroP62=UE(b_E$65 zJ3rj%k`OA9ewQcJ&VDZ!{Snz2bNg*SSGbu#Ki6{b=$7=?FyTnv233Cj$M6RY+xJC8 z?C{l@$LxZf7A7EL!x%qlb)`97mf)qmalSi@YG%HpN21xJf&GZX?6)z~6u=*9}8sTmH*M*Pqjc$?K!@SpcD^+*gMFfNV5kPQ2 z*__eVoNVJLsGT#bEUos(6!}ry zve&ctwN_eJ2%P*s(X#KA3KaN0MA#O;%f05%DF8@-Q^t|ei zH_@ZwFzMQ>u&j@;N&9R3`Wt)Lh@lE%kUgT0^Y;v}P29V^uk27=oaf^K_J{^)2E(A6 zxFj%%;9Z>GRl#aLc+7vTfc!J49qJG3;yk`J>c;eO7KjEnB0(k&z=G-GeEVlZ9wdkb z`P(Pv9pSzG5bID-jZ2o~rRl{9wz-A0kxKhBCi^(a8bP>Ccgro|*B|aUKH@Wi!M&pH zvH80Ljvtt=XtLFemu9-q<)$@~%aWz!IG-<53VL^rz-sgcMMOl9&C{KhdDn*NYlISM z?2!{P+*P7yNv|$EZ?bm%;=I;nKNnjU!8Ib4MK1u;RHNVB$3=bsCk^1S^+`FK(w#=U zlkrgh<@Qzg)rOa!Olz_TRy%gxZSrXN`_)s&%$e59Yw#fI1z|*~ksz~c67za0&SxaP z9d|#sayEQkl5Z>#*MevM=sye&&N4cX7TpjG-gh?Z^m!~r+z?*RV|ICB-XytAAb%E~ zSvaQ=cip^iUwyccWBidNE8KSI!{j@BrH9Bjr|Phe!aNkcx>K0vw!iP^K0cvetq7b zA2ome_%hcW1tQTvco_CFF}d>80(Eg&<7QhT_x_MdtgorB*&Mxw1NTLH=~s(?ufrB` zgFD_LtCHs7K^&1OYqNN#aaCYK)_)M6y61ZLZb=f zz614b5y-Xs9`84l2PsGkZ6(+N6R3qeCnP7J07t~4aulEd{Rx7I1v&2x{p2Lv?(!i);SIb|?4b2=P-QTjfh@zvm(=_$|QIvlHyuNB?YzJC`V$ z)cFDsd*WbmV_)$HZepG6Yd{1Dv5iFf!H*)?LO{M0nWl=t+;YZ52uz2=?l>y18clLG zYXPJH)0Q8`5mt;D5~FucJK;0nh5GtD0p~+K)Y9j7~guApbdd~qA&w-Q2PMFpy znET%^D!2sSou2hlaYp0ueO;Mfu!=Hjc@ zNb(i_2+{9c$y$|ITlH%Uwe4&ZyWV$|T)WFodRwh8lDWMgc!;HIVqsuhxCG?;!08Tn zI$A1)26YOOVNzVbu{j18E)dgp8`kA+{s-I2xAf-a=DMDKQ;lse|L6s!5>Gg3I48tK z1j9bs*r3V3dNjm&vK&5*fr-$MMKK`22pLbJy-;UAWLfr_4t?|oj1{^503iY zyvH@A;f3}~m8R6TA4=E6yN~09CLd%)zgzsH8O47oBL2gHRe7VEG%8#Bm4jXP&hzy6 z`Le7w1~+|z3Wi77=2{IlFHYlEAVWl#XcE_;phy4coj$c5#oVr{;dwstcno62lD_Hd z$NxWI5JuP=3>v<2%RVDB(*?EdR&y!V zsI8?gXpQE?^4MS-6-#4;Y|*R=6mYIPtqQ!LjOoJe8Qffr>^jX+3qc9c-<5rJCq#c2 z_s#=+N-y-Lj{F;U_!?$7NY^^vHvbhf(466(ZNl%Q78HUJd-7O58C*4zaPYc z8EHjT&X~As#yNIOi}h2tnbR9e#zI(RDvR|UI!Cy!f~G$#uF7`-Ifa0rEPRr!ct+6y zG;ZFdg8<-f1!N0Ap&k(4&Z}P{cUt=29Iz_3M0#5pdy!y}ion>W`U zulp0&rNnd%&48)}Z-vt2jLH~?&7OvCgLSKV?hWQd!N}Na2@AV@IcRdsz(ReU{?O|| z)Y$nz-?^?&3FGcyuf>hTMoB+;li)_4>rN9D0lZ_)gVupaHdg^o>kD?o4*p9ZS)=AL zwPp1|tsAMXm<1gV3tKpmKr+4%k7T$>jWa`0u(3#zF0Mnm;#xYDTcj$-B!TN{*R=SiJKMM2t<0HQYOvUb%i7?=jgT<@ zfpOl-t^?#j3sj~soFu%5mI)N?f5$no0?z-6b7b`Y?{SW|!b~+^UjNc~Q92VNpRtwx zmVJQi<-J|0Br(~^V`%zbQzRnDZQ=;TFO)DUZsuY2~|`E zs~n75jkNK$3?GaD-2*;#c$)t~5&CNn&h;}eQuPqRRQREanKS}Es~qx)+W#Sx_+F+{z_NtA;ZMu zOj4YSvKFjaA=T21mLn)eN>lZ6ToIFsD=3jP+A>79TZGxdZ=_NdmoHT9Q{K?|1<@HD zP5T=n?cvYU^JO7UOy~TbPf?fcxOZWQe65e5ApX0;AYaywInPmz#8Wb?){Q|Rnm+|n zf2ffQ6d>!-8K$%ytq@O)SKK0?EMaFnN_@D&Q62Pn_#qx7f`hHwi39&-|I}x?)le{D zQvd*VALG05b178i4*<$_Xl(QutF#Ltnl6ajje^3S(@S_hs$uwY6!#`So~dMVZT-EOkips3jhj z=?C2qgz#Ky`eop5Ta&4uDig0pr*|$QU?D7lq;jM*NBFqE+;t=d%vxIC2#KoIH|qXrGJcH zN(8i44LL(PF+E(d8AE!|G_DhbJi}v@W2MqoT#g)3Q)$YtLrzC;`C~y_-#`uLf=HshOmt-2xWu>^m_kVefd@agL?U`*;5bFg&u`1n{U8%! z4s32eY+R%yA1>N$!1oT~;Qa7DPb}PrWPmNb{^xYQRvQJL&SD&E*GMa^&OXm>Ntl=W zWa^)T;oXq_w3>o-FfN!WNq@PSP){NWrWamH_)g1;1n8emfV`z>N3P-;SAjdSQV9q& zA_7Kiriob2@?X#8DAC!1g+sT$pUV=Whclm-F8^^bZ=jJbSTc&F zk%-|2{fC3X+s3jL+H)wV6aKgugYwP{H(K1#Y+BXI&b`J1%GG}Qp zwXfY513=Vv$#`D_)8B{lEeCi}O4Yb^`2tjiQ={3}jpMcpXA)d0LrwO5>>Q7;Wh;5Y zos)B>jE?t|WYe&67#nNOF~8Q_qTiu++^=T_JR1^jyjKT&)VY@^Bur?sCbGpYZ~TRW zUnGalR7iW{+N|g#@0pLF`V$j9e(i9R?=#Xq9`NlTPgxF$d9_-E&dnu}AuI&ttf9jm zsX<-YZU;Ldd~dc+Zo?HGEJy-RWQYvI|Bh&D;Isxk_lebK6zW7P#2#1b~ zd7cJ9%u2#LnVe;BVYLJgEygs>w)773Ag3D_4`2V|9fHjJ!Avm~te*O;Q&-nUQ=$|! z#L3uLeZ1HE`_UcsYc%xLbk1p&EI8r$@CJ-*51;p9GX9$&Pl#Wupt`~C@CeAiyT6NN z*w*^)XjJD<43BI02l%+d`zQGL?rss)gk%JX%N_qJOJ&DT~(IqB)eFB-Cf3ja;77P!UFP z$}G(!X7J2(X<=FVBd^9`t=obFOhc{i#i&S&@T;X;YlZjBXsrgYbH&`<4SZe77B_%R zYk2Cxe<3Wn%0xnG?Up12h`<=D?E7gGAbQ$*Hx#(#YuLRv5hhI~f1#x37}grpPCY$= zf_~5MIet8+vOx$u5rDCV65my5a>`k+;-QQ9oWlqO^LxWiLd4_dQR> z`bD>R=V_GmEUQ@9N|=gHB>j(bYpWx_Cnt@k8|V9ywJYwr1h0b0v(eX=?xUQlpM&dl zg{v56CJtTzy;`^3S9uQ}!jFmJR2WO+8JMuvT|wvH%%4u!w{k8DpIU}cr_tKvf{Dlr3pG{9PPSkJBVJ3b z`Z=lB`BZPaj9A~`dIyCbf@5e;VHoEM!Pu3X1 zH2gmvMz;57V4P-sA_uRWOtL*^uQlKph(KxL2#a~SoUEjXKFj=W90htQ25r(983 z%2u_5mw^1@Z8oE2kM)7=ayvM#IOss_jWe$}T{P^O5 zWmht8?&tvy2F0r%sn>S8^RLDha}B5>pG~K)Wx>*-gk`tFO0Ih2a=n{TsksFPYs26W zQjxEpg@5kJR=~&<9Y4w_;h{>DWLICeoHYAsZUJ^>kKAfVV^zHE?yf?tvR1X-1+}N6 zGBK;0yag*KuoZTGq_=7Z=h7UhFpmI)zr?jaIYjX@qVD+jb%QfKuukec`MQY!zgoX( zt=c&AtH+1eox7$XZ zsD0tQX_(-%EqUjy{mhcxdrv7}f&&XHGi>pG9hiZ1*3YV)B*GI{CSOt@c8o#fq7`jY zvD&QBzoo;DcW<(x;Q0DAcfOQA12*%D&M>%drarsF$$EMpw-zYcU_g{DnvLrXB@Wqv zE~`JpijMItSk}d=@Z_PWwod4NEMG3y+Km)rBoAE~sP_%7&&e+Hp209Z^74w0k&Q|E zPH`hvduwbhZ>2aGsWg8ga+7t7Yzw(HK{a9vIL=j-8UFNK=moE6Z1r-}j6`d@?lU3O z$xO~s!k&|&UH1t6$)qE!D`Yip5%``*l$39C$@UU4ZPk+K=!5*ePP^bu_^cDqX{@uM zu*UMMCyZd&VISK}T-klkr_$y>Fgt4=o7+X>DyIQ-Tcl76%~->Z*g_V-B6edcYf{`Z zmoDs7f@^8KNol~=3A>wq*;SCw6^fE6(O=%+XgkaGSpOueR{!4Dz-s(nM#3jCbr^d# zAX9M{dF-M=&<^i}e7^E~ChCTSd+ONBL8+9?rnha5Gn6l$v?;WGS?n1R@J?Z5)bVvX zIVWDlQ$2figKP(P>X%{hpYZkR_l15tJ_RKl>o;w`lw{So$Z#1)_!`y@#TJ^=B#8Uz z%DJzGam>v%bLf-Oq<0&BWHIk}k5~>$6?g9?QMB)&QG=BAURF!y@!tn~*al)AXcbzY znYXz?BtUvLT^HGJr}FxgGOc32%KUIJ9A%8lTAuZKEZ$GG+<|6+M;B?SIJ8Pc=Mz%q z@$igbedL1I#&%}+8u?5&LiRq2#)XP0J`wnZaO0DPtZT=e)G*{{^?m^OKe}OUZ3sl4`K-VRM9{+MTpozx^CB9WuN~QOtnI&rX>Y#{;lT&nr-In`5pk#ad%A& zX|$WAuBTCHa;}ZPE{Usap1f|<$S}wUH#iO{__hI|kuv#5kbYvjQuG5u_(nQlZ7;*h z-?&Hanl9Ch?FB7*pHB?!hs=88yI@=)M8a-_P{}3Ra27E%Hyk${pu>*=KKRE`{AgZ< z>5oXxo84sYhZEq;`DXw1xrW{*iFzbShNOaNQr2=%oA%a7`??)_+<%$zRp} z^{X$zQnId5fg(m~<5tN`R8&36qP^6f{l(0S7=(-wd@#aL<*i^$t?mXIBJu-unxdaS z8J`4`@nC(aap5$pa24Zd7Gf--&kp)*OJ6MXVs$GLRyM$E%fBg8!v4JObbZ5Y#8iA> z?wPDue+f?!zg|rFoRz# z$y79iy#9HQE@(>MaU)|*FJmxm(@T=dWw3i8Ob+7C!4-fbMdep|G(%VIp{NJIZ*reo z)JEeS!A^;5bE{3!CCU8ByjaEAme!*R*<|*V4wBAkrTfkcVv5>d59A6qSN&Zl=svnY z&6Mc>a(!t={AbkPH%crL>B9Y6K!rD?7pwMk0iUXp%F!46N~9Ru{T5uL=;)J=Uw7}M zNQnqS0q;XOVLeSbas~5KH8Z6Q_hV{l+jkMagq^8o`f?HG>sa4R%lAoE;Fx3j#BVD} z?Kq^(F!Q0`EbhdncNr74-nXn^ELkAAH3d)}Sd}@!l&;c#(M%8!`xTN%Y@J}CrkS~` zuWs%l(_|)Il}r<1ob7svk}W&a#(06XCOevo$;F^^RxdhOY&cTy8$F*1?2k7(zYHdj zl|+WBV2R!3mO?GNwKmB)Q!N@TfR?-VzPGC{vyil6Op9&ea-X_s)EZ@~B8|q{sK$ zrdf;5CO5HG+#I0DcRaep*l6}UJ9hRSbIPcLFEy}+G9EuR9j|0UmWj4@LY~+=bqSYy z)7#oix#N7h>IfjT3d@+Y=)QK5NhHQ)CD+URh)2z-rgF5x73fR%u z-g>G5_jJ-`kG++sKh|V5a?Es9Vp6sD5e#tJFF)4DLGbIXYdD?-qZ{n&mxG6bFF!O* z|2m~~d=U-k-t_bI$$VZtyIp+sUGM|U`Ki7Dr!N9ky!4O!Pi20XVDT2hb4brwIgR&@ zNIyzUY2)j2%7hCvKnnMcd9^($bJR6owCsY&YtfAe1(&f(+ZaJrAaj}CC4rQ}3z)v6 z0#m^D9}zfV{mYd*1D1sEKl1K>NB!MJpc~~v``4a7KjwG9DYnXhnx%5G83Ti2K}UT>7>v47T-mN!CZBqBLTF7O$8 z-#qtx)(Caa|EL}(xjC^g#9HX%pLO_rtU_;X#*qJrKi$-~!IZ2Mi)mkF+yW!4!g=r2 zEoLeFVznV5d>8UOk{!ZNDj_;*N|P&`;DBt9>)p)Q-%UkOfFCH9dN{hz$Dq z+~sEEStK7HvD`t#wSNzgexIUETY+OCXvCW^aI*xu@HI*2BbD|tAwqG=ePram7w4YV z+k;n?d4(4X{bd7v)RV?KhES7Rpa><^1@`6AHB1=YTA90?fdS>t{94^Pbd&^aVOo*$}c$x9=T?jm=5$)-l_1-E?# zDOx*Z9x2O9DPz<0UQ@J3{XV>6n*}gNaB`x>OXxzfL8x6}`%0F2HP367SXs+dL2#PaTg_F(2@3Q^1 zagWJCSaKK1Rd)rYh^uqga|tG@jdsuF7lrk$jL5(h(H2imtKIl?Oa+IriqXAyJxEfeWUU!08>b<3!;LAc*tY&@OC*LldkEw^_ zlW?Rga%_<~z|MhvJaQD5-DFHy^U<+|vuS|hUj6#nrBS67eC`BesZ!#%t}3BXf~A6H z;-aR4nTt$2Ywnfn|65K8bY%mq{{JtjT&{a{4&1l5OUtTz-Cd59)r2tlzE6mQxb!g2 zJsg0}a@>y=Hpk`KnZgd6;BXnpV@52(_^+#v?tS}zpZ*N^=N`hhgYEC1@K?a8rumwh zW#K0*)uH$e7+Wj1k{N1{+n1o98=C>&oEy>X(mI^I%TmBUsjJMdfYJ;@wUAineBpfr z7)piMV>HYdWcG&#s(omf0iI6xokd9#woMH1Za73SC?V^i>!I<+B4?fJFJ>o4cut%y zjnO)cm}h>i1>Nk3D+`u4fw3=%9~w0RG;OQ5Gmh`jqxQLEzA>+!3iXM&J!s}9S3Ocs z0AD`a)s(PTaRI}`n4vDRirLad8ZlaxJdYry<+*BnX`MNIomZEHH)G(HR#O^Yonl=5 z_E5g))l@Ei$t3?+HxxH-OK4(ULfQ`CaQQ+Kx5?@Fwa{ZQ7)+=bwIhniY`)S=mCuw{9ygZlVy;d|};Lgr6EyJOcgSrD+Hq)&DGd-+6^}y8hGk z_c~ap4dX!TUjx>erz(~s@vqAg^R>dR4SX$Zga>y$3`}fZ-_Z@KQ=xz`UVUO1Qyil1 z1wlxR=GFu1M5Aq&Y2E#KeQLIkXJJrwwd&&lL~>f|9B)F;6s2@XI^W6vG(WAh4GMUd zmLZD^D~d))bM2bHnu&s9_hU-MR*L3pnmQ)?>1ItcE~n!)YSod%dM=EMd$36!4GbAO zu+s+V?SFOXoW#J(2&J67VN`Q|W8Ww4KmJyKDj8yJ1?A(LtnRLGjhE>}X~LB*AV{~2 z7w40f^_!5*G-hN_ot^IjkRpm$$S6|(ZtQhroep0Ykmu-gn>;bySmdfd6f!TOWlcxK1qnGVBc zj?-QCS|)htxW(>sO3VcPqc3&en441e+7I{nY(>Y=llYUi4h+(r~Gq8%f@P-2 z%=?S0KF7D*S?U)lNe%nJ(+-+>-ECEW_2xNSd@oTb)I;T|w9bHQoInqPQKpt;jkU)Y z+-`R_Xj@JBxR9ujM+J;(TSZ5ux@#aH_n%Qja`qwe7i7}lidIysiIrVu zoB%&G%BxfDmYo??kLDbXo7w%hU3NEsk;fN>~;#eJ3PGV z8JTK=T&=$mw?Nk2o~9No7s*72s;YZJeDo7Wq|XgX4w4um#9xYRF8A^L}S!+trpsD)xe zkv9r{4^4_JMewAWFQ%9}urCH>ts9YP9$f&)5=jvJH`-y_xhh)JwxHZWCf zJxD8Eg`+?%%(=GQ1&BtvQdhw$xN6A1T>UpBP2_@AQF4-hx&9ONw~fH+>_F>Z12*Wb zbfql|^H-`51noiTV5TzsH^8sRa@oJpu_1p3`GlD20t5nRS7cvSku9Ssj@gdcfIQVp z_<=0Gk#!J1dDK+Na_i06NJX{Qgm2NId)zyg7^PfF#c#oU6Wxhe~>@%smoSx*q?8>V$HpHnQ%~;3Ke^^Vuz%+OynM0J}meE$A zHaynkR}aVm=9d1iw2*d;Sa5IV5EY;e|K0c98CWycp9>CAgr5Z{0u(^=z~4MKA}#%Q z6X?6pvp(*FKD0t_w>0*@Hv`y@zI40{l|R5DeF6>^rZ0ox(3o%5z4g&qw`zZ~vnEg8 z@+KNe6;qiDad5mDzE)f-Rn<}YO?0Rg5Pff*1c{T|PBo7TJGV((pbd+o{<3WJWh!9n zcI4T&K~c9dbxfo!nLgK&_~6&R>W_OW24Br?Q(4Vww!SIF9;i7F7Qq9NL6u+97kMrVlbn1h35#RLah|E;OtX|iGGo8gB8yHV-GBTSm@)h^S<3e0@ z#Z4qE!OWqzQ~+JIU)AZx&$@npqN|d+XB$__JDuPQ?2iuj{pbw$&`v1t#YyyW}FXCJvb>GvxuZar>^$inELhn zKFw9+O=`+qT!JAV1&&HKAP4vre_$!=nUoD^<7r+DXdSc|5n1v=w>>Po=v1@cdb(g& zE7=J9u&O)2M>z6{tJA6E(6y--P9OUY~*;URYgZI{DCOb4;gt#Z%qb1jPHF|{h<+g z?K2WpWm8X&wx}q7N#gq-9$)$g+G||UXf}1fU6^$ol*rrkh!-r;z;^re?#;xLCaiZvN0PbVA9UXQ=p{3KYmnNn9S5Xux6S(OJ^a5)Y~r>*FEwu_UL@<7P9uJ3o36)OI9XUwZ(1@J$^! z!26WanP8&=?n5=U9jk{htdJs z%pkc0!Rg3?zswBc=VOgnTd&EDyf+_#_nC8oT*>;GZ9$E38T=OQ{4d=?EN5lMgigIn zk`m)2zng)iMca{IA+mg2=Be{zWr)cCzUFOM*azaOZG&n<_Cf)=NfRL$Ks6c!UYKIH+LtsjfIvK2(R`=q2JzSC%E z1m(v#f7n=?B&^Q#?qqkNQS#T-DlP#Hv4nX74!S9_@JKGC=N>ZD2dXmSLUdm4uMrRu znNSaqtDoeG)GtKbH0$DbP<&!#tM@*~kPkG|`HhR~(1z^V`h+%gguQC5=^c{Ny`ZZq z5|`5^|LE09Y?NQh_La9_z}|=e=GM zV;D$V%Oq7tCLf90H9NY0=d4=zPkx@xt7QTHr!V!&2AEKp-y5MlG1M{Y(JqlT^ZV zID~6s=;OK1AJ;#>-V4nu!-D3|(KONU$4NYle>9O8Ju&6BX!G^_l;Pvq?elBR*n5E;yN&mvK^XDsKwd}#jgRI zkH|NekMSbh%|ghSgmgkWS0+0|TvEP`%hHQtW%6rilXRA9inWQ`s%aaftidoi9syyG zYZKS4tTiE`R~2Z8_gfF2nW@GMBAnMSdWkxP_xe}Eb27u17uZAW!8i8<9>hGQHp##% zB80e^SJI{@qTS3Imy$nxB>dj-(&*}N{ZUFPf4Rk(Gg6Qxco@0di)Np zI$1faabp)UtKsoA5&7ma^0e6)Fk~{(o1;^zf<+~)yQ;rc1A0(<_yv@xhDWLmWhEoG zTAd-6qnjF8522*(G;zK`CNYYh_+pC$?Z8c_9wAOF^mpFb9T_m9tiVcHV`NW;ojsU(zh5?0O&^~yUzI8v@}fxFO}CU(!oKGx zx+%XK^Q1IZAO5)qyP^F{H#f}`7d=C9Udq?TsO#J3s2F$s0}73<_Q2h7k6HPgrQkTRT1_;-edu@*p)^rD zjEVjMHwNRSM#(Uj(yw4Km0d1(-%=+$*AbYgY6~=Hqf%QQ1egfIB$O(VomyHriMl?C za&s*&K5WTjr!-s3&!&iLo6Og6+4ZB=M4#rpTR*SA=v4`|cRWTJdjscv$=iD?1-%3C zgP-fQkeVVgR&uYIkz3J1%OgbVkH1n_petXP@R#Agi9fraPlTnl*>mb0%wx|{O4vP} zZ1fG4LRdPzsZUP^k6s9;!$-fad`)x&G9YlOGqP1dlTQz4&KdV_X475(`9!% z?0v-h#u#^P-aIneMB6z`q}8QQZl^q+=)@d3L;okmfzRvwZ^eP-B=FrazH~DO4Ue6+ zSFSWkgdIg(T~TPpc2snAi`LmcTq-XnH(D;*WI4P3RdfK*>(7nFGX1D!=SqCEDpQzX zggKA*@FS+azH%~UWURtZ797w>L^G3l)SrBt)7b7Yc#Ls{)A=*|5c>UJZHUZHhD zb!X1O${Ii@n-=VoDsJ|lHxsQuhKVGHN*(S*uYmVh9I~GV`WcBfxvB#F$edcf?4}M* z99wq!D!Ys|ID&r&h21XgayvFkft(e$h=ii2uQk&TGo&^?6}w2C5vAKu)7)1vAs~IV z^F9Hng5Nt_9ag}&2ypPeE?H#`_DykWay$xHLaL<*2l_TM&ZKv{?TpbnJ5O|IJ3jTW zPHN*py->Cvk|Y^xJ|FLn*J!5(mMG&XniGG3O|?pFP}-bLOb_k*751=ReUfqLA%uQ{ zOifD~SZ`p65Xm-S>EX;D*5Y(}y)jiQ0m5gImJQ32M|wV)D~gUVnRsOi#koX|x^r0@ zKg!?Tl_#iVMugXsv{`l~l|4i!jp4)-I9WS9pIP^-?`~nY=03cl=u1b;WPI}Nxqj5D zbZ6goS3Z@M2zGy>kA`&J4a{o&84LnhFZXm@#bBYm`GG8n@CejW*~X;Yb)6X`aoZV+ zEnH@&{L~I!^i1f}dY!2g4GAzg;|gs+F0WbEhZz1HOA>pXoiv^F--^|8lB8#+JepE} zok)#v6xi199TKyBJN>J}fgAxLp` zaDmVyPECJd)e287I^f;cZiyx#8v#q^^0WL%VgCaU@_%!;5E=_{PfO-R?04vV+c{#_J| zkM+DPKpsJl1hhzVz9D0 zU-vy=BYeXx#q3L6yCZT*`A-#CTQoz2mlpsR7xS&nTjrI$24>z=+*;7o8Ft(n(4Kiw z>*fXQl?I4<$NJ)|)U{@!E^d41Q41hs%Kc8M zkPnveNe*(MWx(O5w9=ofylQkXPuy}S;-+*L)VM)s<~dwkb|Qft?U&4*dFq(im8JBE z8(wr*VT?a6)FMCL*Q?XN+Hh(k*6%HzBT>Pgn5t^aF0^GvjG)FnOvcv3rTA&of76#k ze9gcGbiUNy6#=i>`kg(yJv;AqFSbrsrmfF}v?@t*Vc0`29(3ni*cnC9*QdW8F5!0S zxFdD_pm%uQN$K5g&Kc!@zDgOZT+BM3o$NNF`*tB*-Hlt#cbGEAS&I93u=yb>o~FW( zb$oRC?8I;yqxJrl66uRFf8=F*!P|nE<`WcP500yD*c$(9K>a*E*ivJfQ}h ze1?*nb0&j(F@-2W>=BXR5}a$=)u{W=eIyjQ$N1(<#ZOZMc~~lmRCFBxHX|0FC*!6k zrQ6)35|&u4@qv6Z{hS_)8Ky^h_#yazZfqq zb4?K$mUgLs{VaR2G>Q6=*hr`^IQ&44hzuM88WtIifWjg|vsTkn@-1nX(F3QmUVQ$D z{|Ka?AEgNj^wHtjB~L@xjPf0 z+ZkI%*?BHDpOI{YA%p(#iu%m|CEG_9jn~-A3dU8E!O-ZK#1J7Zd%>Uc@StSW+57ke zpST2V4q?DdUF1)BCXV%b4U%7JyNI38XYy{FUAg%q8+)8752iXPJMusi_E(?Flx{~= zp-oahCt7=_ufeXb&w2_2r!;la@M9%o%pL5dlLE}698_%1UrHA%2?>S065u&!KYi1~ zh>nBcU=*u0G}^D`!L_Dhc?GPB1Uu2~iG+}2{&cT>>EdtrbxMtQKI9G+*Ri~Q_#d7l zlI|-)hO}_<((>-?-Q)j}=U|gMK}3;T2+cZ7OU{-TgRa*x)Ba$(O!o6E?EyX5^nb7$ zMwYjjP+0H3#%JSWB|0>7L(WbjQi60BwCGzratR~boVACu1;hksDM zZ0`28usqsx#yA~=V%xqVlaD>2mMKRrbhT${fA(1CC;8xrtwCL=R8VcLB-;N={zVpg zr}9#f*|wD7rePhQIo|OuPg>1=778g#vx<>9g(W#T!I)~c22lj;gY>u+BKPe=v-|KU zj;4Mb`HF(+XuBfHK;3a^1H4Pv)5<`5Rkp$>jNz5Fs4!as15QV~j&jaSd6SiNMi?>@ zKFP5k(Fc`aYlNx0mO*P=EAr)<-#kk?5>6S?o@cX>hBh8`3TG{Ey;t{-G^m6-(c5^x%cU^JL?F~Q0O<6OOww<`{6s13PL<-{lKUQgq zO8!=9TB62$hKSlJBk!|D?Re9QdGpUUwmt{*XhV|qvh%`#ra?QCmdt^U3BBEqI*-~1 zjz1zuy^D!57U^=;g7|m+W?#Vd@lrn>LUG&_8j_x#(RdePuiC)cIuAopluk!g-Bj6- zfYi(S&B}8*^u^ZxygKT>zk}CH?L0?ZM4oe%z|L+(L4z9_Czm-fa9+DsxrofVEKbiDg-9;oRe@ip5>zF{i%-G0>55wvH=OR0c z#E49xaLWaElHM@tvgxo;toS+Sf|$|B0P1W}Mp+$Na`P|j8Q{1$Ye!&Lo8gP)l1kKr|1*TPz4Qye$6&%nN{1r>5R3H^^5;j zFWs

    !1A}C84rL=1!g#z27sYfbHuz3z5Z9l*N%#3x@fKAZxavQQVW>#E%uydFI9` z!mB@?;PBh=x2E(0$Oq3dRTPvujC;Oas+UpPDaMPBm1~ZQX_5X-%ZUxtayo96rr~0b zy9JA0nb7#g7hVq6MnIw0`_}QG{}#J^mgxkFU8-ZGE!}8MmQ-REMvl((?1Gi@+Nsc~ z&<~$A{Wfo!mf7+NRY{?TO%X9%w!oQjq1dpR3V)=mv*9l1ee&mavHLFOI;_9!YKd8) zq2iStc4E8d)f_KwI(?zINQXNf6@1yD-mRTu#QduS-p03<6)0!1;Rf@Aykq{F1M&iY zZtl+y_JsX?GB*$8pDu$8xPQ9d{cYoKIm^EW+`{{rH5pO)ls^Ukma}|VopR))vkHX@ zrEuOIf?+DyAjy2qc1=UGX~UG7NNrR**=m~3-8#b>D!c6`+f3y`f*pZWwOF$w*pS=;cKGM*7l(DW%d>ym$( zv94?GdAo`Yvbej=QO!!?UPea+zBNrmCxM;s5Jh?ygO&~Q*#Oz*6!XUxn1AVfzc*i9 zUI%u*AtV3O`F5)&%^G^^e6NmB=n5W;%u?$rwhGb|p=MqNJZ zW{&IV?aZcgRRTp{uBa~^h8?-eR)QJ*#r6Dho3$vNMzQ8Nqi%jy7`UftOkM4K*Y*Du zSkXIv?mS;%uF}$TDi6;CBOA!nt8LEVCo7itO1o<((o)Q;y(|xK6-L3viO9f^K}oWVgm6&#z!Z9W_~_~gX0*?BQK{q zEGi5;Yf10D;MBf05Ny%0`R#N>ZtHY+xrSCHk^SQ_rIlpVKRV#JLJ1Dp%pOH*J^Aaz z<&D-PXrwDeNGTr|ryuF;rkvZiV%7D_{K(rCDt~AwRvqXf7~OvzSq&%ofH5*WwWQ3E z&d2GSqx^p{_m@F+Zrj!{ngsWt!3pk8aCdhP1b2tv7Tn$4-Q9w_2X}XOw{MbUt-aQ} z_x{el=e+0MTU7NLb39}IqH4CMN$&$N{RCxUYv~IU2lYYK@)eO%_Ns3coRnjQBwBrAVh6*puB1rwQY5DjQZQ1GR3uJp%yxIU2;F#TL$A-JU ztvx*ZQvimp{4auxe@MV@ct`+p`gzVFw}g8;XSzr7l*Dk@Cgl`Yv4LK`Md)P9`rE|;OYx3=LY2f#=GL@-@dx~F1<_g@mLMQ%w+{$ zJHLGW%g`J~l@QOI!@Ztbomn281x`zC@khTBuOXGZ*e_>Fq8(0%Uw1&3kN5jBOG|Ui zFMg1-#!IJ-?;gSwue|R9Qc(=Ar9fP~*|}}5;xhyj?0MyQI5#88)WY{=Kwe3GupD=Qc>*nTQibQN+Rc_&Ks{Acx9c4PZC1eD9wY5czg8@I ziO|)@E0A>rt-$Ht0IMocSWK8@-4v$k3~o_B>KFeXy|p4;M@KcGn{h^CnO&7qCNok1bK&Qv?8R4TtR{ydWk{3Zh= zL#&V#al_Ns631%3?h9e!ZSa@{y>!i%UOE^U2lH+_4;-wMAbL;o1zkcym+XDGl2X3D-!B)sZfbSsVskmF-og;^reRBa}n`*NM^=IQ= z#N9-d2`_6i=gX$eEMEW!GJu#JK9Ppww9umdRNM)$EQA#TB}Ws zuUrG9Gp<%6t;s141_s;9t~P@eQ5>Zb>@Be=@+qV|0Wiysc0#LaX+1J`=XURn22;Fel_11-^y*^LDcjkH&k?>R@|o|~1kaZ1;7 zUC^oTEc{kU9h5oZuC(R78is)3sE2#_5^>A@CwNe%lBl$ooxzzXkEYI5)l>D|;4@*2Y zn&UkR*21CJX|Vc%&+{3>3$-egN>wIW=17LC`b^DR1)_dEr(Qyrfx@{d8OgKgHifC) zTT;r;e<=zC$^xESQuUTrYua~+xfz=1kbrWB7|W$~hlmcvfMQ5d{s=Lc_RmEf;w#~g z7;dF^uMhWx2hvn6*6Bsv{nNki>5)IB!kZahh6WhY((=M=D8}-msSZ+ehdh8N9g~v_ zRK=*@?T?eepHs?Nid=PAkK>IcpLg}AV#_t3W-IM=KOMzzzztDmfoz?vDpZ%`?K{>~ zS+?AP9b|BN-!7!IaS@Y+qqf-O9oyE<34Yw6KNuzq5aD3G}f$g+|;fGoZJ=C-~=$qn4748;SrvWWTT28EV49u^az<< z^;|u>Kp-@biU&sFi&~dulmyz;ML!7?QP;aVdU9Yu2ta5e4-!0x(;iu(^b?K@qd|e- zxDtF>M!UDLN3&Dn#O6YnYG9ex*ARP834DpesM!l5a%?eU70tZyAZ|YsPNHfbJ~#>! z7AmG`vB#;+lk0j&izbtcChoKsLwnmh9nVWSdL)@-}g8UEG9d;!N9f5;f^ z-OB*o4>_1R2_;73bDIM%R+nhnYBgv41Jmsea5w+`P(I5I?dYpJ-h>!wNwF3tI!=UK z%9y?v$TQSaC`2Fp_@5U|kX?qmzM6H$BN999*I7U!6ozz;(X<8NKg`2-m%`}x zR3_-5PCT;i-%MgW96Th%?^SqUy`m3&_X@s%G{?TJ%(1cJ64 z&%^+W1PKOXaNBv52)|_RA#+3ZaKMhDoQ3#U@yTuDeuNDfY%N^<27jvZu*J^i!tZVF zszW8d3u7j+M?cUw@AW<*?9HlerR>Y~NARma z#%&;f4>i*$`9@@em5%0Aw11;5~Zc&Fh+=bNYKb~NM<+oC-4M>QjU3qZsi|!!_kO-emG(KOw`NUArYE{ z$#nM~_HEwqwr}QA4`m2~KvRev1`~_uW%q*FLKnnNM>xE!cpiVJ0v5FihMnj@lz(5E zuIYyD9j*y>rtg%Yj&6$rH|3f{&j+>Q+awhqUz{? zN`c5&MNBDEo%YrQsX&OBgja*UmTf~}laAYkYyBEW^mlcOVm#;c)%b=&KSI;70L#sA zURY8Bu1=pIs1N<4VBXs|0`0w)P zFY4>op>W^mZ2R8nZf?!eW#0xEZ-TOCV?Nvhn-gJ9a;}Qgc~~7|xBN}V+f=x_4T9G6 z&myfgKd1*%n?3};V~Wa+8a*CAQryA0uF3J|f|o=P_IOC9QrXpQUHOEuDI%Xmz3uI6 z`>~OPdQM<0A-Y(Z-5VLlNN&at_2h`ovnCYUSq5xc>A+7!K%gS0841`mBkf03g@)2~ zT2#`(=;A^CR1Hxiv03g(rxEI2yez(p}J8MM`VtFJ?~@7%iT)2n#_iwr5T(B7Uw4Y@_t41Q+^ua%t`%e z>I0aBHgBs>Qq}P%TtNu~)puc|_|6Yy`#U1!Yd?y&W!Rl^oANYo8j%5bO>XZ!N0FA4 zb#-uRy#v7O<^Ayefkrh4JR2Jq+XPz_dx;9dhbS1zMH_{ld&KY}tsI>*|Kf0mpG3^y1bQpqIC-#oKq1E7eK7gu+PlD}f@4`t09WJOi2e=3a#yQ1mj9;Lvq52qsJ zRcn^J45FXk^EgfnD&BM8&;QuPLLAf9_D1jFp$wp_j@9^vegjpysH8|{LJoB`m7{>S z`8jtra^GhwTOgBVDO{lsS9I+HJ1yxX+BqQ zo<=qfCrSB_9&S65O9!S^A>1Q?%S8qY;Q2(9lZDE{nO;_{ZF;mq?b!^H7Mh(bipojC zc$%dpAHC{Kqhj={E(DYdmMhHOg|_UU{}g zj)fSTMy2JrumU|nTssU_z;Ljy)}m*Fo2z2Xp5lg#!wH(wOX zwjEsJTd(XS^6~!-khSOOW3Nhc z_S-cfjm9-iXLW=0uV6rC>b|_^vZg9Juc<3_L5&O@HG9yp@K&MZTh&kjbt+t&&IJxh zd|bRwhsDu@rkq*sV@+zH@x;k&+<3oOj|#}{N$V3vPeh)C1c!MWi4mT+#wM8iX$NBY zRhN}7j8h-ZzO|xm3$mo_sG&wIostleT^t1rY_ZpV8|$k|5JYO(+W?MDxRjSAAFqaq zaA#AdaQX>xq6S7sGI_UPrG->DpAlB5^9^z6p@|Su6JtDXQm&JwO#rfMN{?4?LsL}m zyXmd#+GC&QNN8`(Q4|xnK|GRftNl@;AMje1R}UXqh`ZVXi&JdVr5S3;lfpQXf~)mU zSQ6-zTzPzkS*+^zZ!l);mkXG|{SF&(3S#wc0G%FO&@>>uQCMqL@Ej20*x`!19};CJ zfG{;4vecWHrmSHhuTNtVvDyu*Ygq+8>p1CS+kHF^c10~=Ln)ZYROOV)gUIYYQ=$RN z`Peda-Y+>|*Az{x1o2^*tJU3`q$onTcbn`)K20=~U_iDWvuxGY^uZW}AtC9<_s0-= z?Ixte1#kcA7NJ|!MfLqpG3{9S)pcp4RQ)(6B2H=L4_82+y#j7UE_EK_UF>4#`RPCy z+l8-~Bld|5KnYoE`N3FYvuc$%+eplgT2D+9FrgrrSj?>#H~F0M*~}Q3kd7G5jiO2B z&8ipb`(-kzm(3T5IMKysq7CZ{H}uRpB}E(&;P0Y>r6{tcxO9j45@4f7jsFe0E-KQ_ zd>F>J>z2{Cf>AR_p;(84ndn+@B2`gC?+HlSA$Q_Ed8PD@tSnxs<8h&h}4~gdc zaA>-qLP*f(77wkA^4=hBSsI5IK_7w7!@r^~U`d)5eh$$v@cN@FP)c`*7Q&HLVZdM= zgR6vQ9g|VPt>e-2u3-RhFzKx}J@+OVUn$)mMd{sd%zfQrLnx)Qv%F?)G%~wjJHPdi zCW=j`+V8v${USn|XB3&7iKQ zB5nX-dcOJffZ_V(RJJP(oERdz&Gk8sVzO!lBnifvtZ?juO-J+o`{o#cD7h;xsz`y| z5g1loaLspkYv#4GTsf5dY5 zH^2)sHJ1Jgy!QqeXMY1;*@1(U=>VIBwt) zyS&|?m<*Ooc!Lv1s)4c7gsywkr$(a`J2D_fvY2ng?ZZZnZOKtd#+7=(t5i6dgeIof zd{FsPf5_n_KYX z2%j$nWBVtE|-D%HN*2N?w(3 zHKwG^SiHEW>mMbPk;1ssSX`~X-m(H6v1*-$9JXq`938QU7Vz=)6^#K!IV4~z{+~-J z@NX!7edtIq6<@-1hL*#{FTH$O8RItU&&c4^%U>Ojn&CS(AeISTvh^Xo=gE`&CMG6j zS(L)v)+h%2E|#=D+ewSXD8f+E@m!><6q@Y6;=L^f{r7`!FOx~?VaM}R*lf$Hh8f?n zq^a0b3=JO=cZ+64ERz=kQ6C#UT z{(;C$QvWd`FM9lc6Oqq%#1r*uvt-br*&lV$x7k0$IkQLEeWB80TApOBF87nd?2JN9 zWp6pn7WCY=0x$GZh5rw!FHQH^pVXH*?dAmZ^y%r!GS0Avzq))4ra@)~J3s&a>3P`) z;*gz;6c0bq7-(@;s|g-n7)|5EYu`MrU5B_ZM%o5SR+&IZAwvTTF2n&v6+qL^*)QIZ_c5FtLgm0GH_7Um5juDx8 z;NF${rkdiGiG}(d4FOi#!gcR5c$KPeTWclsrF^H^M9lvHl7&zI4@h>`0YLJXfLBPC zbp8t@PpSQHL-N<}6io=6|I3hkpY{sLY=zxt)adWX{Hd#4Dx-Y)*Z-S*cbmBZDCU}x zwTJLdhf?GO(XQ1^(}V_|E5OYZCu(#=Hc2?QjR>^>n^<;fb10p^H+Gl1*Lt$$01P#! z++_a_k^r`vSnJtiig{7HmG2Fo287{hwD$AndV zv-JSwHxZ`f8>c&j^jCiyQ4K9J82-EbNrYdw4uypWXWNYq0YsSQYu?uB=N)N2CS;wu++|`X{@HTev@CO^t=!gOj4oRYh=8ylc#z~W7s)F`Zsjn z7KMDfoip+eN*XWR(8(uXI;!5jsBZb(ucvXVd!X+s6P-gp>J}H5AzwB9Wo_^?iKlRh z@cfYXXBuzo&pf%Fk@1>EDSAp;!CA5LX*vh&sr6y1Ph0-JB=iADG20g_oNs6oJmG?& zDXI3BbDxxJx`#&j4enkZhqbA|$QMs?#f*@IrVWK_HY#Yqplr(jQCi*}U;sXTprTTv z7-|QG5D#wyh5)QOFe!$3USG08Kv1?dwP63L{N$~#sQL3Drihm0frN|T9vpl$Q?qH# z3VkL_$TBTFC6QphmV9R<37^RwNWN9;>VT`B9z|^=jE1@@A#aNwBvru|Wl2?nM7dPv z{w|%u52=@A&GoI#l{+2{de!Od*xv(dujDz(>`+FSUNku-{Z2KT=p}dfLC&-7k0+Z) z7#F}dUG$e4oZ+9EYSn)0#P7X->cq!uFJV84>;A=0;%YbKa+gK8B9N%MnZJuLDZSM# z0L?TPEyIx(T&c-R(bhm4C`k|GcP?U<|Kt403~!pVlDOfuU^5PXJa zFL^rcHX~gS|KL(N z7ls7m7?eFW(_B*%{u29ketL19*g-P_Pg;Y+l$_`&m>q-=5M39pL8cAZ=1(=ycN4Ky zsXpneEbx^OJut-CRB6|HoC2>)5^tS9`=-6D#Qu_eZFBP8mC}dK8%DlY>Md<-?s{(g zmlvTCJ%yI*=@>Y^@v{aq_rnmHB0ca&@W3iak(kG8krfW+I+bcJqB`K8`Pfn_7#`{YhuzUiHLHk>(@s7dznNjUAoXvof-JL>}TM{RM`VeE3`a3LOF$qbE4H z1I7g76%7MyQ+bj20hLHpz2pv93oWW|CANtesNFY$XQ_O)8FBGSPT*Tm?Bkr4qF-R; z=NC#*;x@x-2%4p4pYAm459#*I&ZePNiz1qqRtGFI2nphP?$Ca z0Y=I8d$HLpDXA15o;#U{7)3T2m+l;20{kzI`uQ&kFbJrE$g2XpNc#szC9;N5Gfv@O zhj#{W)SEZ29Cf4^z)>$Bxf9W{6L?+=3tnOZe?-0}Y?1*H3b~uowT-o1eiLXFg0WT@ zHBOJpL@+7T-A&c@(|6Wn^lV1HBrirVGuU}dz|xmQ*Pqvz{*``2%tL<`*9kH*r45q)PGe0(<``*>9ZL-mpx|L-WLpq&qMz%KOMJMeEhi;>OCKfm3$a#t_sCx54i}3 zSb%g!+#&Lg)H2NgAF6xD-dx`+^k8Z?h-K}IfR|gF*fr2sB0_Q$IIV7>dHH-?6l*py z_Y(ZhFOvnQQ><*VGBTz{79U<(Zbf(BGW;PkKVS*|)O>fjh}$l3-8YFxq8L-I*BFB9 z#OTl?Tyw+vRPs)~$f)g!d|fg%+uN-b5RZ_9B}x}1^$*EJpBzQq=ej-nL!31!1c#GFWisi{3C@KvG3^#UlgHCcMwr*vbl3hhmJU`^wYHJ%OD zZ-ur@FL`|Y4@XrPhM5h<>RL|KwhtNvxiMwWg>%QAT;>Rhz-+0QI<@+%BIe<_ax`c! zD<8U4NxAzKPsKYBa_RCv3rz$L;vHb`72k@v+r{4u0k?_20jwaAin)K|n_{{q2;@JN zf8g8eLrf76(SM6?Lrs6fH^S2z>yt14jBgD!{{g=J;00Vp48XT>-@oJA+47(GCM6Pd zlGFJM+K5hnL)+!qe*kUchqh9wI&ddA7c&#{BSiLcVfg$q>{{d~JHb@XNcd!_?D@cWejB_|divIE zRs=EW&=3{4r0D4}&b}(o!P_EubEX8hnvJ*J-^(1HS&nv=aqm?j^}sRmL(|@AQ%H16 z&^AAZB@Y(59F>sc_-1d|EIiL*Xekc9zg3F$NHwlEYpne5Lz^o5zk#;7pVlVIKcH<7 z4EcZE+B_JT*81OuHX9m`{vF!L{`=78`uqxQAxQr*w0SjYhs|Q%y~(Psd12H(wAWr|}&j9a{y`rhp4Ytt`IN<0vh|Ao_h$LYu*D|np*gWlkkH9O}a0^f)19361snpc!`+WV> z`zy#<>yYl7}~a+MSrE|%m2w}@*59!T|Y0#V(#3vk)Ppgep(p>>&$Qd^Q5&1U>hBh}`~ z$3bU7;fo0t+I6PsXc5TtFF7|dqea<`WOhx-`>9RH<|)A$>uCqrfpar#@iw(O2Lmoo zt2#4s%>R(yHF(^#7R3D1{kAr#Gw20ICb&*pz9iGR+K8mro3cluR)g*Erdfk8UrSD- zy$r+o;eil|6mbOBxA)T(vAgZGE;otb6ArG3;0)h*WcX`wql~{1cH;gb8^^GvGS+c4 zU7};l?JxR_FGPgOE}EgG?ca~NkAZ!WA~BnSSA`bhq5!Y+hRn0;1RS5;TC#yHTYF(> zR1ZihYERW&ZzctqS+hN7vqHYC_4#~owDr>H;gRuJLREN~3+|>N&yTq6xl0yn5q_Xp zc|I)uo0Z`wxO@NjmNASu%9~*W_o{Oju1*RaM4GoL;-3EWMLb(xH*m~<+PBV>01;U{ z^|y%3Ir2+H7Jea#VE*}rkVTN5D2^7*m1dxS3|`|Am#4*}*KXPiL4NYp3j5Jb`|Tsv z*!vx+n_3(?PY*m-4jE0xL5rhwRJ3KqBG-MI44iS#9CNWx5e;$;@eGADM|XuE2vx!k8;2T09TTVe(cPgExcdv8A*2pa_NHjI|F!$Qw` zyk(=&1Oq*6`-X)|;}&veBL2PY)%geP60z%d=n%2nX}0}5_e)g%;|tJhI={bF4g_SU z2}n`>(mZ~iT@z-dss1L6r8Nw zo%Ke6Yz7T^9N8U^%?=8jz8p~`TRgWr<)5fj3P^^sJvm7tseu_yj-)is%;{rzB##!e z#mV9O%2Kk1eZd8s+}ehgQmjnSsI#eUSSq@yg$z2MRixQaaloZ^+f53o)=Mg8NJ^pZ z5WR|p!DTO*8R9nH{++GT)rtKV*(!ku-G6p_gC~D#%j6eD%4f(jXLrL>a>cDXQjDA~ zIXWHZ6ND5N4@p}0UA1=~WAxQi2J1|zx){Wch{D~x!WeS4Yut#6vNthhrE^A>kQ55C zs8BQZu(BiJffyap5fJ%U5sn--#(m#KzC-Q58fuG@dQKLp8nR;$_44pb6YoskS+@=| z7#|tTIqXSNz4MACK-XGFP3=0}gjYnhcQR4gM_Az!1yQ|;Ha}KKQ&~OW>)*GBn;)j< zgK5Q8frV7%#szjxms`~rv^zC!hli#jbM*ZM|BRuoyMSe9UpCXDBrx& zteFv;l8sC!flA z|3=6vjTaEI`bt~1-tZonaRm ziBT~4aOtk`B_M7L#Pq+^TKT!)c>Lu>gQ##EC>tT{z(eV2uhi{*FTir66e z3Xho|^(3T=<~Hep53Yp+yZT=uop8 zFE5Yud47<60u12>@rgh{%h$-mmSE1xvI6M?cj9%RckbgF2nHxq7qPK3y8~fb1p2wryPWEfZDUJC2(#{#Z|z`DXO0 zb@RIZ(z?^&EN;d)ZD&p4_MoKe2IKo47p;CJt9I>^QE+`u7**Pg;Y^~&3nv^6b#oWA zX2BNS>lE=_MV$aDccarD#lqqiKKZEBg55ByPNTA_4QU}cRnm4M1QZqO*egK!9bYc~Fz7@(A2jxb;-I_ok0S8LKep|4izBjfc{=GfcpL|h z#KLJ#wAFwg1HBm#f)x*z)+|bGv`Jwll<}QDhmziKYma#t=sq2b5vLy~`g?+TGs<5b zP5!?ORgFecT3avby|u|)Wfjh{`>Qscj7}R3J>+-^>39K0#bn@689Dse_}3t_weDyK zl;auSifK0Gf^N>-p$oiXVV&u>NFzX+`MK{m;${a&{|m(Z*J)<6?f;u;X5<(g`*2Y~ z=-EvpVR7g^;p&YLF$_pyNKJwndLSvzI~$^5x`i8Q zEWyoA;?Ok_Xen$hS~BaZu+Cv@>8S;6|B90x+=zS_Vs<7PEtZ!m!*HqoN~?5iKKXas zgK-BQwdOUMXYE}J^q^sc%#5)~lUjHyt==~e4+m>hW5EH2TAdiyS*0A(GFK0-%rX|T z{DKTqp!F4A|6PIfGeoRSMj#)0Zwj( zIEFOiB8&rtfO?tve=1*(srCN>A6>1(1Zw8UfqnRBJRO9B5+Ew$N7}evAG`Jm*!mJS(XrAYKCgF&M)?{hmFp&<|$Um`pjv1QS z2G;gm4zV9{FQPrrsM=G=exj^)NtG1i3)(2t1Qq3RN!W@?i8lL6DpiU=mFVvF0xqM7 z9}zmNuw1|ygDDc7W6AQR{b2Tb3eu2{DZ}v&!=THKl%Wm+-pDoVw5Gmk(!|%7P1?x9 zUREuVYdZ3hQ|smctQHjUENZv+n)dFHq9#BW($q6z-wJO^Vgy?Bt7!o2R|+AsSeI`y z^YiX2fT8{<2(Qtq`hLH!{Py#v=73n$;y8&+^@3rYRa6$g#`mRKiDXMEXf)jJuc8cU zQ)iX6T6x**0sfB#s}w9t&7*KCSJFgXn^p6u2^J{QVh&MfARow|^%b>v6kxd>Ro>L7 z$Sg-n9GSsNN@cO}BzPA(iE;Ew_Gua-?=4cQ6oxtL8Vz#uj zf?lJ!bQ56oe+&9%(a*wy*MhD*-seKWnz|Ijk#YFY#P_CY=}oq8tVmUH(DkU$=xhYIE=jD+^!AqHh_lR zfpebpn3}+MtUYzE7tlz|rr|{ph+0p1lToCu^C>?fzRAOV!EAQyO~h~K*_y%sV?O$% z)cT>ltK;aC7ZGj7N=JzBP2zjG$S}X!RW^3POnOR>-kpf%G4-p9>?ei{h^wq`BCqUt ztC1#YYxFAvHTIYCm7ic;09y*ROiWj2@WIn03;f8#q=)pJ`Z2iwouV^IUbJF!Am7-e zd6+cL?DbM2$fe^cfE?Rw#-+*@A!pcDWZ*wls`Q3h`4gq+m-GWcLZPFYtO7ev3d7c|ra_KBsoiWBnl-b<{{wY{N9YN2K zvh~fU;}2>LWooWZ_KtvSSV|CZSLi_Er`8JN4`N*j*VbY!LUvklA-F2xxAI{hPTIPg5 z*Y=B6&E4S@%UjI7A)F{q@W8Y-y2aO(?lBO-AXX<$TOfC+-fR+<3&KQJ_e3xJUhls~ zltfp!6D5Np?hnGuBsgZIzbd`|9R^vorS*ufOS@Z%zk7#CNvh&Gm22+yG$s^cP%5tc z`;mq39^%5bmwckFE!9e8!}|$}nk4Rf7|!wN#F_hq;>buFVV)RPdpY@>&_+$~-2XT9 z2JzDQw|v0+Dr*5ha<4=3SZ zj|bq&Hi&p?jZ(1r#;L#_FvQytRQA}_S!HuszcGgwyw#JFGQTE%dZJ7p7>mA;#%peY zsgs+v%VZIFv@v;%)gIV|5P$B0TPGZO`v+)jHklId#LwX!cr1mGPy2?xz_Nz>ca9=h zZfoXaKSlZhN%`OSefFQ@_jUJwgx_fMP3xo|d&cD#ktPma`WlJ{W|kYzwS-wY>yE;j z;q_~vJ>2!sb_;wLa>+41-8EeCU~vwhs+KlK*fQE^0dw)&_TtuC(GJ|ZWDJRt<1(_d>< zA1h}YI~(4yr{wyh^lG$2M|uPmvbd!i6jwQ zv;OWmQ*l&;{6p8A_u`ys#Q#Xv&>RtO{2qJF#LB*41{%mdeSG;jx1PYgm~hXv<9G(n z6ozP>cr5Hl=X_4Z7_lE|78rkOgTF8-87+Eu76EHlIdtCqUrF=@|Aj=q_xFUfoWy7A zM&}}&%F~RYfPRA-TDleL^w?9P4Jazb$Khg6uR?}z=ap|ji{lT1w&9!BN#q(pscI$3 z&ivKQ?)Qqmzps`k8SK1bzAzcL?^EjI%T!#irf%)6a~v+**;X-A_?`dHK=xEanH0p(u+) z)#WCR#SL39<4jt^%$U36w;jIcKB;74Gu9(OqY*~juSTPYDoLj1Ox^LQ-Nw1{an9^x zHG3iIl?KU{uC|}Da<>Q%1JYEwTcPyJT=>l*mmvb;J*8TLZ5f1&5$gnkgA4Xt6P^N> zlq!TDQE2Eo1Fh?8ocrv;68)9G-qQg1YXb|bHf-5DvozMj5?E59q9PJvI;EapkZ7gR zQ-I0wM^+2dqiW)P8CQ-1(2(@kj0K009T&Gc$jKIe!1(6-cVc1&d+k&b0!EI-^g}m% z?Q&W=1(vlp)l?LVboF{}iFp?ZSfN$2ki-@IxK-%bci&f@H8HZhTD-{3TWoG=W>gZQrEY^U|<`ryS3jWSmt#pO|IHD;um zY>?9;`x!EYIJNm{VI>GE$$j>e_L5Wwcf5f(|6(p7V zv$KXh*`VH;2TjV$P0JyLrd8*>#Y)WZTv^ibD9#oz_VW&oMPcic(v#H7!3*Ug%F4Hw zD-AAWDya)RIQQ>H8vA7p7ZZ0!?oS%c{b8|FKiswj@AL2}X32A@O65PD3)^rfmp5R*ysGQg{{)b6u`bdV8gp zQl41V7L05q_IY3KxN335t(uROd)BvUw#2bEswuSKp>v(|h@1hDN`EH+JKHO6TPCo- z;|s_>;($5V+;9Ns4qBF%vMCm1oHt6D=*>bi(~V&iGJ#8mf(zkI@P&+E%`TX& zRMSDhvxyNI?#`jFm+zU!&xJi@eGf@0&w&+d|7Cz8#(|<+XAkZxMGY1w5L5`@4TH=M z=hH)&Sa(Sh##9`LLh>5v2dM^!fg_HsbrBS)N{7hqDAN^@Siy=C-ZbH;0^I#hc;m{MERSMs5~pV^n!&ro>O*zecJK7_Tb-0fmW z!B=%p;3_g<#(Fwv+P1B_IA}~?BW4@K<=ejLQr|I#6P_SaE%Od68vtA=jrWy#M3NeJ zCJmzX`=fIGQk~|cQ{NbN3iMaHAN_5``d?XoY;nhJu+?MI3tnP2!(Otw(UkE~@H7kr zla&!DeL9U4Eqy4;g{24~TM+iB%Wp!}LSFQ6ivpJB6F7z(??3r+pgR2x&gu=T(s!=} zX-jHE0d4T3sl&Tl4-rv;2+A1Zq$&#wtngZI!unmCjL}adj7rn6o6MfECR~NIVYz%a7yqDC77s z4r)4$ki0Bh`Z`gahy$c)0UyS}UDCX*zp9QI3FILciJTP&8|blsT$o;Rs|pI$oKQ|IW3`88t-j<9=^hLSBB^9;9M5DVL8ODsNQi6Pe0 znm#=t>uUs^SO)LtsqbE-)9qY6{?QX*kbTkeX1gStQ(r=*^PN`=wtA4bfS2b!A3JeE z^SamjY~JSeED-_jXH^Z$q%LuN;$bEjPb&Ey-@~M6_UDBmBfC-vM}$82p9hVPVDX;m8ybu)2y{apQa3iP_*fdj9@?@%_Nl5Gp?*kjesH zL~eGn#jQYFT&f83Xnm=+$9^1~X!dk;D4GN({2jION{6X^+q>z`Q)*nXfcXyN&!WVw zbrKV!B;~WLNvSn5Y7RO#w6&>Auvb{QT+9XtQ&2q#P^c4xQ@qXlG-9~fZdy$EC3el0 z+h0cR4{Bmu&oYk%OYKv1=$RHq?~fl^Hq3ebJ<(jNci51|UsA(f=(%N&of6jLl7cHR z+6{Fzpn^v}stQ5dt?)pa5&4qn05KNhdaR)@TzlH+Y;tLVGel~fwFa4La^zmuWnpaX zeq7Jt?9xv+hdNWtV)51$WQjmQ><{oq@#=bM{z7az5-Pz)%M0j!^?AUjR9nc@{tAIv z-6Eq6DfKRSdtrIhl2Xw5(dT$*S80L#mK%t1_#9I|kofgsVH_G*rhMq;-A$=eT9YhY z!`;J+Fdk}==b`JPoaaSR;RDvyusefwDO$&tx+N?UpSPi|j`vEo+VUAKWbsxgG)wF) zW)fz-jt2IpNh56cX$5|ToY8|m=u!-1ucP=_9>$^XpVMF(^F>>Apuz4I7eXz6ccK7o(&A5-!2gH1 z?tokB%}Bdd+TumK)p`-~v~V;!+0vC)+49s=(?iL(B!s0i-9oo3)~66?VbR+SzN6c* z=shAK*_m~?g%qR{XyI$b*~Zd-u0%*6QX6K;6H~J#(sw3NRdCTTWpRR&vmYn*014Cu zh+OhGT8m66yUnNNVg0KyBr?^S3s|2f>`UeH2nM7-$AKC2f~Ely=3Dz7^Z!!GPf@VMOk1zWNsdMeh5gX=}dc>$)N!^d#*XR~y5DR0^NI-YNm=|DbUM5hW3 zF3Rd6sz6@CnNnXpgD1XUrPF`AU%EctLP^#dmXzc(y!D?3y26uv-4@GthU_YI}P=V8wu4HfICmlavAei8!dj>=4U>{pJ@Dp%hABJGv8Id zDRI6xXdIcoLzP`x4DG>@jZnj`PBQSutPQfi2lTP{PR{MX{x~2ZLJAQ7oneVa&Y2ZDhG!_>~mvUlYlD5-Z zeqxO~k-PcNN}SLly*sr}6-*t)vACxeQ^N)5=1HZdlbbu!V79j;I))9`g41(DKJbt4 zr?C81d%8&uE_~?7Gm>LnEOcE6#6OcoywnVRWkZrXWX^W=Swbny{+r;h$d(9;{p40k z2RoXTnYNmu{HE@3gd(>MYX{FEx_UDYH8$-3cC>H6&7~DC(uNnhxNlws3{s{gg&k%n zjaN{$>Lyt{AxtlU!2ExhS;y)+#`s%Lb;2-7nS)_1XPE!m;$4yapKaBdEL~^-+!n1j ze7<|a=T`~gXJu4!%)6JjCXk8k*m3|V(^*g=Pw!tmoKSD7`de3Z6xpOb*nkO?rO5L) zvGE7*r81mZbnz~!Oq_+@&8jL}s?>i9t&8DjYvQTAK)qvS_zK1qZ1Ow4E{UM{E92+P zd(T|F? zCPnrZS!)}B#3((u+uf<(1{2-T5rGm3!UOfsE0jp)LO+8XjLI8$k6B=hbxO|SZQp0s z7d@H8LKp$ni4)^GbuF_u5AXr~Ot-%e1Ekxjcp5RY2Y(h8yhcoi=yXHK#d^F`n~dR4 z@iEd+58pab?GtJBFg%|^KRy+6C!-VS^ND%If>Oe$zAoL!H|uz{8Gq*5<6$s#b-YYX z!$Z1Eg@=5*On>LvwHXQYJ4gIx@7@Q@g~!AEF8|22zix$k$A@4g7lxUu3ca6y^OE9T zEah~ZGg-39C4S#v_T4JaNBEB(ush*O=?pg}BgWlTjbA-rD!Ve@b;EcKWa&ucgn|IQ z)rowjM|QCi`0ra(^q^&Y4wQqsigU)lXo98HyZz`6$&E)q{pfeT~0T(K~ zf#kr=i=JQ*lX^b0Yn`xu75E*03;d75F5}w|oXNgpnYf%YmqZkh(j0r$Sgm$YEEq+S zrDR|>z(ztW=imsf_`#=rPw^w+fkLEA-d>*-Sx-Hq*<*w5XEfaI?&4YmQdfHJcotDQ zKs$eH`TmstqB#I9-~NE>=Hidcd{W%&Koh~=nR$cX15L6uff_s*seHX;9`#;zi%f2S zZb8bD*Z@ndY5uFn?Bg^@8)8Dz|3lkZN5!?K={^Y(+@0X=8rCc5PO{X0KPC=l9nB?lJQy-ofC<&hlyq zSojpk&x`olNk#j{4iPH4gCE`3qu-q__Y5A*?nNyBq0Qw((#bTb+TK@WBw*?aPw_;A z+sD`#V29aEY6vT3nOICiANBdb*>u4~Cd!&P2bXU=lxyG_? zNiR#&w0-BaOoTvt0Q%`G9qo*>qbxN}E>~U}-UaOpl`oS@o6laRpPaCa0F1LgIbY3-2i&GzDlVLD z)h0+qrM^wU7w!#wyt|BM=BBdn2ih~-8{C4gZeJ3bUJ&qn(0{YXw-dOz2?E`wJ)ZCN z<^kPqf7ts!5ZQw-A^llx!(R#*LJ##w$TeY&-aZ9JuC>cRG;+6n2x z_YY%?9pqdeSg-1dRewoJlS&z$7fr+r9z~lX(%V_Wr=}-_mRUt54jH2f}weD`$mLO zM{wQF$zHt+a=Nkz`OqZs+Mx5@$T6Ns<2U ztCQm2&-$Cf;SR9n5K`X_{DTmu)oej@FN^e>Q(gt*Pfqy-;RG|DCf_J)qOh)qw@XL99!Rm1 z6>Yw;@&qb>qr^FB{zF`W?>esBI;nxmQ2x8RB3krE`{m+qc6qU(XW@lqlu3SV1+D*r z6{i%JX8TtSny?orNUUd@Vp>?|sYK`>4Vv}!|8|3hyKB4P=biIEg=0dUzrwM+x|eY5 z?2mA)U2^j)%kd}W)}5%1BZUEM*}3k!IF-Of{*rO;g;eaidcyGI^S?!6l2+uF!zd7| zhKtjhzvyuRW&^OT8V%YeBs0W$iDo-O{GUEUPNXp{LD+6siJSO3Jcs=a9tX=U!nE<1 zO`AJLIl}{G>DTu^3JZGwp}L?2{Hy9h{x{WyTMOR0EuQ9YLdKX+zY7`f44VJRTH#;w zVy&=pg;onPKKU0RV_PO@ae%VKwF&vMYKyhXGGtA@)Zo-Zh~=Cv*I(hO-95Y~RRp&m zx?-m+07q8UQB|%MOm@&fYvBE3EQ~~Jf4xQURw8|Y6%v$UcK!K(5QWKY@%$cz-Hc(~ zhW&FC#woArC}_#Ok86j4#SgY(R5q31y13y2Mu(ya5JX$K zDZZEWEUe&}Iv0Dzi@l2AQV4uUCu)x~FKJZgy(P~{Q+6Nl{7mTQm*C@qWxG_Y1R$b9 zio4_PAS_A%e9}))d3;-(Jmun7D!t*0dP!t3+k@{!>hlrO=a8-NTe!fE>?N+E;^osO zbwW^vXsO<301lQy z6@Ge!yRuRF6Enr>U`YQ9qFj9_zqkaH`>nq8t%`i?y0{_Sk`cP;L}T00xAsK#${HHCZJ(2ZGE*^NI8De{f*UedmyA?e zj1#ZS)lYtOhZl|Es|}V%XEGlkNIA=R^VwHKWmIT0Ak${d#d)2)}9k~eJEq@VB|!sAzu%j!$Ge1olZH2)LaX z6q>TrD24NiA)>w)oB#Q~@RP>~0a^+J>zzR4h0Eqq^DT+K=v7pwA}KM?%fz1Rq2%mMkL8P%>EF~732@Mn>ABE@ zFIo*wXUqNX-BD|0NY>=Lqv7wc)DIW&Y<8jCzNEe71s9H1xfmrzxWaV-O3;Ue+TYFF z1^~+a=XycvTx9Z{2_zH&9KaJfYCXQHwpkkj^ zoH7MHbj>k^eGT5PU;EVB#j(5GeyX^*i&|Er0Py$B6imD%C%t@$*!)@9b1#dJX~jJ+ z&-ZYRoD`-&kDJ;*fq57I9I`!Z_5z>RR%&|}!5=_REB1om$29}c@LCy)P${Cazo+-l z#ukIKw-0P}-%FC3Oo%v=_rJI+-<4dOlYBOH_;6*&_`Pu2cH%PZx56r6E6|{>#av8q zxcuxPtH79SuFT3*Tl77X1rW%sO~<@7?zMDtqe}Q8hPf%F+=bV5U~Lr9*GGw=5xodW zG{_vKnoI{Z8)H=tp`IjlGX+S%J)f5#3LGw?a<^HHKt+2okFFEF(7jQQDIBIBB_e3} zE`oAV$5^4}!LUk! z&?aF4#dI(&{gxsSAri)lC6-~t?Yl(#SgV7 z&!ls77i}q8@xE>=-P|IRFZZm@8>OY;4#Q9@cjgpVKmH6aVd?d@-1KXJeC~j(=zm9f8!VOh zMESe&*1$u>-6N+Z8J9)*wN}6)Kjx4(W)}8+2O$ncF{GkI^Kuzz_t0Fj&jfd4fcySAjf^$f+5`^n{a_ASO0rJmqA-TisI{!A9uV-Sz?6V$*BJ5VNPwZoI>sNTrFvFHrI^>`e8V8+oNxMHoCE)_~I0g5;aaEqqq#U|9?lDOEjzZ z_#0Q)7}*$bEV||e>oi4 z;su82lzbS~y4DgAC!c--JF7<4l|k9fiCZK!5OG|(dr^_Stks9L49e4E#1JSN_3iUw zXNkm-I>{p5SI}QF9uk*X{31Rl4NF+hD}R1FBc#S27VKPXQp3N&8_x_4Yx3I?8_sP0 zB6Rp2BLN~DqJF_hjGx@mv7A_Rf1fA5!1N1 zn;KZ~WqA*;+Q|HmWdMpz_vPg;K!V()NH_usYskpozx}(tF+DT1TU{rWZn99UF$^Z>%4h@5cYa`VmOZY0uE$g+IvhuOmDbU7pCcuX!OM zG;Fy3*-ha+^ZsmnIp^Em3mDH+hC`C+Wh|B_!f`0tGC`Laf5_tSfEU*vymx}^n%wta z{}S`#2DPbFu=ga0P=Y|@4U%0ccCqOAh|8Lw`*He$S->eZTVFqnGzwzB`$z6WO zyIY1<{IA#z|7UC;4!xDnqu;8LkAEr;O*@^&R5fP6liib4@FMlaL@6&Gz?*##;bwRM zjND75x2TbVtuv0?sQ;6v@W1fg|J*B4!^|KWxUByldn9T)nYC_zIS>E+qd5HXbk_*Z z5AOe~OM*2^Sz-L^PW3l3V=i|P?LRO-mPn36)c;ef1eN;dbEM=YtgXc7*J;Ifu$=~i zb??)|^TY&O;zlhj%H_#b4bh|3;?T?OoXnL9O=Mm2O+JQT$dUi>7ZwWpbhZ`d7&E&F zdYVcdt&ey3?Qslf9EI>=2*1z%zhM4sk7hMdFugb@z9Vaal+1^Ib55M&{mD5ID5B0P zntgN)wm9F&T5GBQbOEF${KnQe`a$|c*PZt&OQ7b|1<3Apt*vmE!+CKg zw3`qD1F88cxmsY-9gJ98NFMNpdi7%OK5miaH5wNe*Bv}Vg=`1y=}m>5JkLf| z)-BN^{=V)kJZtZf=vX|ocOWWK>srOxGk$<)zm)yf9CJU*U*CFRz1`wR(*yQ%JDDI$ zj4(fwG5`G4(dL4)Q;B}DKj@oHUY&=?ZJxh+ko`qW_sdsnt$_u2b8SuM0X)uWZ_rBv zP6hjdHvoXF-jkCJ=vQbZ5zpg*#ZB#vb^eD7`e?&b-n04X4f>cE7}aT=Cy-p3tiX!oUmTz-)&63DWO{E^t-B&Sy}_~ zzvd<~jqB8!<1~>{$v%V+H#Qewubro*!FiXRmqCH8!yR#{{KJGSu#}ApC@3h);>s(q zWBsW-Riy*%T}XU>gVnvi^vo7W<%Qk8^m45C%dAzwHeP*-f&K9G&gswS%i7k|BacQrZ= zIcMUvH)dYf)q*H1@D2)0SLVj#%6z0B8}0R`F6tM>nImyjS5Dw&odpa{OBnQ zZ=X6^;KVvXYOMAtuJIRY2j6s%kr+hb_w9i%{Y2iKk<=Vqe1crQjo{0cjL&ly)~It| zg6Cvd->Jl0%6dPPwDK0IP=x^J-sRi5kvAxxK_Rtv%0v|i!WX2Zk`p%33uv;eaPV@%YoSan+5%a_4;09eJLr>o9K z!K0L}_g)Kq%DjuUWIGJ%{x8+J369w388@LPA{HCP>RGlk9hhZvF&BPDbhV0p5+Vy? z3dHVSK+6_wN>G|a8OQ3^m}G`B-H-hcmWOMZYvNPRbbcLyi;qf=mtdDJsG^xy##;rC zBWsM;l{ng(8Mg3@bs*YLlJO5twir*9IIE)kJghKdYK`T?T=%*7R_f4ulI3mUJqD$Yyu2u?5 zL61nSSEcs`xz#IJ#bx4YG=AobN{Y2~p^Cd$F;u^*kS zwUNxi8m^$f;qHmgody%XN*#u<^PRhLvGMkOI=KjaUpxZw5s@}NbM?;JrGypDG~FVK z^Lm*}t$LUTo2}B!j?U2(2ybWX$@8L8)eLGINesiJDO5m)vuPSVGF^YPu~DwmfvQ4# z|6T&LBH|g5$(Rh~{w@^P0oYjl9VfQYAnBIX6(&>39J3)HtUg~?N5QasH!Doj#C_WO z*k)qrRdg%_bBB|_kl3+J)yrIm?r?~RPb8jsKk4^-Zqi;`h(v5;cm_)9nRX+bMp4cAC8u@r813PWrT8u15xb@?v# ztS0%7Zv^%mhZ97J1MkmkM@Z_1lS;qvVv-Oj!vOVj61JW3qjec)Vut)>-$ZyR48lva z&x&A4bAvN%3GPm@z}?`>$*d*1i%ok#k8jw;W*j%D(~&yjXfUjA$cWEK3r$0Rs<^dh zt_#0dT?B5#vpk?hl@VL3(VJ z=+G%OnqOra%*D1-8czB1N;il27r)=iT)y%?Egw&F=>Oy z!1fp`Nr43msdQ&lI5$XEOBr>qfDC7k`?=M^W~AkeYE7l*qy%O8Nvzz1G=df<+0(We z526P;RlA@VK3>@x$W}S&p!*=k!@0qURhRyNXGWl%i?jM_>BEe?Y6^noIs9?p&(&ur z#r|#}$Kv_0S(dtsR-D}1q}wy?dg_5DBgC7a?IH0h7|17no%o7cw~_W~@%NcfUl-v; z9_D^l5xeflRzK7F<{KMdSU+Ht=`e`r11GYgB>yU+Y#Rer zhoNwpKy~h|Ucy1%R1F21!l@#2(kmxt+Uy2;*SdP9@2gnC^Lm^{J$W-3+&v(^J1OSLC93J?+gO zbu0dzy6N}MJptoXd3l(r_CrZGew_Ucex+r^=hTx;j@EPT!6e<#0nR89brg5LYyAgp=FFV z8D3KRsLmFv?$3pmKNT_s>Rwypm*!s1m|g+D^=$f@F&%8iY+q2CmHbmSEj+yh&i*}@ zehYk=a0D8KOfq$Er&v29n5Hn0rJ|q_CSLx6i0Mw_2e^B~{|RePFd0lQ8ii8gjN@5m zU1&W)C$r7y8QLx>%xxGyn81lSQ7cxD3yW5$8fn1KD`Q}o+BU5m6}vjbTa@ug^}Qf7 zp5`5D_=N9z49lwvZ#3nWiuIHOm)X1$QjF<^zEn_mQ0rRk6P=aB*y=g$m$f!7ERjT=DRv$qjnOT12qnUVlE`=crU3i! z_T}XdVFTnxuj!>LA)w&g#g!0TiLnG1U)i>}g&on-^X8KG%iros5O8{41U_JS@_&OW z9>k>beobfYFdZH?3^TU&`sT9_%^)j6=yxgR_o)K^)W>6al!9%dOZ&hTu%1AQm_DCwX^p z`{wmKiHa)$$cBRJ)^ZL%f%WqFha3jj^Uose<%kNXXpe(7R*M-Uk%d!S1wV=GL}@xhoZ;;R36zEze0F8j`4>s>@h*g_6nXMi0v z#-0rNQ2xgH1lO#wIa0JOqoz)*d3QQ2A}5jWY2_fNF5Tb)`9sp-(4I%pvqtjc2t^-w z;ubcFYPWP7yxHw_b30k_e7Cu=R9o^FtHM684G@R5NyHu;6`7#Fx*9BXnz)PNUSl@3DNNXYh z1+eUzai_}bPfsN7=bc)^hL__!tH!G>W+bL*hf-oX(EB#!(wIKc>xC;6LQw!0GZbT&h*I7Q|SWab4m`Na!-DNgbl>4eHn30#R zHl&&BR|BK7P5v>rJKZ4bZdy7#Z`Kat)6XB^i#k!-PpW+jsj_BVS(+9eDRxts0Ih41 z#BG$x!A*`@vljdJ9OS;l|R%t0+T}+7AP5=$elv;`ByhwUd4Gxc%!%IX=!@ zy55VDx2D_&0|NU!#Q+hu1wHaX3bR-g#(efr0;~{r78}>f~?yXLEKbt zS!H#1(wc90?)v=;q3DpuBHK zS=9}t$G?!?i)dk_k@*>Tx%;?NT2Wy>GJr7g{;c>XZy3O?FnY9ab3(lu0(Ha3HHeL9OKMhP&wXJ4j(K?i1f2NV+kkrdoOdypm0J^NkkAM}}1P0HL4L>h)IBN*pp# zp5JeyW1GH3%3A1jA$D(hN=~B8*xU z!v^ifiS?tZs~VUEL!yrjmytHLC(bwZeJd`_n^K%OYk~`a-$Sgq`B55cACrEfLtUnO zZ=CU=t^4jkd@`(~U^6ol1=d1=VZ+M4;u{-haTFDqKhM{4tOn;he;YDF#sF`7lQYgE z{f~Qsbx{K2>~*giE|#g0yLDhV;;FD-ZIZyaiGgXE3f%`1Z3HQR&cp``3`eYWG)`c@ z6O6)mN!r07oY2Jrqs_i0JnCcb!o(^18*zL)L~N4lB$6U4);4R6ui^n*)7rB}g+@9D z-_ejKg+GeqE9qX)kq!idMmKsK@Ax$>y~hGxOWsjzS#}p~?+zul5=Y)VhkHrhi%2Dn z&}YvG=F{ebP-ecs40*gkm71-A-yk7lui#|9;)yuNuV5|AW`2At^$kaRQMh^S!-jm! zr&_G{YeBD)WmYr}WK_MfgE{19q~!eD%S*Ro%n0k*TM;>)&TvTQ6PnD z_YRz07VtjH?k0wwe-?1H5-)e$vQn7S|73?jn^m&(`EnLER8rWTgCW($#OB*t8~BHT zMNZ>cB^_Qxmb*h7jxIPgXRBIMd_C)g6UO>(jQGgzH7*OPTsLBCIdd_dF2vxKVxA4w zOtWh=#9^?S+Q+Sq(6^sO6*b=FHeX2BRCE_C46>9yzB8UNGPt0xsI}y?@U*d&Xexcs zU}CMwp^yo_zMX!BY@*z9#QaVfUT}P@AKSpP4#y$^W^mYNtaf?v0ISSMPa!33yD@d& zG^HeQ0SP;f)_=!^ndCyakV;Yw${2c91Y_JNST~jp*{ZEASyf&Yb<~UK=-hn3yrmpH zm(;3&k^#o%LB}2+ngG5W_D41tz*eil7=F#BY10FBWVFYupW%6AhJQiHU!BwDw7-oN zbM-mixa}j8f=zf;H_-++BGs%n4 zshV~&s8-7SEy5IN-t~&03x+tii<&5FH58!ss#R0ITS0iH^X_7Z0zj`X*B^cvBfV!< zwlSn>hlv7BMWkagVXVAFX7=Vb-Lj3;*}9((@`y7|OpMweX6Cz0y@|r7iPUMWdqr7- z-VaoUBAy13>u$&rUm5mq;l(&Hhcy+zF7AbksAYT&JT8@K25uAzhVMGb^sEAv=w|VS)vRAad{exMdvBi>T zqO=j8>g~Z4G&n^*&ooFn@dQr%Ql_W@I~FBwbtsa%kLXlvB3cy>8(%q3O4(Y2h|}wx zds*GsbNZm2Z0n~J-6kj7V7|xRiR-|*i-H1x>*t<=cMPj?3^o1k=gfQ3CaJvBt<~b8 zoOt%v1YI*ab|yeSUAH;1g2hxM?_5%vzE7^pbx0SO_%EPqRa27RqKh6<_;z%DMQ8a? z>&R6&eLd6nBUUH|2@ClxLG+MXFbybQHB3p|Wpr4Mn8;^Ht%=!~56*FE50YV9PP2wb z91rr5#4u0+o~rM+8g;BUwE1XwyIVj01Q974l3!wWJ?)Zquhczf6+XYzPeoX!eKeZ+c-@ zOu1iHqr)!ew&(I6acX=pb%E^T`&ko)319LyQ)<&xv+Xt(wFeb;67nfx@q!6()Qw&+ zVTLlh1Voo=3;p8#w)0-cEU3+re$#h05RP(7jc0sORiEY6W`ouk8J2H1iy_AX%uiy% zaKlxC)D4W~4MXd*YXk@eV{!y3oOotpBTd>76y(G7wd3QOvbtGZ2c-(8K@>t1eq7Ws zG+1nOSSwP^!}Rw&sg7F=rP6oV^AoWLO5y!SRwR=?`Rr^=hEuTtsGAeE@oFJ{&T5G_ z*O%S2$kCa%Ztq9JFXFgwR{ zlywxUw7MgI%P(CZUZ-t;x?R_sz{+TEX>C1y`NQgEVh)NlF^RZDWHbgY4XcW2v3qEO zjM6o-CXU70`rxee+_bppN7Cj6NE^q;>^*lEPdAMOlhajab9sRdDsAybS;+=hYkdM6 ziO5MQ&MY;Kjk6Tg16b=#(NM@$EY#OaYDB5FY5e*}_=)@db?=T6f?RuTMQv8cM&4j; zpol>Ula}Wa!PUHAQ|HizzN#C{;+ipkGQmWVXliD|jo5*}g1cCAcI$jCh)F!l#Wr|z#1hdlTYeIeF+s-G zDOerr4pj5GYPvlI<=N)6Yn}%7dHo|p`w`&;*o@Q4g_6eVfRQ{I)FhUAtHdsO&gJWm z@lxUz4m|=@SOl2})s=L)1O~%Mr;gb8r^j_z_AXqNbT3YzxVX<<)*mY4D+lOv+n~-T zvk={Lx5%(aFKnfua+V>(5hyw16IxqZ;wzzNVYQ<2NQZww?5RfaAv@A?NcAJJjKeo} zG5^()0f88oJ`t#;|N9O8g`R2RQiO?dR_=Pc5F7h%Z;DSKCHw7``cE$M$Azb>0)PLV z?U0yyqH;s~wy{WIH})~(BQ*A%Idf>rNFw7Ode{>gA@{*#OR zarJ`VZv>%6XgBVGE{+N7fv%Qw>*j6JrYzFAFbG2w47}dSOp~N?K9LY{$%#$ObYp0z zH{@d88+P=s3*`_$=8x1Di_B)zcS=DI{uGr+=CLM4&B(gKE;PcIbP>4imnPF81XPDU z|16~kG%w|G$8-cxn~U+5KCd%Q3j4o#M4!%cum**MlzEA;Q7EL0FdHFyx(&jN4eOv< z`@KDyN!@?uwa+K=T#1XC2}AG4cWvr4Ne8s4u^Vo_`cO_Di!1i!y%8?nGQU5^RcP;y z?P->LnZQf(f;`%BFXao}PVhtwQ-##LN8&~Z{g@|?8xL^<0>8`Aiq=ObEm+okWW+?z zdM!sWpd=P@DbG=R%OTJwlvw@fH(;3@G}|QNy%Gp52sW1X(`jGf*wOwP6mj8$Qf9SR zuq7ia(#jmm!V1~4@@&LWg-T#rN~2_zRb=fd@QEv$u}lBQds4i9rqygi=q6;E`30=S zQa768D0^_Gble6sh1jkab10qUj_?`K^^{+>&CBf(N8$0>?i(w!ETBDxyPU(Y>6 zv;!2OzJ%`J*W6gEMugMseU|_amX7gO_7pgMk22H#ehV<@uosx`D*U7eEfb2*S!sAG zf8#lX!yNvSQ=FrhX^%{mS1-VxziGSUa6O`=OKCFJax-&Uxl(_%;$f2iT1)N^-lX*N4nyS>9EuqJ4_ZM`lDEL4}yclD6pEHI975SL^xtJ{;-y>0( z@duTt#Db~AQcZZ&E64oi;sLRN7UQSCN+!B9zuzXmhJOV}dlOLUXsxGgpXB(qQ1e@q zAr6rzsz|1o`S)nhYa&ar8&PoxGMpV)F=FS~gV)2^R!pI=9(f^@Br{*r>Kqx_R}8xV z6ErGlBd|sO0cmGHtJAWsTb^4Mn7$yEZc895o)AncG%T;ou{u`Hb>S9{<`=U0tyf&W z>9Ah1^V_oVvX5F_j?!y39@wz8A@kz0_l|mnhd|R8kB04!-@!*1JTCf?WYj~{aJu5t z0qd&O^oCk!mloN?eTmNZ4&~__`6!AFoV6=`VesRU)rr!5J)L2$sEH$KF>n zpP2vfg9Iy*gD+P(3fEHps#f1rmsd6!`slRaHQ`WUbiqI88G#Znd4`i&b-SfPg8ky2 z8q~V);;in1UOx=EgT+R|zsoc{dCIZeq6mN+Hp>$nuKlWF6qTOid-mP7I4<> zk%<%cNGH4qhX&cL8?szEh)}mKuEJD=9c8GeM~&QExXR%-%Gd;{qMwfz zkt26p@Ybqc+X%|awy~F8N}uxI)e7$_u#%o~3>I_;-iZ!wi?CkTRi z-n0wfea?NcS!XRqSJzz2hXxI)eT)iJgP=-H7Ls*wDaY+_2-qH2z$l{g!*MH{UF8*3 zFv?|Xc%1Zj&EtV8#OJ9jaF$W?j0m+7wLpkZL+y1ZK-u**YcNV0N4clo&e+ebd*8$R zFEbSjjFB;7q7~H-=PMs+24lH#S#<~i{lj{zMplSMl*xA`BS1Psv2|m+ULhA%zXzuU zfZJ)yI9a7=tCy$6Hsn4Y*KD&nyp)MkN=c}Gq!?gtl2AKw@>6^Q+qdP>j7Tct0_!X% z*F9XseTkimv2?^WCpL!pA$>r#!k&hRLI$ysw+rjM9^$ET%HDu;I4)v1_uCa+iz2Uc z36u|RD0}4WwG`r5l$UCc^g(4zI`j2yWeH21IKrpuh>(3t&_0a*5AdL-=ZLbMgFp?GfyGPnJM1Af((H- zZ}MAuSQ?`9L)eq#(rmyKsX|Sws7G7TsE6c8PJFEJ(E;yy=z5?v+8}}#uRlW>KNIMh z4`n9m8j&f|_WASZeeKcnq^Mw-2@_aHe&j_k;=k_yaQyOF&Ox9!epUn#oCnZT?KgMt zPF5pN19A?pT<#=c{)ZpEg5al@g1$j+8Q3#{TfWD~v*)?B?7_fb*WdSI$KS&L@#3m? zZZj7{)(12&mj}{)1{wJKc08RrIR*er99lxB99VhKJD7xDa}HT8lpd5%>7-fw5a6?- zu&6@l=YwI-N!Vi@jfx{8*A^3-`(pEE+~7hnlNR}kR=Ga2XVaNJKVAKD;(3>m)bj>} zrn_L!^PX1*`$_xYVfozy(Dm^$HAQ&grT&isfgJeu%2_Fh z!poJ{Zcj&s)b*pCVG;VC0co-_jH1dL^&Ega1}o(lg=TtMHEH8_{FG5Xb}|D1*5G_{ zPkcya*EL5>>ZB0K%uo73nHt0>)XXyj&n0e@`N8HxvcN{h?kA^po zx!p~r2BR;H!Yr2u;smrdfzfyEWL6ik7)4X32?y=iIluY_=?`QJYZyGr6^QDUOn-6Md(+rfb>6lCwt%yLnQz#qcSj<%EY#N*V2HsRe63g{HjBOAoP`OJb3e@TelJ1>l1nlmSg~1^pUJoTg%%S!;*Pz zj3{HN=m`>KtDKO_j7nUDjmvXiUmSCB#?GS9H!VNJW`>7^@}jc_6BN4Rpp>{nh7pYL zMlOk39TNQ%dyUV(@sN0O+8vbB&G%TMAKhQc5uW>MiT(3^r^ViKUk;;Mcb7=3sT8b6 zq7~OiRhgwvQD@6)pJhN$P4C`UzAfRyy{Z-74=sAW;6{mz^^C$N-|g5szk;h4K04(o zUu(>y2+<;_hNpVyn9$PC+#?mmbj9RL;%}Jf0S8u&HVw{N@L#mkQl&fXY&BB@*60Siwvz_mV%0it2aYJ62?Ecl z@4f|sdVPTV8`{9QG)u7P<9XX>#^r%eiZ?c8fDRLrv**f^BE0VAyA7QuYV?4`2mg{!>=361cbZ~&i4p^5KIRLzd7l~Fk~5gtLk&s zl#9w%s5;?62qRz&>yX3xwUAA)s4Ge-!2AKcmjzt%u6zJ9-OYf(pZEOH>h6+1#-B`qiD-}%>7pH!Y>fqaqmNbRCOu2gb3QVIO zs#`}_S2pQHDYwOvOrzws5&BX}mLo*Sh`a9WG1L1Bmmw__!h6q zq_BI-KeB3$YPe!cSXQVm2rsTpnpCR*#o5#aPg$74~q-%{$6&1zXAhU%tMqQffcd^Z zU(0lLFSNCG2MwIAw9~DQLK;re$PDw-+`8S&RBgF>Tja^ANK!Yxirtmq1Q~F>#Kq=AYsDAPBtQ9=z(!s8VUQvH|g^TPgx#ukD+@B-5%|a2T<-*jd}y+JIL9 z=?5l7Yld-+%l>2j#mC>{FXJy6G6U_Zz&BPUpr!FN@AGNURokgxdx|!qiq^rwgY@TT zL@5O?^Z4=T67_Ynv2bMp{(UxDs5sr5l@6*w+n~pshiHvHNKz+#j2qXykA)_5(@X}( z)KUPlxF?mt(6h8QT4c(K4+CEqlG_W6m9^~h^P+HdGzM3T`|K0+>-0B`&=v41?cOh% zGvjmm_BMYBHwJY(*Vh6*xD>iGF4?+7DH7=Yz)WFhTTmj{42N7%vxGj1WYhC}1#qr> z&Vv(geR-DeRchGZX*>z`csGOL_P~;QV0iv^A)xtTfoR-5T@iGP;+wIMYo2Z{Q4GqrG3uxO;8xtJl25o0iTA2dOQ^`l+Il z@cUIcsWw(QOmoY zuBm2aOZ#L8CbM5p2|w~gJ-f(K4%POyq+hZUbU==2ikL6>rtbO$SdhWdt3fHL#?o1E zpE>~NIoUishOT5`7#`76!W_P6E`IK0$23^&E)&uB!Fd;CvzZTL5_RpqptLv#nbsM0 zKYhVm_>S(a(H?QtyO2-J>>&jt2#7|OFau-4n%swsYDjS9a90Eo%k@;-)%265EpjV6U zPQ+0W^_j7-Bm9{3uQqq0dNzD5nvu+~O6{V1NGZH(XMeTn{4u7Njex73q-w&(eZYyG z0;dcsMuCzdr1s{oHeG8n#6_ z72Ecgs(SZc>#Vl-cg~-u-E++RN=xP(qxXLGamXZ4g>K&W&|^{L2O+ro?Nd;=kf_a6 zptO)b4b7&Nq%^GqIo@A#qedgWX(KA|S0Ky7Fswqykn@h?S<|7xC1;t^$@H7#{COf< zCC~wL$Npz9(S|h9CN`@$G(AgXP^6HpP}P>fSMxBr7hZbY}r2gVlAN%}L8v`~~NI)VG&}Hc= zVNZh+#0O#}@jM+<(#LDa7v2s5BGZaxz~I#p?P$lqk&N1L4@jGl(e=jdJsqn|zh3^( z=faB+?VY_s4fcvR5JY4H)uIl#d>WcLkIr6%{BgI?Xxn1{ns8#m&rN~-QEUWQ_CUGX zUzq5$XartZ{0l4}eov7`&zbr$vml*~!6SL0sOc-h*U^hdskxQ~?$3mYtdsGfDu5v$ zsbo(14zm2NRj3L{fHQtVc<5=;p;%*}^>gd07*@)c2dM>lk>oSvz91mkTJ54Ad4M{-M3jj)rFqG& zTGqr$800VBHu-A^R=S?DpEJ|y{ zsG_?*;Y6=?hxwF|wLnkg4J2J;D{9$9j@#3JZ3;-)DS*+WG5qNkru!4DiEc$y_q(n6 zxq6t_C04da41Hcg`Ls1%cWJ>TW+`g}VQKh>4>Za>4* zSs?TAIv#JD_Hi;rlD+jf`^F`6^RktvW%IM3idfN~X2nO~0?!>*16W4{e+#2mGs0Sm z=?5(j*^h<{nhjiW0u72(@5QMM8%WEiR2!!%8=owY88ewmBm6fi}o>*fO#dS-jRd}05 zd#=Xe4rWA=nk5Fp2}M0qlE8Bj&hDN11>$=H-M58BdnK%zHTyfmP3DZp<-D~RW{j}! zv7)Sb=Er4Tak~6ZkM%idMU>@7sn9dC!Jo;bh$)mWnyUL->Rrrg%tZ;om~<`fQZ=yt z&?Y0ED%74ek`!y|f(a}*=F$=6whD;&J=Z@)7s4Vb_fs1YcsbFZ=J2E&Vz?{cklm<| zTg2hhOyOs8e5-CVzNlo0IE2`QXLN&*vzr<@OX<~22>K#IQ+~JPWF8f`FwFqH-Ua-{ z0o)UHsADa}K~&??Y|@ym;1U|j%^Es!#NUR?YopT7hMN&_N_`0bPh_bmJ4hq`$vF%) zIVYtF)nV!IxT+9D*~}Tf?Xd}i!e=&R3I}YW*g!9|4tX;N=$9VdhOxX|ui`J-nzu2y zv`e~hLvS%`4j;f6I^K)^2E;ubavU56Dop!_PFWZYiYZ*2M-=Li=8umtmFEag1EC7a zNdzJ$8q1^rI-&=gf4j=>?GlacHjRRn<+ZJ zU|8MZ@2uR!@Y+lXsFkU!MHL5r=uQ;sc<1&Yu)~*L_qmsEv~pVsv#ZQ^O8vaD*R2N7 zCy40=vW-MmdKX^Q<=^%fl!-bP!i2uk8K~x?9!dpc($pL+DJlsu?1;W~sq3L$RZQ~b zBL7(c;!EXoV%3*pfXAeld9&%UKcPmb2H*IEQV^W@X;Bo>Nz9hMy2ocNOp38N*gXum z)eF$Asd{wjTXs>M@@|-EG|Wn_#*7SvM@VFXQ$0r7ZBGx}BuCOyON)B_%|pR95F{H> z4lI_#tweIjnxkYgt2@LvJW|439Rvh#^dP8BpMO#M5XpVkIC6})Y3BQ-;ArW>)e`n; zRRB#d`=n34-%A@g!smyhY4u~Zg*co6lI>#^r3mRo`{TkvOhIbf$lWc=){_^xSt&@Q z1~*kTu>wOrcCEv)fBd!@RwuX{Tg_MkmubG1i)nv#IEy99tN+hb6nTCk873=9 z++F$=pyMU}ch~DY&ou_EC(N&sC}ns0^0K$*Uf--jS;A4{X-7x3EUSAAO%^Spy_tRDV& z4LYh{nMzt_;Tr7!BNXM?_)jQG{=Y&|{QKr!s~hGJb;7qD#dVim+cco*T<1y+RAT`V^3r<#=(t_ za2N4SE1A0V;Txe~h#K7Oxs$~LY^SZwGg{Dz0z|~ZDE)N-^?FHhvJJ&f2ePB9@I=L@ zJTL*VXxtcPqC6p;cgG0>F3octTdPwka&|(k1=!60Er9JYn=*^)2vFNm9`rlWr1ejt zsh$Bby&&|TIChk@u_L{PmiBzp=g#kdx%Y1byXrH7?dpeL3v}@3pelaTtEEt0LTZdiI{|SQERzvOZiT}^~{0^Co`dxvwS8d zNerkQbuK*JmB)#sQi_EgO&|nA6wu8?WGRajr8PCJhH1Qa1L6mg^E1ffSfO%Cmd8IV z;&fpQ)OkUh6liYx#cu}vQh$>>_bs4Suy;VvFAW@}wW!r_FJ=jzmj2{M`l6HFj4F#HLzXj=!wzGUosua7nesTx8#T}ai_Dc`G;=g&ZoIwG zH9WCXgmAKLM_e`dXPC++e$u<|u+5t*_=QxW*ZWiovFfjsb+IWKhD4xes;@ zc;DpMrUd+^C&YR)C$~hYI;BvI(JT|ONzv%M$EYrqn5&Yfz{FXPnRT>6q$+i;zE&hC zx?GmrfDYk^IpV4iL%Kc%+HjywPUJ0MzFe!dLe1Ze*>tlWbk9BgflA4F5gfobEM>wgHdQ__N2}q6N8Vv`H*W_w) zi=GM^;4rBnYyprMR4NzXp+P2T#zoI}rI&(7kYdfFojWWnfQ6RxvxIO)5J0LK1;IbN zkK8IP>}ON<-Nn^#xZWHwX-zINTO{T(!*i>6@#TptWT)sP176T-;wsw zBkN(NksEAjUY>pNC}|Mo{sKQm^;fe8hznwx?`K zN(Rvw6%upd*yoA1ck*t6-S{M1!a(&?W?HLt?}MV~Q0|Hon$qz-wjtzD*l7v*+Az*A zVO}+9(d4n^x$oJT(p`+sLlb6Qnr9)O`o=<%?G;LKIXfp^yfrVFccRLpLcj)8VC=!h ziu586)v}aSST${07UeYRyF7!lQ{Xf6#&38p;U&JG94NLIi@y69zX(hlemtoVxvtur zo}Ha_c)hW|d~9rNjD9@me7>ak!>4PpdzxG@e~BTL+SR#ld|x=;KOdR;cn*|O_b7H= zXKq69VkUKTyFHm0%136|rab^VJ0Y{4Ggvsrr9yGhZdTKf_>vVLEI8BMc6msP?ozpM zu;MftWW{VH3I>MJYaWxSA@m0TTU1M4$xAgTxTYi=d?y<$sT4- z(WyG`4CST!s)<9IIC=jr%l$&=_&B{-)-LbM`o5AW`Ml75A znIZ@6l;ZZ7mS?B++NO_uN#vfN|Hy(H)(NG%ME+f;4Us+H(-v$%CInAZ-)?upQKvqP z>FG>R8v9!LOcA|ZJ-QotgNA-Fy1yI?yA3o2-4{0~wR_$e_tC>-y>)e%d^}$7J^6h4 zzs^rhAM_7=a%rL+wt^lYDw^5J@2gDLsGmp6TjsOXcwQ9La{{02V584N^OSB&P3Z;E zoS>AX{O)+UuSy;EWna43hXVjYu~y4V&AH z!$Ql!>9+)H#-GZdqsP39T5rptT5wEOFjn`6Jt*v$OAQ$~Nqo65h?M~u>g`Zv(nr-J zCI?2dr2xYw6(^RqT&!gkJ_i5@LPkQ<37sX1VU7KGV(I>!=o;sdCjdU@-O=+O`GvXw zw$Q6D-!a~JXg1r|d|u3#KW^+VCQeiiKitOOaU3N%4TpdsJ2Zk=GGyp%y?3O$HMq(L zpEl2S@qO}zDal|>C6~xT4Mv9!LV5C5ti%h)l_A)ag!ARZ>L~C|go~jh!l{~2za-mO|p(lEc;Mmfg#aokjd*@R=H_>V{!^Qqa2ISM*0EE_gRi zbJv)~DUBN3n)(C~;&aOPUvcLg1wwp-yZuqren1s)6J#Flp)nC)>KwPE;muc&9m&T<)w2Ls5X;RgQu0~VLJG{2d2!^=zL zr?>1&3)|Tb(i?LHzt*X+y9Q2#JoP>04K7A?OQH^RR7A44tKAyHcqrZz`b^Y2?4KU8 z7knLHzXxlXi;<=2{E=U4;$&gyxt=cQh9%pqpoJ<R!t&qw;1*b8W{v(S$xd02Y2UprLdgLxcPH3Cn^VgxKbxfb$p=s_2DF_A@ z3d@=cmcSPZCt%3^Yv`9JFs1X!=t&NV2#IJq>!g!ly(@a^eER_m;X@p8+09Yilw{-f z-s50Rz&?Z^e7h2u+lCFbrD-b9blf_V1&|v8zDKRn%Gv}eCQN`q>5iND`Y2Fp^l7Gq zZ%7P^;t23aoUmr7m?~G~f@FI^oky9UJ^tb_n zH`Z}pPndc~JyEN#Y-2se9KgpP${7oEMG4$gjq%#cGD^4IivF6A=Z6y^oPc_FEHOT5 z^fTU(!DN3M%3@Aqe(4_}%9!bo5LFaW`K1;YNqD9uNJTWZiAXyWZY5Nf+BiZZZ-~gx z^IIg+;XaJ3hj7KW5#qQB4>9Jt@MdQq$(Tg^neT+R%7H5yc|McdTgXOwyoSxvw_qdH zONn>PEexkh3M+ByN(8P%^4j<9)7|C<>3`LykrtZTO?=RG6n(~V~7I^=?w zYcPeRN(V2*q6jGLJ($AZ0zg{%v6Jy2FGy{;xsFlF82h~4_p2Q+P!2-7p;FC)RU{@X zstMz*!~I>OXw4g0QnJLwKV`fwYlD+aH8^5{W_0JpXqm-u?1fxB0$4#f@qP|-R~^hP zdu~p>2-u^01PLZoN)1|+wuUq9OZY|1#ZV-al4y0R)I?^=<{!DLw|nbIrKFwt<)J|5 zi3Kcsaz4QPQs~7-mHvytq`KlvH1H4g0-EQ!J_v6$W$l) zEmOrAm;a59+cO+fGOsvvY1a+3)Yne_yGGUdTce`>3ec-7kd&76W7~EhEt+8Scx1*{ zRGA>sZpw!cA*wj-c?DdQHrLZrT^1_-m5!?pPyPAwGx2)04EQ}f0jPPQN=nmB+^rct zzHAZcW$9O48A_*W5wbOvn`gyR@`VOtr)Zo*r$aXN`KgI!3Z^4o7xa7o4l0^MJ z1uCNaT{8s#HfJGC;1Hk+A}nHzM!SZptLbkE#K2z%kh}2dIS;ef&M;Z>y7@o)2gy?heT%V^S@co0}{Q`EnFD?X3LL4FqsBV?}ZD`m1LnIKQMt`lpP-5;n_uSq^$WHF;l@LOd-?=3d<Y|hmZ73AW(CW8cwmnTCyd$)QoVv%SbtnYLMKg! z$|mA07~Cz~nK8_^l-^sB7gM6L(eYXW*Fq^dHM=T4VQTq9gu)_lv6Ti>pwEVe{ZF`_ zzt;bX>)A}^xqs73x8ZYFWs6WTW)||Vpy=8t_|f1XKdHIqWruwx3&p~`Q1LrQ%Ms(^ z=tH&Sh)Lh#8f#aAmY2T1V^C-B&&a89ao3)EL;re^(am;4jigXJ~(uws8%w@YAk))7W`4ih*>Ve=gGGUer+EZz*(g@{~O3Rc3!CG$y?e8$!z;H6!3DHsm+1BK= zYUL=sGQY_^yZXQ69_g6tny&xIWaZ+w{BpqFU%Lht5o(mj?gf(F*oe?7{hn(2WQ?Ei z9wzzQM%|oz7#2%X!|24uKl*6RpRhkQMI zqwyfCmXeBw8ZDy*j4oM2b?RBbjx``Z0Xh9n*lSBR&jM?oufbZ*%%Q_~B+|wKwT>|s zTyTrjBsJg~!jW<6U|882#QdzLEKHNH(Fncygj{iaw7#@@pCSKRsK@Q8p+z{tLWxf3 zy{P213{%}s5FiI*3VzpyX<@C$Ddg!Z!KU1A4P~+&Md=1KUEAJ?TxOC-gsoqewL|(a zBUzrQHx8+qYP@+?mZPO+`>_tr!a<5KMJ^cJvnx8Qk5{{T+Z}fF1m`EbbHsP~JiRz) z5SD#iqQw6I6R#>ea+S)pWzft3PLO(zfzCcIq4g6^yeNMlyiXx2=ANUb#YI=63+H~o7UE%$&Q1xZ}Q>Y3Z{w-8pJDhuX!|#Kx zN3Z43hx`$$GVjit3#005z`@`MQMJiJB28WI}ngvI*!K9>(@DX$sQbr{tk zSfuC2mWH9R6M`WB#_f~!iuYN!{++jvi-?bUR5TQ2ZI|l-8;w7SP9GIa#e-#_71@y3 za)FV$+mqV5^9|W<3#TFvH2{UTl~Q$XAFB=hMD51{{u{M#BB4tKS7mF(4S}wq^~l<-6kIglY()N3yW}uCUnSxaVHxvj8%<_Y#I0Ij zg+0!|Tr=X2tBO91el?=}^CK6znZ;PYro6QhD7J}li|{7ZBM-rC$*6&_F;%8Uw$FuJkGtS8iIP#aKty=;y2X2(ltdLyLhNc`PNw`s$?- zPV{6|$hW+1no5yL@6TGu9_&X!1+kttM(*A_gSe4MlA#G(WV=_4AyiMQyj_|xk64Z= zsd!W!ek#rgW$qks?_5RP&0{CTs6?P%^uy**SMBrAAlJe|Vlc`v?OC}F0lw~>MY>#hHHHKlA5d5Cd z$Mu_&J~hkKhqhNA75H;kTX#IE(8FuDdt0pW9i@bgRX%5G(g4PEU^KO-<5oT-j@DyY zI|y{YDXuUO9Y)9uZv?15BV4}kBL`YY7n9S4IvB0OI||qpq-~6#fGfCs*<=L(0Y=$* z7`FRQn(+xZHGx!L6a*NCyP5W`DusCxaV>}=jhg4iaR`Y>!7`f_iC-J7zJ03K%d>H$ zfLy+Uxo%Js2Bz2oH)v@?)0{#3bd1se!%_o%bdS?05;zAq;Kwc|vFIa%UI5s8562ee zTd?2fwkepp&9ndWFyo%D*V9MuoQ{m&Tbj85S|{pejT#pr%%~cWl?dt!dqnhE*NDBJ znFr16t0B==nLmOX?>7a3^OB}Ms!5hyPtNkK*l*#PzY*4y$WDB}9-OkH<5cQeD8*}; zL1o*9`V;hM{BzZ>+g!kjGL|qSXm_p+9k)9XzGn#^gE@!XiV#LpKz`JhVX^( zi%ZfMG*{&+JWFY_yf=aU-57JR-PaJ)xOY7Kn})EDGU z!_2}-1l4}#h}*J)6>6vnpb-1+^v0<6_TMyro{vw< zhrQ1S4Tnb)z;{72{EynEyC!887a8yOMH*T!aW~V+$1hU7{24kV_(F$Kup(i?skGf8 z^6vms#{RxDPe$?YT2jEvN+ZjX_$<3$tf)T}+b>e4ijg=0Apxd9nKI+o_cOSTNFtaL z1~f=TkTI<`g8$%7!if339+L2vLHGg_sr=rZK)0tL#Mx|sBs+ur1IVOc2
    W*h7Za)}T(?w) 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 z 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 Q4*&rF|ClF>oB*6b02zh`OaK4? literal 0 HcmV?d00001 diff --git a/pkg/resource/schemas/embed.go b/pkg/resource/schemas/embed.go new file mode 100644 index 00000000..0008aa07 --- /dev/null +++ b/pkg/resource/schemas/embed.go @@ -0,0 +1,231 @@ +// Package schemas provides the JSON schemas embedded into the nelm binary, so that resources can be +// validated without network access. The archives are committed rather than built, so that importing +// nelm as a library gets them too; they are regenerated by scripts/schemagen, see data/README.md. +package schemas + +import ( + "context" + "embed" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" +) + +const ( + crdsArchiveFileName = "data/crds.tar.gz" + crdsBundleName = "crds" + // Both templates must mirror the layout of the archive they address, and must end in ".json", or + // kubeconform takes the source for a directory and appends a layout of its own. + crdsSchemaPathTemplate = "{{ .Group }}/{{ .ResourceKind }}_{{ .ResourceAPIVersion }}.json" + indexFileName = "data/index.json" + kubernetesArchiveFileName = "data/kubernetes.tar.gz" + kubernetesBundleName = "kubernetes" + kubernetesSchemaPathTemplate = "{{ .ResourceKind }}{{ .KindSuffix }}.json" + sha256HexLen = 64 +) + +var ( + // Named one by one rather than by glob: the patterns failing the build on a missing archive is what + // makes a binary without embedded schemas impossible to produce. + //go:embed data/index.json + //go:embed data/crds.tar.gz + //go:embed data/kubernetes.tar.gz + data embed.FS + + // Whatever the build cannot enforce about the index is checked here, once. It describes files that + // are part of the binary image, so anything wrong with it is a defect of this repository. + readIndex = sync.OnceValues(func() (*Index, error) { + indexBytes, err := data.ReadFile(indexFileName) + if err != nil { + return nil, fmt.Errorf("read %s: %w", indexFileName, err) + } + + var index Index + + if err := json.Unmarshal(indexBytes, &index); err != nil { + return nil, fmt.Errorf("decode %s: %w", indexFileName, err) + } + + if err := index.validate(); err != nil { + return nil, fmt.Errorf("invalid %s, regenerate it with: task generate:validation-schemas: %w", indexFileName, err) + } + + return &index, nil + }) +) + +// Bundle describes one embedded archive. It is generated alongside the archive itself. +type Bundle struct { + FilesCount int `json:"filesCount"` + // KubeVersions are ordered newest first, and only set for the Kubernetes bundle. + KubeVersions []string `json:"kubeVersions,omitempty"` + SHA256 string `json:"sha256"` + UncompressedSize int64 `json:"uncompressedSize"` + UpstreamCommit string `json:"upstreamCommit"` + UpstreamRef string `json:"upstreamRef"` + UpstreamRepo string `json:"upstreamRepo"` + // UpstreamTrees maps each collected Kubernetes version to the git tree sha of the upstream + // directory its schemas came from, which is what tells a moved ref that changed those schemas from + // one that did not. Only set for the Kubernetes bundle. + UpstreamTrees map[string]string `json:"upstreamTrees,omitempty"` +} + +func (b *Bundle) validate() error { + if len(b.SHA256) != sha256HexLen { + return fmt.Errorf("invalid sha256 %q", b.SHA256) + } + + if b.FilesCount < 1 { + return errors.New("no embedded validation schemas found") + } + + return nil +} + +// Index describes the bundles embedded into this binary. Both are always present: a binary that +// cannot validate resources on its own is not a supported build. +type Index struct { + CRDs *Bundle `json:"crds"` + // Kubernetes holds several minor versions merged into one flat set: the newest contributes all of + // its schemas, every older one only what no newer version has any more, which is what keeps + // resources of API versions removed along the way validatable. + Kubernetes *Bundle `json:"kubernetes"` +} + +func (i *Index) validate() error { + if i.Kubernetes == nil { + return errors.New("no kubernetes bundle") + } + + if err := i.Kubernetes.validate(); err != nil { + return fmt.Errorf("kubernetes bundle: %w", err) + } + + if len(i.Kubernetes.KubeVersions) == 0 { + return errors.New("kubernetes bundle: no kube versions recorded") + } + + for _, kubeVersion := range i.Kubernetes.KubeVersions { + if !isKubeVersion(kubeVersion) { + return fmt.Errorf("kubernetes bundle: invalid kube version %q", kubeVersion) + } + } + + if i.CRDs == nil { + return errors.New("no crds bundle") + } + + if err := i.CRDs.validate(); err != nil { + return fmt.Errorf("crds bundle: %w", err) + } + + return nil +} + +// Source is a kubeconform schema source backed by an embedded bundle. +type Source struct { + // Template is a kubeconform schema source: a path template into the unpacked bundle. + Template string + + bundle *embeddedBundle +} + +// EnsureExtracted unpacks the bundle backing this source. Callers must do this before reading it. +func (s *Source) EnsureExtracted(ctx context.Context) error { + _, err := s.bundle.ensureExtracted(ctx) + + return err +} + +func (b *embeddedBundle) source() *Source { + return &Source{ + Template: filepath.Join(b.extractionDir(), filepath.FromSlash(b.schemaPathTemplate)), + bundle: b, + } +} + +// KubeVersion returns the Kubernetes version resources are validated against: the newest the embedded +// schemas were collected from, pinned when they are generated. +func KubeVersion() (string, error) { + kubeVersions, err := KubeVersions() + if err != nil { + return "", err + } + + return kubeVersions[0], nil +} + +// CRDsSource returns the schema source backed by the embedded CRD catalog. +func CRDsSource() (*Source, error) { + index, err := ReadIndex() + if err != nil { + return nil, err + } + + return newBundle(crdsBundleName, crdsArchiveFileName, crdsSchemaPathTemplate, crdsMaxPathDepth, index.CRDs).source(), nil +} + +// KubeVersions returns the Kubernetes versions the embedded schemas were collected from, newest first. +func KubeVersions() ([]string, error) { + index, err := ReadIndex() + if err != nil { + return nil, err + } + + return slices.Clone(index.Kubernetes.KubeVersions), nil +} + +// KubernetesSource returns the schema source backed by the embedded Kubernetes schemas. +func KubernetesSource() (*Source, error) { + index, err := ReadIndex() + if err != nil { + return nil, err + } + + return newBundle(kubernetesBundleName, kubernetesArchiveFileName, + kubernetesSchemaPathTemplate, kubernetesMaxPathDepth, index.Kubernetes).source(), nil +} + +// ReadIndex returns the index of the embedded bundles. +func ReadIndex() (*Index, error) { + return readIndex() +} + +// liveExtractionDirNames are the directory names the stale extraction cleanup must not touch. +func liveExtractionDirNames() ([]string, error) { + index, err := ReadIndex() + if err != nil { + return nil, err + } + + return []string{ + extractionDirName(kubernetesBundleName, index.Kubernetes), + extractionDirName(crdsBundleName, index.CRDs), + }, nil +} + +func extractionDirName(name string, index *Bundle) string { + return fmt.Sprintf("%s-%s", name, index.SHA256[:12]) +} + +// isKubeVersion reports whether the string is a numeric Kubernetes version, which is what the index +// must record. kubeconform's "master" is not one. +func isKubeVersion(kubeVersion string) bool { + parts := strings.SplitN(strings.TrimPrefix(kubeVersion, "v"), ".", 3) + if len(parts) < 2 { + return false + } + + for _, part := range parts[:2] { + if _, err := strconv.Atoi(part); err != nil { + return false + } + } + + return true +} diff --git a/pkg/resource/schemas/extract.go b/pkg/resource/schemas/extract.go new file mode 100644 index 00000000..e3e22a38 --- /dev/null +++ b/pkg/resource/schemas/extract.go @@ -0,0 +1,343 @@ +package schemas + +import ( + "archive/tar" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "time" + + "github.com/gofrs/flock" + + "github.com/werf/nelm/pkg/common" + "github.com/werf/nelm/pkg/helm/pkg/helmpath" + "github.com/werf/nelm/pkg/log" +) + +const ( + // The depths allow the CRDs catalog its one "/" level and keep the Kubernetes bundle flat. + crdsMaxPathDepth = 2 + extractionLockFileName = ".lock" + extractionTempPattern = "extract-*" + kubernetesMaxPathDepth = 1 + // Generous on purpose: another nelm process may still be validating against the old extraction. + staleExtractionLifetime = 30 * 24 * time.Hour +) + +var ( + extractionMu sync.Mutex + + extractedDirs = make(map[string]struct{}) +) + +// embeddedBundle is one archive embedded into the binary, with how it is laid out once unpacked. +type embeddedBundle struct { + archiveFileName string + index *Bundle + maxPathDepth int + name string + schemaPathTemplate string +} + +// ensureExtracted unpacks the bundle to the cache directory and returns that directory. Bundles go to +// disk rather than memory, at most once per process. +func (b *embeddedBundle) ensureExtracted(ctx context.Context) (string, error) { + dir := b.extractionDir() + + extractionMu.Lock() + defer extractionMu.Unlock() + + if _, ok := extractedDirs[dir]; ok { + return dir, nil + } + + if err := b.extract(ctx, dir); err != nil { + return "", err + } + + extractedDirs[dir] = struct{}{} + + return dir, nil +} + +func (b *embeddedBundle) extract(ctx context.Context, dir string) error { + if extracted, err := b.isExtracted(dir); err != nil { + return err + } else if extracted { + return nil + } + + root := filepath.Dir(dir) + + if err := os.MkdirAll(root, 0o755); err != nil { + return fmt.Errorf("create cache dir %q: %w", root, err) + } + + fileLock := flock.New(filepath.Join(root, extractionLockFileName)) + + if err := fileLock.Lock(); err != nil { + return fmt.Errorf("acquire lock on %s: %w", fileLock.Path(), err) + } + + defer func() { + if err := fileLock.Unlock(); err != nil { + log.Default.Error(ctx, "release lock on %s: %s", fileLock.Path(), err) + } + }() + + // Another process might have unpacked the bundle while we were waiting for the lock. + if extracted, err := b.isExtracted(dir); err != nil { + return err + } else if extracted { + return nil + } + + log.Default.Debug(ctx, "Unpacking %d embedded %s schemas, %.1f MiB, to %s", + b.index.FilesCount, b.name, float64(b.index.UncompressedSize)/1024/1024, dir) + + tmpDir, err := os.MkdirTemp(root, extractionTempPattern) + if err != nil { + return fmt.Errorf("create temp dir in %q: %w", root, err) + } + + defer func() { + _ = os.RemoveAll(tmpDir) + }() + + if err := b.unpack(tmpDir); err != nil { + return fmt.Errorf("unpack embedded %s schemas: %w", b.name, err) + } + + // An incomplete directory has to go first: renaming onto a non-empty one fails. + if present, err := isDir(dir); err != nil { + return err + } else if present { + log.Default.Debug(ctx, "Replacing incomplete embedded %s schemas at %s", b.name, dir) + + if err := os.RemoveAll(dir); err != nil { + return fmt.Errorf("remove incomplete %q: %w", dir, err) + } + } + + // Renaming keeps the bundle either wholly absent or wholly usable, even if we are killed midway. + if err := os.Rename(tmpDir, dir); err != nil { + if extracted, checkErr := b.isExtracted(dir); checkErr != nil || !extracted { + return fmt.Errorf("move %q to %q: %w", tmpDir, dir, err) + } + } + + removeStaleExtractions(ctx, root) + + return nil +} + +// extractionDir carries the archive digest in its name, so an upgrade shipping a regenerated bundle +// unpacks next to the old one instead of mutating what other nelm processes may still be reading. +func (b *embeddedBundle) extractionDir() string { + return filepath.Join(helmpath.CachePath(common.CacheDirEmbeddedAPIResourceJSONSchemas), extractionDirName(b.name, b.index)) +} + +// isExtracted counts the files against the index rather than just stat'ing the directory: one that is +// there but has lost files makes kubeconform find no schema, and validation then passes silently. +// Cache cleaners deleting by age, as systemd-tmpfiles does to ~/.cache, produce exactly that. +func (b *embeddedBundle) isExtracted(dir string) (bool, error) { + if present, err := isDir(dir); err != nil || !present { + return false, err + } + + filesCount, err := countFiles(dir) + if err != nil { + return false, err + } + + return filesCount == b.index.FilesCount, nil +} + +func (b *embeddedBundle) unpack(destDir string) error { + archiveFile, err := data.Open(b.archiveFileName) + if err != nil { + return fmt.Errorf("open %s: %w", b.archiveFileName, err) + } + + defer archiveFile.Close() + + return unpackArchive(destDir, archiveFile, b.maxPathDepth) +} + +func unpackArchive(destDir string, reader io.Reader, maxPathDepth int) error { + gzipReader, err := gzip.NewReader(reader) + if err != nil { + return fmt.Errorf("read bundle as gzip: %w", err) + } + + defer gzipReader.Close() + + tarReader := tar.NewReader(gzipReader) + + for { + header, err := tarReader.Next() + if errors.Is(err, io.EOF) { + break + } else if err != nil { + return fmt.Errorf("read bundle as tar: %w", err) + } + + if header.Typeflag != tar.TypeReg { + return fmt.Errorf("unexpected non-regular entry %q in bundle", header.Name) + } + + schemaPath, err := schemaEntryPath(destDir, header.Name, maxPathDepth) + if err != nil { + return err + } + + if err := writeSchemaFile(schemaPath, tarReader); err != nil { + return err + } + } + + return nil +} + +func countFiles(dir string) (int, error) { + var count int + + if err := filepath.WalkDir(dir, func(_ string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + + if entry.Type().IsRegular() { + count++ + } + + return nil + }); err != nil { + return 0, fmt.Errorf("count files in %q: %w", dir, err) + } + + return count, nil +} + +func isDir(dir string) (bool, error) { + stat, err := os.Stat(dir) + if os.IsNotExist(err) { + return false, nil + } else if err != nil { + return false, fmt.Errorf("stat %q: %w", dir, err) + } + + if !stat.IsDir() { + return false, fmt.Errorf("%s is not a directory", dir) + } + + return true, nil +} + +func newBundle(name, archiveFileName, schemaPathTemplate string, maxPathDepth int, index *Bundle) *embeddedBundle { + return &embeddedBundle{ + archiveFileName: archiveFileName, + index: index, + maxPathDepth: maxPathDepth, + name: name, + schemaPathTemplate: schemaPathTemplate, + } +} + +// removeStaleExtractions cleans up bundles of other nelm versions and temp directories of interrupted +// extractions. Best effort: failing to clean up must never fail validation. +// +// Every bundle this binary carries is kept, not just the one extracted last: a release that +// regenerates one bundle and leaves the other alone would otherwise delete the other's live +// extraction, once it has aged past staleExtractionLifetime. +func removeStaleExtractions(ctx context.Context, root string) { + keep, err := liveExtractionDirNames() + if err != nil { + log.Default.Debug(ctx, "Cannot tell live embedded schemas apart from stale ones, skipping cleanup: %s", err) + + return + } + + entries, err := os.ReadDir(root) + if err != nil { + log.Default.Debug(ctx, "Cannot list %s to clean up stale embedded schemas: %s", root, err) + + return + } + + for _, entry := range entries { + if !entry.IsDir() || slices.Contains(keep, entry.Name()) { + continue + } + + info, err := entry.Info() + if err != nil || time.Since(info.ModTime()) < staleExtractionLifetime { + continue + } + + path := filepath.Join(root, entry.Name()) + + log.Default.Debug(ctx, "Removing stale embedded schemas %s", path) + + if err := os.RemoveAll(path); err != nil { + log.Default.Debug(ctx, "Cannot remove stale embedded schemas %s: %s", path, err) + } + } +} + +// schemaEntryPath turns a bundle entry name into a path inside destDir. Our bundles hold nothing but +// schema files at a known depth, so an entry resolving anywhere else means this is not one of ours. +func schemaEntryPath(destDir, entryName string, maxPathDepth int) (string, error) { + if strings.ContainsRune(entryName, '\\') { + return "", fmt.Errorf("unexpected entry name %q in bundle", entryName) + } + + parts := strings.Split(entryName, "/") + if len(parts) > maxPathDepth { + return "", fmt.Errorf("unexpected entry name %q in bundle: at most %d path components allowed", entryName, maxPathDepth) + } + + for _, part := range parts { + if part == "" || part == "." || part == ".." { + return "", fmt.Errorf("unexpected entry name %q in bundle", entryName) + } + } + + path := filepath.Join(append([]string{destDir}, parts...)...) + if !strings.HasPrefix(path, filepath.Clean(destDir)+string(os.PathSeparator)) { + return "", fmt.Errorf("unexpected entry name %q in bundle", entryName) + } + + return path, nil +} + +func writeSchemaFile(path string, reader io.Reader) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create %q: %w", filepath.Dir(path), err) + } + + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return fmt.Errorf("create %q: %w", path, err) + } + + if _, err := io.Copy(file, reader); err != nil { + file.Close() + + return fmt.Errorf("write %q: %w", path, err) + } + + if err := file.Close(); err != nil { + return fmt.Errorf("close %q: %w", path, err) + } + + return nil +} diff --git a/pkg/resource/schemas/extract_ai_test.go b/pkg/resource/schemas/extract_ai_test.go new file mode 100644 index 00000000..8cc178ed --- /dev/null +++ b/pkg/resource/schemas/extract_ai_test.go @@ -0,0 +1,280 @@ +//go:build ai_tests + +// This is an internal test on purpose: unpackBundle is what stands between a tampered archive and +// the file system, and there is no way to feed it a crafted archive through the exported API. +package schemas + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAI_EnsureExtractedRepairs covers the two ways an extraction directory can be wrong without +// being absent. Both used to be taken for a usable bundle, which makes kubeconform find no schema and +// validation pass silently. +func TestAI_EnsureExtractedRepairs(t *testing.T) { + t.Run("re-extracts a bundle that lost files", func(t *testing.T) { + setupCacheHome(t) + + ctx := context.Background() + bundle := testKubernetesBundle(t) + + dir, err := bundle.ensureExtracted(ctx) + require.NoError(t, err) + + marker := filepath.Join(dir, "deployment-apps-v1.json") + require.NoError(t, os.Remove(marker)) + + extracted, err := bundle.isExtracted(dir) + require.NoError(t, err) + assert.False(t, extracted, "a directory missing schemas must not count as extracted") + + forgetExtraction(dir) + + redone, err := bundle.ensureExtracted(ctx) + require.NoError(t, err) + assert.Equal(t, dir, redone) + assert.FileExists(t, marker) + }) + + t.Run("re-extracts an emptied bundle", func(t *testing.T) { + setupCacheHome(t) + + ctx := context.Background() + bundle := testKubernetesBundle(t) + + dir, err := bundle.ensureExtracted(ctx) + require.NoError(t, err) + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + + for _, entry := range entries { + require.NoError(t, os.RemoveAll(filepath.Join(dir, entry.Name()))) + } + + forgetExtraction(dir) + + redone, err := bundle.ensureExtracted(ctx) + require.NoError(t, err) + + count, err := countFiles(redone) + require.NoError(t, err) + assert.Equal(t, bundle.index.FilesCount, count) + }) +} + +// TestAI_RemoveStaleExtractionsKeepsEveryLiveBundle guards against the cleanup dropping the extraction +// of a bundle other than the one just extracted, which happens once that other bundle's directory has +// aged past staleExtractionLifetime while still being the current one. +func TestAI_RemoveStaleExtractionsKeepsEveryLiveBundle(t *testing.T) { + setupCacheHome(t) + + ctx := context.Background() + + index, err := ReadIndex() + require.NoError(t, err) + + crdsDir, err := newBundle(crdsBundleName, crdsArchiveFileName, crdsSchemaPathTemplate, + crdsMaxPathDepth, index.CRDs).ensureExtracted(ctx) + require.NoError(t, err) + + // Age the CRDs extraction past the cleanup threshold, as a cache untouched for a month would be. + aged := time.Now().Add(-2 * staleExtractionLifetime) + require.NoError(t, os.Chtimes(crdsDir, aged, aged)) + + root := filepath.Dir(crdsDir) + + // A leftover of some other nelm version, which is what the cleanup is actually for. + staleDir := filepath.Join(root, "kubernetes-000000000000") + require.NoError(t, os.MkdirAll(staleDir, 0o755)) + require.NoError(t, os.Chtimes(staleDir, aged, aged)) + + _, err = newBundle(kubernetesBundleName, kubernetesArchiveFileName, kubernetesSchemaPathTemplate, + kubernetesMaxPathDepth, index.Kubernetes).ensureExtracted(ctx) + require.NoError(t, err) + + assert.DirExists(t, crdsDir, "the CRDs extraction is live and must survive extracting another bundle") + assert.NoDirExists(t, staleDir, "a bundle of another nelm version must be cleaned up") +} + +func TestAI_UnpackArchive(t *testing.T) { + t.Run("unpacks regular entries", func(t *testing.T) { + destDir := t.TempDir() + + archive := buildTestArchive(t, []tar.Header{ + {Name: "deployment-apps-v1.json", Typeflag: tar.TypeReg}, + {Name: "configmap-v1.json", Typeflag: tar.TypeReg}, + }) + + require.NoError(t, unpackArchive(destDir, bytes.NewReader(archive), kubernetesMaxPathDepth)) + + for _, name := range []string{"deployment-apps-v1.json", "configmap-v1.json"} { + content, err := os.ReadFile(filepath.Join(destDir, name)) + require.NoError(t, err) + assert.Equal(t, "{}", string(content)) + } + }) + + t.Run("rejects entries that escape the destination", func(t *testing.T) { + for _, name := range []string{ + "../escape.json", + "nested/deployment.json", + "/absolute.json", + "..", + ".", + `..\windows.json`, + "a/b/c/deep.json", + } { + t.Run(name, func(t *testing.T) { + destDir := t.TempDir() + + archive := buildTestArchive(t, []tar.Header{{Name: name, Typeflag: tar.TypeReg}}) + + err := unpackArchive(destDir, bytes.NewReader(archive), kubernetesMaxPathDepth) + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected entry name") + + entries, err := os.ReadDir(destDir) + require.NoError(t, err) + assert.Empty(t, entries) + }) + } + }) + + // Note that this is about the depth allowed in entry names, not about unpacking a part of a + // bundle: a bundle is always written out whole, in a single streaming pass. The CRDs bundle is laid + // out as "/_.json", so it needs one directory level to be accepted, while the + // Kubernetes bundle is flat and must not have any. + t.Run("accepts a group directory in an entry name when the bundle allows that depth", func(t *testing.T) { + destDir := t.TempDir() + + archive := buildTestArchive(t, []tar.Header{ + {Name: "monitoring.coreos.com/prometheus_v1.json", Typeflag: tar.TypeReg}, + }) + + require.NoError(t, unpackArchive(destDir, bytes.NewReader(archive), crdsMaxPathDepth)) + + assert.FileExists(t, filepath.Join(destDir, "monitoring.coreos.com", "prometheus_v1.json")) + }) + + t.Run("rejects a group directory in a flat bundle", func(t *testing.T) { + destDir := t.TempDir() + + archive := buildTestArchive(t, []tar.Header{ + {Name: "monitoring.coreos.com/prometheus_v1.json", Typeflag: tar.TypeReg}, + }) + + err := unpackArchive(destDir, bytes.NewReader(archive), kubernetesMaxPathDepth) + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected entry name") + }) + + t.Run("rejects escaping a group directory", func(t *testing.T) { + destDir := t.TempDir() + + archive := buildTestArchive(t, []tar.Header{ + {Name: "../escape/prometheus_v1.json", Typeflag: tar.TypeReg}, + }) + + err := unpackArchive(destDir, bytes.NewReader(archive), crdsMaxPathDepth) + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected entry name") + + entries, err := os.ReadDir(destDir) + require.NoError(t, err) + assert.Empty(t, entries) + }) + + t.Run("rejects non-regular entries", func(t *testing.T) { + destDir := t.TempDir() + + archive := buildTestArchive(t, []tar.Header{ + {Name: "link.json", Typeflag: tar.TypeSymlink, Linkname: "/etc/passwd"}, + }) + + err := unpackArchive(destDir, bytes.NewReader(archive), kubernetesMaxPathDepth) + require.Error(t, err) + assert.Contains(t, err.Error(), "non-regular entry") + }) + + t.Run("rejects a non-gzip bundle", func(t *testing.T) { + err := unpackArchive(t.TempDir(), bytes.NewReader([]byte("not an archive")), kubernetesMaxPathDepth) + require.Error(t, err) + assert.Contains(t, err.Error(), "gzip") + }) +} + +func buildTestArchive(t *testing.T, headers []tar.Header) []byte { + t.Helper() + + const content = "{}" + + var buf bytes.Buffer + + gzipWriter := gzip.NewWriter(&buf) + tarWriter := tar.NewWriter(gzipWriter) + + for _, header := range headers { + header.Mode = 0o644 + + if header.Typeflag == tar.TypeReg { + header.Size = int64(len(content)) + } + + // A crafted archive would not use the strict USTAR format our generator does. + header.Format = tar.FormatPAX + + require.NoError(t, tarWriter.WriteHeader(&header)) + + if header.Typeflag == tar.TypeReg { + _, err := tarWriter.Write([]byte(content)) + require.NoError(t, err) + } + } + + require.NoError(t, tarWriter.Close()) + require.NoError(t, gzipWriter.Close()) + + return buf.Bytes() +} + +// forgetExtraction drops the in-process record that a directory is unpacked, which is what makes the +// next call check the directory on disk again. A bundle is only ever unpacked once per process, so +// this is how a run that finds a damaged extraction left by an earlier one is reproduced. +func forgetExtraction(dir string) { + extractionMu.Lock() + defer extractionMu.Unlock() + + delete(extractedDirs, dir) +} + +// setupCacheHome points the cache directory at a temporary one. Note that the vendored helmpath only +// honors XDG_CACHE_HOME, not HELM_CACHE_HOME. +func setupCacheHome(t *testing.T) string { + t.Helper() + + cacheHome := t.TempDir() + t.Setenv("XDG_CACHE_HOME", cacheHome) + + return cacheHome +} + +func testKubernetesBundle(t *testing.T) *embeddedBundle { + t.Helper() + + index, err := ReadIndex() + require.NoError(t, err) + + return newBundle(kubernetesBundleName, kubernetesArchiveFileName, kubernetesSchemaPathTemplate, + kubernetesMaxPathDepth, index.Kubernetes) +} diff --git a/pkg/resource/schemas/schemas_ai_test.go b/pkg/resource/schemas/schemas_ai_test.go new file mode 100644 index 00000000..ce3ad996 --- /dev/null +++ b/pkg/resource/schemas/schemas_ai_test.go @@ -0,0 +1,291 @@ +//go:build ai_tests + +package schemas_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/werf/nelm/pkg/common" + "github.com/werf/nelm/pkg/resource/schemas" +) + +func TestAI_CRDsSource(t *testing.T) { + t.Run("unpacks a schema per group directory", func(t *testing.T) { + setupCacheHome(t) + + source := requireCRDsSource(t) + require.NoError(t, source.EnsureExtracted(context.Background())) + + dir := sourceDir(t, source) + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + require.NotEmpty(t, entries) + + for _, entry := range entries { + require.True(t, entry.IsDir(), "the CRDs bundle must unpack into one directory per API group") + } + + // A well known CRD, laid out the way the schema path template resolves. + assert.FileExists(t, filepath.Join(dir, "monitoring.coreos.com", "prometheus_v1.json")) + }) + + t.Run("template matches the upstream catalog layout", func(t *testing.T) { + setupCacheHome(t) + + source := requireCRDsSource(t) + + assert.True(t, strings.HasSuffix(source.Template, ".json"), "got %s", source.Template) + assert.Contains(t, filepath.ToSlash(source.Template), "{{ .Group }}/{{ .ResourceKind }}_{{ .ResourceAPIVersion }}.json") + }) +} + +func TestAI_EmbeddedBundlesPresent(t *testing.T) { + // Reading the index is what rejects a binary that cannot validate resources on its own, so this + // failing means the committed bundles are unusable: run task generate:validation-schemas. + index, err := schemas.ReadIndex() + require.NoError(t, err) + + require.NotNil(t, index.Kubernetes, "the Kubernetes schemas bundle is missing") + assert.Positive(t, index.Kubernetes.FilesCount) + assert.Positive(t, index.Kubernetes.UncompressedSize) + assert.NotEmpty(t, index.Kubernetes.UpstreamCommit) + + kubeVersions := index.Kubernetes.KubeVersions + require.NotEmpty(t, kubeVersions, "the Kubernetes bundle records no versions it was built from") + assert.Equal(t, kubeVersions, lo.Uniq(kubeVersions), "every collected version must be distinct") + + // The newest collected version is the one resources are validated against. That it is the version + // pinned in the Taskfile is what "task lint:validation-schemas" checks. + kubeVersion, err := schemas.KubeVersion() + require.NoError(t, err) + assert.Equal(t, kubeVersions[0], kubeVersion) + + // Newest first: the merge relies on that order, since a version only contributes the schemas that + // none of the versions before it in this list has. + for i := 1; i < len(kubeVersions); i++ { + assert.Less(t, minorNumberOf(t, kubeVersions[i]), minorNumberOf(t, kubeVersions[i-1]), + "the collected versions must be recorded newest first, got %v", kubeVersions) + } + + require.NotNil(t, index.CRDs, "the CRDs bundle is missing") + assert.Positive(t, index.CRDs.FilesCount) + assert.Positive(t, index.CRDs.UncompressedSize) + assert.NotEmpty(t, index.CRDs.UpstreamCommit) + assert.Empty(t, index.CRDs.KubeVersions, "CRD schemas are not tied to a Kubernetes version") +} + +func TestAI_EnsureExtracted(t *testing.T) { + t.Run("is idempotent", func(t *testing.T) { + setupCacheHome(t) + + ctx := context.Background() + source := requireKubernetesSource(t) + + require.NoError(t, source.EnsureExtracted(ctx)) + + marker := filepath.Join(sourceDir(t, source), "deployment-apps-v1.json") + + stat, err := os.Stat(marker) + require.NoError(t, err) + + require.NoError(t, source.EnsureExtracted(ctx)) + + // Unpacking again would have recreated the file. + restat, err := os.Stat(marker) + require.NoError(t, err) + assert.Equal(t, stat.ModTime(), restat.ModTime()) + }) + + t.Run("is safe to call concurrently", func(t *testing.T) { + setupCacheHome(t) + + ctx := context.Background() + source := requireKubernetesSource(t) + errs := make([]error, 8) + + var wg sync.WaitGroup + + for i := range errs { + wg.Add(1) + + go func() { + defer wg.Done() + + errs[i] = source.EnsureExtracted(ctx) + }() + } + + wg.Wait() + + for i := range errs { + require.NoError(t, errs[i]) + } + }) + + t.Run("unpacks bundles side by side under the nelm cache directory", func(t *testing.T) { + cacheHome := setupCacheHome(t) + + ctx := context.Background() + kubernetesSource := requireKubernetesSource(t) + crdsSource := requireCRDsSource(t) + + require.NoError(t, kubernetesSource.EnsureExtracted(ctx)) + require.NoError(t, crdsSource.EnsureExtracted(ctx)) + + kubernetesDir := sourceDir(t, kubernetesSource) + crdsDir := sourceDir(t, crdsSource) + + assert.NotEqual(t, kubernetesDir, crdsDir) + + for _, dir := range []string{kubernetesDir, crdsDir} { + assert.True(t, strings.HasPrefix(dir, cacheHome), "expected %s to be under the cache home %s", dir, cacheHome) + assert.Contains(t, filepath.ToSlash(dir), common.CacheDirEmbeddedAPIResourceJSONSchemas) + } + }) +} + +func TestAI_KubernetesSource(t *testing.T) { + t.Run("unpacks a flat directory of schemas", func(t *testing.T) { + setupCacheHome(t) + + source := requireKubernetesSource(t) + require.NoError(t, source.EnsureExtracted(context.Background())) + + dir := sourceDir(t, source) + index, err := schemas.ReadIndex() + require.NoError(t, err) + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + require.Len(t, entries, index.Kubernetes.FilesCount) + + for _, entry := range entries { + require.False(t, entry.IsDir(), "the Kubernetes bundle must unpack flat") + } + + // The file kubeconform builds a path to for Deployment/apps/v1 must be there. + assert.FileExists(t, filepath.Join(dir, "deployment-apps-v1.json")) + }) + + t.Run("holds the schemas of every collected version, newest content winning", func(t *testing.T) { + setupCacheHome(t) + + source := requireKubernetesSource(t) + require.NoError(t, source.EnsureExtracted(context.Background())) + + dir := sourceDir(t, source) + + // PodCertificateRequest certificates.k8s.io/v1alpha1 exists in Kubernetes 1.34 alone, so this + // schema can only have come from an older collected version, and only while 1.34 is one of them. + // Pick another such schema once the collected window moves past 1.34. + assert.FileExists(t, filepath.Join(dir, "podcertificaterequest-certificates-v1alpha1.json"), + "the schemas only older Kubernetes versions have must be collected too") + + // Kinds that several collected versions have must come from the newest of them. Pod is one, + // and spec.hostnameOverride was added to it after the oldest collected version, so an older + // schema winning would drop the field. + assert.Contains(t, specProperties(t, filepath.Join(dir, "pod-v1.json")), "hostnameOverride", + "a schema present in several versions must be the one of the newest of them") + }) + + t.Run("template resolves without a Kubernetes version in the path", func(t *testing.T) { + setupCacheHome(t) + + source := requireKubernetesSource(t) + + // Ending with ".json" is what stops kubeconform from treating the source as a directory and + // appending its own "-standalone/..." path suffix to it. + assert.True(t, strings.HasSuffix(source.Template, ".json"), "got %s", source.Template) + assert.Contains(t, source.Template, "{{ .ResourceKind }}{{ .KindSuffix }}") + assert.NotContains(t, source.Template, "standalone") + }) +} + +// minorNumberOf returns the minor of a Kubernetes version as a number, so that versions can be +// ordered by it. +func minorNumberOf(t *testing.T, kubeVersion string) int { + t.Helper() + + parts := strings.SplitN(strings.TrimPrefix(kubeVersion, "v"), ".", 3) + require.GreaterOrEqual(t, len(parts), 2, "kube version %q", kubeVersion) + + minor, err := strconv.Atoi(parts[1]) + require.NoError(t, err, "kube version %q", kubeVersion) + + return minor +} + +func requireCRDsSource(t *testing.T) *schemas.Source { + t.Helper() + + source, err := schemas.CRDsSource() + require.NoError(t, err) + require.NotNil(t, source) + + return source +} + +func requireKubernetesSource(t *testing.T) *schemas.Source { + t.Helper() + + source, err := schemas.KubernetesSource() + require.NoError(t, err) + require.NotNil(t, source) + + return source +} + +// setupCacheHome points the cache directory, which is where nelm unpacks the embedded schemas, at a +// temporary directory, and returns it. Note that the vendored helmpath only honors XDG_CACHE_HOME, +// not HELM_CACHE_HOME. +func setupCacheHome(t *testing.T) string { + t.Helper() + + cacheHome := t.TempDir() + t.Setenv("XDG_CACHE_HOME", cacheHome) + + return cacheHome +} + +// sourceDir is the directory a source's path template resolves against. +func sourceDir(t *testing.T, source *schemas.Source) string { + t.Helper() + + dir, _, found := strings.Cut(source.Template, "{{") + require.True(t, found, "source template %s has no placeholders", source.Template) + + return filepath.Clean(dir) +} + +// specProperties returns the names of the "spec" properties a schema file defines. +func specProperties(t *testing.T, path string) []string { + t.Helper() + + schemaBytes, err := os.ReadFile(path) + require.NoError(t, err) + + var schema struct { + Properties struct { + Spec struct { + Properties map[string]any `json:"properties"` + } `json:"spec"` + } `json:"properties"` + } + + require.NoError(t, json.Unmarshal(schemaBytes, &schema)) + require.NotEmpty(t, schema.Properties.Spec.Properties, "%s defines no spec properties", path) + + return lo.Keys(schema.Properties.Spec.Properties) +} diff --git a/pkg/resource/validate.go b/pkg/resource/validate.go index ff96b8f7..700d127e 100644 --- a/pkg/resource/validate.go +++ b/pkg/resource/validate.go @@ -43,9 +43,9 @@ func validateResourceSchemas(ctx context.Context, releaseNamespace string, resou } kubeConformValidator, err := newKubeConformValidator( - opts.ValidationKubeVersion, opts.ValidationSchemaCacheLifetime, - append(opts.ValidationExtraSchemas, opts.ValidationSchemas...)) + opts.ValidationExtraSchemas, + opts.LocalResourceValidation) if err != nil { return fmt.Errorf("get schema validator: %w", err) } @@ -61,19 +61,17 @@ func validateResourceSchemas(ctx context.Context, releaseNamespace string, resou continue } - if !opts.LocalResourceValidation { - if err := kubeConformValidator.Validate(ctx, res.ResourceSpec); err != nil { - e := fmt.Errorf("validate %s: %w", res.IDHuman(), err) - - var vErr *validator.ValidationError - if errors.As(err, &vErr) { - validationErrs.Add(e) + if err := kubeConformValidator.Validate(ctx, res.ResourceSpec); err != nil { + e := fmt.Errorf("validate %s: %w", res.IDHuman(), err) - continue - } + var vErr *validator.ValidationError + if errors.As(err, &vErr) { + validationErrs.Add(e) - return e + continue } + + return e } if err := validateResourceWithCodec(res); err != nil { diff --git a/pkg/resource/validate_ai_test.go b/pkg/resource/validate_ai_test.go index 73d472d9..3a4e856d 100644 --- a/pkg/resource/validate_ai_test.go +++ b/pkg/resource/validate_ai_test.go @@ -121,7 +121,7 @@ func TestAI_ValidateLocal(t *testing.T) { }, testReleaseNamespace) ctx := context.Background() - opts := makeValidationOptions(testKubeVersion, []string{schemaURL}) + opts := makeValidationOptions([]string{schemaURL}) opts.ValidationSkip = []string{"kind=ConfigMap"} err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{invalidConfigMap}, opts) @@ -140,7 +140,7 @@ func TestAI_ValidateLocal(t *testing.T) { }, testReleaseNamespace) ctx := context.Background() - opts := makeValidationOptions(testKubeVersion, []string{schemaURL}) + opts := makeValidationOptions([]string{schemaURL}) opts.ValidationSkip = []string{"name=skip-me"} err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{invalidDeployment}, opts) @@ -149,14 +149,19 @@ func TestAI_ValidateLocal(t *testing.T) { }) t.Run("integration", func(t *testing.T) { - t.Run("LocalResourceValidation_skips_kubeconform", func(t *testing.T) { + t.Run("LocalResourceValidation_validates_against_embedded_schemas", func(t *testing.T) { setupTestEnvironment(t) - deploymentMissingSpec := makeInstallableResource(t, map[string]interface{}{ + // No schema sources are configured and nothing is reachable over the network, so this + // can only be caught by the schemas embedded into the binary. + invalidDeployment := makeInstallableResource(t, map[string]interface{}{ "apiVersion": "apps/v1", "kind": "Deployment", "metadata": map[string]interface{}{ - "name": "deployment-missing-spec", + "name": "invalid-deployment", + }, + "spec": map[string]interface{}{ + "replicas": "should-be-integer", }, }, testReleaseNamespace) @@ -165,8 +170,8 @@ func TestAI_ValidateLocal(t *testing.T) { LocalResourceValidation: true, } - err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{deploymentMissingSpec}, opts) - assert.NoError(t, err) + err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{invalidDeployment}, opts) + assertValidationError(t, err, "/spec/replicas") }) t.Run("NoResourceValidation_skips_all_validation", func(t *testing.T) { @@ -303,6 +308,43 @@ func TestAI_ValidateResourceWithCodec(t *testing.T) { LocalResourceValidation: true, } + // Since LocalResourceValidation validates against the embedded schemas instead of skipping + // schema validation, this is now caught by the schema before the codec ever sees it. + err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{pod}, opts) + assertValidationError(t, err, "/spec/containers") + }) + + t.Run("value_rejected_only_by_the_codec_fails", func(t *testing.T) { + setupTestEnvironment(t) + + // A quantity is just a string as far as the JSON schema is concerned, so this reaches the + // codec check and keeps it covered now that schema validation always runs. + pod := makeInstallableResource(t, map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]interface{}{ + "name": "test-pod", + }, + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{ + "name": "app", + "image": "nginx:latest", + "resources": map[string]interface{}{ + "limits": map[string]interface{}{ + "memory": "not-a-quantity", + }, + }, + }, + }, + }, + }, testReleaseNamespace) + + ctx := context.Background() + opts := common.ResourceValidationOptions{ + LocalResourceValidation: true, + } + err := resource.ValidateLocal(ctx, testReleaseNamespace, []*resource.InstallableResource{pod}, opts) assertValidationError(t, err, "decode") }) diff --git a/scripts/schemagen/download.go b/scripts/schemagen/download.go new file mode 100644 index 00000000..a11b89dc --- /dev/null +++ b/scripts/schemagen/download.go @@ -0,0 +1,453 @@ +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "slices" + "strings" + "sync" + "time" + + "github.com/werf/nelm/pkg/log" +) + +const downloadAttempts = 3 + +// downloadMergedKubernetesSchemas collects the passed versions into a single flat set, newest first, +// each contributing only the schemas none of the newer ones has. Only those are downloaded, which is +// what makes reaching further back cheap: consecutive minor versions differ by a handful of files. +func downloadMergedKubernetesSchemas(ctx context.Context, client *http.Client, opts options, kubeVersions []string, dirSHAs map[string]string, commit string) ([]schemaFile, error) { + var files []schemaFile + + seen := make(map[string]struct{}) + + for _, kubeVersion := range kubeVersions { + dirName := kubernetesVersionDirName(kubeVersion) + + dirSHA, ok := dirSHAs[dirName] + if !ok { + return nil, fmt.Errorf("%s has no %s directory at %s", opts.kubernetesRepo, dirName, commit) + } + + names, err := listKubernetesSchemas(ctx, client, opts, dirName, dirSHA) + if err != nil { + return nil, fmt.Errorf("list schemas of Kubernetes %s: %w", kubeVersion, err) + } + + missing := make([]string, 0, len(names)) + + for _, name := range names { + if _, ok := seen[name]; ok { + continue + } + + seen[name] = struct{}{} + + missing = append(missing, name) + } + + if len(missing) == 0 { + log.Default.Info(ctx, "Kubernetes %s has no schemas that a newer version does not have already", kubeVersion) + + continue + } + + log.Default.Info(ctx, "Downloading %d of the %d schemas of Kubernetes %s from %s@%s", + len(missing), len(names), kubeVersion, opts.kubernetesRepo, commit[:7]) + + downloaded, err := downloadKubernetesSchemas(ctx, client, opts, kubeVersion, commit, missing) + if err != nil { + return nil, fmt.Errorf("download schemas of Kubernetes %s: %w", kubeVersion, err) + } + + files = append(files, downloaded...) + } + + return files, nil +} + +func downloadKubernetesSchemas(ctx context.Context, client *http.Client, opts options, kubeVersion, commit string, names []string) ([]schemaFile, error) { + // Cancelling on the first failure keeps a dead or rate-limiting server from being hammered with + // every remaining name before the error surfaces. + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + files := make([]schemaFile, len(names)) + errs := make([]error, len(names)) + semaphore := make(chan struct{}, opts.parallelism) + + var wg sync.WaitGroup + + for i, name := range names { + wg.Add(1) + + go func() { + defer wg.Done() + + semaphore <- struct{}{} + defer func() { <-semaphore }() + + file, err := downloadKubernetesSchema(ctx, client, opts, kubeVersion, commit, name) + if err != nil { + // Requests we cancelled ourselves would only bury the error that caused it. + if ctx.Err() == nil { + errs[i] = err + } + + cancel() + + return + } + + files[i] = file + }() + } + + wg.Wait() + + if err := errors.Join(errs...); err != nil { + return nil, err + } + + return files, nil +} + +// kubernetesVersionDirSHAs lists the directories of the repository at treeish, which may be a commit +// or a ref, mapped to the tree sha their contents are listed by. One request serves every version. +func kubernetesVersionDirSHAs(ctx context.Context, client *http.Client, repo, treeish string) (map[string]string, error) { + var rootTree struct { + Tree []struct { + Path string `json:"path"` + SHA string `json:"sha"` + Type string `json:"type"` + } `json:"tree"` + } + + rootURL := fmt.Sprintf("https://api.github.com/repos/%s/git/trees/%s", repo, treeish) + if err := getJSON(ctx, client, rootURL, &rootTree); err != nil { + return nil, err + } + + dirSHAs := make(map[string]string, len(rootTree.Tree)) + + for _, entry := range rootTree.Tree { + if entry.Type == "tree" { + dirSHAs[entry.Path] = entry.SHA + } + } + + if len(dirSHAs) == 0 { + return nil, fmt.Errorf("%s has no directories at %s", repo, treeish) + } + + return dirSHAs, nil +} + +// listKubernetesSchemas returns the names kubeconform can actually request. Upstream ships every +// schema twice, bare and suffixed with group and version; kubeconform only ever builds the suffixed +// name, so the bare half of the tree and the shared "_definitions.json" are dead weight. +func listKubernetesSchemas(ctx context.Context, client *http.Client, opts options, dirName, dirSHA string) ([]string, error) { + var dirTree struct { + Tree []struct { + Path string `json:"path"` + Type string `json:"type"` + } `json:"tree"` + Truncated bool `json:"truncated"` + } + + dirURL := fmt.Sprintf("https://api.github.com/repos/%s/git/trees/%s", opts.kubernetesRepo, dirSHA) + if err := getJSON(ctx, client, dirURL, &dirTree); err != nil { + return nil, err + } + + if dirTree.Truncated { + return nil, fmt.Errorf("listing of %s was truncated by the GitHub API", dirName) + } + + var names []string + + for _, entry := range dirTree.Tree { + if entry.Type != "blob" || !strings.HasSuffix(entry.Path, ".json") { + continue + } + + // Every name kubeconform builds carries at least a "-" suffix. + if !strings.Contains(entry.Path, "-") { + continue + } + + names = append(names, entry.Path) + } + + if len(names) == 0 { + return nil, fmt.Errorf("no schemas found in %s", dirName) + } + + slices.Sort(names) + + return names, nil +} + +func downloadKubernetesSchema(ctx context.Context, client *http.Client, opts options, kubeVersion, commit, name string) (schemaFile, error) { + url := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/v%s-standalone/%s", + opts.kubernetesRepo, commit, kubeVersion, name) + + content, err := get(ctx, client, url, nil) + if err != nil { + return schemaFile{}, fmt.Errorf("download %s: %w", name, err) + } + + stripped, err := stripSchemaBytes(content) + if err != nil { + return schemaFile{}, fmt.Errorf("strip %s: %w", name, err) + } + + return schemaFile{content: stripped, name: name}, nil +} + +func getJSON(ctx context.Context, client *http.Client, url string, target any) error { + body, err := get(ctx, client, url, nil) + if err != nil { + return err + } + + if err := json.Unmarshal(body, target); err != nil { + return fmt.Errorf("decode response of %s: %w", url, err) + } + + return nil +} + +func resolveCommit(ctx context.Context, client *http.Client, repo, ref string) (string, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/commits/%s", repo, ref) + + // This Accept header makes the API respond with the bare commit sha instead of the full commit. + body, err := get(ctx, client, url, map[string]string{"Accept": "application/vnd.github.sha"}) + if err != nil { + return "", err + } + + commit := strings.TrimSpace(string(body)) + if len(commit) < 7 { + return "", fmt.Errorf("got unexpected commit sha %q", commit) + } + + return commit, nil +} + +// downloadCRDsCatalog takes the catalog as one repository tarball and keeps what kubeconform can +// address: the schemas laid out as "/.json". Anything deeper, such as the +// "openshift/v4.11-strict/" trees, is unreachable through the path template and would only bloat. +func downloadCRDsCatalog(ctx context.Context, client *http.Client, repo, commit string) ([]schemaFile, error) { + url := fmt.Sprintf("https://codeload.github.com/%s/tar.gz/%s", repo, commit) + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("create request for %s: %w", url, err) + } + + response, err := client.Do(request) + if err != nil { + return nil, fmt.Errorf("get %s: %w", url, err) + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("get %s: unexpected status %s", url, response.Status) + } + + gzipReader, err := gzip.NewReader(response.Body) + if err != nil { + return nil, fmt.Errorf("read %s as gzip: %w", url, err) + } + + defer gzipReader.Close() + + tarReader := tar.NewReader(gzipReader) + + var files []schemaFile + + for { + header, err := tarReader.Next() + if errors.Is(err, io.EOF) { + break + } else if err != nil { + return nil, fmt.Errorf("read %s as tar: %w", url, err) + } + + if header.Typeflag != tar.TypeReg { + continue + } + + name, ok := crdSchemaName(header.Name) + if !ok { + continue + } + + content, err := io.ReadAll(tarReader) + if err != nil { + return nil, fmt.Errorf("read %s: %w", header.Name, err) + } + + stripped, err := stripSchemaBytes(content) + if err != nil { + return nil, fmt.Errorf("strip %s: %w", header.Name, err) + } + + files = append(files, schemaFile{content: stripped, name: name}) + } + + if len(files) == 0 { + return nil, fmt.Errorf("no CRD schemas found in %s", url) + } + + return files, nil +} + +func get(ctx context.Context, client *http.Client, url string, headers map[string]string) ([]byte, error) { + var lastErr error + + for attempt := range downloadAttempts { + if attempt > 0 { + time.Sleep(time.Duration(attempt) * time.Second) + } + + body, retriable, err := getOnce(ctx, client, url, headers) + if err == nil { + return body, nil + } + + lastErr = err + + if !retriable { + break + } + } + + return nil, lastErr +} + +// buildArchive packs the schemas into a reproducible tar.gz: entries sorted by name and every piece of +// metadata that would vary between machines zeroed out, so the same inputs give identical bytes. +func buildArchive(files []schemaFile) ([]byte, int64, error) { + slices.SortFunc(files, func(a, b schemaFile) int { + return strings.Compare(a.name, b.name) + }) + + var buf bytes.Buffer + + gzipWriter, err := gzip.NewWriterLevel(&buf, gzip.BestCompression) + if err != nil { + return nil, 0, fmt.Errorf("create gzip writer: %w", err) + } + + tarWriter := tar.NewWriter(gzipWriter) + + var uncompressedSize int64 + + for _, file := range files { + header := &tar.Header{ + Format: tar.FormatUSTAR, + ModTime: time.Unix(0, 0).UTC(), + Mode: 0o644, + Name: file.name, + Size: int64(len(file.content)), + Typeflag: tar.TypeReg, + } + + if err := tarWriter.WriteHeader(header); err != nil { + return nil, 0, fmt.Errorf("write header for %s: %w", file.name, err) + } + + if _, err := tarWriter.Write(file.content); err != nil { + return nil, 0, fmt.Errorf("write %s: %w", file.name, err) + } + + uncompressedSize += int64(len(file.content)) + } + + if err := tarWriter.Close(); err != nil { + return nil, 0, fmt.Errorf("close tar writer: %w", err) + } + + if err := gzipWriter.Close(); err != nil { + return nil, 0, fmt.Errorf("close gzip writer: %w", err) + } + + return buf.Bytes(), uncompressedSize, nil +} + +// crdSchemaName turns a path inside the catalog tarball into the bundle entry name, reporting +// whether the file is a schema kubeconform can address at all. +func crdSchemaName(tarPath string) (string, bool) { + // Repository tarballs nest everything under a single "-/" directory. + _, path, found := strings.Cut(tarPath, "/") + if !found { + return "", false + } + + if !strings.HasSuffix(path, ".json") { + return "", false + } + + parts := strings.Split(path, "/") + if len(parts) != 2 { + return "", false + } + + // The schema path template always renders "_.json". + if !strings.Contains(parts[1], "_") { + return "", false + } + + return path, true +} + +func getOnce(ctx context.Context, client *http.Client, url string, headers map[string]string) ([]byte, bool, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, false, fmt.Errorf("create request for %s: %w", url, err) + } + + for name, value := range headers { + request.Header.Set(name, value) + } + + // Authenticating lifts the GitHub API rate limit, which is easy to hit on shared CI runners. + if token := os.Getenv("GITHUB_TOKEN"); token != "" && strings.HasPrefix(url, "https://api.github.com/") { + request.Header.Set("Authorization", "Bearer "+token) + } + + response, err := client.Do(request) + if err != nil { + return nil, true, fmt.Errorf("get %s: %w", url, err) + } + + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, true, fmt.Errorf("read response of %s: %w", url, err) + } + + if response.StatusCode != http.StatusOK { + retriable := response.StatusCode >= http.StatusInternalServerError || response.StatusCode == http.StatusTooManyRequests + + return nil, retriable, fmt.Errorf("get %s: unexpected status %s", url, response.Status) + } + + return body, false, nil +} + +func kubernetesVersionDirName(kubeVersion string) string { + return "v" + kubeVersion + "-standalone" +} diff --git a/scripts/schemagen/main.go b/scripts/schemagen/main.go new file mode 100644 index 00000000..1e278559 --- /dev/null +++ b/scripts/schemagen/main.go @@ -0,0 +1,720 @@ +// Command schemagen downloads the JSON schemas embedded into the nelm binary, strips them down and +// packs them into the archives read by pkg/resource/schemas: the Kubernetes API schemas of +// -kube-version merged with those of the minor versions before it at their latest patch, and the CRDs +// catalog. +// +// The version is pinned by "kubeconformValidationSchemasUpstreamKubeVersion" in the Taskfile, the one +// place it is configured; the generated index is what tells nelm which version it validates against. +// Runs as a build dependency and is idempotent: an archive matching what the index records is left +// alone. +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "net/http" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "time" + + "github.com/dustin/go-humanize" + + "github.com/werf/nelm/pkg/log" + "github.com/werf/nelm/pkg/resource/schemas" +) + +const ( + crdsArchiveFileName = "crds.tar.gz" + defaultCRDsRef = "main" + defaultCRDsRepo = "datreeio/CRDs-catalog" + // Older versions only contribute the schemas newer ones no longer have, so each one past the first + // costs next to nothing. + defaultKubeVersionCount = 5 + defaultKubernetesRef = "master" + defaultKubernetesRepo = "yannh/kubernetes-json-schema" + // defaultMaxBundlesSize caps what the archives may grow to. Every byte of them is carried by every + // nelm binary and by every module that imports nelm, so the size is a deliberate budget rather than + // whatever upstream happens to produce. + defaultMaxBundlesSize = "5MiB" + defaultOutputDir = "pkg/resource/schemas/data" + defaultParallelism = 16 + indexFileName = "index.json" + kubernetesArchiveFileName = "kubernetes.tar.gz" + // The per version archives the merged bundle replaced, which must not linger in the repository. + legacyKubernetesArchiveGlob = "kubernetes-*.tar.gz" + requestTimeout = 10 * time.Minute +) + +type options struct { + checkUpstream bool + crdsRef string + crdsRepo string + force bool + kubeVersion string + kubeVersionCount int + kubernetesRef string + kubernetesRepo string + maxBundlesSize string + outputDir string + parallelism int + verify bool +} + +type schemaFile struct { + content []byte + name string +} + +func run(ctx context.Context) error { + opts := parseFlags() + + if opts.verify { + return verifyBundles(ctx, opts) + } + + if opts.checkUpstream { + return checkUpstream(ctx, &http.Client{Timeout: requestTimeout}, opts) + } + + // Zero would deadlock the download semaphore and a negative value would panic building it. + if opts.parallelism < 1 { + return fmt.Errorf("parallelism must be positive, got %d", opts.parallelism) + } + + existingIndex := readExistingIndex(filepath.Join(opts.outputDir, indexFileName)) + client := &http.Client{Timeout: requestTimeout} + + var index schemas.Index + + kubernetesBundle, err := ensureKubernetesBundle(ctx, client, opts, existingIndex) + if err != nil { + return fmt.Errorf("generate Kubernetes schemas bundle: %w", err) + } + + index.Kubernetes = kubernetesBundle + + crdsBundle, err := ensureCRDsBundle(ctx, client, opts, existingIndex) + if err != nil { + return fmt.Errorf("generate CRDs schemas bundle: %w", err) + } + + index.CRDs = crdsBundle + + if err := writeIndex(filepath.Join(opts.outputDir, indexFileName), index); err != nil { + return fmt.Errorf("write index: %w", err) + } + + if err := removeLegacyKubernetesBundles(ctx, opts.outputDir); err != nil { + return fmt.Errorf("remove legacy bundles: %w", err) + } + + // Checked once both archives are on disk and the index describes them, so that a run failing here + // leaves the output directory self consistent and -verify reports the same thing. + return checkBundlesSize(ctx, opts) +} + +// ensureKubernetesBundle generates the bundle holding every collected minor version, skipping the work +// if the archive on disk is the one asked for already. +func ensureKubernetesBundle(ctx context.Context, client *http.Client, opts options, existingIndex *schemas.Index) (*schemas.Bundle, error) { + minors, err := kubeMinors(opts.kubeVersion, opts.kubeVersionCount) + if err != nil { + return nil, err + } + + archivePath := filepath.Join(opts.outputDir, kubernetesArchiveFileName) + + var existing *schemas.Bundle + if existingIndex != nil { + existing = existingIndex.Kubernetes + } + + if !opts.force && isBundleUpToDate(archivePath, existing, opts.kubernetesRepo, opts.kubernetesRef) && + kubeVersionsMatch(existing.KubeVersions, opts.kubeVersion, minors) { + log.Default.Info(ctx, "Embedded schemas for Kubernetes %s are up to date (%d schemas)", + strings.Join(existing.KubeVersions, ", "), existing.FilesCount) + + return existing, nil + } + + commit, err := resolveCommit(ctx, client, opts.kubernetesRepo, opts.kubernetesRef) + if err != nil { + return nil, fmt.Errorf("resolve %s of %s: %w", opts.kubernetesRef, opts.kubernetesRepo, err) + } + + // The listing is what decides which patch of every older minor is collected, so it is fetched + // before the versions are known rather than inside the downloader. + dirSHAs, err := kubernetesVersionDirSHAs(ctx, client, opts.kubernetesRepo, commit) + if err != nil { + return nil, fmt.Errorf("list directories of %s: %w", opts.kubernetesRepo, err) + } + + kubeVersions, err := resolveKubeVersions(dirSHAs, opts.kubeVersion, minors) + if err != nil { + return nil, err + } + + files, err := downloadMergedKubernetesSchemas(ctx, client, opts, kubeVersions, dirSHAs, commit) + if err != nil { + return nil, err + } + + trees := make(map[string]string, len(kubeVersions)) + + for _, kubeVersion := range kubeVersions { + trees[kubeVersion] = dirSHAs[kubernetesVersionDirName(kubeVersion)] + } + + return packBundle(ctx, archivePath, files, schemas.Bundle{ + KubeVersions: kubeVersions, + UpstreamCommit: commit, + UpstreamRef: opts.kubernetesRef, + UpstreamRepo: opts.kubernetesRepo, + UpstreamTrees: trees, + }) +} + +// checkUpstream reports whether the committed archives still match what upstream serves, without +// downloading a single schema: two requests, one per repository. +// +// The CRD catalog is taken whole, so its commit moving means its contents moved with it. The +// Kubernetes repository is different: it holds every version ever released, and the directories we +// take are frozen historical snapshots, so its ref moves all the time without changing anything we +// consume. Comparing the tree sha of each collected version tells the two apart. +// +// This is deliberately not part of generating or verifying: a build that followed upstream on its own +// would stop being reproducible and would need the network. It belongs on a schedule instead. +func checkUpstream(ctx context.Context, client *http.Client, opts options) error { + indexPath := filepath.Join(opts.outputDir, indexFileName) + + index := readExistingIndex(indexPath) + if index == nil || index.CRDs == nil || index.Kubernetes == nil { + return fmt.Errorf("%s is missing or incomplete, run: task generate:validation-schemas", indexPath) + } + + var stale []string + + crdsCommit, err := resolveCommit(ctx, client, index.CRDs.UpstreamRepo, index.CRDs.UpstreamRef) + if err != nil { + return fmt.Errorf("resolve %s of %s: %w", index.CRDs.UpstreamRef, index.CRDs.UpstreamRepo, err) + } + + if crdsCommit != index.CRDs.UpstreamCommit { + stale = append(stale, fmt.Sprintf("the CRD catalog moved from %s to %s", index.CRDs.UpstreamCommit[:7], crdsCommit[:7])) + } + + dirSHAs, err := kubernetesVersionDirSHAs(ctx, client, index.Kubernetes.UpstreamRepo, index.Kubernetes.UpstreamRef) + if err != nil { + return fmt.Errorf("list directories of %s: %w", index.Kubernetes.UpstreamRepo, err) + } + + for i, kubeVersion := range index.Kubernetes.KubeVersions { + recorded, ok := index.Kubernetes.UpstreamTrees[kubeVersion] + if !ok { + stale = append(stale, fmt.Sprintf("Kubernetes %s records no upstream tree to compare", kubeVersion)) + + continue + } + + if current := dirSHAs[kubernetesVersionDirName(kubeVersion)]; current != recorded { + stale = append(stale, fmt.Sprintf("the schemas of Kubernetes %s changed upstream", kubeVersion)) + } + + // The newest version is pinned in the Taskfile, so a newer patch of it is a bump to decide on, + // not staleness. The older minors are collected at whatever patch upstream had at generation + // time, so a newer one there means the archives no longer hold what they are supposed to. + if i == 0 { + continue + } + + latest, err := latestPatchOfMinor(dirSHAs, minorsOf([]string{kubeVersion})[0]) + if err != nil { + return err + } + + if latest != kubeVersion { + stale = append(stale, fmt.Sprintf("Kubernetes %s is collected, but upstream has %s", kubeVersion, latest)) + } + } + + // A newer minor version showing up is not staleness: nothing we ship is out of date, there is just + // a bump available. Reported, but not failed on. + if newest := newestKubernetesVersionDir(dirSHAs); newest != "" && newest != index.Kubernetes.KubeVersions[0] { + log.Default.Info(ctx, "Kubernetes %s is available upstream, pinned is %s", newest, index.Kubernetes.KubeVersions[0]) + } + + if len(stale) > 0 { + return fmt.Errorf("embedded schemas are behind upstream: %s, run: task generate:validation-schemas:force", + strings.Join(stale, "; ")) + } + + log.Default.Info(ctx, "Embedded schemas match upstream: %s@%s, and Kubernetes %s", + index.CRDs.UpstreamRepo, crdsCommit[:7], strings.Join(index.Kubernetes.KubeVersions, ", ")) + + return nil +} + +// resolveKubeVersions turns the minor window into the exact versions to collect: the newest exactly as +// pinned, since that is the one nelm validates against, and every older minor at the highest patch +// upstream has, so that schema fixes released after ".0" are not missed. +func resolveKubeVersions(dirSHAs map[string]string, newest string, minors []string) ([]string, error) { + newest = strings.TrimPrefix(newest, "v") + + if _, ok := dirSHAs[kubernetesVersionDirName(newest)]; !ok { + return nil, fmt.Errorf("upstream has no %s directory", kubernetesVersionDirName(newest)) + } + + versions := make([]string, 0, len(minors)) + versions = append(versions, newest) + + for _, minor := range minors[1:] { + latest, err := latestPatchOfMinor(dirSHAs, minor) + if err != nil { + return nil, err + } + + versions = append(versions, latest) + } + + return versions, nil +} + +// verifyBundles checks the committed archives offline: the index lists exactly the versions asked +// for, every archive it references is present with the recorded digest, and nothing lingers besides. +// It is what keeps a bumped Kubernetes version or a hand-edited index from reaching a release. +func verifyBundles(ctx context.Context, opts options) error { + indexPath := filepath.Join(opts.outputDir, indexFileName) + + index := readExistingIndex(indexPath) + if index == nil { + return fmt.Errorf("%s is missing or unreadable, run: task generate:validation-schemas", indexPath) + } + + if index.CRDs == nil { + return fmt.Errorf("%s lists no CRDs bundle, run: task generate:validation-schemas", indexPath) + } + + if index.Kubernetes == nil { + return fmt.Errorf("%s lists no Kubernetes bundle, run: task generate:validation-schemas", indexPath) + } + + // Which patch of every older minor was collected is decided from the upstream listing at generation + // time, so it is taken from the index as recorded. What is checked against the Taskfile is the pin + // itself: the newest version and the window of minors around it. + wantMinors, err := kubeMinors(opts.kubeVersion, opts.kubeVersionCount) + if err != nil { + return err + } + + gotVersions := index.Kubernetes.KubeVersions + + if !kubeVersionsMatch(gotVersions, opts.kubeVersion, wantMinors) { + return fmt.Errorf("%s was generated from Kubernetes versions %s, which is not the pinned %s over %d minors, run: task generate:validation-schemas", + indexPath, strings.Join(gotVersions, ", "), opts.kubeVersion, opts.kubeVersionCount) + } + + for _, kubeVersion := range gotVersions { + if index.Kubernetes.UpstreamTrees[kubeVersion] == "" { + return fmt.Errorf("%s records no upstream tree for Kubernetes %s, run: task generate:validation-schemas:force", + indexPath, kubeVersion) + } + } + + for _, want := range []struct { + bundle *schemas.Bundle + name string + ref string + }{ + {bundle: index.Kubernetes, name: "kubernetes", ref: opts.kubernetesRef}, + {bundle: index.CRDs, name: "crds", ref: opts.crdsRef}, + } { + if want.bundle.UpstreamRef != want.ref { + return fmt.Errorf("%s records the %s bundle as generated from ref %q, but %q is asked for, run: task generate:validation-schemas", + indexPath, want.name, want.bundle.UpstreamRef, want.ref) + } + } + + archives := map[string]*schemas.Bundle{ + crdsArchiveFileName: index.CRDs, + kubernetesArchiveFileName: index.Kubernetes, + } + + for name, bundle := range archives { + path := filepath.Join(opts.outputDir, name) + + archive, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w, run: task generate:validation-schemas", path, err) + } + + digest := sha256.Sum256(archive) + + if recorded := hex.EncodeToString(digest[:]); recorded != bundle.SHA256 { + return fmt.Errorf("%s has digest %s, but %s records %s, run: task generate:validation-schemas:force", + path, recorded, indexPath, bundle.SHA256) + } + } + + paths, err := legacyKubernetesArchives(opts.outputDir) + if err != nil { + return err + } + + if len(paths) > 0 { + return fmt.Errorf("%s is not embedded any more, run: task generate:validation-schemas", paths[0]) + } + + if err := checkBundlesSize(ctx, opts); err != nil { + return err + } + + log.Default.Info(ctx, "Embedded schemas are consistent: %d Kubernetes schemas of %s, and %d CRD schemas", + index.Kubernetes.FilesCount, strings.Join(index.Kubernetes.KubeVersions, ", "), index.CRDs.FilesCount) + + return nil +} + +// kubeVersionsMatch reports whether the recorded versions are the ones asked for: the newest exactly as +// pinned, and one version per minor of the window. Which patch each older minor sits at is not part of +// the question, since that is decided from the upstream listing and cannot be checked offline. +func kubeVersionsMatch(recorded []string, newest string, minors []string) bool { + if len(recorded) == 0 || recorded[0] != strings.TrimPrefix(newest, "v") { + return false + } + + return slices.Equal(minorsOf(recorded), minors) +} + +// latestPatchOfMinor picks the highest patch release of a minor that upstream has a schema directory +// for. +func latestPatchOfMinor(dirSHAs map[string]string, minor string) (string, error) { + var ( + latest string + latestPatch = -1 + ) + + for dirName := range dirSHAs { + major, dirMinor, patch, err := parseKubernetesVersionDirName(dirName) + if err != nil { + continue + } + + if fmt.Sprintf("%d.%d", major, dirMinor) != minor || patch <= latestPatch { + continue + } + + latest, latestPatch = fmt.Sprintf("%d.%d.%d", major, dirMinor, patch), patch + } + + if latest == "" { + return "", fmt.Errorf("upstream has no schema directory for Kubernetes %s", minor) + } + + return latest, nil +} + +// newestKubernetesVersionDir picks the highest "v..-standalone" directory name +// upstream has, so that an available version bump can be pointed out. Ordered on all three numbers, so +// that the answer does not depend on map iteration order among patches of the same minor. +func newestKubernetesVersionDir(dirSHAs map[string]string) string { + var ( + newest string + newestWeight int + ) + + for dirName := range dirSHAs { + major, minor, patch, err := parseKubernetesVersionDirName(dirName) + if err != nil { + continue + } + + if weight := major*1_000_000 + minor*1_000 + patch; weight > newestWeight { + newest, newestWeight = fmt.Sprintf("%d.%d.%d", major, minor, patch), weight + } + } + + return newest +} + +func ensureCRDsBundle(ctx context.Context, client *http.Client, opts options, existingIndex *schemas.Index) (*schemas.Bundle, error) { + archivePath := filepath.Join(opts.outputDir, crdsArchiveFileName) + + var existing *schemas.Bundle + if existingIndex != nil { + existing = existingIndex.CRDs + } + + if !opts.force && isBundleUpToDate(archivePath, existing, opts.crdsRepo, opts.crdsRef) { + log.Default.Info(ctx, "Embedded CRD schemas are up to date (%d schemas)", existing.FilesCount) + + return existing, nil + } + + commit, err := resolveCommit(ctx, client, opts.crdsRepo, opts.crdsRef) + if err != nil { + return nil, fmt.Errorf("resolve %s of %s: %w", opts.crdsRef, opts.crdsRepo, err) + } + + log.Default.Info(ctx, "Downloading CRD schemas from %s@%s", opts.crdsRepo, commit[:7]) + + // The catalog is thousands of files, so it is fetched as one repository tarball rather than + // file by file. + files, err := downloadCRDsCatalog(ctx, client, opts.crdsRepo, commit) + if err != nil { + return nil, fmt.Errorf("download catalog: %w", err) + } + + return packBundle(ctx, archivePath, files, schemas.Bundle{ + UpstreamCommit: commit, + UpstreamRef: opts.crdsRef, + UpstreamRepo: opts.crdsRepo, + }) +} + +// kubeMinors lists the "." windows to collect schemas from, newest first: the one the +// asked for version belongs to, and the minors right before it. Computed without the network, which is +// what lets the up to date check and -verify work offline; which patch of each minor is actually +// collected is decided from the upstream listing, see resolveKubeVersions. +func kubeMinors(newest string, count int) ([]string, error) { + if newest == "" { + return nil, errors.New("no Kubernetes version given, run: task generate:validation-schemas") + } + + if count < 1 { + return nil, fmt.Errorf("kube version count must be positive, got %d", count) + } + + major, minor, _, err := parseKubeVersion(newest) + if err != nil { + return nil, err + } + + minors := make([]string, 0, count) + + for i := 0; i < count && minor-i >= 0; i++ { + minors = append(minors, fmt.Sprintf("%d.%d", major, minor-i)) + } + + return minors, nil +} + +// minorsOf reduces exact versions to their "." parts, keeping the order. +func minorsOf(kubeVersions []string) []string { + minors := make([]string, 0, len(kubeVersions)) + + for _, kubeVersion := range kubeVersions { + major, minor, _, err := parseKubeVersion(kubeVersion) + if err != nil { + minors = append(minors, kubeVersion) + + continue + } + + minors = append(minors, fmt.Sprintf("%d.%d", major, minor)) + } + + return minors +} + +func parseKubernetesVersionDirName(dirName string) (major, minor, patch int, err error) { + kubeVersion, ok := strings.CutSuffix(dirName, "-standalone") + if !ok { + return 0, 0, 0, fmt.Errorf("%q is not a schema directory", dirName) + } + + return parseKubeVersion(kubeVersion) +} + +// removeLegacyKubernetesBundles drops the per version archives that the merged Kubernetes bundle +// replaced, so that a tree generated by an older schemagen does not keep carrying them. +func removeLegacyKubernetesBundles(ctx context.Context, outputDir string) error { + paths, err := legacyKubernetesArchives(outputDir) + if err != nil { + return err + } + + for _, path := range paths { + log.Default.Info(ctx, "Removing no longer embedded %s", path) + + if err := os.Remove(path); err != nil { + return fmt.Errorf("remove %s: %w", path, err) + } + } + + return nil +} + +// checkBundlesSize fails when the archives outgrow the budget. They are embedded verbatim, so their +// size is what every nelm binary and every module importing nelm pays, whether the schemas are used or +// not. +func checkBundlesSize(ctx context.Context, opts options) error { + limit, err := humanize.ParseBytes(opts.maxBundlesSize) + if err != nil { + return fmt.Errorf("invalid max bundles size %q: %w", opts.maxBundlesSize, err) + } + + if limit == 0 { + return fmt.Errorf("max bundles size must be positive, got %q", opts.maxBundlesSize) + } + + var total uint64 + + for _, name := range []string{kubernetesArchiveFileName, crdsArchiveFileName} { + stat, err := os.Stat(filepath.Join(opts.outputDir, name)) + if err != nil { + return fmt.Errorf("stat %s: %w", filepath.Join(opts.outputDir, name), err) + } + + total += uint64(stat.Size()) + } + + if total > limit { + return fmt.Errorf("the embedded archives take %s, over the %s limit: collect fewer Kubernetes minor versions, or raise -max-bundles-size if the budget is meant to grow", + humanize.IBytes(total), humanize.IBytes(limit)) + } + + log.Default.Info(ctx, "Embedded archives take %s of the %s limit", humanize.IBytes(total), humanize.IBytes(limit)) + + return nil +} + +// isBundleUpToDate reports whether the archive on disk was already generated from the requested +// repository and ref, so that repeated builds do not hit the network. It cannot tell that the ref +// itself has moved, nor that a new patch release appeared: that is what -check-upstream is for. +func isBundleUpToDate(archivePath string, existing *schemas.Bundle, repo, ref string) bool { + if existing == nil || existing.UpstreamRepo != repo || existing.UpstreamRef != ref { + return false + } + + archive, err := os.ReadFile(archivePath) + if err != nil { + return false + } + + digest := sha256.Sum256(archive) + + return hex.EncodeToString(digest[:]) == existing.SHA256 +} + +func legacyKubernetesArchives(outputDir string) ([]string, error) { + paths, err := filepath.Glob(filepath.Join(outputDir, legacyKubernetesArchiveGlob)) + if err != nil { + return nil, fmt.Errorf("list bundles in %s: %w", outputDir, err) + } + + return paths, nil +} + +// packBundle strips, packs and persists the schemas, returning the index entry describing them. +func packBundle(ctx context.Context, archivePath string, files []schemaFile, bundle schemas.Bundle) (*schemas.Bundle, error) { + archive, uncompressedSize, err := buildArchive(files) + if err != nil { + return nil, fmt.Errorf("build archive: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(archivePath), 0o755); err != nil { + return nil, fmt.Errorf("create %s: %w", filepath.Dir(archivePath), err) + } + + if err := os.WriteFile(archivePath, archive, 0o644); err != nil { + return nil, fmt.Errorf("write %s: %w", archivePath, err) + } + + digest := sha256.Sum256(archive) + + bundle.FilesCount = len(files) + bundle.SHA256 = hex.EncodeToString(digest[:]) + bundle.UncompressedSize = uncompressedSize + + log.Default.Info(ctx, "Packed %d schemas into %s (%.0f KiB compressed, %.1f MiB unpacked)", + bundle.FilesCount, archivePath, float64(len(archive))/1024, float64(uncompressedSize)/1024/1024) + + return &bundle, nil +} + +func parseFlags() options { + var opts options + + flag.StringVar(&opts.crdsRef, "crds-ref", defaultCRDsRef, "Git ref of the CRDs catalog to download from") + flag.StringVar(&opts.crdsRepo, "crds-repo", defaultCRDsRepo, "GitHub repository to download the CRD schemas from") + flag.BoolVar(&opts.force, "force", false, "Regenerate the bundles even if they are already up to date") + flag.StringVar(&opts.kubeVersion, "kube-version", "", + "Newest Kubernetes version to collect schemas from, which is also the version nelm validates against (required)") + flag.IntVar(&opts.kubeVersionCount, "kube-version-count", defaultKubeVersionCount, + "How many Kubernetes minor versions to collect schemas from, counting back from -kube-version. Every one but the newest is taken at the latest patch upstream has") + flag.StringVar(&opts.kubernetesRef, "kubernetes-ref", defaultKubernetesRef, "Git ref of the Kubernetes schemas repository to download from") + flag.StringVar(&opts.kubernetesRepo, "kubernetes-repo", defaultKubernetesRepo, "GitHub repository to download the Kubernetes schemas from") + flag.StringVar(&opts.maxBundlesSize, "max-bundles-size", defaultMaxBundlesSize, + "Fail when the archives add up to more than this, since all of it ends up in every nelm binary. Accepts 5MiB, 5MB or plain bytes") + flag.StringVar(&opts.outputDir, "output", defaultOutputDir, "Directory to write the bundles and their index to") + flag.IntVar(&opts.parallelism, "parallelism", defaultParallelism, "How many schemas to download concurrently") + flag.BoolVar(&opts.verify, "verify", false, "Check the committed archives against their index and exit, without downloading anything") + flag.BoolVar(&opts.checkUpstream, "check-upstream", false, "Report whether upstream has schemas the committed archives do not, and exit, without downloading them") + flag.Parse() + + return opts +} + +func parseKubeVersion(kubeVersion string) (major, minor, patch int, err error) { + parts := strings.Split(strings.TrimPrefix(kubeVersion, "v"), ".") + if len(parts) != 3 { + return 0, 0, 0, fmt.Errorf("invalid kube version %q", kubeVersion) + } + + numbers := make([]int, 0, len(parts)) + + for _, part := range parts { + number, convErr := strconv.Atoi(part) + if convErr != nil { + return 0, 0, 0, fmt.Errorf("invalid kube version %q: %w", kubeVersion, convErr) + } + + numbers = append(numbers, number) + } + + return numbers[0], numbers[1], numbers[2], nil +} + +func readExistingIndex(path string) *schemas.Index { + indexBytes, err := os.ReadFile(path) + if err != nil { + return nil + } + + var index schemas.Index + + if err := json.Unmarshal(indexBytes, &index); err != nil { + return nil + } + + return &index +} + +func writeIndex(path string, index schemas.Index) error { + indexBytes, err := json.MarshalIndent(index, "", " ") + if err != nil { + return fmt.Errorf("encode index: %w", err) + } + + if err := os.WriteFile(path, append(indexBytes, '\n'), 0o644); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + + return nil +} + +func main() { + ctx := context.Background() + + if err := run(ctx); err != nil { + log.Default.Error(ctx, "Error: %s", err) + os.Exit(1) + } +} diff --git a/scripts/schemagen/size_ai_test.go b/scripts/schemagen/size_ai_test.go new file mode 100644 index 00000000..23515371 --- /dev/null +++ b/scripts/schemagen/size_ai_test.go @@ -0,0 +1,87 @@ +//go:build ai_tests + +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAI_CheckBundlesSize(t *testing.T) { + for _, tt := range []struct { + crdsBytes int + errContains string + kubernetesBytes int + name string + size string + }{ + { + crdsBytes: 500, + kubernetesBytes: 500, name: "passes under the limit", size: "1KiB", + }, + { + crdsBytes: 600, + kubernetesBytes: 400, name: "passes exactly at the limit", size: "1000", + }, + { + crdsBytes: 601, + errContains: "over the 1000 B limit", + kubernetesBytes: 400, name: "fails over the limit", size: "1000", + }, + { + crdsBytes: 2048, + errContains: "the embedded archives take", + kubernetesBytes: 1, // Both archives count, so neither can grow unnoticed behind the other one being small. + name: "counts both archives", size: "1KiB", + }, + { + crdsBytes: 1, + errContains: `invalid max bundles size "many"`, + kubernetesBytes: 1, name: "rejects an unparsable limit", size: "many", + }, + { + crdsBytes: 1, + errContains: "must be positive", + kubernetesBytes: 1, name: "rejects a zero limit", size: "0", + }, + } { + t.Run(tt.name, func(t *testing.T) { + outputDir := t.TempDir() + + writeArchive(t, filepath.Join(outputDir, kubernetesArchiveFileName), tt.kubernetesBytes) + writeArchive(t, filepath.Join(outputDir, crdsArchiveFileName), tt.crdsBytes) + + err := checkBundlesSize(context.Background(), options{maxBundlesSize: tt.size, outputDir: outputDir}) + + if tt.errContains == "" { + require.NoError(t, err) + + return + } + + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + }) + } + + t.Run("fails when an archive is missing", func(t *testing.T) { + outputDir := t.TempDir() + + writeArchive(t, filepath.Join(outputDir, kubernetesArchiveFileName), 1) + + err := checkBundlesSize(context.Background(), options{maxBundlesSize: "1MiB", outputDir: outputDir}) + require.Error(t, err) + assert.Contains(t, err.Error(), crdsArchiveFileName) + }) +} + +func writeArchive(t *testing.T, path string, size int) { + t.Helper() + + require.NoError(t, os.WriteFile(path, make([]byte, size), 0o644)) +} diff --git a/scripts/schemagen/strip.go b/scripts/schemagen/strip.go new file mode 100644 index 00000000..56d7d9ba --- /dev/null +++ b/scripts/schemagen/strip.go @@ -0,0 +1,101 @@ +package main + +import ( + "encoding/json" + "fmt" + "slices" + "strings" +) + +var ( + // nameMapKeywords are keywords whose value is keyed by *names* rather than by keywords, so their + // keys must be left alone: "description" is a valid property name, PriorityClass has one. + // dependentRequired and dependentSchemas are the draft 2020-12 split of "dependencies"; nothing + // upstream uses them yet, but a property named "description" inside one would be dropped. + nameMapKeywords = []string{ + "$defs", + "definitions", + "dependencies", + "dependentRequired", + "dependentSchemas", + "patternProperties", + "properties", + } + // opaqueKeywords hold instance data rather than a nested schema, so recursing into them could + // corrupt a legitimate "description" value. + opaqueKeywords = []string{ + "const", + "default", + "enum", + "examples", + } +) + +func stripSchemaBytes(schemaBytes []byte) ([]byte, error) { + var schema any + + if err := json.Unmarshal(schemaBytes, &schema); err != nil { + return nil, fmt.Errorf("decode schema: %w", err) + } + + strippedBytes, err := json.Marshal(stripSchema(schema)) + if err != nil { + return nil, fmt.Errorf("encode stripped schema: %w", err) + } + + return strippedBytes, nil +} + +func stripNameMap(node any) any { + names, ok := node.(map[string]any) + if !ok { + // "dependencies" values may also be arrays of property names. + return stripSchema(node) + } + + stripped := make(map[string]any, len(names)) + + for name, subSchema := range names { + stripped[name] = stripSchema(subSchema) + } + + return stripped +} + +// stripSchema removes "description" annotations, which are roughly 87% of the bytes of the Kubernetes +// schemas and carry no validation semantics. PriorityClass, which has a field of that name itself, +// shows what survives and what does not (16935 bytes upstream, 2314 in the bundle): +// +// "description": {"description": "an arbitrary string ...", "type": ["string", "null"]} +// "description": {"type": ["string", "null"]} +func stripSchema(node any) any { + switch typedNode := node.(type) { + case map[string]any: + stripped := make(map[string]any, len(typedNode)) + + for key, value := range typedNode { + switch { + case key == "description": + continue + case slices.Contains(opaqueKeywords, key) || strings.HasPrefix(key, "x-"): + stripped[key] = value + case slices.Contains(nameMapKeywords, key): + stripped[key] = stripNameMap(value) + default: + stripped[key] = stripSchema(value) + } + } + + return stripped + case []any: + stripped := make([]any, 0, len(typedNode)) + + for _, item := range typedNode { + stripped = append(stripped, stripSchema(item)) + } + + return stripped + default: + return node + } +} diff --git a/scripts/schemagen/strip_ai_test.go b/scripts/schemagen/strip_ai_test.go new file mode 100644 index 00000000..bd4b5ff3 --- /dev/null +++ b/scripts/schemagen/strip_ai_test.go @@ -0,0 +1,118 @@ +//go:build ai_tests + +package main + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAI_StripSchemaBytes(t *testing.T) { + t.Run("drops description annotations", func(t *testing.T) { + stripped := strip(t, `{ + "description": "Deployment enables declarative updates.", + "type": "object", + "properties": { + "spec": { + "description": "Specification of the desired behavior.", + "properties": { + "replicas": {"description": "Number of desired pods.", "format": "int32", "type": "integer"} + } + } + } + }`) + + assert.NotContains(t, stripped, "description") + + spec := stripped["properties"].(map[string]any)["spec"].(map[string]any) + assert.NotContains(t, spec, "description") + + replicas := spec["properties"].(map[string]any)["replicas"].(map[string]any) + assert.Equal(t, "int32", replicas["format"]) + assert.NotContains(t, replicas, "description") + }) + + t.Run("keeps description used as a property name", func(t *testing.T) { + // PriorityClass really does have a "description" field, so a naive strip corrupts it. + stripped := strip(t, `{ + "description": "PriorityClass defines mapping from a priority class name to the priority integer value.", + "properties": { + "description": { + "description": "description is an arbitrary string.", + "type": ["string", "null"] + }, + "value": {"type": "integer"} + }, + "required": ["value"] + }`) + + assert.NotContains(t, stripped, "description", "the top level annotation must be gone") + + properties := stripped["properties"].(map[string]any) + require.Contains(t, properties, "description", "the property named description must survive") + + descriptionProperty := properties["description"].(map[string]any) + assert.Equal(t, []any{"string", "null"}, descriptionProperty["type"]) + assert.NotContains(t, descriptionProperty, "description", "its own annotation must be gone") + }) + + t.Run("keeps names in every name-keyed map", func(t *testing.T) { + stripped := strip(t, `{ + "definitions": {"description": {"type": "string"}}, + "$defs": {"description": {"type": "string"}}, + "patternProperties": {"description": {"type": "string"}}, + "dependencies": {"description": ["value"]} + }`) + + for _, keyword := range []string{"definitions", "$defs", "patternProperties", "dependencies"} { + assert.Contains(t, stripped[keyword].(map[string]any), "description", "%s lost its key", keyword) + } + }) + + t.Run("keeps instance data verbatim", func(t *testing.T) { + stripped := strip(t, `{ + "enum": [{"description": "a literal value"}], + "default": {"description": "a literal default"}, + "x-kubernetes-group-version-kind": [{"description": "extension data", "kind": "Deployment"}] + }`) + + assert.Equal(t, []any{map[string]any{"description": "a literal value"}}, stripped["enum"]) + assert.Equal(t, map[string]any{"description": "a literal default"}, stripped["default"]) + assert.Equal(t, + []any{map[string]any{"description": "extension data", "kind": "Deployment"}}, + stripped["x-kubernetes-group-version-kind"]) + }) + + t.Run("recurses through schema combinators", func(t *testing.T) { + stripped := strip(t, `{ + "oneOf": [{"description": "first", "type": "string"}], + "items": {"description": "item", "type": "string"}, + "not": {"description": "not", "type": "string"} + }`) + + assert.NotContains(t, stripped["oneOf"].([]any)[0].(map[string]any), "description") + assert.NotContains(t, stripped["items"].(map[string]any), "description") + assert.NotContains(t, stripped["not"].(map[string]any), "description") + }) + + t.Run("rejects malformed json", func(t *testing.T) { + _, err := stripSchemaBytes([]byte("{")) + require.Error(t, err) + }) +} + +func strip(t *testing.T, schema string) map[string]any { + t.Helper() + + strippedBytes, err := stripSchemaBytes([]byte(schema)) + require.NoError(t, err) + + var stripped map[string]any + + require.NoError(t, json.Unmarshal(strippedBytes, &stripped)) + + return stripped +} From 2a29517d8d19ab606c5cd7cb24f55e3d394fabb5 Mon Sep 17 00:00:00 2001 From: Ilya Drey <157472+drey@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:03:07 +0300 Subject: [PATCH 54/85] fix: repair ai lookup tests after helm fork api changes (#676) The vendored Helm fork dropped pkg/chartutil and pkg/werf/helmopts, moved CoalesceValues to pkg/chart/common/util, and changed RenderWithClientProvider to take a context instead of HelmOptions, which left pkg/chart/lookup_ai_test.go unable to compile. Signed-off-by: Ilya Drey --- pkg/chart/lookup_ai_test.go | 59 +++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/pkg/chart/lookup_ai_test.go b/pkg/chart/lookup_ai_test.go index c4251ec0..87292d0b 100644 --- a/pkg/chart/lookup_ai_test.go +++ b/pkg/chart/lookup_ai_test.go @@ -3,33 +3,34 @@ package chart import ( + "context" "path" "testing" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - helmchart "github.com/werf/nelm/pkg/helm/pkg/chart" - "github.com/werf/nelm/pkg/helm/pkg/chartutil" - "github.com/werf/nelm/pkg/helm/pkg/engine" - "github.com/werf/nelm/pkg/helm/pkg/werf/helmopts" + chartcommon "github.com/werf/nelm/pkg/helm/pkg/chart/common" + chartcommonutil "github.com/werf/nelm/pkg/helm/pkg/chart/common/util" + v2chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + helmengine "github.com/werf/nelm/pkg/helm/pkg/engine" ) func TestAI_LocalClientProviderEmpty(t *testing.T) { provider := newLocalClientProvider(nil) - c := &helmchart.Chart{ - Metadata: &helmchart.Metadata{Name: "moby", Version: "1.2.3"}, - Templates: []*helmchart.File{ + c := &v2chart.Chart{ + Metadata: &v2chart.Metadata{Name: "moby", Version: "1.2.3"}, + Templates: []*chartcommon.File{ {Name: "templates/empty", Data: []byte(`{{ (lookup "v1" "Pod" "default" "pod1") }}`)}, }, Values: map[string]any{}, } - vals, err := chartutil.CoalesceValues(c, map[string]any{"Values": map[string]any{}}) + vals, err := chartcommonutil.CoalesceValues(c, map[string]any{"Values": map[string]any{}}) require.NoError(t, err) - out, err := engine.RenderWithClientProvider(c, vals, provider, helmopts.HelmOptions{}) + out, err := helmengine.RenderWithClientProvider(context.Background(), c, vals, provider) require.NoError(t, err) require.Equal(t, "map[]", out["moby/templates/empty"]) } @@ -57,22 +58,22 @@ func TestAI_LocalClientProviderLookup(t *testing.T) { "missing-get": "map[]", } - c := &helmchart.Chart{ - Metadata: &helmchart.Metadata{Name: "moby", Version: "1.2.3"}, + c := &v2chart.Chart{ + Metadata: &v2chart.Metadata{Name: "moby", Version: "1.2.3"}, Values: map[string]any{}, } for name, tpl := range templates { - c.Templates = append(c.Templates, &helmchart.File{ + c.Templates = append(c.Templates, &chartcommon.File{ Name: path.Join("templates", name), Data: []byte(tpl), }) } - vals, err := chartutil.CoalesceValues(c, map[string]any{"Values": map[string]any{}}) + vals, err := chartcommonutil.CoalesceValues(c, map[string]any{"Values": map[string]any{}}) require.NoError(t, err) - out, err := engine.RenderWithClientProvider(c, vals, provider, helmopts.HelmOptions{}) + out, err := helmengine.RenderWithClientProvider(context.Background(), c, vals, provider) require.NoError(t, err) for name, want := range expected { @@ -96,22 +97,22 @@ func TestAI_LocalClientProviderNamespaceIsolation(t *testing.T) { "other-ns-get": "map[]", } - c := &helmchart.Chart{ - Metadata: &helmchart.Metadata{Name: "moby", Version: "1.2.3"}, + c := &v2chart.Chart{ + Metadata: &v2chart.Metadata{Name: "moby", Version: "1.2.3"}, Values: map[string]any{}, } for name, tpl := range templates { - c.Templates = append(c.Templates, &helmchart.File{ + c.Templates = append(c.Templates, &chartcommon.File{ Name: path.Join("templates", name), Data: []byte(tpl), }) } - vals, err := chartutil.CoalesceValues(c, map[string]any{"Values": map[string]any{}}) + vals, err := chartcommonutil.CoalesceValues(c, map[string]any{"Values": map[string]any{}}) require.NoError(t, err) - out, err := engine.RenderWithClientProvider(c, vals, provider, helmopts.HelmOptions{}) + out, err := helmengine.RenderWithClientProvider(context.Background(), c, vals, provider) require.NoError(t, err) for name, want := range expected { @@ -124,18 +125,18 @@ func TestAI_LocalClientProviderNamespaceIsolation(t *testing.T) { func TestAI_LocalClientProviderUnstubbedListEmptyProvider(t *testing.T) { provider := newLocalClientProvider(nil) - c := &helmchart.Chart{ - Metadata: &helmchart.Metadata{Name: "moby", Version: "1.2.3"}, - Templates: []*helmchart.File{ + c := &v2chart.Chart{ + Metadata: &v2chart.Metadata{Name: "moby", Version: "1.2.3"}, + Templates: []*chartcommon.File{ {Name: "templates/list", Data: []byte(`{{ (lookup "v1" "Pod" "" "").items | len }}`)}, }, Values: map[string]any{}, } - vals, err := chartutil.CoalesceValues(c, map[string]any{"Values": map[string]any{}}) + vals, err := chartcommonutil.CoalesceValues(c, map[string]any{"Values": map[string]any{}}) require.NoError(t, err) - out, err := engine.RenderWithClientProvider(c, vals, provider, helmopts.HelmOptions{}) + out, err := helmengine.RenderWithClientProvider(context.Background(), c, vals, provider) require.NoError(t, err) require.Equal(t, "0", out["moby/templates/list"]) } @@ -145,18 +146,18 @@ func TestAI_LocalClientProviderUnstubbedListOtherKind(t *testing.T) { makeUnstructured("v1", "Pod", "pod1", "default"), }) - c := &helmchart.Chart{ - Metadata: &helmchart.Metadata{Name: "moby", Version: "1.2.3"}, - Templates: []*helmchart.File{ + c := &v2chart.Chart{ + Metadata: &v2chart.Metadata{Name: "moby", Version: "1.2.3"}, + Templates: []*chartcommon.File{ {Name: "templates/list", Data: []byte(`{{ (lookup "v1" "ConfigMap" "" "").items | len }}`)}, }, Values: map[string]any{}, } - vals, err := chartutil.CoalesceValues(c, map[string]any{"Values": map[string]any{}}) + vals, err := chartcommonutil.CoalesceValues(c, map[string]any{"Values": map[string]any{}}) require.NoError(t, err) - out, err := engine.RenderWithClientProvider(c, vals, provider, helmopts.HelmOptions{}) + out, err := helmengine.RenderWithClientProvider(context.Background(), c, vals, provider) require.NoError(t, err) require.Equal(t, "0", out["moby/templates/list"]) } From cb96a15b9c26a41ea452287709e25265b8a0aede Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Thu, 30 Jul 2026 17:01:28 +0300 Subject: [PATCH 55/85] feat: embed deno binary into release binaries behind embeddeno tag Signed-off-by: Dmitry Mordvinov --- .gitignore | 3 + Taskfile.dist.yaml | 38 +++++++- cmd/embed-deno/main.go | 148 ++++++++++++++++++++++++++++++++ pkg/ts/deno.go | 17 ++-- pkg/ts/downloader.go | 71 ++++++++++++--- pkg/ts/embed_darwin_amd64.go | 13 +++ pkg/ts/embed_darwin_arm64.go | 13 +++ pkg/ts/embed_linux_amd64.go | 13 +++ pkg/ts/embed_linux_arm64.go | 13 +++ pkg/ts/embed_windows_amd64.go | 13 +++ pkg/ts/embedded.go | 17 ++++ pkg/ts/embedded_deno.go | 117 +++++++++++++++++++++++++ pkg/ts/embedded_deno_ai_test.go | 127 +++++++++++++++++++++++++++ pkg/ts/embedded_stub.go | 9 ++ pkg/ts/export_test.go | 1 + 15 files changed, 592 insertions(+), 21 deletions(-) create mode 100644 cmd/embed-deno/main.go create mode 100644 pkg/ts/embed_darwin_amd64.go create mode 100644 pkg/ts/embed_darwin_arm64.go create mode 100644 pkg/ts/embed_linux_amd64.go create mode 100644 pkg/ts/embed_linux_arm64.go create mode 100644 pkg/ts/embed_windows_amd64.go create mode 100644 pkg/ts/embedded.go create mode 100644 pkg/ts/embedded_deno.go create mode 100644 pkg/ts/embedded_deno_ai_test.go create mode 100644 pkg/ts/embedded_stub.go diff --git a/.gitignore b/.gitignore index e9d0dcb5..29e6ca50 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.swp *.test +.task /.idea/ /.vscode/ /Taskfile.yaml @@ -9,5 +10,7 @@ /go.work /go.work.sum node_modules/ +/pkg/ts/embed/*/*/deno.gz +/pkg/ts/embed/*/*/deno.sha256 /.opencode/ /.sisyphus diff --git a/Taskfile.dist.yaml b/Taskfile.dist.yaml index fa1d2a8d..f789a7c1 100644 --- a/Taskfile.dist.yaml +++ b/Taskfile.dist.yaml @@ -17,6 +17,7 @@ vars: kubeconformValidationSchemasUpstreamKubeVersion: "1.36.3" tags: "" + buildTags: '{{trim (printf "%s embeddeno" .tags)}}' ldFlags: "-s -w -X github.com/werf/nelm/pkg/common.Brand={{.brand}} -X github.com/werf/nelm/pkg/common.Version={{.version}}" tasks: @@ -36,7 +37,7 @@ tasks: _build:dev: &build_dev cmds: - go build {{if eq .raceDetectorEnabled "true" }}-race{{end}} -o "{{.outputDir | default (printf "./build/%s-%s" .targetOS .targetArch)}}/{{.brandSlug}}{{if (eq .targetOS "windows")}}.exe{{end}}" - -ldflags="{{.ldFlags}}" -tags="{{.ctags}}" {{.extraGoBuildArgs}} {{.CLI_ARGS}} {{.pkg | default .package}} + -ldflags="{{.ldFlags}}" -tags="{{.tags}}" {{.extraGoBuildArgs}} {{.CLI_ARGS}} {{.pkg | default .package}} env: GOOS: "{{.targetOS}}" GOARCH: "{{.targetArch}}" @@ -48,7 +49,7 @@ tasks: <<: *build_dev cmds: - go build -o "{{.outputDir | default (printf "./dist/%s/%s-%s/bin" .version .targetOS .targetArch)}}/{{.brandSlug}}{{if (eq .targetOS "windows")}}.exe{{end}}" - -ldflags="{{.ldFlags}}" -tags="{{.ctags}}" {{.extraGoBuildArgs}} {{.CLI_ARGS}} {{.pkg | default .package}} + -ldflags="{{.ldFlags}}" -tags="{{.buildTags}}" {{.extraGoBuildArgs}} {{.CLI_ARGS}} {{.pkg | default .package}} _golangci-lint: internal: true @@ -107,6 +108,10 @@ tasks: build:dist:linux:amd64: desc: 'Build main release binary for linux/amd64. Important vars: "version".' + deps: + - task: deno:embed + vars: + platform: "linux/amd64" cmds: - task: _build:dist vars: @@ -128,6 +133,10 @@ tasks: build:dist:linux:arm64: desc: 'Build main release binary for linux/arm64. Important vars: "version".' + deps: + - task: deno:embed + vars: + platform: "linux/arm64" cmds: - task: _build:dist vars: @@ -149,6 +158,10 @@ tasks: build:dist:darwin:amd64: desc: 'Build main release binary for darwin/amd64. Important vars: "version".' + deps: + - task: deno:embed + vars: + platform: "darwin/amd64" cmds: - task: _build:dist vars: @@ -169,6 +182,10 @@ tasks: build:dist:darwin:arm64: desc: 'Build main release binary for darwin/arm64. Important vars: "version".' + deps: + - task: deno:embed + vars: + platform: "darwin/arm64" cmds: - task: _build:dist vars: @@ -189,6 +206,10 @@ tasks: build:dist:windows:amd64: desc: 'Build main release binary for windows/amd64. Important vars: "version".' + deps: + - task: deno:embed + vars: + platform: "windows/amd64" cmds: - task: _build:dist vars: @@ -280,6 +301,19 @@ tasks: cmds: - docker run --rm -u "$(id -u):$(id -g)" -v $(pwd):/work tmknom/prettier -w "**/*.yaml" "**/*.yml" + deno:embed: + desc: 'Download and embed the Deno binary for one platform. Important vars: "platform".' + run: when_changed + vars: + platform: '{{.platform | default (printf "%s/%s" .os .arch)}}' + sources: + - pkg/ts/downloader.go + generates: + - pkg/ts/embed/{{.platform}}/deno.gz + - pkg/ts/embed/{{.platform}}/deno.sha256 + cmds: + - env -u GOOS -u GOARCH DENO_EMBED_PLATFORM="{{.platform}}" go run ./cmd/embed-deno + generate: desc: 'Run all generators. Important vars: "paths".' run: once diff --git a/cmd/embed-deno/main.go b/cmd/embed-deno/main.go new file mode 100644 index 00000000..110fb706 --- /dev/null +++ b/cmd/embed-deno/main.go @@ -0,0 +1,148 @@ +package main + +import ( + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/werf/nelm/pkg/ts" +) + +var platforms = [][2]string{ + {"linux", "amd64"}, + {"linux", "arm64"}, + {"darwin", "amd64"}, + {"darwin", "arm64"}, + {"windows", "amd64"}, +} + +func run() error { + embedRoot := os.Getenv("DENO_EMBED_ROOT") + if embedRoot == "" { + embedRoot = filepath.Join("pkg", "ts", "embed") + } + + requested := platforms + + if platform := os.Getenv("DENO_EMBED_PLATFORM"); platform != "" { + goos, goarch, found := strings.Cut(platform, "/") + if !found { + return fmt.Errorf("parse DENO_EMBED_PLATFORM %q: want /", platform) + } + + known := false + for _, p := range platforms { + if p[0] == goos && p[1] == goarch { + known = true + + break + } + } + + if !known { + return fmt.Errorf("unsupported DENO_EMBED_PLATFORM %q", platform) + } + + requested = [][2]string{{goos, goarch}} + } + + ctx := context.Background() + + for _, platform := range requested { + if err := embedPlatform(ctx, platform[0], platform[1], embedRoot); err != nil { + return fmt.Errorf("embed %s/%s: %w", platform[0], platform[1], err) + } + } + + return nil +} + +func embedPlatform(ctx context.Context, goos, goarch, embedRoot string) error { + platformDir := filepath.Join(embedRoot, goos, goarch) + if err := os.MkdirAll(platformDir, 0o755); err != nil { + return fmt.Errorf("create dir %s: %w", platformDir, err) + } + + downloadDir, err := os.MkdirTemp("", "embed-deno-*") + if err != nil { + return fmt.Errorf("create temp download directory: %w", err) + } + + defer os.RemoveAll(downloadDir) + + binaryPath, err := ts.DownloadDenoForPlatform(ctx, goos, goarch, downloadDir) + if err != nil { + return fmt.Errorf("download deno: %w", err) + } + + binaryFile, err := os.Open(binaryPath) + if err != nil { + return fmt.Errorf("open downloaded deno: %w", err) + } + + defer binaryFile.Close() + + gzPath := filepath.Join(platformDir, "deno.gz") + sha256Path := filepath.Join(platformDir, "deno.sha256") + + gzTmpFile, err := os.CreateTemp(platformDir, "deno.gz.*.tmp") + if err != nil { + return fmt.Errorf("create temp file in %s: %w", platformDir, err) + } + + gzTmpPath := gzTmpFile.Name() + defer os.Remove(gzTmpPath) + + hasher := sha256.New() + gzWriter := gzip.NewWriter(gzTmpFile) + + if _, err := io.Copy(io.MultiWriter(gzWriter, hasher), binaryFile); err != nil { + gzTmpFile.Close() + + return fmt.Errorf("compress deno: %w", err) + } + + if err := gzWriter.Close(); err != nil { + gzTmpFile.Close() + + return fmt.Errorf("close gzip writer: %w", err) + } + + if err := gzTmpFile.Close(); err != nil { + return fmt.Errorf("close %s: %w", gzTmpPath, err) + } + + decompressedSha256 := hex.EncodeToString(hasher.Sum(nil)) + + sha256TmpPath := sha256Path + ".tmp" + if err := os.WriteFile(sha256TmpPath, []byte(decompressedSha256+"\n"), 0o644); err != nil { + return fmt.Errorf("write %s: %w", sha256TmpPath, err) + } + + defer os.Remove(sha256TmpPath) + + if err := os.Rename(gzTmpPath, gzPath); err != nil { + return fmt.Errorf("rename %s to %s: %w", gzTmpPath, gzPath, err) + } + + if err := os.Rename(sha256TmpPath, sha256Path); err != nil { + return fmt.Errorf("rename %s to %s: %w", sha256TmpPath, sha256Path, err) + } + + fmt.Printf("Embedded deno for %s/%s (sha256 %s)\n", goos, goarch, decompressedSha256) + + return nil +} + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "embed-deno: %v\n", err) + os.Exit(1) + } +} diff --git a/pkg/ts/deno.go b/pkg/ts/deno.go index 4a209894..688510d9 100644 --- a/pkg/ts/deno.go +++ b/pkg/ts/deno.go @@ -167,7 +167,16 @@ func getDenoBinary(ctx context.Context, binaryPath string) (string, error) { return binaryPath, nil } - link, err := getDownloadLink() + embeddedPath, embedded, err := embeddedDenoBinary(ctx) + if err != nil { + return "", fmt.Errorf("get embedded Deno binary: %w", err) + } + + if embedded { + return embeddedPath, nil + } + + link, err := getDownloadLink(runtime.GOOS, runtime.GOARCH) if err != nil { return "", fmt.Errorf("get download link: %w", err) } @@ -177,9 +186,7 @@ func getDenoBinary(ctx context.Context, binaryPath string) (string, error) { return "", fmt.Errorf("get Deno cache folder: %w", err) } - binaryName := lo.Ternary(runtime.GOOS == "windows", "deno.exe", "deno") - - denoPath := filepath.Join(cacheDir, binaryName) + denoPath := filepath.Join(cacheDir, denoBinaryName(runtime.GOOS)) if _, err := os.Stat(denoPath); err == nil { log.Default.Debug(ctx, "Using cached Deno binary: %s", denoPath) @@ -209,7 +216,7 @@ func getDenoBinary(ctx context.Context, binaryPath string) (string, error) { return denoPath, nil } - if err := downloadDeno(ctx, cacheDir, link); err != nil { + if err := downloadDeno(ctx, cacheDir, link, runtime.GOOS); err != nil { return "", fmt.Errorf("download deno: %w", err) } diff --git a/pkg/ts/downloader.go b/pkg/ts/downloader.go index 664d4eaf..3fdbe176 100644 --- a/pkg/ts/downloader.go +++ b/pkg/ts/downloader.go @@ -4,12 +4,12 @@ import ( "archive/zip" "context" "crypto/sha256" + "encoding/hex" "fmt" "hash/fnv" "io" "os" "path/filepath" - "runtime" "strings" "time" @@ -24,7 +24,26 @@ import ( const denoVersion = "2.7.1" -func downloadDeno(ctx context.Context, cacheDir, link string) error { +// DownloadDenoForPlatform downloads the pinned Deno release for an arbitrary +// target platform into destDir and returns the path of the extracted binary. +func DownloadDenoForPlatform(ctx context.Context, goos, goarch, destDir string) (string, error) { + link, err := getDownloadLink(goos, goarch) + if err != nil { + return "", fmt.Errorf("get download link: %w", err) + } + + if err := os.MkdirAll(destDir, 0o755); err != nil { + return "", fmt.Errorf("create destination directory: %w", err) + } + + if err := downloadDeno(ctx, destDir, link, goos); err != nil { + return "", fmt.Errorf("download deno: %w", err) + } + + return filepath.Join(destDir, denoBinaryName(goos)), nil +} + +func downloadDeno(ctx context.Context, cacheDir, link, goos string) error { httpClient := util.NewRestyClient(ctx) httpClient.SetTimeout(15 * time.Minute) @@ -72,7 +91,7 @@ func downloadDeno(ctx context.Context, cacheDir, link string) error { } }() - binaryName := lo.Ternary(runtime.GOOS == "windows", "deno.exe", "deno") + binaryName := denoBinaryName(goos) var binaryFound bool for _, file := range reader.File { @@ -119,14 +138,38 @@ func fetchExpectedChecksum(ctx context.Context, httpClient *resty.Client, archiv return "", fmt.Errorf("download checksum from %s: %s", checksumURL, response.Status()) } - hash, _, _ := strings.Cut(strings.TrimSpace(response.String()), " ") - if len(hash) != 64 { - return "", fmt.Errorf("unexpected checksum format from %s: %s", checksumURL, hash) + hash, found := findChecksum(response.String()) + if !found { + return "", fmt.Errorf("unexpected checksum format from %s: %s", checksumURL, strings.TrimSpace(response.String())) } return hash, nil } +func denoBinaryName(goos string) string { + return lo.Ternary(goos == "windows", "deno.exe", "deno") +} + +// findChecksum extracts a sha256 hex digest from a checksum file. Deno +// publishes plain " " for unix targets, but PowerShell +// Get-FileHash output ("Hash : ") for windows ones, so the +// digest is located by shape rather than by field position. +func findChecksum(body string) (string, bool) { + for _, field := range strings.Fields(body) { + if len(field) != 64 { + continue + } + + if _, err := hex.DecodeString(field); err != nil { + continue + } + + return strings.ToLower(field), true + } + + return "", false +} + func getDenoFolder(downloadURL string) (string, error) { hash := fnv.New32a() if _, err := hash.Write([]byte(downloadURL)); err != nil { @@ -150,24 +193,24 @@ func getDenoFolder(downloadURL string) (string, error) { return cacheDir, nil } -func getDownloadLink() (string, error) { +func getDownloadLink(goos, goarch string) (string, error) { var target string switch { - case runtime.GOOS == "linux" && runtime.GOARCH == "amd64": + case goos == "linux" && goarch == "amd64": target = "x86_64-unknown-linux-gnu" - case runtime.GOOS == "linux" && runtime.GOARCH == "arm64": + case goos == "linux" && goarch == "arm64": target = "aarch64-unknown-linux-gnu" - case runtime.GOOS == "darwin" && runtime.GOARCH == "amd64": + case goos == "darwin" && goarch == "amd64": target = "x86_64-apple-darwin" - case runtime.GOOS == "darwin" && runtime.GOARCH == "arm64": + case goos == "darwin" && goarch == "arm64": target = "aarch64-apple-darwin" - case runtime.GOOS == "windows" && runtime.GOARCH == "amd64": + case goos == "windows" && goarch == "amd64": target = "x86_64-pc-windows-msvc" - case runtime.GOOS == "windows" && runtime.GOARCH == "arm64": + case goos == "windows" && goarch == "arm64": target = "aarch64-pc-windows-msvc" default: - return "", fmt.Errorf("unsupported platform: %s/%s", runtime.GOOS, runtime.GOARCH) + return "", fmt.Errorf("unsupported platform: %s/%s", goos, goarch) } url := fmt.Sprintf("https://github.com/denoland/deno/releases/download/v%s/deno-%s.zip", denoVersion, target) diff --git a/pkg/ts/embed_darwin_amd64.go b/pkg/ts/embed_darwin_amd64.go new file mode 100644 index 00000000..3064f7b9 --- /dev/null +++ b/pkg/ts/embed_darwin_amd64.go @@ -0,0 +1,13 @@ +//go:build embeddeno + +package ts + +import _ "embed" + +var ( + //go:embed embed/darwin/amd64/deno.sha256 + embeddedDenoSHA256 string + + //go:embed embed/darwin/amd64/deno.gz + embeddedDeno []byte +) diff --git a/pkg/ts/embed_darwin_arm64.go b/pkg/ts/embed_darwin_arm64.go new file mode 100644 index 00000000..2149604b --- /dev/null +++ b/pkg/ts/embed_darwin_arm64.go @@ -0,0 +1,13 @@ +//go:build embeddeno + +package ts + +import _ "embed" + +var ( + //go:embed embed/darwin/arm64/deno.sha256 + embeddedDenoSHA256 string + + //go:embed embed/darwin/arm64/deno.gz + embeddedDeno []byte +) diff --git a/pkg/ts/embed_linux_amd64.go b/pkg/ts/embed_linux_amd64.go new file mode 100644 index 00000000..20133bda --- /dev/null +++ b/pkg/ts/embed_linux_amd64.go @@ -0,0 +1,13 @@ +//go:build embeddeno + +package ts + +import _ "embed" + +var ( + //go:embed embed/linux/amd64/deno.sha256 + embeddedDenoSHA256 string + + //go:embed embed/linux/amd64/deno.gz + embeddedDeno []byte +) diff --git a/pkg/ts/embed_linux_arm64.go b/pkg/ts/embed_linux_arm64.go new file mode 100644 index 00000000..379d0ff8 --- /dev/null +++ b/pkg/ts/embed_linux_arm64.go @@ -0,0 +1,13 @@ +//go:build embeddeno + +package ts + +import _ "embed" + +var ( + //go:embed embed/linux/arm64/deno.sha256 + embeddedDenoSHA256 string + + //go:embed embed/linux/arm64/deno.gz + embeddedDeno []byte +) diff --git a/pkg/ts/embed_windows_amd64.go b/pkg/ts/embed_windows_amd64.go new file mode 100644 index 00000000..4a65d62f --- /dev/null +++ b/pkg/ts/embed_windows_amd64.go @@ -0,0 +1,13 @@ +//go:build embeddeno + +package ts + +import _ "embed" + +var ( + //go:embed embed/windows/amd64/deno.sha256 + embeddedDenoSHA256 string + + //go:embed embed/windows/amd64/deno.gz + embeddedDeno []byte +) diff --git a/pkg/ts/embedded.go b/pkg/ts/embedded.go new file mode 100644 index 00000000..a415d742 --- /dev/null +++ b/pkg/ts/embedded.go @@ -0,0 +1,17 @@ +//go:build embeddeno + +package ts + +import ( + "context" + "strings" +) + +func embeddedDenoBinary(ctx context.Context) (string, bool, error) { + path, err := ExtractEmbeddedDeno(ctx, embeddedDeno, strings.TrimSpace(embeddedDenoSHA256)) + if err != nil { + return "", false, err + } + + return path, true, nil +} diff --git a/pkg/ts/embedded_deno.go b/pkg/ts/embedded_deno.go new file mode 100644 index 00000000..d250d7dc --- /dev/null +++ b/pkg/ts/embedded_deno.go @@ -0,0 +1,117 @@ +package ts + +import ( + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + + "github.com/gofrs/flock" + + "github.com/werf/nelm/pkg/helm/pkg/helmpath" + "github.com/werf/nelm/pkg/log" +) + +// ExtractEmbeddedDeno decompresses an embedded Deno binary into a cache +// directory keyed by expectedSHA256 and returns the path of the extracted +// binary. expectedSHA256 must be the checksum of the decompressed binary. +func ExtractEmbeddedDeno(ctx context.Context, compressedDeno []byte, expectedSHA256 string) (string, error) { + if len(expectedSHA256) != 64 { + return "", fmt.Errorf("unexpected embedded deno checksum format: %q", expectedSHA256) + } + + cacheDir := helmpath.CachePath("nelm", "deno", "embedded-"+expectedSHA256[:16]) + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return "", fmt.Errorf("create cache directory for embedded deno: %w", err) + } + + denoPath := filepath.Join(cacheDir, denoBinaryName(runtime.GOOS)) + if _, err := os.Stat(denoPath); err == nil { + log.Default.Debug(ctx, "Using cached embedded Deno binary: %s", denoPath) + + return denoPath, nil + } + + lockFile := filepath.Join(cacheDir, "lock") + + fileLock := flock.New(lockFile) + if err := fileLock.Lock(); err != nil { + return "", fmt.Errorf("acquire lock on embedded deno cache: %w", err) + } + + defer func() { + if err := fileLock.Unlock(); err != nil { + log.Default.Error(ctx, "release lock on embedded deno cache: %v", err) + } + + if err := os.Remove(lockFile); err != nil { + log.Default.Error(ctx, "remove embedded deno cache lock file: %v", err) + } + }() + + if _, err := os.Stat(denoPath); err == nil { + log.Default.Debug(ctx, "Using cached embedded Deno binary: %s", denoPath) + + return denoPath, nil + } + + log.Default.Debug(ctx, "Extracting embedded Deno binary to %s", denoPath) + + if err := extractDeno(compressedDeno, expectedSHA256, denoPath); err != nil { + return "", fmt.Errorf("extract embedded deno: %w", err) + } + + return denoPath, nil +} + +func extractDeno(compressedDeno []byte, expectedSHA256, denoPath string) error { + gzReader, err := gzip.NewReader(bytes.NewReader(compressedDeno)) + if err != nil { + return fmt.Errorf("init gzip reader: %w", err) + } + + defer gzReader.Close() + + destDir := filepath.Dir(denoPath) + + tmpFile, err := os.CreateTemp(destDir, filepath.Base(denoPath)+".*.tmp") + if err != nil { + return fmt.Errorf("create temp file in %s: %w", destDir, err) + } + + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + + hasher := sha256.New() + + if _, err := io.Copy(io.MultiWriter(tmpFile, hasher), gzReader); err != nil { + tmpFile.Close() + + return fmt.Errorf("decompress: %w", err) + } + + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("close %s: %w", tmpPath, err) + } + + actualSHA256 := hex.EncodeToString(hasher.Sum(nil)) + if actualSHA256 != expectedSHA256 { + return fmt.Errorf("integrity check failed: expected sha256 %s, got %s", expectedSHA256, actualSHA256) + } + + if err := os.Chmod(tmpPath, 0o755); err != nil { + return fmt.Errorf("chmod: %w", err) + } + + if err := os.Rename(tmpPath, denoPath); err != nil { + return fmt.Errorf("rename %s to %s: %w", tmpPath, denoPath, err) + } + + return nil +} diff --git a/pkg/ts/embedded_deno_ai_test.go b/pkg/ts/embedded_deno_ai_test.go new file mode 100644 index 00000000..9a597a9f --- /dev/null +++ b/pkg/ts/embedded_deno_ai_test.go @@ -0,0 +1,127 @@ +//go:build ai_tests + +package ts_test + +import ( + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/werf/nelm/pkg/ts" +) + +func TestAI_ExtractEmbeddedDeno(t *testing.T) { + payload := []byte("#!/bin/sh\necho fake-deno\n") + compressed, expectedSHA256 := gzipPayload(t, payload) + + expectedName := "deno" + if runtime.GOOS == "windows" { + expectedName = "deno.exe" + } + + t.Run("extracts, caches on second call, and is content-addressed", func(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + path, err := ts.ExtractEmbeddedDeno(context.Background(), compressed, expectedSHA256) + require.NoError(t, err) + + assert.Equal(t, expectedName, filepath.Base(path)) + assert.Contains(t, path, "embedded-"+expectedSHA256[:16]) + + content, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, payload, content) + + info, err := os.Stat(path) + require.NoError(t, err) + + if runtime.GOOS != "windows" { + assert.Equal(t, os.FileMode(0o755), info.Mode().Perm()) + } + + cachedPath, err := ts.ExtractEmbeddedDeno(context.Background(), compressed, expectedSHA256) + require.NoError(t, err) + assert.Equal(t, path, cachedPath) + + cachedInfo, err := os.Stat(cachedPath) + require.NoError(t, err) + assert.Equal(t, info.ModTime(), cachedInfo.ModTime(), "cache hit must not rewrite the binary") + + leftovers, err := filepath.Glob(filepath.Join(filepath.Dir(path), "*.tmp")) + require.NoError(t, err) + assert.Empty(t, leftovers, "no temp files must be left behind") + }) + + t.Run("fails on sha256 mismatch and leaves no binary", func(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + wrongSHA256 := strings.Repeat("0", 64) + + path, err := ts.ExtractEmbeddedDeno(context.Background(), compressed, wrongSHA256) + require.Error(t, err) + assert.Contains(t, err.Error(), "integrity check failed") + assert.Empty(t, path) + }) + + t.Run("fails on corrupted gzip data", func(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + path, err := ts.ExtractEmbeddedDeno(context.Background(), []byte("not gzip at all"), expectedSHA256) + require.Error(t, err) + assert.Empty(t, path) + }) +} + +func TestAI_FindChecksum(t *testing.T) { + const digest = "b7154ae42839d7b1453422e2f33c907e5c68fde8fe9f145cd43b8dd083671a6f" + + t.Run("parses plain sha256sum output", func(t *testing.T) { + hash, found := ts.FindChecksum(digest + " deno-x86_64-unknown-linux-gnu.zip\n") + require.True(t, found) + assert.Equal(t, digest, hash) + }) + + t.Run("parses PowerShell Get-FileHash output used for windows", func(t *testing.T) { + body := "\r\nAlgorithm : SHA256\r\nHash : " + strings.ToUpper(digest) + + "\r\nPath : C:\\a\\deno\\deno\\target\\release\\deno-x86_64-pc-windows-msvc.zip\r\n\r\n" + + hash, found := ts.FindChecksum(body) + require.True(t, found) + assert.Equal(t, digest, hash, "must be normalized to lowercase") + }) + + t.Run("reports not found when no digest is present", func(t *testing.T) { + _, found := ts.FindChecksum("Algorithm : SHA256\r\nPath : nowhere\r\n") + assert.False(t, found) + }) + + t.Run("ignores 64-char non-hex tokens", func(t *testing.T) { + _, found := ts.FindChecksum(strings.Repeat("z", 64)) + assert.False(t, found) + }) +} + +func gzipPayload(t *testing.T, payload []byte) ([]byte, string) { + t.Helper() + + var buf bytes.Buffer + + writer := gzip.NewWriter(&buf) + _, err := writer.Write(payload) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + sum := sha256.Sum256(payload) + + return buf.Bytes(), hex.EncodeToString(sum[:]) +} diff --git a/pkg/ts/embedded_stub.go b/pkg/ts/embedded_stub.go new file mode 100644 index 00000000..6f885410 --- /dev/null +++ b/pkg/ts/embedded_stub.go @@ -0,0 +1,9 @@ +//go:build !embeddeno + +package ts + +import "context" + +func embeddedDenoBinary(_ context.Context) (string, bool, error) { + return "", false, nil +} diff --git a/pkg/ts/export_test.go b/pkg/ts/export_test.go index 8b9590fc..a702a0e5 100644 --- a/pkg/ts/export_test.go +++ b/pkg/ts/export_test.go @@ -4,4 +4,5 @@ var ( ChartTSBuildScript = denoBuildScript ChartTSDevScript = denoDevScript ChartTSStartScript = denoStartScript + FindChecksum = findChecksum ) From bee47f9ccbfbce7b27c24ed7751c48cb02100b2b Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Fri, 31 Jul 2026 11:22:24 +0300 Subject: [PATCH 56/85] refactor: pass embedded Deno by context through TypeScriptOptions Signed-off-by: Dmitry Mordvinov --- pkg/common/common.go | 4 +- pkg/ts/deno.go | 9 +++ pkg/ts/deno_binary_ai_test.go | 143 ++++++++++++++++++++++++++++++++++ pkg/ts/export_test.go | 4 + 4 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 pkg/ts/deno_binary_ai_test.go diff --git a/pkg/common/common.go b/pkg/common/common.go index 154aacc4..57e761b8 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -298,7 +298,9 @@ type ChartLoadOptions struct { } type TypeScriptOptions struct { - DenoBinaryPath string + DenoBinaryPath string + EmbeddedDenoCompressed []byte + EmbeddedDenoSHA256 string } type helmOptionsContextKey struct{} diff --git a/pkg/ts/deno.go b/pkg/ts/deno.go index 688510d9..eda1b087 100644 --- a/pkg/ts/deno.go +++ b/pkg/ts/deno.go @@ -167,6 +167,15 @@ func getDenoBinary(ctx context.Context, binaryPath string) (string, error) { return binaryPath, nil } + if opts := GetTSOptionsFromContext(ctx); len(opts.EmbeddedDenoCompressed) > 0 || opts.EmbeddedDenoSHA256 != "" { + path, err := ExtractEmbeddedDeno(ctx, opts.EmbeddedDenoCompressed, opts.EmbeddedDenoSHA256) + if err != nil { + return "", fmt.Errorf("extract embedded Deno binary: %w", err) + } + + return path, nil + } + embeddedPath, embedded, err := embeddedDenoBinary(ctx) if err != nil { return "", fmt.Errorf("get embedded Deno binary: %w", err) diff --git a/pkg/ts/deno_binary_ai_test.go b/pkg/ts/deno_binary_ai_test.go new file mode 100644 index 00000000..ab790c30 --- /dev/null +++ b/pkg/ts/deno_binary_ai_test.go @@ -0,0 +1,143 @@ +//go:build ai_tests + +package ts_test + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/werf/nelm/pkg/common" + helmchart "github.com/werf/nelm/pkg/helm/pkg/chart" + v2chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + "github.com/werf/nelm/pkg/ts" +) + +func TestAI_GetDenoBinaryCtxEmbeddedData(t *testing.T) { + payload := []byte("#!/bin/sh\necho fake-deno\n") + compressed, sha := gzipPayload(t, payload) + + t.Run("explicit binary path wins over ctx data without extraction", func(t *testing.T) { + cacheHome := t.TempDir() + t.Setenv("XDG_CACHE_HOME", cacheHome) + + explicit := filepath.Join(t.TempDir(), "deno") + require.NoError(t, os.WriteFile(explicit, payload, 0o755)) + + ctx := ts.NewContextWithTSOptions(context.Background(), common.TypeScriptOptions{ + EmbeddedDenoCompressed: compressed, + EmbeddedDenoSHA256: sha, + }) + + path, err := ts.GetDenoBinary(ctx, explicit) + require.NoError(t, err) + assert.Equal(t, explicit, path) + assertNoEmbeddedCacheDirs(t, cacheHome) + }) + + t.Run("complete ctx data extracts to content-addressed path", func(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + ctx := ts.NewContextWithTSOptions(context.Background(), common.TypeScriptOptions{ + EmbeddedDenoCompressed: compressed, + EmbeddedDenoSHA256: sha, + }) + + path, err := ts.GetDenoBinary(ctx, "") + require.NoError(t, err) + assert.Contains(t, path, "embedded-"+sha[:16]) + + content, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, payload, content) + }) + + t.Run("sha without bytes errors instead of downloading", func(t *testing.T) { + cacheHome := t.TempDir() + t.Setenv("XDG_CACHE_HOME", cacheHome) + + ctx := ts.NewContextWithTSOptions(context.Background(), common.TypeScriptOptions{ + EmbeddedDenoSHA256: sha, + }) + + path, err := ts.GetDenoBinary(ctx, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "extract embedded Deno binary") + assert.Empty(t, path) + }) + + t.Run("bytes without sha errors mentioning checksum format", func(t *testing.T) { + cacheHome := t.TempDir() + t.Setenv("XDG_CACHE_HOME", cacheHome) + + ctx := ts.NewContextWithTSOptions(context.Background(), common.TypeScriptOptions{ + EmbeddedDenoCompressed: compressed, + }) + + path, err := ts.GetDenoBinary(ctx, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "checksum format") + assert.Empty(t, path) + }) + + t.Run("empty ctx options fall through to download cache", func(t *testing.T) { + cacheHome := t.TempDir() + t.Setenv("XDG_CACHE_HOME", cacheHome) + + link, err := ts.GetDownloadLink(runtime.GOOS, runtime.GOARCH) + require.NoError(t, err) + + cacheDir, err := ts.GetDenoFolder(link) + require.NoError(t, err) + + cachedBin := filepath.Join(cacheDir, ts.DenoBinaryName(runtime.GOOS)) + require.NoError(t, os.WriteFile(cachedBin, payload, 0o755)) + + path, err := ts.GetDenoBinary(ts.NewContextWithTSOptions(context.Background(), common.TypeScriptOptions{}), "") + require.NoError(t, err) + assert.Equal(t, cachedBin, path) + assertNoEmbeddedCacheDirs(t, cacheHome) + }) + + t.Run("chart without TS files never triggers extraction even with invalid ctx data", func(t *testing.T) { + cacheHome := t.TempDir() + t.Setenv("XDG_CACHE_HOME", cacheHome) + + ctx := ts.NewContextWithTSOptions(context.Background(), common.TypeScriptOptions{ + EmbeddedDenoCompressed: []byte("not gzip at all"), + EmbeddedDenoSHA256: "short-and-invalid", + }) + + chrt := &v2chart.Chart{Metadata: &v2chart.Metadata{Name: "no-ts", Version: "0.1.0", APIVersion: "v2"}} + acc, err := helmchart.NewAccessor(chrt) + require.NoError(t, err) + + require.NoError(t, ts.BundleChartsRecursive(ctx, acc, t.TempDir(), false, "")) + assertNoEmbeddedCacheDirs(t, cacheHome) + }) +} + +func assertNoEmbeddedCacheDirs(t *testing.T, cacheHome string) { + t.Helper() + + var matches []string + require.NoError(t, filepath.WalkDir(cacheHome, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if strings.HasPrefix(d.Name(), "embedded-") { + matches = append(matches, path) + } + + return nil + })) + assert.Empty(t, matches) +} diff --git a/pkg/ts/export_test.go b/pkg/ts/export_test.go index a702a0e5..121af0a0 100644 --- a/pkg/ts/export_test.go +++ b/pkg/ts/export_test.go @@ -5,4 +5,8 @@ var ( ChartTSDevScript = denoDevScript ChartTSStartScript = denoStartScript FindChecksum = findChecksum + GetDenoBinary = getDenoBinary + GetDownloadLink = getDownloadLink + GetDenoFolder = getDenoFolder + DenoBinaryName = denoBinaryName ) From 615df530ca9d2bbcde6b2e15e58cb6bdb4e2cefa Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Fri, 31 Jul 2026 18:22:38 +0300 Subject: [PATCH 57/85] feat: enhance Deno embedding process and sync platform lists Signed-off-by: Dmitry Mordvinov --- Taskfile.dist.yaml | 1 + cmd/embed-deno/main.go | 56 +++++++++++++----------------- pkg/ts/deno.go | 4 --- pkg/ts/embedded_deno.go | 4 --- pkg/ts/platforms_sync_ai_test.go | 58 ++++++++++++++++++++++++++++++++ 5 files changed, 83 insertions(+), 40 deletions(-) create mode 100644 pkg/ts/platforms_sync_ai_test.go diff --git a/Taskfile.dist.yaml b/Taskfile.dist.yaml index f789a7c1..8d28a54f 100644 --- a/Taskfile.dist.yaml +++ b/Taskfile.dist.yaml @@ -308,6 +308,7 @@ tasks: platform: '{{.platform | default (printf "%s/%s" .os .arch)}}' sources: - pkg/ts/downloader.go + - cmd/embed-deno/main.go generates: - pkg/ts/embed/{{.platform}}/deno.gz - pkg/ts/embed/{{.platform}}/deno.sha256 diff --git a/cmd/embed-deno/main.go b/cmd/embed-deno/main.go index 110fb706..25e4bbdc 100644 --- a/cmd/embed-deno/main.go +++ b/cmd/embed-deno/main.go @@ -11,6 +11,9 @@ import ( "path/filepath" "strings" + "github.com/samber/lo" + + "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/ts" ) @@ -23,41 +26,22 @@ var platforms = [][2]string{ } func run() error { - embedRoot := os.Getenv("DENO_EMBED_ROOT") - if embedRoot == "" { - embedRoot = filepath.Join("pkg", "ts", "embed") + platform := os.Getenv("DENO_EMBED_PLATFORM") + if platform == "" { + return fmt.Errorf("DENO_EMBED_PLATFORM is required: want /") } - requested := platforms - - if platform := os.Getenv("DENO_EMBED_PLATFORM"); platform != "" { - goos, goarch, found := strings.Cut(platform, "/") - if !found { - return fmt.Errorf("parse DENO_EMBED_PLATFORM %q: want /", platform) - } - - known := false - for _, p := range platforms { - if p[0] == goos && p[1] == goarch { - known = true - - break - } - } - - if !known { - return fmt.Errorf("unsupported DENO_EMBED_PLATFORM %q", platform) - } - - requested = [][2]string{{goos, goarch}} + goos, goarch, found := strings.Cut(platform, "/") + if !found { + return fmt.Errorf("parse DENO_EMBED_PLATFORM %q: want /", platform) } - ctx := context.Background() + if !lo.ContainsBy(platforms, func(p [2]string) bool { return p[0] == goos && p[1] == goarch }) { + return fmt.Errorf("unsupported DENO_EMBED_PLATFORM %q", platform) + } - for _, platform := range requested { - if err := embedPlatform(ctx, platform[0], platform[1], embedRoot); err != nil { - return fmt.Errorf("embed %s/%s: %w", platform[0], platform[1], err) - } + if err := embedPlatform(context.Background(), goos, goarch, filepath.Join("pkg", "ts", "embed")); err != nil { + return fmt.Errorf("embed %s/%s: %w", goos, goarch, err) } return nil @@ -127,6 +111,14 @@ func embedPlatform(ctx context.Context, goos, goarch, embedRoot string) error { defer os.Remove(sha256TmpPath) + if err := os.Remove(gzPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove %s: %w", gzPath, err) + } + + if err := os.Remove(sha256Path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove %s: %w", sha256Path, err) + } + if err := os.Rename(gzTmpPath, gzPath); err != nil { return fmt.Errorf("rename %s to %s: %w", gzTmpPath, gzPath, err) } @@ -135,14 +127,14 @@ func embedPlatform(ctx context.Context, goos, goarch, embedRoot string) error { return fmt.Errorf("rename %s to %s: %w", sha256TmpPath, sha256Path, err) } - fmt.Printf("Embedded deno for %s/%s (sha256 %s)\n", goos, goarch, decompressedSha256) + log.Default.Info(ctx, "Embedded deno for %s/%s (sha256 %s)", goos, goarch, decompressedSha256) return nil } func main() { if err := run(); err != nil { - fmt.Fprintf(os.Stderr, "embed-deno: %v\n", err) + log.Default.Error(context.Background(), "embed-deno: %v", err) os.Exit(1) } } diff --git a/pkg/ts/deno.go b/pkg/ts/deno.go index eda1b087..efb827fe 100644 --- a/pkg/ts/deno.go +++ b/pkg/ts/deno.go @@ -213,10 +213,6 @@ func getDenoBinary(ctx context.Context, binaryPath string) (string, error) { if err := fileLock.Unlock(); err != nil { log.Default.Error(ctx, "release lock on Deno cache: %v", err) } - - if err := os.Remove(lockFile); err != nil { - log.Default.Error(ctx, "remove Deno cache lock file: %v", err) - } }() if _, err := os.Stat(denoPath); err == nil { diff --git a/pkg/ts/embedded_deno.go b/pkg/ts/embedded_deno.go index d250d7dc..f07a84a5 100644 --- a/pkg/ts/embedded_deno.go +++ b/pkg/ts/embedded_deno.go @@ -49,10 +49,6 @@ func ExtractEmbeddedDeno(ctx context.Context, compressedDeno []byte, expectedSHA if err := fileLock.Unlock(); err != nil { log.Default.Error(ctx, "release lock on embedded deno cache: %v", err) } - - if err := os.Remove(lockFile); err != nil { - log.Default.Error(ctx, "remove embedded deno cache lock file: %v", err) - } }() if _, err := os.Stat(denoPath); err == nil { diff --git a/pkg/ts/platforms_sync_ai_test.go b/pkg/ts/platforms_sync_ai_test.go new file mode 100644 index 00000000..10636aa7 --- /dev/null +++ b/pkg/ts/platforms_sync_ai_test.go @@ -0,0 +1,58 @@ +//go:build ai_tests + +package ts_test + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAI_EmbedPlatformListsInSync(t *testing.T) { + embedFiles, err := filepath.Glob("embed_*.go") + require.NoError(t, err) + + fileRe := regexp.MustCompile(`^embed_([a-z0-9]+)_([a-z0-9]+)\.go$`) + + var embedPlatforms []string + for _, f := range embedFiles { + m := fileRe.FindStringSubmatch(filepath.Base(f)) + require.NotNil(t, m, "unexpected embed file name: %s", f) + embedPlatforms = append(embedPlatforms, m[1]+"/"+m[2]) + } + + require.NotEmpty(t, embedPlatforms) + sort.Strings(embedPlatforms) + + generatorSrc, err := os.ReadFile(filepath.Join("..", "..", "cmd", "embed-deno", "main.go")) + require.NoError(t, err) + + generatorRe := regexp.MustCompile(`\{"([a-z0-9]+)", "([a-z0-9]+)"\}`) + + var generatorPlatforms []string + for _, m := range generatorRe.FindAllStringSubmatch(string(generatorSrc), -1) { + generatorPlatforms = append(generatorPlatforms, m[1]+"/"+m[2]) + } + + sort.Strings(generatorPlatforms) + + taskfileSrc, err := os.ReadFile(filepath.Join("..", "..", "Taskfile.dist.yaml")) + require.NoError(t, err) + + taskfileRe := regexp.MustCompile(`platform: "([a-z0-9]+)/([a-z0-9]+)"`) + + var taskfilePlatforms []string + for _, m := range taskfileRe.FindAllStringSubmatch(string(taskfileSrc), -1) { + taskfilePlatforms = append(taskfilePlatforms, m[1]+"/"+m[2]) + } + + sort.Strings(taskfilePlatforms) + + assert.Equal(t, embedPlatforms, generatorPlatforms, "cmd/embed-deno platforms whitelist out of sync with pkg/ts/embed_*.go files") + assert.Equal(t, embedPlatforms, taskfilePlatforms, "Taskfile.dist.yaml deno:embed platforms out of sync with pkg/ts/embed_*.go files") +} From 62d593ff8fb413e6dcd1e9dc13b749fec90f1375 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Fri, 31 Jul 2026 20:11:23 +0300 Subject: [PATCH 58/85] fix: harden deno embedding per review findings - key deno:embed fingerprint per platform via label, otherwise a warm workspace regenerates only one platform after a denoVersion bump and ships stale deno blobs for the rest - join user tags with embeddeno by comma: space-joined -tags rejects commas in go build - validate embedded deno checksum as normalized hex in the public ExtractEmbeddedDeno (it is used as a cache path component), drop the now-redundant TrimSpace at the call site - gitignore deno.sha256.tmp left behind by a killed generator - skip the download-cache fallthrough subtest under -tags embeddeno where the embedded blob legitimately wins Signed-off-by: Dmitry Mordvinov --- .gitignore | 2 ++ Taskfile.dist.yaml | 3 ++- pkg/ts/deno_binary_ai_test.go | 4 ++++ pkg/ts/embedded.go | 3 +-- pkg/ts/embedded_deno.go | 4 +++- pkg/ts/helpers_embeddeno_ai_test.go | 5 +++++ pkg/ts/helpers_noembeddeno_ai_test.go | 5 +++++ 7 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 pkg/ts/helpers_embeddeno_ai_test.go create mode 100644 pkg/ts/helpers_noembeddeno_ai_test.go diff --git a/.gitignore b/.gitignore index 29e6ca50..f2db6d5e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,7 @@ node_modules/ /pkg/ts/embed/*/*/deno.gz /pkg/ts/embed/*/*/deno.sha256 +/pkg/ts/embed/*/*/deno.gz.*.tmp +/pkg/ts/embed/*/*/deno.sha256.tmp /.opencode/ /.sisyphus diff --git a/Taskfile.dist.yaml b/Taskfile.dist.yaml index 8d28a54f..2a1386a5 100644 --- a/Taskfile.dist.yaml +++ b/Taskfile.dist.yaml @@ -17,7 +17,7 @@ vars: kubeconformValidationSchemasUpstreamKubeVersion: "1.36.3" tags: "" - buildTags: '{{trim (printf "%s embeddeno" .tags)}}' + buildTags: '{{if .tags}}{{.tags}},{{end}}embeddeno' ldFlags: "-s -w -X github.com/werf/nelm/pkg/common.Brand={{.brand}} -X github.com/werf/nelm/pkg/common.Version={{.version}}" tasks: @@ -303,6 +303,7 @@ tasks: deno:embed: desc: 'Download and embed the Deno binary for one platform. Important vars: "platform".' + label: 'deno:embed {{.platform}}' run: when_changed vars: platform: '{{.platform | default (printf "%s/%s" .os .arch)}}' diff --git a/pkg/ts/deno_binary_ai_test.go b/pkg/ts/deno_binary_ai_test.go index ab790c30..74770f3c 100644 --- a/pkg/ts/deno_binary_ai_test.go +++ b/pkg/ts/deno_binary_ai_test.go @@ -88,6 +88,10 @@ func TestAI_GetDenoBinaryCtxEmbeddedData(t *testing.T) { }) t.Run("empty ctx options fall through to download cache", func(t *testing.T) { + if embeddedDenoEnabled { + t.Skip("embedded deno blob takes priority over the download cache") + } + cacheHome := t.TempDir() t.Setenv("XDG_CACHE_HOME", cacheHome) diff --git a/pkg/ts/embedded.go b/pkg/ts/embedded.go index a415d742..f2a91239 100644 --- a/pkg/ts/embedded.go +++ b/pkg/ts/embedded.go @@ -4,11 +4,10 @@ package ts import ( "context" - "strings" ) func embeddedDenoBinary(ctx context.Context) (string, bool, error) { - path, err := ExtractEmbeddedDeno(ctx, embeddedDeno, strings.TrimSpace(embeddedDenoSHA256)) + path, err := ExtractEmbeddedDeno(ctx, embeddedDeno, embeddedDenoSHA256) if err != nil { return "", false, err } diff --git a/pkg/ts/embedded_deno.go b/pkg/ts/embedded_deno.go index f07a84a5..23b7a53c 100644 --- a/pkg/ts/embedded_deno.go +++ b/pkg/ts/embedded_deno.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "github.com/gofrs/flock" @@ -22,7 +23,8 @@ import ( // directory keyed by expectedSHA256 and returns the path of the extracted // binary. expectedSHA256 must be the checksum of the decompressed binary. func ExtractEmbeddedDeno(ctx context.Context, compressedDeno []byte, expectedSHA256 string) (string, error) { - if len(expectedSHA256) != 64 { + expectedSHA256 = strings.ToLower(strings.TrimSpace(expectedSHA256)) + if _, err := hex.DecodeString(expectedSHA256); err != nil || len(expectedSHA256) != 64 { return "", fmt.Errorf("unexpected embedded deno checksum format: %q", expectedSHA256) } diff --git a/pkg/ts/helpers_embeddeno_ai_test.go b/pkg/ts/helpers_embeddeno_ai_test.go new file mode 100644 index 00000000..e4933449 --- /dev/null +++ b/pkg/ts/helpers_embeddeno_ai_test.go @@ -0,0 +1,5 @@ +//go:build ai_tests && embeddeno + +package ts_test + +const embeddedDenoEnabled = true diff --git a/pkg/ts/helpers_noembeddeno_ai_test.go b/pkg/ts/helpers_noembeddeno_ai_test.go new file mode 100644 index 00000000..abfcbe2a --- /dev/null +++ b/pkg/ts/helpers_noembeddeno_ai_test.go @@ -0,0 +1,5 @@ +//go:build ai_tests && !embeddeno + +package ts_test + +const embeddedDenoEnabled = false From ee84ed77ac0d5cde679fc66a6d27d87c851e3b80 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Fri, 31 Jul 2026 20:36:20 +0300 Subject: [PATCH 59/85] fix: format taskfile Signed-off-by: Dmitry Mordvinov --- Taskfile.dist.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Taskfile.dist.yaml b/Taskfile.dist.yaml index 2a1386a5..01b63483 100644 --- a/Taskfile.dist.yaml +++ b/Taskfile.dist.yaml @@ -17,7 +17,7 @@ vars: kubeconformValidationSchemasUpstreamKubeVersion: "1.36.3" tags: "" - buildTags: '{{if .tags}}{{.tags}},{{end}}embeddeno' + buildTags: "{{if .tags}}{{.tags}},{{end}}embeddeno" ldFlags: "-s -w -X github.com/werf/nelm/pkg/common.Brand={{.brand}} -X github.com/werf/nelm/pkg/common.Version={{.version}}" tasks: @@ -303,7 +303,7 @@ tasks: deno:embed: desc: 'Download and embed the Deno binary for one platform. Important vars: "platform".' - label: 'deno:embed {{.platform}}' + label: "deno:embed {{.platform}}" run: when_changed vars: platform: '{{.platform | default (printf "%s/%s" .os .arch)}}' From 3abafee7837e7fab412f2c47287a4e626b7a35ad Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Fri, 31 Jul 2026 21:45:35 +0300 Subject: [PATCH 60/85] fix: remove unsupported os/arch from getDownloadLink Signed-off-by: Dmitry Mordvinov --- pkg/ts/downloader.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/ts/downloader.go b/pkg/ts/downloader.go index 3fdbe176..81c6cd5f 100644 --- a/pkg/ts/downloader.go +++ b/pkg/ts/downloader.go @@ -207,8 +207,6 @@ func getDownloadLink(goos, goarch string) (string, error) { target = "aarch64-apple-darwin" case goos == "windows" && goarch == "amd64": target = "x86_64-pc-windows-msvc" - case goos == "windows" && goarch == "arm64": - target = "aarch64-pc-windows-msvc" default: return "", fmt.Errorf("unsupported platform: %s/%s", goos, goarch) } From 9b5af8d8c453d76234095247b3f05e4fd57d86cb Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Fri, 31 Jul 2026 22:09:26 +0300 Subject: [PATCH 61/85] fix: restore DENO_EMBED_ROOT for werf Signed-off-by: Dmitry Mordvinov --- cmd/embed-deno/main.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cmd/embed-deno/main.go b/cmd/embed-deno/main.go index 25e4bbdc..c52b13cd 100644 --- a/cmd/embed-deno/main.go +++ b/cmd/embed-deno/main.go @@ -26,6 +26,11 @@ var platforms = [][2]string{ } func run() error { + embedRoot := os.Getenv("DENO_EMBED_ROOT") + if embedRoot == "" { + embedRoot = filepath.Join("pkg", "ts", "embed") + } + platform := os.Getenv("DENO_EMBED_PLATFORM") if platform == "" { return fmt.Errorf("DENO_EMBED_PLATFORM is required: want /") @@ -40,7 +45,7 @@ func run() error { return fmt.Errorf("unsupported DENO_EMBED_PLATFORM %q", platform) } - if err := embedPlatform(context.Background(), goos, goarch, filepath.Join("pkg", "ts", "embed")); err != nil { + if err := embedPlatform(context.Background(), goos, goarch, embedRoot); err != nil { return fmt.Errorf("embed %s/%s: %w", goos, goarch, err) } From 4c91c3a739c5db511f06003cb19495c8a824543e Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Sat, 1 Aug 2026 02:19:40 +0300 Subject: [PATCH 62/85] fix: pin deno digests in the repository instead of fetching them Both integrity checks of the deno binary were circular. The download path fetched the expected sha256 from the same host, over the same connection, moments before the archive it was supposed to vouch for, so it could only catch a broken transfer. The embedded path was worse: embed-deno hashed whatever it had just downloaded, wrote that digest next to the blob, and the runtime verified the blob against it, proving only that gzip round tripped. Move the trust anchor into the repository. pkg/ts/denolock records the pinned version and, per platform, the sha256 of the release archive and of the binary inside it; it is committed and embedded, so what a binary verifies against went through code review and is in git history. The download path checks the archive and the unpacked binary against it, and embed-deno no longer records a digest of its own. The lock is generated by scripts/denolock, which cross-checks each digest against the .sha256sum upstream publishes - the one thing that file can still tell us - and offers -verify (offline, wired into lint) and -check-upstream, which reports a release asset replaced under an existing tag. The lock is now the only place the deno version is configured. wormatter is no longer applied to pkg/ts/embed_*.go: it drops the //go:embed directive of a single var declaration, which compiles fine and silently produces a binary with an empty embedded deno. Signed-off-by: Dmitry Mordvinov --- .gitignore | 2 - Taskfile.dist.yaml | 23 +- cmd/embed-deno/main.go | 50 ++-- pkg/ts/deno.go | 2 +- pkg/ts/denolock/data/README.md | 53 +++++ pkg/ts/denolock/data/lock.json | 30 +++ pkg/ts/denolock/embed.go | 185 +++++++++++++++ pkg/ts/denolock/embed_ai_test.go | 95 ++++++++ pkg/ts/downloader.go | 102 ++------ pkg/ts/embed_darwin_amd64.go | 9 +- pkg/ts/embed_darwin_arm64.go | 9 +- pkg/ts/embed_linux_amd64.go | 9 +- pkg/ts/embed_linux_arm64.go | 9 +- pkg/ts/embed_windows_amd64.go | 9 +- pkg/ts/embedded.go | 11 +- pkg/ts/embedded_deno_ai_test.go | 29 --- pkg/ts/export_test.go | 1 - pkg/ts/platforms_sync_ai_test.go | 23 +- scripts/denolock/checksum_ai_test.go | 40 ++++ scripts/denolock/download.go | 179 ++++++++++++++ scripts/denolock/main.go | 334 +++++++++++++++++++++++++++ 21 files changed, 1011 insertions(+), 193 deletions(-) create mode 100644 pkg/ts/denolock/data/README.md create mode 100644 pkg/ts/denolock/data/lock.json create mode 100644 pkg/ts/denolock/embed.go create mode 100644 pkg/ts/denolock/embed_ai_test.go create mode 100644 scripts/denolock/checksum_ai_test.go create mode 100644 scripts/denolock/download.go create mode 100644 scripts/denolock/main.go diff --git a/.gitignore b/.gitignore index f2db6d5e..922d9d9b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,8 +11,6 @@ /go.work.sum node_modules/ /pkg/ts/embed/*/*/deno.gz -/pkg/ts/embed/*/*/deno.sha256 /pkg/ts/embed/*/*/deno.gz.*.tmp -/pkg/ts/embed/*/*/deno.sha256.tmp /.opencode/ /.sisyphus diff --git a/Taskfile.dist.yaml b/Taskfile.dist.yaml index 01b63483..af8df136 100644 --- a/Taskfile.dist.yaml +++ b/Taskfile.dist.yaml @@ -62,7 +62,9 @@ tasks: _wormatter: internal: true cmds: - - docker run --rm -u "$(id -u):$(id -g)" -v $(pwd):/work -w /work ghcr.io/werf/wormatter:1.5.0 {{if eq .onlyLint "true"}}-c{{end}} -e ^pkg/helm/ {{.paths | default "cmd/ pkg/"}} + # pkg/ts/embed_ is excluded because wormatter drops the //go:embed directive of a single var + # declaration, which would silently produce a binary with an empty embedded Deno. + - docker run --rm -u "$(id -u):$(id -g)" -v $(pwd):/work -w /work ghcr.io/werf/wormatter:1.5.0 {{if eq .onlyLint "true"}}-c{{end}} -e ^pkg/helm/ -e ^pkg/ts/embed_ {{.paths | default "cmd/ pkg/"}} vars: onlyLint: '{{.onlyLint | default "false"}}' paths: "{{.paths}}" @@ -309,13 +311,19 @@ tasks: platform: '{{.platform | default (printf "%s/%s" .os .arch)}}' sources: - pkg/ts/downloader.go + - pkg/ts/denolock/data/lock.json - cmd/embed-deno/main.go generates: - pkg/ts/embed/{{.platform}}/deno.gz - - pkg/ts/embed/{{.platform}}/deno.sha256 cmds: - env -u GOOS -u GOARCH DENO_EMBED_PLATFORM="{{.platform}}" go run ./cmd/embed-deno + deno:lock: + desc: "Re-pin the Deno release nelm downloads and embeds, and commit the result. Bump with: task deno:lock -- -version 2.8.0." + cmds: + # Takes the version to pin from the lock itself, so it needs no vars. + - go run ./scripts/denolock {{.CLI_ARGS}} + generate: desc: 'Run all generators. Important vars: "paths".' run: once @@ -358,18 +366,29 @@ tasks: - lint:prettier - lint:golangci-lint - lint:validation-schemas + - lint:deno-lock lint:validation-schemas: desc: 'Check that the committed embedded JSON schema archives match their index and the pinned Kubernetes version. Runs offline. Important vars: "kubeconformValidationSchemasUpstreamKubeVersion".' cmds: - go run ./scripts/schemagen -kube-version {{.kubeconformValidationSchemasUpstreamKubeVersion}} -verify {{.CLI_ARGS}} + lint:deno-lock: + desc: "Check that the committed Deno lock is well formed and covers the platforms nelm builds for. Runs offline." + cmds: + - go run ./scripts/denolock -verify {{.CLI_ARGS}} + check:validation-schemas-upstream: desc: "Report whether upstream has schemas the committed archives do not. Needs network, so it is not part of lint: run it on a schedule." cmds: # Takes the repositories, refs and versions to compare from the index, so it needs no vars. - go run ./scripts/schemagen -check-upstream {{.CLI_ARGS}} + check:deno-lock-upstream: + desc: "Report whether the Deno release assets the lock pins still hash to what it records. Needs network, so it is not part of lint: run it before a bump, or when a release build fails on a checksum." + cmds: + - go run ./scripts/denolock -check-upstream {{.CLI_ARGS}} + lint:golangci-lint: desc: 'Lint with golangci-lint. Important vars: "paths".' cmds: diff --git a/cmd/embed-deno/main.go b/cmd/embed-deno/main.go index c52b13cd..25ac12e5 100644 --- a/cmd/embed-deno/main.go +++ b/cmd/embed-deno/main.go @@ -1,30 +1,23 @@ +// Command embed-deno downloads the pinned Deno release for one platform and compresses it into +// pkg/ts/embed///deno.gz, which the embeddeno build tag compiles into the release binary. +// The blob is not committed, so what makes it the Deno this repository pinned is pkg/ts/denolock, +// which both the download here and the extraction at run time verify against. package main import ( "compress/gzip" "context" - "crypto/sha256" - "encoding/hex" "fmt" "io" "os" "path/filepath" "strings" - "github.com/samber/lo" - "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/ts" + "github.com/werf/nelm/pkg/ts/denolock" ) -var platforms = [][2]string{ - {"linux", "amd64"}, - {"linux", "arm64"}, - {"darwin", "amd64"}, - {"darwin", "arm64"}, - {"windows", "amd64"}, -} - func run() error { embedRoot := os.Getenv("DENO_EMBED_ROOT") if embedRoot == "" { @@ -41,8 +34,8 @@ func run() error { return fmt.Errorf("parse DENO_EMBED_PLATFORM %q: want /", platform) } - if !lo.ContainsBy(platforms, func(p [2]string) bool { return p[0] == goos && p[1] == goarch }) { - return fmt.Errorf("unsupported DENO_EMBED_PLATFORM %q", platform) + if _, err := denolock.Get(goos, goarch); err != nil { + return fmt.Errorf("unsupported DENO_EMBED_PLATFORM %q: %w", platform, err) } if err := embedPlatform(context.Background(), goos, goarch, embedRoot); err != nil { @@ -78,7 +71,6 @@ func embedPlatform(ctx context.Context, goos, goarch, embedRoot string) error { defer binaryFile.Close() gzPath := filepath.Join(platformDir, "deno.gz") - sha256Path := filepath.Join(platformDir, "deno.sha256") gzTmpFile, err := os.CreateTemp(platformDir, "deno.gz.*.tmp") if err != nil { @@ -88,10 +80,9 @@ func embedPlatform(ctx context.Context, goos, goarch, embedRoot string) error { gzTmpPath := gzTmpFile.Name() defer os.Remove(gzTmpPath) - hasher := sha256.New() gzWriter := gzip.NewWriter(gzTmpFile) - if _, err := io.Copy(io.MultiWriter(gzWriter, hasher), binaryFile); err != nil { + if _, err := io.Copy(gzWriter, binaryFile); err != nil { gzTmpFile.Close() return fmt.Errorf("compress deno: %w", err) @@ -107,32 +98,25 @@ func embedPlatform(ctx context.Context, goos, goarch, embedRoot string) error { return fmt.Errorf("close %s: %w", gzTmpPath, err) } - decompressedSha256 := hex.EncodeToString(hasher.Sum(nil)) - - sha256TmpPath := sha256Path + ".tmp" - if err := os.WriteFile(sha256TmpPath, []byte(decompressedSha256+"\n"), 0o644); err != nil { - return fmt.Errorf("write %s: %w", sha256TmpPath, err) - } - - defer os.Remove(sha256TmpPath) - if err := os.Remove(gzPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove %s: %w", gzPath, err) } - if err := os.Remove(sha256Path); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("remove %s: %w", sha256Path, err) - } - if err := os.Rename(gzTmpPath, gzPath); err != nil { return fmt.Errorf("rename %s to %s: %w", gzTmpPath, gzPath, err) } - if err := os.Rename(sha256TmpPath, sha256Path); err != nil { - return fmt.Errorf("rename %s to %s: %w", sha256TmpPath, sha256Path, err) + // Written by an older embed-deno, replaced by pkg/ts/denolock. + if err := os.Remove(filepath.Join(platformDir, "deno.sha256")); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove %s: %w", filepath.Join(platformDir, "deno.sha256"), err) + } + + version, err := denolock.Version() + if err != nil { + return fmt.Errorf("get the pinned Deno version: %w", err) } - log.Default.Info(ctx, "Embedded deno for %s/%s (sha256 %s)", goos, goarch, decompressedSha256) + log.Default.Info(ctx, "Embedded Deno %s for %s/%s", version, goos, goarch) return nil } diff --git a/pkg/ts/deno.go b/pkg/ts/deno.go index efb827fe..168ac403 100644 --- a/pkg/ts/deno.go +++ b/pkg/ts/deno.go @@ -221,7 +221,7 @@ func getDenoBinary(ctx context.Context, binaryPath string) (string, error) { return denoPath, nil } - if err := downloadDeno(ctx, cacheDir, link, runtime.GOOS); err != nil { + if err := downloadDeno(ctx, cacheDir, runtime.GOOS, runtime.GOARCH); err != nil { return "", fmt.Errorf("download deno: %w", err) } diff --git a/pkg/ts/denolock/data/README.md b/pkg/ts/denolock/data/README.md new file mode 100644 index 00000000..5db3c0f2 --- /dev/null +++ b/pkg/ts/denolock/data/README.md @@ -0,0 +1,53 @@ +# Pinned Deno release + +`lock.json` is embedded into the nelm binary by `../embed.go` and records the Deno release nelm runs +TypeScript charts with: the version, and per platform the target triple, the sha256 of the release +archive and the sha256 of the Deno binary inside it. + +## Why the digests are committed + +Deno publishes a `.sha256sum` next to every archive. Verifying a download against it is +worth nothing beyond catching a truncated transfer: it comes from the same host, over the same +connection, moments before the artifact it vouches for, so anyone able to serve a tampered archive +serves a matching checksum with it. The same goes for a digest computed on the release machine from +whatever was downloaded there and then embedded next to the blob — it only proves the blob survived +compression. + +Committing the digests moves the trust anchor off the network and into this repository: the digest a +binary verifies against is one that appeared as a diff in a pull request, was reviewed, and is in git +history. It is the same reason `go.sum` and `Cargo.lock` exist. Changing what nelm executes now takes +a commit rather than control of a download. + +What this does not cover: a release that was already malicious when it was pinned. Against that only +upstream provenance helps, which Deno does not publish — the v2.7.1 assets are archives and +`.sha256sum` files, nothing signed. + +## Regenerating + +```shell +task deno:lock # re-pin the version recorded in the lock +task deno:lock -- -version 2.8.0 # bump +``` + +It downloads every platform's archive, hashes it, unpacks it and hashes the binary, and cross-checks +each digest against the `.sha256sum` upstream publishes — a mismatch there means the download and the +checksum disagree, which is the one thing the upstream file can still tell us. Then it writes +`lock.json`. **Review the diff**: it is the whole point of the mechanism. + +`task lint:deno-lock` checks the committed lock offline and runs with the other linters. + +## When a digest stops matching + +GitHub lets a release asset be replaced without moving the tag, so a pinned digest can stop matching +what upstream serves. A release build is what notices — it fails on the checksum, which is the +behaviour we want. To tell a replaced asset from a broken download: + +```shell +task check:deno-lock-upstream +``` + +Five small requests, no downloads. Nothing legitimate replaces a published release asset, so a +mismatch it reports is a reason to investigate rather than to re-run `task deno:lock`. + +This directory is not documentation the binary carries: `embed.go` names `lock.json` explicitly, so +this file is not embedded. diff --git a/pkg/ts/denolock/data/lock.json b/pkg/ts/denolock/data/lock.json new file mode 100644 index 00000000..b04d9c66 --- /dev/null +++ b/pkg/ts/denolock/data/lock.json @@ -0,0 +1,30 @@ +{ + "platforms": { + "darwin/amd64": { + "archiveSha256": "5478393fc9893c6f3516cee7579453a990834ceebf5ff44aaced2d0f285302d7", + "binarySha256": "43576ea690f25932cc08905857e05d3369974ca72073251d4a21401f96532b07", + "target": "x86_64-apple-darwin" + }, + "darwin/arm64": { + "archiveSha256": "bc3392a0f50be9a1ecb68596530319308639a6f69d99678a0018c47e23a10c1f", + "binarySha256": "f658986b8f6dd59fabd4745bf4eb691304e7e9f5a1b5b023206719b7456cba68", + "target": "aarch64-apple-darwin" + }, + "linux/amd64": { + "archiveSha256": "b7154ae42839d7b1453422e2f33c907e5c68fde8fe9f145cd43b8dd083671a6f", + "binarySha256": "d79ad1e2b52916c68038bca1e21b4db233667d460287008e961438610b6651e1", + "target": "x86_64-unknown-linux-gnu" + }, + "linux/arm64": { + "archiveSha256": "bfe62e582faceb9885748f00a9f44cd5af458b9d08d7ac8275c4048ff1bb1540", + "binarySha256": "64274ce0ae6c4daa970e023959e72252c805704b9c7fde1038b9ca5cbf84a3a8", + "target": "aarch64-unknown-linux-gnu" + }, + "windows/amd64": { + "archiveSha256": "94d71d4772436de27a0495933ca4bab7b6895992622b65baeaf4b7995dae1e69", + "binarySha256": "6c35a4c45d521b340e5ce78dfb25014e5b04894067b5e71c1180273ffa5b3792", + "target": "x86_64-pc-windows-msvc" + } + }, + "version": "2.7.1" +} diff --git a/pkg/ts/denolock/embed.go b/pkg/ts/denolock/embed.go new file mode 100644 index 00000000..8c703879 --- /dev/null +++ b/pkg/ts/denolock/embed.go @@ -0,0 +1,185 @@ +// Package denolock provides the pinned Deno release nelm downloads and embeds. The digests are +// committed and embedded rather than fetched, so that they go through code review instead of coming +// from the same host as the artifact they are supposed to vouch for. Generated by scripts/denolock. +package denolock + +import ( + "embed" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "maps" + "slices" + "strings" + "sync" +) + +const ( + // ChecksumURLSuffix is only for scripts/denolock: nothing verifies against a fetched digest. + ChecksumURLSuffix = ".sha256sum" + + archiveURLTemplate = "https://github.com/denoland/deno/releases/download/v%s/deno-%s.zip" + lockFileName = "data/lock.json" + sha256HexLen = 64 +) + +var ( + //go:embed data/lock.json + data embed.FS + + readLock = sync.OnceValues(func() (*Lock, error) { + lockBytes, err := data.ReadFile(lockFileName) + if err != nil { + return nil, fmt.Errorf("read %s: %w", lockFileName, err) + } + + var lock Lock + + if err := json.Unmarshal(lockBytes, &lock); err != nil { + return nil, fmt.Errorf("decode %s: %w", lockFileName, err) + } + + if err := lock.Validate(); err != nil { + return nil, fmt.Errorf("invalid %s, regenerate it with: task deno:lock: %w", lockFileName, err) + } + + return &lock, nil + }) +) + +type Platform struct { + ArchiveSHA256 string `json:"archiveSha256"` + // BinarySHA256 is the digest of the uncompressed Deno binary inside the archive, which is what the + // embedded blob is checked against. + BinarySHA256 string `json:"binarySha256"` + Target string `json:"target"` +} + +func (p *Platform) validate() error { + if p.Target == "" { + return errors.New("no target triple") + } + + if err := validateSHA256(p.ArchiveSHA256); err != nil { + return fmt.Errorf("archive sha256: %w", err) + } + + if err := validateSHA256(p.BinarySHA256); err != nil { + return fmt.Errorf("binary sha256: %w", err) + } + + return nil +} + +type Lock struct { + // Platforms is keyed by "/". + Platforms map[string]*Platform `json:"platforms"` + Version string `json:"version"` +} + +func (l *Lock) ArchiveURL(goos, goarch string) (string, error) { + entry, err := l.Get(goos, goarch) + if err != nil { + return "", err + } + + return ArchiveURLFor(l.Version, entry.Target), nil +} + +func (l *Lock) Get(goos, goarch string) (*Platform, error) { + entry, found := l.Platforms[platformKey(goos, goarch)] + if !found { + return nil, fmt.Errorf("unsupported platform: %s/%s", goos, goarch) + } + + return entry, nil +} + +func (l *Lock) Validate() error { + if l.Version == "" { + return errors.New("no version") + } + + if strings.HasPrefix(l.Version, "v") { + return fmt.Errorf("version %q must be recorded without the leading \"v\"", l.Version) + } + + if len(l.Platforms) == 0 { + return errors.New("no platforms") + } + + for _, platform := range slices.Sorted(maps.Keys(l.Platforms)) { + goos, goarch, found := strings.Cut(platform, "/") + if !found || goos == "" || goarch == "" { + return fmt.Errorf("platform key %q is not \"/\"", platform) + } + + entry := l.Platforms[platform] + if entry == nil { + return fmt.Errorf("platform %s: no entry", platform) + } + + if err := entry.validate(); err != nil { + return fmt.Errorf("platform %s: %w", platform, err) + } + } + + return nil +} + +func ArchiveURL(goos, goarch string) (string, error) { + lock, err := Read() + if err != nil { + return "", err + } + + return lock.ArchiveURL(goos, goarch) +} + +func Get(goos, goarch string) (*Platform, error) { + lock, err := Read() + if err != nil { + return nil, err + } + + return lock.Get(goos, goarch) +} + +func Version() (string, error) { + lock, err := Read() + if err != nil { + return "", err + } + + return lock.Version, nil +} + +// ArchiveURLFor is for scripts/denolock, which builds URLs for a version the lock does not record yet. +func ArchiveURLFor(version, target string) string { + return fmt.Sprintf(archiveURLTemplate, strings.TrimPrefix(version, "v"), target) +} + +func Read() (*Lock, error) { + return readLock() +} + +func platformKey(goos, goarch string) string { + return goos + "/" + goarch +} + +func validateSHA256(digest string) error { + if len(digest) != sha256HexLen { + return fmt.Errorf("%q is not %d hex characters", digest, sha256HexLen) + } + + if digest != strings.ToLower(digest) { + return fmt.Errorf("%q must be lowercase", digest) + } + + if _, err := hex.DecodeString(digest); err != nil { + return fmt.Errorf("%q is not hex: %w", digest, err) + } + + return nil +} diff --git a/pkg/ts/denolock/embed_ai_test.go b/pkg/ts/denolock/embed_ai_test.go new file mode 100644 index 00000000..4654b20f --- /dev/null +++ b/pkg/ts/denolock/embed_ai_test.go @@ -0,0 +1,95 @@ +//go:build ai_tests + +package denolock_test + +import ( + "encoding/hex" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/werf/nelm/pkg/ts/denolock" +) + +func TestAI_CommittedLock(t *testing.T) { + lock, err := denolock.Read() + require.NoError(t, err) + + t.Run("pins a version without the leading v", func(t *testing.T) { + assert.NotEmpty(t, lock.Version) + assert.False(t, strings.HasPrefix(lock.Version, "v")) + }) + + t.Run("pins a digest of both the archive and the binary for every platform", func(t *testing.T) { + require.NotEmpty(t, lock.Platforms) + + for platform, entry := range lock.Platforms { + assert.NotEmpty(t, entry.Target, platform) + + for name, digest := range map[string]string{"archive": entry.ArchiveSHA256, "binary": entry.BinarySHA256} { + assert.Len(t, digest, 64, "%s %s digest", platform, name) + assert.Equal(t, strings.ToLower(digest), digest, "%s %s digest must be lowercase", platform, name) + + _, err := hex.DecodeString(digest) + assert.NoError(t, err, "%s %s digest must be hex", platform, name) + } + } + }) + + t.Run("builds the release URL of the pinned version", func(t *testing.T) { + url, err := denolock.ArchiveURL("linux", "amd64") + require.NoError(t, err) + + assert.Equal(t, "https://github.com/denoland/deno/releases/download/v"+lock.Version+ + "/deno-"+lock.Platforms["linux/amd64"].Target+".zip", url) + }) + + t.Run("rejects a platform it does not pin", func(t *testing.T) { + _, err := denolock.Get("plan9", "mips") + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported platform: plan9/mips") + }) +} + +func TestAI_LockValidate(t *testing.T) { + const digest = "b7154ae42839d7b1453422e2f33c907e5c68fde8fe9f145cd43b8dd083671a6f" + + valid := func() *denolock.Lock { + return &denolock.Lock{ + Platforms: map[string]*denolock.Platform{ + "linux/amd64": {ArchiveSHA256: digest, BinarySHA256: digest, Target: "x86_64-unknown-linux-gnu"}, + }, + Version: "2.7.1", + } + } + + require.NoError(t, valid().Validate()) + + for name, mutate := range map[string]func(*denolock.Lock){ + "no version": func(l *denolock.Lock) { l.Version = "" }, + "version with leading v": func(l *denolock.Lock) { l.Version = "v2.7.1" }, + "no platforms": func(l *denolock.Lock) { l.Platforms = nil }, + "platform key without os": func(l *denolock.Lock) { + l.Platforms = map[string]*denolock.Platform{"amd64": l.Platforms["linux/amd64"]} + }, + "nil entry": func(l *denolock.Lock) { l.Platforms["linux/amd64"] = nil }, + "no target": func(l *denolock.Lock) { l.Platforms["linux/amd64"].Target = "" }, + "truncated archive digest": func(l *denolock.Lock) { + l.Platforms["linux/amd64"].ArchiveSHA256 = digest[:32] + }, + "uppercase binary digest": func(l *denolock.Lock) { + l.Platforms["linux/amd64"].BinarySHA256 = strings.ToUpper(digest) + }, + "non hex binary digest": func(l *denolock.Lock) { + l.Platforms["linux/amd64"].BinarySHA256 = strings.Repeat("z", 64) + }, + } { + t.Run("rejects "+name, func(t *testing.T) { + lock := valid() + mutate(lock) + assert.Error(t, lock.Validate()) + }) + } +} diff --git a/pkg/ts/downloader.go b/pkg/ts/downloader.go index 81c6cd5f..a46a2ffe 100644 --- a/pkg/ts/downloader.go +++ b/pkg/ts/downloader.go @@ -4,54 +4,50 @@ import ( "archive/zip" "context" "crypto/sha256" - "encoding/hex" "fmt" "hash/fnv" "io" "os" "path/filepath" - "strings" "time" - "github.com/go-resty/resty/v2" "github.com/gosimple/slug" "github.com/samber/lo" "github.com/werf/nelm/pkg/helm/pkg/helmpath" "github.com/werf/nelm/pkg/log" + "github.com/werf/nelm/pkg/ts/denolock" "github.com/werf/nelm/pkg/util" ) -const denoVersion = "2.7.1" - // DownloadDenoForPlatform downloads the pinned Deno release for an arbitrary // target platform into destDir and returns the path of the extracted binary. func DownloadDenoForPlatform(ctx context.Context, goos, goarch, destDir string) (string, error) { - link, err := getDownloadLink(goos, goarch) - if err != nil { - return "", fmt.Errorf("get download link: %w", err) - } - if err := os.MkdirAll(destDir, 0o755); err != nil { return "", fmt.Errorf("create destination directory: %w", err) } - if err := downloadDeno(ctx, destDir, link, goos); err != nil { + if err := downloadDeno(ctx, destDir, goos, goarch); err != nil { return "", fmt.Errorf("download deno: %w", err) } return filepath.Join(destDir, denoBinaryName(goos)), nil } -func downloadDeno(ctx context.Context, cacheDir, link, goos string) error { - httpClient := util.NewRestyClient(ctx) - httpClient.SetTimeout(15 * time.Minute) +func downloadDeno(ctx context.Context, cacheDir, goos, goarch string) error { + pinned, err := denolock.Get(goos, goarch) + if err != nil { + return fmt.Errorf("get the pinned Deno release: %w", err) + } - expectedHash, err := fetchExpectedChecksum(ctx, httpClient, link) + link, err := denolock.ArchiveURL(goos, goarch) if err != nil { - return fmt.Errorf("fetch checksum: %w", err) + return fmt.Errorf("get the pinned Deno release URL: %w", err) } + httpClient := util.NewRestyClient(ctx) + httpClient.SetTimeout(15 * time.Minute) + tmpDir, err := os.MkdirTemp(cacheDir, "download-*") if err != nil { return fmt.Errorf("create temp directory: %w", err) @@ -76,8 +72,8 @@ func downloadDeno(ctx context.Context, cacheDir, link, goos string) error { return fmt.Errorf("download Deno from %s: %s", link, response.Status()) } - if err := verifyChecksum(ctx, zipFile, expectedHash); err != nil { - return fmt.Errorf("verify checksum: %w", err) + if err := verifyChecksum(ctx, zipFile, pinned.ArchiveSHA256); err != nil { + return fmt.Errorf("verify downloaded archive against the digest pinned for %s/%s: %w", goos, goarch, err) } reader, err := zip.OpenReader(zipFile) @@ -106,6 +102,12 @@ func downloadDeno(ctx context.Context, cacheDir, link, goos string) error { tmpBinaryPath := filepath.Join(tmpDir, filepath.Base(file.Name)) finalPath := filepath.Join(cacheDir, filepath.Base(file.Name)) + // Redundant after the archive matched, but it is the digest the embedded blob is checked + // against, so a mismatch here would mean the two ways of getting Deno disagree. + if err := verifyChecksum(ctx, tmpBinaryPath, pinned.BinarySHA256); err != nil { + return fmt.Errorf("verify unpacked binary against the digest pinned for %s/%s: %w", goos, goarch, err) + } + if err := os.Rename(tmpBinaryPath, finalPath); err != nil { return fmt.Errorf("move Deno binary to cache: %w", err) } @@ -124,52 +126,10 @@ func downloadDeno(ctx context.Context, cacheDir, link, goos string) error { return nil } -func fetchExpectedChecksum(ctx context.Context, httpClient *resty.Client, archiveURL string) (string, error) { - checksumURL := archiveURL + ".sha256sum" - - log.Default.Debug(ctx, "Fetching Deno checksum from %s", checksumURL) - - response, err := httpClient.R().SetContext(ctx).Get(checksumURL) - if err != nil { - return "", fmt.Errorf("download checksum from %s: %w", checksumURL, err) - } - - if response.IsError() { - return "", fmt.Errorf("download checksum from %s: %s", checksumURL, response.Status()) - } - - hash, found := findChecksum(response.String()) - if !found { - return "", fmt.Errorf("unexpected checksum format from %s: %s", checksumURL, strings.TrimSpace(response.String())) - } - - return hash, nil -} - func denoBinaryName(goos string) string { return lo.Ternary(goos == "windows", "deno.exe", "deno") } -// findChecksum extracts a sha256 hex digest from a checksum file. Deno -// publishes plain " " for unix targets, but PowerShell -// Get-FileHash output ("Hash : ") for windows ones, so the -// digest is located by shape rather than by field position. -func findChecksum(body string) (string, bool) { - for _, field := range strings.Fields(body) { - if len(field) != 64 { - continue - } - - if _, err := hex.DecodeString(field); err != nil { - continue - } - - return strings.ToLower(field), true - } - - return "", false -} - func getDenoFolder(downloadURL string) (string, error) { hash := fnv.New32a() if _, err := hash.Write([]byte(downloadURL)); err != nil { @@ -194,26 +154,12 @@ func getDenoFolder(downloadURL string) (string, error) { } func getDownloadLink(goos, goarch string) (string, error) { - var target string - - switch { - case goos == "linux" && goarch == "amd64": - target = "x86_64-unknown-linux-gnu" - case goos == "linux" && goarch == "arm64": - target = "aarch64-unknown-linux-gnu" - case goos == "darwin" && goarch == "amd64": - target = "x86_64-apple-darwin" - case goos == "darwin" && goarch == "arm64": - target = "aarch64-apple-darwin" - case goos == "windows" && goarch == "amd64": - target = "x86_64-pc-windows-msvc" - default: - return "", fmt.Errorf("unsupported platform: %s/%s", goos, goarch) + link, err := denolock.ArchiveURL(goos, goarch) + if err != nil { + return "", fmt.Errorf("get the pinned Deno release URL: %w", err) } - url := fmt.Sprintf("https://github.com/denoland/deno/releases/download/v%s/deno-%s.zip", denoVersion, target) - - return url, nil + return link, nil } func unzipBinary(ctx context.Context, cacheDir string, file *zip.File) error { diff --git a/pkg/ts/embed_darwin_amd64.go b/pkg/ts/embed_darwin_amd64.go index 3064f7b9..6b283daa 100644 --- a/pkg/ts/embed_darwin_amd64.go +++ b/pkg/ts/embed_darwin_amd64.go @@ -4,10 +4,5 @@ package ts import _ "embed" -var ( - //go:embed embed/darwin/amd64/deno.sha256 - embeddedDenoSHA256 string - - //go:embed embed/darwin/amd64/deno.gz - embeddedDeno []byte -) +//go:embed embed/darwin/amd64/deno.gz +var embeddedDeno []byte diff --git a/pkg/ts/embed_darwin_arm64.go b/pkg/ts/embed_darwin_arm64.go index 2149604b..fd2746b9 100644 --- a/pkg/ts/embed_darwin_arm64.go +++ b/pkg/ts/embed_darwin_arm64.go @@ -4,10 +4,5 @@ package ts import _ "embed" -var ( - //go:embed embed/darwin/arm64/deno.sha256 - embeddedDenoSHA256 string - - //go:embed embed/darwin/arm64/deno.gz - embeddedDeno []byte -) +//go:embed embed/darwin/arm64/deno.gz +var embeddedDeno []byte diff --git a/pkg/ts/embed_linux_amd64.go b/pkg/ts/embed_linux_amd64.go index 20133bda..a7911b9b 100644 --- a/pkg/ts/embed_linux_amd64.go +++ b/pkg/ts/embed_linux_amd64.go @@ -4,10 +4,5 @@ package ts import _ "embed" -var ( - //go:embed embed/linux/amd64/deno.sha256 - embeddedDenoSHA256 string - - //go:embed embed/linux/amd64/deno.gz - embeddedDeno []byte -) +//go:embed embed/linux/amd64/deno.gz +var embeddedDeno []byte diff --git a/pkg/ts/embed_linux_arm64.go b/pkg/ts/embed_linux_arm64.go index 379d0ff8..e4d72347 100644 --- a/pkg/ts/embed_linux_arm64.go +++ b/pkg/ts/embed_linux_arm64.go @@ -4,10 +4,5 @@ package ts import _ "embed" -var ( - //go:embed embed/linux/arm64/deno.sha256 - embeddedDenoSHA256 string - - //go:embed embed/linux/arm64/deno.gz - embeddedDeno []byte -) +//go:embed embed/linux/arm64/deno.gz +var embeddedDeno []byte diff --git a/pkg/ts/embed_windows_amd64.go b/pkg/ts/embed_windows_amd64.go index 4a65d62f..b3dc918a 100644 --- a/pkg/ts/embed_windows_amd64.go +++ b/pkg/ts/embed_windows_amd64.go @@ -4,10 +4,5 @@ package ts import _ "embed" -var ( - //go:embed embed/windows/amd64/deno.sha256 - embeddedDenoSHA256 string - - //go:embed embed/windows/amd64/deno.gz - embeddedDeno []byte -) +//go:embed embed/windows/amd64/deno.gz +var embeddedDeno []byte diff --git a/pkg/ts/embedded.go b/pkg/ts/embedded.go index f2a91239..6c0e768d 100644 --- a/pkg/ts/embedded.go +++ b/pkg/ts/embedded.go @@ -4,10 +4,19 @@ package ts import ( "context" + "fmt" + "runtime" + + "github.com/werf/nelm/pkg/ts/denolock" ) func embeddedDenoBinary(ctx context.Context) (string, bool, error) { - path, err := ExtractEmbeddedDeno(ctx, embeddedDeno, embeddedDenoSHA256) + pinned, err := denolock.Get(runtime.GOOS, runtime.GOARCH) + if err != nil { + return "", false, fmt.Errorf("get the pinned Deno release: %w", err) + } + + path, err := ExtractEmbeddedDeno(ctx, embeddedDeno, pinned.BinarySHA256) if err != nil { return "", false, err } diff --git a/pkg/ts/embedded_deno_ai_test.go b/pkg/ts/embedded_deno_ai_test.go index 9a597a9f..964d081d 100644 --- a/pkg/ts/embedded_deno_ai_test.go +++ b/pkg/ts/embedded_deno_ai_test.go @@ -82,35 +82,6 @@ func TestAI_ExtractEmbeddedDeno(t *testing.T) { }) } -func TestAI_FindChecksum(t *testing.T) { - const digest = "b7154ae42839d7b1453422e2f33c907e5c68fde8fe9f145cd43b8dd083671a6f" - - t.Run("parses plain sha256sum output", func(t *testing.T) { - hash, found := ts.FindChecksum(digest + " deno-x86_64-unknown-linux-gnu.zip\n") - require.True(t, found) - assert.Equal(t, digest, hash) - }) - - t.Run("parses PowerShell Get-FileHash output used for windows", func(t *testing.T) { - body := "\r\nAlgorithm : SHA256\r\nHash : " + strings.ToUpper(digest) + - "\r\nPath : C:\\a\\deno\\deno\\target\\release\\deno-x86_64-pc-windows-msvc.zip\r\n\r\n" - - hash, found := ts.FindChecksum(body) - require.True(t, found) - assert.Equal(t, digest, hash, "must be normalized to lowercase") - }) - - t.Run("reports not found when no digest is present", func(t *testing.T) { - _, found := ts.FindChecksum("Algorithm : SHA256\r\nPath : nowhere\r\n") - assert.False(t, found) - }) - - t.Run("ignores 64-char non-hex tokens", func(t *testing.T) { - _, found := ts.FindChecksum(strings.Repeat("z", 64)) - assert.False(t, found) - }) -} - func gzipPayload(t *testing.T, payload []byte) ([]byte, string) { t.Helper() diff --git a/pkg/ts/export_test.go b/pkg/ts/export_test.go index 121af0a0..8d12b248 100644 --- a/pkg/ts/export_test.go +++ b/pkg/ts/export_test.go @@ -4,7 +4,6 @@ var ( ChartTSBuildScript = denoBuildScript ChartTSDevScript = denoDevScript ChartTSStartScript = denoStartScript - FindChecksum = findChecksum GetDenoBinary = getDenoBinary GetDownloadLink = getDownloadLink GetDenoFolder = getDenoFolder diff --git a/pkg/ts/platforms_sync_ai_test.go b/pkg/ts/platforms_sync_ai_test.go index 10636aa7..2ba8f6ae 100644 --- a/pkg/ts/platforms_sync_ai_test.go +++ b/pkg/ts/platforms_sync_ai_test.go @@ -3,16 +3,22 @@ package ts_test import ( + "maps" "os" "path/filepath" "regexp" + "slices" "sort" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/werf/nelm/pkg/ts/denolock" ) +// A platform missing from any of the three lists is a release binary that silently falls back to +// downloading Deno at run time. func TestAI_EmbedPlatformListsInSync(t *testing.T) { embedFiles, err := filepath.Glob("embed_*.go") require.NoError(t, err) @@ -29,17 +35,10 @@ func TestAI_EmbedPlatformListsInSync(t *testing.T) { require.NotEmpty(t, embedPlatforms) sort.Strings(embedPlatforms) - generatorSrc, err := os.ReadFile(filepath.Join("..", "..", "cmd", "embed-deno", "main.go")) + lock, err := denolock.Read() require.NoError(t, err) - generatorRe := regexp.MustCompile(`\{"([a-z0-9]+)", "([a-z0-9]+)"\}`) - - var generatorPlatforms []string - for _, m := range generatorRe.FindAllStringSubmatch(string(generatorSrc), -1) { - generatorPlatforms = append(generatorPlatforms, m[1]+"/"+m[2]) - } - - sort.Strings(generatorPlatforms) + lockPlatforms := slices.Sorted(maps.Keys(lock.Platforms)) taskfileSrc, err := os.ReadFile(filepath.Join("..", "..", "Taskfile.dist.yaml")) require.NoError(t, err) @@ -53,6 +52,8 @@ func TestAI_EmbedPlatformListsInSync(t *testing.T) { sort.Strings(taskfilePlatforms) - assert.Equal(t, embedPlatforms, generatorPlatforms, "cmd/embed-deno platforms whitelist out of sync with pkg/ts/embed_*.go files") - assert.Equal(t, embedPlatforms, taskfilePlatforms, "Taskfile.dist.yaml deno:embed platforms out of sync with pkg/ts/embed_*.go files") + assert.Equal(t, embedPlatforms, lockPlatforms, + "pkg/ts/denolock/data/lock.json platforms out of sync with pkg/ts/embed_*.go files, run: task deno:lock") + assert.Equal(t, embedPlatforms, taskfilePlatforms, + "Taskfile.dist.yaml deno:embed platforms out of sync with pkg/ts/embed_*.go files") } diff --git a/scripts/denolock/checksum_ai_test.go b/scripts/denolock/checksum_ai_test.go new file mode 100644 index 00000000..3e2d8d1c --- /dev/null +++ b/scripts/denolock/checksum_ai_test.go @@ -0,0 +1,40 @@ +//go:build ai_tests + +package main + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAI_FindChecksum(t *testing.T) { + const digest = "b7154ae42839d7b1453422e2f33c907e5c68fde8fe9f145cd43b8dd083671a6f" + + t.Run("parses plain sha256sum output", func(t *testing.T) { + hash, found := findChecksum(digest + " deno-x86_64-unknown-linux-gnu.zip\n") + require.True(t, found) + assert.Equal(t, digest, hash) + }) + + t.Run("parses PowerShell Get-FileHash output used for windows", func(t *testing.T) { + body := "\r\nAlgorithm : SHA256\r\nHash : " + strings.ToUpper(digest) + + "\r\nPath : C:\\a\\deno\\deno\\target\\release\\deno-x86_64-pc-windows-msvc.zip\r\n\r\n" + + hash, found := findChecksum(body) + require.True(t, found) + assert.Equal(t, digest, hash, "must be normalized to lowercase") + }) + + t.Run("reports not found when no digest is present", func(t *testing.T) { + _, found := findChecksum("Algorithm : SHA256\r\nPath : nowhere\r\n") + assert.False(t, found) + }) + + t.Run("ignores 64-char non-hex tokens", func(t *testing.T) { + _, found := findChecksum(strings.Repeat("z", 64)) + assert.False(t, found) + }) +} diff --git a/scripts/denolock/download.go b/scripts/denolock/download.go new file mode 100644 index 00000000..1c473d46 --- /dev/null +++ b/scripts/denolock/download.go @@ -0,0 +1,179 @@ +package main + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/werf/nelm/pkg/ts/denolock" +) + +const ( + downloadAttempts = 3 + retryDelay = 3 * time.Second + sha256HexLen = 64 +) + +func download(ctx context.Context, client *http.Client, url string) ([]byte, error) { + var lastErr error + + for attempt := 1; attempt <= downloadAttempts; attempt++ { + body, retriable, err := getOnce(ctx, client, url) + if err == nil { + return body, nil + } + + lastErr = err + + if !retriable || ctx.Err() != nil { + break + } + + if attempt < downloadAttempts { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(retryDelay): + } + } + } + + return nil, lastErr +} + +func fetchUpstreamChecksum(ctx context.Context, client *http.Client, archiveURL string) (string, error) { + checksumURL := archiveURL + denolock.ChecksumURLSuffix + + body, err := download(ctx, client, checksumURL) + if err != nil { + return "", err + } + + digest, found := findChecksum(string(body)) + if !found { + return "", fmt.Errorf("no sha256 digest in %s: %s", checksumURL, strings.TrimSpace(string(body))) + } + + return digest, nil +} + +// findChecksum extracts a sha256 hex digest from a checksum file. Deno publishes plain +// " " for unix targets, but PowerShell Get-FileHash output ("Hash : ") for +// windows ones, so the digest is located by shape rather than by field position. +func findChecksum(body string) (string, bool) { + for _, field := range strings.Fields(body) { + if len(field) != sha256HexLen { + continue + } + + if _, err := hex.DecodeString(field); err != nil { + continue + } + + return strings.ToLower(field), true + } + + return "", false +} + +func extractDenoBinary(archive []byte, goos string) ([]byte, error) { + reader, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + return nil, fmt.Errorf("open archive: %w", err) + } + + binaryName := "deno" + if goos == "windows" { + binaryName = "deno.exe" + } + + for _, file := range reader.File { + if file.Name != binaryName { + continue + } + + fileReader, err := file.Open() + if err != nil { + return nil, fmt.Errorf("open %s in archive: %w", binaryName, err) + } + + defer fileReader.Close() + + binary, err := io.ReadAll(fileReader) + if err != nil { + return nil, fmt.Errorf("read %s from archive: %w", binaryName, err) + } + + return binary, nil + } + + return nil, fmt.Errorf("no %s in archive", binaryName) +} + +func latestVersion(ctx context.Context, client *http.Client) (string, error) { + body, err := download(ctx, client, latestReleaseURL) + if err != nil { + return "", err + } + + var release struct { + TagName string `json:"tag_name"` + } + + if err := json.Unmarshal(body, &release); err != nil { + return "", fmt.Errorf("decode response of %s: %w", latestReleaseURL, err) + } + + if release.TagName == "" { + return "", fmt.Errorf("%s reports no tag name", latestReleaseURL) + } + + return strings.TrimPrefix(release.TagName, "v"), nil +} + +func sha256Hex(data []byte) string { + digest := sha256.Sum256(data) + + return hex.EncodeToString(digest[:]) +} + +func getOnce(ctx context.Context, client *http.Client, url string) ([]byte, bool, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, false, fmt.Errorf("create request for %s: %w", url, err) + } + + // Authenticating lifts the GitHub API rate limit, which is easy to hit on shared CI runners. + if token := os.Getenv("GITHUB_TOKEN"); token != "" && strings.HasPrefix(url, "https://api.github.com/") { + request.Header.Set("Authorization", "Bearer "+token) + } + + response, err := client.Do(request) + if err != nil { + return nil, true, fmt.Errorf("get %s: %w", url, err) + } + + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, true, fmt.Errorf("read response of %s: %w", url, err) + } + + if response.StatusCode != http.StatusOK { + retriable := response.StatusCode >= http.StatusInternalServerError || response.StatusCode == http.StatusTooManyRequests + + return nil, retriable, fmt.Errorf("get %s: unexpected status %s", url, response.Status) + } + + return body, false, nil +} diff --git a/scripts/denolock/main.go b/scripts/denolock/main.go new file mode 100644 index 00000000..3a06f5b0 --- /dev/null +++ b/scripts/denolock/main.go @@ -0,0 +1,334 @@ +// Command denolock pins the Deno release nelm runs TypeScript charts with: it downloads every +// platform's release archive, records its sha256 and the sha256 of the binary inside it, and writes +// pkg/ts/denolock/data/lock.json, which is committed and embedded into the binary. The generated +// lock is the only place the Deno version is configured. See pkg/ts/denolock/data/README.md. +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "maps" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "time" + + "github.com/dustin/go-humanize" + + "github.com/werf/nelm/pkg/log" + "github.com/werf/nelm/pkg/ts/denolock" +) + +const ( + defaultOutputPath = "pkg/ts/denolock/data/lock.json" + defaultParallelism = 5 + latestReleaseURL = "https://api.github.com/repos/denoland/deno/releases/latest" + requestTimeout = 15 * time.Minute +) + +// targets is the canonical platform list: the lock is generated from it and -verify checks the +// committed lock against it. Adding a platform also needs a pkg/ts/embed__.go. +var targets = map[string]string{ + "darwin/amd64": "x86_64-apple-darwin", + "darwin/arm64": "aarch64-apple-darwin", + "linux/amd64": "x86_64-unknown-linux-gnu", + "linux/arm64": "aarch64-unknown-linux-gnu", + "windows/amd64": "x86_64-pc-windows-msvc", +} + +type options struct { + checkUpstream bool + outputPath string + parallelism int + verify bool + version string +} + +type platformResult struct { + archiveSize int64 + entry *denolock.Platform + platform string +} + +func run(ctx context.Context) error { + opts := parseFlags() + + if opts.verify { + return verifyLock(ctx, opts) + } + + client := &http.Client{Timeout: requestTimeout} + + if opts.checkUpstream { + return checkUpstream(ctx, client, opts) + } + + // Zero would deadlock the download semaphore and a negative value would panic building it. + if opts.parallelism < 1 { + return fmt.Errorf("parallelism must be positive, got %d", opts.parallelism) + } + + return generate(ctx, client, opts) +} + +func generate(ctx context.Context, client *http.Client, opts options) error { + version, err := resolveVersion(opts) + if err != nil { + return err + } + + log.Default.Info(ctx, "Pinning Deno %s for %d platforms", version, len(targets)) + + platforms := slices.Sorted(maps.Keys(targets)) + results := make([]*platformResult, len(platforms)) + errs := make([]error, len(platforms)) + + semaphore := make(chan struct{}, opts.parallelism) + + var wg sync.WaitGroup + + for i, platform := range platforms { + wg.Add(1) + + go func() { + defer wg.Done() + + semaphore <- struct{}{} + defer func() { <-semaphore }() + + result, err := pinPlatform(ctx, client, version, platform) + if err != nil { + errs[i] = fmt.Errorf("pin %s: %w", platform, err) + + return + } + + results[i] = result + }() + } + + wg.Wait() + + if err := errors.Join(errs...); err != nil { + return err + } + + lock := &denolock.Lock{ + Platforms: make(map[string]*denolock.Platform, len(results)), + Version: version, + } + + for _, result := range results { + lock.Platforms[result.platform] = result.entry + + log.Default.Info(ctx, "Pinned %s: archive %s (%s), binary %s", + result.platform, result.entry.ArchiveSHA256[:12], humanize.IBytes(uint64(result.archiveSize)), + result.entry.BinarySHA256[:12]) + } + + if err := lock.Validate(); err != nil { + return fmt.Errorf("generated lock is invalid: %w", err) + } + + if err := writeLock(opts.outputPath, lock); err != nil { + return err + } + + log.Default.Info(ctx, "Wrote %s: review the digests in the diff, that is what the pinning is for", opts.outputPath) + + return nil +} + +func pinPlatform(ctx context.Context, client *http.Client, version, platform string) (*platformResult, error) { + goos, _, _ := strings.Cut(platform, "/") + target := targets[platform] + archiveURL := denolock.ArchiveURLFor(version, target) + + log.Default.Info(ctx, "Downloading %s", archiveURL) + + archive, err := download(ctx, client, archiveURL) + if err != nil { + return nil, err + } + + archiveSHA256 := sha256Hex(archive) + + // Useless as a trust anchor, but it is the only thing that can tell an intact download from a + // broken one at the moment the digest is taken. + upstreamSHA256, err := fetchUpstreamChecksum(ctx, client, archiveURL) + if err != nil { + return nil, err + } + + if upstreamSHA256 != archiveSHA256 { + return nil, fmt.Errorf("downloaded archive hashes to %s, but upstream publishes %s: refusing to pin a download upstream does not vouch for", + archiveSHA256, upstreamSHA256) + } + + binary, err := extractDenoBinary(archive, goos) + if err != nil { + return nil, err + } + + return &platformResult{ + archiveSize: int64(len(archive)), + entry: &denolock.Platform{ + ArchiveSHA256: archiveSHA256, + BinarySHA256: sha256Hex(binary), + Target: target, + }, + platform: platform, + }, nil +} + +// checkUpstream reports whether the release assets the lock pins still hash to what it records: +// GitHub lets an asset be replaced without moving its tag. It compares the published checksums +// rather than downloading a few hundred megabytes, which is enough to notice a swap but is not +// verification — that is the lock's job. +func checkUpstream(ctx context.Context, client *http.Client, opts options) error { + lock, err := readLock(opts.outputPath) + if err != nil { + return err + } + + var drifted []string + + for _, platform := range slices.Sorted(maps.Keys(lock.Platforms)) { + entry := lock.Platforms[platform] + archiveURL := denolock.ArchiveURLFor(lock.Version, entry.Target) + + upstreamSHA256, err := fetchUpstreamChecksum(ctx, client, archiveURL) + if err != nil { + return err + } + + if upstreamSHA256 != entry.ArchiveSHA256 { + drifted = append(drifted, fmt.Sprintf("%s now publishes %s, the lock pins %s", platform, upstreamSHA256, entry.ArchiveSHA256)) + } + } + + if len(drifted) > 0 { + return fmt.Errorf("the Deno %s release assets changed after the lock was generated: %s. Nothing legitimate replaces a published release asset: investigate before running \"task deno:lock\"", + lock.Version, strings.Join(drifted, "; ")) + } + + // Not drift, just a bump available, so it is reported rather than failed on. + if latest, err := latestVersion(ctx, client); err != nil { + log.Default.Warn(ctx, "Could not check for a newer Deno release: %s", err) + } else if latest != lock.Version { + log.Default.Info(ctx, "Deno %s is available upstream, pinned is %s", latest, lock.Version) + } + + log.Default.Info(ctx, "The Deno %s release assets still match the lock, for all %d platforms", lock.Version, len(lock.Platforms)) + + return nil +} + +// verifyLock checks the committed lock offline. Whether the digests describe the real Deno release +// needs the network, which is what -check-upstream is for. +func verifyLock(ctx context.Context, opts options) error { + lock, err := readLock(opts.outputPath) + if err != nil { + return err + } + + want := slices.Sorted(maps.Keys(targets)) + got := slices.Sorted(maps.Keys(lock.Platforms)) + + if !slices.Equal(want, got) { + return fmt.Errorf("%s pins %s, but nelm builds for %s, run: task deno:lock", + opts.outputPath, strings.Join(got, ", "), strings.Join(want, ", ")) + } + + for _, platform := range got { + if recorded, want := lock.Platforms[platform].Target, targets[platform]; recorded != want { + return fmt.Errorf("%s records %s as target %q, but it is %q, run: task deno:lock", + opts.outputPath, platform, recorded, want) + } + } + + log.Default.Info(ctx, "The Deno lock is consistent: %s, for %s", lock.Version, strings.Join(got, ", ")) + + return nil +} + +func resolveVersion(opts options) (string, error) { + if opts.version != "" { + return strings.TrimPrefix(opts.version, "v"), nil + } + + lock, err := readLock(opts.outputPath) + if err != nil { + return "", fmt.Errorf("%w, and no -version given to pin instead", err) + } + + return lock.Version, nil +} + +// readLock reads the file in the working tree, not the copy embedded into this binary. +func readLock(path string) (*denolock.Lock, error) { + lockBytes, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + + var lock denolock.Lock + + if err := json.Unmarshal(lockBytes, &lock); err != nil { + return nil, fmt.Errorf("decode %s: %w", path, err) + } + + if err := lock.Validate(); err != nil { + return nil, fmt.Errorf("invalid %s: %w", path, err) + } + + return &lock, nil +} + +func writeLock(path string, lock *denolock.Lock) error { + lockBytes, err := json.MarshalIndent(lock, "", " ") + if err != nil { + return fmt.Errorf("encode lock: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create %s: %w", filepath.Dir(path), err) + } + + if err := os.WriteFile(path, append(lockBytes, '\n'), 0o644); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + + return nil +} + +func parseFlags() options { + var opts options + + flag.BoolVar(&opts.checkUpstream, "check-upstream", false, + "Report whether the release assets the lock pins still hash to what it records, and exit") + flag.StringVar(&opts.outputPath, "output", defaultOutputPath, "Lock file to write, verify or check") + flag.IntVar(&opts.parallelism, "parallelism", defaultParallelism, "How many platforms to download concurrently") + flag.BoolVar(&opts.verify, "verify", false, + "Check the committed lock against the platforms nelm builds for and exit, without downloading anything") + flag.StringVar(&opts.version, "version", "", + "Deno version to pin, with or without the leading \"v\". Defaults to the version the lock already records") + flag.Parse() + + return opts +} + +func main() { + ctx := context.Background() + + if err := run(ctx); err != nil { + log.Default.Error(ctx, "Error: %s", err) + os.Exit(1) + } +} From bd6de22800ec94ca79db9b3604acd91b5d612ec9 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Sat, 1 Aug 2026 02:50:09 +0300 Subject: [PATCH 63/85] refactor: move embed-deno to scripts and drop the redundant sha option cmd/ now holds only what ships. embed-deno is build tooling like schemagen and denolock, and it is invoked as "go run /scripts/embed-deno", which werf has to follow. TypeScriptOptions.EmbeddedDenoSHA256 is gone. An embedder can only hand over the release pkg/ts/denolock pins - it downloads it through nelm's own embed-deno - so nelm looks the digest up itself instead of trusting one passed alongside the bytes. That closes the last path where a digest came from outside the repository, and drops a field embedders had to fill correctly for the check to mean anything. ExtractEmbeddedDeno is unexported for the same reason: with the digest no longer supplied by the caller, nothing outside pkg/ts has a use for it. Signed-off-by: Dmitry Mordvinov --- Taskfile.dist.yaml | 4 +-- pkg/common/common.go | 5 ++-- pkg/ts/deno.go | 10 +++++-- pkg/ts/deno_binary_ai_test.go | 41 ++++------------------------- pkg/ts/embedded.go | 2 +- pkg/ts/embedded_deno.go | 4 +-- pkg/ts/export_test.go | 15 ++++++----- {cmd => scripts}/embed-deno/main.go | 0 8 files changed, 29 insertions(+), 52 deletions(-) rename {cmd => scripts}/embed-deno/main.go (100%) diff --git a/Taskfile.dist.yaml b/Taskfile.dist.yaml index af8df136..f4253ef8 100644 --- a/Taskfile.dist.yaml +++ b/Taskfile.dist.yaml @@ -312,11 +312,11 @@ tasks: sources: - pkg/ts/downloader.go - pkg/ts/denolock/data/lock.json - - cmd/embed-deno/main.go + - scripts/embed-deno/main.go generates: - pkg/ts/embed/{{.platform}}/deno.gz cmds: - - env -u GOOS -u GOARCH DENO_EMBED_PLATFORM="{{.platform}}" go run ./cmd/embed-deno + - env -u GOOS -u GOARCH DENO_EMBED_PLATFORM="{{.platform}}" go run ./scripts/embed-deno deno:lock: desc: "Re-pin the Deno release nelm downloads and embeds, and commit the result. Bump with: task deno:lock -- -version 2.8.0." diff --git a/pkg/common/common.go b/pkg/common/common.go index 57e761b8..14e2f864 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -298,9 +298,10 @@ type ChartLoadOptions struct { } type TypeScriptOptions struct { - DenoBinaryPath string + DenoBinaryPath string + // EmbeddedDenoCompressed is a gzipped Deno binary an embedder ships instead of letting nelm + // download one. It must be the release pkg/ts/denolock pins, which is what it is checked against. EmbeddedDenoCompressed []byte - EmbeddedDenoSHA256 string } type helmOptionsContextKey struct{} diff --git a/pkg/ts/deno.go b/pkg/ts/deno.go index 168ac403..35f7eddc 100644 --- a/pkg/ts/deno.go +++ b/pkg/ts/deno.go @@ -18,6 +18,7 @@ import ( helmchart "github.com/werf/nelm/pkg/helm/pkg/chart" chartcommon "github.com/werf/nelm/pkg/helm/pkg/chart/common" "github.com/werf/nelm/pkg/log" + "github.com/werf/nelm/pkg/ts/denolock" ) var chartTSEntryPoints = [...]string{common.ChartTSEntryPointTS, common.ChartTSEntryPointJS} @@ -167,8 +168,13 @@ func getDenoBinary(ctx context.Context, binaryPath string) (string, error) { return binaryPath, nil } - if opts := GetTSOptionsFromContext(ctx); len(opts.EmbeddedDenoCompressed) > 0 || opts.EmbeddedDenoSHA256 != "" { - path, err := ExtractEmbeddedDeno(ctx, opts.EmbeddedDenoCompressed, opts.EmbeddedDenoSHA256) + if opts := GetTSOptionsFromContext(ctx); len(opts.EmbeddedDenoCompressed) > 0 { + pinned, err := denolock.Get(runtime.GOOS, runtime.GOARCH) + if err != nil { + return "", fmt.Errorf("get the pinned Deno release: %w", err) + } + + path, err := extractEmbeddedDeno(ctx, opts.EmbeddedDenoCompressed, pinned.BinarySHA256) if err != nil { return "", fmt.Errorf("extract embedded Deno binary: %w", err) } diff --git a/pkg/ts/deno_binary_ai_test.go b/pkg/ts/deno_binary_ai_test.go index 74770f3c..c6077599 100644 --- a/pkg/ts/deno_binary_ai_test.go +++ b/pkg/ts/deno_binary_ai_test.go @@ -22,7 +22,7 @@ import ( func TestAI_GetDenoBinaryCtxEmbeddedData(t *testing.T) { payload := []byte("#!/bin/sh\necho fake-deno\n") - compressed, sha := gzipPayload(t, payload) + compressed, _ := gzipPayload(t, payload) t.Run("explicit binary path wins over ctx data without extraction", func(t *testing.T) { cacheHome := t.TempDir() @@ -33,7 +33,6 @@ func TestAI_GetDenoBinaryCtxEmbeddedData(t *testing.T) { ctx := ts.NewContextWithTSOptions(context.Background(), common.TypeScriptOptions{ EmbeddedDenoCompressed: compressed, - EmbeddedDenoSHA256: sha, }) path, err := ts.GetDenoBinary(ctx, explicit) @@ -42,38 +41,9 @@ func TestAI_GetDenoBinaryCtxEmbeddedData(t *testing.T) { assertNoEmbeddedCacheDirs(t, cacheHome) }) - t.Run("complete ctx data extracts to content-addressed path", func(t *testing.T) { - t.Setenv("XDG_CACHE_HOME", t.TempDir()) - - ctx := ts.NewContextWithTSOptions(context.Background(), common.TypeScriptOptions{ - EmbeddedDenoCompressed: compressed, - EmbeddedDenoSHA256: sha, - }) - - path, err := ts.GetDenoBinary(ctx, "") - require.NoError(t, err) - assert.Contains(t, path, "embedded-"+sha[:16]) - - content, err := os.ReadFile(path) - require.NoError(t, err) - assert.Equal(t, payload, content) - }) - - t.Run("sha without bytes errors instead of downloading", func(t *testing.T) { - cacheHome := t.TempDir() - t.Setenv("XDG_CACHE_HOME", cacheHome) - - ctx := ts.NewContextWithTSOptions(context.Background(), common.TypeScriptOptions{ - EmbeddedDenoSHA256: sha, - }) - - path, err := ts.GetDenoBinary(ctx, "") - require.Error(t, err) - assert.Contains(t, err.Error(), "extract embedded Deno binary") - assert.Empty(t, path) - }) - - t.Run("bytes without sha errors mentioning checksum format", func(t *testing.T) { + // An embedder can only hand over the release the lock pins, so bytes that are not it are rejected + // rather than run. + t.Run("ctx bytes that are not the pinned release are rejected", func(t *testing.T) { cacheHome := t.TempDir() t.Setenv("XDG_CACHE_HOME", cacheHome) @@ -83,7 +53,7 @@ func TestAI_GetDenoBinaryCtxEmbeddedData(t *testing.T) { path, err := ts.GetDenoBinary(ctx, "") require.Error(t, err) - assert.Contains(t, err.Error(), "checksum format") + assert.Contains(t, err.Error(), "integrity check failed") assert.Empty(t, path) }) @@ -116,7 +86,6 @@ func TestAI_GetDenoBinaryCtxEmbeddedData(t *testing.T) { ctx := ts.NewContextWithTSOptions(context.Background(), common.TypeScriptOptions{ EmbeddedDenoCompressed: []byte("not gzip at all"), - EmbeddedDenoSHA256: "short-and-invalid", }) chrt := &v2chart.Chart{Metadata: &v2chart.Metadata{Name: "no-ts", Version: "0.1.0", APIVersion: "v2"}} diff --git a/pkg/ts/embedded.go b/pkg/ts/embedded.go index 6c0e768d..1d4c41d4 100644 --- a/pkg/ts/embedded.go +++ b/pkg/ts/embedded.go @@ -16,7 +16,7 @@ func embeddedDenoBinary(ctx context.Context) (string, bool, error) { return "", false, fmt.Errorf("get the pinned Deno release: %w", err) } - path, err := ExtractEmbeddedDeno(ctx, embeddedDeno, pinned.BinarySHA256) + path, err := extractEmbeddedDeno(ctx, embeddedDeno, pinned.BinarySHA256) if err != nil { return "", false, err } diff --git a/pkg/ts/embedded_deno.go b/pkg/ts/embedded_deno.go index 23b7a53c..748ea809 100644 --- a/pkg/ts/embedded_deno.go +++ b/pkg/ts/embedded_deno.go @@ -19,10 +19,10 @@ import ( "github.com/werf/nelm/pkg/log" ) -// ExtractEmbeddedDeno decompresses an embedded Deno binary into a cache +// extractEmbeddedDeno decompresses an embedded Deno binary into a cache // directory keyed by expectedSHA256 and returns the path of the extracted // binary. expectedSHA256 must be the checksum of the decompressed binary. -func ExtractEmbeddedDeno(ctx context.Context, compressedDeno []byte, expectedSHA256 string) (string, error) { +func extractEmbeddedDeno(ctx context.Context, compressedDeno []byte, expectedSHA256 string) (string, error) { expectedSHA256 = strings.ToLower(strings.TrimSpace(expectedSHA256)) if _, err := hex.DecodeString(expectedSHA256); err != nil || len(expectedSHA256) != 64 { return "", fmt.Errorf("unexpected embedded deno checksum format: %q", expectedSHA256) diff --git a/pkg/ts/export_test.go b/pkg/ts/export_test.go index 8d12b248..5d78b616 100644 --- a/pkg/ts/export_test.go +++ b/pkg/ts/export_test.go @@ -1,11 +1,12 @@ package ts var ( - ChartTSBuildScript = denoBuildScript - ChartTSDevScript = denoDevScript - ChartTSStartScript = denoStartScript - GetDenoBinary = getDenoBinary - GetDownloadLink = getDownloadLink - GetDenoFolder = getDenoFolder - DenoBinaryName = denoBinaryName + ChartTSBuildScript = denoBuildScript + ChartTSDevScript = denoDevScript + ChartTSStartScript = denoStartScript + ExtractEmbeddedDeno = extractEmbeddedDeno + GetDenoBinary = getDenoBinary + GetDownloadLink = getDownloadLink + GetDenoFolder = getDenoFolder + DenoBinaryName = denoBinaryName ) diff --git a/cmd/embed-deno/main.go b/scripts/embed-deno/main.go similarity index 100% rename from cmd/embed-deno/main.go rename to scripts/embed-deno/main.go From 0ed2a27bdefc51e452c8f1ce99c2f45fc3bf0887 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Sat, 1 Aug 2026 03:13:04 +0300 Subject: [PATCH 64/85] refactor: clear comments Signed-off-by: Dmitry Mordvinov --- pkg/common/common.go | 4 +--- pkg/ts/deno_binary_ai_test.go | 2 -- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/pkg/common/common.go b/pkg/common/common.go index 14e2f864..2aaef244 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -298,9 +298,7 @@ type ChartLoadOptions struct { } type TypeScriptOptions struct { - DenoBinaryPath string - // EmbeddedDenoCompressed is a gzipped Deno binary an embedder ships instead of letting nelm - // download one. It must be the release pkg/ts/denolock pins, which is what it is checked against. + DenoBinaryPath string EmbeddedDenoCompressed []byte } diff --git a/pkg/ts/deno_binary_ai_test.go b/pkg/ts/deno_binary_ai_test.go index c6077599..cd6a1879 100644 --- a/pkg/ts/deno_binary_ai_test.go +++ b/pkg/ts/deno_binary_ai_test.go @@ -41,8 +41,6 @@ func TestAI_GetDenoBinaryCtxEmbeddedData(t *testing.T) { assertNoEmbeddedCacheDirs(t, cacheHome) }) - // An embedder can only hand over the release the lock pins, so bytes that are not it are rejected - // rather than run. t.Run("ctx bytes that are not the pinned release are rejected", func(t *testing.T) { cacheHome := t.TempDir() t.Setenv("XDG_CACHE_HOME", cacheHome) From 55abe9fe04f0d26d06598249faa8b289f2a11895 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Sat, 1 Aug 2026 12:54:23 +0300 Subject: [PATCH 65/85] chore: add comment about unsupported platform Signed-off-by: Dmitry Mordvinov --- pkg/ts/embedded.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/ts/embedded.go b/pkg/ts/embedded.go index 1d4c41d4..1988cb86 100644 --- a/pkg/ts/embedded.go +++ b/pkg/ts/embedded.go @@ -10,6 +10,8 @@ import ( "github.com/werf/nelm/pkg/ts/denolock" ) +// There is a blob only for the platforms denolock pins, so a tagged build for any other one fails +// with "undefined: embeddedDeno". func embeddedDenoBinary(ctx context.Context) (string, bool, error) { pinned, err := denolock.Get(runtime.GOOS, runtime.GOARCH) if err != nil { From fa56ffe91cd95ecb02aa04c701e914a4f240a7cf Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Sat, 1 Aug 2026 16:12:06 +0300 Subject: [PATCH 66/85] refactor: use restify instead of http for denolock client Signed-off-by: Dmitry Mordvinov --- scripts/denolock/download.go | 81 +++++++----------------------------- scripts/denolock/main.go | 14 ++++--- 2 files changed, 23 insertions(+), 72 deletions(-) diff --git a/scripts/denolock/download.go b/scripts/denolock/download.go index 1c473d46..c3deac3c 100644 --- a/scripts/denolock/download.go +++ b/scripts/denolock/download.go @@ -9,51 +9,32 @@ import ( "encoding/json" "fmt" "io" - "net/http" - "os" "strings" - "time" - "github.com/werf/nelm/pkg/ts/denolock" -) + "github.com/go-resty/resty/v2" -const ( - downloadAttempts = 3 - retryDelay = 3 * time.Second - sha256HexLen = 64 + "github.com/werf/nelm/pkg/ts/denolock" ) -func download(ctx context.Context, client *http.Client, url string) ([]byte, error) { - var lastErr error +const sha256HexLen = 64 - for attempt := 1; attempt <= downloadAttempts; attempt++ { - body, retriable, err := getOnce(ctx, client, url) - if err == nil { - return body, nil - } - - lastErr = err - - if !retriable || ctx.Err() != nil { - break - } +func get(ctx context.Context, client *resty.Client, url string) ([]byte, error) { + response, err := client.R().SetContext(ctx).Get(url) + if err != nil { + return nil, fmt.Errorf("get %s: %w", url, err) + } - if attempt < downloadAttempts { - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(retryDelay): - } - } + if response.IsError() { + return nil, fmt.Errorf("get %s: %s", url, response.Status()) } - return nil, lastErr + return response.Body(), nil } -func fetchUpstreamChecksum(ctx context.Context, client *http.Client, archiveURL string) (string, error) { +func fetchUpstreamChecksum(ctx context.Context, client *resty.Client, archiveURL string) (string, error) { checksumURL := archiveURL + denolock.ChecksumURLSuffix - body, err := download(ctx, client, checksumURL) + body, err := get(ctx, client, checksumURL) if err != nil { return "", err } @@ -119,8 +100,8 @@ func extractDenoBinary(archive []byte, goos string) ([]byte, error) { return nil, fmt.Errorf("no %s in archive", binaryName) } -func latestVersion(ctx context.Context, client *http.Client) (string, error) { - body, err := download(ctx, client, latestReleaseURL) +func latestVersion(ctx context.Context, client *resty.Client) (string, error) { + body, err := get(ctx, client, latestReleaseURL) if err != nil { return "", err } @@ -145,35 +126,3 @@ func sha256Hex(data []byte) string { return hex.EncodeToString(digest[:]) } - -func getOnce(ctx context.Context, client *http.Client, url string) ([]byte, bool, error) { - request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, false, fmt.Errorf("create request for %s: %w", url, err) - } - - // Authenticating lifts the GitHub API rate limit, which is easy to hit on shared CI runners. - if token := os.Getenv("GITHUB_TOKEN"); token != "" && strings.HasPrefix(url, "https://api.github.com/") { - request.Header.Set("Authorization", "Bearer "+token) - } - - response, err := client.Do(request) - if err != nil { - return nil, true, fmt.Errorf("get %s: %w", url, err) - } - - defer response.Body.Close() - - body, err := io.ReadAll(response.Body) - if err != nil { - return nil, true, fmt.Errorf("read response of %s: %w", url, err) - } - - if response.StatusCode != http.StatusOK { - retriable := response.StatusCode >= http.StatusInternalServerError || response.StatusCode == http.StatusTooManyRequests - - return nil, retriable, fmt.Errorf("get %s: unexpected status %s", url, response.Status) - } - - return body, false, nil -} diff --git a/scripts/denolock/main.go b/scripts/denolock/main.go index 3a06f5b0..a2815d5c 100644 --- a/scripts/denolock/main.go +++ b/scripts/denolock/main.go @@ -11,7 +11,6 @@ import ( "flag" "fmt" "maps" - "net/http" "os" "path/filepath" "slices" @@ -20,9 +19,11 @@ import ( "time" "github.com/dustin/go-humanize" + "github.com/go-resty/resty/v2" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/ts/denolock" + "github.com/werf/nelm/pkg/util" ) const ( @@ -63,7 +64,8 @@ func run(ctx context.Context) error { return verifyLock(ctx, opts) } - client := &http.Client{Timeout: requestTimeout} + client := util.NewRestyClient(ctx) + client.SetTimeout(requestTimeout) if opts.checkUpstream { return checkUpstream(ctx, client, opts) @@ -77,7 +79,7 @@ func run(ctx context.Context) error { return generate(ctx, client, opts) } -func generate(ctx context.Context, client *http.Client, opts options) error { +func generate(ctx context.Context, client *resty.Client, opts options) error { version, err := resolveVersion(opts) if err != nil { return err @@ -145,14 +147,14 @@ func generate(ctx context.Context, client *http.Client, opts options) error { return nil } -func pinPlatform(ctx context.Context, client *http.Client, version, platform string) (*platformResult, error) { +func pinPlatform(ctx context.Context, client *resty.Client, version, platform string) (*platformResult, error) { goos, _, _ := strings.Cut(platform, "/") target := targets[platform] archiveURL := denolock.ArchiveURLFor(version, target) log.Default.Info(ctx, "Downloading %s", archiveURL) - archive, err := download(ctx, client, archiveURL) + archive, err := get(ctx, client, archiveURL) if err != nil { return nil, err } @@ -191,7 +193,7 @@ func pinPlatform(ctx context.Context, client *http.Client, version, platform str // GitHub lets an asset be replaced without moving its tag. It compares the published checksums // rather than downloading a few hundred megabytes, which is enough to notice a swap but is not // verification — that is the lock's job. -func checkUpstream(ctx context.Context, client *http.Client, opts options) error { +func checkUpstream(ctx context.Context, client *resty.Client, opts options) error { lock, err := readLock(opts.outputPath) if err != nil { return err From 0b9d576a0c2e16e1d09a30b0cd7df00230415d19 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Sat, 1 Aug 2026 16:13:51 +0300 Subject: [PATCH 67/85] chore: bump deno version and update deno lock Signed-off-by: Dmitry Mordvinov --- pkg/ts/denolock/data/lock.json | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/pkg/ts/denolock/data/lock.json b/pkg/ts/denolock/data/lock.json index b04d9c66..4cb3eca3 100644 --- a/pkg/ts/denolock/data/lock.json +++ b/pkg/ts/denolock/data/lock.json @@ -1,30 +1,30 @@ { "platforms": { "darwin/amd64": { - "archiveSha256": "5478393fc9893c6f3516cee7579453a990834ceebf5ff44aaced2d0f285302d7", - "binarySha256": "43576ea690f25932cc08905857e05d3369974ca72073251d4a21401f96532b07", + "archiveSha256": "f757df6d3991e37601c69fad56c22b37c4ea77b5dcfad3636a642c2ba4c9b19f", + "binarySha256": "e0d641386d4f396414da81fa4cfda7b73533ce092a8e12ab0f0551d1a2bc8dcd", "target": "x86_64-apple-darwin" }, "darwin/arm64": { - "archiveSha256": "bc3392a0f50be9a1ecb68596530319308639a6f69d99678a0018c47e23a10c1f", - "binarySha256": "f658986b8f6dd59fabd4745bf4eb691304e7e9f5a1b5b023206719b7456cba68", + "archiveSha256": "6d17647fdbf9c587a581dba205054c4ccf732dae0a196cc1e9b44c07589db412", + "binarySha256": "433088c827fa0e39ff162ab0e475f1fd4c7690eaedec500cf678edc3865e9287", "target": "aarch64-apple-darwin" }, "linux/amd64": { - "archiveSha256": "b7154ae42839d7b1453422e2f33c907e5c68fde8fe9f145cd43b8dd083671a6f", - "binarySha256": "d79ad1e2b52916c68038bca1e21b4db233667d460287008e961438610b6651e1", + "archiveSha256": "c24f955d9fbfe0ea5ae2b501c8e71ae76e31e4c9782390a54a284b3364fda725", + "binarySha256": "1d97ecaf9e6bbb2a99e991caaf64ba9d62bf98759e8ef9938b9005855772b017", "target": "x86_64-unknown-linux-gnu" }, "linux/arm64": { - "archiveSha256": "bfe62e582faceb9885748f00a9f44cd5af458b9d08d7ac8275c4048ff1bb1540", - "binarySha256": "64274ce0ae6c4daa970e023959e72252c805704b9c7fde1038b9ca5cbf84a3a8", + "archiveSha256": "111da5c05c240cfdc4340f234a0e3539d39dbcb6755221f19dcd60bacc8be5aa", + "binarySha256": "7d87b8a5225485ddea1786024f875b2b3422c31100ba11cb2e36b6125959e218", "target": "aarch64-unknown-linux-gnu" }, "windows/amd64": { - "archiveSha256": "94d71d4772436de27a0495933ca4bab7b6895992622b65baeaf4b7995dae1e69", - "binarySha256": "6c35a4c45d521b340e5ce78dfb25014e5b04894067b5e71c1180273ffa5b3792", + "archiveSha256": "68ed08b05c56cf887e9aa509947dc3f468f7e12f47a13e5c1abd51d46d1453ef", + "binarySha256": "4a2757fe99afc2c62c46500c8221cfa0189ac4bfb7064141875ad9c0f04b60ef", "target": "x86_64-pc-windows-msvc" } }, - "version": "2.7.1" + "version": "2.9.4" } From 45823a46d66ca1f3256a42335cce55c7bd6f59c9 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Sat, 1 Aug 2026 16:28:17 +0300 Subject: [PATCH 68/85] chore: add check_deno_lock_upstream job to CI workflow Signed-off-by: Dmitry Mordvinov --- .../workflows/_check_deno_lock_upstream.yml | 44 +++++++++++++++++++ .github/workflows/test_daily.yml | 4 ++ Taskfile.dist.yaml | 2 +- scripts/denolock/download.go | 19 +++++++- 4 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/_check_deno_lock_upstream.yml diff --git a/.github/workflows/_check_deno_lock_upstream.yml b/.github/workflows/_check_deno_lock_upstream.yml new file mode 100644 index 00000000..b2ba7b4a --- /dev/null +++ b/.github/workflows/_check_deno_lock_upstream.yml @@ -0,0 +1,44 @@ +name: xxxxx(internal) + +on: + workflow_call: + inputs: + forceSkip: + default: false + type: string + +defaults: + run: + shell: bash + +env: + DEBIAN_FRONTEND: "noninteractive" + +jobs: + _: + if: inputs.forceSkip == 'false' + runs-on: ubuntu-22.04 + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + cache: false + go-version-file: go.mod + + - name: Install Task + uses: go-task/setup-task@v2 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + # Fails when the release assets the lock pins no longer hash to what it records: GitHub lets an + # asset be replaced without moving its tag, and nothing else would notice, since builds verify + # against the committed lock and never look upstream. A newer Deno release is only reported, + # since bumping the pin is a decision. + - name: Check the pinned Deno release against upstream + run: task -p check:deno-lock-upstream + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test_daily.yml b/.github/workflows/test_daily.yml index ff0bdc0a..46616b42 100644 --- a/.github/workflows/test_daily.yml +++ b/.github/workflows/test_daily.yml @@ -14,6 +14,9 @@ jobs: check_validation_schemas_upstream: uses: ./.github/workflows/_check_validation_schemas_upstream.yml + check_deno_lock_upstream: + uses: ./.github/workflows/_check_deno_lock_upstream.yml + unit: uses: ./.github/workflows/_test_unit.yml with: @@ -30,6 +33,7 @@ jobs: needs: - lint - check_validation_schemas_upstream + - check_deno_lock_upstream - coverage_report uses: werf/common-ci/.github/workflows/notification.yml@main secrets: diff --git a/Taskfile.dist.yaml b/Taskfile.dist.yaml index f4253ef8..91a4ebd4 100644 --- a/Taskfile.dist.yaml +++ b/Taskfile.dist.yaml @@ -385,7 +385,7 @@ tasks: - go run ./scripts/schemagen -check-upstream {{.CLI_ARGS}} check:deno-lock-upstream: - desc: "Report whether the Deno release assets the lock pins still hash to what it records. Needs network, so it is not part of lint: run it before a bump, or when a release build fails on a checksum." + desc: "Report whether the Deno release assets the lock pins still hash to what it records. Needs network, so it is not part of lint: it runs on a schedule." cmds: - go run ./scripts/denolock -check-upstream {{.CLI_ARGS}} diff --git a/scripts/denolock/download.go b/scripts/denolock/download.go index c3deac3c..524eaa64 100644 --- a/scripts/denolock/download.go +++ b/scripts/denolock/download.go @@ -9,6 +9,7 @@ import ( "encoding/json" "fmt" "io" + "os" "strings" "github.com/go-resty/resty/v2" @@ -101,11 +102,25 @@ func extractDenoBinary(archive []byte, goos string) ([]byte, error) { } func latestVersion(ctx context.Context, client *resty.Client) (string, error) { - body, err := get(ctx, client, latestReleaseURL) + request := client.R().SetContext(ctx) + + // The only call that hits the GitHub API, and the drift check runs daily from shared CI runners, + // where the unauthenticated rate limit is easy to hit. + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + request.SetHeader("Authorization", "Bearer "+token) + } + + response, err := request.Get(latestReleaseURL) if err != nil { - return "", err + return "", fmt.Errorf("get %s: %w", latestReleaseURL, err) } + if response.IsError() { + return "", fmt.Errorf("get %s: %s", latestReleaseURL, response.Status()) + } + + body := response.Body() + var release struct { TagName string `json:"tag_name"` } From 8b91b6d732f7a20275d5e4d13225293ce27e971b Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Sat, 1 Aug 2026 17:01:40 +0300 Subject: [PATCH 69/85] docs: update deno lock version in README.md Signed-off-by: Dmitry Mordvinov --- pkg/ts/denolock/data/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/ts/denolock/data/README.md b/pkg/ts/denolock/data/README.md index 5db3c0f2..936d29a8 100644 --- a/pkg/ts/denolock/data/README.md +++ b/pkg/ts/denolock/data/README.md @@ -19,7 +19,7 @@ history. It is the same reason `go.sum` and `Cargo.lock` exist. Changing what ne a commit rather than control of a download. What this does not cover: a release that was already malicious when it was pinned. Against that only -upstream provenance helps, which Deno does not publish — the v2.7.1 assets are archives and +upstream provenance helps, which Deno does not publish — the v2.9.4 assets are archives and `.sha256sum` files, nothing signed. ## Regenerating From 79ce86ac06dea4f891a4f19d3ad888fa723a53b1 Mon Sep 17 00:00:00 2001 From: Ilya Lesikov Date: Tue, 4 Aug 2026 12:03:30 +0300 Subject: [PATCH 70/85] fix: ignore helm.sh(werf.io)/resource-policy: keep from cluster Signed-off-by: Ilya Lesikov --- .gitignore | 3 + .../issue-7233/charts/alpine-0.1.0.tgz | Bin 1166 -> 0 bytes pkg/plan/resource_info.go | 40 +---- pkg/plan/resource_policy_live_ai_test.go | 161 ++++++++++++++++++ pkg/resource/resource.go | 16 -- pkg/resource/resource_policy_test.go | 104 ----------- 6 files changed, 172 insertions(+), 152 deletions(-) delete mode 100644 pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/charts/alpine-0.1.0.tgz create mode 100644 pkg/plan/resource_policy_live_ai_test.go diff --git a/.gitignore b/.gitignore index 922d9d9b..2b5e7e09 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ node_modules/ /pkg/ts/embed/*/*/deno.gz.*.tmp /.opencode/ /.sisyphus + +# Regenerated by TestDependencyBuildCmdWithHelmV2Hash on every run (fresh mtimes) +/pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/charts/alpine-0.1.0.tgz diff --git a/pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/charts/alpine-0.1.0.tgz b/pkg/helm/pkg/cmd/testdata/testcharts/issue-7233/charts/alpine-0.1.0.tgz deleted file mode 100644 index fa8d1a7ac72209301dd03de5f315c64c3803e608..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1166 zcmV;91abQxiwG0|00000|0w_~VMtOiV@ORlOnEsqVl!4SWK%V1T2nbTPgYhoO;>Dc zVQyr3R8em|NM&qo0PI-bZ`-yL&$IrDgZf&_^sfZQ zBPlDM>;HWqDLJvSCIePG`=NUgM3Eof9q)5@6pE5d8vZXwZIdk);^ONIV~kx+r~b(p z>pt0dHoF+j#^bBY>1;9?U$D`5I-6bqJAp2bq%s!u1^df;b&dOn2$uX4X~UJA!)6p= zSsq^FN%AI+GBT;=rS(H^sT67p2un0Pg=-%?@GY0s9h54Ia#heAa%)R79}aU8MPhRQ zj3l~GA>1OPDxH#A1uH*>u|T z|18p}|F>LbbNHyTs340OTAW7!p?1f+U2;Q$&`{#i#(qE8?UGNSa)g060uZ<)Vcar+ zSMWnl5Mx;;x7CugBuggYYFMDeHD!b4HVzAb8>kFrU=>)6=`GYbPjj$Ji3Te3%?D+G zu;v?*kou+--rHvJsuHkL8ZaxkG*IUXYLwPN8X~B3v<;LFW*9&yQCpr?0=3`EL1{9j zl7=q@IlRO#ddh}5JaEnTq&GYz_zb^R$`b7NPrHIkV^O9QL#pJ4r0cYxz*{oFEfAWm z_X#J!R__MTDnKDXt-$64-yBe#0inSHo1=xAG(oBft#h97CWlpBhkVJ@R>vbl-H~^# ze7y9~!tHjDgloq2p~-yc9Bv+4ja9~NIA^E~t{Vm0#?pTXffT9<5L=Dh?rs0?bxgyz}lk!iCS!+1MZ3(VY1oY1cTp zqhthbTZ>$h_IYB^B$yhxCGAg&7r1sQCsd>P7h)%zYO0C?UE2C79xk~Y8bMUXuj4n2 zvH0h{qj^%A`hWB8`t5h`k|I0B9?$jvv&*Y)|IbGK{r@cTs=36^;kwOn|0hv|oEt?q zmGsF^t_8UpzDpnvZ~aLW`CLeM=-1Y0U`>*=uQioh=(AbFQCQ?2+}EP!XTZW^5G?L{ z&;?{%Q)-powyDD9wGHWQtEAJVEpt$Fn*&Cvs@x~Faup5>vZPH5{@{YUfE5>X5BwA~D~LNYv9{L;sMkcR(sXEpwEqQ3yo z-v1|)4*#bkcGdI$9Ae2>SA)ZU03QZ}5C(sr813bB((vCkgQueZpWuJ@{y(`I&wBo! gLyr0H!l0*rFTM2A%YQ+B4*&rF|CCL6y8s{n03_)<*Z=?k diff --git a/pkg/plan/resource_info.go b/pkg/plan/resource_info.go index b925c4ad..20c4f80f 100644 --- a/pkg/plan/resource_info.go +++ b/pkg/plan/resource_info.go @@ -189,10 +189,9 @@ func buildInstallableResourceInfo(ctx context.Context, localRes *resource.Instal } var ( - getMeta *spec.ResourceMeta - dryApplyObj *unstructured.Unstructured - dryApplyErr error - resourcePolicies = localRes.ResourcePolicies + getMeta *spec.ResourceMeta + dryApplyObj *unstructured.Unstructured + dryApplyErr error ) if getErr == nil { var err error @@ -203,7 +202,6 @@ func buildInstallableResourceInfo(ctx context.Context, localRes *resource.Instal } getMeta = spec.NewResourceMetaFromUnstructured(getObj, releaseNamespace, localRes.FilePath) - resourcePolicies = resource.ResolveResourcePolicies(localRes, getMeta, releaseNamespace) dryApplyObj, dryApplyErr = clientFactory.KubeClient().Apply(ctx, localRes.ResourceSpec, kube.KubeClientApplyOptions{ DefaultNamespace: releaseNamespace, @@ -211,7 +209,7 @@ func buildInstallableResourceInfo(ctx context.Context, localRes *resource.Instal }) } - installType, skippedByPolicy, err := resourceInstallType(ctx, localRes, getObj, dryApplyObj, dryApplyErr, opts.ExtraRuntimeAnnotations, opts.ExtraRuntimeLabels, resourcePolicies, diffPatches) + installType, skippedByPolicy, err := resourceInstallType(ctx, localRes, getObj, dryApplyObj, dryApplyErr, opts.ExtraRuntimeAnnotations, opts.ExtraRuntimeLabels, localRes.ResourcePolicies, diffPatches) if err != nil { return nil, fmt.Errorf("determine install type for resource %q: %w", localRes.IDHuman(), err) } @@ -222,7 +220,7 @@ func buildInstallableResourceInfo(ctx context.Context, localRes *resource.Instal } } - mustDeleteOnSuccess := mustDeleteOnSuccessfulDeploy(localRes, getMeta, installType, releaseNamespace, skippedByPolicy) + mustDeleteOnSuccess := mustDeleteOnSuccessfulDeploy(localRes, getMeta, installType, skippedByPolicy) trackReadiness := mustTrackReadiness(localRes, installType, getObj != nil, prevRelFailed, mustDeleteOnSuccess, skippedByPolicy) return lo.Map(stages, func(stg common.Stage, _ int) *InstallableResourceInfo { @@ -232,7 +230,7 @@ func buildInstallableResourceInfo(ctx context.Context, localRes *resource.Instal DryApplyResult: dryApplyObj, GetResult: getObj, LocalResource: localRes, - MustDeleteOnFailedInstall: mustDeleteOnFailedDeploy(localRes, getMeta, installType, releaseNamespace, trackReadiness, skippedByPolicy), + MustDeleteOnFailedInstall: mustDeleteOnFailedDeploy(localRes, installType, trackReadiness, skippedByPolicy), MustDeleteOnSuccessfulInstall: mustDeleteOnSuccess, MustInstall: installType, MustTrackReadiness: trackReadiness, @@ -383,12 +381,6 @@ func buildDeletableResourceInfo(ctx context.Context, localRes *resource.Deletabl getMeta := spec.NewResourceMetaFromUnstructured(getObj, releaseNamespace, localRes.FilePath) - if err := resource.ValidateResourcePolicy(getMeta); err != nil { - return noDeleteInfo, nil - } else if lo.Contains(resource.ResourcePolicies(getMeta, releaseNamespace), common.ResourcePolicySkipDelete) { - return noDeleteInfo, nil - } - if orphaned(getMeta, releaseName, releaseNamespace) { return noDeleteInfo, nil } @@ -686,7 +678,7 @@ func iterateInstallableResourceInfos(infos []*InstallableResourceInfo) { } } -func mustDeleteOnFailedDeploy(res *resource.InstallableResource, getMeta *spec.ResourceMeta, installType ResourceInstallType, releaseNamespace string, mustTrackReadiness, skippedByPolicy bool) bool { +func mustDeleteOnFailedDeploy(res *resource.InstallableResource, installType ResourceInstallType, mustTrackReadiness, skippedByPolicy bool) bool { if skippedByPolicy || !res.DeleteOnFailed || lo.Contains(res.ResourcePolicies, common.ResourcePolicySkipDelete) || @@ -695,32 +687,16 @@ func mustDeleteOnFailedDeploy(res *resource.InstallableResource, getMeta *spec.R return false } - if getMeta != nil { - if err := resource.ValidateResourcePolicy(getMeta); err != nil { - return false - } else if lo.Contains(resource.ResourcePolicies(getMeta, releaseNamespace), common.ResourcePolicySkipDelete) { - return false - } - } - return true } -func mustDeleteOnSuccessfulDeploy(localRes *resource.InstallableResource, getMeta *spec.ResourceMeta, installType ResourceInstallType, releaseNamespace string, skippedByPolicy bool) bool { +func mustDeleteOnSuccessfulDeploy(localRes *resource.InstallableResource, getMeta *spec.ResourceMeta, installType ResourceInstallType, skippedByPolicy bool) bool { if skippedByPolicy || !localRes.DeleteOnSucceeded || lo.Contains(localRes.ResourcePolicies, common.ResourcePolicySkipDelete) { return false } - if getMeta != nil { - if err := resource.ValidateResourcePolicy(getMeta); err != nil { - return false - } else if lo.Contains(resource.ResourcePolicies(getMeta, releaseNamespace), common.ResourcePolicySkipDelete) { - return false - } - } - if installType == ResourceInstallTypeNone { return getMeta != nil } diff --git a/pkg/plan/resource_policy_live_ai_test.go b/pkg/plan/resource_policy_live_ai_test.go new file mode 100644 index 00000000..8a42a30f --- /dev/null +++ b/pkg/plan/resource_policy_live_ai_test.go @@ -0,0 +1,161 @@ +//go:build ai_tests + +package plan_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/werf/nelm/pkg/common" + "github.com/werf/nelm/pkg/kube" + "github.com/werf/nelm/pkg/kube/fake" + "github.com/werf/nelm/pkg/plan" +) + +type ResourcePolicyLiveAISuite struct { + suite.Suite + + clientFactory *fake.ClientFactory + releaseName string + releaseNamespace string +} + +func (s *ResourcePolicyLiveAISuite) SetupSubTest() { + var err error + + s.clientFactory, err = fake.NewClientFactory(context.Background()) + s.Require().NoError(err) +} + +func (s *ResourcePolicyLiveAISuite) SetupSuite() { + s.releaseName = "test-release" + s.releaseNamespace = "test-namespace" +} + +func (s *ResourcePolicyLiveAISuite) TestAI_ChartPolicyStillProtectsChartRemovedResource() { + s.Run("chart skip-delete keeps resource", func() { + s.createLiveResource(nil) + + localRes := defaultDeletableResource(s.releaseName, s.releaseNamespace) + localRes.ResourcePolicies = []common.ResourcePolicy{common.ResourcePolicySkipDelete} + + resInfo, err := plan.BuildDeletableResourceInfo(context.Background(), localRes, common.DeployTypeUninstall, s.releaseName, s.releaseNamespace, s.clientFactory) + s.Require().NoError(err) + s.Require().False(resInfo.MustDelete, "chart-side skip-delete must keep protecting the resource") + }) +} + +func (s *ResourcePolicyLiveAISuite) TestAI_ChartPolicyStillSuppressesDeleteOnSucceeded() { + s.Run("chart skip-delete suppresses delete-on-succeeded", func() { + s.createLiveResource(nil) + + localRes := defaultInstallableResource(s.releaseName, s.releaseNamespace) + localRes.DeleteOnSucceeded = true + localRes.ResourcePolicies = []common.ResourcePolicy{common.ResourcePolicySkipDelete} + + resInfos, err := plan.BuildInstallableResourceInfo(context.Background(), localRes, common.DeployTypeInitial, s.releaseNamespace, false, true, s.clientFactory, plan.BuildResourceInfosOptions{}, nil) + s.Require().NoError(err) + s.Require().NotEmpty(resInfos) + s.Require().False(resInfos[0].MustDeleteOnSuccessfulInstall, "chart-side skip-delete must still suppress delete-on-succeeded") + }) +} + +func (s *ResourcePolicyLiveAISuite) TestAI_LiveOnlyPolicyDoesNotProtectChartRemovedResource() { + livePolicies := []map[string]string{ + {"helm.sh/resource-policy": "keep"}, + {"werf.io/resource-policy": "keep"}, + {"werf.io/resource-policy": "skip-delete"}, + {"werf.io/resource-policy": "bogus"}, + } + + for _, policy := range livePolicies { + s.Run(policyName(policy), func() { + s.createLiveResource(policy) + + localRes := defaultDeletableResource(s.releaseName, s.releaseNamespace) + + resInfo, err := plan.BuildDeletableResourceInfo(context.Background(), localRes, common.DeployTypeUninstall, s.releaseName, s.releaseNamespace, s.clientFactory) + s.Require().NoError(err) + s.Require().True(resInfo.MustDelete, "chart-removed resource must be deleted despite live-only policy %v", policy) + }) + } +} + +func (s *ResourcePolicyLiveAISuite) TestAI_LiveOnlyPolicyDoesNotSuppressDeleteOnFailed() { + livePolicies := []map[string]string{ + {"werf.io/resource-policy": "skip-delete"}, + {"werf.io/resource-policy": "bogus"}, + } + + for _, policy := range livePolicies { + s.Run(policyName(policy), func() { + s.createLiveResource(policy) + + localRes := updatedInstallableResource(&s.Suite, s.releaseName, s.releaseNamespace) + localRes.DeleteOnFailed = true + + resInfos, err := plan.BuildInstallableResourceInfo(context.Background(), localRes, common.DeployTypeInitial, s.releaseNamespace, false, true, s.clientFactory, plan.BuildResourceInfosOptions{}, nil) + s.Require().NoError(err) + s.Require().NotEmpty(resInfos) + s.Require().Equal(plan.ResourceInstallTypeUpdate, resInfos[0].MustInstall) + s.Require().True(resInfos[0].MustDeleteOnFailedInstall, "delete-on-failed must not be suppressed by live-only policy %v", policy) + }) + } +} + +func (s *ResourcePolicyLiveAISuite) TestAI_LiveOnlyPolicyDoesNotSuppressDeleteOnSucceeded() { + livePolicies := []map[string]string{ + {"werf.io/resource-policy": "skip-delete"}, + {"werf.io/resource-policy": "bogus"}, + } + + for _, policy := range livePolicies { + s.Run(policyName(policy), func() { + s.createLiveResource(policy) + + localRes := defaultInstallableResource(s.releaseName, s.releaseNamespace) + localRes.DeleteOnSucceeded = true + + resInfos, err := plan.BuildInstallableResourceInfo(context.Background(), localRes, common.DeployTypeInitial, s.releaseNamespace, false, true, s.clientFactory, plan.BuildResourceInfosOptions{}, nil) + s.Require().NoError(err) + s.Require().NotEmpty(resInfos) + s.Require().Equal(plan.ResourceInstallTypeNone, resInfos[0].MustInstall) + s.Require().True(resInfos[0].MustDeleteOnSuccessfulInstall, "delete-on-succeeded must not be suppressed by live-only policy %v", policy) + }) + } +} + +func (s *ResourcePolicyLiveAISuite) createLiveResource(policyAnnotations map[string]string) { + resSpec := defaultResourceSpec(s.releaseName, s.releaseNamespace) + + annotations := resSpec.Unstruct.GetAnnotations() + for k, v := range policyAnnotations { + annotations[k] = v + } + + resSpec.SetAnnotations(annotations) + + _, err := s.clientFactory.KubeClient().Create(context.Background(), resSpec, kube.KubeClientCreateOptions{ + DefaultNamespace: s.releaseNamespace, + }) + s.Require().NoError(err) +} + +func TestAI_ResourcePolicyLiveSuite(t *testing.T) { + suite.Run(t, new(ResourcePolicyLiveAISuite)) +} + +func policyName(policy map[string]string) string { + if len(policy) == 0 { + return "no policy" + } + + var name string + for k, v := range policy { + name += k + "=" + v + } + + return name +} diff --git a/pkg/resource/resource.go b/pkg/resource/resource.go index 95f3eab6..49d446e2 100644 --- a/pkg/resource/resource.go +++ b/pkg/resource/resource.go @@ -372,19 +372,3 @@ func BuildResources(ctx context.Context, deployType common.DeployType, releaseNa return instResources, delResources, nil } - -func ResolveResourcePolicies(localRes *InstallableResource, liveMeta *spec.ResourceMeta, releaseNamespace string) []common.ResourcePolicy { - if len(localRes.ResourcePolicies) > 0 || liveMeta == nil { - return localRes.ResourcePolicies - } - - // TODO(major): in the next major keep/skip-delete should also be read/respected only from the manifest, not the cluster. - livePolicies := lo.Filter(ResourcePolicies(liveMeta, releaseNamespace), func(p common.ResourcePolicy, _ int) bool { - return p == common.ResourcePolicySkipDelete - }) - if len(livePolicies) == 0 { - return nil - } - - return livePolicies -} diff --git a/pkg/resource/resource_policy_test.go b/pkg/resource/resource_policy_test.go index 507bcbf3..3b36d900 100644 --- a/pkg/resource/resource_policy_test.go +++ b/pkg/resource/resource_policy_test.go @@ -1,12 +1,10 @@ package resource_test import ( - "context" "testing" "github.com/samber/lo" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "github.com/werf/nelm/pkg/common" @@ -16,84 +14,6 @@ import ( const resourcePolicyTestNamespace = "test-namespace" -func TestResolveResourcePolicies(t *testing.T) { - tests := []struct { - name string - chart, live map[string]string - nilLive bool - want []common.ResourcePolicy - }{ - { - name: "no policies", - nilLive: true, - }, - { - name: "chart skip-create only", - chart: map[string]string{"werf.io/resource-policy": "skip-create"}, - nilLive: true, - want: []common.ResourcePolicy{common.ResourcePolicySkipCreate}, - }, - { - name: "chart all skips", - chart: map[string]string{"werf.io/resource-policy": "skip-create,skip-update,skip-recreate"}, - nilLive: true, - want: []common.ResourcePolicy{common.ResourcePolicySkipCreate, common.ResourcePolicySkipUpdate, common.ResourcePolicySkipRecreate}, - }, - { - name: "live skip-update dropped when chart absent", - live: map[string]string{"werf.io/resource-policy": "skip-update"}, - }, - { - name: "live install skips dropped when chart absent", - live: map[string]string{"werf.io/resource-policy": "skip-create,skip-update,skip-recreate"}, - }, - { - name: "live skip-delete retained when chart absent", - live: map[string]string{"werf.io/resource-policy": "skip-delete"}, - want: []common.ResourcePolicy{common.ResourcePolicySkipDelete}, - }, - { - name: "live werf.io keep retained as skip-delete when chart absent", - live: map[string]string{"werf.io/resource-policy": "keep"}, - want: []common.ResourcePolicy{common.ResourcePolicySkipDelete}, - }, - { - name: "live helm.sh keep retained as skip-delete when chart absent", - live: map[string]string{"helm.sh/resource-policy": "keep"}, - want: []common.ResourcePolicy{common.ResourcePolicySkipDelete}, - }, - { - name: "live mixed policies filtered to skip-delete when chart absent", - live: map[string]string{"werf.io/resource-policy": "skip-update,skip-delete"}, - want: []common.ResourcePolicy{common.ResourcePolicySkipDelete}, - }, - { - name: "chart present takes precedence over live (no merge)", - chart: map[string]string{"werf.io/resource-policy": "skip-update"}, - live: map[string]string{"werf.io/resource-policy": "skip-delete"}, - want: []common.ResourcePolicy{common.ResourcePolicySkipUpdate}, - }, - { - name: "chart helm.sh keep present suppresses live werf.io skips", - chart: map[string]string{"helm.sh/resource-policy": "keep"}, - live: map[string]string{"werf.io/resource-policy": "skip-create"}, - want: []common.ResourcePolicy{common.ResourcePolicySkipDelete}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var liveMeta *spec.ResourceMeta - if !tt.nilLive { - liveMeta = resourcePolicyMeta(tt.live) - } - - localRes := chartInstallableResource(t, tt.chart) - assert.Equal(t, tt.want, resource.ResolveResourcePolicies(localRes, liveMeta, resourcePolicyTestNamespace)) - }) - } -} - func TestResourcePoliciesSkipDelete(t *testing.T) { tests := []struct { name string @@ -151,30 +71,6 @@ func TestValidateResourcePolicy(t *testing.T) { } } -func chartInstallableResource(t *testing.T, annotations map[string]string) *resource.InstallableResource { - t.Helper() - - obj := &unstructured.Unstructured{ - Object: map[string]interface{}{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]interface{}{ - "name": "test-configmap", - }, - }, - } - - resSpec := spec.NewResourceSpec(obj, resourcePolicyTestNamespace, spec.ResourceSpecOptions{}) - if len(annotations) > 0 { - resSpec.SetAnnotations(annotations) - } - - localRes, err := resource.NewInstallableResource(context.Background(), resSpec, nil, resourcePolicyTestNamespace, resource.InstallableResourceOptions{}) - require.NoError(t, err) - - return localRes -} - func resourcePolicyMeta(annotations map[string]string) *spec.ResourceMeta { obj := &unstructured.Unstructured{ Object: map[string]interface{}{ From e9bbd6e25a02ac304d2d14ec82425724161fba02 Mon Sep 17 00:00:00 2001 From: Ilya Lesikov Date: Tue, 4 Aug 2026 12:56:04 +0300 Subject: [PATCH 71/85] feat: clean null fields in rendered manifests by default for better compat Signed-off-by: Ilya Lesikov --- README.md | 13 ------------- pkg/action/chart_lint.go | 6 +++--- pkg/action/release_get.go | 2 +- pkg/action/release_install.go | 14 +++++++------- pkg/action/release_plan_install.go | 6 +++--- pkg/action/release_rollback.go | 8 ++++---- pkg/action/release_uninstall.go | 2 +- pkg/release/release.go | 5 +---- pkg/resource/spec/resource_spec.go | 6 ++---- pkg/resource/spec/unstruct.go | 5 ----- 10 files changed, 22 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index c69b0d67..b21148f3 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,6 @@ Nelm is production-ready: as the werf deployment engine, it was battle-tested ac - [`NELM_FEAT_NATIVE_RELEASE_UNINSTALL` environment variable](#nelm_feat_native_release_uninstall-environment-variable) - [`NELM_FEAT_PERIODIC_STACK_TRACES` environment variable](#nelm_feat_periodic_stack_traces-environment-variable) - [`NELM_FEAT_FIELD_SENSITIVE` environment variable](#nelm_feat_field_sensitive-environment-variable) - - [`NELM_FEAT_CLEAN_NULL_FIELDS` environment variable](#nelm_feat_clean_null_fields-environment-variable) - [`NELM_FEAT_MORE_DETAILED_EXIT_CODE_FOR_PLAN` environment variable](#nelm_feat_more_detailed_exit_code_for_plan-environment-variable) - [More documentation](#more-documentation) - [Limitations](#limitations) @@ -845,18 +844,6 @@ export NELM_FEAT_FIELD_SENSITIVE=true nelm release plan install -n myproject -r myproject ``` -### `NELM_FEAT_CLEAN_NULL_FIELDS` environment variable - -Improve Helm chart compatibility. When rendering charts, remove keys with `null` values from the rendered resource manifests, before applying them. Otherwise, SSA often fail on `null` values, which didn't happen with 3WM. - -Will be the default in the next major release. - -Example: -```shell -export NELM_FEAT_CLEAN_NULL_FIELDS=true -nelm release install -n myproject -r myproject -``` - ### `NELM_FEAT_MORE_DETAILED_EXIT_CODE_FOR_PLAN` environment variable When the `--exit-code` flag is specified for `nelm release plan install`, return exit code 3, if no resource changes planned, but release still must be installed. Previously, exit code 2 was returned in this case. diff --git a/pkg/action/chart_lint.go b/pkg/action/chart_lint.go index 3ac422e1..4d8a833c 100644 --- a/pkg/action/chart_lint.go +++ b/pkg/action/chart_lint.go @@ -312,7 +312,7 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { var prevRelResSpecs []*spec.ResourceSpec if prevRelease != nil { - prevRelResSpecs, err = release.ReleaseToResourceSpecs(ctx, prevRelease, opts.ReleaseNamespace, false) + prevRelResSpecs, err = release.ReleaseToResourceSpecs(ctx, prevRelease, opts.ReleaseNamespace) if err != nil { return fmt.Errorf("convert previous release to resource specs: %w", err) } @@ -320,7 +320,7 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { log.Default.Debug(ctx, "Convert new release to resource specs") - newRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, newRelease, opts.ReleaseNamespace, false) + newRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, newRelease, opts.ReleaseNamespace) if err != nil { return fmt.Errorf("convert new release to resource specs: %w", err) } @@ -353,7 +353,7 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { var lastDeployedOrLastRelResSpecs []*spec.ResourceSpec if lastDeployedOrLastRelease != nil { - lastDeployedOrLastRelResSpecs, err = release.ReleaseToResourceSpecs(ctx, lastDeployedOrLastRelease, opts.ReleaseNamespace, false) + lastDeployedOrLastRelResSpecs, err = release.ReleaseToResourceSpecs(ctx, lastDeployedOrLastRelease, opts.ReleaseNamespace) if err != nil { return fmt.Errorf("convert last deployed or last release to resource specs: %w", err) } diff --git a/pkg/action/release_get.go b/pkg/action/release_get.go index 8d9afdf9..c6e24045 100644 --- a/pkg/action/release_get.go +++ b/pkg/action/release_get.go @@ -192,7 +192,7 @@ func ReleaseGet(ctx context.Context, releaseName, releaseNamespace string, opts Values: values, } - resSpecs, err := release.ReleaseToResourceSpecs(ctx, relAccessor, releaseNamespace, false) + resSpecs, err := release.ReleaseToResourceSpecs(ctx, relAccessor, releaseNamespace) if err != nil { return nil, fmt.Errorf("convert release to resource specs: %w", err) } diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index 6d2a6420..910c9650 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -423,7 +423,7 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re var prevRelResSpecs []*spec.ResourceSpec if prevRelease != nil { - prevRelResSpecs, err = release.ReleaseToResourceSpecs(ctx, prevRelease, releaseNamespace, false) + prevRelResSpecs, err = release.ReleaseToResourceSpecs(ctx, prevRelease, releaseNamespace) if err != nil { return fmt.Errorf("convert previous release to resource specs: %w", err) } @@ -431,7 +431,7 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re log.Default.Debug(ctx, "Convert new release to resource specs") - newRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, newRelease, releaseNamespace, false) + newRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, newRelease, releaseNamespace) if err != nil { return fmt.Errorf("convert new release to resource specs: %w", err) } @@ -463,7 +463,7 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re var lastDeployedOrLastRelResSpecs []*spec.ResourceSpec if lastDeployedOrLastRelease != nil { - lastDeployedOrLastRelResSpecs, err = release.ReleaseToResourceSpecs(ctx, lastDeployedOrLastRelease, releaseNamespace, false) + lastDeployedOrLastRelResSpecs, err = release.ReleaseToResourceSpecs(ctx, lastDeployedOrLastRelease, releaseNamespace) if err != nil { return fmt.Errorf("convert last deployed or last release to resource specs: %w", err) } @@ -835,7 +835,7 @@ func runRollbackPlan(ctx context.Context, releaseName, releaseNamespace string, log.Default.Debug(ctx, "Convert prev deployed release to resource specs") - resSpecs, err := release.ReleaseToResourceSpecs(ctx, prevDeployedRelease, releaseNamespace, false) + resSpecs, err := release.ReleaseToResourceSpecs(ctx, prevDeployedRelease, releaseNamespace) if err != nil { return nil, nonCritErrs, critErrs.Add(fmt.Errorf("convert previous deployed release to resource specs: %w", err)) } @@ -881,14 +881,14 @@ func runRollbackPlan(ctx context.Context, releaseName, releaseNamespace string, log.Default.Debug(ctx, "Convert failed release to resource specs") - failedRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, failedRelease, releaseNamespace, false) + failedRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, failedRelease, releaseNamespace) if err != nil { return nil, nonCritErrs, critErrs.Add(fmt.Errorf("convert previous release to resource specs: %w", err)) } log.Default.Debug(ctx, "Convert new release to resource specs") - newRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, newRelease, releaseNamespace, false) + newRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, newRelease, releaseNamespace) if err != nil { return nil, nonCritErrs, critErrs.Add(fmt.Errorf("convert new release to resource specs: %w", err)) } @@ -914,7 +914,7 @@ func runRollbackPlan(ctx context.Context, releaseName, releaseNamespace string, log.Default.Debug(ctx, "Build resource infos") - lastDeployedOrLastRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, prevDeployedRelease, releaseNamespace, false) + lastDeployedOrLastRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, prevDeployedRelease, releaseNamespace) if err != nil { return nil, nonCritErrs, critErrs.Add(fmt.Errorf("convert last deployed or last release to resource specs: %w", err)) } diff --git a/pkg/action/release_plan_install.go b/pkg/action/release_plan_install.go index 7cb3e7dd..819f76a6 100644 --- a/pkg/action/release_plan_install.go +++ b/pkg/action/release_plan_install.go @@ -318,7 +318,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc var prevRelResSpecs []*spec.ResourceSpec if prevRelease != nil { - prevRelResSpecs, err = release.ReleaseToResourceSpecs(ctx, prevRelease, releaseNamespace, false) + prevRelResSpecs, err = release.ReleaseToResourceSpecs(ctx, prevRelease, releaseNamespace) if err != nil { return nil, fmt.Errorf("convert previous release to resource specs: %w", err) } @@ -326,7 +326,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc log.Default.Debug(ctx, "Convert new release to resource specs") - newRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, newRelease, releaseNamespace, false) + newRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, newRelease, releaseNamespace) if err != nil { return nil, fmt.Errorf("convert new release to resource specs: %w", err) } @@ -355,7 +355,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc var lastDeployedOrLastRelResSpecs []*spec.ResourceSpec if lastDeployedOrLastRelease != nil { - lastDeployedOrLastRelResSpecs, err = release.ReleaseToResourceSpecs(ctx, lastDeployedOrLastRelease, releaseNamespace, false) + lastDeployedOrLastRelResSpecs, err = release.ReleaseToResourceSpecs(ctx, lastDeployedOrLastRelease, releaseNamespace) if err != nil { return nil, fmt.Errorf("convert last deployed or last release to resource specs: %w", err) } diff --git a/pkg/action/release_rollback.go b/pkg/action/release_rollback.go index 8d41255f..ab01f6fb 100644 --- a/pkg/action/release_rollback.go +++ b/pkg/action/release_rollback.go @@ -230,7 +230,7 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r log.Default.Debug(ctx, "Convert release to resource specs") - rollbackReleaseResSpecs, err := release.ReleaseToResourceSpecs(ctx, rollbackRelease, releaseNamespace, false) + rollbackReleaseResSpecs, err := release.ReleaseToResourceSpecs(ctx, rollbackRelease, releaseNamespace) if err != nil { return fmt.Errorf("convert release to rollback to resource specs: %w", err) } @@ -275,14 +275,14 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r log.Default.Debug(ctx, "Convert previous release to resource specs") - prevRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, prevRelease, releaseNamespace, false) + prevRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, prevRelease, releaseNamespace) if err != nil { return fmt.Errorf("convert previous release to resource specs: %w", err) } log.Default.Debug(ctx, "Convert new release to resource specs") - newRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, newRelease, releaseNamespace, false) + newRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, newRelease, releaseNamespace) if err != nil { return fmt.Errorf("convert new release to resource specs: %w", err) } @@ -312,7 +312,7 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r var lastDeployedOrLastRelResSpecs []*spec.ResourceSpec if lastDeployedOrLastRelease != nil { - lastDeployedOrLastRelResSpecs, err = release.ReleaseToResourceSpecs(ctx, lastDeployedOrLastRelease, releaseNamespace, false) + lastDeployedOrLastRelResSpecs, err = release.ReleaseToResourceSpecs(ctx, lastDeployedOrLastRelease, releaseNamespace) if err != nil { return fmt.Errorf("convert last deployed or last release to resource specs: %w", err) } diff --git a/pkg/action/release_uninstall.go b/pkg/action/release_uninstall.go index f3219b52..1b1b42ca 100644 --- a/pkg/action/release_uninstall.go +++ b/pkg/action/release_uninstall.go @@ -214,7 +214,7 @@ func releaseUninstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, log.Default.Debug(ctx, "Convert previous release to resource specs") - prevRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, prevRelease, releaseNamespace, false) + prevRelResSpecs, err := release.ReleaseToResourceSpecs(ctx, prevRelease, releaseNamespace) if err != nil { return fmt.Errorf("convert previous release to resource specs: %w", err) } diff --git a/pkg/release/release.go b/pkg/release/release.go index 43672ed3..f0890ca1 100644 --- a/pkg/release/release.go +++ b/pkg/release/release.go @@ -260,12 +260,11 @@ func NewRelease(name, namespace string, revision int, deployType common.DeployTy } // Constructs ResourceSpecs from a Release object. -func ReleaseToResourceSpecs(ctx context.Context, rel helmrel.Accessor, releaseNamespace string, noCleanNullFields bool) ([]*spec.ResourceSpec, error) { +func ReleaseToResourceSpecs(ctx context.Context, rel helmrel.Accessor, releaseNamespace string) ([]*spec.ResourceSpec, error) { var resources []*spec.ResourceSpec for _, manifest := range util.SplitManifests(rel.UnstoredManifest()) { if res, err := spec.NewResourceSpecFromManifest(ctx, manifest, releaseNamespace, spec.ResourceSpecOptions{ StoreAs: common.StoreAsNone, - LegacyNoCleanNullFields: noCleanNullFields, DropInvalidAnnotationsAndLabels: true, }); err != nil { return nil, fmt.Errorf("construct resource spec from unstored manifest: %w", err) @@ -277,7 +276,6 @@ func ReleaseToResourceSpecs(ctx context.Context, rel helmrel.Accessor, releaseNa for _, manifest := range util.SplitManifests(rel.Manifest()) { if res, err := spec.NewResourceSpecFromManifest(ctx, manifest, releaseNamespace, spec.ResourceSpecOptions{ StoreAs: common.StoreAsRegular, - LegacyNoCleanNullFields: noCleanNullFields, DropInvalidAnnotationsAndLabels: true, }); err != nil { return nil, fmt.Errorf("construct resource spec from regular manifest: %w", err) @@ -294,7 +292,6 @@ func ReleaseToResourceSpecs(ctx context.Context, rel helmrel.Accessor, releaseNa if res, err := spec.NewResourceSpecFromManifest(ctx, hookAcc.Manifest(), releaseNamespace, spec.ResourceSpecOptions{ StoreAs: common.StoreAsHook, - LegacyNoCleanNullFields: noCleanNullFields, DropInvalidAnnotationsAndLabels: true, }); err != nil { return nil, fmt.Errorf("construct resource spec from hook manifest: %w", err) diff --git a/pkg/resource/spec/resource_spec.go b/pkg/resource/spec/resource_spec.go index ef26405a..99100664 100644 --- a/pkg/resource/spec/resource_spec.go +++ b/pkg/resource/spec/resource_spec.go @@ -23,9 +23,8 @@ type ResourceSpec struct { } func NewResourceSpec(unstruct *unstructured.Unstructured, releaseNamespace string, opts ResourceSpecOptions) *ResourceSpec { - unstruct = CleanUnstruct(unstruct, CleanUnstructOptions{ - CleanNullFields: !opts.LegacyNoCleanNullFields, - }) + unstruct = unstruct.DeepCopy() + unstruct.Object = cleanNulls(unstruct.Object).(map[string]interface{}) if opts.StoreAs == "" { if IsHook(unstruct.GetAnnotations()) { @@ -88,7 +87,6 @@ func (s *ResourceSpec) SetLabels(labels map[string]string) { type ResourceSpecOptions struct { DropInvalidAnnotationsAndLabels bool FilePath string - LegacyNoCleanNullFields bool // TODO(major): always clean StoreAs common.StoreAs } diff --git a/pkg/resource/spec/unstruct.go b/pkg/resource/spec/unstruct.go index b9cd77d8..6ba715e3 100644 --- a/pkg/resource/spec/unstruct.go +++ b/pkg/resource/spec/unstruct.go @@ -14,7 +14,6 @@ type CleanUnstructOptions struct { CleanHelmShAnnos bool CleanLabels map[string]string CleanManagedFields bool - CleanNullFields bool CleanReleaseAnnosLabels bool CleanRuntimeData bool CleanWerfIoAnnos bool @@ -71,10 +70,6 @@ func CleanUnstruct(unstruct *unstructured.Unstructured, opts CleanUnstructOption unstructCopy.SetLabels(filteredLabels) } - if opts.CleanNullFields { - unstructCopy.Object = cleanNulls(unstructCopy.Object).(map[string]interface{}) - } - return unstructCopy } From 1c5d25ec2a2768aa7e45b6d08367e58694a6c836 Mon Sep 17 00:00:00 2001 From: Ilya Lesikov Date: Tue, 4 Aug 2026 14:54:30 +0300 Subject: [PATCH 72/85] fix: injected service values might break values.schema.json Signed-off-by: Ilya Lesikov --- cmd/nelm/chart_render.go | 7 + docs/reference.md | 7 + pkg/action/chart_render.go | 3 + .../chart_render_novalidation_ai_test.go | 50 ++++ pkg/chart/chart_render.go | 186 +++++++++++- pkg/chart/chart_render_fallback_ai_test.go | 281 ++++++++++++++++++ 6 files changed, 532 insertions(+), 2 deletions(-) create mode 100644 pkg/action/chart_render_novalidation_ai_test.go create mode 100644 pkg/chart/chart_render_fallback_ai_test.go diff --git a/cmd/nelm/chart_render.go b/cmd/nelm/chart_render.go index 3c23adad..64103668 100644 --- a/cmd/nelm/chart_render.go +++ b/cmd/nelm/chart_render.go @@ -74,6 +74,13 @@ func newChartRenderCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[* return fmt.Errorf("add secret values flags: %w", err) } + if err := cli.AddFlag(cmd, &cfg.NoValuesSchemaValidation, "no-values-schema-validation", false, "Disable values validation against JSON schema", cli.AddFlagOptions{ + GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, + Group: resourceValidationGroup, + }); err != nil { + return fmt.Errorf("add flag: %w", err) + } + if err := cli.AddFlag(cmd, &cfg.ChartAppVersion, "app-version", "", "Set appVersion of Chart.yaml", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: patchFlagGroup, diff --git a/docs/reference.md b/docs/reference.md index e871b25c..f0281772 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -2540,6 +2540,13 @@ nelm chart render [options...] [chart-dir|chart-repo-name/chart-name|chart-archi Secret values files paths\. Vars: \$NELM\_SECRET\_VALUES, \$NELM\_CHART\_RENDER\_SECRET\_VALUES +**Resource validation options:** + +- `--no-values-schema-validation` (default: `false`) + + Disable values validation against JSON schema\. Vars: \$NELM\_NO\_VALUES\_SCHEMA\_VALIDATION, \$NELM\_CHART\_RENDER\_NO\_VALUES\_SCHEMA\_VALIDATION + + **Patch options:** - `--annotations` (default: `{}`) diff --git a/pkg/action/chart_render.go b/pkg/action/chart_render.go index 947adb4a..106398b5 100644 --- a/pkg/action/chart_render.go +++ b/pkg/action/chart_render.go @@ -100,6 +100,8 @@ type ChartRenderOptions struct { // NetworkParallelism limits the number of concurrent network-related operations (API calls, resource fetches). // Defaults to DefaultNetworkParallelism if not set or <= 0. NetworkParallelism int + // NoValuesSchemaValidation disables validation of values against the chart's JSON schema. + NoValuesSchemaValidation bool // OutputFilePath, if specified, writes the rendered manifests to this file instead of stdout. OutputFilePath string // OutputNoPrint, when true, suppresses printing the rendered manifests to stdout. @@ -273,6 +275,7 @@ func ChartRender(ctx context.Context, opts ChartRenderOptions) (*ChartRenderResu IgnoreBundleJS: opts.IgnoreBundleJS, DenoBinaryPath: opts.DenoBinaryPath, LocalLookupResourcesPaths: opts.LocalLookupResourcesPaths, + NoValuesSchemaValidation: opts.NoValuesSchemaValidation, } log.Default.Debug(ctx, "Render chart") diff --git a/pkg/action/chart_render_novalidation_ai_test.go b/pkg/action/chart_render_novalidation_ai_test.go new file mode 100644 index 00000000..76101c7a --- /dev/null +++ b/pkg/action/chart_render_novalidation_ai_test.go @@ -0,0 +1,50 @@ +//go:build ai_tests + +package action + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAI_ChartRender_NoValuesSchemaValidation_SkipsValidation(t *testing.T) { + dir := writeSchemaViolatingChartAI(t) + + _, errWithout := ChartRender(context.Background(), ChartRenderOptions{ + Chart: dir, + OutputNoPrint: true, + }) + require.Error(t, errWithout, "render must fail when user values violate the schema and the flag is off") + + _, errWith := ChartRender(context.Background(), ChartRenderOptions{ + Chart: dir, + OutputNoPrint: true, + NoValuesSchemaValidation: true, + }) + assert.NoError(t, errWith, "render must succeed when values-schema validation is disabled") +} + +func writeSchemaViolatingChartAI(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + require.NoError(t, os.WriteFile(filepath.Join(dir, "Chart.yaml"), []byte("apiVersion: v2\nname: novalidation\nversion: 0.1.0\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "values.yaml"), []byte("foo: bar\nbadkey: oops\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "values.schema.json"), []byte(`{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { "foo": { "type": "string" } } +}`), 0o644)) + + tmplDir := filepath.Join(dir, "templates") + require.NoError(t, os.MkdirAll(tmplDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmplDir, "cm.yaml"), []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cm\ndata:\n foo: {{ .Values.foo }}\n"), 0o644)) + + return dir +} diff --git a/pkg/chart/chart_render.go b/pkg/chart/chart_render.go index c917ee5e..634978dd 100644 --- a/pkg/chart/chart_render.go +++ b/pkg/chart/chart_render.go @@ -13,6 +13,7 @@ import ( "unicode" "github.com/goccy/go-yaml" + "github.com/mitchellh/copystructure" "github.com/samber/lo" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -159,6 +160,11 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s log.Default.TraceStruct(ctx, loadedChart, "Chart:") + pristineOverrideValues, err := deepCopyValues(overrideValues) + if err != nil { + return nil, fmt.Errorf("copy override values for chart %q: %w", chartAccessor.Name(), err) + } + if chartV2 != nil { if err := chartv2util.ProcessDependencies(chartV2, &overrideValues); err != nil { return nil, fmt.Errorf("process chart %q dependencies: %w", chartV2.Name(), err) @@ -214,13 +220,15 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s log.Default.Debug(ctx, "Rendering values for chart at %q", chartPath) - renderedValues, err := chartcommonutil.ToRenderValuesWithSchemaValidation(loadedChart, overrideValues, chartcommon.ReleaseOptions{ + releaseOptions := chartcommon.ReleaseOptions{ Name: releaseName, Namespace: releaseNamespace, Revision: revision, IsInstall: !isUpgrade, IsUpgrade: isUpgrade, - }, caps, opts.NoValuesSchemaValidation) + } + + renderedValues, err := renderValuesToleratingServiceValues(ctx, chartPath, loadedChart, overrideValues, pristineOverrideValues, releaseOptions, caps, opts.NoValuesSchemaValidation) if err != nil { return nil, fmt.Errorf("build rendered values for chart %q: %w", chartAccessor.Name(), err) } @@ -340,6 +348,102 @@ func RenderChart(ctx context.Context, chartPath, releaseName, releaseNamespace s }, nil } +// renderValuesToleratingServiceValues coalesces and schema-validates the chart values, +// tolerating the werf/nelm service values (werf, global, dockerconfigjson) that are injected +// into every chart via ExtraValues. A chart whose values.schema.json rejects unknown keys +// (e.g. additionalProperties:false) would otherwise fail validation solely because of those +// injected keys, even when the user's own values are valid — the regression fixed here. +// +// On a validation failure, and only when the chart actually carries service values, the chart +// is re-loaded from disk with ExtraValues cleared, its dependencies are re-processed from the +// pristine (pre-processing) override values, and the service-free values are validated instead. +// If those pass, the failure was caused only by the injected service values, so the render +// proceeds with the original (service-merged) values and a debug message is logged. If they +// still fail, the service-free error is returned, so the message never names keys the user did +// not write. +// +// The fallback re-loads and re-validates because ProcessDependencies bakes the service values +// into the chart's (and subcharts') coalesced defaults before this point, so suppressing them +// after the fact is not possible without re-deriving from a pristine chart state. +// +// The fallback is only trusted when the service-free chart resolves the SAME dependency graph +// as the rendered one: dependency conditions are evaluated against coalesced values that +// include the service values (e.g. a `condition: werf.is_stub`), so clearing them could enable +// or disable a subchart and make the validated graph differ from the rendered one. When the +// graphs differ, the fallback is refused and the original error is returned pointing at the +// explicit escape hatch. +// +// The accept path re-coalesces the original chart rather than reusing the first result. This +// is exact for the service values werf injects, which never contain explicit nil leaves; a nil +// leaf inside ExtraValues could in theory make the re-coalesce diverge from the first one, but +// werf's GetServiceValues never produces one. +func renderValuesToleratingServiceValues(ctx context.Context, chartPath string, loadedChart helmchart.Charter, overrideValues, pristineOverrideValues map[string]interface{}, releaseOptions chartcommon.ReleaseOptions, caps *chartcommon.Capabilities, skipSchemaValidation bool) (chartcommon.Values, error) { + renderedValues, err := chartcommonutil.ToRenderValuesWithSchemaValidation(loadedChart, overrideValues, releaseOptions, caps, skipSchemaValidation) + if err == nil { + return renderedValues, nil + } + + if skipSchemaValidation || !chartHasExtraValues(loadedChart) { + return nil, fmt.Errorf("render values: %w", err) + } + + serviceFreeChart, serviceFreeOverrideValues, loadErr := loadServiceFreeChart(ctx, chartPath, pristineOverrideValues) + if loadErr != nil { + return nil, fmt.Errorf("validate service-free values: %w (original schema validation error: %w)", loadErr, err) + } + + if !sameDependencyGraph(loadedChart, serviceFreeChart) { + return nil, fmt.Errorf("service values affect which subcharts are enabled, so values cannot be validated without them; re-run with --no-values-schema-validation to skip values schema validation: %w", err) + } + + if _, serviceFreeErr := chartcommonutil.ToRenderValuesWithSchemaValidation(serviceFreeChart, serviceFreeOverrideValues, releaseOptions, caps, false); serviceFreeErr != nil { + return nil, fmt.Errorf("render values: %w", serviceFreeErr) + } + + log.Default.Debug(ctx, "Values schema validation failed only for injected service values; tolerating them for chart at %q", chartPath) + + renderedValues, err = chartcommonutil.ToRenderValuesWithSchemaValidation(loadedChart, overrideValues, releaseOptions, caps, true) + if err != nil { + return nil, fmt.Errorf("render values: %w", err) + } + + return renderedValues, nil +} + +// loadServiceFreeChart re-loads the chart from disk with its service values (ExtraValues) +// cleared and re-processes its dependencies from the pristine override values, so the returned +// chart and override values represent what the user authored without the injected werf/global +// keys. Only infrastructure errors (reload, copy, dependency processing) are returned; schema +// validation of the result is done by the caller. +func loadServiceFreeChart(ctx context.Context, chartPath string, pristineOverrideValues map[string]interface{}) (helmchart.Charter, map[string]interface{}, error) { + freshChart, err := loader.Load(ctx, chartPath) + if err != nil { + return nil, nil, fmt.Errorf("reload chart at %q: %w", chartPath, err) + } + + overrideValues, err := deepCopyValues(pristineOverrideValues) + if err != nil { + return nil, nil, fmt.Errorf("copy override values for chart at %q: %w", chartPath, err) + } + + switch c := freshChart.(type) { + case *v2chart.Chart: + c.ExtraValues = nil + if err := chartv2util.ProcessDependencies(c, &overrideValues); err != nil { + return nil, nil, fmt.Errorf("process chart %q dependencies: %w", c.Name(), err) + } + case *v3chart.Chart: + c.ExtraValues = nil + if err := chartv3util.ProcessDependencies(c, overrideValues); err != nil { + return nil, nil, fmt.Errorf("process chart %q dependencies: %w", c.Name(), err) + } + default: + return nil, nil, fmt.Errorf("reloaded chart at %q has unexpected type %T", chartPath, freshChart) + } + + return freshChart, overrideValues, nil +} + func parseLocalLookupResources(paths []string) ([]*unstructured.Unstructured, error) { var resources []*unstructured.Unstructured @@ -395,6 +499,32 @@ func parseLocalLookupResources(paths []string) ([]*unstructured.Unstructured, er return resources, nil } +// sameDependencyGraph reports whether two processed charts resolve to the same set of enabled +// subcharts (compared by each node's full chart path, recursively). +func sameDependencyGraph(a, b helmchart.Charter) bool { + sigA, err := dependencyGraphSignature(a) + if err != nil { + return false + } + + sigB, err := dependencyGraphSignature(b) + if err != nil { + return false + } + + if len(sigA) != len(sigB) { + return false + } + + for i := range sigA { + if sigA[i] != sigB[i] { + return false + } + } + + return true +} + func buildChartCapabilities(ctx context.Context, clientFactory kube.ClientFactorier, opts buildChartCapabilitiesOptions) (*chartcommon.Capabilities, error) { capabilities := &chartcommon.Capabilities{ HelmVersion: chartcommon.DefaultCapabilities.HelmVersion, @@ -489,6 +619,17 @@ func buildContextFromJSONSets(jsonSets []string) (map[string]interface{}, error) return context, nil } +func chartHasExtraValues(chrt helmchart.Charter) bool { + switch c := chrt.(type) { + case *v2chart.Chart: + return len(c.ExtraValues) > 0 + case *v3chart.Chart: + return len(c.ExtraValues) > 0 + default: + return false + } +} + func collectLocalLookupResource(unstruct *unstructured.Unstructured, seen map[string]bool) (*unstructured.Unstructured, error) { if unstruct.GetAPIVersion() == "" { return nil, fmt.Errorf("apiVersion is missing") @@ -510,6 +651,47 @@ func collectLocalLookupResource(unstruct *unstructured.Unstructured, seen map[st return unstruct, nil } +func deepCopyValues(vals map[string]interface{}) (map[string]interface{}, error) { + if vals == nil { + return map[string]interface{}{}, nil + } + + copied, err := copystructure.Copy(vals) + if err != nil { + return nil, fmt.Errorf("deep copy values: %w", err) + } + + return copied.(map[string]interface{}), nil +} + +func dependencyGraphSignature(chrt helmchart.Charter) ([]string, error) { + acc, err := helmchart.NewAccessor(chrt) + if err != nil { + return nil, fmt.Errorf("create chart accessor: %w", err) + } + + var paths []string + for _, dep := range acc.Dependencies() { + depAcc, err := helmchart.NewAccessor(dep) + if err != nil { + return nil, fmt.Errorf("create dependency accessor: %w", err) + } + + paths = append(paths, depAcc.ChartFullPath()) + + subPaths, err := dependencyGraphSignature(dep) + if err != nil { + return nil, err + } + + paths = append(paths, subPaths...) + } + + sort.Strings(paths) + + return paths, nil +} + func isLocalChart(path string) bool { return filepath.IsAbs(path) || filepath.HasPrefix(path, "..") || filepath.HasPrefix(path, ".") } diff --git a/pkg/chart/chart_render_fallback_ai_test.go b/pkg/chart/chart_render_fallback_ai_test.go new file mode 100644 index 00000000..2c594ac6 --- /dev/null +++ b/pkg/chart/chart_render_fallback_ai_test.go @@ -0,0 +1,281 @@ +//go:build ai_tests + +package chart + +import ( + "bytes" + "context" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/werf/logboek" + nelmcommon "github.com/werf/nelm/pkg/common" + v3chart "github.com/werf/nelm/pkg/helm/intern/chart/v3" + chartcommon "github.com/werf/nelm/pkg/helm/pkg/chart/common" + "github.com/werf/nelm/pkg/helm/pkg/chart/loader" + v2chart "github.com/werf/nelm/pkg/helm/pkg/chart/v2" + chartv2util "github.com/werf/nelm/pkg/helm/pkg/chart/v2/util" + "github.com/werf/nelm/pkg/log" +) + +type fallbackChartFilesAI struct { + childSchema string + dependencyBlock string + rootSchema string + userValuesYAML string +} + +type capturingLoggerAI struct { + log.Logger + + messages bytes.Buffer + mu sync.Mutex +} + +func (l *capturingLoggerAI) Debug(ctx context.Context, format string, a ...interface{}) { + l.mu.Lock() + defer l.mu.Unlock() + + l.messages.WriteString(format) +} + +func (l *capturingLoggerAI) sawFallbackDebug() bool { + l.mu.Lock() + defer l.mu.Unlock() + + return bytes.Contains(l.messages.Bytes(), []byte("injected service values")) +} + +func TestAI_ServiceValuesFallback_ChartWithSubchart_StrictRootSchema_Renders(t *testing.T) { + chartPath := writeFallbackChartAI(t, fallbackChartFilesAI{rootSchema: strictRootSchemaJSONAI(), userValuesYAML: "foo: bar\n"}) + ctx, c := loadChartWithServiceValuesAI(t, chartPath, serviceExtraValuesAI()) + require.NotEmpty(t, c.ExtraValues) + + vals, err := runFallbackAI(t, ctx, chartPath, c, map[string]any{"foo": "override"}) + require.NoError(t, err) + + rendered := vals.AsMap()["Values"] + renderedMap, ok := rendered.(chartcommon.Values) + if !ok { + renderedMap = chartcommon.Values(rendered.(map[string]any)) + } + assert.Contains(t, renderedMap, "werf", "service values must remain in the rendered values") + assert.Contains(t, renderedMap, "global") +} + +func TestAI_ServiceValuesFallback_ConditionOnServiceValue_DivergentGraph_Refused(t *testing.T) { + chartPath := writeFallbackChartAI(t, fallbackChartFilesAI{ + dependencyBlock: "dependencies:\n - name: child\n version: 0.1.0\n condition: werf.is_stub\n", + rootSchema: strictRootSchemaJSONAI(), + userValuesYAML: "foo: bar\nwerf:\n is_stub: false\n", + }) + extra := map[string]any{ + "werf": map[string]any{"name": "proj", "is_stub": true}, + "global": map[string]any{"werf": map[string]any{"name": "proj"}}, + } + ctx, c := loadChartWithServiceValuesAI(t, chartPath, extra) + + _, err := runFallbackAI(t, ctx, chartPath, c, map[string]any{"foo": "override"}) + require.Error(t, err, "when a service value drives dependency enablement, the fallback must refuse rather than validate a different graph") + assert.Contains(t, err.Error(), "--no-values-schema-validation") +} + +func TestAI_ServiceValuesFallback_DebugMessage_OnlyOnAccept(t *testing.T) { + original := log.Default + captured := &capturingLoggerAI{Logger: original} + log.Default = captured + t.Cleanup(func() { log.Default = original }) + + chartPath := writeFallbackChartAI(t, fallbackChartFilesAI{rootSchema: strictRootSchemaJSONAI(), userValuesYAML: "foo: bar\n"}) + + ctx, c := loadChartWithServiceValuesAI(t, chartPath, serviceExtraValuesAI()) + _, err := runFallbackAI(t, ctx, chartPath, c, map[string]any{"foo": "override"}) + require.NoError(t, err) + assert.True(t, captured.sawFallbackDebug(), "accepted fallback must emit the debug message") + + captured.messages.Reset() + + ctx2, c2 := loadChartWithServiceValuesAI(t, chartPath, serviceExtraValuesAI()) + _, err = runFallbackAI(t, ctx2, chartPath, c2, map[string]any{"foo": "override", "fooo": "typo"}) + require.Error(t, err) + assert.False(t, captured.sawFallbackDebug(), "rejected fallback must not emit the accept debug message") +} + +func TestAI_ServiceValuesFallback_DeliberateStringConstraintOnServiceKey_Bypassed(t *testing.T) { + cases := map[string]string{ + "root werf as string": `{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"foo":{"type":"string"},"werf":{"type":"string"}}}`, + "global.werf as string": `{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"foo":{"type":"string"},"global":{"type":"object","properties":{"werf":{"type":"string"}}}}}`, + "global.werf.name as integer": `{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"foo":{"type":"string"},"global":{"type":"object","properties":{"werf":{"type":"object","properties":{"name":{"type":"integer"}}}}}}}`, + } + + for name, schema := range cases { + t.Run(name, func(t *testing.T) { + chartPath := writeFallbackChartAI(t, fallbackChartFilesAI{rootSchema: schema, userValuesYAML: "foo: bar\n"}) + ctx, c := loadChartWithServiceValuesAI(t, chartPath, serviceExtraValuesAI()) + + _, err := runFallbackAI(t, ctx, chartPath, c, map[string]any{"foo": "override"}) + require.NoError(t, err, "a deliberate but unsatisfiable-under-werf constraint on a service key is bypassed by design") + }) + } +} + +func TestAI_ServiceValuesFallback_KeywordAgnostic_PassThrough(t *testing.T) { + cases := map[string]string{ + "unevaluatedProperties false": `{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"foo":{"type":"string"},"child":{"type":"object"}},"unevaluatedProperties":false}`, + "not required werf": `{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","not":{"required":["werf"]}}`, + "propertyNames restricted": `{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","propertyNames":{"enum":["foo","child"]}}`, + } + + for name, schema := range cases { + t.Run(name, func(t *testing.T) { + chartPath := writeFallbackChartAI(t, fallbackChartFilesAI{rootSchema: schema, userValuesYAML: "foo: bar\n"}) + ctx, c := loadChartWithServiceValuesAI(t, chartPath, serviceExtraValuesAI()) + + _, err := runFallbackAI(t, ctx, chartPath, c, map[string]any{"foo": "override"}) + require.NoError(t, err, "the fallback re-runs the real validator, so it tolerates service values under any rejection keyword") + }) + } +} + +func TestAI_ServiceValuesFallback_NoExtraValues_NotEntered(t *testing.T) { + original := log.Default + captured := &capturingLoggerAI{Logger: original} + log.Default = captured + t.Cleanup(func() { log.Default = original }) + + chartPath := writeFallbackChartAI(t, fallbackChartFilesAI{rootSchema: strictRootSchemaJSONAI(), userValuesYAML: "foo: bar\n"}) + ctx, c := loadChartWithServiceValuesAI(t, chartPath, nil) + require.Empty(t, c.ExtraValues) + require.False(t, chartHasExtraValues(c), "guard must report no service values") + + _, err := runFallbackAI(t, ctx, chartPath, c, map[string]any{"foo": "override", "fooo": "typo"}) + require.Error(t, err, "plain-nelm schema violations must still surface, unchanged") + assert.Contains(t, err.Error(), "fooo") + assert.False(t, captured.sawFallbackDebug(), "the fallback must not be entered when the chart carries no service values") +} + +func TestAI_ServiceValuesFallback_RequiredWerf_ForbiddenGlobal_StillFails(t *testing.T) { + schema := `{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["werf"],"properties":{"foo":{"type":"string"},"child":{"type":"object"},"werf":{"type":"object"}}}` + chartPath := writeFallbackChartAI(t, fallbackChartFilesAI{rootSchema: schema, userValuesYAML: "foo: bar\n"}) + ctx, c := loadChartWithServiceValuesAI(t, chartPath, serviceExtraValuesAI()) + + _, err := runFallbackAI(t, ctx, chartPath, c, map[string]any{"foo": "override"}) + require.Error(t, err, "a schema that requires werf but forbids global is unsatisfiable under werf; the service-free fallback drops werf and fails") + assert.Contains(t, err.Error(), "werf") +} + +func TestAI_ServiceValuesFallback_SubchartOwnStrictSchema_ReceivesPropagatedGlobal_Renders(t *testing.T) { + childSchema := `{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"properties":{"a":{"type":"integer"},"global":{"type":"object"}}}` + chartPath := writeFallbackChartAI(t, fallbackChartFilesAI{ + childSchema: childSchema, + rootSchema: strictRootSchemaJSONAI(), + userValuesYAML: "foo: bar\n", + }) + ctx, c := loadChartWithServiceValuesAI(t, chartPath, serviceExtraValuesAI()) + + _, err := runFallbackAI(t, ctx, chartPath, c, map[string]any{"foo": "override"}) + require.NoError(t, err, "subchart with its own strict schema must pass once service-injected global is stripped") +} + +func TestAI_ServiceValuesFallback_UserTypo_StillFails_NamingOnlyTypo(t *testing.T) { + chartPath := writeFallbackChartAI(t, fallbackChartFilesAI{rootSchema: strictRootSchemaJSONAI(), userValuesYAML: "foo: bar\n"}) + ctx, c := loadChartWithServiceValuesAI(t, chartPath, serviceExtraValuesAI()) + + _, err := runFallbackAI(t, ctx, chartPath, c, map[string]any{"foo": "override", "fooo": "typo"}) + require.Error(t, err) + + msg := err.Error() + assert.Contains(t, msg, "fooo") + assert.NotContains(t, msg, "werf") + assert.NotContains(t, msg, "global") +} + +func TestAI_chartHasExtraValues(t *testing.T) { + assert.False(t, chartHasExtraValues(&v2chart.Chart{}), "v2 chart with nil ExtraValues") + assert.False(t, chartHasExtraValues(&v2chart.Chart{ExtraValues: map[string]any{}}), "v2 chart with empty ExtraValues") + assert.True(t, chartHasExtraValues(&v2chart.Chart{ExtraValues: map[string]any{"werf": map[string]any{}}}), "v2 chart with populated ExtraValues") + assert.False(t, chartHasExtraValues(&v3chart.Chart{}), "v3 chart with nil ExtraValues") + assert.True(t, chartHasExtraValues(&v3chart.Chart{ExtraValues: map[string]any{"werf": map[string]any{}}}), "v3 chart with populated ExtraValues") +} + +func runFallbackAI(t *testing.T, ctx context.Context, chartPath string, c *v2chart.Chart, overrideValues map[string]any) (chartcommon.Values, error) { + t.Helper() + + pristine, err := deepCopyValues(overrideValues) + require.NoError(t, err) + + require.NoError(t, chartv2util.ProcessDependencies(c, &overrideValues)) + + return renderValuesToleratingServiceValues(ctx, chartPath, c, overrideValues, pristine, renderReleaseOptionsAI(), nil, false) +} + +func loadChartWithServiceValuesAI(t *testing.T, chartPath string, extra map[string]any) (context.Context, *v2chart.Chart) { + t.Helper() + + ctx := logboek.NewContext(context.Background(), logboek.DefaultLogger()) + + helmOpts := nelmcommon.HelmOptions{} + helmOpts.ChartLoadOpts.ExtraValues = extra + ctx = nelmcommon.ContextWithHelmOptions(ctx, helmOpts) + + loaded, err := loader.Load(ctx, chartPath) + require.NoError(t, err) + + c, ok := loaded.(*v2chart.Chart) + require.True(t, ok, "expected v2 chart, got %T", loaded) + + return ctx, c +} + +func renderReleaseOptionsAI() chartcommon.ReleaseOptions { + return chartcommon.ReleaseOptions{Name: "rel", Namespace: "ns", Revision: 1, IsInstall: true} +} + +func serviceExtraValuesAI() map[string]any { + return map[string]any{ + "werf": map[string]any{"name": "proj"}, + "global": map[string]any{"werf": map[string]any{"name": "proj"}}, + } +} + +func strictRootSchemaJSONAI() string { + return `{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"properties":{"foo":{"type":"string"},"child":{"type":"object"}}}` +} + +func writeFallbackChartAI(t *testing.T, files fallbackChartFilesAI) string { + t.Helper() + + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "templates"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "charts", "child", "templates"), 0o755)) + + dependencyBlock := files.dependencyBlock + if dependencyBlock == "" { + dependencyBlock = "dependencies:\n - name: child\n version: 0.1.0\n" + } + + require.NoError(t, os.WriteFile(filepath.Join(dir, "Chart.yaml"), + []byte("apiVersion: v2\nname: parent\nversion: 0.1.0\n"+dependencyBlock), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "values.yaml"), []byte(files.userValuesYAML), 0o644)) + if files.rootSchema != "" { + require.NoError(t, os.WriteFile(filepath.Join(dir, "values.schema.json"), []byte(files.rootSchema), 0o644)) + } + require.NoError(t, os.WriteFile(filepath.Join(dir, "templates", "cm.yaml"), + []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: p\n"), 0o644)) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "charts", "child", "Chart.yaml"), + []byte("apiVersion: v2\nname: child\nversion: 0.1.0\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "charts", "child", "values.yaml"), []byte("a: 1\n"), 0o644)) + if files.childSchema != "" { + require.NoError(t, os.WriteFile(filepath.Join(dir, "charts", "child", "values.schema.json"), []byte(files.childSchema), 0o644)) + } + require.NoError(t, os.WriteFile(filepath.Join(dir, "charts", "child", "templates", "cm.yaml"), + []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: c\n"), 0o644)) + + return dir +} From 9d049189ce4e8ba2035cf26837dde4c831c34e34 Mon Sep 17 00:00:00 2001 From: Ilya Drey Date: Wed, 5 Aug 2026 11:29:59 +0300 Subject: [PATCH 73/85] fix: optimize validation args Signed-off-by: Ilya Drey --- cmd/nelm/common_flags.go | 7 ------- docs/reference.md | 16 ---------------- pkg/common/options.go | 2 -- pkg/resource/kubeconform.go | 6 +----- pkg/resource/validate.go | 2 +- 5 files changed, 2 insertions(+), 31 deletions(-) diff --git a/cmd/nelm/common_flags.go b/cmd/nelm/common_flags.go index d4020616..464ce924 100644 --- a/cmd/nelm/common_flags.go +++ b/cmd/nelm/common_flags.go @@ -351,13 +351,6 @@ func AddResourceValidationFlags(cmd *cobra.Command, cfg *common.ResourceValidati return fmt.Errorf("add flag: %w", err) } - if err := cli.AddFlag(cmd, &cfg.LocalResourceValidation, "local-resource-validation", false, "Do not use external json schema sources, validate against the json schemas embedded into the binary instead", cli.AddFlagOptions{ - GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, - Group: resourceValidationGroup, - }); err != nil { - return fmt.Errorf("add flag: %w", err) - } - if err := cli.AddFlag(cmd, &cfg.ValidationSkip, "resource-validation-skip", []string{}, "Skip resource validation for resources with specified attributes. Format: key1=value1,key2=value2. Supported keys: group, version, kind, name, namespace. Example: kind=Deployment,name=my-app", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: resourceValidationGroup, diff --git a/docs/reference.md b/docs/reference.md index f0281772..dbe0dee8 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -264,10 +264,6 @@ nelm release install [options...] -n namespace -r release [chart-dir|chart-repo- **Resource validation options:** -- `--local-resource-validation` (default: `false`) - - Do not use external json schema sources, validate against the json schemas embedded into the binary instead\. Vars: \$NELM\_LOCAL\_RESOURCE\_VALIDATION, \$NELM\_RELEASE\_INSTALL\_LOCAL\_RESOURCE\_VALIDATION - - `--no-resource-validation` (default: `false`) Disable resource validation\. Vars: \$NELM\_NO\_RESOURCE\_VALIDATION, \$NELM\_RELEASE\_INSTALL\_NO\_RESOURCE\_VALIDATION @@ -621,10 +617,6 @@ nelm release rollback [options...] -n namespace -r release [revision] **Resource validation options:** -- `--local-resource-validation` (default: `false`) - - Do not use external json schema sources, validate against the json schemas embedded into the binary instead\. Vars: \$NELM\_LOCAL\_RESOURCE\_VALIDATION, \$NELM\_RELEASE\_ROLLBACK\_LOCAL\_RESOURCE\_VALIDATION - - `--no-resource-validation` (default: `false`) Disable resource validation\. Vars: \$NELM\_NO\_RESOURCE\_VALIDATION, \$NELM\_RELEASE\_ROLLBACK\_NO\_RESOURCE\_VALIDATION @@ -990,10 +982,6 @@ nelm release plan install [options...] -n namespace -r release [chart-dir|chart- **Resource validation options:** -- `--local-resource-validation` (default: `false`) - - Do not use external json schema sources, validate against the json schemas embedded into the binary instead\. Vars: \$NELM\_LOCAL\_RESOURCE\_VALIDATION, \$NELM\_RELEASE\_PLAN\_INSTALL\_LOCAL\_RESOURCE\_VALIDATION - - `--no-resource-validation` (default: `false`) Disable resource validation\. Vars: \$NELM\_NO\_RESOURCE\_VALIDATION, \$NELM\_RELEASE\_PLAN\_INSTALL\_NO\_RESOURCE\_VALIDATION @@ -2159,10 +2147,6 @@ nelm chart lint [options...] [chart-dir|chart-repo-name/chart-name|chart-archive **Resource validation options:** -- `--local-resource-validation` (default: `false`) - - Do not use external json schema sources, validate against the json schemas embedded into the binary instead\. Vars: \$NELM\_LOCAL\_RESOURCE\_VALIDATION, \$NELM\_CHART\_LINT\_LOCAL\_RESOURCE\_VALIDATION - - `--no-resource-validation` (default: `false`) Disable resource validation\. Vars: \$NELM\_NO\_RESOURCE\_VALIDATION, \$NELM\_CHART\_LINT\_NO\_RESOURCE\_VALIDATION diff --git a/pkg/common/options.go b/pkg/common/options.go index 964115a0..cc018a0f 100644 --- a/pkg/common/options.go +++ b/pkg/common/options.go @@ -222,8 +222,6 @@ type ResourceValidationOptions struct { NoResourceValidation bool `json:"noResourceValidation"` // NoValuesSchemaValidation disables values validation against json schema. NoValuesSchemaValidation bool `json:"noValuesSchemaValidation"` - // LocalResourceValidation validates by using kubeconform embedded schemas and client-go codec only. - LocalResourceValidation bool `json:"localResourceValidation"` // ValidationSkip Do not validate resources with specific attributes. ValidationSkip []string `json:"validationSkip"` // ValidationSchemaCacheLifetime how long the schema cache should be valid. diff --git a/pkg/resource/kubeconform.go b/pkg/resource/kubeconform.go index 33e69f77..712ab96a 100644 --- a/pkg/resource/kubeconform.go +++ b/pkg/resource/kubeconform.go @@ -44,7 +44,7 @@ type kubeConformValidator struct { validators []*kubeConformInstance } -func newKubeConformValidator(schemaCacheLifetime time.Duration, schemaSources []string, embeddedSchemasOnly bool) (*kubeConformValidator, error) { +func newKubeConformValidator(schemaCacheLifetime time.Duration, schemaSources []string) (*kubeConformValidator, error) { kubernetesSource, err := schemas.KubernetesSource() if err != nil { return nil, fmt.Errorf("get embedded Kubernetes schemas: %w", err) @@ -55,10 +55,6 @@ func newKubeConformValidator(schemaCacheLifetime time.Duration, schemaSources [] return nil, fmt.Errorf("get embedded CRD schemas: %w", err) } - if embeddedSchemasOnly { - schemaSources = nil - } - cacheSubDirName := getHash(strings.Join(schemaSources, "-")) sources := slices.Clone(schemaSources) diff --git a/pkg/resource/validate.go b/pkg/resource/validate.go index 700d127e..1c826fec 100644 --- a/pkg/resource/validate.go +++ b/pkg/resource/validate.go @@ -45,7 +45,7 @@ func validateResourceSchemas(ctx context.Context, releaseNamespace string, resou kubeConformValidator, err := newKubeConformValidator( opts.ValidationSchemaCacheLifetime, opts.ValidationExtraSchemas, - opts.LocalResourceValidation) + ) if err != nil { return fmt.Errorf("get schema validator: %w", err) } From b4771c646edee72cbf26757fb027e70d389ea4d6 Mon Sep 17 00:00:00 2001 From: Ilya Lesikov Date: Thu, 6 Aug 2026 12:25:43 +0300 Subject: [PATCH 74/85] fix: exclude external dependencies from readiness forcing `forceReadinessTrackingForReadyDependencyTargets` iterated every ready dependency and matched it against local resource infos. External dependencies are tracked by their own operation keyed on the dependency itself (`resolveTrackReadinessOpInStage` never consults local infos for them), so forcing a same-named local resource served no edge while silently overriding that resource's chart-authored fail mode with `FailWholeDeployProcessImmediately`. Upstream had no `External` notion when this function was written, so the port inherited the gap. Also extract the triplicated release-lock construction guard into `newReleaseLockManager` so install/rollback/uninstall share one seam that tests can exercise, and rename `CompareInternalDependencyOption` to match the `Dependency` type it actually sorts. Signed-off-by: Ilya Lesikov --- pkg/action/common.go | 16 ++++++++ pkg/action/release_install.go | 13 ++----- pkg/action/release_lock_ai_test.go | 31 +++++----------- pkg/action/release_rollback.go | 13 ++----- pkg/action/release_uninstall.go | 13 ++----- pkg/plan/ready_dependency_override_ai_test.go | 37 +++++++++++++++++++ pkg/plan/resource_info.go | 7 ++++ pkg/plan/resource_info_test.go | 2 +- pkg/resource/resource_test.go | 2 +- pkg/test/comparer.go | 2 +- 10 files changed, 85 insertions(+), 51 deletions(-) diff --git a/pkg/action/common.go b/pkg/action/common.go index bb079cbb..8a7114c1 100644 --- a/pkg/action/common.go +++ b/pkg/action/common.go @@ -24,6 +24,7 @@ import ( helmchart "github.com/werf/nelm/pkg/helm/pkg/chart" helmreleasestatus "github.com/werf/nelm/pkg/helm/pkg/release/common" "github.com/werf/nelm/pkg/kube" + "github.com/werf/nelm/pkg/lock" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/plan" "github.com/werf/nelm/pkg/release" @@ -294,3 +295,18 @@ func writeWithSyntaxHighlight(outStream io.Writer, text, lang string, colorLevel return nil } + +// Reports enabled=false when release locking is disabled, in which case callers must skip acquiring +// the lock entirely. +func newReleaseLockManager(ctx context.Context, releaseNamespace string, clientFactory kube.ClientFactorier, legacyNoReleaseLock bool) (*lock.LockManager, bool, error) { + if legacyNoReleaseLock { + return nil, false, nil + } + + lockManager, err := lock.NewLockManager(ctx, releaseNamespace, false, clientFactory) + if err != nil { + return nil, false, fmt.Errorf("construct lock manager: %w", err) + } + + return lockManager, true, nil +} diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index b9e66290..2b598cba 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -27,7 +27,6 @@ import ( helmreleasestatus "github.com/werf/nelm/pkg/helm/pkg/release/common" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/legacy/progrep" - "github.com/werf/nelm/pkg/lock" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/plan" "github.com/werf/nelm/pkg/release" @@ -272,13 +271,9 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re return fmt.Errorf("construct release storage: %w", err) } - var lockManager *lock.LockManager - if !opts.LegacyNoReleaseLock { - if m, err := lock.NewLockManager(ctx, releaseNamespace, false, clientFactory); err != nil { - return fmt.Errorf("construct lock manager: %w", err) - } else { - lockManager = m - } + lockManager, lockEnabled, err := newReleaseLockManager(ctx, releaseNamespace, clientFactory, opts.LegacyNoReleaseLock) + if err != nil { + return err } if !opts.NoCreateNamespace { @@ -289,7 +284,7 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re log.Default.Info(ctx, color.Style{color.Bold, color.Green}.Render("Start release")+" %q (namespace: %q)", releaseName, releaseNamespace) - if lockManager != nil { + if lockEnabled { if lock, err := lockManager.LockRelease(ctx, releaseName); err != nil { return fmt.Errorf("lock release: %w", err) } else { diff --git a/pkg/action/release_lock_ai_test.go b/pkg/action/release_lock_ai_test.go index c8139c6a..14f8fed5 100644 --- a/pkg/action/release_lock_ai_test.go +++ b/pkg/action/release_lock_ai_test.go @@ -16,7 +16,6 @@ import ( "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/kube/fake" - "github.com/werf/nelm/pkg/lock" ) const ( @@ -26,8 +25,8 @@ const ( var lockConfigMapGVR = schema.GroupVersionResource{Version: "v1", Resource: "configmaps"} -// The release actions gate locking behind LegacyNoReleaseLock by leaving lockManager nil, so what -// distinguishes the two paths is whether the lock ConfigMap is ever touched in the cluster. +// Exercises newReleaseLockManager, the seam all three release actions use to gate locking behind +// LegacyNoReleaseLock: a nil manager means the caller skips acquiring the lock entirely. func TestAI_ReleaseLockAcquiredWhenLegacyNoReleaseLockDisabled(t *testing.T) { ctx := context.Background() @@ -36,7 +35,9 @@ func TestAI_ReleaseLockAcquiredWhenLegacyNoReleaseLockDisabled(t *testing.T) { require.False(t, lockConfigMapExists(t, ctx, clientFactory), "lock ConfigMap must not exist before locking") - lockManager := newLockManagerForOptions(t, ctx, clientFactory, false) + lockManager, lockEnabled, err := newReleaseLockManager(ctx, lockTestReleaseNamespace, clientFactory, false) + require.NoError(t, err) + require.True(t, lockEnabled, "locking must be reported enabled") require.NotNil(t, lockManager, "lock manager must be constructed when locking is enabled") handle, err := lockManager.LockRelease(ctx, lockTestReleaseName) @@ -53,7 +54,9 @@ func TestAI_ReleaseLockSkippedWhenLegacyNoReleaseLockEnabled(t *testing.T) { clientFactory, err := fake.NewClientFactory(ctx) require.NoError(t, err) - lockManager := newLockManagerForOptions(t, ctx, clientFactory, true) + lockManager, lockEnabled, err := newReleaseLockManager(ctx, lockTestReleaseNamespace, clientFactory, true) + require.NoError(t, err) + assert.False(t, lockEnabled, "locking must be reported disabled so callers skip acquiring") assert.Nil(t, lockManager, "lock manager must not be constructed when locking is disabled") assert.False(t, lockConfigMapExists(t, ctx, clientFactory), "disabling the release lock must not touch the lock ConfigMap") @@ -65,7 +68,8 @@ func TestAI_ReleaseLockIsExclusiveWhenLegacyNoReleaseLockDisabled(t *testing.T) clientFactory, err := fake.NewClientFactory(ctx) require.NoError(t, err) - lockManager := newLockManagerForOptions(t, ctx, clientFactory, false) + lockManager, _, err := newReleaseLockManager(ctx, lockTestReleaseNamespace, clientFactory, false) + require.NoError(t, err) require.NotNil(t, lockManager) handle, err := lockManager.LockRelease(ctx, lockTestReleaseName) @@ -90,21 +94,6 @@ func TestAI_ReleaseLockIsExclusiveWhenLegacyNoReleaseLockDisabled(t *testing.T) require.NoError(t, lockManager.Unlock(secondHandle)) } -// Mirrors the lock-manager construction guard shared by ReleaseInstall, ReleaseRollback and -// ReleaseUninstall, whose full action bodies need a real cluster. -func newLockManagerForOptions(t *testing.T, ctx context.Context, clientFactory kube.ClientFactorier, legacyNoReleaseLock bool) *lock.LockManager { - t.Helper() - - if legacyNoReleaseLock { - return nil - } - - lockManager, err := lock.NewLockManager(ctx, lockTestReleaseNamespace, false, clientFactory) - require.NoError(t, err) - - return lockManager -} - func lockConfigMapExists(t *testing.T, ctx context.Context, clientFactory kube.ClientFactorier) bool { t.Helper() diff --git a/pkg/action/release_rollback.go b/pkg/action/release_rollback.go index 690281c4..eb4014f8 100644 --- a/pkg/action/release_rollback.go +++ b/pkg/action/release_rollback.go @@ -20,7 +20,6 @@ import ( helmrel "github.com/werf/nelm/pkg/helm/pkg/release" helmreleasestatus "github.com/werf/nelm/pkg/helm/pkg/release/common" "github.com/werf/nelm/pkg/kube" - "github.com/werf/nelm/pkg/lock" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/plan" "github.com/werf/nelm/pkg/release" @@ -157,18 +156,14 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r return fmt.Errorf("construct release storage: %w", err) } - var lockManager *lock.LockManager - if !opts.LegacyNoReleaseLock { - if m, err := lock.NewLockManager(ctx, releaseNamespace, false, clientFactory); err != nil { - return fmt.Errorf("construct lock manager: %w", err) - } else { - lockManager = m - } + lockManager, lockEnabled, err := newReleaseLockManager(ctx, releaseNamespace, clientFactory, opts.LegacyNoReleaseLock) + if err != nil { + return err } log.Default.Info(ctx, color.Style{color.Bold, color.Green}.Render("Start rollback of release")+" %q (namespace: %q)", releaseName, releaseNamespace) - if lockManager != nil { + if lockEnabled { if lock, err := lockManager.LockRelease(ctx, releaseName); err != nil { return fmt.Errorf("lock release: %w", err) } else { diff --git a/pkg/action/release_uninstall.go b/pkg/action/release_uninstall.go index e0df7de2..e2682f6d 100644 --- a/pkg/action/release_uninstall.go +++ b/pkg/action/release_uninstall.go @@ -22,7 +22,6 @@ import ( helmreleasestatus "github.com/werf/nelm/pkg/helm/pkg/release/common" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/legacy/progrep" - "github.com/werf/nelm/pkg/lock" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/plan" "github.com/werf/nelm/pkg/release" @@ -152,13 +151,9 @@ func releaseUninstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, return fmt.Errorf("construct release storage: %w", err) } - var lockManager *lock.LockManager - if !opts.LegacyNoReleaseLock { - if m, err := lock.NewLockManager(ctx, releaseNamespace, false, clientFactory); err != nil { - return fmt.Errorf("construct lock manager: %w", err) - } else { - lockManager = m - } + lockManager, lockEnabled, err := newReleaseLockManager(ctx, releaseNamespace, clientFactory, opts.LegacyNoReleaseLock) + if err != nil { + return err } nsMeta := spec.NewResourceMeta(releaseNamespace, "", releaseNamespace, "", schema.GroupVersionKind{Version: "v1", Kind: "Namespace"}, nil, nil) @@ -190,7 +185,7 @@ func releaseUninstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, if err := func() error { log.Default.Info(ctx, color.Style{color.Bold, color.Green}.Render("Delete release")+" %q (namespace: %q)", releaseName, releaseNamespace) - if lockManager != nil { + if lockEnabled { if lock, err := lockManager.LockRelease(ctx, releaseName); err != nil { return fmt.Errorf("lock release: %w", err) } else { diff --git a/pkg/plan/ready_dependency_override_ai_test.go b/pkg/plan/ready_dependency_override_ai_test.go index 82206588..4c741b78 100644 --- a/pkg/plan/ready_dependency_override_ai_test.go +++ b/pkg/plan/ready_dependency_override_ai_test.go @@ -63,6 +63,43 @@ func TestAI_ReadyDependencyCrossStageDoesNotForceTracking(t *testing.T) { "fail mode must remain the resource's own value when not forced") } +func TestAI_ReadyDependencyDoesNotForceExternalDependencyTarget(t *testing.T) { + target := readyDepInstallableResource( + readyDepConfigMapSpec("target", readyDepReleaseNamespace, nil), + statestore.NonBlocking, + statestore.IgnoreAndContinueDeployProcess, + ) + + dependent := readyDepInstallableResource( + readyDepConfigMapSpec("dependent", readyDepReleaseNamespace, nil), + statestore.WaitUntilResourceReady, + statestore.FailWholeDeployProcessImmediately, + ) + dependent.ManualDependencies = []*resource.Dependency{ + { + ResourceMatcher: &spec.ResourceMatcher{ + Names: []string{"target"}, + Namespaces: []string{""}, + Groups: []string{""}, + Versions: []string{"v1"}, + Kinds: []string{"ConfigMap"}, + }, + ResourceState: common.ResourceStateReady, + External: true, + MinMatches: common.DefaultExternalDependencyMinMatches, + MaxMatches: common.DefaultExternalDependencyMaxMatches, + }, + } + + infos := buildReadyDepInfos(t, target, dependent, nil) + + targetInfo := findInfo(t, infos, "target") + require.False(t, targetInfo.MustTrackReadiness, + "an external ready-dependency is tracked by its own operation and must not force a same-named local resource") + require.Equal(t, statestore.IgnoreAndContinueDeployProcess, targetInfo.FailMode, + "the local resource must keep its chart-authored fail mode") +} + func TestAI_ReadyDependencyDoesNotForceCRDTarget(t *testing.T) { crdInfo := &plan.InstallableResourceInfo{ ResourceMeta: &spec.ResourceMeta{ diff --git a/pkg/plan/resource_info.go b/pkg/plan/resource_info.go index 6e67659e..6d39046e 100644 --- a/pkg/plan/resource_info.go +++ b/pkg/plan/resource_info.go @@ -600,6 +600,13 @@ func forceReadinessTrackingForReadyDependencyTargets(infos []*InstallableResourc var readyMatchers []readyMatcher for _, info := range infos { for _, dep := range info.LocalResource.ManualDependencies { + // External dependencies get their own track-readiness operation keyed on the + // dependency itself, so forcing a same-named local resource would not serve the + // edge and would silently override its chart-authored fail mode. + if dep.External { + continue + } + if dep.ResourceState == common.ResourceStateReady { readyMatchers = append(readyMatchers, readyMatcher{matcher: dep.ResourceMatcher, stage: info.Stage}) } diff --git a/pkg/plan/resource_info_test.go b/pkg/plan/resource_info_test.go index 5c0fba9d..d0611104 100644 --- a/pkg/plan/resource_info_test.go +++ b/pkg/plan/resource_info_test.go @@ -44,7 +44,7 @@ func (s *ResourceInfoSuite) SetupSuite() { s.cmpOpts = cmp.Options{ cmpopts.EquateEmpty(), test.CompareRegexpOption(), - test.CompareInternalDependencyOption(), + test.CompareDependencyOption(), test.CompareResourceMetadataOption(s.releaseNamespace), } } diff --git a/pkg/resource/resource_test.go b/pkg/resource/resource_test.go index 51354c63..1c4b752b 100644 --- a/pkg/resource/resource_test.go +++ b/pkg/resource/resource_test.go @@ -33,7 +33,7 @@ func (s *InstallableResourceSuite) SetupSuite() { s.cmpOpts = cmp.Options{ cmpopts.EquateEmpty(), test.CompareRegexpOption(), - test.CompareInternalDependencyOption(), + test.CompareDependencyOption(), } } diff --git a/pkg/test/comparer.go b/pkg/test/comparer.go index 4e1e5624..0513bede 100644 --- a/pkg/test/comparer.go +++ b/pkg/test/comparer.go @@ -12,7 +12,7 @@ import ( "github.com/werf/nelm/pkg/resource" ) -func CompareInternalDependencyOption() cmp.Option { +func CompareDependencyOption() cmp.Option { sp := &spew.ConfigState{ Indent: " ", DisablePointerAddresses: true, From 56475887b199ac2ca35c30b1c7ae16089cefdc89 Mon Sep 17 00:00:00 2001 From: Ilya Lesikov Date: Thu, 6 Aug 2026 13:23:58 +0300 Subject: [PATCH 75/85] feat: add case-insensitive-condition-tracking feature gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom resources whose readiness lives in status.conditions were never tracked by the heuristic, because kubedog built its universal condition rules from lowercase type names while real resources use CamelCase. Fixing that makes previously instantly-ready resources start blocking, so it is opt-in: NELM_FEAT_CASE_INSENSITIVE_CONDITION_TRACKING=true forwards the option to the readiness tracker, and the gate defaults to off. The CloudNativePG Cluster fix that prompted this is deliberately not behind the gate — it is an exact rule in kubedog and applies unconditionally. Signed-off-by: Ilya Lesikov --- docs/reference.md | 7 +++++++ pkg/featgate/feat.go | 4 ++++ pkg/featgate/feat_ai_test.go | 36 ++++++++++++++++++++++++++++++++++++ pkg/plan/plan_execute.go | 2 ++ 4 files changed, 49 insertions(+) create mode 100644 pkg/featgate/feat_ai_test.go diff --git a/docs/reference.md b/docs/reference.md index 28b426b8..035ea919 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -49,6 +49,7 @@ - [NELM_FEAT_PERIODIC_STACK_TRACES](#nelm_feat_periodic_stack_traces) - [NELM_FEAT_TYPESCRIPT](#nelm_feat_typescript) - [NELM_FEAT_ADOPT_DECKHOUSE_CONTROLLER_FIELDS](#nelm_feat_adopt_deckhouse_controller_fields) + - [NELM_FEAT_CASE_INSENSITIVE_CONDITION_TRACKING](#nelm_feat_case_insensitive_condition_tracking) @@ -4533,3 +4534,9 @@ Enable TypeScript chart rendering from ts/ directory Adopt managed fields owned by the legacy "deckhouse\-controller" field manager \(the pre\-nelm Helm 3 engine\)\. Unsafe if any resource still has hook\-owned "deckhouse\-controller" entries +### NELM_FEAT_CASE_INSENSITIVE_CONDITION_TRACKING + +**Default:** `false` + +Match custom resource status condition types case\-insensitively when detecting readiness \(e\.g\. "Ready" in addition to "ready"\) + diff --git a/pkg/featgate/feat.go b/pkg/featgate/feat.go index c923d91b..40751717 100644 --- a/pkg/featgate/feat.go +++ b/pkg/featgate/feat.go @@ -25,6 +25,10 @@ var ( "adopt-deckhouse-controller-fields", `Adopt managed fields owned by the legacy "deckhouse-controller" field manager (the pre-nelm Helm 3 engine). Unsafe if any resource still has hook-owned "deckhouse-controller" entries`, ) + FeatGateCaseInsensitiveConditionTracking = NewFeatGate( + "case-insensitive-condition-tracking", + `Match custom resource status condition types case-insensitively when detecting readiness (e.g. "Ready" in addition to "ready")`, + ) ) // A feature gate, which enabled/disables a specific feature. Can be toggled via an env var or diff --git a/pkg/featgate/feat_ai_test.go b/pkg/featgate/feat_ai_test.go new file mode 100644 index 00000000..cf234c56 --- /dev/null +++ b/pkg/featgate/feat_ai_test.go @@ -0,0 +1,36 @@ +//go:build ai_tests + +package featgate + +import ( + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAI_CaseInsensitiveConditionTracking_EnvVarName(t *testing.T) { + assert.Equal(t, "NELM_FEAT_CASE_INSENSITIVE_CONDITION_TRACKING", FeatGateCaseInsensitiveConditionTracking.EnvVarName()) + assert.Equal(t, "case-insensitive-condition-tracking", FeatGateCaseInsensitiveConditionTracking.Name) +} + +func TestAI_CaseInsensitiveConditionTracking_OnlyTrueEnables(t *testing.T) { + assert.False(t, FeatGateCaseInsensitiveConditionTracking.Default()) + + for _, value := range []string{"", "1", "yes", "TRUE", "True", "false"} { + t.Setenv(FeatGateCaseInsensitiveConditionTracking.EnvVarName(), value) + assert.False(t, FeatGateCaseInsensitiveConditionTracking.Enabled(), "value %q must not enable the gate", value) + } + + t.Setenv(FeatGateCaseInsensitiveConditionTracking.EnvVarName(), "true") + assert.True(t, FeatGateCaseInsensitiveConditionTracking.Enabled()) +} + +func TestAI_CaseInsensitiveConditionTracking_Registered(t *testing.T) { + _, found := lo.Find(FeatGates, func(fg *FeatGate) bool { + return fg == FeatGateCaseInsensitiveConditionTracking + }) + + require.True(t, found, "gate must be registered in FeatGates so CLI usage and reference docs pick it up") +} diff --git a/pkg/plan/plan_execute.go b/pkg/plan/plan_execute.go index 3f1dab17..9ce2d948 100644 --- a/pkg/plan/plan_execute.go +++ b/pkg/plan/plan_execute.go @@ -15,6 +15,7 @@ import ( kdutil "github.com/werf/kubedog/pkg/dyntracker/util" "github.com/werf/kubedog/pkg/informer" "github.com/werf/nelm/pkg/common" + "github.com/werf/nelm/pkg/featgate" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/release" @@ -277,6 +278,7 @@ func execOpTrackReadiness(ctx context.Context, op *Operation, releaseNamespace s NoActivityTimeout: opConfig.NoActivityTimeout, IgnoreReadinessProbeFailsByContainerName: opConfig.IgnoreReadinessProbeFailsByContainerName, CaseInsensitiveGVKMatching: true, + CaseInsensitiveConditionTracking: featgate.FeatGateCaseInsensitiveConditionTracking.Enabled(), SaveLogsOnlyForNumberOfReplicas: opConfig.SaveLogsOnlyForNumberOfReplicas, SaveLogsOnlyForContainers: opConfig.SaveLogsOnlyForContainers, SaveLogsByRegex: opConfig.SaveLogsByRegex, From ec98995b35c2efc2460cc6451a3d608833951e5f Mon Sep 17 00:00:00 2001 From: Aleksei Igrychev Date: Fri, 7 Aug 2026 09:51:32 +0100 Subject: [PATCH 76/85] fix(helm): serialize action configuration initialization (#686) Protect shared action configuration updates because concurrent Helm command initialization races on its client and storage fields. Signed-off-by: Aleksei Igrychev --- pkg/helm/pkg/action/action.go | 3 ++ pkg/helm/pkg/action/configuration_ai_test.go | 34 ++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 pkg/helm/pkg/action/configuration_ai_test.go diff --git a/pkg/helm/pkg/action/action.go b/pkg/helm/pkg/action/action.go index 39f2489e..c1675f19 100644 --- a/pkg/helm/pkg/action/action.go +++ b/pkg/helm/pkg/action/action.go @@ -663,6 +663,9 @@ func (cfg *Configuration) recordRelease(r *release.Release) { // Init initializes the action configuration func (cfg *Configuration) Init(getter genericclioptions.RESTClientGetter, namespace, helmDriver string) error { + cfg.mutex.Lock() + defer cfg.mutex.Unlock() + kc := kube.New(getter) kc.SetLogger(cfg.Logger().Handler()) diff --git a/pkg/helm/pkg/action/configuration_ai_test.go b/pkg/helm/pkg/action/configuration_ai_test.go new file mode 100644 index 00000000..f6f08d93 --- /dev/null +++ b/pkg/helm/pkg/action/configuration_ai_test.go @@ -0,0 +1,34 @@ +//go:build ai_tests + +package action + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/cli-runtime/pkg/genericclioptions" +) + +func TestAIConfigurationInitConcurrent(t *testing.T) { + cfg := NewConfiguration() + getter := genericclioptions.NewConfigFlags(true) + errs := make(chan error, 2) + var wg sync.WaitGroup + wg.Add(2) + + for range 2 { + go func() { + defer wg.Done() + errs <- cfg.Init(getter, "default", "memory") + }() + } + + wg.Wait() + close(errs) + + for err := range errs { + require.NoError(t, err) + } + require.NotNil(t, cfg.Releases) +} From 91104e4309cd919619a91ee84c070c54fdf3e117 Mon Sep 17 00:00:00 2001 From: Ilya Lesikov Date: Fri, 7 Aug 2026 12:33:30 +0300 Subject: [PATCH 77/85] chore: format Signed-off-by: Ilya Lesikov --- pkg/action/common.go | 30 ++++---- pkg/action/release_lock_ai_test.go | 30 ++++---- pkg/plan/ready_dependency_override_ai_test.go | 74 +++++++++---------- pkg/resource/kubeconform_embedded_ai_test.go | 1 - .../resource_namespace_dependency_ai_test.go | 1 - 5 files changed, 67 insertions(+), 69 deletions(-) diff --git a/pkg/action/common.go b/pkg/action/common.go index 8a7114c1..49bdb743 100644 --- a/pkg/action/common.go +++ b/pkg/action/common.go @@ -117,6 +117,21 @@ func handleBuildPlanErr(ctx context.Context, installPlan *plan.Plan, planErr err log.Default.Warn(ctx, "Plan graph saved to %q for debugging", graphPath) } +// Reports enabled=false when release locking is disabled, in which case callers must skip acquiring +// the lock entirely. +func newReleaseLockManager(ctx context.Context, releaseNamespace string, clientFactory kube.ClientFactorier, legacyNoReleaseLock bool) (*lock.LockManager, bool, error) { + if legacyNoReleaseLock { + return nil, false, nil + } + + lockManager, err := lock.NewLockManager(ctx, releaseNamespace, false, clientFactory) + if err != nil { + return nil, false, fmt.Errorf("construct lock manager: %w", err) + } + + return lockManager, true, nil +} + func printNotes(ctx context.Context, notes string) { if notes == "" { return @@ -295,18 +310,3 @@ func writeWithSyntaxHighlight(outStream io.Writer, text, lang string, colorLevel return nil } - -// Reports enabled=false when release locking is disabled, in which case callers must skip acquiring -// the lock entirely. -func newReleaseLockManager(ctx context.Context, releaseNamespace string, clientFactory kube.ClientFactorier, legacyNoReleaseLock bool) (*lock.LockManager, bool, error) { - if legacyNoReleaseLock { - return nil, false, nil - } - - lockManager, err := lock.NewLockManager(ctx, releaseNamespace, false, clientFactory) - if err != nil { - return nil, false, fmt.Errorf("construct lock manager: %w", err) - } - - return lockManager, true, nil -} diff --git a/pkg/action/release_lock_ai_test.go b/pkg/action/release_lock_ai_test.go index 14f8fed5..ce18daf3 100644 --- a/pkg/action/release_lock_ai_test.go +++ b/pkg/action/release_lock_ai_test.go @@ -8,11 +8,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/werf/lockgate" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" + "github.com/werf/lockgate" "github.com/werf/nelm/pkg/common" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/kube/fake" @@ -48,20 +48,6 @@ func TestAI_ReleaseLockAcquiredWhenLegacyNoReleaseLockDisabled(t *testing.T) { require.NoError(t, lockManager.Unlock(handle)) } -func TestAI_ReleaseLockSkippedWhenLegacyNoReleaseLockEnabled(t *testing.T) { - ctx := context.Background() - - clientFactory, err := fake.NewClientFactory(ctx) - require.NoError(t, err) - - lockManager, lockEnabled, err := newReleaseLockManager(ctx, lockTestReleaseNamespace, clientFactory, true) - require.NoError(t, err) - assert.False(t, lockEnabled, "locking must be reported disabled so callers skip acquiring") - assert.Nil(t, lockManager, "lock manager must not be constructed when locking is disabled") - - assert.False(t, lockConfigMapExists(t, ctx, clientFactory), "disabling the release lock must not touch the lock ConfigMap") -} - func TestAI_ReleaseLockIsExclusiveWhenLegacyNoReleaseLockDisabled(t *testing.T) { ctx := context.Background() @@ -94,6 +80,20 @@ func TestAI_ReleaseLockIsExclusiveWhenLegacyNoReleaseLockDisabled(t *testing.T) require.NoError(t, lockManager.Unlock(secondHandle)) } +func TestAI_ReleaseLockSkippedWhenLegacyNoReleaseLockEnabled(t *testing.T) { + ctx := context.Background() + + clientFactory, err := fake.NewClientFactory(ctx) + require.NoError(t, err) + + lockManager, lockEnabled, err := newReleaseLockManager(ctx, lockTestReleaseNamespace, clientFactory, true) + require.NoError(t, err) + assert.False(t, lockEnabled, "locking must be reported disabled so callers skip acquiring") + assert.Nil(t, lockManager, "lock manager must not be constructed when locking is disabled") + + assert.False(t, lockConfigMapExists(t, ctx, clientFactory), "disabling the release lock must not touch the lock ConfigMap") +} + func lockConfigMapExists(t *testing.T, ctx context.Context, clientFactory kube.ClientFactorier) bool { t.Helper() diff --git a/pkg/plan/ready_dependency_override_ai_test.go b/pkg/plan/ready_dependency_override_ai_test.go index 4c741b78..385631ff 100644 --- a/pkg/plan/ready_dependency_override_ai_test.go +++ b/pkg/plan/ready_dependency_override_ai_test.go @@ -63,43 +63,6 @@ func TestAI_ReadyDependencyCrossStageDoesNotForceTracking(t *testing.T) { "fail mode must remain the resource's own value when not forced") } -func TestAI_ReadyDependencyDoesNotForceExternalDependencyTarget(t *testing.T) { - target := readyDepInstallableResource( - readyDepConfigMapSpec("target", readyDepReleaseNamespace, nil), - statestore.NonBlocking, - statestore.IgnoreAndContinueDeployProcess, - ) - - dependent := readyDepInstallableResource( - readyDepConfigMapSpec("dependent", readyDepReleaseNamespace, nil), - statestore.WaitUntilResourceReady, - statestore.FailWholeDeployProcessImmediately, - ) - dependent.ManualDependencies = []*resource.Dependency{ - { - ResourceMatcher: &spec.ResourceMatcher{ - Names: []string{"target"}, - Namespaces: []string{""}, - Groups: []string{""}, - Versions: []string{"v1"}, - Kinds: []string{"ConfigMap"}, - }, - ResourceState: common.ResourceStateReady, - External: true, - MinMatches: common.DefaultExternalDependencyMinMatches, - MaxMatches: common.DefaultExternalDependencyMaxMatches, - }, - } - - infos := buildReadyDepInfos(t, target, dependent, nil) - - targetInfo := findInfo(t, infos, "target") - require.False(t, targetInfo.MustTrackReadiness, - "an external ready-dependency is tracked by its own operation and must not force a same-named local resource") - require.Equal(t, statestore.IgnoreAndContinueDeployProcess, targetInfo.FailMode, - "the local resource must keep its chart-authored fail mode") -} - func TestAI_ReadyDependencyDoesNotForceCRDTarget(t *testing.T) { crdInfo := &plan.InstallableResourceInfo{ ResourceMeta: &spec.ResourceMeta{ @@ -140,6 +103,43 @@ func TestAI_ReadyDependencyDoesNotForceCRDTarget(t *testing.T) { require.NotEqual(t, statestore.FailWholeDeployProcessImmediately, crdInfo.FailMode) } +func TestAI_ReadyDependencyDoesNotForceExternalDependencyTarget(t *testing.T) { + target := readyDepInstallableResource( + readyDepConfigMapSpec("target", readyDepReleaseNamespace, nil), + statestore.NonBlocking, + statestore.IgnoreAndContinueDeployProcess, + ) + + dependent := readyDepInstallableResource( + readyDepConfigMapSpec("dependent", readyDepReleaseNamespace, nil), + statestore.WaitUntilResourceReady, + statestore.FailWholeDeployProcessImmediately, + ) + dependent.ManualDependencies = []*resource.Dependency{ + { + ResourceMatcher: &spec.ResourceMatcher{ + Names: []string{"target"}, + Namespaces: []string{""}, + Groups: []string{""}, + Versions: []string{"v1"}, + Kinds: []string{"ConfigMap"}, + }, + ResourceState: common.ResourceStateReady, + External: true, + MinMatches: common.DefaultExternalDependencyMinMatches, + MaxMatches: common.DefaultExternalDependencyMaxMatches, + }, + } + + infos := buildReadyDepInfos(t, target, dependent, nil) + + targetInfo := findInfo(t, infos, "target") + require.False(t, targetInfo.MustTrackReadiness, + "an external ready-dependency is tracked by its own operation and must not force a same-named local resource") + require.Equal(t, statestore.IgnoreAndContinueDeployProcess, targetInfo.FailMode, + "the local resource must keep its chart-authored fail mode") +} + func TestAI_ReadyDependencyDoesNotForceSkipCreateAbsentTarget(t *testing.T) { target := readyDepInstallableResource( readyDepConfigMapSpec("target", readyDepReleaseNamespace, nil), diff --git a/pkg/resource/kubeconform_embedded_ai_test.go b/pkg/resource/kubeconform_embedded_ai_test.go index 91eff99f..5b3a1f3e 100644 --- a/pkg/resource/kubeconform_embedded_ai_test.go +++ b/pkg/resource/kubeconform_embedded_ai_test.go @@ -186,7 +186,6 @@ func TestAI_EmbeddedSchemasNotUnpackedWhenNotNeeded(t *testing.T) { } func TestAI_EmbeddedSchemasOnly(t *testing.T) { - t.Run("validates_invalid_resources", func(t *testing.T) { setupTestEnvironment(t) diff --git a/pkg/resource/resource_namespace_dependency_ai_test.go b/pkg/resource/resource_namespace_dependency_ai_test.go index 73debfa1..d4d053e5 100644 --- a/pkg/resource/resource_namespace_dependency_ai_test.go +++ b/pkg/resource/resource_namespace_dependency_ai_test.go @@ -41,7 +41,6 @@ func TestAI_ManualDeployDependencyNormalizesReleaseNamespace(t *testing.T) { require.Empty(t, dep.Namespaces) }) - } func manualDeployDep(t *testing.T, releaseNamespace string, annotations map[string]string) *resource.Dependency { From f5a210321229b7d189e75e19424081b05380c135 Mon Sep 17 00:00:00 2001 From: Ilya Lesikov Date: Fri, 7 Aug 2026 13:48:29 +0300 Subject: [PATCH 78/85] chore: bump kubedog Signed-off-by: Ilya Lesikov --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 38e2f112..a11a5f55 100644 --- a/go.mod +++ b/go.mod @@ -58,7 +58,7 @@ require ( github.com/tidwall/sjson v1.2.5 github.com/wI2L/jsondiff v0.7.0 github.com/werf/common-go v0.0.0-20260428201303-b0dcadceca5c - github.com/werf/kubedog v0.13.1-0.20260709123314-5d578345082f + github.com/werf/kubedog v0.13.1-0.20260806110242-85087f97ed99 github.com/werf/lockgate v0.1.1 github.com/werf/logboek v0.6.1 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e diff --git a/go.sum b/go.sum index 7be33003..793d5718 100644 --- a/go.sum +++ b/go.sum @@ -408,8 +408,8 @@ github.com/werf/3p-cobra v0.0.0-20260403075225-552c82797324 h1:aqEM5aboMpBfsILja github.com/werf/3p-cobra v0.0.0-20260403075225-552c82797324/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/werf/common-go v0.0.0-20260428201303-b0dcadceca5c h1:4/qKB2licflIlCZGX4U1o4Ij/oraYyuNc2cWFiutZ10= github.com/werf/common-go v0.0.0-20260428201303-b0dcadceca5c/go.mod h1:DlN/hD9tXLxYgdAMkulQdzHkiaPrvs3MzgPZvjluea4= -github.com/werf/kubedog v0.13.1-0.20260709123314-5d578345082f h1:A3dA97W1cJwUP9CegXunkiJ6gAhek7hyDXP89P7P8r0= -github.com/werf/kubedog v0.13.1-0.20260709123314-5d578345082f/go.mod h1:tU8RyC5qXvJMPT2LmaNnVINWvwDHao0Ca3Bw9P8JC3E= +github.com/werf/kubedog v0.13.1-0.20260806110242-85087f97ed99 h1:GOFrJ93iXCYV8FlDOSz3953jxRV0tNSK6/A+AiDRmuk= +github.com/werf/kubedog v0.13.1-0.20260806110242-85087f97ed99/go.mod h1:5s5JQyn7rI+63ppk5MGPBYv6uRv0HARLD3kLN8FEFsw= github.com/werf/lockgate v0.1.1 h1:S400JFYjtWfE4i4LY9FA8zx0fMdfui9DPrBiTciCrx4= github.com/werf/lockgate v0.1.1/go.mod h1:0yIFSLq9ausy6ejNxF5uUBf/Ib6daMAfXuCaTMZJzIE= github.com/werf/logboek v0.6.1 h1:oEe6FkmlKg0z0n80oZjLplj6sXcBeLleCkjfOOZEL2g= From 6f46733610da6a3a2722c7f95d8ea275d0c34123 Mon Sep 17 00:00:00 2001 From: Aleksei Igrychev Date: Fri, 7 Aug 2026 12:58:17 +0100 Subject: [PATCH 79/85] fix: prevent concurrent Helm configuration races (#688) Signed-off-by: Aleksei Igrychev --- pkg/helm/pkg/action/action.go | 3 ++ pkg/helm/pkg/action/configuration_ai_test.go | 29 ++++++++++++-------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/pkg/helm/pkg/action/action.go b/pkg/helm/pkg/action/action.go index c1675f19..a7d11e32 100644 --- a/pkg/helm/pkg/action/action.go +++ b/pkg/helm/pkg/action/action.go @@ -724,6 +724,9 @@ func (cfg *Configuration) Init(getter genericclioptions.RESTClientGetter, namesp // SetHookOutputFunc sets the HookOutputFunc on the Configuration. func (cfg *Configuration) SetHookOutputFunc(hookOutputFunc func(_, _, _ string) io.Writer) { + cfg.mutex.Lock() + defer cfg.mutex.Unlock() + cfg.HookOutputFunc = hookOutputFunc } diff --git a/pkg/helm/pkg/action/configuration_ai_test.go b/pkg/helm/pkg/action/configuration_ai_test.go index f6f08d93..f4219647 100644 --- a/pkg/helm/pkg/action/configuration_ai_test.go +++ b/pkg/helm/pkg/action/configuration_ai_test.go @@ -3,6 +3,7 @@ package action import ( + "io" "sync" "testing" @@ -10,25 +11,29 @@ import ( "k8s.io/cli-runtime/pkg/genericclioptions" ) -func TestAIConfigurationInitConcurrent(t *testing.T) { +func TestAIConfigurationInitAndSetHookOutputFuncConcurrent(t *testing.T) { cfg := NewConfiguration() getter := genericclioptions.NewConfigFlags(true) - errs := make(chan error, 2) + start := make(chan struct{}) + errs := make(chan error, 1) var wg sync.WaitGroup wg.Add(2) - for range 2 { - go func() { - defer wg.Done() - errs <- cfg.Init(getter, "default", "memory") - }() - } + go func() { + defer wg.Done() + <-start + errs <- cfg.Init(getter, "default", "memory") + }() + go func() { + defer wg.Done() + <-start + cfg.SetHookOutputFunc(func(_, _, _ string) io.Writer { return io.Discard }) + }() + close(start) wg.Wait() - close(errs) - for err := range errs { - require.NoError(t, err) - } + require.NoError(t, <-errs) require.NotNil(t, cfg.Releases) + require.NotNil(t, cfg.HookOutputFunc) } From 0585a1f4041e9e12b429ba02806ccb4490b8e93c Mon Sep 17 00:00:00 2001 From: Ilya Lesikov Date: Fri, 7 Aug 2026 18:40:43 +0300 Subject: [PATCH 80/85] feat: always track resource conditions case-insensitively Remove the case-insensitive-condition-tracking feature gate so that matching custom resource status condition types case-insensitively is unconditional and cannot be disabled. The gate declaration, its gate-only tests and the readiness tracker option it fed are gone; the CLI help, generated reference docs and the unsupported environment variable warning all derive from the gate registry, so they follow automatically and NELM_FEAT_CASE_INSENSITIVE_CONDITION_TRACKING is now reported as unsupported. A custom resource carrying a CamelCase Ready/Available condition previously matched no rule and was reported ready immediately; it now blocks until that condition is True, so deployments that appeared to succeed may start timing out. Signed-off-by: Ilya Lesikov --- docs/reference.md | 7 ------- pkg/featgate/feat.go | 4 ---- pkg/featgate/feat_ai_test.go | 36 ------------------------------------ pkg/plan/plan_execute.go | 2 -- 4 files changed, 49 deletions(-) delete mode 100644 pkg/featgate/feat_ai_test.go diff --git a/docs/reference.md b/docs/reference.md index 035ea919..28b426b8 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -49,7 +49,6 @@ - [NELM_FEAT_PERIODIC_STACK_TRACES](#nelm_feat_periodic_stack_traces) - [NELM_FEAT_TYPESCRIPT](#nelm_feat_typescript) - [NELM_FEAT_ADOPT_DECKHOUSE_CONTROLLER_FIELDS](#nelm_feat_adopt_deckhouse_controller_fields) - - [NELM_FEAT_CASE_INSENSITIVE_CONDITION_TRACKING](#nelm_feat_case_insensitive_condition_tracking) @@ -4534,9 +4533,3 @@ Enable TypeScript chart rendering from ts/ directory Adopt managed fields owned by the legacy "deckhouse\-controller" field manager \(the pre\-nelm Helm 3 engine\)\. Unsafe if any resource still has hook\-owned "deckhouse\-controller" entries -### NELM_FEAT_CASE_INSENSITIVE_CONDITION_TRACKING - -**Default:** `false` - -Match custom resource status condition types case\-insensitively when detecting readiness \(e\.g\. "Ready" in addition to "ready"\) - diff --git a/pkg/featgate/feat.go b/pkg/featgate/feat.go index 40751717..c923d91b 100644 --- a/pkg/featgate/feat.go +++ b/pkg/featgate/feat.go @@ -25,10 +25,6 @@ var ( "adopt-deckhouse-controller-fields", `Adopt managed fields owned by the legacy "deckhouse-controller" field manager (the pre-nelm Helm 3 engine). Unsafe if any resource still has hook-owned "deckhouse-controller" entries`, ) - FeatGateCaseInsensitiveConditionTracking = NewFeatGate( - "case-insensitive-condition-tracking", - `Match custom resource status condition types case-insensitively when detecting readiness (e.g. "Ready" in addition to "ready")`, - ) ) // A feature gate, which enabled/disables a specific feature. Can be toggled via an env var or diff --git a/pkg/featgate/feat_ai_test.go b/pkg/featgate/feat_ai_test.go deleted file mode 100644 index cf234c56..00000000 --- a/pkg/featgate/feat_ai_test.go +++ /dev/null @@ -1,36 +0,0 @@ -//go:build ai_tests - -package featgate - -import ( - "testing" - - "github.com/samber/lo" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAI_CaseInsensitiveConditionTracking_EnvVarName(t *testing.T) { - assert.Equal(t, "NELM_FEAT_CASE_INSENSITIVE_CONDITION_TRACKING", FeatGateCaseInsensitiveConditionTracking.EnvVarName()) - assert.Equal(t, "case-insensitive-condition-tracking", FeatGateCaseInsensitiveConditionTracking.Name) -} - -func TestAI_CaseInsensitiveConditionTracking_OnlyTrueEnables(t *testing.T) { - assert.False(t, FeatGateCaseInsensitiveConditionTracking.Default()) - - for _, value := range []string{"", "1", "yes", "TRUE", "True", "false"} { - t.Setenv(FeatGateCaseInsensitiveConditionTracking.EnvVarName(), value) - assert.False(t, FeatGateCaseInsensitiveConditionTracking.Enabled(), "value %q must not enable the gate", value) - } - - t.Setenv(FeatGateCaseInsensitiveConditionTracking.EnvVarName(), "true") - assert.True(t, FeatGateCaseInsensitiveConditionTracking.Enabled()) -} - -func TestAI_CaseInsensitiveConditionTracking_Registered(t *testing.T) { - _, found := lo.Find(FeatGates, func(fg *FeatGate) bool { - return fg == FeatGateCaseInsensitiveConditionTracking - }) - - require.True(t, found, "gate must be registered in FeatGates so CLI usage and reference docs pick it up") -} diff --git a/pkg/plan/plan_execute.go b/pkg/plan/plan_execute.go index 9ce2d948..3f1dab17 100644 --- a/pkg/plan/plan_execute.go +++ b/pkg/plan/plan_execute.go @@ -15,7 +15,6 @@ import ( kdutil "github.com/werf/kubedog/pkg/dyntracker/util" "github.com/werf/kubedog/pkg/informer" "github.com/werf/nelm/pkg/common" - "github.com/werf/nelm/pkg/featgate" "github.com/werf/nelm/pkg/kube" "github.com/werf/nelm/pkg/log" "github.com/werf/nelm/pkg/release" @@ -278,7 +277,6 @@ func execOpTrackReadiness(ctx context.Context, op *Operation, releaseNamespace s NoActivityTimeout: opConfig.NoActivityTimeout, IgnoreReadinessProbeFailsByContainerName: opConfig.IgnoreReadinessProbeFailsByContainerName, CaseInsensitiveGVKMatching: true, - CaseInsensitiveConditionTracking: featgate.FeatGateCaseInsensitiveConditionTracking.Enabled(), SaveLogsOnlyForNumberOfReplicas: opConfig.SaveLogsOnlyForNumberOfReplicas, SaveLogsOnlyForContainers: opConfig.SaveLogsOnlyForContainers, SaveLogsByRegex: opConfig.SaveLogsByRegex, From 72d7f3dc1f0e5930e0718f0a10475dec1ff2c231 Mon Sep 17 00:00:00 2001 From: Ilya Lesikov Date: Mon, 10 Aug 2026 02:47:47 +0300 Subject: [PATCH 81/85] chore: bump kubedog Signed-off-by: Ilya Lesikov --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index a11a5f55..02a75c1d 100644 --- a/go.mod +++ b/go.mod @@ -58,7 +58,7 @@ require ( github.com/tidwall/sjson v1.2.5 github.com/wI2L/jsondiff v0.7.0 github.com/werf/common-go v0.0.0-20260428201303-b0dcadceca5c - github.com/werf/kubedog v0.13.1-0.20260806110242-85087f97ed99 + github.com/werf/kubedog v0.13.1-0.20260807153813-e8f61a4bc90a github.com/werf/lockgate v0.1.1 github.com/werf/logboek v0.6.1 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e diff --git a/go.sum b/go.sum index 793d5718..0d754c43 100644 --- a/go.sum +++ b/go.sum @@ -408,8 +408,8 @@ github.com/werf/3p-cobra v0.0.0-20260403075225-552c82797324 h1:aqEM5aboMpBfsILja github.com/werf/3p-cobra v0.0.0-20260403075225-552c82797324/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/werf/common-go v0.0.0-20260428201303-b0dcadceca5c h1:4/qKB2licflIlCZGX4U1o4Ij/oraYyuNc2cWFiutZ10= github.com/werf/common-go v0.0.0-20260428201303-b0dcadceca5c/go.mod h1:DlN/hD9tXLxYgdAMkulQdzHkiaPrvs3MzgPZvjluea4= -github.com/werf/kubedog v0.13.1-0.20260806110242-85087f97ed99 h1:GOFrJ93iXCYV8FlDOSz3953jxRV0tNSK6/A+AiDRmuk= -github.com/werf/kubedog v0.13.1-0.20260806110242-85087f97ed99/go.mod h1:5s5JQyn7rI+63ppk5MGPBYv6uRv0HARLD3kLN8FEFsw= +github.com/werf/kubedog v0.13.1-0.20260807153813-e8f61a4bc90a h1:O3LWkGBPLb9P3jUdvRIpASe+m65iMtsKSI1joJIR3B4= +github.com/werf/kubedog v0.13.1-0.20260807153813-e8f61a4bc90a/go.mod h1:5s5JQyn7rI+63ppk5MGPBYv6uRv0HARLD3kLN8FEFsw= github.com/werf/lockgate v0.1.1 h1:S400JFYjtWfE4i4LY9FA8zx0fMdfui9DPrBiTciCrx4= github.com/werf/lockgate v0.1.1/go.mod h1:0yIFSLq9ausy6ejNxF5uUBf/Ib6daMAfXuCaTMZJzIE= github.com/werf/logboek v0.6.1 h1:oEe6FkmlKg0z0n80oZjLplj6sXcBeLleCkjfOOZEL2g= From 06096861c10698f1dcb23bba0629be7d6edb8c2b Mon Sep 17 00:00:00 2001 From: Aleksei Igrychev Date: Mon, 10 Aug 2026 15:28:48 +0100 Subject: [PATCH 82/85] fix: prevent progress printer race (#690) Signed-off-by: Aleksei Igrychev --- pkg/track/progress_tables.go | 6 ++--- pkg/track/progress_tables_ai_test.go | 33 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 pkg/track/progress_tables_ai_test.go diff --git a/pkg/track/progress_tables.go b/pkg/track/progress_tables.go index 201b90e3..3288a747 100644 --- a/pkg/track/progress_tables.go +++ b/pkg/track/progress_tables.go @@ -35,10 +35,10 @@ func NewProgressTablesPrinter(taskStore *kdutil.Concurrent[*statestore.TaskStore } func (p *ProgressTablesPrinter) Start(ctx context.Context, interval time.Duration) { - go func() { - p.finishedCh = make(chan struct{}) + p.finishedCh = make(chan struct{}) + ctx, p.ctxCancelFn = context.WithCancelCause(ctx) - ctx, p.ctxCancelFn = context.WithCancelCause(ctx) + go func() { defer func() { p.ctxCancelFn(fmt.Errorf("context canceled: table printer finished")) diff --git a/pkg/track/progress_tables_ai_test.go b/pkg/track/progress_tables_ai_test.go new file mode 100644 index 00000000..f2e9a6db --- /dev/null +++ b/pkg/track/progress_tables_ai_test.go @@ -0,0 +1,33 @@ +//go:build ai_tests + +package track + +import ( + "context" + "io" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/werf/kubedog/pkg/dyntracker/logstore" + "github.com/werf/kubedog/pkg/dyntracker/statestore" + kdutil "github.com/werf/kubedog/pkg/dyntracker/util" + "github.com/werf/logboek" +) + +func TestAIProgressTablesPrinterStartInitializesBeforeReturn(t *testing.T) { + printer := NewProgressTablesPrinter( + kdutil.NewConcurrent(statestore.NewTaskStore()), + kdutil.NewConcurrent(logstore.NewLogStore()), + ProgressTablesPrinterOptions{}, + ) + + ctx := logboek.NewContext(context.Background(), logboek.NewLogger(io.Discard, io.Discard)) + printer.Start(ctx, time.Hour) + require.NotNil(t, printer.ctxCancelFn) + require.NotNil(t, printer.finishedCh) + + printer.Stop() + printer.Wait() +} From 8ed239329cbc0d5da7893c44781e92f609d2361e Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Thu, 20 Aug 2026 12:59:03 +0300 Subject: [PATCH 83/85] chore: add AGENTS.md symlink for claude (#694) Signed-off-by: Dmitry Mordvinov (cherry picked from commit 2771db668518b56e3df5e73900d7ed691120e301) --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) create mode 120000 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From 191171c87ea1804baaaeb97c30b5b23399d9e58c Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Mon, 24 Aug 2026 14:40:51 +0300 Subject: [PATCH 84/85] feat: add `renderPatches` support for --patches/patches.yaml (#696) Signed-off-by: Dmitry Mordvinov --- cmd/nelm/chart_lint.go | 4 + cmd/nelm/chart_render.go | 4 + cmd/nelm/common_flags.go | 16 +- cmd/nelm/release_install.go | 2 +- cmd/nelm/release_plan_install.go | 2 +- cmd/nelm/release_rollback.go | 2 +- cmd/nelm/release_uninstall.go | 2 +- docs/reference.md | 20 +- pkg/action/chart_lint.go | 25 +- pkg/action/chart_render.go | 24 +- pkg/action/common.go | 28 +- pkg/action/release_install.go | 29 +- pkg/action/release_plan_install.go | 23 +- pkg/action/release_rollback.go | 11 +- pkg/action/release_uninstall.go | 11 +- pkg/common/options.go | 13 +- pkg/plan/resource_info.go | 17 +- pkg/plan/resource_info_test.go | 19 +- pkg/resource/spec/export_test.go | 14 +- pkg/resource/spec/helpers_ai_test.go | 33 ++ .../spec/{diff_patch.go => patches.go} | 176 +++++---- pkg/resource/spec/patches_ai_test.go | 351 ++++++++++++++++++ pkg/resource/spec/patches_internal_ai_test.go | 15 + .../{diff_patch_test.go => patches_test.go} | 104 +++--- pkg/resource/spec/resource_spec.go | 95 ++++- 25 files changed, 842 insertions(+), 198 deletions(-) create mode 100644 pkg/resource/spec/helpers_ai_test.go rename pkg/resource/spec/{diff_patch.go => patches.go} (55%) create mode 100644 pkg/resource/spec/patches_ai_test.go create mode 100644 pkg/resource/spec/patches_internal_ai_test.go rename pkg/resource/spec/{diff_patch_test.go => patches_test.go} (83%) diff --git a/cmd/nelm/chart_lint.go b/cmd/nelm/chart_lint.go index b7de43b0..bb072d37 100644 --- a/cmd/nelm/chart_lint.go +++ b/cmd/nelm/chart_lint.go @@ -75,6 +75,10 @@ func newChartLintCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[*co return fmt.Errorf("add secret values flags: %w", err) } + if err := AddPatchesFlags(cmd, &cfg.PatchesFiles, &cfg.DefaultPatchesDisable, AddPatchesFlagsOptions{}); err != nil { + return fmt.Errorf("add patches flags: %w", err) + } + if err := cli.AddFlag(cmd, &cfg.ChartAppVersion, "app-version", "", "Set appVersion of Chart.yaml", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: patchFlagGroup, diff --git a/cmd/nelm/chart_render.go b/cmd/nelm/chart_render.go index 64103668..1692b75a 100644 --- a/cmd/nelm/chart_render.go +++ b/cmd/nelm/chart_render.go @@ -74,6 +74,10 @@ func newChartRenderCommand(ctx context.Context, afterAllCommandsBuiltFuncs map[* return fmt.Errorf("add secret values flags: %w", err) } + if err := AddPatchesFlags(cmd, &cfg.PatchesFiles, &cfg.DefaultPatchesDisable, AddPatchesFlagsOptions{}); err != nil { + return fmt.Errorf("add patches flags: %w", err) + } + if err := cli.AddFlag(cmd, &cfg.NoValuesSchemaValidation, "no-values-schema-validation", false, "Disable values validation against JSON schema", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: resourceValidationGroup, diff --git a/cmd/nelm/common_flags.go b/cmd/nelm/common_flags.go index 464ce924..27595b02 100644 --- a/cmd/nelm/common_flags.go +++ b/cmd/nelm/common_flags.go @@ -3,12 +3,18 @@ package main import ( "fmt" + "github.com/samber/lo" "github.com/spf13/cobra" "github.com/werf/common-go/pkg/cli" "github.com/werf/nelm/pkg/common" ) +type AddPatchesFlagsOptions struct { + // NoRender marks a command that renders nothing, so render patches have no effect. + NoRender bool +} + func AddChartRepoConnectionFlags(cmd *cobra.Command, cfg *common.ChartRepoConnectionOptions) error { if err := cli.AddFlag(cmd, &cfg.ChartRepoBasicAuthPassword, "chart-repo-basic-password", "", "Basic auth password to authenticate in chart repository", cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, @@ -317,8 +323,14 @@ func AddKubeConnectionFlags(cmd *cobra.Command, cfg *common.KubeConnectionOption return nil } -func AddPatchesFlags(cmd *cobra.Command, patchesFiles *[]string, defaultPatchesDisable *bool) error { - if err := cli.AddFlag(cmd, patchesFiles, "patches", []string{}, "Additional patches files (diff patches for drift detection)", cli.AddFlagOptions{ +func AddPatchesFlags(cmd *cobra.Command, patchesFiles *[]string, defaultPatchesDisable *bool, opts AddPatchesFlagsOptions) error { + description := lo.Ternary( + opts.NoRender, + "Additional patches files (diff patches for drift detection)", + "Additional patches files (render patches for rendered resources, diff patches for drift detection)", + ) + + if err := cli.AddFlag(cmd, patchesFiles, "patches", []string{}, description, cli.AddFlagOptions{ GetEnvVarRegexesFunc: cli.GetFlagGlobalAndLocalEnvVarRegexes, Group: patchFlagGroup, Type: cli.FlagTypeFile, diff --git a/cmd/nelm/release_install.go b/cmd/nelm/release_install.go index 12ef8359..0c102188 100644 --- a/cmd/nelm/release_install.go +++ b/cmd/nelm/release_install.go @@ -77,7 +77,7 @@ func newReleaseInstallCommand(ctx context.Context, afterAllCommandsBuiltFuncs ma return fmt.Errorf("add secret values flags: %w", err) } - if err := AddPatchesFlags(cmd, &cfg.PatchesFiles, &cfg.DefaultPatchesDisable); err != nil { + if err := AddPatchesFlags(cmd, &cfg.PatchesFiles, &cfg.DefaultPatchesDisable, AddPatchesFlagsOptions{}); err != nil { return fmt.Errorf("add patches flags: %w", err) } diff --git a/cmd/nelm/release_plan_install.go b/cmd/nelm/release_plan_install.go index 7bf9a9ab..3b9e705b 100644 --- a/cmd/nelm/release_plan_install.go +++ b/cmd/nelm/release_plan_install.go @@ -77,7 +77,7 @@ func newReleasePlanInstallCommand(ctx context.Context, afterAllCommandsBuiltFunc return fmt.Errorf("add secret values flags: %w", err) } - if err := AddPatchesFlags(cmd, &cfg.PatchesFiles, &cfg.DefaultPatchesDisable); err != nil { + if err := AddPatchesFlags(cmd, &cfg.PatchesFiles, &cfg.DefaultPatchesDisable, AddPatchesFlagsOptions{}); err != nil { return fmt.Errorf("add patches flags: %w", err) } diff --git a/cmd/nelm/release_rollback.go b/cmd/nelm/release_rollback.go index 15097342..c55d5386 100644 --- a/cmd/nelm/release_rollback.go +++ b/cmd/nelm/release_rollback.go @@ -65,7 +65,7 @@ func newReleaseRollbackCommand(ctx context.Context, afterAllCommandsBuiltFuncs m return fmt.Errorf("add tracking flags: %w", err) } - if err := AddPatchesFlags(cmd, &cfg.PatchesFiles, &cfg.DefaultPatchesDisable); err != nil { + if err := AddPatchesFlags(cmd, &cfg.PatchesFiles, &cfg.DefaultPatchesDisable, AddPatchesFlagsOptions{NoRender: true}); err != nil { return fmt.Errorf("add patches flags: %w", err) } diff --git a/cmd/nelm/release_uninstall.go b/cmd/nelm/release_uninstall.go index b75e9eea..2d35d4ad 100644 --- a/cmd/nelm/release_uninstall.go +++ b/cmd/nelm/release_uninstall.go @@ -53,7 +53,7 @@ func newReleaseUninstallCommand(ctx context.Context, afterAllCommandsBuiltFuncs return fmt.Errorf("add tracking flags: %w", err) } - if err := AddPatchesFlags(cmd, &cfg.PatchesFiles, &cfg.DefaultPatchesDisable); err != nil { + if err := AddPatchesFlags(cmd, &cfg.PatchesFiles, &cfg.DefaultPatchesDisable, AddPatchesFlagsOptions{NoRender: true}); err != nil { return fmt.Errorf("add patches flags: %w", err) } diff --git a/docs/reference.md b/docs/reference.md index 28b426b8..3c7e6142 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -306,7 +306,7 @@ nelm release install [options...] -n namespace -r release [chart-dir|chart-repo- - `--patches` (default: `[]`) - Additional patches files \(diff patches for drift detection\)\. Vars: \$NELM\_PATCHES, \$NELM\_RELEASE\_INSTALL\_PATCHES + Additional patches files \(render patches for rendered resources, diff patches for drift detection\)\. Vars: \$NELM\_PATCHES, \$NELM\_RELEASE\_INSTALL\_PATCHES - `--runtime-annotations` (default: `{}`) @@ -1024,7 +1024,7 @@ nelm release plan install [options...] -n namespace -r release [chart-dir|chart- - `--patches` (default: `[]`) - Additional patches files \(diff patches for drift detection\)\. Vars: \$NELM\_PATCHES, \$NELM\_RELEASE\_PLAN\_INSTALL\_PATCHES + Additional patches files \(render patches for rendered resources, diff patches for drift detection\)\. Vars: \$NELM\_PATCHES, \$NELM\_RELEASE\_PLAN\_INSTALL\_PATCHES - `--runtime-annotations` (default: `{}`) @@ -2183,6 +2183,14 @@ nelm chart lint [options...] [chart-dir|chart-repo-name/chart-name|chart-archive Add labels to all resources\. Vars: \$NELM\_LABELS\_\*, \$NELM\_CHART\_LINT\_LABELS\_\* +- `--no-default-patches` (default: `false`) + + Ignore patches\.yaml of the top\-level chart and subcharts\. Vars: \$NELM\_NO\_DEFAULT\_PATCHES, \$NELM\_CHART\_LINT\_NO\_DEFAULT\_PATCHES + +- `--patches` (default: `[]`) + + Additional patches files \(render patches for rendered resources, diff patches for drift detection\)\. Vars: \$NELM\_PATCHES, \$NELM\_CHART\_LINT\_PATCHES + - `--runtime-annotations` (default: `{}`) Add annotations which will not trigger resource updates to all resources\. Vars: \$NELM\_RUNTIME\_ANNOTATIONS\_\*, \$NELM\_CHART\_LINT\_RUNTIME\_ANNOTATIONS\_\* @@ -2546,6 +2554,14 @@ nelm chart render [options...] [chart-dir|chart-repo-name/chart-name|chart-archi Add labels to all resources\. Vars: \$NELM\_LABELS\_\*, \$NELM\_CHART\_RENDER\_LABELS\_\* +- `--no-default-patches` (default: `false`) + + Ignore patches\.yaml of the top\-level chart and subcharts\. Vars: \$NELM\_NO\_DEFAULT\_PATCHES, \$NELM\_CHART\_RENDER\_NO\_DEFAULT\_PATCHES + +- `--patches` (default: `[]`) + + Additional patches files \(render patches for rendered resources, diff patches for drift detection\)\. Vars: \$NELM\_PATCHES, \$NELM\_CHART\_RENDER\_PATCHES + - `--runtime-annotations` (default: `{}`) Add annotations which will not trigger resource updates to all resources\. Vars: \$NELM\_RUNTIME\_ANNOTATIONS\_\*, \$NELM\_CHART\_RENDER\_RUNTIME\_ANNOTATIONS\_\* diff --git a/pkg/action/chart_lint.go b/pkg/action/chart_lint.go index 4d8a833c..ef17640e 100644 --- a/pkg/action/chart_lint.go +++ b/pkg/action/chart_lint.go @@ -58,6 +58,9 @@ type ChartLintOptions struct { DefaultChartVersion string // DefaultDeletePropagation sets the deletion propagation policy for resource deletions. DefaultDeletePropagation string + // DefaultPatchesDisable, when true, ignores chart-shipped patches.yaml files + // (from the top-level chart and subcharts). + DefaultPatchesDisable bool // DenoBinaryPath, if specified, uses this path as the Deno binary instead of auto-downloading. DenoBinaryPath string // DockerConfig is the path to the Docker configuration directory (e.g., ~/.docker). @@ -112,6 +115,11 @@ type ChartLintOptions struct { // NoRemoveManualChanges, when true, preserves fields during validation that would be manually added. // Used in the validation dry-run to check resource compatibility. NoRemoveManualChanges bool + // PatchesFiles are paths to patches files (same format as a chart-shipped + // patches.yaml) whose rules are applied on top of chart-shipped ones. Rules from + // these files are UNSCOPED (they may match any resource), unlike chart-shipped + // rules which are scoped to their chart subtree. + PatchesFiles []string // RegistryCredentialsPath is the path to Docker config.json file with registry credentials. // Defaults to DockerConfig/config.json if not set. // Used for authenticating to OCI registries when pulling charts. @@ -284,6 +292,13 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { return fmt.Errorf("render chart: %w", err) } + log.Default.Debug(ctx, "Resolve patches") + + patches, err := resolvePatches(renderChartResult.Chart, opts.DefaultPatchesDisable, opts.PatchesFiles) + if err != nil { + return fmt.Errorf("resolve patches: %w", err) + } + log.Default.Debug(ctx, "Build transformed resource specs") transformedResSpecs, err := spec.BuildTransformedResourceSpecs(ctx, opts.ReleaseNamespace, renderChartResult.ResourceSpecs, []spec.ResourceTransformer{ @@ -293,9 +308,16 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { return fmt.Errorf("build transformed resource specs: %w", err) } + log.Default.Debug(ctx, "Build render patched resource specs") + + renderPatchedResSpecs, err := spec.BuildRenderPatchedResourceSpecs(ctx, opts.ReleaseNamespace, transformedResSpecs, patches.Render) + if err != nil { + return fmt.Errorf("build render patched resource specs: %w", err) + } + log.Default.Debug(ctx, "Build releasable resource specs") - releasableResSpecs, err := spec.BuildPatchedResourceSpecs(ctx, opts.ReleaseNamespace, transformedResSpecs, []spec.ResourcePatcher{ + releasableResSpecs, err := spec.BuildPatchedResourceSpecs(ctx, opts.ReleaseNamespace, renderPatchedResSpecs, []spec.ResourcePatcher{ spec.NewExtraMetadataPatcher(opts.ExtraAnnotations, opts.ExtraLabels), spec.NewSecretStringDataPatcher(), }) @@ -360,6 +382,7 @@ func ChartLint(ctx context.Context, opts ChartLintOptions) error { } instResInfos, delResInfos, err := plan.BuildResourceInfos(ctx, deployType, opts.ReleaseName, opts.ReleaseNamespace, instResources, delResources, prevReleaseFailed, clientFactory, plan.BuildResourceInfosOptions{ + DiffPatches: patches.Diff, NetworkParallelism: opts.NetworkParallelism, NoRemoveManualChanges: opts.NoRemoveManualChanges, LastDeployedOrLastRelResourceSpecs: lastDeployedOrLastRelResSpecs, diff --git a/pkg/action/chart_render.go b/pkg/action/chart_render.go index 106398b5..ea358476 100644 --- a/pkg/action/chart_render.go +++ b/pkg/action/chart_render.go @@ -57,6 +57,9 @@ type ChartRenderOptions struct { DefaultChartName string // DefaultChartVersion sets the default chart version when Chart.yaml doesn't specify one. DefaultChartVersion string + // DefaultPatchesDisable, when true, ignores chart-shipped patches.yaml files + // (from the top-level chart and subcharts). + DefaultPatchesDisable bool // DenoBinaryPath, if specified, uses this path as the Deno binary instead of auto-downloading. DenoBinaryPath string // DockerConfig is the path to the Docker configuration directory (e.g., ~/.docker). @@ -107,6 +110,11 @@ type ChartRenderOptions struct { // OutputNoPrint, when true, suppresses printing the rendered manifests to stdout. // Useful when only the result data structure is needed. OutputNoPrint bool + // PatchesFiles are paths to patches files (same format as a chart-shipped + // patches.yaml) whose rules are applied on top of chart-shipped ones. Rules from + // these files are UNSCOPED (they may match any resource), unlike chart-shipped + // rules which are scoped to their chart subtree. + PatchesFiles []string // RegistryCredentialsPath is the path to Docker config.json file with registry credentials. // Defaults to DockerConfig/config.json if not set. // Used for authenticating to OCI registries when pulling charts. @@ -285,6 +293,13 @@ func ChartRender(ctx context.Context, opts ChartRenderOptions) (*ChartRenderResu return nil, fmt.Errorf("render chart: %w", err) } + log.Default.Debug(ctx, "Resolve patches") + + patches, err := resolvePatches(renderChartResult.Chart, opts.DefaultPatchesDisable, opts.PatchesFiles) + if err != nil { + return nil, fmt.Errorf("resolve patches: %w", err) + } + log.Default.Debug(ctx, "Build transformed resource specs") transformedResSpecs, err := spec.BuildTransformedResourceSpecs(ctx, opts.ReleaseNamespace, renderChartResult.ResourceSpecs, []spec.ResourceTransformer{ @@ -294,9 +309,16 @@ func ChartRender(ctx context.Context, opts ChartRenderOptions) (*ChartRenderResu return nil, fmt.Errorf("build transformed resource specs: %w", err) } + log.Default.Debug(ctx, "Build render patched resource specs") + + renderPatchedResSpecs, err := spec.BuildRenderPatchedResourceSpecs(ctx, opts.ReleaseNamespace, transformedResSpecs, patches.Render) + if err != nil { + return nil, fmt.Errorf("build render patched resource specs: %w", err) + } + log.Default.Debug(ctx, "Build releasable resource specs") - resSpecs, err := spec.BuildPatchedResourceSpecs(ctx, opts.ReleaseNamespace, transformedResSpecs, []spec.ResourcePatcher{ + resSpecs, err := spec.BuildPatchedResourceSpecs(ctx, opts.ReleaseNamespace, renderPatchedResSpecs, []spec.ResourcePatcher{ spec.NewExtraMetadataPatcher(opts.ExtraAnnotations, opts.ExtraLabels), spec.NewExtraMetadataPatcher(opts.ExtraRuntimeAnnotations, opts.ExtraRuntimeLabels), spec.NewSecretStringDataPatcher(), diff --git a/pkg/action/common.go b/pkg/action/common.go index 49bdb743..5539f7ed 100644 --- a/pkg/action/common.go +++ b/pkg/action/common.go @@ -180,24 +180,40 @@ func printReport(ctx context.Context, report *ReleaseReportV3) { } } -func resolveDiffPatches(chart helmchart.Accessor, defaultDisable bool, patchesFiles []string) ([]spec.DiffPatch, error) { - var patches []spec.DiffPatch +// Chart-shipped rules are scoped to their own chart subtree, rules from patches files are not. +// Both kinds are compiled right away, so an invalid rule fails before anything is applied. +func resolvePatches(chart helmchart.Accessor, defaultDisable bool, patchesFiles []string) (spec.CompiledPatches, error) { + var patches spec.Patches if !defaultDisable { chartPatches, err := spec.CollectChartPatches(chart) if err != nil { - return nil, fmt.Errorf("collect chart patches: %w", err) + return spec.CompiledPatches{}, fmt.Errorf("collect chart patches: %w", err) } - patches = append(patches, chartPatches...) + patches.Diff = append(patches.Diff, chartPatches.Diff...) + patches.Render = append(patches.Render, chartPatches.Render...) } filePatches, err := spec.LoadPatchesFiles(patchesFiles) if err != nil { - return nil, fmt.Errorf("load patches files: %w", err) + return spec.CompiledPatches{}, fmt.Errorf("load patches files: %w", err) } - return append(patches, filePatches...), nil + patches.Diff = append(patches.Diff, filePatches.Diff...) + patches.Render = append(patches.Render, filePatches.Render...) + + diffPatches, err := spec.CompilePatches(patches.Diff) + if err != nil { + return spec.CompiledPatches{}, fmt.Errorf("compile diff patches: %w", err) + } + + renderPatches, err := spec.CompilePatches(patches.Render) + if err != nil { + return spec.CompiledPatches{}, fmt.Errorf("compile render patches: %w", err) + } + + return spec.CompiledPatches{Diff: diffPatches, Render: renderPatches}, nil } func runFailurePlan(ctx context.Context, releaseNamespace string, failedPlan *plan.Plan, installableInfos []*plan.InstallableResourceInfo, releaseInfos []*plan.ReleaseInfo, taskStore *kdutil.Concurrent[*statestore.TaskStore], logStore *kdutil.Concurrent[*logstore.LogStore], informerFactory *kdutil.Concurrent[*informer.InformerFactory], history *release.History, clientFactory *kube.ClientFactory, opts runFailureInstallPlanOptions) (result *runFailurePlanResult, nonCritErrs, critErrs *util.MultiError) { diff --git a/pkg/action/release_install.go b/pkg/action/release_install.go index 2b598cba..54f7a81c 100644 --- a/pkg/action/release_install.go +++ b/pkg/action/release_install.go @@ -386,6 +386,13 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re return fmt.Errorf("render chart: %w", err) } + log.Default.Debug(ctx, "Resolve patches") + + patches, err := resolvePatches(renderChartResult.Chart, opts.DefaultPatchesDisable, opts.PatchesFiles) + if err != nil { + return fmt.Errorf("resolve patches: %w", err) + } + log.Default.Debug(ctx, "Build transformed resource specs") transformedResSpecs, err := spec.BuildTransformedResourceSpecs(ctx, releaseNamespace, renderChartResult.ResourceSpecs, []spec.ResourceTransformer{ @@ -395,6 +402,13 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re return fmt.Errorf("build transformed resource specs: %w", err) } + log.Default.Debug(ctx, "Build render patched resource specs") + + renderPatchedResSpecs, err := spec.BuildRenderPatchedResourceSpecs(ctx, releaseNamespace, transformedResSpecs, patches.Render) + if err != nil { + return fmt.Errorf("build render patched resource specs: %w", err) + } + log.Default.Debug(ctx, "Build releasable resource specs") patchers := []spec.ResourcePatcher{ @@ -406,7 +420,7 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re patchers = append(patchers, spec.NewLegacyOnlyTrackJobsPatcher()) } - releasableResSpecs, err := spec.BuildPatchedResourceSpecs(ctx, releaseNamespace, transformedResSpecs, patchers) + releasableResSpecs, err := spec.BuildPatchedResourceSpecs(ctx, releaseNamespace, renderPatchedResSpecs, patchers) if err != nil { return fmt.Errorf("build releasable resource specs: %w", err) } @@ -470,13 +484,8 @@ func releaseInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, re } } - diffPatches, err := resolveDiffPatches(renderChartResult.Chart, opts.DefaultPatchesDisable, opts.PatchesFiles) - if err != nil { - return fmt.Errorf("resolve diff patches: %w", err) - } - instResInfos, delResInfos, err = plan.BuildResourceInfos(ctx, deployType, releaseName, releaseNamespace, instResources, delResources, prevReleaseFailed, clientFactory, plan.BuildResourceInfosOptions{ - DiffPatches: diffPatches, + DiffPatches: patches.Diff, NetworkParallelism: opts.NetworkParallelism, NoRemoveManualChanges: opts.NoRemoveManualChanges, LastDeployedOrLastRelResourceSpecs: lastDeployedOrLastRelResSpecs, @@ -922,13 +931,13 @@ func runRollbackPlan(ctx context.Context, releaseName, releaseNamespace string, return nil, nonCritErrs, critErrs.Add(fmt.Errorf("convert last deployed or last release to resource specs: %w", err)) } - diffPatches, err := resolveDiffPatches(chartAccessor, opts.DefaultPatchesDisable, opts.PatchesFiles) + patches, err := resolvePatches(chartAccessor, opts.DefaultPatchesDisable, opts.PatchesFiles) if err != nil { - return nil, nonCritErrs, critErrs.Add(fmt.Errorf("resolve diff patches: %w", err)) + return nil, nonCritErrs, critErrs.Add(fmt.Errorf("resolve patches: %w", err)) } instResInfos, delResInfos, err := plan.BuildResourceInfos(ctx, common.DeployTypeRollback, releaseName, releaseNamespace, instResources, delResources, true, clientFactory, plan.BuildResourceInfosOptions{ - DiffPatches: diffPatches, + DiffPatches: patches.Diff, NetworkParallelism: opts.NetworkParallelism, NoRemoveManualChanges: opts.NoRemoveManualChanges, LastDeployedOrLastRelResourceSpecs: lastDeployedOrLastRelResSpecs, diff --git a/pkg/action/release_plan_install.go b/pkg/action/release_plan_install.go index 18a09c3a..8cef8ba5 100644 --- a/pkg/action/release_plan_install.go +++ b/pkg/action/release_plan_install.go @@ -280,6 +280,13 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc return nil, fmt.Errorf("render chart: %w", err) } + log.Default.Debug(ctx, "Resolve patches") + + patches, err := resolvePatches(renderChartResult.Chart, opts.DefaultPatchesDisable, opts.PatchesFiles) + if err != nil { + return nil, fmt.Errorf("resolve patches: %w", err) + } + log.Default.Debug(ctx, "Build transformed resource specs") transformedResSpecs, err := spec.BuildTransformedResourceSpecs(ctx, releaseNamespace, renderChartResult.ResourceSpecs, []spec.ResourceTransformer{ @@ -289,6 +296,13 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc return nil, fmt.Errorf("build transformed resource specs: %w", err) } + log.Default.Debug(ctx, "Build render patched resource specs") + + renderPatchedResSpecs, err := spec.BuildRenderPatchedResourceSpecs(ctx, releaseNamespace, transformedResSpecs, patches.Render) + if err != nil { + return nil, fmt.Errorf("build render patched resource specs: %w", err) + } + log.Default.Debug(ctx, "Build releasable resource specs") patchers := []spec.ResourcePatcher{ @@ -300,7 +314,7 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc patchers = append(patchers, spec.NewLegacyOnlyTrackJobsPatcher()) } - releasableResSpecs, err := spec.BuildPatchedResourceSpecs(ctx, releaseNamespace, transformedResSpecs, patchers) + releasableResSpecs, err := spec.BuildPatchedResourceSpecs(ctx, releaseNamespace, renderPatchedResSpecs, patchers) if err != nil { return nil, fmt.Errorf("build releasable resource specs: %w", err) } @@ -361,13 +375,8 @@ func releasePlanInstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc } } - diffPatches, err := resolveDiffPatches(renderChartResult.Chart, opts.DefaultPatchesDisable, opts.PatchesFiles) - if err != nil { - return nil, fmt.Errorf("resolve diff patches: %w", err) - } - instResInfos, delResInfos, err := plan.BuildResourceInfos(ctx, deployType, releaseName, releaseNamespace, instResources, delResources, prevReleaseFailed, clientFactory, plan.BuildResourceInfosOptions{ - DiffPatches: diffPatches, + DiffPatches: patches.Diff, NetworkParallelism: opts.NetworkParallelism, NoRemoveManualChanges: opts.NoRemoveManualChanges, LastDeployedOrLastRelResourceSpecs: lastDeployedOrLastRelResSpecs, diff --git a/pkg/action/release_rollback.go b/pkg/action/release_rollback.go index eb4014f8..1eb51db3 100644 --- a/pkg/action/release_rollback.go +++ b/pkg/action/release_rollback.go @@ -61,8 +61,9 @@ type ReleaseRollbackOptions struct { // NoShowNotes, when true, suppresses printing of NOTES.txt after successful rollback. // NOTES.txt typically contains usage instructions and next steps. NoShowNotes bool - // PatchesFiles are paths to additional patches files (diff patches for drift - // detection) applied on top of chart-shipped ones during the rollback plan. + // PatchesFiles are paths to additional patches files applied on top of + // chart-shipped ones during the rollback plan. Nothing is rendered during a + // rollback, so only diff patches (drift detection) have an effect here. PatchesFiles []string // ReleaseHistoryLimit sets the maximum number of release revisions to keep in storage. // When exceeded, the oldest revisions are deleted. Defaults to DefaultReleaseHistoryLimit if not set or <= 0. @@ -319,13 +320,13 @@ func releaseRollback(ctx context.Context, ctxCancelFn context.CancelCauseFunc, r } } - diffPatches, err := resolveDiffPatches(chartAccessor, opts.DefaultPatchesDisable, opts.PatchesFiles) + patches, err := resolvePatches(chartAccessor, opts.DefaultPatchesDisable, opts.PatchesFiles) if err != nil { - return fmt.Errorf("resolve diff patches: %w", err) + return fmt.Errorf("resolve patches: %w", err) } instResInfos, delResInfos, err := plan.BuildResourceInfos(ctx, deployType, releaseName, releaseNamespace, instResources, delResources, prevReleaseFailed, clientFactory, plan.BuildResourceInfosOptions{ - DiffPatches: diffPatches, + DiffPatches: patches.Diff, NetworkParallelism: opts.NetworkParallelism, NoRemoveManualChanges: opts.NoRemoveManualChanges, LastDeployedOrLastRelResourceSpecs: lastDeployedOrLastRelResSpecs, diff --git a/pkg/action/release_uninstall.go b/pkg/action/release_uninstall.go index e2682f6d..32c3b0d9 100644 --- a/pkg/action/release_uninstall.go +++ b/pkg/action/release_uninstall.go @@ -59,8 +59,9 @@ type ReleaseUninstallOptions struct { // NoRemoveManualChanges, when true, preserves fields manually added to resources in the cluster // that are not present in the chart manifests. By default, such fields are removed during deletion. NoRemoveManualChanges bool - // PatchesFiles are paths to additional patches files (diff patches for drift - // detection) applied on top of chart-shipped ones during the uninstall plan. + // PatchesFiles are paths to additional patches files applied on top of + // chart-shipped ones during the uninstall plan. Nothing is rendered during an + // uninstall, so only diff patches (drift detection) have an effect here. PatchesFiles []string // ReleaseHistoryLimit sets the maximum number of release revisions to keep in storage. // Defaults to DefaultReleaseHistoryLimit if not set or <= 0. @@ -245,13 +246,13 @@ func releaseUninstall(ctx context.Context, ctxCancelFn context.CancelCauseFunc, return fmt.Errorf("access chart of previous release: %w", err) } - diffPatches, err := resolveDiffPatches(uninstallChart, opts.DefaultPatchesDisable, opts.PatchesFiles) + patches, err := resolvePatches(uninstallChart, opts.DefaultPatchesDisable, opts.PatchesFiles) if err != nil { - return fmt.Errorf("resolve diff patches: %w", err) + return fmt.Errorf("resolve patches: %w", err) } instResInfos, delResInfos, err := plan.BuildResourceInfos(ctx, deployType, releaseName, releaseNamespace, instResources, delResources, prevReleaseFailed, clientFactory, plan.BuildResourceInfosOptions{ - DiffPatches: diffPatches, + DiffPatches: patches.Diff, NetworkParallelism: opts.NetworkParallelism, NoRemoveManualChanges: opts.NoRemoveManualChanges, LastDeployedOrLastRelResourceSpecs: prevRelResSpecs, diff --git a/pkg/common/options.go b/pkg/common/options.go index cc018a0f..5227fb42 100644 --- a/pkg/common/options.go +++ b/pkg/common/options.go @@ -240,12 +240,13 @@ type ReleaseInstallRuntimeOptions struct { // DefaultDeletePropagation sets the deletion propagation policy for resource deletions. DefaultDeletePropagation string `json:"defaultDeletePropagation"` // PatchesFiles are paths to patches files (same format as a chart-shipped - // patches.yaml) whose diff patch rules are applied on top of chart-shipped - // ones. Diff patches affect ONLY drift detection: each matching rule's jq - // transform is applied identically to the live and the dry-apply object before - // comparison, so normalized-away fields never produce a diff. They never change - // what is rendered or applied. These rules are UNSCOPED (they may match any - // resource), unlike chart-shipped rules which are scoped to their chart subtree. + // patches.yaml) whose rules are applied on top of chart-shipped ones. Render + // patches are applied to the rendered resources, so they change what is + // released and applied. Diff patches affect ONLY drift detection: each + // matching rule's jq transform is applied identically to the live and the + // dry-apply object before comparison, so normalized-away fields never produce + // a diff. Rules from these files are UNSCOPED (they may match any resource), + // unlike chart-shipped rules which are scoped to their chart subtree. PatchesFiles []string `json:"patchesFiles"` // DefaultPatchesDisable, when true, ignores chart-shipped patches.yaml files // (from the top-level chart and subcharts). diff --git a/pkg/plan/resource_info.go b/pkg/plan/resource_info.go index 6d39046e..aae60ab5 100644 --- a/pkg/plan/resource_info.go +++ b/pkg/plan/resource_info.go @@ -78,7 +78,7 @@ type DeletableResourceInfo struct { } type BuildResourceInfosOptions struct { - DiffPatches []spec.DiffPatch + DiffPatches []*spec.CompiledPatch ExtraRuntimeAnnotations map[string]string ExtraRuntimeLabels map[string]string LastDeployedOrLastRelResourceSpecs []*spec.ResourceSpec @@ -91,11 +91,6 @@ type BuildResourceInfosOptions struct { // more info, and here we actually decide what to do with each resource. Initially all this logic // was in BuildPlan, but it became way too complex, so we extracted it here. func BuildResourceInfos(ctx context.Context, deployType common.DeployType, releaseName, releaseNamespace string, instResources []*resource.InstallableResource, delResources []*resource.DeletableResource, prevReleaseFailed bool, clientFactory kube.ClientFactorier, opts BuildResourceInfosOptions) (instResourceInfos []*InstallableResourceInfo, delResourceInfos []*DeletableResourceInfo, err error) { - diffPatches, err := spec.CompileDiffPatches(opts.DiffPatches) - if err != nil { - return nil, nil, fmt.Errorf("compile diff patches: %w", err) - } - totalResourcesCount := len(instResources) + len(delResources) routines := lo.Max([]int{len(instResources) / lo.Max([]int{totalResourcesCount, 1}) * opts.NetworkParallelism, 1}) @@ -103,7 +98,7 @@ func BuildResourceInfos(ctx context.Context, deployType common.DeployType, relea instResourcesPool := pool.NewWithResults[[]*InstallableResourceInfo]().WithContext(ctx).WithMaxGoroutines(routines).WithCancelOnError().WithFirstError() for _, res := range instResources { instResourcesPool.Go(func(ctx context.Context) ([]*InstallableResourceInfo, error) { - infos, err := buildInstallableResourceInfo(ctx, res, deployType, releaseNamespace, prevReleaseFailed, opts.NoRemoveManualChanges, clientFactory, opts, diffPatches) + infos, err := buildInstallableResourceInfo(ctx, res, deployType, releaseNamespace, prevReleaseFailed, opts.NoRemoveManualChanges, clientFactory, opts, opts.DiffPatches) if err != nil { return nil, fmt.Errorf("build installable resource info: %w", err) } @@ -165,7 +160,7 @@ func ResourceInstallTypeSortHandler(type1, type2 ResourceInstallType) bool { return type1I < type2I } -func buildInstallableResourceInfo(ctx context.Context, localRes *resource.InstallableResource, deployType common.DeployType, releaseNamespace string, prevRelFailed, noRemoveManualChanges bool, clientFactory kube.ClientFactorier, opts BuildResourceInfosOptions, diffPatches []*spec.CompiledDiffPatch) ([]*InstallableResourceInfo, error) { +func buildInstallableResourceInfo(ctx context.Context, localRes *resource.InstallableResource, deployType common.DeployType, releaseNamespace string, prevRelFailed, noRemoveManualChanges bool, clientFactory kube.ClientFactorier, opts BuildResourceInfosOptions, diffPatches []*spec.CompiledPatch) ([]*InstallableResourceInfo, error) { var stages []common.Stage switch deployType { case common.DeployTypeInitial, common.DeployTypeInstall: @@ -836,7 +831,7 @@ func removeUndesirableManagers(managedFields []v1.ManagedFieldsEntry, oursEntry return newManagedFields, newOursEntry, changed } -func resourceInstallType(ctx context.Context, localRes *resource.InstallableResource, getObj, dryApplyObj *unstructured.Unstructured, dryApplyErr error, extraRuntimeAnnotations, extraRuntimeLabels map[string]string, resourcePolicies []common.ResourcePolicy, diffPatches []*spec.CompiledDiffPatch) (installType ResourceInstallType, skippedByPolicy bool, err error) { +func resourceInstallType(ctx context.Context, localRes *resource.InstallableResource, getObj, dryApplyObj *unstructured.Unstructured, dryApplyErr error, extraRuntimeAnnotations, extraRuntimeLabels map[string]string, resourcePolicies []common.ResourcePolicy, diffPatches []*spec.CompiledPatch) (installType ResourceInstallType, skippedByPolicy bool, err error) { skipCreate := lo.Contains(resourcePolicies, common.ResourcePolicySkipCreate) skipUpdate := lo.Contains(resourcePolicies, common.ResourcePolicySkipUpdate) skipRecreate := lo.Contains(resourcePolicies, common.ResourcePolicySkipRecreate) @@ -881,11 +876,11 @@ func resourceInstallType(ctx context.Context, localRes *resource.InstallableReso // namespace selector dimension. namespace := getObj.GetNamespace() - if patchedGetObj, err = spec.ApplyDiffPatches(diffPatches, localRes.ResourceMeta, namespace, getObj); err != nil { + if patchedGetObj, err = spec.ApplyPatches(ctx, diffPatches, localRes.ResourceMeta, namespace, getObj); err != nil { return "", false, fmt.Errorf("apply diff patches to live version of resource %q: %w", localRes.IDHuman(), err) } - if patchedDryApplyObj, err = spec.ApplyDiffPatches(diffPatches, localRes.ResourceMeta, namespace, dryApplyObj); err != nil { + if patchedDryApplyObj, err = spec.ApplyPatches(ctx, diffPatches, localRes.ResourceMeta, namespace, dryApplyObj); err != nil { return "", false, fmt.Errorf("apply diff patches to dry-apply version of resource %q: %w", localRes.IDHuman(), err) } } diff --git a/pkg/plan/resource_info_test.go b/pkg/plan/resource_info_test.go index d0611104..1532e91f 100644 --- a/pkg/plan/resource_info_test.go +++ b/pkg/plan/resource_info_test.go @@ -572,28 +572,17 @@ func (s *ResourceInfoSuite) TestBuildResourceInfosDiffPatches() { localRes := updatedInstallableResource(&s.Suite, s.releaseName, s.releaseNamespace) + diffPatches, err := spec.CompilePatches([]spec.Patch{{Patch: `del(.data.key2)`}}) + s.Require().NoError(err) + instResInfos, _, err := plan.BuildResourceInfos(context.Background(), common.DeployTypeInitial, s.releaseName, s.releaseNamespace, []*resource.InstallableResource{localRes}, nil, false, s.clientFactory, plan.BuildResourceInfosOptions{ NetworkParallelism: 10, - DiffPatches: []spec.DiffPatch{ - {Patch: `del(.data.key2)`}, - }, + DiffPatches: diffPatches, }) s.Require().NoError(err) s.Require().Len(instResInfos, 1) s.Require().Equal(plan.ResourceInstallTypeNone, instResInfos[0].MustInstall) }) - - s.Run(`invalid diff patch fails closed`, func() { - localRes := defaultInstallableResource(s.releaseName, s.releaseNamespace) - - _, _, err := plan.BuildResourceInfos(context.Background(), common.DeployTypeInitial, s.releaseName, s.releaseNamespace, []*resource.InstallableResource{localRes}, nil, false, s.clientFactory, plan.BuildResourceInfosOptions{ - NetworkParallelism: 10, - DiffPatches: []spec.DiffPatch{ - {Patch: `del(.data.key2`}, - }, - }) - s.Require().Error(err) - }) } type buildInstallableResourceInfoTestCase struct { diff --git a/pkg/resource/spec/export_test.go b/pkg/resource/spec/export_test.go index 4e5853f8..40366d4d 100644 --- a/pkg/resource/spec/export_test.go +++ b/pkg/resource/spec/export_test.go @@ -1,15 +1,19 @@ package spec -import "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +import ( + "context" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) var ( - // CompileDiffPatch and ParsePatchesFile expose unexported symbols to the + // CompilePatch and ParsePatchesFile expose unexported symbols to the // external spec_test package. - CompileDiffPatch = compileDiffPatch + CompilePatch = compilePatch ParsePatchesFile = parsePatchesFile ) // Transform exposes the unexported transform method to the external spec_test package. -func (c *CompiledDiffPatch) Transform(unstruct *unstructured.Unstructured) (*unstructured.Unstructured, error) { - return c.transform(unstruct) +func (c *CompiledPatch) Transform(ctx context.Context, unstruct *unstructured.Unstructured) (*unstructured.Unstructured, error) { + return c.transform(ctx, unstruct) } diff --git a/pkg/resource/spec/helpers_ai_test.go b/pkg/resource/spec/helpers_ai_test.go new file mode 100644 index 00000000..9882d134 --- /dev/null +++ b/pkg/resource/spec/helpers_ai_test.go @@ -0,0 +1,33 @@ +//go:build ai_tests + +package spec_test + +import ( + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/werf/nelm/pkg/resource/spec" +) + +func renderedSpec(t *testing.T, name, namespace, filePath string, annotations map[string]interface{}) *spec.ResourceSpec { + t.Helper() + + metadata := map[string]interface{}{"name": name} + if namespace != "" { + metadata["namespace"] = namespace + } + + if annotations != nil { + metadata["annotations"] = annotations + } + + unstruct := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": metadata, + "spec": map[string]interface{}{"replicas": int64(3)}, + }} + + return spec.NewResourceSpec(unstruct, "prod", spec.ResourceSpecOptions{FilePath: filePath}) +} diff --git a/pkg/resource/spec/diff_patch.go b/pkg/resource/spec/patches.go similarity index 55% rename from pkg/resource/spec/diff_patch.go rename to pkg/resource/spec/patches.go index 3f4924a7..e40c42a6 100644 --- a/pkg/resource/spec/diff_patch.go +++ b/pkg/resource/spec/patches.go @@ -2,6 +2,7 @@ package spec import ( "bytes" + "context" "encoding/json" "fmt" "os" @@ -15,30 +16,46 @@ import ( ) const ( - DiffPatchTypeJQ DiffPatchType = "jq" + PatchTypeJQ PatchType = "jq" // patchesFileName is the conventional name of a chart-shipped patches file. patchesFileName = "patches.yaml" ) -// DiffPatchType is the transform kind of a diff patch. -type DiffPatchType string +// PatchType is the transform kind of patch. +type PatchType string -// PatchesFile is the on-disk format of a patches file. Only diffPatches is -// supported today; the top-level key leaves room for future siblings. +// PatchesFile is the on-disk format of a patches file. type PatchesFile struct { - DiffPatches []DiffPatch `json:"diffPatches,omitempty"` + DiffPatches []Patch `json:"diffPatches,omitempty"` + RenderPatches []Patch `json:"renderPatches,omitempty"` } -// DiffPatch is a diff-time normalization rule. It affects ONLY drift detection: -// the transform is applied identically to the live and the dry-apply object -// before they are compared, so normalized-away fields never produce a diff. -type DiffPatch struct { +// Patches are patch rules grouped by the point at which they are applied. +type Patches struct { + // Diff rules affect ONLY drift detection: the transform is applied identically + // to the live and the dry-apply object before they are compared, so + // normalized-away fields never produce a diff. They never change what is + // rendered or applied. + Diff []Patch + // Render rules are applied to the rendered resources, so they do change what is + // released and applied to the cluster. + Render []Patch +} + +// CompiledPatches are Patches with their jq programs compiled. +type CompiledPatches struct { + Diff []*CompiledPatch + Render []*CompiledPatch +} + +// Patch is a jq transform applied to every resource its matcher matches. +type Patch struct { // Match chooses which resources this rule applies to. An empty matcher // matches every resource. Match ResourceMatcher `json:"match,omitempty"` // Type is the transform kind. Only "jq" is supported; empty defaults to "jq". - Type DiffPatchType `json:"type,omitempty"` + Type PatchType `json:"type,omitempty"` // Patch is the jq program: it receives the whole raw resource object and must // return exactly one object. Patch string `json:"patch,omitempty"` @@ -48,9 +65,9 @@ type DiffPatch struct { ChartScope string `json:"-"` } -// CompiledDiffPatch is a DiffPatch with its jq program compiled once, ready to -// match and transform many resources. -type CompiledDiffPatch struct { +// CompiledPatch is a Patch with its jq program compiled once, ready to match and +// transform many resources. +type CompiledPatch struct { chartScope string code *gojq.Code matcher ResourceMatcher @@ -59,7 +76,7 @@ type CompiledDiffPatch struct { // Match reports whether the rule matches the resource. namespace is the // resource's true namespace (empty only for cluster-scoped resources), passed in // because ResourceMeta.Namespace is blanked for release-namespace resources. -func (c *CompiledDiffPatch) Match(resMeta *ResourceMeta, namespace string) bool { +func (c *CompiledPatch) Match(resMeta *ResourceMeta, namespace string) bool { if !resourceInChartScope(c.chartScope, resMeta.FilePath) { return false } @@ -74,7 +91,7 @@ func (c *CompiledDiffPatch) Match(resMeta *ResourceMeta, namespace string) bool // transform runs the compiled jq program over a deep copy of the object and // returns the single object output. Zero, multiple, or non-object output is an // error, and a jq panic is recovered into an error; the input is never mutated. -func (c *CompiledDiffPatch) transform(unstruct *unstructured.Unstructured) (result *unstructured.Unstructured, err error) { +func (c *CompiledPatch) transform(ctx context.Context, unstruct *unstructured.Unstructured) (result *unstructured.Unstructured, err error) { // Unstructured stores integers as int64, which gojq rejects; round-trip // through JSON with UseNumber so numbers reach gojq as json.Number. input, err := toJQInput(unstruct.Object) @@ -89,7 +106,7 @@ func (c *CompiledDiffPatch) transform(unstruct *unstructured.Unstructured) (resu } }() - iter := c.code.Run(input) + iter := c.code.RunWithContext(ctx, input) first, ok := iter.Next() if !ok { @@ -116,10 +133,10 @@ func (c *CompiledDiffPatch) transform(unstruct *unstructured.Unstructured) (resu return &unstructured.Unstructured{Object: obj}, nil } -// ApplyDiffPatches runs every rule whose matcher matches the resource, threading -// each transform's output into the next, and returns a transformed deep copy; the +// ApplyPatches runs every rule whose matcher matches the resource, threading each +// transform's output into the next, and returns a transformed deep copy; the // input is never mutated. namespace is the resource's true namespace. -func ApplyDiffPatches(patches []*CompiledDiffPatch, resMeta *ResourceMeta, namespace string, unstruct *unstructured.Unstructured) (*unstructured.Unstructured, error) { +func ApplyPatches(ctx context.Context, patches []*CompiledPatch, resMeta *ResourceMeta, namespace string, unstruct *unstructured.Unstructured) (*unstructured.Unstructured, error) { result := unstruct transformed := false @@ -128,9 +145,9 @@ func ApplyDiffPatches(patches []*CompiledDiffPatch, resMeta *ResourceMeta, names continue } - out, err := patch.transform(result) + out, err := patch.transform(ctx, result) if err != nil { - return nil, fmt.Errorf("apply diff patch #%d: %w", i+1, err) + return nil, fmt.Errorf("apply patch #%d: %w", i+1, err) } result = out @@ -146,30 +163,31 @@ func ApplyDiffPatches(patches []*CompiledDiffPatch, resMeta *ResourceMeta, names // CollectChartPatches returns every chart-shipped patches.yaml rule in the chart // tree, ordered leaf-first and each constrained to its own chart subtree. -func CollectChartPatches(chart helmchart.Accessor) ([]DiffPatch, error) { +func CollectChartPatches(chart helmchart.Accessor) (Patches, error) { if chart == nil { - return nil, nil + return Patches{}, nil } chartPath := chart.ChartFullPath() - var patches []DiffPatch + var patches Patches for _, dep := range chart.Dependencies() { depAccessor, err := helmchart.NewAccessor(dep) if err != nil { - return nil, fmt.Errorf("access subchart of %q: %w", chartPath, err) + return Patches{}, fmt.Errorf("access subchart of %q: %w", chartPath, err) } depPatches, err := CollectChartPatches(depAccessor) if err != nil { - return nil, err + return Patches{}, err } - patches = append(patches, depPatches...) + patches.Diff = append(patches.Diff, depPatches.Diff...) + patches.Render = append(patches.Render, depPatches.Render...) } - var own []DiffPatch + var own Patches for _, f := range chart.Files() { if f.Name != patchesFileName { continue @@ -177,7 +195,7 @@ func CollectChartPatches(chart helmchart.Accessor) ([]DiffPatch, error) { ownPatches, err := parsePatchesFile(f.Data) if err != nil { - return nil, fmt.Errorf("read %s of chart %q: %w", patchesFileName, chartPath, err) + return Patches{}, fmt.Errorf("read %s of chart %q: %w", patchesFileName, chartPath, err) } own = ownPatches @@ -185,26 +203,32 @@ func CollectChartPatches(chart helmchart.Accessor) ([]DiffPatch, error) { break } - for i := range own { - own[i].ChartScope = chartPath + for i := range own.Diff { + own.Diff[i].ChartScope = chartPath + } + + for i := range own.Render { + own.Render[i].ChartScope = chartPath } - return append(patches, own...), nil + patches.Diff = append(patches.Diff, own.Diff...) + patches.Render = append(patches.Render, own.Render...) + + return patches, nil } -// CompileDiffPatches compiles diff patch rules, returning a plan error on the -// first invalid regexp, unsupported type, empty patch body, or invalid jq -// program. -func CompileDiffPatches(patches []DiffPatch) ([]*CompiledDiffPatch, error) { +// CompilePatches compiles patch rules, returning an error on the first invalid +// regexp, unsupported type, empty patch body, or invalid jq program. +func CompilePatches(patches []Patch) ([]*CompiledPatch, error) { if len(patches) == 0 { return nil, nil } - compiled := make([]*CompiledDiffPatch, 0, len(patches)) + compiled := make([]*CompiledPatch, 0, len(patches)) for i, patch := range patches { - c, err := compileDiffPatch(patch) + c, err := compilePatch(patch) if err != nil { - return nil, fmt.Errorf("compile diff patch #%d: %w", i+1, err) + return nil, fmt.Errorf("compile patch #%d: %w", i+1, err) } compiled = append(compiled, c) @@ -215,20 +239,21 @@ func CompileDiffPatches(patches []DiffPatch) ([]*CompiledDiffPatch, error) { // LoadPatchesFiles reads and parses the given patches file paths, returning their // rules concatenated in order. -func LoadPatchesFiles(paths []string) ([]DiffPatch, error) { - var patches []DiffPatch +func LoadPatchesFiles(paths []string) (Patches, error) { + var patches Patches for _, path := range paths { data, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("read patches file %q: %w", path, err) + return Patches{}, fmt.Errorf("read patches file %q: %w", path, err) } filePatches, err := parsePatchesFile(data) if err != nil { - return nil, fmt.Errorf("patches file %q: %w", path, err) + return Patches{}, fmt.Errorf("patches file %q: %w", path, err) } - patches = append(patches, filePatches...) + patches.Diff = append(patches.Diff, filePatches.Diff...) + patches.Render = append(patches.Render, filePatches.Render...) } return patches, nil @@ -248,7 +273,12 @@ func fromJQOutput(value interface{}) (map[string]interface{}, error) { return nil, fmt.Errorf("decode: %w", err) } - normalized, ok := normalizeNumbers(decoded).(map[string]interface{}) + result, err := normalizeNumbers(decoded) + if err != nil { + return nil, fmt.Errorf("normalize jq output numbers: %w", err) + } + + normalized, ok := result.(map[string]interface{}) if !ok { return nil, fmt.Errorf("jq program output is not an object") } @@ -256,18 +286,18 @@ func fromJQOutput(value interface{}) (map[string]interface{}, error) { return normalized, nil } -func compileDiffPatch(patch DiffPatch) (*CompiledDiffPatch, error) { +func compilePatch(patch Patch) (*CompiledPatch, error) { patchType := patch.Type if patchType == "" { - patchType = DiffPatchTypeJQ + patchType = PatchTypeJQ } - if patchType != DiffPatchTypeJQ { - return nil, fmt.Errorf("unsupported diff patch type %q, only %q is supported", patch.Type, DiffPatchTypeJQ) + if patchType != PatchTypeJQ { + return nil, fmt.Errorf("unsupported patch type %q, only %q is supported", patch.Type, PatchTypeJQ) } if strings.TrimSpace(patch.Patch) == "" { - return nil, fmt.Errorf("diff patch program is empty") + return nil, fmt.Errorf("patch program is empty") } if err := patch.Match.Validate(); err != nil { @@ -284,47 +314,61 @@ func compileDiffPatch(patch DiffPatch) (*CompiledDiffPatch, error) { return nil, fmt.Errorf("compile jq program: %w", err) } - return &CompiledDiffPatch{chartScope: patch.ChartScope, code: code, matcher: patch.Match}, nil + return &CompiledPatch{chartScope: patch.ChartScope, code: code, matcher: patch.Match}, nil } -func normalizeNumbers(value interface{}) interface{} { +func normalizeNumbers(value interface{}) (interface{}, error) { switch v := value.(type) { case map[string]interface{}: for key, elem := range v { - v[key] = normalizeNumbers(elem) + normalized, err := normalizeNumbers(elem) + if err != nil { + return nil, err + } + + v[key] = normalized } - return v + return v, nil case []interface{}: for i, elem := range v { - v[i] = normalizeNumbers(elem) + normalized, err := normalizeNumbers(elem) + if err != nil { + return nil, err + } + + v[i] = normalized } - return v + return v, nil case json.Number: if i, err := v.Int64(); err == nil { - return i + return i, nil + } + + if s := v.String(); !strings.ContainsAny(s, ".eE") { + return nil, fmt.Errorf("integer %s overflows int64 and cannot be represented exactly", s) } if f, err := v.Float64(); err == nil { - return f + return f, nil } - return v.String() + return nil, fmt.Errorf("number %s cannot be represented as int64 or float64", v.String()) default: - return value + return value, nil } } -// parsePatchesFile parses a patches file into its diff patch rules. Unknown -// top-level keys are rejected so typos and unsupported kinds fail loudly. -func parsePatchesFile(data []byte) ([]DiffPatch, error) { +// parsePatchesFile parses a patches file into its patch rules. Unknown top-level +// keys are rejected so typos and unsupported kinds fail loudly. +func parsePatchesFile(data []byte) (Patches, error) { var file PatchesFile if err := yaml.UnmarshalStrict(data, &file); err != nil { - return nil, fmt.Errorf("parse patches file: %w", err) + return Patches{}, fmt.Errorf("parse patches file: %w", err) } - return file.DiffPatches, nil + return Patches{Diff: file.DiffPatches, Render: file.RenderPatches}, nil } func resourceInChartScope(chartPath, filePath string) bool { diff --git a/pkg/resource/spec/patches_ai_test.go b/pkg/resource/spec/patches_ai_test.go new file mode 100644 index 00000000..18f43a61 --- /dev/null +++ b/pkg/resource/spec/patches_ai_test.go @@ -0,0 +1,351 @@ +//go:build ai_tests + +package spec_test + +import ( + "context" + "math" + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/werf/nelm/pkg/common" + "github.com/werf/nelm/pkg/resource/spec" +) + +func TestAI_ApplyPatches_CancelledContextStopsInfiniteProgram(t *testing.T) { + obj := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{"name": "web"}, + }} + meta := metaFor("Deployment", "apps", "v1", "web", "", "myapp/templates/web.yaml", nil, nil) + + c, err := spec.CompilePatch(spec.Patch{Patch: `last(repeat(.))`}) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err = spec.ApplyPatches(ctx, []*spec.CompiledPatch{c}, meta, "prod", obj) + require.ErrorIs(t, err, context.Canceled) +} + +func TestAI_BuildRenderPatchedResourceSpecs_AllowsExplicitReleaseNamespace(t *testing.T) { + res := renderedSpec(t, "web", "", "myapp/templates/web.yaml", nil) + + patches, err := spec.CompilePatches([]spec.Patch{{Patch: `.metadata.namespace = "prod"`}}) + require.NoError(t, err) + + out, err := spec.BuildRenderPatchedResourceSpecs(context.Background(), "prod", []*spec.ResourceSpec{res}, patches) + require.NoError(t, err) + require.Empty(t, out[0].Unstruct.GetNamespace()) +} + +func TestAI_BuildRenderPatchedResourceSpecs_CancelledContextStopsInfiniteProgram(t *testing.T) { + res := renderedSpec(t, "web", "", "myapp/templates/web.yaml", nil) + + patches, err := spec.CompilePatches([]spec.Patch{{Patch: `def f: f; f`}}) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err = spec.BuildRenderPatchedResourceSpecs(ctx, "prod", []*spec.ResourceSpec{res}, patches) + require.ErrorIs(t, err, context.Canceled) +} + +func TestAI_BuildRenderPatchedResourceSpecs_ChainsRules(t *testing.T) { + res := renderedSpec(t, "web", "", "myapp/templates/web.yaml", nil) + + patches, err := spec.CompilePatches([]spec.Patch{ + {Patch: `.spec.replicas = 5`}, + {Patch: `.spec.replicas += 1`}, + }) + require.NoError(t, err) + + out, err := spec.BuildRenderPatchedResourceSpecs(context.Background(), "prod", []*spec.ResourceSpec{res}, patches) + require.NoError(t, err) + + replicas, _, err := unstructured.NestedInt64(out[0].Unstruct.Object, "spec", "replicas") + require.NoError(t, err) + require.Equal(t, int64(6), replicas) +} + +func TestAI_BuildRenderPatchedResourceSpecs_ChainsRulesByMetadata(t *testing.T) { + res := renderedSpec(t, "web", "", "myapp/templates/web.yaml", nil) + + patches, err := spec.CompilePatches([]spec.Patch{ + {Patch: `.metadata.labels = {"tier": "backend"}`}, + {Match: spec.ResourceMatcher{Labels: map[string]string{"tier": "backend"}}, Patch: `.spec.replicas = 7`}, + }) + require.NoError(t, err) + + out, err := spec.BuildRenderPatchedResourceSpecs(context.Background(), "prod", []*spec.ResourceSpec{res}, patches) + require.NoError(t, err) + + replicas, _, err := unstructured.NestedInt64(out[0].Unstruct.Object, "spec", "replicas") + require.NoError(t, err) + require.Equal(t, int64(7), replicas) +} + +func TestAI_BuildRenderPatchedResourceSpecs_ChartScope(t *testing.T) { + inScope := renderedSpec(t, "cached", "", "myapp/charts/cache/templates/web.yaml", nil) + outOfScope := renderedSpec(t, "web", "", "myapp/templates/web.yaml", nil) + + patches, err := spec.CompilePatches([]spec.Patch{{ + ChartScope: "myapp/charts/cache", + Patch: `del(.spec.replicas)`, + }}) + require.NoError(t, err) + + out, err := spec.BuildRenderPatchedResourceSpecs(context.Background(), "prod", []*spec.ResourceSpec{inScope, outOfScope}, patches) + require.NoError(t, err) + + _, found, err := unstructured.NestedInt64(out[0].Unstruct.Object, "spec", "replicas") + require.NoError(t, err) + require.False(t, found) + + _, found, err = unstructured.NestedInt64(out[1].Unstruct.Object, "spec", "replicas") + require.NoError(t, err) + require.True(t, found) +} + +func TestAI_BuildRenderPatchedResourceSpecs_ContractViolations(t *testing.T) { + tests := []struct { + name string + program string + }{ + {name: "no output", program: `empty`}, + {name: "multiple outputs", program: `., .`}, + {name: "not an object", program: `.metadata.name`}, + {name: "an object that is not a resource", program: `.spec`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res := renderedSpec(t, "web", "", "myapp/templates/web.yaml", nil) + + patches, err := spec.CompilePatches([]spec.Patch{{Patch: tt.program}}) + require.NoError(t, err) + + _, err = spec.BuildRenderPatchedResourceSpecs(context.Background(), "prod", []*spec.ResourceSpec{res}, patches) + require.ErrorContains(t, err, "apply render patches to resource") + }) + } +} + +func TestAI_BuildRenderPatchedResourceSpecs_IntegerOverflowErrorContext(t *testing.T) { + res := renderedSpec(t, "web", "", "myapp/templates/web.yaml", nil) + + patches, err := spec.CompilePatches([]spec.Patch{{Patch: `.spec.replicas = 9223372036854775807 + 10`}}) + require.NoError(t, err) + + _, err = spec.BuildRenderPatchedResourceSpecs(context.Background(), "prod", []*spec.ResourceSpec{res}, patches) + require.ErrorContains(t, err, "apply render patches to resource") + require.ErrorContains(t, err, "overflows int64") +} + +func TestAI_BuildRenderPatchedResourceSpecs_NamespaceSelector(t *testing.T) { + // A resource without an explicit namespace ends up in the release namespace, so it is + // matched by a selector naming the release namespace. + releaseNamespaced := renderedSpec(t, "web", "", "myapp/templates/web.yaml", nil) + otherNamespaced := renderedSpec(t, "web", "other", "myapp/templates/other.yaml", nil) + + patches, err := spec.CompilePatches([]spec.Patch{{ + Match: spec.ResourceMatcher{Namespaces: []string{"prod"}}, + Patch: `del(.spec.replicas)`, + }}) + require.NoError(t, err) + + out, err := spec.BuildRenderPatchedResourceSpecs(context.Background(), "prod", []*spec.ResourceSpec{releaseNamespaced, otherNamespaced}, patches) + require.NoError(t, err) + + _, found, err := unstructured.NestedInt64(out[0].Unstruct.Object, "spec", "replicas") + require.NoError(t, err) + require.False(t, found) + + _, found, err = unstructured.NestedInt64(out[1].Unstruct.Object, "spec", "replicas") + require.NoError(t, err) + require.True(t, found) +} + +func TestAI_BuildRenderPatchedResourceSpecs_NoPatches(t *testing.T) { + res := renderedSpec(t, "web", "", "myapp/templates/web.yaml", nil) + + out, err := spec.BuildRenderPatchedResourceSpecs(context.Background(), "prod", []*spec.ResourceSpec{res}, nil) + require.NoError(t, err) + require.Equal(t, []*spec.ResourceSpec{res}, out) +} + +func TestAI_BuildRenderPatchedResourceSpecs_PatchesMatchedOnly(t *testing.T) { + deployment := renderedSpec(t, "web", "", "myapp/templates/web.yaml", nil) + secret := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]interface{}{"name": "creds"}, + "data": map[string]interface{}{"key": "dmFsdWU="}, + }} + secretSpec := spec.NewResourceSpec(secret, "prod", spec.ResourceSpecOptions{FilePath: "myapp/templates/creds.yaml"}) + + patches, err := spec.CompilePatches([]spec.Patch{{ + Match: spec.ResourceMatcher{Kinds: []string{"Deployment"}}, + Patch: `.spec.replicas = 5`, + }}) + require.NoError(t, err) + + out, err := spec.BuildRenderPatchedResourceSpecs(context.Background(), "prod", []*spec.ResourceSpec{deployment, secretSpec}, patches) + require.NoError(t, err) + require.Len(t, out, 2) + + replicas, found, err := unstructured.NestedInt64(out[0].Unstruct.Object, "spec", "replicas") + require.NoError(t, err) + require.True(t, found) + require.Equal(t, int64(5), replicas) + + require.Equal(t, secret.Object, out[1].Unstruct.Object) + + // The input specs are never mutated. + inputReplicas, _, err := unstructured.NestedInt64(deployment.Unstruct.Object, "spec", "replicas") + require.NoError(t, err) + require.Equal(t, int64(3), inputReplicas) +} + +func TestAI_BuildRenderPatchedResourceSpecs_PreservesStoreAsNone(t *testing.T) { + res := renderedSpec(t, "web", "", "myapp/templates/web.yaml", nil) + crd := spec.NewResourceSpec(res.Unstruct, "prod", spec.ResourceSpecOptions{ + FilePath: "myapp/crds/foo.yaml", + StoreAs: common.StoreAsNone, + }) + + patches, err := spec.CompilePatches([]spec.Patch{ + {Match: spec.ResourceMatcher{Names: []string{"nonexistent"}}, Patch: `.spec.replicas = 5`}, + {Patch: `.metadata.annotations["helm.sh/hook"] = "post-install"`}, + }) + require.NoError(t, err) + + out, err := spec.BuildRenderPatchedResourceSpecs(context.Background(), "prod", []*spec.ResourceSpec{crd}, patches) + require.NoError(t, err) + require.Equal(t, common.StoreAsNone, out[0].StoreAs) +} + +func TestAI_BuildRenderPatchedResourceSpecs_RederivesStoreAs(t *testing.T) { + t.Run("adding the hook annotation turns a regular resource into a hook", func(t *testing.T) { + res := renderedSpec(t, "web", "", "myapp/templates/web.yaml", nil) + require.Equal(t, common.StoreAsRegular, res.StoreAs) + + patches, err := spec.CompilePatches([]spec.Patch{{Patch: `.metadata.annotations["helm.sh/hook"] = "post-install"`}}) + require.NoError(t, err) + + out, err := spec.BuildRenderPatchedResourceSpecs(context.Background(), "prod", []*spec.ResourceSpec{res}, patches) + require.NoError(t, err) + require.Equal(t, common.StoreAsHook, out[0].StoreAs) + }) + + t.Run("removing the hook annotation turns a hook into a regular resource", func(t *testing.T) { + res := renderedSpec(t, "web", "", "myapp/templates/web.yaml", map[string]interface{}{"helm.sh/hook": "post-install"}) + require.Equal(t, common.StoreAsHook, res.StoreAs) + + patches, err := spec.CompilePatches([]spec.Patch{{Patch: `del(.metadata.annotations["helm.sh/hook"])`}}) + require.NoError(t, err) + + out, err := spec.BuildRenderPatchedResourceSpecs(context.Background(), "prod", []*spec.ResourceSpec{res}, patches) + require.NoError(t, err) + require.Equal(t, common.StoreAsRegular, out[0].StoreAs) + }) +} + +func TestAI_BuildRenderPatchedResourceSpecs_RejectsIdentityChange(t *testing.T) { + tests := []struct { + name string + program string + }{ + {name: "name", program: `.metadata.name = "other"`}, + {name: "kind", program: `.kind = "StatefulSet"`}, + {name: "apiVersion", program: `.apiVersion = "apps/v1beta1"`}, + {name: "namespace", program: `.metadata.namespace = "other"`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res := renderedSpec(t, "web", "", "myapp/templates/web.yaml", nil) + + patches, err := spec.CompilePatches([]spec.Patch{{Patch: tt.program}}) + require.NoError(t, err) + + _, err = spec.BuildRenderPatchedResourceSpecs(context.Background(), "prod", []*spec.ResourceSpec{res}, patches) + require.ErrorContains(t, err, "changed resource identity") + }) + } +} + +func TestAI_BuildRenderPatchedResourceSpecs_RejectsNonStringMetadata(t *testing.T) { + tests := []struct { + name string + program string + }{ + {name: "annotations", program: `.metadata.annotations = {"werf.io/weight": 10}`}, + {name: "labels", program: `.metadata.labels = {"app": true}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res := renderedSpec(t, "web", "", "myapp/templates/web.yaml", nil) + + patches, err := spec.CompilePatches([]spec.Patch{{Patch: tt.program}}) + require.NoError(t, err) + + _, err = spec.BuildRenderPatchedResourceSpecs(context.Background(), "prod", []*spec.ResourceSpec{res}, patches) + require.ErrorContains(t, err, "expected string") + }) + } +} + +func TestAI_CompiledPatch_Transform_CancelledContextStopsInfiniteProgram(t *testing.T) { + obj := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{"name": "web"}, + }} + + c, err := spec.CompilePatch(spec.Patch{Patch: `def f: f; f`}) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err = c.Transform(ctx, obj) + require.ErrorIs(t, err, context.Canceled) +} + +func TestAI_CompiledPatch_Transform_KeepsFractional(t *testing.T) { + obj := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{"name": "web"}, + "spec": map[string]interface{}{}, + }} + + c, err := spec.CompilePatch(spec.Patch{Patch: `.spec.ratio = 0.5`}) + require.NoError(t, err) + + out, err := c.Transform(context.Background(), obj) + require.NoError(t, err) + require.Equal(t, float64(0.5), out.Object["spec"].(map[string]interface{})["ratio"]) +} + +func TestAI_CompiledPatch_Transform_RejectsIntegerOverflow(t *testing.T) { + obj := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{"name": "web"}, + "spec": map[string]interface{}{"big": int64(math.MaxInt64)}, + }} + + c, err := spec.CompilePatch(spec.Patch{Patch: `.spec.big += 10`}) + require.NoError(t, err) + + _, err = c.Transform(context.Background(), obj) + require.ErrorContains(t, err, "overflows int64") +} diff --git a/pkg/resource/spec/patches_internal_ai_test.go b/pkg/resource/spec/patches_internal_ai_test.go new file mode 100644 index 00000000..3f6758e6 --- /dev/null +++ b/pkg/resource/spec/patches_internal_ai_test.go @@ -0,0 +1,15 @@ +//go:build ai_tests + +package spec + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAI_normalizeNumbers_ErrorsOnUnrepresentableNumber(t *testing.T) { + _, err := normalizeNumbers(json.Number("1e10000")) + require.ErrorContains(t, err, "cannot be represented as int64 or float64") +} diff --git a/pkg/resource/spec/diff_patch_test.go b/pkg/resource/spec/patches_test.go similarity index 83% rename from pkg/resource/spec/diff_patch_test.go rename to pkg/resource/spec/patches_test.go index d6545768..5d404688 100644 --- a/pkg/resource/spec/diff_patch_test.go +++ b/pkg/resource/spec/patches_test.go @@ -1,6 +1,7 @@ package spec_test import ( + "context" "os" "path/filepath" "testing" @@ -15,7 +16,7 @@ import ( "github.com/werf/nelm/pkg/resource/spec" ) -func TestApplyDiffPatches(t *testing.T) { +func TestApplyPatches(t *testing.T) { obj := &unstructured.Unstructured{Object: map[string]interface{}{ "apiVersion": "apps/v1", "kind": "Deployment", @@ -25,31 +26,31 @@ func TestApplyDiffPatches(t *testing.T) { meta := metaFor("Deployment", "apps", "v1", "web", "", "myapp/templates/web.yaml", nil, nil) t.Run("no rules returns unchanged deep copy", func(t *testing.T) { - out, err := spec.ApplyDiffPatches(nil, meta, "prod", obj) + out, err := spec.ApplyPatches(context.Background(), nil, meta, "prod", obj) require.NoError(t, err) require.Equal(t, obj.Object, out.Object) require.NotSame(t, &obj.Object, &out.Object) }) t.Run("non-matching rule leaves object unchanged", func(t *testing.T) { - c, err := spec.CompileDiffPatch(spec.DiffPatch{ + c, err := spec.CompilePatch(spec.Patch{ Match: spec.ResourceMatcher{Names: []string{"other"}}, Patch: "del(.spec.replicas)", }) require.NoError(t, err) - out, err := spec.ApplyDiffPatches([]*spec.CompiledDiffPatch{c}, meta, "prod", obj) + out, err := spec.ApplyPatches(context.Background(), []*spec.CompiledPatch{c}, meta, "prod", obj) require.NoError(t, err) require.Equal(t, int64(3), out.Object["spec"].(map[string]interface{})["replicas"]) }) t.Run("matching rules chain in order", func(t *testing.T) { - c1, err := spec.CompileDiffPatch(spec.DiffPatch{Patch: "del(.spec.replicas)"}) + c1, err := spec.CompilePatch(spec.Patch{Patch: "del(.spec.replicas)"}) require.NoError(t, err) - c2, err := spec.CompileDiffPatch(spec.DiffPatch{Patch: `.metadata.name = "patched"`}) + c2, err := spec.CompilePatch(spec.Patch{Patch: `.metadata.name = "patched"`}) require.NoError(t, err) - out, err := spec.ApplyDiffPatches([]*spec.CompiledDiffPatch{c1, c2}, meta, "prod", obj) + out, err := spec.ApplyPatches(context.Background(), []*spec.CompiledPatch{c1, c2}, meta, "prod", obj) require.NoError(t, err) _, hasReplicas := out.Object["spec"].(map[string]interface{})["replicas"] @@ -61,56 +62,62 @@ func TestApplyDiffPatches(t *testing.T) { func TestCollectChartPatches_NilChart(t *testing.T) { patches, err := spec.CollectChartPatches(nil) require.NoError(t, err) - require.Empty(t, patches) + require.Empty(t, patches.Diff) + require.Empty(t, patches.Render) } func TestCollectChartPatches_ScopingAndOrder(t *testing.T) { // Tree: app (root) -> [cache subchart] - cache := chartWithPatches("cache", "diffPatches:\n- patch: del(.cacheField)\n") - app := chartWithPatches("app", "diffPatches:\n- patch: del(.appField)\n", cache) + cache := chartWithPatches("cache", "diffPatches:\n- patch: del(.cacheField)\nrenderPatches:\n- patch: del(.cacheRendered)\n") + app := chartWithPatches("app", "diffPatches:\n- patch: del(.appField)\nrenderPatches:\n- patch: del(.appRendered)\n", cache) accessor, err := helmchart.NewAccessor(app) require.NoError(t, err) patches, err := spec.CollectChartPatches(accessor) require.NoError(t, err) - require.Len(t, patches, 2) + require.Len(t, patches.Diff, 2) + require.Len(t, patches.Render, 2) // Leaf-first ordering: subchart (cache) rule before parent (app) rule. - require.Equal(t, "del(.cacheField)", patches[0].Patch) - require.Equal(t, "del(.appField)", patches[1].Patch) + require.Equal(t, "del(.cacheField)", patches.Diff[0].Patch) + require.Equal(t, "del(.appField)", patches.Diff[1].Patch) + require.Equal(t, "del(.cacheRendered)", patches.Render[0].Patch) + require.Equal(t, "del(.appRendered)", patches.Render[1].Patch) // Scoping: subchart rule constrained to its subtree; parent rule to the root. - require.Equal(t, "app/charts/cache", patches[0].ChartScope) - require.Equal(t, "app", patches[1].ChartScope) + require.Equal(t, "app/charts/cache", patches.Diff[0].ChartScope) + require.Equal(t, "app", patches.Diff[1].ChartScope) + require.Equal(t, "app/charts/cache", patches.Render[0].ChartScope) + require.Equal(t, "app", patches.Render[1].ChartScope) } -func TestCompileDiffPatch_DefaultsType(t *testing.T) { - c, err := spec.CompileDiffPatch(spec.DiffPatch{Patch: "."}) +func TestCompilePatch_DefaultsType(t *testing.T) { + c, err := spec.CompilePatch(spec.Patch{Patch: "."}) require.NoError(t, err) require.NotNil(t, c) } -func TestCompileDiffPatch_FailsClosed(t *testing.T) { +func TestCompilePatch_FailsClosed(t *testing.T) { tests := []struct { name string - patch spec.DiffPatch + patch spec.Patch }{ { name: "invalid jq program", - patch: spec.DiffPatch{Patch: "del(.spec.replicas"}, + patch: spec.Patch{Patch: "del(.spec.replicas"}, }, { name: "empty patch body", - patch: spec.DiffPatch{Patch: " "}, + patch: spec.Patch{Patch: " "}, }, { name: "unsupported type", - patch: spec.DiffPatch{Type: "jsonPointer", Patch: "."}, + patch: spec.Patch{Type: "jsonPointer", Patch: "."}, }, { name: "invalid regexp in selector", - patch: spec.DiffPatch{ + patch: spec.Patch{ Match: spec.ResourceMatcher{Names: []string{"/(/"}}, Patch: ".", }, @@ -119,13 +126,13 @@ func TestCompileDiffPatch_FailsClosed(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := spec.CompileDiffPatch(tt.patch) + _, err := spec.CompilePatch(tt.patch) require.Error(t, err) }) } } -func TestCompiledDiffPatch_ChartScope(t *testing.T) { +func TestCompiledPatch_ChartScope(t *testing.T) { scope := "app/charts/cache" tests := []struct { name string @@ -141,7 +148,7 @@ func TestCompiledDiffPatch_ChartScope(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - c, err := spec.CompileDiffPatch(spec.DiffPatch{ChartScope: scope, Patch: "."}) + c, err := spec.CompilePatch(spec.Patch{ChartScope: scope, Patch: "."}) require.NoError(t, err) meta := metaFor("Deployment", "apps", "v1", "web", "", tt.filePath, nil, nil) @@ -150,7 +157,7 @@ func TestCompiledDiffPatch_ChartScope(t *testing.T) { } } -func TestCompiledDiffPatch_Match(t *testing.T) { +func TestCompiledPatch_Match(t *testing.T) { tests := []struct { name string selector spec.ResourceMatcher @@ -339,14 +346,14 @@ func TestCompiledDiffPatch_Match(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - c, err := spec.CompileDiffPatch(spec.DiffPatch{Match: tt.selector, Patch: "."}) + c, err := spec.CompilePatch(spec.Patch{Match: tt.selector, Patch: "."}) require.NoError(t, err) require.Equal(t, tt.want, c.Match(tt.meta, tt.namespace)) }) } } -func TestCompiledDiffPatch_Transform(t *testing.T) { +func TestCompiledPatch_Transform(t *testing.T) { obj := &unstructured.Unstructured{Object: map[string]interface{}{ "apiVersion": "apps/v1", "kind": "Deployment", @@ -357,10 +364,10 @@ func TestCompiledDiffPatch_Transform(t *testing.T) { }} t.Run("happy path removes field without mutating input", func(t *testing.T) { - c, err := spec.CompileDiffPatch(spec.DiffPatch{Patch: "del(.spec.replicas)"}) + c, err := spec.CompilePatch(spec.Patch{Patch: "del(.spec.replicas)"}) require.NoError(t, err) - out, err := c.Transform(obj) + out, err := c.Transform(context.Background(), obj) require.NoError(t, err) outSpec := out.Object["spec"].(map[string]interface{}) @@ -388,10 +395,10 @@ func TestCompiledDiffPatch_Transform(t *testing.T) { for _, tt := range numeric { t.Run("numeric "+tt.name, func(t *testing.T) { - c, err := spec.CompileDiffPatch(spec.DiffPatch{Patch: tt.program}) + c, err := spec.CompilePatch(spec.Patch{Patch: tt.program}) require.NoError(t, err) - out, err := c.Transform(obj) + out, err := c.Transform(context.Background(), obj) require.NoError(t, err) require.Equal(t, tt.want, out.Object["spec"].(map[string]interface{})["replicas"]) }) @@ -410,10 +417,10 @@ func TestCompiledDiffPatch_Transform(t *testing.T) { for _, tt := range failures { t.Run("rejects "+tt.name, func(t *testing.T) { - c, err := spec.CompileDiffPatch(spec.DiffPatch{Patch: tt.program}) + c, err := spec.CompilePatch(spec.Patch{Patch: tt.program}) require.NoError(t, err) - _, err = c.Transform(obj) + _, err = c.Transform(context.Background(), obj) require.Error(t, err) }) } @@ -424,33 +431,41 @@ func TestLoadPatchesFiles(t *testing.T) { a := filepath.Join(dir, "a.yaml") b := filepath.Join(dir, "b.yaml") - require.NoError(t, os.WriteFile(a, []byte("diffPatches:\n- patch: del(.a)\n"), 0o644)) + require.NoError(t, os.WriteFile(a, []byte("diffPatches:\n- patch: del(.a)\nrenderPatches:\n- patch: del(.renderedA)\n"), 0o644)) require.NoError(t, os.WriteFile(b, []byte("diffPatches:\n- patch: del(.b)\n"), 0o644)) patches, err := spec.LoadPatchesFiles([]string{a, b}) require.NoError(t, err) - require.Len(t, patches, 2) - require.Equal(t, "del(.a)", patches[0].Patch) - require.Equal(t, "del(.b)", patches[1].Patch) + require.Len(t, patches.Diff, 2) + require.Equal(t, "del(.a)", patches.Diff[0].Patch) + require.Equal(t, "del(.b)", patches.Diff[1].Patch) + require.Len(t, patches.Render, 1) + require.Equal(t, "del(.renderedA)", patches.Render[0].Patch) _, err = spec.LoadPatchesFiles([]string{filepath.Join(dir, "missing.yaml")}) require.Error(t, err) } func TestParsePatchesFile(t *testing.T) { - t.Run("parses diffPatches", func(t *testing.T) { + t.Run("parses both patch kinds", func(t *testing.T) { data := []byte(` diffPatches: - match: kinds: [Deployment] patch: del(.spec.replicas) - patch: del(.data.foo) +renderPatches: +- match: + kinds: [StatefulSet] + patch: del(.spec.template.spec.nodeSelector) `) patches, err := spec.ParsePatchesFile(data) require.NoError(t, err) - require.Len(t, patches, 2) - require.Equal(t, []string{"Deployment"}, patches[0].Match.Kinds) - require.Equal(t, "del(.spec.replicas)", patches[0].Patch) + require.Len(t, patches.Diff, 2) + require.Equal(t, []string{"Deployment"}, patches.Diff[0].Match.Kinds) + require.Equal(t, "del(.spec.replicas)", patches.Diff[0].Patch) + require.Len(t, patches.Render, 1) + require.Equal(t, []string{"StatefulSet"}, patches.Render[0].Match.Kinds) }) t.Run("rejects unknown top-level key", func(t *testing.T) { @@ -461,7 +476,8 @@ diffPatches: t.Run("empty file yields no patches", func(t *testing.T) { patches, err := spec.ParsePatchesFile([]byte("{}\n")) require.NoError(t, err) - require.Empty(t, patches) + require.Empty(t, patches.Diff) + require.Empty(t, patches.Render) }) } diff --git a/pkg/resource/spec/resource_spec.go b/pkg/resource/spec/resource_spec.go index 99100664..a074d2ca 100644 --- a/pkg/resource/spec/resource_spec.go +++ b/pkg/resource/spec/resource_spec.go @@ -6,6 +6,7 @@ import ( "sort" "strings" + "github.com/samber/lo" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/client-go/kubernetes/scheme" @@ -61,14 +62,8 @@ func NewResourceSpecFromManifest(ctx context.Context, manifest, releaseNamespace if opts.DropInvalidAnnotationsAndLabels { unstruct.SetAnnotations(stripInvalidEntries(ctx, opts.FilePath, unstruct.Object, "metadata", "annotations")) unstruct.SetLabels(stripInvalidEntries(ctx, opts.FilePath, unstruct.Object, "metadata", "labels")) - } else { - if _, _, err := unstructured.NestedNullCoercingStringMap(unstruct.Object, "metadata", "annotations"); err != nil { - return nil, fmt.Errorf("decode resource (file: %q): %w", opts.FilePath, err) - } - - if _, _, err := unstructured.NestedNullCoercingStringMap(unstruct.Object, "metadata", "labels"); err != nil { - return nil, fmt.Errorf("decode resource (file: %q): %w", opts.FilePath, err) - } + } else if err := validateMetadataStringMaps(unstruct); err != nil { + return nil, fmt.Errorf("decode resource (file: %q): %w", opts.FilePath, err) } return NewResourceSpec(unstruct, releaseNamespace, opts), nil @@ -144,6 +139,57 @@ func BuildPatchedResourceSpecs(ctx context.Context, releaseNamespace string, tra return releasableResources, nil } +// Patch ResourceSpecs with render patches, i.e. right after the chart is rendered. Unlike diff +// patches, the result is what gets released and applied to the cluster, so a patch must not change +// the resource identity. StoreAs is re-derived, because a patch can add or remove the Helm hook +// annotation, except for StoreAsNone, which is not releasable at all and must survive patching. +func BuildRenderPatchedResourceSpecs(ctx context.Context, releaseNamespace string, resources []*ResourceSpec, patches []*CompiledPatch) ([]*ResourceSpec, error) { + if len(patches) == 0 { + return resources, nil + } + + patchedResources := make([]*ResourceSpec, 0, len(resources)) + + for _, res := range resources { + patchedUnstruct := res.Unstruct + patchedMeta := res.ResourceMeta + + for i, patch := range patches { + // There is no live object at render time to take the true namespace from, and + // namespaced resources without an explicit namespace end up in the release namespace, + // so cluster-scoped resources are indistinguishable from them here. + namespace := lo.Ternary(patchedUnstruct.GetNamespace() == "", releaseNamespace, patchedUnstruct.GetNamespace()) + + if !patch.Match(patchedMeta, namespace) { + continue + } + + out, err := patch.transform(ctx, patchedUnstruct) + if err != nil { + return nil, fmt.Errorf("apply render patches to resource %q: patch #%d: %w", res.IDHuman(), i+1, err) + } + + if err := validateMetadataStringMaps(out); err != nil { + return nil, fmt.Errorf("apply render patches to resource %q: patch #%d: %w", res.IDHuman(), i+1, err) + } + + patchedUnstruct = out + patchedMeta = NewResourceMetaFromUnstructured(patchedUnstruct, releaseNamespace, res.FilePath) + } + + if err := validateSameResourceIdentity(res.Unstruct, patchedUnstruct, releaseNamespace); err != nil { + return nil, fmt.Errorf("apply render patches to resource %q: %w", res.IDHuman(), err) + } + + patchedResources = append(patchedResources, NewResourceSpec(patchedUnstruct, releaseNamespace, ResourceSpecOptions{ + FilePath: res.FilePath, + StoreAs: lo.Ternary(res.StoreAs == common.StoreAsNone, common.StoreAsNone, common.StoreAs("")), + })) + } + + return patchedResources, nil +} + // Transforms ResourceSpecs, which means specs can be added, deleted, expanded (like Lists). If you // just need to modify specs, use patchers in BuildReleasableResourceSpecs instead. func BuildTransformedResourceSpecs(ctx context.Context, releaseNamespace string, resources []*ResourceSpec, transformers []ResourceTransformer) ([]*ResourceSpec, error) { @@ -182,3 +228,36 @@ func BuildTransformedResourceSpecs(ctx context.Context, releaseNamespace string, return transformedResources, nil } + +// Annotations and labels are read via apimachinery accessors, which silently discard non-string +// values, so anything but a string map must be rejected before the resource is used. +func validateMetadataStringMaps(unstruct *unstructured.Unstructured) error { + for _, field := range []string{"annotations", "labels"} { + if _, _, err := unstructured.NestedNullCoercingStringMap(unstruct.Object, "metadata", field); err != nil { + return fmt.Errorf("validate resource metadata: %w", err) + } + } + + return nil +} + +func validateSameResourceIdentity(original, patched *unstructured.Unstructured, releaseNamespace string) error { + if patched.GetAPIVersion() == "" || patched.GetKind() == "" || patched.GetName() == "" { + return fmt.Errorf("patch output is not a resource: apiVersion, kind or name is missing") + } + + originalNamespace := lo.Ternary(original.GetNamespace() == "", releaseNamespace, original.GetNamespace()) + patchedNamespace := lo.Ternary(patched.GetNamespace() == "", releaseNamespace, patched.GetNamespace()) + + if original.GetAPIVersion() == patched.GetAPIVersion() && + original.GetKind() == patched.GetKind() && + original.GetName() == patched.GetName() && + originalNamespace == patchedNamespace { + return nil + } + + return fmt.Errorf( + "patch changed resource identity to apiVersion %q, kind %q, name %q, namespace %q, which is not allowed", + patched.GetAPIVersion(), patched.GetKind(), patched.GetName(), patchedNamespace, + ) +} From 16e45e2e628b2903e1060ed2086c559ee705d9ff Mon Sep 17 00:00:00 2001 From: Aleksei Igrychev Date: Mon, 31 Aug 2026 19:06:51 +0200 Subject: [PATCH 85/85] feat: support authenticated secret values (#706) Update common-go so nelm v2 reads and writes the versioned AES-GCM secret formats. Signed-off-by: Aleksei Igrychev --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 02a75c1d..1cbab0ca 100644 --- a/go.mod +++ b/go.mod @@ -57,7 +57,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tidwall/sjson v1.2.5 github.com/wI2L/jsondiff v0.7.0 - github.com/werf/common-go v0.0.0-20260428201303-b0dcadceca5c + github.com/werf/common-go v0.0.0-20260831162604-46c3b45163be github.com/werf/kubedog v0.13.1-0.20260807153813-e8f61a4bc90a github.com/werf/lockgate v0.1.1 github.com/werf/logboek v0.6.1 diff --git a/go.sum b/go.sum index 0d754c43..1ba2f532 100644 --- a/go.sum +++ b/go.sum @@ -408,6 +408,8 @@ github.com/werf/3p-cobra v0.0.0-20260403075225-552c82797324 h1:aqEM5aboMpBfsILja github.com/werf/3p-cobra v0.0.0-20260403075225-552c82797324/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/werf/common-go v0.0.0-20260428201303-b0dcadceca5c h1:4/qKB2licflIlCZGX4U1o4Ij/oraYyuNc2cWFiutZ10= github.com/werf/common-go v0.0.0-20260428201303-b0dcadceca5c/go.mod h1:DlN/hD9tXLxYgdAMkulQdzHkiaPrvs3MzgPZvjluea4= +github.com/werf/common-go v0.0.0-20260831162604-46c3b45163be h1:r5zI64fcwZJfELi/uGgLV2YWOWx1QY9YW1yVkRba2do= +github.com/werf/common-go v0.0.0-20260831162604-46c3b45163be/go.mod h1:DlN/hD9tXLxYgdAMkulQdzHkiaPrvs3MzgPZvjluea4= github.com/werf/kubedog v0.13.1-0.20260807153813-e8f61a4bc90a h1:O3LWkGBPLb9P3jUdvRIpASe+m65iMtsKSI1joJIR3B4= github.com/werf/kubedog v0.13.1-0.20260807153813-e8f61a4bc90a/go.mod h1:5s5JQyn7rI+63ppk5MGPBYv6uRv0HARLD3kLN8FEFsw= github.com/werf/lockgate v0.1.1 h1:S400JFYjtWfE4i4LY9FA8zx0fMdfui9DPrBiTciCrx4=

    g?b@u~;!RLyg**q!b-NS&u67g1Cg0Ipq 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%0D#^9Pu#`P}$zWP8l)El+HGQ2oZHY$cn1}~(>)HAI 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⋘4-n*-H&z-IvPU7Q*WDYw}3Rui_Wu5P4enS2 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�VNTBP+_$APvyKe2TpTBo1InW?!fx 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 literal 0 HcmV?d00001 diff --git a/pkg/resource/schemas/data/index.json b/pkg/resource/schemas/data/index.json new file mode 100644 index 00000000..9b5e3d69 --- /dev/null +++ b/pkg/resource/schemas/data/index.json @@ -0,0 +1,32 @@ +{ + "crds": { + "filesCount": 3896, + "sha256": "5ab7179ba8db22fb425042a826c89bc6d8fe9d3653c020f4c0df2bcec7b0d793", + "uncompressedSize": 40242255, + "upstreamCommit": "dcaa31aa03082906c0325a7a0ee7d5191e9cbe24", + "upstreamRef": "main", + "upstreamRepo": "datreeio/CRDs-catalog" + }, + "kubernetes": { + "filesCount": 833, + "kubeVersions": [ + "1.36.3", + "1.35.7", + "1.34.10", + "1.33.13", + "1.32.13" + ], + "sha256": "f2c925c0d95a9bb39556bff532a70f02fd77368e8109ef233bd9b41e2277ffb7", + "uncompressedSize": 3650901, + "upstreamCommit": "c8f4e61c63bc529749125ac566bccc6986e08d45", + "upstreamRef": "master", + "upstreamRepo": "yannh/kubernetes-json-schema", + "upstreamTrees": { + "1.32.13": "c3bfb7b062ba60cf198c047ceb53947cf58aa592", + "1.33.13": "5c1d22516657fff5f9ce1dff18f835353250e96f", + "1.34.10": "2a44dd615d8e6fb3fc66f94d36f2688cf74df3ee", + "1.35.7": "d7a203e139011586ee918b5b599eb7a0c0faa6c1", + "1.36.3": "1c539645d55cd9984e4bd3d0130aebe8c6c6d3fd" + } + } +} diff --git a/pkg/resource/schemas/data/kubernetes.tar.gz b/pkg/resource/schemas/data/kubernetes.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..33f4a573bf8c5cbbee209c057c08043d362bddff GIT binary patch literal 179246 zcmagG1ymf%*8WX`L$F{W5Hz@Z2r{??x8N2u!JS}(y9N*L9^74myZd0lCAiM?N6xwD zz4!mh`lz+`JZtD4R!vt|@846qAPYx<`}6Z0IP%hR+^SCm3k}7aksgaDHh_PPR<+@KW~nSltyZ>e3C;4K3Nrky_R$?;a%X=L!rOWr!f} znRcaCkq}o_!{qeaXzsH{pOL2cC3$g9fln_1iY3TxOUU8WUTuhLO}*{TDRV^$ab#KI z6|DeP`xP=(=8$#AHykn(VUo^pXb_8w_km8MQ5f>d*2ONU&NbJG>1P*Y$o;e87xU2Z z)94c|5j8qjrIW0cuJA|)^I->b**Wbmn`{!3BO)c@_WqkltUTZOEOyAh=B$V``4bZV z8arfj|5tKYIRi z`{A}UqugOYzUl;)W8wW60OG%+$moBQ)9Gv^^Cl*&NQo3JMgR};=Zh zqGEwlL1353NUVBJDd9jLn`SHaW+s?hY?T@fQ~h$;Uq0%@Ki&k>U70O`JaHX{$+X#4 zT&IfwH>M!Th?zhEhnPs`Gq7D8If%B-XtEIgRtlc?j}3Pk6_$;DGq&wbPyXJTzVOJ*kCFW@F0z#+h~(VGc%5t zUYv}BEQAB8WP5ET#$M#8Wmf@fa}Yt$HJHUSlwiR=0ILtwX%cK4s(M&APpS3n4$SU(OZ zDnA__{YZ8PItQ$OgNIV!h}=ue_aH$)W)!U{a7w=kJd6Vx8nmA*RB%9Szg?Ker7xr8 z_1EoqaxY#_cEg`%Dyib;j0B>s@RR%9W(nh^cu3MFT-Lc4C5lO5`K#%m7|YPjFa=H7 z_egL`lH@AfOr!-T$o4!7Ouue6Zpk0l2^__$JA$d zvXqAOI84-j`Qk3x9}u=G*IXWx=3s|iSGLALdc=6t(3!z5?j>;40u4?i*0x!$qnr>H zhJS{Ez=i#n03*!<9w^W+`v70hE$bzah3^X^dwKzoNBOxD$id%I@XO?+U*z1}+YbW9A#?>8RdBwg@J+b4B0! z5wzc=W-HotJ56r{qbffp*!1nqPtsAv<9p%pP*PTtLA);qfw++~BNHEAKrw1uari1T zx=(5VFrWsaZFQKy^Gmea% zamEpZlf?>w4GVgUrTFy0u*`qmSM;0jS^&o_kFFtpe1uNKkvb|5j+jQ}ZTgK+;A?}in~KOf5ZI5epS}0}vVC5htn=`ru1yh( zQ)iu5oW7w8K<0cVe+{|aw#Ws>iRQrWKd&JgoZ}M*@eTXx+{Fp@R-yvcbEHe=&pxtg zF_f#NHxA{`>Pr{b<%Ef$7dkC2C&-BTg;C==ITt=~#mrftS?4v-0@NYY&g*O+5qH!| z-friEm^;aVahm=(2WbCAjd47{ssc;0nHdBE4I3cR`th~;bSOD=KVEap$P$a$zMnV#KhC!Hm< zSNKS4^@%EyF>ZrPRr*D$Da(CZdFO!Ss~DpsQlb!@WM8PBdH-UZwH z7z>1Q2M_&d$H$TVZAM1tQAAc(e}_RYinPA>Vg(|)<9;~9>-CX4U_ObR7^?{<9=#+3 zq#_BG{!!t$w>4OD0Pf4$YCd_HrqsC#k8uK8@j?_aAEy!EnZ@U!3Vq1A@j+L=c=j4c z)x%IW)gumbsDAPb;6!6`N5W(N!jQnDWM(IkF4{g#1es&3n7M7evv3V|6Q=Y zu{ugVoB>u&eQb{3)E7LMD3Y^v|6# zdEMEY!GX_yFV}cObFqFIK;4dyf)RXg(rTAbRKqhHg(78(Zvx?y_kg`JaUkyOj_4Y^ z=z1UAy3?-y_SyF&)-+fsA(QqMyo~}7$9@e+-1ld;q=BAkAQvH8 zS|a@&w-N&jc@^S0&u_7QAW|`E=nmNJfBLqoPA@?<19+GrN@_`L)?s4=!Ykt=oQNj0 z8g=bePJFc#9tevv;s8YK8A}3|7p@olJxGvY87P!8z66f>q5QpCN<6@T9z1TykEEtX zQLcmGCXP2 zVng{iL!s$sG5ktPc|?V5{;T$V!ZuhC4M~U1&(^Ow%s*wNjn~{ybem%&4D~cxDjT!C zcGf7$P$}V5x;+#OS^AbDq&)TsF>@Y6&;J}csDDegjVrhN0cwNBh-86Mv5% zIF-X7N`0&mYOT(<62(Rnp+m^V4tNq^hez7#->XkpMA}<&y>UROcObOT2aXtqlF@Dr zzPbQiV*w(f9yOPsMINZ}BjFSX_CgUrwRjH`E~^ptCN_3FVEzZ3o{9n`W`E`s-Lce3zrBKkk|)$s&RFuq^xo_ zqQ#zb=}5{lCrTF zm^Xv%E-vzIf}ig6SKgr;Nkz_WE-;7~jgsTj-w1+|{hd!ogFHF`TK;t)83_F=R|Y>W z3IT*T6xYbliOwK34$zK^3)BPPVGMwf8+dy~j77m(eBmPZ!5l*TUH6bbvxLXfM0Pv{ zx&}n=rS2gNP}OHbY}6hu!1t|=D4IW+_73<&5L!qnc9NIH{0VD08xTT!##0FVc>=}4 zVqrJ_Fxr+Z@FhSFrGjE90_)VXU{me-BHLe(0^n2<7@xj)N->`LF;P&%YRC^s5(5OC zpM6mPcAwss25xJN?@x=-g&prf{QMFC+0&&v%7D@G=RAEP#nWU-um{o{+4dRjVBYNL zC%%G=DJWC{yY15*<3#GZR@wd1hD=)1MTBP_2U=H@9f2tYw_c!p+vWs%EK{$q6%H@N zTvSq-c(Z|LsbHM};9@KHB{Y*HGYcTw2dJljcI^7PN5ZhNA~8Ja_vZ1VstsdCXRNDk ze!j4Ag+B9Nw;fwg(`5DOWkm&`-O2+3J%a8ye|2z`qk1%b-(qQsZy3z)WvJ4mCMfOw zl%QGmGF<&r)dwp?i2*ng`NE}O2Lx6TYR7uZ z3HvqLXZg=Ri+|?`QT!O6E2t;4QyFL*R)&i1kuN>5$W!KJVy)k-CLY*~l*mZ^!(G*x z>}aoL{>S(UPVot%(KoXL1$24Z@7uE2k=A^^SRC&^QX>@N4>2r&)dUr`oW{S z`^B@`vE_Y=DYpmF_aSYYe#R`bRh`v+bDFcdKX@pwE6^@j&H%IrP+|9pun#K0FUWca z(8Qt6fmb~qfP|X$FIra2a*WC6LU;U(`yjJpsG$$iJqVN;{|JuD9YpN|+TT1S8E0i> zX5z1LK8xq!#7h2$Frb)9UY15-qwHLO@3eS{zAXCM@?Bh=^|^0-gCZrnf|fQ-Y~Iu}pWW~#-6TSal9 zViFu-=1BacKz=9d~o!0RW7@CsgqxUS|Cmxn&bexIYZq9 zdnf|2XT4&xXpdr?pKSL)@n`|-q$FYV_Cf1#1&^oGaTxa?vfM|=!|nK;Zl{0?+)7YJ zfxWqVgH02%Yw>b9=Gj@O1HtL}y^^!o)&vno{5OQy&rlzG_)$AI@zy+MaYp8{!)4)T zW@l?&ugL3v_a|cve?sR6@!o|H5mnc?g*R5Tpc_8{Eu2F zlZ-{-tSoh{hJYfyBYo-(7W}ItU^;i&0VtdvWM2Y2YysQx7Z1!$-z_4uxr63jX&e77 zG!wY{1Sj05I62E;v+}kdh)!8_{cQSKdv1w{d#WL<)zNgXcX}Qi1WgDxmU)=7#&XisY~Fq(Sunet~o$xc@F>wKtYf+==={f?4?u-+nl!0Ynp$AS!A}q+0Mt2k*NHS z_tm_qYTAp*Sh8iUB`+DvPt+9gKk7$7AsrjGrT|%FKkcu_@i^5HK>UGhYm|MBWNQ@1 zUbGh#hgLid`)?7!+wXRfgk#&T{;G`k5#6_f_l0L0YA-sN>o)Ucw%T+|6KTAx_e)mI z7zLj!3fS)U?PKc-0PJnQIO`w`1@>DUsFRBDnHh!rzTQ6=3HY;sr@|IA(^YGjQcxt>?5K(}29xMm~^*^4@=K=jmD6?QS zKp84^MfQ&XYaD=cBC@V4<^kg``?JV~C4iF<%N8KD6~}%6*uDU4lYnp-64$$lbzy&a z49q{m%^yi>O~lL<3LHZ*M*YvxYCc)55g$BMfS_woO_yr}Ub2`)7~>5_*;! z@40}9)qpuNZLkG2`zrWvaXfQByee<;n0+>%c{U$S=r|2SM~H5x)pk~N_IYs)nLLw( zX4XvqNzYbc;a3YS&v8C|P1n%Z?=R~)C)X+z5qS}!{pz+o0ZI9Ny4#ZNf_5t|5Y1Is z{`82aHBUVwTL8ePcVJp-i1hH)lF-YOkNaSyM*}fnt0s;r5B$`CuKmo1XXc&pJz6;? z*Lu)dIiTPCFH)!71D0LyktrIVP2Pxn;Nbm@TpWT{enjN4gM%?_E_rPv0u1V3DLxY1 zWyV$-6h3{VV;S(s0R;*+8=BIJ;RAv`F(ckur01&LlUzpR5wEB^k^fY5z9!Efu63fY zen-qfYS(a<^6JLN9_v3t}(MA>1HDXm}lF_<)%igJ{whO60LYLh%j|Q+v*a?eWa-XviF!9!VRW( zOCLR|M%@sDYlzaNJ}i*u+5Q4RLS#vMUmd;N#*EpS5D9x*&Wp z5361e|I&zz7f8*dnRHOoxTf4`H)KTU8=MhaKo>TW*sB+R3*x1w3{<@U)`)vhrKtmQ zJsAK>Nwew&$c!FO+YbRjQ+S|r@6js@kiN|;a)OX_CIgPmIDli&733xZHebS{wx0oI z3vob2imMRIji4x=p9aWd!B0fv&ciYcYz^{Ff%*W7l8_?GoUjlE%74U(j-4KN>jGUF zOF9z#fxfk{-dVs6didBes83HCR_OX_Apl5Ad;pQ8XBa_~p#9+P&u=5*mZ9dZYy&FA z7QKEW=Q+|hrTE)Z?pss_TI?xlum{!$OETbVtuK&a2qd)?f^om8s?W~vvcz9SZOOE) zRq3syV`7hS)HcO2W5TYIkph=1#w`P`1W-F)gkC9Z~7}o7OE#VH+l2W z{hqQXRtf2o`5B~Ns(-8cF*7ZK9WXwHf<6hk-9i0hd%QB=h74A+KCds^kWR2OTnWnZ zRz8cl@q5r-q47n61?=i1FFJNJ-_Ge6#j*GOfPRy-fJNd@>th@@TVQ=B6)sgk{^@~x zsFQg)UvilqShXL4EJ&hmCf!jXP_6qe*-MT^PbG!iIjw6~>qVeCrU5iG5LQc95l+zl z)%ZMi)kV~gO1KU4Wvvw7))khLCrxTYY9Yl>113F;&O{>ra>I4L!r2UaNOOt}FZ?sk zdlSEr+|eK(F3NOM&+*c)tE|#f#uwaA$Nf-U zO`Hi|Dv!MU0THR$MocR47MaWi!`TmupdQC3G4P~Led2JZyBJu(rCT3t;(5tNOkgBu z7W-kjs7C4XV2L$}R5;7gC}mUq9N*@?_M6lp?+WTkE@YJ$5$?Tbn9MpAc;6t{D^suTozSku*SqC5aZGNVHEI zpruUaR;2w#n0cOqIB_y*$bI>JSE$XAmb#PGJTDTgE1yHIc$FcX^`)+Tum?tn6_g6O zQ-01sNz>SzDPN$*`H{w+9_|_p*(OLNN<7SH&grl?O-ir7gB+F2_4pG#@5b z*Td}AXD5~?N)aF@}>D25*$WGfYfO_et#7$6c4_lqG1X~F!><(-LmlT z;n0S{qz@lese@AxG{6#LiPnR?a+gdkFw2g+>20=l!MuAQcqJ?(i`Z^tgHw%u=ke`o znk|*V{T$bP^3Y2ET!Q8*PhM;Y4a0@C>i-FVc&1 zcpFU|-B=v9XZo?Kl^&O2gd6U$QpkgMSHy_La$KZ3x2jvOA={fP%K;SEfkqnBBvG93 zT8VCQU%vT+N!5qu0G&}cYg59DKzZag4>WOAxr3}qK6u@jy1|y!=vlS7`K{P+qO?wtWfwb<0K38&KT za^=lvh3Bh7mdM$|SpU6!P*f*G9;n!w=75rAk{CgQy$XRw?V;b~HF=PY26IG=V{#_O zwFnp$l{4=ZxWAD2${;E_x)OF89t&8DUh2!L|Xtx=ApfyXf_$&o&jy;pC_h2UQgBgU+OevQzbT*k-SKuYx0|4; zy;@b8X)Dh>Ub)HQiW!*-gsQ?-kS1+RmEM}6kgK%B(ywHad6zMZlq2}Eq;@{L(Ar{( zoiuyhO1ZDH-W!B22x<9L0poW;z|G~WGYFwSaGmA& z8UK}9d+m@$g`8tLO!bY;@;SJg2(XVhGkAh-ufwaN7f0H#UJv? z|0!@La3bEkXKNjAW2*jK0cjl!jHpJit}gl z<`os%S7FN`nx(b%%*=8ZZf{RCvbkm%vGpa~oX6K^U1t3evFFpgB7ebEkz{3)tebx9 zEJaLtJ^YazOOo_aqIfq!QPEYf@wKfEON=((KyNu;5pu8=DSol&H1l^l6*VQE{9AbW z54C2~`a3t5JZ*(nw!cMUkC^jKIs5Pj4|hLmHScKCq=vg7^x{HPK`)oQC%SVSy8OKl zgv~gewMHX2HYZPiHJ1qzxW`UpQB9J(67S$HOe>_~mN6}`z8Ju3U8@LEe3jx)e4|}) ztTd4)+uq7Mx2n+RC0J5eZ{plNqu*SaTPK0VOWbJ58-72*={cp3&eoG`-<0c<+#Uw1 z4v`uahAVZY+K*~Byp{j=0*T@CAL30$hJ8B6krQfCCA{}&khu0O@*2}_ZSKJW2{3;t zi?EAN9C7;fDDM0vIL0~K0==0JT!>v$=ITTo(4U+Qib4%zkM_-@V2QNkGDhfFX#oKH|Wo}3W4cq79ix5n|; zk^u?pM+Tl@zVol#BL6d}&$d6et5yCf6RxzoxMB`9A3LWB#>MO-H(&qc4zt9Yr~9F-Yx$j)bptKrP#KB&=WzTxEKeRw>gMqAHJ?T^ z76I<`o(=DqiJ$$oYgY2ax1Ls8q?Pnru+glk+kn8{mgNDlsh3%b8EgFvN{<^8Zt~A? zdM`S2r)(eTTyr&opMMO~ZaS*e7_a}3-hgi9SK~U%+;H(J+H6D6)Ab}Ry4LbnYDEsg z&99@NmZUUlZSmENp;m$tnFcoYjoS(c63(|Em#x`T4qKO>jNeK%AA)>Z8nE4ZLuL7v zUXlIO?fN!~ZGYve=nF@WflMHa^ahcuwMmd+3g6>Z`DJHGx+g7^eys%yzGw{ez8HhU zi6o|)xgwklnQ+Br7E)7)Zq@Fu&;OQaPzkA1F7N>V`?7^iZd2qA&)=d=-7$5a+VbJ06oOXo83-K8>r-VsRiQ{QWjk3x{ zeF8*F$5CQMgJAcgl7cFq#m%BJ*i#vcqTBj7Fj)>&*sMYwu7v$rg!&7>ZXjGA)fzF@yq&8$)+09rW$HX zW^ViBoTq9R^_)f#`=@G`Q4%i47y;#fmB;KlC-c=2V58#Sd>xp1^JfjYJ5<(j!wy0#6EgJ|w%jcYSroO}$zJ7bQk){*)O|+gd=E(bfi~_4%&s3p zweslSI_Y@Kye6Gy=3>NTpYfK-HS8nKAuwCg`4_|Z%wCg5JckC8L?o`DLsgEx%(@cv z6>ySX+No(dEjO7YA9-bk2tUqOejV|^Km1x;r87b(#ZUNecEMMW_u}AX68r-%8S+u_ zQs(Od{G@wXn#Cu0IA1Olf9F+TRN-IcxL6W?KflqJg=YP1~t2%mP*UIH-#`u z<1UZ^IO2V{kkVZ0461;W@pyL5baOjxdU}ad&qr_eKxvSkBqwv&epr1Lu$@jbWLh7-laK#}DMI5H*>3KGP zkOkfPgMP_0jeschqbI{XLs3V-!}fg$vuS-0#QfGpZ*^BtgmT|^MEAeh{OQv3rML4Znk@0l>bFxz6<|r0gs7~l?NA#<4v~*u z`$}g$-Eh($Ty1`UFS8#@$=Hqf-jb^xD0>l8Y}!z$Ji&?3zu=_!tuA@v1v_JuM!#Ih zL(13jhP2X~8TdOU{M(==i2;`FQ@>ylRe4J9f5VA-+V@tmZseiv!f_G(MmY(Pa#Y5H>fS2XX<*y;zK3n~QQ4F{|C*wXVmlOo=7<3YDjQ!L{i>8ayyO^j zWKm`}pXeNpq;_I{uF(b0(Ll$h1CK`izD-6Wlfvou60fp(Ys!49&hJZ%Zvv4E6tK*&O4wI_^&3vS#2Nd?mxkuVnKi0Xf*by z`8Qo`-6PFwd~-Vt+xzLxktPea-fP+#mu1h_T+8=|sj~3AA(CFt@%7ZgV|nK_2y!Wh zWwaxVKho%M9bCK&$ogUZ;#!X+V2xU&LMN^+Nh~vVDWb_CnKAf(21|bn&YdO0A0$(( zf7;GaHY-UJ@qPCsVbs44{;#f`6FUB4)4=>c!+(vF)Oe!<;)3?SLR;4iZ=W6;|mzIN9}C>qb~SC5Pvc^ao@m{yVra}zHkzB51j6T z%^xS~rcy1bO=6ji20JN@XD|d)NvfRaOdWA+4rMo6NoKPud5Q9LCBLrRvS!|YP-QVp zawh+E?;*we6OiweshLJ@<6YDrUHbZBs-J~0U{R}swVTff;M*H@lNTrNA^+WpTo6JC znfwB3#<@ILFJ03NoR#-Xj5^mB(2c~*e`($49gE)2&L2MCuWcci1(UW+)E&41Jgx>2 z@>xQBoD6anO@$*WOI{SR+@PAyNq@m|X9?fa{p*N@P*CtfeL=u_{ZT1vl z?yxY&Sir|>?B5-C^@ZEicOdv2QcEg+^_0R>%Bgj0QpsPes*($T=)qCfXtW>zxFUG# zbcr`Q8IFpl?R!0LD3%Ns8DVo(0TW-ess=%B$Q*}qGF8DQBeeF-qWn=Ov zUGBl6B!bk|squN`IedQYmHAI*urAy~_giJPF}f6Yf|u`?ldlGyRWS_Hv|FoFSDRse zk{OvxuSTTq#;8=3cX^}vv0TH@BVP){-AFPEkc&=^aL#P!Xf+3Xv!zeuF0B}L^il_p zZF|tIhYcZPut}vMnqEY&a3{{kVkP)OCdoQh^eq9inry82CYNUp@@R|Ovii&ut@5XJ z1+l-nsDG+5G|+!6J3?c@H)9aeXz@n&`zd#LY@%jA8DX?5mz^{bC;4!*X5ryNr!p=7 zTzdgx>G^*7?%pwX1aUG7pL=QHjg=;(jEOZTFZ^3_?1U;2qh)uJV~Isk);;qYz9xR6 zEZ%6A6zgN(q*d-_JXP^d7J{Ci1v3-n@gYr`yBf=hi*q?K>N)yfbsqN`JM+DI)#Y{b zKYv9Z#iHD7?zopCTvCQ}TuZNN9#(c|k;e4Qa?t#Sze?+SmQYJ=_j!-_66^krkWyv| zsX~@_O4oBwY*ci81M_a3xo&JHvfL0^yvd0-#=7O5HL%91t^piuMNdVqn6TSJQt~9a zcWPW8tt@TcxlLewNFJRr<>nj;k?Qb&_2M#SB1@>7MdF`!F?L%{bNp|1&$~dF8i^* zje$fchF4vq&ABWCqs9H{!Q+!w8uS#zPCuk0&`!P{?iLX>V!lt_D#nt0s(n+b{w=uA zkI50Oefik%2G3p*vW$@sPV-r*i@NFlqc~Gns#n%g*ZO^djmNlamh1qfeP4e+(~bLj zyn4=n-WVp2G?w@*dYwm`X1vycnU)*XYu+}@oBX9lRa zk=$`PWQ5~ra*w@+QFOjBOOo6K((Ck{Vas(CzXMEhpB< zO3Aic7D*~ZUAj$c5rWee*UL5xdTphMJG@qH2RD4$o(q}<2-Rb66$nZ@>(Y(OL`fbHsl(Q$9 z_V(VCIj{U!lwuFp0&so&k`y9k4qUxXZ49cVR)2{gi1PL6iP zyQn51*TQy)_P_2J!O17{*@dH;%UvIV7wH|`v1R=LDNT1f2bw!11w=-3m8HzR{< zxnquPr*&Kl*PO>zm))8VNR$=TZu;uOpwIbrJ2))JRQauHaqY?U*r)Wl4$o}1K8;uk zr7*Q+ZD?0_Te&MK;bhaL8a%TX9LmavWhNeH1g>@5s=-8Zm+#XG&A1rhSl|t5CQFL2 zfPO7ptt8u?u030!wuXffH&->yNhu3dFT^n&IIOeFXa(9DGSV+aDeih)Z%m_pViI-WW(F`PG2WKAY^D z((-AOMtXZboK2HL-}%*a+;Iv0yoJY1k^%wDe1~Umw$9uA!0Gund!1Vq4SZ9a)ecJ#>$dqaOE?OkHU8Nfz< zJ|=Bv0_j}6dIsZRwiNHm#me zXI93P{q{KVLOQiboH^?F&}AC3&qV^1Whc4+}aXxFL8R{m$M4r3HyPx#rAe zi2W@ryS|mhn?%=+wZ;rLlAl^ik#NBixy?nIqxy0`=iiRPU)F*ScZ1B5iv(_+mc>xc z;6}8iHi}(E_nz3v`A)+BYbYRE&ax{xpQtT7Qu*>G$|3yeMvNExxzf-tV3vu&tR$?tr055&6v5gBeOC5entGoQAh-Lu- zMMe1yX`aJ!#fY_7>w;|=Ou_Td)|snvx?!QD?sePmml;AcV=pryWUIIyYm`qLV*c7P z1KJ+@7%r;!|FdJ}3p4pkhLek!Vt%h|MX^gI?ZzS+6Pb|)yH}VuUf%-fFdirkhrdUc zM+Aj^_03NF$hF2GV+2hm;o)*6Fk;G2H5Dym0Nx z&#ra7+#?fSahD(c__g>`4LvhWZZLawzo6v?ajHU1pfUI2+v_Ph#hVX?uy}(4f(|`VH2+hVQoB zWa?O)#R6SmSkxVwi$8n0xZNe~`%zX)fGqLWZnHl$Gy_d3Z5ew=&&-9Lo_a{;MF$*6$!TJu4#c}V zCG=I+cbpJVn(D@`U+_;~_#Zt_`OuB%$4^$X>9(^L@E6+qJEY2a>8!=*4M1vGI^?-R zZwD3ABr0mY4kp!VSmp+O`R!Z%d*3l;zMk>Vvg1s5B5!UkPkAr~M#-Dthi5OLd$Alz zprN7Zw1;oHJA#L&Q|}eI5uehHR4UIYbqTY8$Zw2=0UWngK0^`!p`!{OoCz)yJ73q}ScpMx($KaStOTQoiA zh!UDR~MX5WL{d2~jarig*{=@kH zt2NLn`s9lIWskURg=&b8i0Cc#CwwX(F#BTkC$wQ(RqR&oNW18{#SdtE<3@&YHU{xR zKO64oa=rTW5U15*Erjvi8hSOF?@KJ6E94u-Qu1>k^^h@KIri6E=00}DIOf+^Ur|Ms z=6R4FSTIFTzh%vr4=xR7k4szk>GF*v^!(3ICsIq~X#E80f7=y~za|KyRxrFa_R#zP zb?`r3d%EZTry=m4Cc!^;g|GY&+ZBc?r^P#<8s5bqxy!5tt>?50OCYDSd&D!6rz5A#PLb#Du36NZxwbD?=7-74u!>jg&pIX$Bt)jR)|5mpx>pq|n zl9l?(O8P@GSS6IlxMLu2D>?n=p)qqUsS-GkTb5<}0cd+FNl_sb7j9;qJmMgi)Km{r zO?n96uzXvG+?wJ+9#VYFH)1xaKhI*x+@+tnpc~aY>8;nEm1s~~gLL%wp3xRzID1yU za4vCetQ>a}G<_Tz2BWgrg1ZxXZ1Vh#kP=2+u5j?{zEtfWk#>go*E9R?HrpPsk1LMw z!H1fn7IkF{+hhVG{j`d%SiddywXf-0!nBl(A!J)Dx__1suB@p1a?ftqziQt3sw~~t zY$k$Oe7ual(J`3E%L$^RrdyBPZ#Nesu_-X?FOJ&ifkmjs6|GJt2fXvSY@$4ji`6DG$jrmpINu9hN zJ{$-h>O9?xHn6Fv1J^80AP?!bz%|hXTAfo@5Lm1g+-Oe^oUeb|q)%AJWBllXn1sfO zpINHc0pInh;L3%gq|u_U(y?Gdc26c3>S0?e$d6aNPxFlV=#*P1{n!)Qq`c#2(I58O z`UsP!%=Tj(8yTX>whozT|T$dR4$WTnOB1Ls9TBVK=!)HPv0f-e9oEyxR zMkvkE%DvQK7JE_bKR&K&fpzx{w^2f9+Y5!HB_5rzVr2117+y2hbMul2>>*Q+{Kw8V zBzI{GB{Cz-7XcCrGD0fGCEx2uiQ&B7|Gkxs9k;A*HdPuCJ)MH+=Bnsj$rtBtBs88M zazWGbV@lxLPg;gjc#>AXMJ3o2@DnZtu)&jrHtW)R)G`V7%9O zD~Wj1dgVCtLKpeX$k_~XZcMWoz1D5U;rNlz23cDdLw)LeRqa4-U zZ3wx9ME}$19YF~e}ZtpMESaj3e zB=5Ss%J;b~kx1EeBR0@jW0l4f9_!6+Uf`n)l2_vp1Yj ziq<^w=)U12Vn9mNtdlW~%wze-j)jnokp4xs6mmi%1C2^=iPw4+=Vsi-(0c-1`?>He zXKTM_o61*=o1%ga%EbuRuLLGtUx<;rU6$s#Xxg$YJoi>k=uDM{c;I}v(izaT#zaB7 zYGnEnQSDXhHxwL7s zWUPb%9F7}rXvc+R5z6jDk=^QFt0_BCDSUIg!JAlWrOG|Y+L(~&C`7PlS8J8l`mE1v zdbJ99@wr@7!v!O-Q&`kc5cr(|f}5_qoXDpc*f2yWNVP;$ty*@QP6QT&PVW>43di3U zaPmGVlrx5p7OUaYWj>db3oR(HxEbN9j(xqaaxQhyR;AjIE}`+|Qu}rd*)0mtdbs_a z@-oeXl|QS)8cE!R=c;Ulf>al5<9h9wvmU!=>p|Y=*LePzAdz@ZFm70fN}!;*-)3xb zKolNTxTal0%l*d9xHaLI`*sk|d7ou$y1I~~J+F?8#|X@#fKK!ERdYmrL6^oL_4MqO zhaizc{Uk;vlYeqagCo5^BFdhh$BxOV@@~`GjrMD+hZGkcccU^d_*e}sfr^q}nYB~e z7br>ou`2o{G6o}fAvaIOI&cm4>^t0eU>66x8W2&6r@!^u5vBg=a&jbToWuaxm4I74+yqRmZR%Z77c*;f+4_VZg_^h_R zyss{*JkarM@#t6HBQ-)64kD!lDQETEM{cY)@22U`hdm*VD#A4r4O6`Re<6KV&$i9~ zj`ULR{~*1Z<4$)VJi+~>kJC3SwIBGE^O{x(s0uh(`Al5;9Rnm;R^H{E9V1^@xfwL( z!9^{)*cE+q#S>e92%OE z{GZ|TSZ~D9a+Ff%|Hb@7{~vpA9aIIoxNA#;G)gx}hcpP%4N7+--Cfct-Hmj2cO%^( zAuZh?-K?_~==;8V|MofGnfbndzL`BZ&KhQ|HNvRF{XF+|-Ok!!76--B*jHB2alI6@ zZ_J|HSRr>}@~hpokK zL#>_*Qd#hPr!DM$hwJ!lX{h9_l2G2cDN%JEcMS14oj04-q0nM z2h~%>exKFX?w#ugUo2+0jcjf?{BiO1{v)6G89tM`UYds!%aj(|oSGo|3tw@wE8(%~3cZJ@`a7ns( zc-l)JCWOX0r z-XrnSV_tBI{pw38PK{+c4((K$ZXV;XC@#`*SCBx29Nvkcf~|2_tvZs#qyE~Y@TIy+ zVLQwK1~+1~!^P1aA0C{3I+a8~{uN4^{Y1IC+*{LnN1&&rxh!Sctwp!VSaW34f<+TL_y%!1^M6HSbetB(^tX=9Y5yGU9K@K2wS8NEzn>GE+V@N7Y~ z7w1m=#{u%OPM_wg)RqY5-dII3NwJhTc1!Z|hi`mWr`j`Zp^GdDy zpx7!fGt-3T_Y1{pagDV2l0Ef)@qyMD>H|&1TJ1wQVF%s~jIRF;`tOGS8}$Dh^#2?5 z{~PrG8}$Dh^#2?5|9=$pn@FwHGDqwu{to(+Bx3L`e+B&q_t6;Er3`@31J0Fw}FqkH^mM=>bm_Sdi;QPU?RDBu zWP6<1)&MVamKhJqhhJ4GBcjtpT^I6>V{?p!!}uzN1}Vf5B6bIRqN zk$=4@e?M7~Lu=%AG3$fj#-L_cVp#JY=i&NNkLd30a*(b$pYMZ{2g_UqU{o3v;PUD+ zoeCTw>;ntaM86Q}tABZ*9T7EG#kc%R#Be%cn(8}Yfr174p4gEL2F|xrHvv_lsF(Lc zq2@Ra4TtHqgHWi9zH|3QpE9N7JCp78oB56>%?En$aZ&=uT#enpJ?l0A5h|mWztXd- zv$GNJx3Sg%mjnQQL=;xOsTZ!3FP!=Sq@QsaZinyytVuxY#mO$}DWDz)a1qq()$tzu z40f25A>#P~JNTBoUE7gmStt!YTE6#ms4)Ee>%gtDwyI+_vj+?1oR7!$O&sv9*?G{@ z^ky0g=W&sUgYF1;KVKKSz!Dx2078y#Gv4v#-Sgrk(BJuE)t_-y{-;t zIV^qmQ75MGfmi1|{o}(d;vL``?-1Z#50HiVQG`@+tT>HJ39;iiNAcLc=au5ePc9)8 zA=d%a;`E8GqXEey(Z}|ydryCi7T1@scE|HqV3pk>O>vW2rB~!|qsv!N6bMsZqpC#C zQK2r_qOVKO{qdw~rnrQ+DvseTrcCbysQB|UeU?}E^&fe~X71;!W{OjcC^6DSZAUv! zI|hn7Vj`kUoY50eq2c=WZ`yT5ZS@(J@}2j$5YMy6fXB74kxVgP5L3FyKk)BIa$?a1 zVRqFqF6Shfx0Pj9C9JpJF?f~i7&;r8Y!gIP>Bg*uiGH!WibL;akB_P_0bCHiG!F)F zr^h~c8W|Y_bnoBdBpK^xMFokF|I34U+@!dPg1EX(Yu$cB(rQ&9TN54xDG!>?fhON$ zKNXH>q}LbIL;%1+jt1cH0O8KFE%bWDqbsHf=Ap%h2-|&UsG^cyiW2>bdFw!ErB5SK z7Q^#9F&yQ4DxJ!vsrYw5OWJwPYrwmAoLpQ_b~Bh=Se!(ywd zQDIU|8KtX4J`BwBN6ovO>~yVZYiXz@J*wu#oeSJJ-thLVpM^aF^hyBL$LH-n5PPtv zsCn?Cge(DHM7;|oM-A=EZfI`kh6RM}IMaBFKVP%aMO#b~NTA5TZBpN}GT7#Lg=~p` zFp!F-{YfM}SIG8LaJ9BqM5)^yUh-u`A=wNFeH+muCoPc?^f)&3;7K7$0a8c0KWe`TZOr)Q`hJl9{nEyS zohObclon;6kxyJB&>VTkd+#JZxRog?1d$f)A<#sj=^}ytKtXFpb3L}PbKdX)uq_HS zHxU9hIZ!CA?d0Y#FJ%~h&fEBRLB5T_R!H;=64Bni^f=_yti0??rdHl0C>j5GOYWr+20fcLE+s!S!H()Z*dLiNxLLHz%ei!T?I|5D z#s1K~4g3>E04{`frh`)suPt)`@vO0?x5vmY7$yMM_c@_JF$5riJIC`)g7Vcm+OKyI zThL#bSjDdMqaG=MYRdHW7&xFXMDuaQ9??CJf{&^N=KL)`el$@eR|a5U{$EY5 zolo2OZT`4Q#z^K-6be`^*prhH^QvoSciFB=Fdyv0mxUz(j+!+vACWsuU56Z+LaxXyf9TF) z6)~`-o@9K<9?bf_tW2Of3O!6*2hvys4vpdqvXhDwvyl_dSy1RNE^azRX1HTw+I_ro zw27ZlIa_Xz{<%!R7kAMR5pY9Nm3QRKMqpv{k1%FX#5JAMKy!uJl~HhhS$AsH|Bbi8 zD94?P=%4r(cBU5PAO34ZF6|HhRXJl&#NNesbosJDeUZx>WUQz&xdt5-GXi)(oFl72 zix8U&gB+3DpcpUqV(@K;;Pl>$J+AM{6Waf&8s2?yYx|->{{hOM z)iFS3kXhZR6fNNy`vP^tRysG!Ux>GVdwE>~YKEN@25Ufx3Hwo#7Lc!q?-Z#ex#LYq?N4te^OPj;-W!fc5SbT{KB5Db6iC_ z`P8h-+Eu4)YT-*~rfl9HEmqzBh~22b>C+oc(ZsEqUzT$_`ETma|I-Qb>}z-8hO?+I zACeN`Y^xg^P~(x3M%wO3(w3?N3PTm!$s{#D*~b|d=%wP> zEh1s=x|~cVf0&r>9q>#=vazU$%hGlEFms2y#r6fl^)u)ek5_IH^R-WdE@;2CR`m z3nU~qcn2wqwSzu@?Xhsh4NG8itdIKlD>9z|#ithIe{PHYfy@4U7lYW-oQ1pbrgGgi zsD4+*bn|B5J1;*YnT6}m`ilmYY_d-jaQK=ze@}-t92NE@uH#LrUD*1bF*l>VqJh)E z&$9QbU?)p0v;}B@z(QA*n*4-U%`}%Ko1Q=f>0qH(alA%!P7n8aDY>K-?>2PG@IO|@ zfHg8)p%lhhXuHj4n+>BflKX1^60kYeX8rpWf`1n@|078DS8wcDWCE-f{>VZ2T^VDz zI7G~AV?6Rvod_3M@`jNL=2q0TH$1ErrhE@0J3^o7WXH+ynV?(wDTu z!?XReNRX5QbLkWN6SY2NamyMs#3ANzlF%5~*_JzV8-clkR4FNmn%ZdFn+4GRtph}x zOoQvao;Op<$+lq4B@k4zD_l)fk5=GP$*DX$tpk^)GoyNuJdTY-!=tzEsH1RqWBgXV zW+dR)(B3dS48kqf;YxQCVHwIW+84dlC6;T|_OVxMtETppamLJ-AsQUvbE+*oGXncy z7ouv_8v<>Bm11dfN(X+(oIA7;dWcVciUu4Gjnb6WB66-BDUC2biSudS%rOof4y$I+ z4laU5HpTD`XE)(ZZ-lwbI?9|oxOqI=;2}}2qN~LOb1sOAMDu>4>t><#;+P~Kr-6gX z;e;17bqQBr=j*leZhd*9#UaH-(ry#m#4A;U( zn#uK(YoD5WZ&`dUXAPr8rl(4iYy4RG$w1kWasGk}h1O8biXuBW#1MhY=w+*4 z8q0k9mB>Q1U{tat+IZKa*82Ck(b;rd1NuP6l7fleBb{%zh6XOEWSX(vp?+zDDypyA zzRC*=LQPu%aps~5jVxnm?$4|2;fA>A`FP^2Fl4~B0yAAy6K(d@scc)D`eL&su}E!7 zvKr(pGwUPyGm?22A!@b+Qah)!0hWyKdIY&1FAk`y3F(Fi<4wU-rwSP>tuZKgT6rB>flrHOWh!4 z%_}llXaO6~_l-tFJMpC58D18e#Yg)e zi?7&3x=dwlw;yH%y525Z7HF`D`@WRd_ukbn;a>C0$3A zrhoszuNeiA+?kklS}|+Xq8{`(ByArC#(ML6%`9vjk%e&!IW|5U`0=lgq7e+;D~=;s zt|or0dp%B!I8;9cP_5vroL9Izg0yU3S$z?1akjCyH5@4OG*jeD+*SF=ZW!IYVtNr0 zh86Do6)^_R7WL8rYF{~L;)2>riFu~nsuSI)6;^GQ9E*rRv7Ll|iWDpI3gz1-yEg!m{n2K;MLFcWQnF{Ux!_!+{Ix@$&%!jToAqpoZ-lQ@Y4+j}a$bO_m z={-YuJtmLK3#^P`L7ry$@PQv`iKz2thqHa|QhhkF<$vULJ)T*tJ3K_Z%08G_PH-e8 zJo|90!6nW;(#d6eo9@EZ=ruqFxA3L|Ji|OU=J*jQf`lsag>*FX5c@|96iBd=v)fYP zq{d(2F{I;YU}hd`1T#v$rooooL_tmwU>4o@*bXp=U+80<{4dq0;?8? zCG*P5Gyl7JmYiMbWXT>h|1o38hFbsuVS0Cb&|l+v^s?@y7XqPo7BwUiGPGy#B$yyPd<`X;t@Xv&EX6HOgGp1fJ;B$yYQ*x|JgHluJ(UKVmYVXUTl0$%SBH?XCD&BcKZkP|nPY}A1Fp{s-JrQuC;oma`?8Kt^g$d}#w z=7?&UH`EFMrd1x7``-RwnYX^R$TC0Y?T<9gs$)sn3nAW5#>kuZI0^E;KsPlr{H%rd zp?!F;sRk9p1#V=E2~Te6W!IY#86^A{NFH)C^Zh}&j0x;3u!0ILl{yMMV_2K;a$f|- zaG(a5=)PyB%E0RE*&Vo~MCC z$BUZ`#Qa3e<6TxUG~hf&)mVu&gDqz<(-RxpSLyBN_crT|WCdS)@I~J8YT!TIdPY}k z2V5sf<$pi>{{6cB`)8hNIeCi50lO!=aFc*n`2gDzER`LKf@Z!pReW^+>|QQ&>+|zOPG4pNWDCFTD?=W? zYx8a+QXbrsxxg_IH@3Cvp;58HPa*Ysa*_dhH@*@*J)GsUlhlmR%)03Y1e3UEjod&@k9t_I}@8sJA9lJ$s})v?!pvQgKALLJtwYiahksH0TsdsIzfdn zK|X#syb#Xi49+x=P-d4oU}2{mnGaL4`SC99cOT3-jPt1v#>U%k9aK+*^Uwh5gWY8= z5SRwJ1}c&xJ2(!J~s3@@^>k*CTj260g}QEq{H=PVzNGil!LE zkL7Z%ls#%eV9uWaC&^snK~75E-FL&T`ZC!Bg-9j&snYkGb;EJY8)1`0OfYp;H**U% zg<*JvT$ZdSVT=Ju%zVCQmzw%nPB<^3GGW=E-fgZS_t1QX-5&n}#E$w0Wo65}#x@Bx zNyfAj9k1*zk~|mwvWBhkA)?Oo^0F7wgDuR^>dGwK>outol8CaF%qR@`5qWRMN2kkg z-`bSe?@2KB&3}$ykGGI{&k196xHGs6qfwLu?1ZTx5jf11bIHBMsdp??_{FabZ#9sc zS!MJ@fEhj!V0upkSRaT0yV&r8XZ^rmj1{w76zqP!w>2nB;Fago%CaRtdkf6?DL^-+ z;bP;lqiewYczlY%S~T+?$BPlj@uDMwiO!mXF4dDw^8|l6{rZa4`BmoaYwPCML{e!j_oBdzFAL20{S2^{ z!tOsa_v~Nra^AiGU(^LEaB>93^UA zZY@*!%+|s@3QG!5!e`O$%76WAcj>(kUgodK1KC|Xe%oDU&*=~za6XUpDB>w9t3VMH zHf@w03DkWAGwqRXRuCy#{*93 zmO2Qz4-QU|RO`XryzT5yGRi`4C7|zxYX66><;U~5r;gZRbnfvWCa@z$Y<4;;5)+ty zKdgGrnUdfpsa{*_^2IMA);~hq2bk^sb#$QekQ`g^@f~*G8}CT(ax5Z8?oa?F3itdo zX0R5?4k~Laj@v6K!S`4E2UZhu%r&13=zVjPrUw2FX4= z0byKmz>Zj9P0&mzjoV3njzdVJNz-o}EPvolF$f3y(fx#jX_CCHmUza&P#2Lm=kB0D zIGE@&4%Q38!RDWFFbp6LhWk|@zO^=0My$!FJo#k)bKMqv3ex8*tiU=RP(>`%nqMO3 zs+lmQu%x^X0`n@CuB-s7Q*ls)#i&1>y6VIP2{3n}lcJtkUy}+R>q| z(mMGx6>I;lh&4OCY`ztnYaRIHLo~Sqc|wos?B78HsYU$jrMEI{i@;BG@NyL3rx5Km zNvt8fe}{V5maYzJTXuw!PdezopY}HiCj93$|KV+D&ZRRv!5?1jq&shR(2!tc&>x;j zpceXz(FvIn%k3v!UqVa$j`Zdmz(|kP$=bFn4Z*yf)rqx`K0I%AW-@NMUv5Q@xJZO^ zfvsRKODD3dp{*JkOiN+H7_ORRK2h+N2F0hT?%j`S4!BlizH;rD+U+N^i^{w1L1HDO zO54CV!4kYrn5k1AgjO81zws*V`4|*$86)8UYF)5KoW=jCb-Cqen%JEtRcft$S)9@U)Vid#Hse4@pwEY{lVFDHAz>u^ zo7QD+LqZb`Y`0CQddLfFsfek?4wUCFbC7;oZ4tnJGId2dbE%mP8=?j~xain2B@e&c zKZSC*&uV4WQGWlEK)b}~FRja5&|VbRuVBlp;0V@9mKAPj@EjIAS$F>s#~bN&A^GB3 zRHTy0w9ojWElz~l4{^S?E1%<0=4olzZbYTeP6pNw2nc8ehj8R1Rw@AMY^LvFq@ z70o>5dEA&Uig=(rZ)SP(4ebXzD9=j`q#6-tR)dR z=6qJTj~2&XW8#oG(_4k9IrI5tsp=Pu2v;M(k*&zln$2h=Y~L2$d>x38=|GRW!s)}a ziI1h@v6)rT5?!7p_Yn2Gzr1+L^R4;AwUUu$@}N9#_%C_B`5$?{7nJ9>=Z!&m-Vv1N zeRI-kp7XqDRI&#s&(ojmns29!g)w0Yu`5lP>Rnan3RYLO1V+5-cB{lkAIT50zD25m-~6@@q8P_T9p4ttDCi+gP-7 zdIg3SCT%pQfjTxV%mm&F7?Q=1l>Jn+@oL&Z4k8D=(r-AvpM{WS`x85mr#MF-ii{iBH9C9a27XzlRGWf?owpK?iz!n5_F!Nm^xO<6+PX;KDt5Nj zM+NYzz76-K&BEXYxFZWMe!sdr15#i$ ze^X$*a*kH8Knl!tgKHeDN-XsHI##z_Gys_A4c~G7An|@L-!&sX!wCT9`8&<7k^Cu> zb5c;A-#wogX$(mmQ+`$1+rLsA1j_TOKMeJimIz7-n7@$Nhr#1O3S73T(_e~F+S4&{ zIyK#xwOW^C$WB5Y@d9pIAapN(@o6?{6IH`Rc=lZBSOZ9qMJj{S>+;)t-`WtEY&A6q z=Maijh<%WN@wihu`Z*<0tKNMCCY}qPcg{E}E7;_H*NFL|Vh`DF#HI~OQTty4ea-u_ zngeuJszdd(PPg8zIM+-CB71%b9m|gD(CkBjj0?-@OV<{cxQ~8hQlLOD9$2m(OBGRH zi_CJYEvxEf(EKuLZZ)+qRKns-pKA0^5``feYWy~E-A|Tt*Q);v^!e`|M2~d?+Ygh4 zuO@e9j4@Vv%yYcpg3j) zZ(4V+n!);RDD*0%2h4Kn%>aUxN-BGUl^(k_oR2j+k#a`L2&DF~vNUFk&HJ;uy0WkF?$+ zEV;K1T7R_!I9!7(w#CC&&iMm4qqDvtvdm!(=e_|{;~+0qUK?byVt!U(_D_-B&3#^M zYvG|yz5#-m>uG4+Jt{XOin%V;u_I2N>>aV7b*q+Wq$KEC?pLgV9hE{dJffSg; zZwhRY`}l@p!^sT)yK16d{!CmChC&g}TMz}tCof;;YKSu@7G_U%Lpn0?gR4G8SlI+b zfw`*m0nFXp+;Skdm(Qejj)T4WNAr^6xVnA!-!6@M@V|bK@?e1ssf?nW)?UR&b^Hr~ zEky^_FK(Gj7TST#d}Uln2j)NAd&0-M1!in*Hq_II&z7nYwP)D|G3nrN{NPEL_7q&#gy1I>BZkQP5fJ8YyvOVC6Q zrP}+hMDSZ|-D5nmN&sF4GDjXeEHQrxHLK;!kQwy#=^f^oz_6rO{ zKN;!~Yl@v_1klYk9V;Ihey}xCG#noodkn2tvtSS;?Q++3@54K`*%uZe=MxRa@nwRM zuq@o;fp=ie`Z)0)+5UcIeSLRM_7OCwoZlyE<#|)SOV36W8yu*e_nnxh>e^;(V|!%) z&>iN{zfs>HJ85OLEcC@Q@%~hq9>Xfy^U-ONrNvlO1_Qo0{G@;u>H--@HGLn?bcs68 zU!1m_B1o7RoB;J#pqD7*dy=21U=gs+u8=KRG!5WupJVw&PXs0h3zIt}Gs}v|5qSf0 zpY1D#E`XqYsP&uj6>gtbh6!x}UOs3YP;ssNaonKm`|m~%HE@T#FEnWnKkq30-a1}q zU625|To>+%zF{o-7$Y>1E|Dx9eX(>cAiFHona;SxFgX8EZu#N!a?pq67V=vnA)L$@ z!|g+eg2jtxeJhRccn|RZOWc{ok6FndWU=lwW9V4?=2chEq(!f~>sZCoD4l>Yd(j2Q z)+oPW6h4nK`JGrCrKQXw(B{evGlKU<8YFjpdJ49oR5p~5K#w~C6YKZcJqCT`+#lZp zV#z9hbUp!D(OE1JNf1Q-$VqL;g8HRX~iF^cy&vA=w?syS4~?KTt2iGAHmwKA&`5&-uXh*-@pGf3&&>3D}mpKR~tW&$6y&a5JBUXY%?#l zNgP0#gbthdy>B8J=Sq7`Wp^sh=R+-PiY2XNlB+$^==?-n8DfJ1v#?(t2g3WkDs876 zEf&i|xx$~mWoaYPl7bFpPaM|B6NlCJ#9>7kI49x;!e|ue2FrCv)+)srXDhQSd;WR> zj$bqzE3LWjG)Q2vY)z49v)#6Eh;1sUZW$j0^o0JNgGeU^%|T#mDojNxaiB+(d4aU5 zE1hr_%u=*%TF7gi!9eX zA*O6^VCntFdcuC%6KY$+b6u7@AqreuTsXw60ulU*&DX%t0}+cEUnB@fhT4j~qg^ z*_JKKBNwI8pQkzqI1=;fhkaI?jtfisD`hW~*8EA?Z0-j%kPtKs4wk z&hq36iDr;jf-aXHoxWWfGf}?!qI&+#l_lyd8II2*5B|d5bI|#b2A&@%UPqVxEdGaZ z1v;x#8Z_}hsu4N4@o>~<2okPz^l%%5&`^t5PqP9Qs!p~bh3Z$3LUk>i^%IN?4ng9r zSQ2T0NW;+BgodOL$53j<5sumlvk9py(b0HPR~ZlRhrlYJUWkIlee{ce@`Zzb6Axn!M*v&0{o)F{>W2%f%4Q#pggtaZ+R;8f0U;Vy#&cqkuGVa z1dbub_hsQ>;K7V@7% z`g7p^`}pPKAShk4`?V3DAEFDaU^#V`7>i~b{`8KyP1|z=-IxnfgKo^#fH&seD&RI( z8&?RBUWDp0Y^S6svGB>TQA(yVw~M)=IV^;Z1m+KXud#eUH|A<{m7A-2&1Y|2uaDrF zf0!%lUef_KQ``()ju-31G~j#Us!%i7bs*cY?ZNfJ9j?TDW6%Q`kq$~k?A_l z99P3i{qRH*Vb(%J1s)YX?=Cpi@Pu31f4&G*v?>L9R?2d-Ta1p&dcEZQ+149Co)zDo z%}NY44mY3I7NUQ?xG6Xt67?l-#I3x zOfCE%ifi(<`cRLT`iwm{Gal>H$o$wqSLPGnfmh}n&0k=K#;c^~6o-A(`W}IT_D?+d zp+&V}u;INcy&JmdFcK#FW6A1|6_A%<;#mb>fmSV5u^n^0!I=F#oKB8fX9M2m+It_R zsPtaEVVGxgs#65l74ws4Wk~(mvx0$bYBxbE`0QC>1$kCXd5lg;L7tV?UPjTcWCkJ9 zPv+FC+8~fQ)xCA*al}yYn9A-DZDam*)j&dvYKBY7ve`${Az#Vh{3%QM5|uf04eDV9 zw=$atB*>vgw)ugAwJiK^qe)#vg%qWi$E_2W2eLVN>5w%Q?UEpK>eiDvH4MFW{s*`D zkMSpS>QHoI%#%6w8_1kmm~o5$$DFFuL3uwjagg=Loa((p`(#dSIV;c@y>uv5e=?`G z!lNUU4<~@ksbN5KD)NE%lQ}gX@|-q=3F*n4+M7WT^^Fx|POX(X^P>LCvjRp~C_Vjx zV!UNo2M**}NtjLg6)%V5bENm$CWr*5NiVcG?97~_%$~spG>Rcdm_y?64txU*>Pv8M zu*Ek-803HxG(+YK)se5jaSTEt5j&t~MV3$(4Cq;z|I4$|d1udX0!IoDG^hSBF0TJ= zPA$(~D`=XA!&R=ZF<@$0*m}A$uK`|}UjeVoHTi&7=Ew-$%89-&X1*?#aP9dlwwyKC z0mzaMsmb(xN6~|1JGsl-|C>r&C-&=KkZ_K6bO%bt_Y%Dowq);*iO8m zqi%TfJc@A^5xWFDaGN?LxIwb_qD3R+;W7BJA4Oa&6s07F*#ET{{YRtJUL? zV0}jAfb#&WtvBGp>27a#M6z`@EmXpIdSy!DDQClvQHPzRST9=KH=GHpx$Xn)9qE1? z9|F8G*92afr&>O$y~U$3j%D$_8}sBj=1b&$FQa^f3A{32*FNFKT5Rw4l`UDzKP)`{ zDfv}2xsl%TNu7!YRHv@A#c^@e${k`-9zoBB7y4EBd8Z8x8F4EzFP55E5kMz zt``OhS5?}|c979U(9pRG14S$GMnH9{%ab})GxABDx^CQzV%(};`#@l&W$;QjCfWD)%5p}j4?to4Fn+DkF(_o1&<$Q^W>TDkI#sL8n zZ1yR!h%$>^CQ^V2SFAOwX2XeAmL}F@;GH?{)15g?)pen$*Q?eI@@1qDx0AJhJJQIB>~dZIB$4hpD$ii?A28%nko&X(Lnqib!ItnWFFI ztZ)|GAcL$iMePkiE{%}-4F#6qfw~Z%O$D~@XL|v}FY?JNB|o_b0w(1Uu+E%iWy5*o zSm?=&Z1E-?rT!pC30XmEl&!PS*s<@4yguSYiIAry(ZBD^^TXL(yXNjsY{e;=F_&#@ zA=OB9Mx+Q&%0Fc!H*>4O@;xuidTWNreNp<(f@ir!m(@xSJ)lK6{L{u>G4-F?2aau9jt_WsMcyS2{38^|8uUcN+Hj@xlRht-Y zBAxuam8`GUMu9>W@?3!EMDLE7v87`S20^ z4wN$g5-4w1)?;>6FONZaa$EGf_M=%-2qJ7Q$y&m$eP9Hw$0GC1jngTw4Vfp?4c(F7 zuZ0x$i3t#36&KQz0QrZCm_#}jFVK@a%rm))99t~Dg23m-a;*UTZV1dTeBvlz~~{EBe0+wtdH%UYo} z-QC}HT~Q4HHV&A<2^EkT`T@Ih6SBgO>FIdw!Z(2KP2ObWw_Y$kJ>0fIG?UrQ}>F(kYFrZE4DSx%VC6jZW&m!@< z`0eeN3(`PLS_{KLZ7dmQg^sn?b*CZco>cEu+IySYEeMeG*;pQxH<2d*BJ=2Q(`boV z;am8up7Z8>BVPB4nQFHDo;&$7_qo4_5|DzNJ$O9MLX78}qM{=2>F$g!t%)PDT9$5k zH?*?>c04rOT^~aR;*F*siPJ6`F309dEaL~YlI6}84!~5v5F$}z$Vw$9?B>fwOpr*=zH_)l3~iPn z(75d}+?y<~&X3t7N8SRAK2jb9o|!#7Sl!w^&gb#pxK2VAo*t;Thi<>0cr5hDJ3wG> z#gVX@c;IZmY~nSvl5oG@94KlZa?;&@8)xFyG=A1`9EPsleQV}ZhK2dIaz|4(fF27I zWfnOsbka${`h>73K0(~$;uu`azS*ilLavi`Uq)YmrOXaYRrkD?d!0lD%@GQW0aM~f zVY1yWh(;ad{CGZ|V8w2!QNY_IflZsX;ZSB5JM_l1#M2_c+s8V^y5e2~6I)6w_|?BFOuvt*QAF^d=dCJB(45s@?}R zunT>+Elg3$xke5zO%pDh(nX#8o5%9zfYzY#6Zqzj*_v{BB~I$%#rZ=N1h^3R%kyiE zhsZN3Nci#NE*K-|`|lIB*6R=x=?P~Y;uW&-^vez-iq+Kc+e#K*FGx})+2GGith$Vl z*xpP#1d#ide^b6lw+tn-luXv$dQUadXz|)<3#JysejB^~U=Qibf=&40D>|$l&G?B+ zlO@NRRJ}XW=$NC5M>b^3_PUv&aa}U*?a2ePF>Ka!bw@kh{|C+)P|(>pbbmrjQD6*uon48m)NE92kIF!iZzsSM$nB`Lo; zq6~SZ&xPRyD@GYsG0FGK{{_{v_US|u96W+E=bN3l<{+Nb+8AvJDZ^UuO%=VOC&rg~aVf87t{%(0J z@_ORuQ{(|b5m*o?f&>0i;-XWQjzEF#S@Szl!$u8&81Io&RWvMjlfT{IkAm%OLY{Ly zgmX+G)Xz<_;%Awm6#%UUAunCZNFLDaDw=^?$cl{Z)r7I?Ig!M$1BZiP;cN9WFGs4@ zl4Y|Y4BV)l9tIcLx%- z;s%5RcK*ekVxTPqmwb0(X{%%coTJ%0o+jOSdJc2WypoZJ4?ZM9h-ziyr zIOp)qIyN#i2lc^vGQ~olMS>PI+@qTVcc9!>D&vo)wiE39!zoR(!0>EuuXpY&WZsUJ zFJ?T0^m65uZ&hdNT3X<^$225Pbk-)`ahQqD-thjE8+nbIy!`oB!H~yN)aQwYTFG1= zb{4~v&7MS8G?;Ua&>4+qh>0RPi6R#rI335sA>Lo*Y23`MJo!igj{ zm&BAtn({u862(JJrjFO=Y21espO195D7PJG1F9BJZTMn&@J_*A4LCB4bsmMLOZ&sGYO{4?!?xp};Es_d1+tFwMxLj0X}=A<$F ziSQ`;Ec3B4hNVQo<8#YY-p2DF>2Wt`kaSD(I#z(N(tlrmI#GLqD{#hsW$kc&Z2x0y zMsw$_<+gvOwqSW<1Mq(AWpM|oV#4CIW`7+OU+J|;!l2l+x_Jz|0^LybLAS~VT_+rr z;y!5V$Mk9H$C`8}_;ZEVzfb+-H#Pj8`guB;+HtxDiABkUY;MG!E39h@`@3o(6 z_fl=ZhTerTuU?m}7~fHWC_6*IcUE?}h~4Lv$g$${;|)NnlEo zkd|RwR|->Rmj-DA;b8R@Ji+U&s&KqFTsSm~VFpWCV6}Hi_YU7R<}?Cpd#V1$J9<7= z6#p#?VnnfUmxJ*i!Cv)=B|moJWdq5AYN%Gw(McQjaDY}|u6H0S@F>U%EWX|itbttj zx>d;4npV;-N!DE1{A&*c40xv_rmIXy)y)1E-tpY~`1cM+gLj!Eq$Sa>-y=WX%E05P zqX2J3gc6e9jgi0khkw*Z{{3y7{U4vcf50k8IUbPh00*)iT2v&pD9=?8?pG_z9VWjg ze!@T#Kco%4e}uq#Kq2rQPzXH#w-DI(KMH{jpg=-k7*`C6%qy^+3$v!EtJ!d1Gfv&WD##RF3zNv87vWNQX(c=AXjQpeF@^^jYe^x>MYPfWe8^F5%u7X^? z%(Mz^)={d-mSqOc`?yOx@NiaN&J&}jEzszcA_tfA(Rw$yp8h-6LGw4)fn4W7^b(=- zQoql#^x((ougayH1a7xKTnEN)t|Q@Zu7k10Co`^7;9s~7o$krZSh^3xmXtwfZVJ9# z%lh=2MG1XjfKwOBMB7fMxs4p!b|+i;`<3o@CP=qB=j02;F7<)@w?8(c8MyYZR3Rx2 zcww@!-v+_zV?h#Jcf3AX<5D9OW#?@D++9?!r0ei%c2ah^+DTcDuTUVxMq{JPX1H_h zR0X~iW{S{cBpUwa(X*T1p35Z3^suWD$b@gTb&I zA%Cf!XK1rEW4gS^^gL+a#|pUb6a2LAGeYO70)E#E+V`PqJfnSMU&pu!-1iB4+V@Fc zQuE+1?w8g>+VXw20XO%h-X*OGWJ8W^mde5-2g z-=My1$U4d5U`mWKiPl{#D~Q} zHsD&24Y&qq14jB|17^^9?Xhp{_{RoZ#%KR*173Dfpf|c}nkjv<0WbT%3{c*Z1lfRl zfi_^6OTH%?usrzZcwuaqCmXPyOho@G4#);= z0+LXu%V%i+gR>+RBL5X*aDxyk;u2%;bgv5=o-$`52l!f$RUxt*YC9g7ZIF+kuwGBy zYJe}yH)pu&q}vihDeRLSWR&c8+;3_MA}U@$caa>vAuME6{J5|c_S2m+@!Bs6s)6y9 zdCl~~ZyWF|aNlQX&o7MTM*|b~vW1<}p`k3Hn)X1t>vy;$dZLWETo~vznqmGIW>U{! z$Fj>}BLHp0F%GSYe>=Fl;AfgL4-o9A`ri2!2zJ2z4R#2Fz>ecg5ZK|N)o2`zfvyo< zY$e={)^Z~{c!Zc~wdWY&2E)mR5rj&Z&UA3m9|_#|p*4b0e-mjW@q=?fZ5y=j(=OR? z6U!Oq;Ro9HN#$vJ=Y)E8V@fiEb>qq!1={z4UkDv8TF9Q(+mi7sv*GvU>u+-!>LGyh zFB2BomSXoT#SBM(0z0;O)181|2SEYN6}s-`Mu$Cf5gFVz87Ay}gYp5~MM<0p%;{}*X}Y{< z3iqn{qjTe%*d+L4j(6a6s1;ijN4gfQ1$jFn2tXY$*0{)Gl&sUZ_0Fe7d7lO>8tt4S z7OVKGo1s3Nfg;ElsslPMY;NKa+k*Yhkr)pqbQc2DAjzLi4SR<^-O-kR3eBIGmH^?)3 z5rGGx4+bZjoIkC!Ni202*D2KI4Qagjw#4$gG-Pq=CVvoh8$ls$CZaOOe3%i*p?odI zbU*7(Py!7*_j!p3hq1E9D*zQ^VX#cTUwbcq8JeN632XiOd4r#VF0H$J=7Y?bZfLO} zX~=GyYpp1{{31gkt(ENn{d+Q;oyTuZPTlNRRpd!JIkeGG{{}X_@o7`0wazO%-)yAk@^|cbVAF>Kq+7rqM4@~T66aF^}Gh$gi~~Yh^UuWJP<*n1}4@J zU%3JQblau>FiSD^YnI~auUU%mf0?DAXr%W^S47E+5MCd?--`uWcA8V-}ZJ`X1cs6%P4rE zhO{$M+#cJJ>2+;#+=B^SM*Ecp|C_M_k=~RSvg>~JX>_=S_on7y$L{0Ay@+sm zPn>%w)?G29!!w^8QvE>Bu`kOuTCe;w`_Fs$GNuJb4kYU_9S$_1OX4M*${k~swgNh; z|EA5?75>)dMcX!ST=Do3{3N|pgB(rucv-~nmg>9TQ~qK&{4LGro@hnjXh%f8!K+qI z9hZ`zUL4vHl3V_8althwGeSvf%zX!NGU6D%8M~7#fL}wpW)` zee#IO#Gd)C$A02!AW6mr{Xe@p2soREOTc#T9$ehRr5 zaCTO;_4pggn|IyK)!@ov0P71&B8^)wXs!yne;nwM;Kezg(CDJ88)tEQ6!LE931+m~ z^R!Ta^&_?w(>x+D@!of^?q4?kXOYya&?>-F;@+JIurQ7r%2|?7VgSy{c*Zw+fH7DF0lM3DSf7zi(Z9R-Ij+ z`7bKGT@)#z+3sW>6YQ8F_0LWaXP*Du3Gxr`or`vHFh^`R1q4J`ID$aLR#me9N>~^; zCoI65+IRJ(a`lU|GVDHRN#_^hj9e8_XnC-d*P&>~R#822Aie#+A-*-b|GyXCldfam zuRpRCw3Qtn9kaKsd;GWf{zqF#U&{ZeEu@U)Xw|z*c;v02rq9yD`aF8A+U&L3yCIP9 z?o7O2_0)(=6!ETn)kT0C5nr`#vrmM}z>3e%i0xrli`U$Qr$k5KAtJm!=rP!zHvIe( z1LJ@yJ&O|}uvqk4F!xuBnKJkIQIDrAVb%OI$vU`CGN)Wt`N!WQQqAx!sJof zukZ1r$R;`+GNE6}#`_OVf#;m2FbmNXrvIiX@cu`dg0apyO~L7d)pIgW2ERv*w{7}D z#h5Co|83^tk7%xxaNsqygT^p^+Y9Lly$oh@gJgI6mp>ByAE1c;G`jJ(_Wo-q2iI2P zPge+(XqS>8+dtu3%7|R!$0AN1KGs^v;p_vz$&W>rhso=oUeh9c9FY zTVC#)CiCnI+UZW>?wc_25Q#Z|VkJ`UhOEQq&RIi6a>brDzT%jsX1S$6*EHKB zO42%fM7zBIq?9H>`@;B!|F;pPUnqt36B)1HD1`>Sz6+SG^@|*XB-Z6uMwr~@$fu>r z8Emqx8@vdx<*n~!BJn7<_R`wJb9HQ(X!UYL4~1X3?}*8svf~^!9&%i-(ATlui%MNy zi0PORy{{J%@6+|(lww%;sN)kp<-U-GR*9YTGd{GyZQ`PPY7Y$Iqu=*JZ+&o3B9mE4 zek0N+DQQKcnfg$0A@(Yc?hK-_zR_AUz|VEzWul*e4`tx_4eC+(|0vd%7G; z8imH-NgR@rj6hz-H(}%$RN0HZra4k!H*(Yi@yzcQlKgd_rO0bb(bIm z9u5(@9>g$C%>Odr(U*n{cuwhm8St!}Tk5L+ZK(^--kc$UEOn1N-O{nlqp5axSz8`O z5$NA~(F36rWG;B*29?AVyP)B_Y)7?0NvZ`yt8ypT6nlr(vXY!AdD2R6n?^=A>Rv&_ zyudH}@U64>v#5cnC6UbPHA=qAL7QIH4>zOLRt?OZx1IMP10Fqza|0eYdfx-*%jOUN z&yJ1amc;bn#ZL1aT893mDLKqe)d$CT{yxM~c>ar}5Pi;4I4b$xToCuPI&AtkOTjET zD+FRGIB{c`j4&)GE_mY?Hb^${)2PaDyorp9xWHxY@z8pL{i()e^?it?@a;ECA>bEF zfm&`VSbSr;aO>E8pDpRKV)D(m4GnKdjH_8R=^bnsp3F36mSJ?elHVwA5z(ZMo0NZ5 zezRF7KVDZ$*!$v_*6(Gz<H6D&2X6Nr$$#gw#L_p*_T@v)nt59i z$W!+w*D;U7 z^m8p9@iayRKHU!O{S+dfTwMDI8Y`)(+axq}_HpU-yz-dJ*tU9VL8_I(bm-_=oVzD+ z48dHZQ-qsjfVid5exvex#`B$J3gzmqx|VZvffXu+^CkrrKI%!6WondGeAkbS{AwaD z^5^DVl1z`~+Amr}*j#LgA9+{0G8ew@s1W4!ZTaDtq3@Md-nO!#=;=C^xCt@}^t1`K zOC8rPkAv|$KKSgCOmqz<}54<6O9BSDJtDSR4lbYRJ6}G z;S9ZcaImw5T2rHKnQ1D#ay`KeH<|wlbtGkc&g+DF%B$s1()yd zNZ)pNbj63{LS%nw<^DFN);2P(#x9KVH17H;WP8(0Xk&j{T^W;=UDBD%%oIl_WXx*3(_M)ty&bm^1Y1Z*5Ii25D<9 znxDV4bxVKVxxKB6iz=_mRDp0S33>SiOef?$1J#8;!aAy;%6ur&;+E5IVck{vTUZZ= z`2VZ0jw{!9v}Wlg{|~~N*a`fEzy<`xtg<(%*raJ7UHvo!hh(*BB-eZ0pb;{<*@uj7 zUWyz~+xMx_OgciYwvM^(e!nv7Q-U>JHu|2`%*>FAF9q(-7YVZKNY98;uQ0v6vL1ZF zKHRsU!E+5`Ov zWwskyBM`PGCHryC-q1phz=*3}R8qgoyr#9+0R?vLSihX@4^u%8(Oawb9~wF>AFqz} z+i*do!p{(?Q2sZm&<;)_OOi- zai|THgouu@ZX&;8S#ZH$SLW6Hyj9av+xrbNwI#f`Th*S#`?sm>08cB)f8M>eXty-u zvM^>#q31u}zGk(4W4i5C=+XW~T&wGS*6IIr>-rxuddf`r|D}u;f=>N*-|H|k;M8f~d=>N)S2ub)~8LhDO;lDEa|38|bxm<}0J6W`_ zGgCcx8=m)UR}>=Ws3W*(!G_`On-}?N^bj*Yv?$9Ms5%~X-+}$hjnl8 z?PESb^pJF2=0Uga<@@WeM;mYIQqpYTy>}|l1Nm%~lvI}+Z!8RQv!Ok5`VHSzg79#@XqPb? z4q>qbT_w|q_oYPo4mSuEBDP$;&aChi0%5AVkmbgeu4|95{!B<{qtj^9GS1-hd&bi9 zNt|R~^A%a#lY?(Wd!kMUPZoElLM8}@)!S~KO=hRV43Wm_I7H>+vrUPOS~!`Zf7}&Oz2R<>CASqL$hQS#_DUFRC?p?GD&|NW^jK zR=0z)rY=P);7+r3eJV1o?4lJ@@GTEa9*a{hlNWx{crOmw_+5Qjqw3QzO@5ZGA+qaO z;YyQHqyEfZUtCxamrOEtt3qw+_<$2$^!J-zY_PBbv%eOH$xW9AaULZ0Fz)<}S=HL{ z$PO@hyLU6kot{72w8QXV%=Oxe3FFA)HQv%sBAWu0`6(sJwy_z+cU|52-Ey7ZT<29Y zaEsD>+x+V7Q9Xl;$+s_|6k;}TeZJ>k$EB4O&e*~&&%&`KZeQ3ENM8J`QixKcx}C!f z8MFwDRc*7>f95AEYF)yE*Iyi<3T$iLPYqvbe zX+!&2Ob6ipbI?Z@Jv>k-s$-w={7i)Ij7tDbj9)D+=hEHIM*k6y)ZnI9wY=J_tPjnh z-Eg(>>jpc+z6^^F|9*&of9~0o4 z=~1cnAVKqpS`KoY6KW?x}zV1TM;3@o-+Xk5Ef~;ahn@_lxK~@$Ho#^VS zgdDN3Xa7LpD|+Vx6Ey5|K!2Y9dq(Gr8SIz$0nP!imDV2gysp&6La0UYMGq`(O_hNa zcTdM73KUHBJ7~e$QyqV=U?z^edws|->F|ql;-J*59nJbHoBhP3M%^Mloonp(>E4?u z15p!+tu?TEK(G(aotDywP{;E4+v1?Z;Uk)T5H<*gGQk4fAZ!#ajg&*q3Zd{{XXJ&H z;8>@Fm^F2B09-wT4#4mljFg=4y_`8oyVziENQ4hfm|W}kMY`MXRVRWM{P>o>)D>oj z@TqJ(+OEDQ^(DD7Xi!=zha+DjYVZWTA%frv$Ixt{YOAkk@=XepQl3)OeOb_CQJiYN&{zmvs{l5PiMz(Z%31L>AS5g-iy}UR4TIE| z=xQk=>=6Pj-k&Q4odA7kB76d$NlibKLqzQ{pkS*&0uZxV=D+>XM}OG3FuegE2ZZO} zsRpEeUqTJEr12a!7LRwaMEVgTX9sq_>Q{o}HQZ&ms0)g?Xv?HBMZIt<{hlY;8T^B+ zN$wf+ z_TXm|n4pjC!K-;R{~m}Ls@4WOJnbOuKImBQ(Mv&u>srYw45k3$F`Oz<=qG$ZI&^|) zzyu(3AyqWI4u+0GrM>?3dDI!4@@$rAn4tq1?3*_|q zV?Q^YBy1f}0T1fsFGX%dJH4$Eu=|M?`9qodA-Yth zKg;>Mk5Yi(!#%gWv&QnFA*Tw^kCr+m-h%ajmNVG!s#+HG&w&VIc-AcDK9DT~WIkd_ z@4H@`+|fkMmRnb%r>0_9C*i8oXs^TNX+`mb$$t2(`P}FM{JyBiDa9HC^Z{7U&!rPV zsMnW=zn?Aw6vIv;niK>cL>)AWAQK!N4vk+$^;NGoCTE_(*AG-LAjfij??Gl}p8B^F zR>w{OGd$<)+j_oyu1TN_jtQ`GheHOOTPpO+?u8pS`5J%D4j*@n~9-B@W^oGU;YhQH0%mpq}{6 z>l96l$PythKDu|08U?17Aqw54#%p4a%`vyV@KUGVK7M#3DK{|u_UA}oPk;QU&s6z- z^Ld}Cg_iv|MiJwnj(WAWxbWq6BUVDrlLY6G)RhXh4@cxvfbFB{HcjQAD7a z?XiQN9*w0ooctuGi!X{^I*jVDe(PeV2ncO{Jb7dJ&JPhd5DABiB1#GgEo~R7-z4g; zO7ZH`Df{|%IHsId0atynw7Kg2SsN^2@1}9Lp%u5u4E%p?!fVf^0Q5}}J7kIQC+G}B=rY6Ki%XaTCboB|uB4D|wfRiK zJG?4EZ$H{g;%Js<68;2aphC}|hJP%mHyh0=fa_DRRf6vO`t_?0Dh>La1WYM-)?sUH zLt*>l2Cy9fy9WoCH{jP%Oeh#GlA#tI6@rdZjf#e1JrwQne%+5B(KzRko@))}dNooV zt5Y1T!8jQi1W*4^3V%l6xkD)G`x;;s|iG4rFw>Y*}e$Kbv6mZ#|@<}x$A$$OE{ zjt?iKyB4&l3r`y?eRi4R3X6KFKI(Kynk9**smV^v8~DQ~a^!|Cf0yD4e^xq?>;Ttx zP5P#LmtkT{QpZy9vK7u(+}-HL9yV>lBVfXxu5<);5mPTva9`Je-Npn{Ff|On)`jm^ zncGk4*MMWPo~&)c`9+$bi%dKx2R^=z zAqY71|6ZLB_+t@KIqm&OxebOAcm;ak_o4?|9PaN@z~fA5A}p@k*IHhpYxMz&(UyP- zy>e>;bkvrEkHfT+;0ee=MIFKZvCzp`?nld=Hb$Y~XFXdWjO5rKz=VZ;YJ&mn3)&cp z4|4CK?#}8gn`MsdFM!%Fa609_zN(+bnv0+l@GQ{O2%$G4zz!SG%>rp@$+Xv@unq;U z2LxvmOaL}R1LV$d_TcAVylX{ymd0Cvonae$p(z(Rn`elCTdm0b3hp}^1uRa(!5RTG z!Cg&<-kHfcOXFeD{#sc%Vb`y0Ueq>KoUwQ;5QCnuE3ZOm6ojZFZU!5I`lk7G9gt_oLTAM#r^SNX9GR(h2fh>K(_l+Lx1ybI4g} z2fPP8o3$2?7Hm7|vl#)8>Ybjr39}=AsP)NCV;|bqb-O31_sOE*vFk9SJy>rZa~Nnn zBOcaKXO)q7gh`j<_8P+%5c>HoCgg=x*m2> zBqZOu;dAhr2*EykzAxg6SjLvVxNRZPJyF9I!vmH}iz!S;B605>7r$_Mdm7w)7*}#4 z<6|Q4!zhvwDEaOCs@}d~z#!F9qXhc66Ni5k_!<@fA8%(@?~pyv)(MzzN2v9rzim#7 zKWmf|)AKsrS;by}{Rv!nmREaD_N5oWF3}Qf6v&+CV@t3ucq};KEj#OTRBmU*uq*^_ z-o6GUz{NY{urclP*{sVR>0WNiAb56Bd~ygNzOs%2+4E$S2}O*9u_F%Tpj?&tJc6aJ z0eAR86e8`<{rl6tm26HHfCKX)Ab144k!X03_hIpRz(tz=wMIb_su!AP)=*FKI)FR7 z9=H|(*b(s5-(#>~?b3+N>5BlwOPKZ!ASEru^o()~Rsqy3GkPT71;e~zpeN-Gcnkdh zdi^ZW^J$JKy=-CT0vA|;C(~;$K_m05?QrN9U|r)7 zRuQy320ap0wu^zv2QZW44z2^Z>#GyhYKLF>k zeCG{XX0@3`z|SAu7u8#Z1j#8Ts6OT$K)hzrV|QD8V+5Lr3)%pI+L@ zo21pBh9tI@&Zu`n$w!Ab@1^?zPT0VUMftF*4+krLELI*Yag2Br8($r+FtlE{4I{pd zZ$g3^rIl5X%ccDIt;z#}>@=5#oHxvzrUtACwDEOe4?Eu(kgndH{kDJ>W_wj=nDQY! zmP(hRBl3!;U<6`RcW-3|X)eoTF0N$U#y}NTdItxy$G=L>X!X!(<@pL)k#9=-L1)Md z10t&j&-&QYvMf#7^|nrlf`|D-34X&=@bAg=jfIl3|H>XnEB~>|w@eZaL`VvI>#rYJ&d|6MSKxx{Vq7jG<~=jY_u=&psar%%a>WAxCzy---X9m7P4#C;C^~j zaqs;r(l+?F6`}qu$p*h~ZbZYlMV-F$d7k@e2TsLQYEF+7pU0T?esGasFV(+He<`#m zD1q*_SEmca5QU zy{H}1MDPTig@{^){o4Y}e*@CTavPv#9PCEY3F9=GfJRZ#l2N#~*9+9_BkeO7B2BAOGWqoSKoDZ zKQ^}akOu;**R&+%ATJT@9-R>E&X%$OGnQlG+{E-_oO;+QN@9oD6W)IsIKyy&{&lLf z7l`yB6l0?zpk)z0I%alb|8(<1Kh$8>{nyl&2;~{fdn}vlY3LM0q7_dm8ilaaCIM~-bW>g zPw~hfu=jX78P_@sCSIyA7_)w7pj|2!g1+HV4V$ zaI+Q(8hXs~rM7;4OVeAOB_A`yj(&U0&oE(S$nJFMTgNbtV?@LJ_8GjbzCTpx(4^xG zte!b1LCMkaFaoeI?Re@j-1TJ2l8Zm1JppqAFkYy>F)rYSLoamFOC<)L*c+XiWgrc}m)meZdIRO6yS|#{7H2;I*4!+7+?O z>Ucq_)atp>kx!f`FJKa>&&}-ld3k@N|2)6&=5CJW+nSS}>Gm&v{1zu{cslpjPvw_L zRvwK{ZM|WBN3&!s|WTZt}w3c%#3jtrnh1Hv}bL&(P!j zKnFD&lf@eg-9l3Ra8|FIo`gsN=L7DJ8@>_fsE!qF6gWA!x&(&u;6|G5BkHw~VkyH9 zOQHO@f5Y`rYj4E32dN0R=}Oz@-iRM5*dy-vmHPTVOx10Q!I)Fbv>)i+QB@;2bkBWT z@atUNw_M$}ZYVbBQ|xpJMi(VZeY)vd8wqo}((`1j;`va{b2I9AFW!cbs+YCLz&cIsIT%R-`+)%7k+BkK{wJz z&xX@4V;Q4`8O}H~F7y*e`MMvpWHHdAey)bf!Mq7&?$ZHH@cmPYND5z9uia$chp3hO zr)u_IZ^%%W>J?njX(x3d$C&(3IWBi)W#nvjKdU+nc%&j=CDX#W1||lAni3(IZn*V| zt45MLWS$BAwLvqt4!AP@yQr-`q#3y1trDImyAPuM*M!K7=2}q44Q8<|B;Qt=_dAG>le8oe@)| z%shco(C6InX!zkFJN?}eEi{q1#Cda9_pJdJDXkjhVJV(J=Y$^C8#*5yDmg~Wt=tYU8HTte3B3D(;2_&LN}HxwgTbT zNL8&~H6f`aj8eKu>?c=D9Hv`_fR0l$>hU#TFb_*Jet8G&<&I2inMmdmdn3+{{cZCV zj>34c54j|xiAtK1PB9bP;=xbF@9+j3+sYfJ;y7Ilk|{?jfdr+b<~cS$TY2Xffit_VKqpg7qGekb_L z)|Cu_5SH(s39i$3JAP3TFO4QHMAIyR#RJD$fWHoKvOt!%EaK%J^^y~Ok9s;tBnC8- zLAg@*S`M5u9a2`WRJm{8Lcj%T>ckO^JG;gosW@ig-)3pIPjBM-5uP__EkC1MT@a_k zb72|IAe!wM;501PQ;GgIRg!w61otCHK1Nj+mF-2Wjwf+G6@@E>tm`qstmMqs?h=YR zbcwFtA0s|mrA@Z#2;5Ts?HB8DH3{;Iedau7%DgTkIv%glypw)lt#pY0;+I`4SG{ds z-Eih>i>%OqNW7QfYWluPOqk_y7wKa3vj-*NUwH>&A+y+Se*x{K_d}jlZ!%;4pZsDW zzHQEYGo4ZF@N3zne0n2HEY`e$zPZA@LFxFjgE_wwoorGy!m#!;p?J@N^R3z~(QM58edi*KIQ}tmS`a&HXweHGIvLTCl zyOfjg+@oSP;i{^x3i;(VXCfx5VSf6d%^5cO~j11`DM>O-oh+l9O790+bXtJLs=r0ub~2Qpk|(2ua&3l9)v78(K^spgX12v zULs5SET&y~$1x?#+U1yY=6!-keK?u3;HOM;7tsU7)sesgp+Y=)!qoiFy}}W;VsYY= z*3UUKtg8{dvS$^9WTps;75pxuAf;6&naBV*Lv!a*y0uMpUm!WQ`T$vUJIG)?I7UE zd-aawn0G(5@!2`-cClP*j%JzSdhMG=<_|oCKio5!Vb6aNMT{*1CdU`)$i&nr#C(X@ zt|@waxFEIkdG6b)JU#{EhQHK?PuJWpTbU?&RwqPO@%{7Jrb-M-J#&li7sLG@VfocU z<4x;-#+yp42N_BN3I7dxOYjSNd;6j&{r)?Al;){MdH_y$6Y zL};*TW_`QKaD3?og+a%M@9wP?Y!^RC;CC*}Kg2p=7qHr)uw&`5pnEFs$~W4-Aut?u zNo(6`Pq!JX?1OlE>F$kNvVICR;Ytq`bbre5KEii*X!x*}?xyPhWW80G%eEJT(%fv% z{@6@3?(ookao)eIjgY*=$4gH`#~8$^%`pbgg&=n#A~??y_6LOK8#ldBx{hY=MI}*Q zKe3`%A_+t2TtBoBxsEpziHtwS5!tL;A`GRG!lP>3*_n!xPT|ee22h7 zaiBrXM#e+NtGK}q+!6Hn>?Z3s(Z&eL6NiMX?)|&ysa4aUBhawWc+ZkadiI-OjlK2h zhOtt;kI`l9d(!?zTOQ^wE|)OCRfDyry)R5?F~}S9Y8RM8J$SuMu^&zGo{l`|KcU^8 zdJ9h!(;Ij=Zb$7Sv5GHgOo9yPrwnRHFZ5J=|I+Sl7+;BIQ@~|G7U z`^+hH;yYw(0F?y0>0K0689rHPkXj-&a3zzj?oe%pnucV{Wh3v$FzU=1 z6vhXUl`+ptI^1kMKR8&Fm8>46 zakZHcD(NpO!!LcIz4}Z}B@XZFSZD1Z-_-U+vqC~6!J55K7E;d6OM0FTrbVpFnk`Fy z!&8uCSj)BrX^&DfP>sh&aN-)eng&zc@j#*5rTiV)=@(3&;!e&z3CtpVC2;S2VfGX^ zH-2u}^y$}`?D)66IkU7eZ2D>z%--o`l_ezMT3wP07RR_21Ci=;Hs55Jw#|UXdDYZm zB6-7rs$^qv-9byN%xFJt&^DZ&&qP+kd-!eKuqa?2jSlI?yo&(sD@(}IuM-ojNI*0Q zjveV?J#3x}cxJ&t&E_|p%`=&<(7jvjl$N_KeD}C7`dyZx2@&Zs$z!4cMj>fBAJ^4f zDM6zc{D9P&z&&uw*5d<#xD;x3^`I^pxIyG@Fxzkn9w|K$#1FXsvz8M@OpbYH5acU? z7{yk&9I~2^COS-H2#^>tY_7rY3Qc7K{A_^A1HPP(Z>~~iz^~yvR1NsVJIZ)JJr&KI z74ek=NN~~3t5e86ynJHSiNm92ZTu;+4+8(4UTQZQr>)zK zI2@*F>`i@CUiGE19QK0xx$pZ1-~#V8HWXxmtFFh+B>QY6>?Y?;!nFHhQy#bOPWQ9# z6mO3qD#uVfUYd9_jY9eeHw2e0Du*vpmC}=5kNa8ce!^?QSxbunArK5<1E1x63Nut} zxDpW2SYYtz+|0!Hig@Of=n~u7q#ZSXU(o8ekj0W|Uao*!j|E9EoUU*6=uer$&?W}g zhup3#DJ7z;IK{uA9>EXjZ%lokbG*9fj*{26nwYqXTk~XPV3o=!e#G;0EX{#P!E!A> zo_^AfooJ-1f+(G2Xt;AXo_UeXxY+1YT=?9iyG~v~l|KB`!&&ze$~JLpfCZ1U@xwIV zcvXVuQIyvWVgRZY%r&Rl%8auk`Wt_NN z=MCy=f=!ChDaUg-px(M&{J;SO2ju$=2eki)zI_e{9Gvbqy+wBp2ke00fVFEoPxhnj zEagRcQdZX10;{OU<5zOqdMlEpnC1<8E9ym(?6U=ChfObh6Se16UK)$_F^PGybylv>IwTQP+$F!Q3^e4&d0U_ zj9!ugH?R+Xm{!K#foY9LK-RYq-aYl*aQZst^L)?IHsXa-{UW7jm(o^vCbSzKqnIg> z<(XB3@4bA;%fj?CUWWCS@%rfOX)|Ne{j7rHs`_K-U#IBSGv4?(Yp^YilOpk)dn9aK zjBI)TDqc>mBW0?cGq-(z|0w=b>$}Bal#(4c`Y8VfW&1ciw|I$j8EK!q!fyJc zO1=AD$Vu|LpHQEh;tg?uB%B&4U1Xm#TJ8pwMZ^`mL?7m*CKkXg2gwB^B`&C1nv7mB zIiY+z){Qza;P)%QcG-qLVcVyVxZIIb5at-a zm(LA^R|O-Ew_7l*M~z-DDjGJ2edN4Oir-&(&%mD%|D-TKK?jrnkT-|-EbiihCmyNp zbQ%34^~pN-IYFEIJFy)5oF!u^if6{8m zlmYUqaQ^L>nWRCm>Zk}9jM9M>SY>E&E`5y_b@y9Q!gBXVGh_`+Oz4292;50fDvoCM z6})2m=_9$*eCA#4uW{1eKQa#J6fE8r3>CC{94D^L2IgS|Wouw+wwzp0K7b2-{{96t zA!qCJ;JW>BxJzB_a-D$^a|;}(Ffv+9Hf6Koh^Y$MkLx_PukAgemLEt1DLIUnkg_d9 z!3Bid&KRBS0@(D&m3#}&{Go1)qes*ev;uSTc$%#p7(l^Q!Z+#z;{ z%5>;eXiLrz!sbbwq{n~X%Gw-DMavl?j1%%!i1 zOY{f#kyN8EqF>2(d8|&y79Dnq-E-;PXIkONS*N-~bt!Gz%iYSiF;PZ8?@&GQO37|M zN@)>XDh?@Fo`0;ST=0RvbWyfM^}~s^)HFMFd-;e_L4}AM!%La!lLtR&CS->F*_k_t zs*0(HSZ=#?D~8Y;a`4A8hD?Sz9$8edk6x4PB-_|B{>b8leaNaf_<*;bN!?5JDvXuD zZ#ESl$NQ>rZPr#K?DN!_Ockjdgz!B(8CYD_#dh1XIkBU3Z{*)AKSC*Nm0r^E%*ugZ zr1p?5^n&K|-VsiIIXgI45t=-h(Xz_y;OZ4Y8rr0q{4~o+O~H%&^fm<&yf~k2`UtRHz)kyuK2Wd}lG}Kb^`GC4}Dn9ZtqIM|?+xgT&Md zCWV7D4>c&RIV7sGWZ6D1YHG9}(ssw#7yq5@Il$w-e}Na7jv+c-Q-T%CZ*lyXvX^?h zyW3cGXL%AZ=sR-%01PRSQ1eJ1H}^*{G&2UzjCRm?HZUn?!%*T=adf@_%KE2zGd}J} z_~5A(27c2pu&NjVEAt1mr?4HU8{pAr-MJhoSyVd$M)|UqWP{)P;=Bh2M$_^M%qq*N z2Yh!!_a*+=)ybA-#BJb1!B=c|R^j(00rwyj@X_Urwxo0)Xgp&%FKnx?Ak0w_F(Yr? zo8J&j0vkt9gKfYsECW?O4t6wi3Dw7NOuJGz%N1VpZt_A^FBJgwVIe!xeyq*;Ld{`5!95)DULMDWG= zDLnB7z@$#0cru%FLuJidK;63ORwZ?HW^U{V84qQyY=FH2PV2~$s`_3odC0JpeqUF`@EDv zK&V7Ki-_8lXIWq;^E)`T4cI*o`S;6fUX^{j$9e0^qipL6M3($`;1E0!c3%r8eHm(G zDpx!>|6R<^KxIGNU{H9)H09E87Msa1kXu6bMeOqq17b+y$k1R~( z?Ff@lv)m1OR1=kbC`yX2W^6K(y1-vwDf}~S;e~tP2;ocE4^QM_Q6@c#-subLWt_JU z)L9G?L}IH}-52~OA1{SSVr*Dfc1$=Ea!2CdMSclUg`VPCcd5zdfS>v=C)Ut28dxkT z%7~$ASU0^NTWKIS9B8k>pwSzw!V_x+2uDJ2&vQEsZNlW=klw!m+ zf_=el^E;8zWG|ltmVdu_ngp>^6Wx^93$7lEzrK2q%zJV_iZi%Nw-mHW!gZ3nog*?0 zclnz{{N28UtW`YY_9q)IsFjU=#>7WaVl$m-D$hF+f8xS7?{P7JBYh5L?Mbml0Jlh} zT6jL*FLQjQ)V}Gp+=L5j9YTzrvNt3C!<>S@N$b!j=7p(#jO4oUXStheTD^PInvCKP zux{INZ1Tr2Kjo$zp@F@%)55HzE2;SKK4V-LG6KEyA;j0YAKOmk=?!hS?Dg2o`zgis z7HbYwA`MU$lLEONnl_2u0}zvj7DtH4F`07Vo`t2l8mk7fxZVxZ?bbS({Dk~OXo+Ij z11w_YPt$LoQ7EN*Z8UK8EV?jfZ8C~1{`9K2S}=>2UEcThBHS|At4oe*sqGsSq{EKd zijK2iyFhqts6?;?Cyi$cbw6uyOv8GT2y7rKF7Qhy7gL%#*G1c2~wP{@5U)>=c4RTs_}x8igd3r!Q;CP z_h!Gqzo1pUj6ooE>s!tj)UEE!#k^spb!#SzaC*T_Da<=Z@S6MV-5>(D3-}~}Q#yh7 zt`GF2`n0j%7?aZ!r2{?r!(dFSbkP0PTx%P6@<<}#s1t{pYfrQVBQ86I-SNAO_0L1- zWv4CDWjst#?0AufCPG(EX(%4Qe-SgeG0l*#W#_UZFRe~-%?L*<<7HV}$pw%Ys>DC? z!h-!=M&Slt@(Rs~j)h)#!+CYlH3w>;a30zd5zRLgL-rlp+1Z%ZNIDjh2cD~9Ma zT&M{FbrL0d0w;M)_NQAF3GWAiXa?!Gtq&P{h+9PbFUN%435O1N!H4G1;c|@LvsdST z&>KoOzTDIh&=B~wI6s(oY~I0+mo`!ObqIPZ$xnYhmqd@u|5kJ)BaMWKLM&4LJ&n@GHP60(5DwdYPxr7) zJBQ#iZCd;(JhG1mUB0uMMu`=+^OjB5f7m1c_3kv4GZr$9Kng7En%|wt1-#k7ClAhn z*-mEi`*2B8ke&kTG47ps3?>>0fq+|0EQiq#)z{6=&oE>d50z0+wJ33ugXkCTv7IX9 zsnheYyp=3vu;cD1(2nf$IR>)2%=YH|6^V}C(pXYsnUU+;7!wbeka^91tqu}k!4dTB zp6ne!`EB1CL-<}?KU{p+5!!Z8C5&yT@efBcTjiWZ?M^H$UO4{>*k=I&ve`tr)+;v> z#yL?qHe6+KJyVn;fN1FNO8(%rVj`K#x-@@&~{L*vJ`n*z3lg90Knn_ZQ&0%`g~ zjh97VOTBKS25a6C=ngaJ&DskoHy%=rG3DnuUbGAzT*Oyv4dwgsw|7ll9MDIoS;KTaIJYsUUS0&&B3jiY-8m z3{UYZeKwRz7sDS3%U|-@*K6`It+qNQxcKG6nQ;((Yi6~>b(48uMM-%J4G%{*wuFI^ z2^SWInN)9PdW+l z6S^#)MaI9*z~;uS1aOx-@S*&>P6j0WdZoJ~3Xl72%f-v(`s1f4kycK=WcNAB#H_eU z01b2{bmP$1Tg&k6dcd8X>LnH1>KyV{0J!tk}R=1{Lo$&6CV$Z0mO z>)z%gjPI-qD;)V{0rYy$iXJy0oFFz(4Fidc)o|82!Yo3rhh`}$6q{Z>8ZqJbgE=kX zTf;}MwsKawrsqHG$=%AHy~8V#&>sv39Sw~V^J1=Na9Y7rwl(;bVQ`cR90ER=e{&e4 zTw4i+8isSv5=K5XY}nQKY}+~BnyTu$_42ulA`ubr^jB-J z5eZJN3nDuAQT>FH(eKtCFCmG%jvFetUG(Ah(OU0Xn6spu2g>MCC!I7H9yi`BK#s#c zs?AOt$@5j6GW+hCUS{#$cehKQKi3*rCzuz-MBj1qA+nOe@%?65DM5R}qlS(Tjz~FS z#4<&)p{W-BFZSLts>*Nw*QODW7LZ0lkZwdtNu{M5lFyBeE|CtUyE~(aUh6wQpLt!Rxr`;bVww-&`+4?x+`nOShP>qxF}RohkgO_g5Qj+e=FvgT}AE-qYKhm-mRw?=AT zdbr$5!1#<{fn53rSx2t{@~@a5CEeD$WWQe%%q04{Cy@1UTYFNF=%N`#-n}(2o>pc< z8g12Jl)b>k__h@xQRgcnr%6`3f_6b~M4kNku(f7^bRMG$V$>S;#!^j~{Aox;D&^>! zHb4a4|EbzL?Ebu}Z7b7xG(szn7ooJ8MY`m%n*uQD$F$W#hn=tnwV8;s_I09W} zcBcB61&%pa_@(s-(Qx2c4}q!>Ykc77){aLI0(@EJuV{er6?hEyjSK_9oB4sCAlXA0 z4rG{Z%izHh+8}h=bw~sAT5*r$i$=MTe0S0{yc2jTwE@U;zu;?6Np{$2D1pWmU^aKAM|V$hXVF|d}6ogr@;F(?s~|`SwyL)-CfoWsV1Oe7>+{@ zF7_Kv>J@`TJN-Ww^v{EChl!p{-#tQ4TPno)5w4ajZuz4Xs}z*Z(2z`n0Zqv_mq5%2 z+zkN!W0Ri6kZ^oR2!3R;#lHtTs?9ze1h6iC0!FSL{Cv1GBr5BCB^yQg;Yp@1`QH7+ zUH$IB?j^9Z`|t{AJ^}XKlugGh*>r5HmT9F!=Ldea^-$J5!#eS@MjRk`qrj7C&&MCz zpokH+k`cGgMVWJM`J1BJT3ZQrJO^cJu3O$i4i>@T{lXbXW5<3c68{ce;rhK0kOw~b z)UTIjsKB!07{39adroQqd^Z76Zj%4n@9I1_j{6kqDc{Wwq%fVN)n7`p?HZf35OfUc?#%ni@o zhFj8y35G6l8n1;n1ocS%z8L%UV`D%|x_e)pXW(wA7$~a@nD5KlXB|hyF*mBqmG8DS zt4_&usk7@`*_D1c8}^S)o6b80)P>LV z2yhoAAK2KvF9vc2y_UFO4;!r49vp@6bDn!zvIu0|dA?Zu=Gk`JQiB8R3?X@a6+MOf zNDajcy@ezo1LXoUjC~lu1MOUnxd6R|X0(Ymec{9bd8h}V2SFLN1C8^&0PoATOQ3lU zKx8@!3$2wtZSlBXiUVYX0K7cBL?Gh6>I{1w;SK|<1gI|q;Mr-al(fvxQ%vF?1dXnT zYXTnw0v^y}nm5u5lUC$-Zi^ELBYae79S?TR%sB2_eC&+`HvMfXslck}MFu2+vKD#b zIN;uc#*^aKvwit8a&*@@T^){`CMy`L!=Kwh2zY;p+u>)`_b8}N?&9#(WNi`7J@U#+ z0$X5ysGp5k)Tc04anS`?{dQ|3h58+|3BiGyue1(f1XW%-5gxK10fhg-lw~VGt$t}4 zgL&>L2mx;&f%lnM&y`P4Ecsq0bwq7Ku9|UxjMVUMG}gP)TUJ}DW%;} zR0F~%jfglY?HJbmrSzSpWo%rm0}DD>NeM4LXBU}#M2p++!%kArnC&O6S)-N~%Jm~f zLeTQyDTV6T^x&{DQ#}t3eMps~4EPC+m(8~sA&Y?9=}2z;`GZ}T7~P704m{@!)Lf}a zl-An~tE>8B#c_x2?`FLVrh`}vpDbiz9Mm}L{XZaec@h^p4kI+k?0=19*o`hyxe4%U z0H@<1SU%?J=CNu?uDp=}`9q3Q$o?~o4{Zhet?zn1W_bm0IbI$b{5k^8=I?O5(1n_) zOSupa2#0<9W!?U0b=jwAfRD2x`F0y7<>2EK+G|nPSZL-cvYJPrsWUcLnI?J8$RevQ zY=iUHhS#r7YQwfSf0Tv=#QQ5=O$i9MKWS}3%0jQRjb9ER(GQvXrIq%+*Gl_%MJJK7 z3Qg2GPR!60Sk3)#HJh`l7W){Iqq(y<%9SHSg7-lu|0c3&DqW}MN^m2n9#IY@#@jB$ z?uONFPLRRa{DGysC?3Oh;8&(Xz*Ltn zFO68$wBSSABM~;l0;~?yV8X^}qm7SAzH1+B5ZBSP%wk`bwe=TNdx_`Q4hiM%3l!(W zDu-j=wYWl{IA?^cIo-^Aqg8g7neWRsujL5NMOu|ONY#^2aZY@$MLK?Zr~A-0z&8?{ z=eMdaZcV}bEFrA^eio(?YHjc9a|}A31ZS&Mf? zJ*=3MraZ(f^M%{vyd_EVJ=t~oXxs6|J}*di4lMorq8HEJ(~ED> zlBt;L6#i)aloia-GAWvI=5*+t$_8}hW{_U1o=jZSD7~`}R=%4D|1L%8gN@jzX z`GsAQhS!us$32vO(N2MD1bVGYL9eyKA@@XwJI_7_(-*=?3Iqf?dUNsaXvr$iJKMFl z+YbmN(znhjvGukjsDm>eAqc=M)HOk}(ATx^f{^;2KqtvXl=eO5JfB(+%mvFn!0oTH zk7T5k_kG!is4P5*QuAr^|E&5LmOLRmy9-ahZC5>gk&@sRhZHOy<0yV9pn7@A-CeQw z1~gkAK5Y1wG7^PbDn1GOVWJ`W>raj`Dev2jV=-0dWEAaaQ)tHW?%rAn>vR*Se*l>7 z#_KPFpLGiOA;bP@h!FtVKSGcSG)&$Oud-y!JT)D8ceK4xwY3sOo!%kg^r(AExVe&~)tpny%UBuIIL^y$OZ0_`H8Qgtqs> zI`X@1qH$Lf<9cwzt{Y*V0&b-_a)}KC%EL@>raMAFmeGh@n4my#+jfd1YSeAo zx%|xWH`REtH++GJ@AFHb-uIxH*>}JJCt%5!)JfReKk15qySD;Lgyx3Lg*QNn`SYl{ z5tUvPgKkeN`TKEH-V}lgI~siy?5Z7!6{2tx+3A%29F6ni+9PegGsRquL(NIiNabUdfI5wVn-)4aB6O?k#^}~gyp#N^frF# zt~sfvq&U2+O^$jk9CkKP2t+(gY?(>dUq*NgdI9`{ps?NK*zue&Ctxz}AMLxCb8EvCOjLd(zM z<{r^0fmS{Lq~M{6(j{*l?kUli_=+0r@29!tx2VptM<7jHv+2YFCiSlcP;Oj(Wei_x zds@WSkl2K%Q^m5ya-n^%FLh}njf0A#(9+QWL#I4JEq_A{z2dWGQ@)|MwA)wrYyq#El*2-hl) z&81-t3;M90i!TJ(tLVz2cYimz_XxRww(B>4+OE+^^JNs#sbyP!+paB3*}gLn8b7Fy zkZY$3HrfhjDV-dwg4B*4Mcq@4)9F!XPrUKfH!3&JvujLEbuc4beqfb6xZ(Yxe^luggsk~o17!ZbJ zulUfLv9b19M2JQt31QZE!gIhpxmjeubESMV>jUbp9qx74v_Ey%HwQ8=F$^Li#^adO zLEZHjsJq@;0Cm@b#VdE`<2C;x5-QW?dik8=AG+(|d)@U91t^vCz`Bj+OFo567*+$F z@|Yod6!*I81)`dYT^x>Qd+O<5-8!%1?n_v9f^T#zTB|~{%*lpLYD`XewOV?ge#4?h z$}MA}YFB3HjqRG7MohjaqhV;R)b#J(J)Be!&Ebq*rGIUOQ@D;Aho%DUN#bUuee7*QM>f8|6O-IiTqO} z#Px4PLbD~>a-vg3jjBT`E%&2KZjSF|05dy$X=&b{th z3)EfXvMQ^?`n+zk?Eif53^iL+EXM9@F)elSGZBsgfy5y;Sw0za$b+ZeI#}I;RNip& zP2pcWq>0VX{i-0VXVJ0-2#eOo$e)EO->Ty+asa|GIsJ( z1r=o;i}b!Zae20L05PC3#d$H!@dySM$$Q|s4V(xh+eU>Fk~Pq}#B}QGa@lbj!nn+@ zA{I{40YA6%;$J2q0#!4?oWjH7XFBZ)|1=5l5xlgWQ?<(xE%QHc_u9P7sE$C_27T9# z(@1ybbfE9L!0_I8eQw)u@4Jrp?Yr(i;lfqC_g$a;;k!;Ugd>*8s@@Pcd0}PJ7^lx; zBSh%DTHQTJn^9MrQj*Q9 zO#LOVEvTVA(I9nYK>eFZs0ePT$mEK;R*WP>$pSj1O&J<5$ z;_%$q1Xha5Kb;K}vSirRk~)2CZPI7tNiyrRvrArIBLB*dSX7MI81(&}!SFHF$jJo= zPkLEAE(4Vx#v@NqWpQ%C+FoXwnRe`q5G8Mr@1=(tui9h#iq|M~H=Re{9-TsU;&B;A zfQXqh;k$7cs}xTKwDy=pVJQ++RkI6U*Nj2v{n&;V+v%1UvN&w(#E*A*_oDTr)I7|7 zWE5i!UA&IQyepfBwPFupv{4d1+!+CmRrUA`J&v;`F1NaBNco$RGCWUI2Xkdsnz&WN zJRvn}#VwAZqFEyyGpe^sBSs34gRNUuK>hgUXy(%9c+4}*p#oaB{h0?r*!5-iN*8hW z5Wg6JeL?(0tfkXk-Q#XOVe3#PS{u^ev68R`I=D}nJ=mffP-C;x=!bCncr$@SCUoIz zgX@e`X~w}$s%w5??a-gcGF+>Dt*`jc$%XN@!PEBq1`~~5gag@xx+?Pg-2(A;&!WvI zDnnss^M7ATj@TEWta*Ds<~Su`Zh8-Ga@R{>4~ZYmJPVrEA0It>&CM2gF!`R)@HRX2 zhD&HYNJd4R^Vr+;!7#PVL0@yFK?RE-4>}L+U1x;Nh5P8NohG{cD=V|5%(N=U3Eo*k z%3ai@!G+Xd*|`p0Z4q~D2F~^U>ae!Py3E0gbmKua~4LG=g3mr>>Tqfx!zD0Peb|E7%jD&8gzJovj%FbZ@Z()7_#Nw;a-Kd zWrbBg`N|Ta$!q@nFq-snIE*6Opu#93M3`A4Z{rVzk=QtI&}<^ny~3z(*c`DsyZT9^ z6h5)3t?Av{mr*@CQ^KTv%s5-ev(5+He>#Ob50!t?l|d1mN_gEP1kbV9>nq4*z#m6c@#gjBD5j3@3>e1*(WhXuNqO?7Uv3Mn7IOR=9YTJd)%}zdtf~ z^=O9&ogX31m6-B!b>K;P&OKpSQIm~TdJb8=9KCT}pf!~*DOy0ijnqMVMRt3fihb!# z>r#O}DLu$oc6P9Hmw{oNNF#scOOGs5zdJ=-fLE>Fpg?=6i5O77HVU;z^pmlybuTX} z{3S2S1m#5!GDr{CGzgIrOZ3*^J~|-{%q9w#m6pjJL$NbRX0v3}j~>MGsUBzogTBA@2_!ld{xTTh3pEk{ZSrsGG)hOoeEuR$l%~%j z&R8Ns>)s+vw`}p+Q}Ff3O;HYlM=fau#~**;D%!lH^(2 z{H@OxdB63+3s0*2i5`^JwlyJquKs^D@FzEaw?^@){B18vkwH(GRoJpDF%Dk*f1dWw zpmsoe(X21bD-O!#!{?q1v&u%$z-|NMOxsE|xh0Y`O}hUC{6Q5B4#)d+b(<#Av5 z#u&7tAoyfdBpTE`rF6ElBa>aj6vz`C~; zF-SVIy)YSbnH>H&{V!AgPNU(KX7Gsu>_2TqfgUmcYAf>nWh?qp=jbqH4cW`m0iPO^!8 zFKi-rjf$97YF)^*jKBazHFeRpt)Q;3d-dx zSpBx~&`OZ=>45|Pq&z4H7Gor#QJ?>hLw1|^J#hV?V}#g6u{j7_0PRKV*F z53`#JbDtr_Rx{(yet7KBl#G~%sViM@6_w+8%2zToH=z~S#bzpg)Elsn5Ci?3`p89E zX*C02;NH0+&KfbYvv$A*9N7uXA&#uv#8T2#OH?hSAhdy+|88n4kNDOPlM66r|FH_% zjU37N0irvZdBOmqJA#YJtFr{{PlQ&{x5tKzxGg_n)I+E7=}xfiXEeU?`^)&&B$saK zQyZL7ruW5#ciu~H&2v(d(wKHC!d5wq@z*T_8u>HIBhB<|#~%-ag$3zD}MK0|C~1 zk0q|Fxgo!4s+p)asO-}du7NukZ?V+J^Al)VkJ(BWrCAh5O^O-H`WQ$`-kMKr(!0!l z`*?R$sYfY<4`myM+Jtcn^OfoE%Qu4CPl&cD*&ln76HTGts>*v~D`z8FS$^9R+CQFT zt3l~J(Qa{tq3&mNhlhqItTxYoaZ&h!kP62lh3|XWYo#HI;f~Q#cZWa6`;4Z4jQ4JY z+3MMI37KYw`7Z8e@*r%@c(17*RKXA`+mHCKiPW_ zSdBCe?JHH0>nYpmHZ7M`)5&uDA z@fiu(9Xa>{f4u4NeOXUa@FeVq0#7{*!rM*^^}zNA8%Pdc@7{W8*p#v0j{V_JnvzBC zo~P3#b99BDXV?y3U+@x3SAW%pXusa2)B&=_b3=vnV&KiH`Tk7uZ;d9{=&l7+E#Fe_ zXBPa_Cpnnt5Ngo2#m*A5s&{)Y2fv&*6;Mwh<@RdOI{~%C4i1^IhF?n6s2D~5%1eFw zw4tLtn4r=XBaLx)^0%)a!*<8>=I2hoCQzDnbEzHAedYK{N}^B6jrN*0zs2w7(RC1m zMn>W6ZSbJr=lUC5%c;J`MPa0R!eYcYhKF~fB#$r(pFjt8U714j6~o_Fb@kT-c9@DR zVx`K;p1BPq`A~ZFlCU(3K3~LsNW&(@Z zZdrq`B)&2w#CI1aiR?Sf*R%-y=jnA--FSl&u0I6pdcv9ul)6jT4pbvbMXe@B zVltkGXc?YFml@~fJUxg!^sYSIwvBv07xw@`NykK>BV|M`rBgTH+$Oy34(bDBD+;{8 z(%U#Y_G9HifKZ%n_ zsu=kwF|+wLL773+64F5$0}gXf;m!3`2Rlxj_N7d}D9-6Y-q^d~_Xn5MppFR@OO6|eVUNNv1TNRaI8_q|2dKhD-tj{a7YCgnP zLbFu&rSa`4mh`H{hsz!@Xi5k_?@LoyP%K;09~C_pm`~+mXaI%SzCG`J$VRu|j!MBu z9;K}OUl*=n`w#wvU>g5R*XytRSVd$%z+3TQ@w?wa|KGCh`yl^kn*Z;XSAS&NAGoxf zMA~7-tKGGpDVgZz;NCBj-k0Yt(x%~hQ*mM+ir2YD@@sueoqnr`pWLg6=Rg(l%;b`E z8R|S4!5NZ*og9Oxw527zs7QRw=f_>el3$F*H*p>aYR(buHarK}RNh)OPfY`bGNNWf zt8Kt^{$CeU-$jGs>aS|t+1mFnFql_)Zo>t}_Vh%*PXS~hpKgEutNje}|KoB^{aaS8 z6T?dH-Zsj0ztnK4{GlVZM8IlA7E`FB9*35WNj?gpp#6(SPtW7Hg z-*1b0jO&#NU17j+K^g11&q3wj>$ELVIVZ+5yB;B-qn$(<+{;bH_R~jP#a(An2puXP zqU=zm&ZRv}b1a|YdquP4EKK|3cEft}yR>ztoX_U*V!l6kRWn&6AX$UGFH-iHK}4m> zMI(JK<=Xb?k`OeP0-q6)ZM*8*FPGh#6pi6-`I7fO;&?x`oHNzr~ z3UnV;u)*;AM;<#y4&Kk##S6J<1eM9uP(s9Sk^|Txde;{uG0>mq=r+?UvjK5h%@h)4g>5j$QAxEH!S z_rCtEBX+V)L$aI^7aDdCC1;cq7IF-=u69U2>x#R-nsL;AF^*Ue)PfIf^T7vOF%>9S-6)6Xi1Eb2T=NrzLq1~CE59iCS{Cf$ z>WLI_c$Bm!_!m>P&kIfLzjfl6X(T-~oJ&<~P#q8KR6gzs+4hsf!x-KjrGPzx-yrjv zpyt{MuP5U&Byl9+6RTRt_A?uI6g|~XwCeMae32nf zS7?lj)24++n-NM>42@_ocxlIKyh7|Hd6;SW<4c4aV!aPNma>~$zeyP)Bj_WZU*FXU zfjgE&Qm0m3y#@Y2A?1vqA9BQ}=1quAZ8E;&(-vtbpzySOdQ1%>j&xI^R2^K|UHXZF zhdCJYF(1raSkz5ZWDetkKH|^!K4RIQKH?=$&slF~SGVj^x0zQ4=;@H)1Hl8(NBr;| z&OuFHvp5oEQ4jf-6gW$hdTY$ z1FV5z=Gg!X>}jYDhSZHyeiwA1Tk{(F`UKG$(WXmA0}6Nznw32P+yhs*=OYriM-D1~ z`iN_kpuS0VnbfnyPy$Lt+Gguk?W<;ql93_hEt3{^!L0RK>jN&G*Y`f+V!Yoz;ve@u z;}KOOw?5hFRMSU#Ed0X`Mp`-m?}+x(#*73PBY;MWM7g|g|M|@Jhfx)E>KSQSf*&i}VR(%4M=F@f z+jdwUb<{$rg`g11MPB&NU-rB6Um88PW7&TusDT>C^j0Z(NSMbX1xY zFP&o$Voke2lf4le{znJSQ`2)Hp$IyNriY+IC5)QGqW8U(s zCG@LNgtOg*Wg-qrSN$CeW>k9>*I7xQbd1M$XlIk3YEvah2JT$A(59MI5%Q@%Wl^-; ze1jl?G2C&G6w@S@ws}2mYFxf&I6JfDUvZ8qtVQLdrP#+mqh$)lbZ@l>aDpykeHr)f7X_^DEi8|$9rT$Y6bSbP9Qg6oiUSyTbQ>pEkE)nv_ zr4v@iTCP3|8;D61KS+elds+Tx${r%|x0L-k%6-ay!v0sv4ki36Wk1MKzE9aLmFZ8j z{s`GWWB)y54>qy=KM&c({qI9|=bZl-vU@@Q8M2>lfFXOxtO6La3)Ry93fVtc@catd zt(IT37Q6W6KSJgF6|z6x07G_Bsb3+xV9>9S-JJA3WG5;^Z(aT)WM93o{}r-7pMBFl zTcA%t{VQY-d-*G5hYdse=O`YyQMWegCH3z^_8@@duaI5W5ozGs>W`3}_xb;2$i9k9 z@{f=m%I=Sl-66zSCiu^g-BI_fAdA~yxggzTipj6xiGzqTmCQ`6Sw{3a#dj%=S&CsAX|v%owtFI z&+?l~4N3S6dGj`h|81jQ4;3HlGzYsw1{s#jdw*W*pZLrv{fC4%JNsnvoj4ynAswf; zWDNpO#cKtuHs2$J8(Nf}``ezSyU6aJovRMK6!PUe38@D!8(>O<9*JtxyuGG~g} zF&Yvn4N3+dBKH@~4>~&Vxg@EL_{7!QX(n6cJO6^sv|qHlVeD`&gl?ZBupB&mt&Ed| z`6c0tP@Aw&sP_XLgg@)w-jEN1ci3<1V)0!W8e-iUIqIi=_UKuRXE`~r!a-OjtlTsC zvU$p)a|H5ml`hYpu*|3g)ttjz$$aK1sQti!nFe@tWLs4+v}ocmoP9Nw8Y6*qkEfnE zJY*;+UNZ!Fk&0~~RI)g_bUDPJ5qD_PO?OQ2ART68oZ4PvIu)9+yK`+}zsrSoXXmK9 z16DtTkQTveVxqOQ$W~nGCqew!A)Wc3v$>8kGZOac~*<3aj6hd7-}!z5eYy|L2VV*SkcjxBL`UV(I6ysIyJD%9x0=XEaXiw4B|XM@yQf z>S`W^JhX>X-ig?uOcpONk9X{f0Mi=5Ktp(i36j~Q{KP3ZXx#T_Dc&ZdzeN86k$&{p zXRjxGL4Bqor7h55Q`_-y0nea(ru5t4PY7x`Gn+Pr7&q?62l}3(WnII_0ZJ|?C4Tgk zRP^{NdZG21hVm5gXgOxO?;PA;r?$zVBD==a#rA2Da6$U=ntNkWn)_Z*uWz_Uv1Ofz?z0oBxnVPdEtnISKiiCQ+S*SY}TK(6PWVX@tRa%9Xeq5 zk2+2?$-HW(Qbh>JOB_GB=pks0NGTjwh$e3|$*5-Hh$$K|xA0@84LfFF;_HX3&}$uvl;t%<7ggN88A9 z+r%u$KS=@GS}!~NMUm3`T(fXVFfxsB@l9+-=!IVO)I8E1_+jmAVfkYy$qgNTUiRvv zbw8%z>zNe_$FRc}$C<20FRxdpm}+KQX3Vx=^-DE5eSXw9DeJ(~C(tAdM#x>3 zWX-^@#8MP&e1y~VHe#SB-!ml`fUp9M?%@w;ZViU_F#NJZ#w4<@)nO6>ngxN6OW$G73VwWt`;mZ8 z{}nN+e0jkBX`?;`s^A4naIG*V>HCQq784l#i1rb3o}6oxaG zOo&Eux=Ph=@S`%BTfaSPN$}yuY2+{zsV?+GKattjd~e?uCVv?qY`w8_Tf-K`UG9`D zpH#9W$Dy|i(3{s@UCA$;Kq6Ewx7KG(HMdpTGms-C(C#5+%Ee~Y6cDJAhov*^!*MW>qY`o6L3iD*Y#1 zUSJPiV1Dev;iOk2hF!qwb!jsWro`dK{cR<`=7KrCW8y93N^y;&BbK5jl3KmEKfuIn zRAy;X2#hUc3&QT{){#0^1LZaA2xp|?#*-5ClRk^bhQ1~YpjTP(ITucm50 zijwIqHLSmj@Oz~mIZ`(^sJ-Ja1Roat)XEdMGe-Qjdk+rOM@iy!x~6MJY`Vd7W)4zp zTuQ&jZEknYPTE<)jq)J-O3w0(`0{MW05MOraDm(bU7Qw&IVZvzjbIdYUM3A0st>|E zMYO3ddj=0vI^#t9F3L=p5xF)yWtB6&>C@93_dd2nu$FGa6pQS~D2~*R>J)GN85K@# zPszNwYe#uTSY?Is>*-mJ-({gl(y@<)7VpEOnuW?OErJDtvz3O#ut!#zK{c?djR%;^ z!c0k>bGlJXT1087s+BuSYS0hZh>Ny6C8KZEOFSy#+At%kbXf%%zd_U*&ggo#T;jNP z@OP$a)`F|F;7Uwm9%4ikYGdO4hzfZ_Ro=Dsq1pB^%-1Y!s%hOOe;pJ^NFmIk@bzMd zY~j+Y4HHuV=7Ni`q;uXJ@9{08XvRdvbz!!g{HHh%PMi^XHwy+xzfxjI!f@wPMtU_& zjZ6*|WDTTSO?ozJVfFU6zLeJTj(?zXf{lJ?#*q(WN5L5Fbn$3-ww@wu|Ks=Jj@mpp zYYv5GaaCh2CjGmGrHA?@uMisoUg?lMDOdG$8Y1;FR&m*S8`Jmw@_{7RRBZ0Mnxk9> zpu%D+iVqEOKp~cIP(($e8 z!zT1YBB1L0=sO+N^9!_>nJwn8ozU=hlEIIYup;zjv253?JoXVaz1sfh31Q%V`2^aZ z8lK+4KBKskU#w;C;P1h59PpArGra^LzVmS7ta}b@w}>`Od&$z0|BpVp?|e*__UcpU z7Ra%L8`WQRbobL<3qFY6X~v;B$U;VQ$l)~GE!pzNPu%i!vsU{A^o?xej{=J$o^>}8 zxFqKB>A}K|vZpeQI^Y5VttII4G~-lm|=2N*8({vMVca#L6yS~a(1wLe^oP_6PH`KqDEndlQgit zFILv&X5`G%W))df$A_CC9&0BvL=iQxX|2W_aC!Li^Ns`Kvw-g^zow)p-ui7z2{3imkRZ94&FXgz?|a0+=Z?&bOX+*7g1X2f-5% z8$1W#_-M^RJ{Pr5q0SMGSPB=I7r^c!-l0bT%_6{RUkC_)^tz{Q5MEE!-MO|yfj7vw z_(&r}#Q{qMph#wlpFAwHiZtzRYgf#W|HaM7OoaG*O9ErFLji9Dn_^=lC<+)$dh(A* za5@i{MecwXHB0-uzz8R#>uQVW2xRy`#Abk%%9g6K;VfILbYgU^Qebs^y~DW<`~i}7 z&(k2kf_-yS=EH0@+E~n^Z7Bl|U+%h@qgcsZ_ zMRo;gDWN2Vcc}$^8%J{B?fvzKd_(Wy%WQV}C(PJ33`fqq5k{gfC#tm>dA$VkxKbKZ zu8?bRw%10kQ5}6=?(=+Z;B_}QY8xn|3J_@hms|Xzjg!6aN&yBWTV4#IeD7RHOG1nd zkT=#R9}4U@KmuRE_`@N8Xu`r8Sb!!uD}dp`h(#$(=T-6EwBpPQ8DFqry=R|em^~ws zyantOI)UU+X^>ofa01j~+(uP)1a6^82|h0;R%K~ePn0kq`RvZ)a)uWPZHV>d**X6c zKg=2P7*>!4a)&0hgeUcY)Qe%974k!Ai0hPBjgmHTP3l&8r`)+43vs&z!fjJvch8Hx zu6>HFS4Z~qdVH(p5TUEbspoc!M?1OCkx{JUKoT}GbiZ@6I@X8_+oDF^r1uK{2m`Jk z1A-kC;U#c&rOyqye>4FHx*MLHgRP<5PIk~NsMur7e_yKP2n>olCN!n+HYiUOh_TJq zs&q2>>2ACV@zR>_P;RN=uHJ?m_Kfv4agW%5V+PjIe1aOXKGW%j*29VFvxEv%La`&! zXIihGEg_hWsf#;kn0~oQ*{l5mkPotiq8c3oX<^cRM;UCpK*Pm3Y44-hXE&7cp)7e) z-+j>9r68mzLkY`3LKegA)L516DbOShXg8=?p3;6&#%v1SQWP#DcflDc3epn!3^fA7 zBU$#YE80!s;#~~+ts6;kyQgB@WqpxP^~I(9ci@8{lvX<^f~=oE2eJUer91w7l20y| zx#MjP`fu!AR{FZgupk*vhc9GzMtpz>Mlt={z>|NVJgzwfX zgF#}6up?r~k(G{&I=eunKj7ft5PX*bKKv7IfvO9G0rOR(xx?Gon%m~U!BAXvh{@o* z!|3V{yA^5&7qna(sF+BF1Vs6J#~t_}NA#tr!kwG)cbAMz4Q7*Dw{; zZb+QTO{fVp8P22DIJBH$(J*0{5y~#`hU{`gW#qdVHJy#>A|W|8(>Wzg0`xs63jrv- zyDAdhR)Wq9$8qz9u+Dj@3tOAL$nr(R_>T(^)FwzGYGG%Pm z`?q`^*GRvuAEZ7Jqu3H>4O`IdYym-b0it^=2fX9WxxZ&@fsQ`_U3{Q zE>|PrLlSRd?IR}bV$9t6Q^zc@dg}Bi!+IbLdMgU}Uv7GPULR&qV+x1tGG1W7>UDSy zao&2ip)B(Uh69qsl5#1{iVg`KZE~50f&BQQQLhkUO|NF%DL-;REZYE2KJcU9qV`a` zRq2kycrOl8&2<4M!{UwJuFZ@?T9C~auR~01=li7>=*iv%@Kb#u=xLB`37Ue+u@>Kq z>0!bm=9iCrn#TB%zHuA(!Gqubt(*dG`G@(p0NE|T`^V(kk|;!5au`58MZaD`z1tLx zoDG3^^3iVs5eU90?VcojcdmkKWAl6049I4g=q>x*_>HfgIviF~O_uvQuKQj4({G_^ z*Z3+wlkxkU7E(AT8yzNwjpj@Tv(faz1+QqKtw)12AsRJ(DR=X*E>NIp&qC??LHI2( zah+egZ@X(17P|4va=;-N09m)+G0rk{06~IR1m7UOEKoP-VrVz$Sm5iN)Z_deaJCI_ z&3M9&dwSg=8b2_8aD(0nwaZV$y~{7oBhEAJdAU6e$x>sDxQaF9)K~Bzi^A=tfL^Ua zm&uZj7BD}{2n1~+86XwyYzch0-A+a-(Bf$;z-fE3QC6m)}<4mUaWkQzpH&UG+5H97@L!PzUm=dTK+#@0YE>$;J^sQ*`w4Dh3vD zuMetz=YY;2+^c3|Vi2q8DS*l>Ue7FE7`v1DP3?(2*-$Kxw~9}gS6DV{izEueGFLfH zBwe@t!P^!Bd01}?U0i4yQ(qvPW;OjVzBaElOHYMn^K(Exe7wM@j7$H$6@JwAPEwfrTYQoe0Bq{*+BBSp~M*x z;{-((pqkv*@B=#Qb5Py!G1FW;@EN4ueia4qPCa>@u1GO*S^`<o@1fxw==s{ESAMS=<&LL7ExqllpN)bOnJ4c{1N zQ4*^(%sF0M6E_LY0u3WJ*4_X=pYjQ>^)% z56OI_bC(SuKf2i1tnui&py5>hU>erE?0seNuHj71Cgo#?H&p8+<}Ty#o`4WpV^`Uc z*%ba0JndUDVF4uVRX!inXFfW2M5wSS0Rn01!u>Tbby1dV>v5^$_<;9fam6cLq}xTo zVulALJODh>KtJ{O=cLik!}PEW(c-k-smYbt6Fm%WD?G+$H@LDN;Jg*G>x(TXiyqHO z*T+h-j%#~mKQ%Ls^u^jqQ)+2y??JN@-LZK1Ua80zg)Y~qgZH;woz>QW>}x!Gc)8bj zadzM$7XGF_(kar(vLM=`Xezhcj5mWq_i>{Z5}$sRk^aZIxRZd~c7XP)<$JWzzTFQS zZkE#Dn5_FIhMOPji&LuJf;YJ}v+?cS390mNUpw{aHf+6>5oSG>9nx~NXxaO)TU=dY zSP^-37@K|^cuEGGEXAx%9TA$8?-5;I8d4d#yBx=AXT;=v?-?THj~E8Vo?^BC0G@Su zhIh6-x#hPU1!5LSoxGRfeefP=k^N%>^Av#zqfy`x<|_&u69y#-CCRT}PUD*dESGv> zG8Y2B|JZH^P6X^%g>_N@L)KcFO+X`6Y8h}2fn=;fTJK;l(C7KBfPZdUnQJ|lA>KEL z`=qs=WJhF2zki{GZZZaBg98r6con__&N}mkZj(~@&qDx1JC^5kn?NYF(50s=P~sEp z@!FD_N8k|ZD=Zv3#;?tIH*BoH|6=SdpyJrNZDCx3LvVsyAV_dcC7CtZwKi(=fG)^rVTtu6MUD39gQCJa=5r1bXJBlcS51iO zywUbg^B|^%kaS45V91U{uk7&qfv?q_0Bt;K_>^BOvs1w*HilEBAHtWZ5}9~^e*$)HSj^J8|XprL@I%ht$)!eYEhT4JZM)O{r*1iYew<%uSM;}Y9bJG@u2o_7FJ^%Jv>!Z@1$jv*b}05O z!zGAH=m1DR1m@aJhx~!}7m%Tlb=5uugK;=g?zsct74RGs9DyOpc|Z=Idak-ge$ zV%0sgX!gtY_?5>TzZ!==+C~Z28|F&JEhe-ZJ))NDGK_Ar+_#VBnS`2b+)@D%&IH@| znOtJlE2*f&7~A-%g7**U!%(70N2SVhl)xxC&n- zNAj6O>Z^AwzrI{{agu>2os!}g2r~?7%NtuFVgwMZO&h^1)!nC_7_FCA8^G31CM|AX>fm8F=JV-sR83i1My8 zpq4=}pcU)~paA|N_>NT9M6D<*ATgH}P?X2t!>EYB>kJKOhBb_za;>~FwG8We_-58j zs~wD$eRl7zn*f3kuH_xF?BPc3PfdQWO0^B%^|G-;6c&i4p5I9~?ZE8~!`V=Cu72Rv3RK5&FNo#K+9q`*vmK*!)MQul<&e zD4UJn2%$JeVpjStLz+vtMAEUL*kY6gQIJyhg27WJfl;Hnyz=_ah$u{zp z@-*afTY1?W>F)u2E}UzZsQFx++vUb5;UUdR72 z#-yC0YMBe<4Vp+~0byadr(hW%6M&C{$KOI0M*z#O2+d6J2>2M3`lNs_+wYm6v?-Hm zS~5BXjN$vx@Bkbsb4#)VoUB9qfS)m2Qy*M09V}1GQA%ee0i-7(vJI@S5|*+pN%N4B|xeh5k0?vgk(c8u8&Dh!14knFhV#(?RHAF*de#neV+D%4ncCbUz_fF!WP`ebl-|wEr>9U?A z$WgG!`ASv-KNRMgnQX!U@kTN3S+I3>nBWW;`#tbTq^Fd^YsmQ-xzwHdCxX{wU}gze zo8J>8Y^24=-?KRzGTZ(lZn3{u+t&QCG#khMGf2( zD$qO0D zo6mhz;}x&MN;JgT($P7G*DgH{#;(qo8wD%{WbY_< z7|tPJ7{g&GA{xMkVZ)EVAv$sxIztX1R2iReF$`hgbfis$W^8X~z zTX3}UPl-4$9d9(7sgH0XF{vQ>IJ#8PIJ%XS5tQWsL;{`m zl{m@|j4vDWxF6P{WaPR2wM~QdMmSl{ert-j=uz{>v)9z&*M~Dz+QVse1+OB z-CalIXL(Erq!ispV1`m!MB4!6ZMHinsibg!;E@=Dl2p33OOXWNCNkln4*J%g5BoS8 zQINCYKmHLH)Y@E=30}RDKR>z#EIpp_jshRNeNkbcei%_iF+Wbm&ug@Ae3}31^|4|3$}B0DRr@=0P3pI&bmw{5b`lP_y0w|M+v_jbZ5 ztCID%Mc{li^OsrX+jcw_dize_hfyJZQ>WXWo>NFoRD-qK7%p8tr?ID7a1(9TA~EmN z>G4g4M~Ax6<{X-igXNWWzk;#%3&cD?pfXM8JVDozKhfQn3~H5 z?p|m=&mo-AFn>~?Wu;G&>RWXQLEJy)*?8CuUgq}xNj6fB(}nTc+Df|gZhdB%h*ATi zU!4ef1=Re`t&xd)E13RG_+XvlO+sAz+tThoTcW(_H)V2WklMN9x$F~MV;n^EZC3Av z%(~kaiwO4XRK)YByt}qOS4XEP&1OH9J@8Kjx*TF z{_W4}CmO3-_N4R6!jjwL99yIF!a$Yr60PM)pRIE#1Kf9p07y4UUw2?L3OuS}t)954lYXPLw`HWiZwzdN#2w)EPLKAaLG zcQEwfZ+iZsL-1YYXYLJduhS|O44=i2yXGSdL%HN2$P0?5{E8ZTvK@+#jSLNo%$&A2 zQ+cL!s??zG*{^kf0wqmb^_m@cIEFb(BZt*;DR5Eh_(Mq8nd=TX1c}y|PnlSxKKNc! ze62ON9(=ZH$M=+qt^0TV(KjOA5?HSyacrcas{05~0dnTP>UEh8Yi zyW$*qg8!PPv`N@@vov2AEUngfn1o;`U_VThafLCYN4x~6kr7T1Khi{eN16zNdlM0a zNRVBZ7Weyg31m6|k;>aU0C?vdxE#l6aJ5Mw!z29i?%g@)T_xf{>&y<|E%kUD=)yP$ zV_Y+|AK9bt_|iw*Meagasvu9{kzYDivEZgJWB}yIbRX%0E%Q!NS9$TD zX9O8tyhfkW0xsTqBhfc08d(HG3w*f>S$^S#wj`}+i2U|whKqp@D zzjxBTMHM1C?(?S=x&vh=g!h}<3*zoye$ZaKx4by(;luBRjE%agCML!w(Di<8A^GEQ z^B*j3?(;!iEbSf3#SA^4f08NFuH{Uy+J)vS(uJmq(@58~_NTG4bWE4Q!0}&a=ob!+=7w55}%40b- z60L7hT0#GNXrjClyCrq$4m8YqK!#p54J;y!FGD7{vC$?_3wFbq;X;SThPZhhGl|_JET={;aR2!egs2ls-dO1;NLG0J~GBn~Y~D zW_a+(;qF#&WHe%wgTQBO^T!1pTG%K+8!0LUu)j03_l?IY(F$oQ5ZxWYWT#XvUSL$Ap&{k_ol=z(1 zV{d*)Np{LhoP!ZCx+tk9AVSpyvX_PN1RgfC4|W!aS_|Gqb7Z>51si-CK!)`nD?>wE z;a36-21kc6Z%N9_X>T&RQI1UzM8hH*fcPGSuHo*A`SD{AA;k<+e)03;Ksk)y9Um@* z&6VMEt2ExEPWDeS1q|Qs62$-15COw0-YzxKfGgoGV0;4}kSlv2c7*ZfsE)Dy>21EE z3L@MlSu*)Y{#UK#&Jgd|8f)CC>*FvNcu&~bD)kO90tNM@JCv42sZ?Z2xOz39q3w3s zYswqWu)t#(Q)8WxesUs0Ij|1}nt|L80p=qB`lKZuTP7wvBMTvjbd|v}=b*W#a25m^ za#1|aKx7o8YOjG;+ViF3QEIoq_{y|!hT#!le`FW*5W#U7GbK9z8PhiBySoA~oZ3%* zxBQ&&YXpKLa8(I#U%AgVSk}G!B$*&v>Dg17HLJXiucGtoTK*X^xQ%qV83|G;Sh~lf zzMlj*HW9~^flMcmS~)<3OVO}4Fb}}ETmFV8ln0EOiTMFU_lqRbu-LD4;9c8ej;tv9f3el>6g}iJxw%0grZk4|l*WPyOIT_XfldG0v4H9glGP3AACs zBZr2}fk*W4K;I>LxOQRUSloV9y*q;uQXF2Ha=Rnq`nV0_6d2hAJnyhKN11IC1)l_v zO^!0&T+eS}-m%pCU1;_M^m8Or*%fEeqBf9Z{-Qm%NwmZH^H9t$ zhN~VQzIn#%@lAHwoPi`yoP;@Ie4V*63_To?xwDZxB8$tPdCzx4l{;=G7{e$61bJxe zK0klI(u#Q?-AbGG$=7(#!(Qr$Hf^qmqV~rHsFu_wx@fw#|C7rZdJ)7p{8p!Cy%ZVv z`*2ll&whj%#1ozoR*^TK@gqM`R*aW?HjqW`{nX?};v%+7ZQkC$@8wm{^= z>iD`?l1Hwx3p-TnSSV>!-qeQW9ZU!jRwO96$lLCmsX^rn# zuS_ph=MJ})0Yn9Q@!?I!SDGNF-S z^Rxd;(rCXrwl82ZF;64+s1k#B*a*X_xKTff>71KjL`3hSi1A_faDa*}mpay?OHfORO;bo6))GbaG!Mj~y2@c~&M6fy*LAaANAH*2t2vk6&9s_Q|HqZ-5*(}Va<->m zd|nGxqg~xzKOXb27_dngKss&VHcW@C-3XzMKYg`}C)YFvWwOmShNOz#=0OvzGybWb zEPD1_b560wmG*-6+2h&jMERE#*zM0boH`yxah}cPAj>**fq`8i_APxftYpmwXIG0@rh)f$`7W=v9pp+oe$*4{4fE6tzN&S1 zzp6+7v&n8r8wbq}t0Eo83eNo<=OiSH@1)q;q>rbchR5TKP;y= zXZ&cb*Lb}OthBbOOFMNy>~m1J@nm)M#a6OQ%w^p#f30|)?ls$b=!I8>QU7W5++Z}3 zZ)1lgo`B%IP~^GrjrHL=yfHoqXDQe)yQGm5)IPVLB}F=tQg&F{8*T%8@kfC~Ez340 z`!g~d25KS6btTn4PL>-GnJR@bQCnh)r)o8EbUTc-^yawEQ#YCS&jY3QM-E%&yO$fv z4XO2YyBDI5jZbxD;wt8IW^J-AH_L3jS@7<1d__Fx@2NI1D@y(#7ZqeGg4&t@;NlcgVUu5G<^}*H%g}< zQtte9)+pFr1nsTWwLLer!Lx{i^QUym9+vzK-kzY3~Th50iJcw2 z<8%JDarY_b5{%o}HUAogyR~h1blSP6ZOlBc*KoK4%hw>h+q+){DK4fr3QNdmkZ~)T zUzDo#3U6pCEB=CKeXdIbrY8=)NSCJe7B?B6gvgh_9k^|c(EhL=)oQ_lXxyNb+yw;0 zL??j8IuwN0%JuO)1=VE#SHYAbVkT@|(4Yzs`fzUu+Y$qm1BRdY5EEUm*E}zsYks=DpWr)6ws^u2}c)rQEU>%j_v>PziIuai5 z00fl~_u)%FTvReNc8nJ|i6e&HK_f@U@Lg~y+d>kS%_yL>ZT$}JBtV}HFr0%>T2cH^ zQP5GIsi9npowH}(O@Ac8dTRbD8};=Du;K0U>IV=DrQQJi2f!GAn9V3qD{&GPTc4m= zk?`Yfx_HNYq!!pCO&1FbzCa_k|NL%?QFiq-ojGi>8Neg%2OB1P@PGysJIEj$DQ;Rvnc( z9S2Nyj~ViQu9mqK@p+3X{At2*MD&+SiXPZ)W!X-H6~2ZMO=)82ebmF$<39S~{a*C~ z_*U8ed5w~FnrVW>5=>-XVQQJT+lT9FGW(eE5@{&XSM+RDXU1(me@i+G(^kA(f3D); z0MMVu8UcOccUhgg8TZWPTqf0{pev(Tx zpaqwp|Ap8^9O?W8d-XU+azTZ;Rh7%Q|1xCyYNccmZh)3x&5Kq!xyap142#FIq2-oH zDRo=7mdz20XAk`D0(uy3?;6l`TJ~MZ+2-i&!qI@<^8xl!cz69v3H|hGP`S%10{e#f z>DD*{&9vAPfw43DJSnI`Zp6tM)ZI2S_FdJ$yLYL!N>x#Aq!!?0^R12f_a5F6zos)y zs(3<~TlnSdNDpK*nn{X<$q#G$O2sWx$(8e!Q|L7gc?g3NG?uqg(^CpK@R8)kz;&;Cnm%Yyk zPQ`*i@7P{_SB?1I<+eD6xW%3w(;;mXq<1A^x zTS*Ep(hMfo0^u3hx_|nLEZG@HP=|)-JH9^qlWw$9XQ^ua!q%4ERb5>5Io*^@VLych zv})lRCT+m`?ncF@p*?WKF0vCzfaPseYb;H>w!>5zAc4T=+SUHA6M%lOR0#y4;pk3qB^_RZ^i zy0#7c+*GA~L|yZ-47)B{YkAYlIZB}n|27Qv4MPcsuQv%Rf_235W8P<@^6ZM}q3)9m zj+spl*L|w!!LtBY4bByFF2%&5B$Fw+YxnfL`Pr2_F>JE$;cnPYJK4CbdT}HY+&FLp zdX(Vn)(cQagsda?pu--1cMKDNycs`QAPH4?s~H9s3qO^An+!Sezkb5OSNfk9{J#xJ z;^8Z$U1pa8OV0b&`3NHGK=a3lGjAycAVfYAklOq0`@1k4xg9X;!t~CgmwXSrJqBFd zOpXB3N(|%7)VYH1l>7Tz`ABAIUcn zcKX~@R%Z9b(}Nq>IB8qjhik1%l$Q2a%;Ls9-V zgq)lGF0FTC(teFdApYWWX4@l)3rL0ry!ajb2U2-A#Dp(#?t(j^hKcu5vSDUGd%2BZ zocW=DVl%?3iFT7NqJ2djq9ST%9XibL{m4L8Cra|Z)y+gnO8$_Pte6rY3$e!%#MZ%e zMt2SjBM`*4BJ3q|!FIu>q;=VLOcbxCAP%ik*-Kq`b<-*3x%(LkUJO&-Bb8R5;!}pj zqbYAVV27-Rb_L`xsli*mp5`Y&0uYP~#mT+@}W;UDe+%_2g@fKgy)g`;A{tJ$xegu6Sx&)9Ru*mReI% ztVl}YPJzM;U{|a2!@WKtpf%U% zRcqda*ToAT8o8|VG(k7nWbznRcqYFS>wt z9CS5vbosG5i$HXso9(y?M;4N4I&w@hRyM}mWKkJIGsdaOW~LdZo%=r5BeSOyCoc#y zI_Jt&baM8{_ZYeiaY%?WKi5`9R>NX);wUUN`4zw>NosA zV9@80@Ssl|yH)~;PRcQ>D|ZQK(bPs8rWGqfLR@3hmA_W@^CICS%7`H}xY6v*Vd2I5 z8spo~%@L$8MC*PYoY?Kuau)EFuI(1JbDan87B>OcFl}{tKN{$`NPKjE?F<`5fzc1P z#n6^s!&iJUFCZqkrMqFWucH`=hihY`%l(FSl##I$F`Y^#bC1WAa!YDYg)g@?-aKG) z>#z$_zpYZb>;!*u>r#@65V{=(YkKEN^^x|GEvJ7BXo8Jl^E`WqHDYQqxDUE9LQD>4 zd|p1nrueN((=msDNq&ukzWX+OtUgS1hG$v)fkAB*&dY2l8Q$c3E6Nn}Woc93a*Lgg z1fm1kl9o=kA?=Gb#fUWBK8-mx7zk!Ret9mqKKeYXOE=VPhpbxo7xCaufqHhdCufpp z`s*M@Sgduo5A+w8rfbd14K=Nd)0zIdfjz0ohn@3OPLU^ldgLi2r3q76^YYIb(4!?D z_-mqMpgcqMM!QxhyAB$AwmzjN_iWwtM#BE-Cqh#n=&@~=ua_Wghae%=&#Ctj%cZt| zNcOKIGs5di9&BuCFf~z=as8P@zx(~{)@>-T-DoI1u&G+T(;lv*swKDSn+J|(W)%!U ztMo3`n}+i9#e9<#LrZ8`P@Oo*yyBD^bB7iAFrF$!okjAb7((hMXY#rJnF-`n(k( z?1xtsRmH>Mpt&dPG~G05tZn3^Wl{P=)W(PMaccrnE(B;|9Phq+!eA`4nNJNvrcYg%ByL8Pdu|WUecT1 z3AwzfXfv-zA;LWs`vPVPlcy;jIx%gNklkT9$?do14D%U74U1cqzmaTj3nm72vQm3^ zlTgJ^RG+vRj_LdYp7jgH))-$a0F!tTm3UUe6kS?Wj~r=du~a z9bzQD(a)q$SntvsFOM8@^b=v3#;SKgE&_Zzl=Utr!GVo!l!T^Y|6JT_`d{t8ee8eS z3H`4bfpLN>^jLn6R%WtY$V|~(Xk9(rGjVKrDO=v8{dzVYS-p_c-KW_h6x}FDN{XkW1D>!|yYdhR^WzgffLgr6?{G9+7^?;45c~SQ3N?# zvsR-w8DQv;8qT{)q;-24H~y-|@N#|YDo)yS{b>NR`tE15u-WuqHgzdDh7?dcc`a{w z!DuH#Z}T!JRE^61m#wciE}L$@i^3#1Z*mvtVqKkQT1o8F1FyL~v6$vty5q9Fhv4~& zpb}J^=zb8U@Fm=Nb$nnq&Nd;u#YW2k51eAw+lK zbX$CLxt=+>@G%RKe_+s%sFjb)xQ;YcK4ICy(0gOBEX%N_BmUNZh0Bl`u#!AEvbgC* zUd0sjq=g1Y_qFA8#&Wy04+oMbr#I{COF`DB`mDRc+L2gh-#w7AG6>%C8xv(|JuKfI z@Ezi?`&u-Cz{hgI9WTmWtJkXj?^s!%4UIUx(VP3?a?-B;k`*oUyfxSA{VN->44US& zR9mK$rw44>JFMqOkv5--xpc#ag|ixH5;M#8-V)15t~ZN?{EA21c2(tGGrvm>W-GqN zJXU+F_ETp2zPj8*OHm&4)99LUmbQ7l&M3a*dAk$cATO83=Wo=6Y84z@hInDGZyO@$ z(ek$3ZyTl}Fm7_EVx$$4-OB!yg*JXPv@Z+eOTQ6}Mrg{2Q`kU1Qb|NLFBKC6qY0>aWc&@%Qdf>Otsjq2JBvrWZ#h|uN>^(ixJp}a+?1;t=XUcn@B+9`@>=q~>W^Y%i&c zUDZzS#`_ZU%kSe0*%Tnaf=F?p^6Gs^e^;zf!AgwDfAW||*I-t@Uc>G-YQ4;=-95*j zk3J1QE>?(bWRsj>^naHV#H}(VhJbjG*ER+fYZi{}cwN&d4~_3-?^{rk4C}9!f7w1> z^gzT>#QZU05g7QboZWL!ile##|y4eRLVDzY!dkjbD_yqUqQf+`H)g= z!^3~{Z`CaccLo^xq<82{ADZ*&LCsk%cz$=<{z^)@qbT>yERZ+T5;?iOx**hu&}lS4 zBjU^x+MGLBKbXv=g_+ioXy-=vAc6r75(2$|F=77UWdrmZ} zZ8Q9Wn}|8LP)Pjo<16`SWjaR+w309PjBk5LfAj~d3cr`nV@4$7TaDOKH`PwXJ>%Q= zLoUnr_l$3O;pue9Wa|Ny;j*ODPO^BBtsjw)=fd&>Hk1wO16VezD9M$eK^*y0M^UMk z1ijbUCf=0U(Q-_}xNeMLM2qODdu+9RZepEbl*;+mKaOCLdYD1=p_2|hFUGD7vwh1+ zI-`Dqg5c!=7u;hz67izN$QpA|$~Pw(7$wJ~_)+0SrqNL_gI;8fdB;jJAClHj0z5B8(*+*wTQ?t|{{ot*Z?2#O=_F+wbi%b{~bhtQH=-{!G%+A$t(y zk)FQx*=}8#7$bl&D*FK%ZZMeK2_E;kdk5F)qNiBge8I${$IvP>7i|jB$>Q^gM(5Q> z7S)*RYto9jCYItOvy~A=NdC{$iZYMrim|3Q{dq=6F?DXsTr zyV1iAQ60Ou)r&@kiQneC=WFh^k3J7)M0FaOjRJmAMhx?EGP47v?ss z&O%uj&4b+THj7@iIa!a!(==1GTt&CABPrjSfETz-0qcZ2PARC-8A!^vD-{{1mKw=h z>e2J0L?gcjAfde@JDC*_|Q_UUP;v38UJn3(hvvvP+`veO8WB( zqQt8=$4Ip{H~0%t+GhT#X8+!lC+Yf=r^7Mg1wy0I0;1xfB}nD>r@tJDR_zCvqb`D3 zV+5WMowulSp4*<@HM1I(OASG~Si`D*S9ZC1s(4Ev%1S!+*x34Mh#IAI!>sFTcE()N zkufk!rJEn05xWA9#fccx5C%n@%kICwoY+lKglytvoW)!^^}UMaE^b_+Fa4cRTg@jzdiBf zhjhoiVJQzRSfmjzt^IgvOS<_>glGZ{_&B<1)gx^`CcLccM^4^0*jFYfRknn9{oSE9 z|M(Z)_ZwGjwnEl37mK&=B+i4C`1XyA+WJG{iMSc`p2cZ$X8b-i-|XneM*iC1zXyNa zvDBWL3T-0#UuaYq74yExdwQqQ9tBOxeOma~+DhBt_N`#UqFrSfr0o10HbCcAp?dv6 zy==^`(#MBZJ@)lJ!@|NG?c>FTsWcM2N1Rdz+gk>OVZlcf66@tU*eaM&4;aa1xMnnd zr=e(}=AfxyD&Z;N{XOWXqfDSBUS5@BVPp3)HpGtxg=a75^~IcKCka2-Zn`x0Gu_EV zQT;bY`c9hy3VCkcBrWoU+m|<{tErrZ!BVU9CpDJ6tF4ZTzA-}$k63I2df<=8PS@t` z-^OrVCa_R!s_p1u49X;iIA&kOqvGLFQT%3`G}ycS?!`9S|ADpI*O+=z~TZ;xcIeY`w3c>g;Hx6y&S-gj~Fi$@FmJaQyoXZM}D8(;Bq zX;Ix-bR>hMKj%4!xVvh^Sq-I2Ev}sTJn})DmSB=?5Z*Ulk&U76n;M``o(W?IPS>+l zfN*@62|r309MnJ+cL+c&y^)}E>mis^`Q`Uay4APx;&UeA6`mz~C1-~lpy^-w3^J~F zU@2;283Z~IQiQ6DUWCOfqC>C(v;wl}e+Ncd0e`^#D?r(hX_YpRv|@~gN^uMR;QWkp z7TDhIKs^A^YXPEKuhOSkmH8qKy1QRPMm4AVp;NmcJR;!bbv*1uh*egMaNAK0HhELlC7Rrc)Y_byhKJs=>u)S3Q6xJS`NwAzHw>OkkNBG*@QTV7$Ks=Xrj9 z{VJonBve!AY25B^7#&%u(rLG)TCCy>^Ws82YQ*-nE@|YFR$tI^g}KXrJP`fi-LRp@ zW|cy;!%SeODG4XO9a;0pU_uVTJ@8wh6ZZzfNSDZ}hIy4<6-z{${3Q=+)X~}D$G zIiheCcU8aT@k8IuupDA!eyU-X&I1$i<`@4&qnOtwKe0}S=<>8#vgE!< zc!E6H9|VeBIgcA`UEfq^_+$!pBg=hq?3RieG-f|mB}SC0ThR)K@`%8A6M{I`64Ppf zc$VVcOSaJ%Zt_z8h7F01J^g{_BR*NUV()=kKLQykj54;SEsJ*h6AX4p@OSu~Hx&~@ zfHmfs>|*7i%g!x13lQlznIq;8fUwaIuWNu|tX{z9`6bvT&%#;^tY15O0^ z0a2dQZ%78}b(AgbIdVo~L#NS>LnnRrj<#A|VQy|q5-11PY}_m0wq+qAZJj(t@_wt2 zaS~b^rkV3)b-p0i?G9>6jffplbbrctaXtKjpDsP&Bx7}!dJVK>c-N!#K_W@xt0?LU zMk}r#843rA21+W57}L^5-J1G?q+CB|ly#KuU99f%(B{Ri6rD%q#hVvN)wL~e*@B_H za|af(RMy^Z*}LsCp2Hfhj|R-IEHrP%hgKUmbdrOqR=0Upz=oZEvnPcwD`)gP^G(Ns_W&fbuirK+Dcqk z>muTtSBM0-i}98QR*817UQ+DJjU+llMgK5&5C~jt0o3@zA+$)QiSG#0`$TuI6(9nI zndQf|M)Xh7PJoOtR}imt2pG69RSIM}1rw$L=l}-d<_J1$QL9FC0!&fJxFSH|A%Vbu zwW~jXYx`dAO|d+X(AT8|Ot0d=0AvP5;=qC$ROWadV>V4>d(w)}KGc?WeN$q4$!f+j zEkZw#`p7tAn#zkIN}kg@<1uwGv{Xs~5xz&1g_AgK&TV4|pRSBRXvVhTTpv4k1NjNt z44Q)-$7KmuCkY$$%-8g7S2!)oC(;4OB;JuLw|Q+>%i^DzpD+^1x~%<`roB<(@(3ug zO2)-AzpI$1CWjG0G7udk8QP>fH~S?gpl9VdeFu&|xoaS|*i1E@n^j0>%=O_s% zyr@QKbr>ITS4jUoF!8jm>Rs+PH%{%!qKBKfuVUBKCv!g3$$epdNYJK+j!3WsjtG(8 z{;<;K^>E(?8q`!d3c!;@aciZmKwmNs#U%qYD_~O}N^PX-C+o_!>Zz26T{q zVZ(oZ9r3!op!ED1`0dSbJO{FZ=`tR0f7&wB3(ztn)<-tMYh&T{kq3*tp*%DxdFEGb z>{tf&A*~c)`f(~wEo6qjAB*yhfg5@jT~TS_kxEFmlS(Mg1;clLL?g3U0mE%9aIUC* zqzww$YWDlBh=q2520SuCCKVym?+XC0o%0NGJXqR&I;X_w0d6U7@xE&Gpzhh#`TGXp zdSkHZ?(Rjx1!mM%S<|=BLi4`G)9z%e{UQn(&b!CvvaGgWuB2JreDWpy1_8lm-22GF zfj*pbWa1_R<^{>O^wMt{5AW9T#`W9tj~0GP6ih4vV;FDxR3)iT#f8P z$~>*3mb`L+*ZzBRSXHy&4gomPFy9a|QB(tXcRR#+ZNAfNDo=EG%Xtl^`9iN<&wv>| zTw{MZEeA+4B*4K)gvH_n$i4!C2S(<#Ha&hO>St&_WAV1anYCI-{G??DeNJe)r7>VF z*>|$?*(+-UFEU|sas;puJqOXf=!b(swztu*0U~7e(hnPh#q3{^N1)UFi8`x+GF9e> z52`a?;{Cf5dOT5~rhaXFeR-4@TGxz>U8yuIXo_-pQ=C2}?p(fmego~7N%wwGE0bP5 zn!iF%wr#ch7cx>0DZR*+er`ao0beExg6u&nHIebLqnYVFfb4fBMQ;#d8Lq zb#4;e`g6(mmK<=^xs801a<+S#A8Yke(?>0RA z^c`TEUm(M>X#^p#m z_cZ`5STmE=Lze#|Cd*kyG=N+fD7(!D4F*EoWi)qy%uq^pK-`FFa?0OR;6(iX3x=VP zUclG-oJeko172pcXt+A?$Qh;h4#YPAjIQ8cfRGdrk-gI>m#Z2;yo!uHS?e3%@U77l zz{i^jbRa+u`U~?vfhf=0P~cHGZ?`oq6oPy$J$B9`{TMLr7+0`SLC4Z7`7C?K&rb8B z?~W->?U{j)*s!oc1OqY+dgqj3)dGh>{(8owy{Dn0(gjf_xLHhuYgP$#2lgoe+fPqH z%Q0{y`vECC;S0J%F@9$eyI*dWyDL44hykg-DtSfZA5^~pI1ADJM3#3A0NKIzwXS%& zSyjrY2yKe@9Aa))){Je&yT@($e^MH&GBr1{L!9IURS6x?Yv*!8HDOojTJrU@BOX zj~k$2CyvWOe0m7(T_pX|kmOu`chN(+r+AZF#+dm5*NQrG{5t4aOHd5gJ7@Ehw#_YR zT*-&u$O*33;wh#svN=o=57KnC%;)!9NsQ{|{>qK_0R1~RzOj$!sG@=LN@pq=JJWFF z(09iTuex)|ux1TU^sxmkPoW+ePB4TlsmW9QPBzc49Ghl=d~eeafy~DkmQF02AMLd> zP&JboHUxWCnAuI28O?pEn>pTj%ctpsE&7Bg=4OzMG*4_shra@^y0UYdNcrj)zRs5g ze`IUx`m!)vxG-zXYV{l(Pm0N%(fva?ll#(_za*qo!h*ln zwPc@$em);NTLT&>BPZBAzw(=WKj+5$-aAW3*g+PCsVW}_^C8*PbQDk^GkFOquLA~RR3tyW&-v>yxBh$WU^PY`-w)x_rcKEHW##F6sR zNFMe|RdYT^){VS|X1W3~Pk|w?j=6)#x-GVHymG!zkyHg6?eCBo*gealcmAFe|+RKSp9S8yG$79MfJ<>tJtu(=z2Usl|Ro#L|M^EgD zRaNs3x+pMcmOG_+%F903)x73Duz2w>G-N?Z_P1I}89m+a#}_8ee#x1ybB~3hJArIfoOHaY|HJ!FuNiVVWbcfZP&lVr%I;pHD&qN)Ey+DNlex^2 zW16ez(PPCXobxJOtu>u9Uu_ZTFhz!vyuig{Rz2R1DXPc0%iAom5;%V$5%KZNuyfBM z;QXT+)yjJ(zs9FSLM;EmGcVu#jc0zQ*DVr$N5_&PB|WOc-eoD#w=(4*tC(Z>NQP&+ zC`J!xAFh>AViW-5WHD=rAT7i&esZr=2E1@J?MX;}Q=P10tn|{cCH7BA72+c}OMUmb`dlZ4 zq9+h;180+BomPIk%9~gkQ8qIds^`8Ob2vA=?-*y+&_*8rLth@EDC^@)uBlJ1m&NzO z6nFU}Q_2?<;kA56E|z$w^5;)p)<1dPK;{!&`_m+DVw=KfoHFn?qby^(6Pwpt)U?q^ zB*lw!ft7swi572AQ~cZh7rq%X9;_+{UnX% zcm?+vgil-tzM2;0usg0>u|od?VRDtMO>8{+=4&P8c|*f%j})%@@@%xVg8L`+BkBS2 zLYUm!i01t2z41BEHp}Ck6DB_wJ^otzbcud7wuDQB#fYy& zemn5TGc4v$57GXCF_C*46DV%3kCpaF?9;z+%y4dRB#g;YWyrlsmF~<>TZmr@=@x6>!)}HhLle!IkmY9vRn~3js#MN^g8wp1H((1Y|PC!8Wl%H z)5orx%wC)vM5u#frO%L?+{#27D*KNy82`a9{|69>mHC|YJtOqNIUWoXjzlcK>wXXw z(_4M5_!rpefq8@XPYe5BNa(-rbdO}-!@t%iIIX=>#nK1zKzN6#VZlBQV2ORVl;L(HTehp$yHzY`0iR9}<)rz>;b3(~ z!HkO*;d)(cA4Mb#rjTpdVvm1mOd8ap97N$;zmU*>-3htj{R^iZ$-yslQt|l<(p)m%qosUS z%{>P#kfeFZ*O6YtFVT28$NbQwj@vAPNKNXMcz)+H*%7sx8f7TvCNoH`FUYcx*ft02 z-LNsH!sAm&3pZoc)b+oJvgP~V#r0?+PIHTe zHqqP+u(e7dR~t3g8IQ$m-C?a>vB#5DX+4Zy2X-2DDHmQ3hj6ck{YBS#Nc_LG^Jn7q z3**K}zByE`4KOjhE)vyS{YYjSpC6bVQoQ<-Yt(9uvi@AEvC_lmSKyTV1*9UGEn#mQ zFanp$7P>Oq*6=|OxLEaG>nakpye95Wi=H4nu_Ae?>fkuM`;q1tDO)u~dN3F|xT!+7 zKFvcpaT@mjk@nUxajoy)Zd)9Rw@}>Oy|}x(ySuv;hvM!|ad+1ug;LzTxVy`|Gj#9o z-amPBa?W8T{bOZjk&sE}xj%BHe;hVZd70mX0KbNG=-DfP>1 z3h&QJ5|k)Lkd;-N&CmeT(QFM`SR2z6N9JXlVmBv_?OmmcPu3$S0%gV|q`s&6Ja88d zw_@2i+9PlSRj6pw^S6V*(?^lwK&p>3a?knQV<19PJLAZO+dSkzBkD3@Kv=hI|Pot8{*6xTp0okR)!UClwlQVg)8G zro}&m9i3plttn7js^})~?zFySfa00J#a^uJ2MILW|Sa{mV7npFu?CJSdo$q^rSlRa3!!G5;69XsueXSF9w$p8AOz0uGiH(yaC!czFp1?VORzHY;g(#%VSjq|c zV2&E0xPV4JTNp0XLJvU zEa$;Q8Cfpz_^{Ije@bbzLtOl9vj_eB zdM+A%n0|lEy_QQs5KfGN*R|U+$6Zv8B7O227f*^ssl->`yaVp9T0TLkP9K5F|oPO{QlsV zhu|l^(0#f#$N8B>;n)O*D+gGbv8;=(Q%$zn^If2PTOAQ{@4~wlv2V-#Avn^M5&6gL zuyC%NmG|;uZFY*Z`5GT&GH`ztS7<`vLY17SjSEW~VTEIJC3F&=GNWnGB=KDlMjH^1RT_yr;?6-eaY~ z$_WB9`}M+1^!~MZp`)Q6>2aTsCEH*F&y`bFN0GX4ts5h#g#GYC6dC6<4vv|4UbuDhmX*29Th-ziDY$5`qkEO1~- zK$X~y5J1)%r4Rir)HiV!;qWJfW;b0aiksrTCmqo@->_!epKtl)PC1#5Ibp#{CMG_l z+ZX)|Gm(&&CP(`1?(ucEBodDw3EH18iV~Xm!;5nJwc-v&K3V$=Ue!*Zz2^pwZK_`q ztlA@L^wo^HobW^>vH0&z$svo&sPfhTKSA{8tgpi(oxg>LB`z%}%1&SO#Vr#HYb}x9 zal`msKlOQ@i?#TD6XmJnM<^njc5$@Ur?kPcUAo9_+vNt;J7&H+nYgT#I2zYrD7L=a zo=QN~%qyW;in8! zHPtIcZOn;mJJ9acX-sKPOsLGGk3h`qe0kepGfPabL>)#&?XITyjV;WTvwnuq5)aPo z*YIwDC+!a&y;aMO(+1}GYo-;et6sHRNJ87%*A-ThJ2^l!=J4_saihkwZGUwmcVHDw zINvOKoY-(R*Z3oA?s(CU%iB1+{)^85w0P>t-d|`-`vm9vcU_GLWyuNTmRv^{N;Fy9 zi+PeG?hoW|V=%TgckYWuoZeIDOn%qCP`_r_4sigmp*u8jEjI>99O7mvqRe*_7o}%) z{p>$f{1Hh!rPDN*bY7X`%K&_=W(V0uSp;~zO@NIdLQ5MY9z~T4= zYkj0B(0kQoUU%4h3rtMZ%zraDSJsIg`_>^Z5w79|8G#mYqlQrd%v@^PKdG)qA zmxp~P5+13T*nc=ww9=;zoN>Yk4wxnl6clEpN*f=CkM6mYjds3tPEH-tInMte-RNWK zkSH6yxwdAOHmLBmbF{QAaB|di6`^*@Ijhc2H~mJ7)~dM|F{pq8ax%Xty(!V->~R4) znIISQ6U`@@KR4ef;IN2of1iwt$}QoS#(ih;roI@}V8)UcS+u*j!1Jd3PD$BtRm+QFlA2mitvgA{wB(fYBylU_KKsM={aM0n@ng&l?fqD-%9!}- z{%$r|6X9%W_Jx59nQUKt_Ka)cI8@Mu88Tw?#~0H=r*p`(^0CrJn;MfIHzQ+1xLyM= zfXGx3vQqUfgX`+L=&PeJEjQmjZ~0xtkqv7h>|K^aI$n|ijWn&OLt{GnmhBQCuowNH zZ|dw6$g%=x^6~}%%~5tv$roSxPYD2Ddd&C902v=%?5u2fz>P1Y?^2KKhq}-%ct~w8 zqw~$Et=%p%fULV?D?S;3E7cO`Zl80UO0qfXxYZ5E+K|gAYpQmGMaFHnJ>L z#|zovbv}>Td5`IEJhgKj;G*@uHGb*0@5>S7d*7lf*$y$r=L1m6c9s1(dN3dUffp-+ z9{?o-AbwA`Eij}>QTTKIqa1J`#oVYAnfFi3`1*m9Etax6>75|5gR)3-)TUJ>V9W7d zmDjhB+thdTv5MI~#3WjK6lP5Fo96q{P~ApP<%=!!o-UYN>zdf}@l*99%Xn9E;8*)z z|FdH)RA;3|Q8F!(cV6QFS>8g2#Z>jc zb=2JyI4i1pznL~W-Bc36Sv1KV8h~~(y298bwy9w|PX8!vu;^U=;lZa-v`7J;-kfC( zfTkQxYO;Vco>)Q9g_sqBj~k|Q9*o8n5HPn@UI36DFa*-EDgoc-3f~qfnIS#vU<$EC zM@e(sVZjn`$%~s@wVAwq9Qnj)E9o&}az}(dg#(c{YyM8NGcUEVX@ZlEWMn&ORkyM{5AxUH#F?-l2Ji*)9f{$qoL^<^a9rm111tPGRmkfN zPrl69hyy^>z=WRvT@|on&OMs7$3o)M{n>gUEM2N7g6Y{}J*~GWInfuMBvXN+#22T3 z%d_c9fAwdx+RMWx+;yJT8|AYxr^5FA&+~RgR(Fd2)CNnKY`(UW8~3TT3(FPVlKbhk zWU}Ex<8$6aYlkw`=}+sliYX^0<MEpc);E8$=@+}K99Pw4L3XL zpOLPPM=q-Wz6{CtRAJ2w`7e@QY(rA&XDy}__8f^&tWYd1s}y#iy!V$o5A0O*{VX}X z|7aeoWZd;{MK3{9frC;aA^BgG1Z9Oskf4XN1&^_W@LiBcyoO%IDzkRsl=vyK(}Wvx z`7XZ0A{<(=wSwCMEc(9YVf2WhO-?Y zHCLR8w*QNQmn4A$Qt-}AWhsqIP>qc1hL*^owQ9Y1a)?$LgPm%6Pd`FF#YZ1w_qP zzLOACJonJ#awa)R;MM5b&~YPcA9QIdDucIfR%fMkFA|0ZiV*%j(J?+Nt&FoW$kk-S zB??UnT;qlY;EKKeVv~<81s1gLy39Mbq=K`=i(-FS>nau$DfYf`^#ogvv@gVfsWj)) zp;BnKN@gmEgX!AtqrBiMRk9rZEW+AiVf7On017S4TIV*TplHe=@2fzKSL(Yz`N>35 z+pe%Hd1*94hKTRyK2St*8a2fTQq-RbjhBxKZxDHYk(||)Q`?+Hl5jT>r8Zs=gSuOTHPjAf9ew+Q#DD9Es0F&L4&>4fj;a*1NI0;Vn7JV_0waV;LLlCAAOKT$Dh~-N2 z!!)Y_X_k&7C;AD3xBi2~T_M~8Gk7sFul3Aihf}z=fuBUQ5$6QD`!35+)g`t}_-25}oehPXoAGeBeA3%={0CPpq}Ey-um2|@3`kGQ zZ&K-H;M&x~w|KTtm&8=|lr{wIj(altZmo<@=L)+qnj)uPiMt+hz0l-{`(el72D^YW zjeNPmc;z=}k^77C*O;HIQhlR_m2ZO-GFJ=BzN3{H$X=!Ew-fL%c~tApTa6DU%dz$s z$iSsf^+Bkod$m2*qz&JDaKYUV-5sUi!e$AL{lG0Y>|ARov8K|GfBL(f)tG?Y}?ipJ@Mk>c6A? z#&8sxVhaMpvyV~>UybLCyF*L2+0KiJ1Im1-vV&ZH_4o2L@@6`>jhgD$Rdls<)Y=8O zO{14?h9A&h-0UpsD_OBqgxD|tFbWs-_RdpaTw{TJq=gY!BjvYkv#}f$%&r0IUH>)E z|Cfxl_y(Bxhj-ZK1J!%z9Jbftpj-p!usiMkit+#5qKWDHOB(y1)d1j=pkBitKrL)f zC3u>xpp)PlzQp#F{yGvad-3D3+cXY*ak9Rw@~2E~PitgYx@FMDIFc+7^Wqfd_C3kB z@bCT(v3R*g0<}t^%nAXuyOr+>-?(=)9Rin1pvp$_mtsW)*oRxBG!S63oa`$IwGdZY>sn8OCtCu#hc} zK5)bC20rd~r=KT<@(JsI8i&|U1OI_jag(pfUq$0JgH>kN8dfzt)icpIV@IB7#&jO8 z<&m|b-p(`2f0+BAt3DbhdA;vj*)^Xt%^u6Vl3W&!0^2VG3u z*tqM&5qaC{qHYOuv-5q&fjH{?FbNL0z}?fbyYlsWsRNFLrPc{{AWIKM0J6 zq2^OG{JDadQHLL85Q|Ow@e|ArT+Q`v65FgKUlTQZzV%A=?^?{APT29VcyO{$39uT@ zOBRlc?}_I>KN>Y~H<2sUWr90Zcg@{7bzWZY4m8Ueziy4Yt=8ABt2IU@QB3%rAmsN; zT^+Tvz2Ivw>_J|Cn?O;oLrETn%U;mh=QM4ljEfIU*a=3mN{wh}H8;*!+~889r#G)_ z<0LCcJVTj}by9UvUH2PjbuaN0!nvQO4!bjE`bkmliQkP#|3qksN+m*0Oq`gD_Z zI-U?C92XJ2cDa0*rKl1{>MH3{&Hrvh3`RAk-2Tb;oYKY>Mm1aE+r;e|OVsNo_d#01 ztg2o^__4LN^Ct=#;?x!qqa^)kk>~|&ESH*2KpZDxt| z?&rrfT^IbHS=p&oH|ayz41rd9`KR4qUNe!uAa?rG^?=Zs*C7H}cq6 zFUbBZthdDv7{NGH#TAlJGeoZhp}GP5iN4C^}t~%oz2`HC_cQv?FTSLR4zO zd6USq0Z}j!#%`}s?>Fd2Lx@Awf(d6zTNR*mXA5eELu;&&e8R_2?jj_(u8T>O&I|=0Nvh94qFTTx8y}=0r?T-68+N`+qx;yRz z^f?!I|K;>RG(I#qKfdTka1>Yo<>~{)WyA#P>Or)sV{dcESyal4@jSuP8aN@e0UF0rV=+-=}pq?XYs*i2b34bb2 z`m;Sw^hT6PxM!l6&A6TgzEgrOwWM-w>4%*|q|M#$PoV8_vXmK$;!Qo=$wqkerwyyO z8bGi~stv1$ez_-DESox4Rq3`afukM9zy;e=dpIyTErE(tf+*Y91pwkTLR{ZN2J~zl zQQrf4^yD4@=UZM}!@kKcKA$<#BS7*1a6z1yIU#`clhb)@e>7)YHX!S%tKb(8`@QL8 z(Rw!EPSgvIr=aVpEvC-50PxghKK4r?o+#Svz?Ukx;e^1;SslEwGe$}wc;9ceNj&JY zbXFG#J-K^dpJEh?=F+4`h8QM02WW8Ch%69N-`qegbq%F1p=oW*D2hhG#~^Pq&Wv_S zjiP{%Omzd*2V{Qyt3L4iulm5kU-bd>|I`N_gGcH>^#R?g;O@AtPl(Lhx#u&?Gdy9R z$71TPd5N#RN_O9{*!#g?% zUL3w3t7_s+B3e4$=REW`epyx$r~J{roT8SB;O(BDCxGA>K)0+&!o6N>xZ-`dS4As{D$k3`-N>&z-h&<_Ck)|a`TzVg z;ksn3ln>7aPW+#%z!S^nKdJ^3FDhzHL&wT`3VR(+?m#2>^5wK8-~z1!iZpj-&EXhyJ>v(snw-*f_*I7;y7LL zvO8=KEtYQ{PDI_&#vEa2fWpiH4!qO|W#5);%1xdnZD2?IUNm7zTt>`(+UW~zHp(E7 z;ZOtSH!59(KT=!Zz1r1Ll!wca&GWz+Q>~+T`R@plw75B4TkLBDDX}~wMx8!Lgat27 zbxJ-wFXw8+m-gEL6M#V3PA%g zu{lK00NhYOyYWwu6nIC^?GWliHPl^@fVFU=eP&q`|N#8Aw^%y&#&1brZVPAJYUKd?v6aYYgs%LBk`AYpUPUi*b+bes!8xubeNB> z!7sJ!QuzCr^Q22b327{sJCWNHM|QH;kHsQp_mE)A;rzid2j!(oA$oc!(K$4dvRulUg4r3feRXvh)!1yr+kj0%77@k4VpGK#2Y7Nz;SX>P7y@GO)XF!kO}dVH9* z)s5ha`)9TKOPJ0x>SAC5=i{f~1mB(X@_xq3v;OQ!5m`a94YjOudZbAE23pBF3p)Ir zQ0FHyDG}!591~N4pcW51UaMc7nrAum$*x;D@lj6ux`{DHzvcB@#-0zIoK8D|nV}KU zlC*~c9lNHM&HNxa!~|K|;)5P#Aobw5BtBlS5=#80M!QMeBdvs+&;0wM*O@{CWexSQ zWc}%H4MNJ469{M3j+Yoqyalj~Wu_rL)wu-TW@^%y8j&JsEc`QAKUL^UQ~SwD~L7UZYAW%Eg`znk)}m;LjZ3jg~(|I1@k|MM3*ANErf zlJ0*0bSZ&c5+p_=WI>>C$mivWQv@O(WXR`P-b-^)^4ne8J}B<*Xer&)JnxscS@R^0 zHojPVwgU+y@F{nB9ue3&+HMhFX~>QlqC8Wr&Wpz!f!lgI+2`|#VZ715tlCJ=BD^Mr zY8*ln1~^jd;XWlBnkq+AIQde{a#?K6oSXu0y_z=;B#%OkRAqvp>2dYaarC$^6z+- zUQDz!%%PQ>_3FO0^pZ0`uz)4@BJ3(sdQJtGf zD@ixT>iJLbL^}aMfkc7F{#78^coj&rhmGlEQ=Ya9sZtzzT-A3Amc33Vs_(i=94B$E zdiJC{RX1Ib1CDBX66|;nZ}I(6U$O%xB9eu`IpX4a-Zv9ax8%>*I~7e?hNxZ7QnuUt zR)7k|X>Y>>lOS1?`c1gYzm9-r8+ASI(fyP)L(M*i2XM6CboeF{=5M9@d!d0?rl~aQ zM38|f!P8TL^NbA+@@w;AR-YR-C@IAH=EgsU>|tfTz<)+zU>sR*tpV)fa3nlbV&F*Ui(c|3H2jOf7x#kwm{PN*%AtNk}9CnRq_ zg08r(+e{jKz(8{QQ&3gUdAufCblf5so3=un6Y|{AQ*@bG#mhD*wHPE03T_|X`udD-Qoc~oCsre@)L?3UGy8Sk> zo&?&Q*%64v#nPI>V8O}YduH7JV3MPN*4_dn9#|C+%<~My#C$Ty1riU`IYG+Eps1g} z(Qi%;FzX>JrgtAuz{rA0zgmyNLqV>%!Ca=gsE?*N^zO0Ab+B_YGXsGKZxM!xj*VC* z4N6-ZBd^!JIe5~LWk``orK)FDYzaSdIR!f{XPfrRE0-zqRO;;xJ2`cYs#l~7n~vW} z`1mqr#w@*im!z#>C)Y#|3DM5evfu*LMcl`P&d%7gjtPm3LO{F&l21!qq9fhXMFE`I z4|n8wxtbDaon)Sa_PFxl*?)?d z`NJ%NvhOhyUvaEryD1fW=fr0}1Aw>#*S5gJzi+a~DG_qU z^U)#dHf~Jr9R%TdO!R5!z$ecpt)m(_Gfg8W?E1^O+TyAl`9<)PsxF%w{GVuWm1{gghdSr2HP-|fp&mwtnA2R%d#6j*oLBPLKqBBd+D_K0Ss@OFmL z_BWW5i5sIbZ@O!FshtMzNvD_nKO{vKQ6m5NH7u-P*2->UxZrr8u-L>E0E7pq{vx#I zTkucl7Nod{$UhdynX#GK@oe`=lno>iNBOCvD-l|ykw6vQRD)V6`m{hfR%28YPwxU~jrH#A}q`}5$5zF6n+*TLJ#{n=vl zwSFsNiUa~H#M-+S(JzV}Mk!VyqMdH`P97%vmA=->d?`z6qc%T@N4pvh&^6hye|*Lx zB~m3k8f(z(dM7eh(yl(H*40@;@9#=zCYWvX#YpM-Yakgz^^`$bdDOp@R+i1n?Pz)vZ`VCLK>Ojbt_g3o>v_Xh=CrkxpU(^TLsvmCH~Rz0{@b$=S>FC#-?*0x(OgpAZ-A$@dOVYFRVZ%E zl4=X;u?jT7VSCtowmd631Z~|ILzx|QW1Do-`Lv$Eu#qh6F!-{=~A;*{J z%ljhqb{{c=#R&nOPe6pd{sSE`RkPXo9NI90My+eSl?%j%Ft8hArVKpR^!6XjB}XF( zfx0pHEwSj)VNsc+92_JcZPK~bbHs)0-a0W}TXv{F6dwK9Bt*WN%O~@$;_b1y4B#TU zM^LK3gWpHnzcJr)fok~98&H4^x0DxwQ^#oONODUUKUWpT3;h-rT5jHR#=i9Sc{tHJ z6#X%gz&1{Ctq;D8PCyq@9}UuXg))EQ)fFETz5Q;3E|x{^dS;Nn?2hYTT4h)5XhWXH zE|y4cBe%TXFTw;hicKt=!1>opFid1BAcP2sObCCQ7=_$%)`l<+DM$~;r5$b1zhg*` zWfm@3Lw}p?Iv%|AH7`8GOUb~b0x3R;`vELTrP=VK??ENAQ0m}OQkrv&vEM)f{}kAK zLsSyHxdvjz&*5TXCR|rLCABWIpnlo)0lEP2t}c!v>w$JhNKIurhTZxKxovHw#aP&Z ztC}XA;x@XPDyIVloaA8zI>9m6p@=8~HOR)V$qHsnj6&)b65aVUkIu3nBU=Xe|LelA zo>Y*_hwCuBXB;uLs*-z3I%O-?{C+1V-QbY6)9MeU?$MnUM%(qwqaV^fR!8V+>kbFL zRr{)HVantDZH>aW-lq?Y9i>OGZE0#In01*NJsBH=Al3(TA;$PLAUg<=0ZS;zGC>pU zcHTB&DUp53;pOWoFd;`D8i@OnWxalclCU`Omke`L;EGx zc%-(r7QyGJTpWw!7(-KWfS~rrCJ~s02>U){kM-VrAtMc(?pu{-*58QJ*QCw|xR?)) z#dr0U!3d26_4ZQcD+EnJ)QR+jNab&>t$mCTPvS<=Kg>4Q!2#PRY!-=uA3&$uKL_|5 zTHlXm5Ad^5Aex>b4hv3xJ;87ScX8#sIGW0$S8LaF;qb4Y;+Lf=UX+$)%U|@enXh8i zn&*QSWndbytmuffK6zl(Jp88gE1@iZ7d`#{qVPOjmPPul#dp~yB@xOsgKVuF2lXT; zvIjz2c>aQ7bp&v>Gc#ngK!%$C%dl9H`AdjT&48_3w7Fi^)I`?1E^abdh-;Mj@o_Cb z=74WP=;C`5Stfo}r<1{Vas4+>t@Txaz1F9)Qy6^+p*YXsJd%X(Y4?|oW!?P3mRQirQdNNNC$C25B2zr-sbn3~$ zq^IL{((YGx?kg{?pixov5_TdYYLOYozP=FV9Yeg>l_=b>V9j`95qqjRqndLWrm=ID z1?+F@NLSN4qq47~&}))yf<*49ywkP{K|-xt!cHEvY8p!4%}k3(Z{XQ~A^h863z zqEBl}4_Z}v8zB-$J~PhGuxumJhQk<@Pf+wi{?7^WbsB_hrA2P`>!y0uMs&e-^z}Zr zU_q)dRDb;EZkMgMWV1RZ7&;$t=s}VZQfwZPGXg*qV z#9^ASr{RMdErF!wVLTnCrH^KY&#!VS5&z#2qOlV*HF-zz@ve@u|C)1dYEQiK{7a6o(}1YIf!h8sgG z^k`8vc7F|xe+w?aQSyHuME?wxuM_0!ha)}Vb%KQU7TjB+3hcB;_Sc+<5{>-AA9J5o z>g8l}+#pQZex}-uiIfk8j@MR*k`j3uY^;cd+|BYAw8TlQfKwa&C>*(uo8JejFq zY!5P=puFilfQ^%8?eF-WR`evxV-MV+>w$n>J?2dkWWT5aBYH3!Xvq^ZXY7^zt3?8p zjt1DO^yX^|(}=hpA`#!ny$ScnAKPW5U4;X z8YTV)ACR6`a4n-Ga^m-KMTi6sNYB&Zk$}+Pj4E#J6|Q?K^2saNUW6XhF_-1U5v_Hd zJAXNldA8+DLeik4Vz~E|y-bZAoB*Td5*%R~rb9uS{(Lhi486O_usu!f4T9&<5+GK6 z2Ey}vm6k91Kir{LhUI!l{ouZg8NVN}lCeoIh1#6nOl(`INT)OVa9=XoqqQd~QlHyX zB?XR1M3f*5gL}wXghm_2$Q=EirNu0ZAooi1tMTZ;;f0p=bT-pH8bx!?L(lINqMn3{ zA7scJBZqq-!{1NuqIGSm%6%^v#-WA8f=J%#vBM@l?Gr&4F!Du(N zzy7O;hv%!=UQ%4p>5o2b8Rsj zqrD`t@WY8iqh;A|T1|&28I0-;pfriAhZD}6<5+$H=ydv5Xg3}dOPL>VI|t$~>mT+W zmW6-rmW8SrZk^5uz0@OrmGWX@9I*kq>n(!Fjeoi8H0XbNCOA;XPYu)uRA!)_$zfGG zH*{UUfAE93-_Q5B0I3uH-4|g3&zi5^ostn(%{+H%lahYU>3jRkWdJpvpW`1yK8(Ag?RYVE|huv46KrbTxr36IPWvEGF?w+lq&N6$D_* zB;?0XBJ`u<@od!>kheYy^42LMfZqDzI2p#Px1Re`pcur=+X}06{8WH)UNyO>pBbbF z_YzL|#+K6iRH=z68@)@-*g-{x|@=3&8z{l1W&)L4#H zbgj1AGgwv(;5enyEhuNMO0Io5sX|aIt4-7vBp8oJn*WZs8V0qFmV_HLz3uRs=iS_> z^S6>a>vqeLuG8$>C)BXVZ;|^_%QcFp!v?6O(m}~%Z^&qKN~o5A9TP85$7F`+dSd>u z`tObjl-esX&$l2D>8Kj@VTlm`qOQbr`>np+b<4_vY^WbZ&6-O|pkKkoQxKGdd$eiw zDtI)0xk)lmbvB(ir`9-(Dlh>H`2}j2oR|@vwgVd`6()ZgCVQZUNs+v*U_Fa8$XBnZ zTpQv4d~d?G>_*C(0rr zMt4TpOgb`ih1E4Gk}Gmqed%GF3O&d{(aU8`M`m8m2c3@nAR*rM32I!ql)bcr$cJgP zpmLNhBwOU|*m(EvlUWx(68#qN=_)3RF_mvq%RMBN-1-N5XOG;E-CZG)Yhx?u#OxcD zF1(%1NUUik9R`-rcc9krCsD5&$!+J_2WoICnqB*|pxlU~G>ow8xa2CH3#nCQR2F z>5*WB!S;p&iSQGpS7$SU;WJfqcm$!ZG276rEl6x_3+Czy3v5>2{4TCe@Fp8Z$mi5UR|Kv2S zR%WSkkXeGom=|sUFVU02^E@I3Cf57{uhSO!ul!tZqY?yIPV4G{4r3B}ia;Hq(Rtb7#@>6r zMLVxbuLFrL#TT>ZU?>M-|B%1q(zLYfi~_Gm&1YQvXA7dOP#FmeF`^9Ee8+VlZaB>I zGqDIgX@W59Ng?c&QgyYtbWB*BpLCJ@K;5j9N=%tzFW!c5QeZD?zi( zl*v{>8#r}JeIa}`u*JovA>3t>bP(OZ6djnBwwtO0$@CZ+m#m_lRojYT}DCQLNbJb;c^_ z|M4Lqa7!?@B7NDiEz;gihh{gHL@0Ik^R)17*HZ7xTnVpY`isfeVME)jPY_Z=8yYYu zImuv1tt&tUB1c=W3Jp85pjs4vPtjuZfWK>w_TzH zGk$qtTVb}c9Q-=o``<(3d*9qSo9hB5obOfFPrBFL`begeqDtk^ZFkn@@usQjKoYyX@q`f@|1E@^OKkaD28+ zq?RGCDeixq$$z_yi#bggHLO7Y@jXx8-Rbwt$e??yl5G&71jMw?rCkh@Ky874aKOC+4Y)u+gJ9 w45bBD?t& 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